diff --git a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts index 6df941d57..f0a1f669c 100644 --- a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts @@ -12,11 +12,12 @@ type UseWorkspaceTabLayoutInput = { actionsReservedWidth: number; rowPaddingHorizontal: number; tabGap: number; + minTabWidth: number; maxTabWidth: number; - iconOnlyTabWidth: number; - tabBaseWidthWithClose: number; - minLabelChars: number; - charWidth: number; + tabIconWidth: number; + tabHorizontalPadding: number; + estimatedCharWidth: number; + closeButtonWidth: number; }; }; diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 848c8d07c..f22f01747 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -80,11 +80,12 @@ export function WorkspaceDesktopTabsRow({ actionsReservedWidth: 120, rowPaddingHorizontal: theme.spacing[2], tabGap: theme.spacing[1], + minTabWidth: 60, maxTabWidth: 260, - iconOnlyTabWidth: 40, - tabBaseWidthWithClose: 84, - minLabelChars: 4, - charWidth: 7, + tabIconWidth: 14, + tabHorizontalPadding: theme.spacing[3], + estimatedCharWidth: 7, + closeButtonWidth: 22, }), [theme.spacing] ); @@ -98,7 +99,6 @@ export function WorkspaceDesktopTabsRow({ { + renderItem={({ item: tab, dragHandleProps }) => { const isActive = tab.key === activeTabKey; const tabAgent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null; const isCloseHovered = hoveredCloseTabKey === tab.key; @@ -125,7 +125,6 @@ export function WorkspaceDesktopTabsRow({ const isClosingTab = isClosingAgent || isClosingTerminal; const shouldShowCloseButton = layout.showCloseButtons; const iconColor = isActive ? theme.colors.foreground : theme.colors.foregroundMuted; - const tabWidth = layout.tabWidths[index] ?? layoutMetrics.maxTabWidth; const tabAgentStatusBucket = tabAgent ? deriveSidebarStateBucket({ status: tabAgent.status, @@ -181,7 +180,6 @@ export function WorkspaceDesktopTabsRow({ enabledOnMobile={false} style={({ hovered, pressed }) => [ styles.tab, - { width: tabWidth, minWidth: tabWidth, maxWidth: tabWidth }, layout.mode === "icon" && styles.tabIconOnly, isActive && styles.tabActive, (hovered || pressed || isCloseHovered) && styles.tabHovered, @@ -200,9 +198,7 @@ export function WorkspaceDesktopTabsRow({ { - dragHandleProps?.setActivatorNodeRef?.(node); - }} + ref={dragHandleProps?.setActivatorNodeRef} style={styles.tabHandle} > {icon} @@ -386,6 +382,8 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[1], }, tab: { + minWidth: 60, + maxWidth: 260, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], borderRadius: theme.borderRadius.md, @@ -394,6 +392,9 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[1], }, tabIconOnly: { + minWidth: 40, + width: 40, + maxWidth: 40, justifyContent: "center", }, tabHandle: { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 41420d2ec..eb9f3baf4 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -575,6 +575,9 @@ function WorkspaceScreenContent({ if (normalized.startsWith("agent_")) { const agentId = normalized.slice("agent_".length).trim(); if (agentId) { + if (areWorkspaceAgentsHydrated && !agentsById.has(agentId)) { + return; + } const tabId = openOrFocusTab({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, @@ -593,6 +596,9 @@ function WorkspaceScreenContent({ if (normalized.startsWith("terminal_")) { const terminalId = normalized.slice("terminal_".length).trim(); if (terminalId) { + if (areWorkspaceTerminalsHydrated && !terminalIds.has(terminalId)) { + return; + } const tabId = openOrFocusTab({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, @@ -624,6 +630,9 @@ function WorkspaceScreenContent({ } }, [ focusTab, + agentsById, + areWorkspaceAgentsHydrated, + areWorkspaceTerminalsHydrated, openDraftTab, openOrFocusTab, openTabs, @@ -631,6 +640,7 @@ function WorkspaceScreenContent({ normalizedServerId, normalizedWorkspaceId, routeTabId, + terminalIds, ]); const activeTabId = useMemo(() => { diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts index 208b740c5..f414de057 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts @@ -6,11 +6,12 @@ const metrics = { actionsReservedWidth: 120, rowPaddingHorizontal: 8, tabGap: 4, + minTabWidth: 60, maxTabWidth: 260, - iconOnlyTabWidth: 40, - tabBaseWidthWithClose: 84, - minLabelChars: 4, - charWidth: 7, + tabIconWidth: 14, + tabHorizontalPadding: 12, + estimatedCharWidth: 7, + closeButtonWidth: 22, }; describe("computeWorkspaceTabLayout", () => { @@ -22,23 +23,20 @@ describe("computeWorkspaceTabLayout", () => { }); expect(result.mode).toBe("full"); - expect(result.shouldScroll).toBe(false); expect(result.showLabels).toBe(true); expect(result.showCloseButtons).toBe(true); }); - it("shrinks proportionally in compact mode before icon-only", () => { + it("uses compact mode before icon-only", () => { const result = computeWorkspaceTabLayout({ - viewportWidth: 500, + viewportWidth: 570, tabLabelLengths: [24, 12, 8], metrics, }); expect(result.mode).toBe("compact"); - expect(result.shouldScroll).toBe(false); expect(result.showLabels).toBe(true); - expect(result.tabWidths[0]).toBeGreaterThan(result.tabWidths[1]); - expect(result.tabWidths[1]).toBeGreaterThan(result.tabWidths[2]); + expect(result.showCloseButtons).toBe(false); }); it("falls back to icon mode when compact labels still cannot fit", () => { @@ -51,7 +49,6 @@ describe("computeWorkspaceTabLayout", () => { expect(result.mode).toBe("icon"); expect(result.showLabels).toBe(false); expect(result.showCloseButtons).toBe(false); - expect(result.shouldScroll).toBe(true); }); it("keeps icon mode without scroll when icons can fit", () => { @@ -62,6 +59,5 @@ describe("computeWorkspaceTabLayout", () => { }); expect(result.mode).toBe("icon"); - expect(result.shouldScroll).toBe(false); }); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.ts b/packages/app/src/screens/workspace/workspace-tab-layout.ts index 400ee0cdc..1d0bd9fa2 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.ts @@ -8,18 +8,17 @@ export type WorkspaceTabLayoutInput = { actionsReservedWidth: number; rowPaddingHorizontal: number; tabGap: number; + minTabWidth: number; maxTabWidth: number; - iconOnlyTabWidth: number; - tabBaseWidthWithClose: number; - minLabelChars: number; - charWidth: number; + tabIconWidth: number; + tabHorizontalPadding: number; + estimatedCharWidth: number; + closeButtonWidth: number; }; }; export type WorkspaceTabLayoutResult = { mode: WorkspaceTabLayoutMode; - tabWidths: number[]; - shouldScroll: boolean; showLabels: boolean; showCloseButtons: boolean; }; @@ -42,18 +41,14 @@ function sum(values: number[]): number { return total; } -function computeTotalRowWidth(input: { - tabWidths: number[]; - tabGap: number; - rowPaddingHorizontal: number; -}): number { - if (input.tabWidths.length === 0) { +function computeTotalRowWidth(input: { itemWidths: number[]; tabGap: number; rowPaddingHorizontal: number }): number { + if (input.itemWidths.length === 0) { return 0; } return ( input.rowPaddingHorizontal * 2 + - sum(input.tabWidths) + - Math.max(input.tabWidths.length - 1, 0) * input.tabGap + sum(input.itemWidths) + + Math.max(input.itemWidths.length - 1, 0) * input.tabGap ); } @@ -64,8 +59,6 @@ export function computeWorkspaceTabLayout( if (tabCount === 0) { return { mode: "full", - tabWidths: [], - shouldScroll: false, showLabels: true, showCloseButtons: true, }; @@ -75,68 +68,36 @@ export function computeWorkspaceTabLayout( 0, input.viewportWidth - input.metrics.rowHorizontalInset * 2 - input.metrics.actionsReservedWidth ); - const minLabelWidth = - input.metrics.tabBaseWidthWithClose + input.metrics.charWidth * input.metrics.minLabelChars; + const baseTabWidth = input.metrics.tabIconWidth + input.metrics.tabHorizontalPadding * 2; + const estimateLabelWidth = (labelLength: number) => labelLength * input.metrics.estimatedCharWidth; - const preferredTabWidths = input.tabLabelLengths.map((rawLength) => { + const fullTabWidths = input.tabLabelLengths.map((rawLength) => { const labelLength = Math.max(rawLength, 1); - const preferred = input.metrics.tabBaseWidthWithClose + labelLength * input.metrics.charWidth; - return clamp(preferred, minLabelWidth, input.metrics.maxTabWidth); + const estimatedWidth = baseTabWidth + estimateLabelWidth(labelLength) + input.metrics.closeButtonWidth; + return clamp(estimatedWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth); }); - - const preferredTotal = computeTotalRowWidth({ - tabWidths: preferredTabWidths, + const fullTotal = computeTotalRowWidth({ + itemWidths: fullTabWidths, tabGap: input.metrics.tabGap, rowPaddingHorizontal: input.metrics.rowPaddingHorizontal, }); - - if (preferredTotal <= availableWidth) { - return { - mode: "full", - tabWidths: preferredTabWidths, - shouldScroll: false, - showLabels: true, - showCloseButtons: true, - }; + if (fullTotal <= availableWidth) { + return { mode: "full", showLabels: true, showCloseButtons: true }; } - const compactMinTabWidths = preferredTabWidths.map(() => minLabelWidth); + const compactTabWidths = input.tabLabelLengths.map((rawLength) => { + const labelLength = Math.max(rawLength, 1); + const estimatedWidth = baseTabWidth + estimateLabelWidth(labelLength); + return clamp(estimatedWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth); + }); const compactTotal = computeTotalRowWidth({ - tabWidths: compactMinTabWidths, + itemWidths: compactTabWidths, tabGap: input.metrics.tabGap, rowPaddingHorizontal: input.metrics.rowPaddingHorizontal, }); - if (compactTotal <= availableWidth) { - const totalShrinkCapacity = sum(preferredTabWidths.map((width) => width - minLabelWidth)); - const targetShrink = preferredTotal - availableWidth; - const shrinkRatio = totalShrinkCapacity > 0 ? targetShrink / totalShrinkCapacity : 1; - const tabWidths = preferredTabWidths.map((preferred) => { - const shrinkCapacity = preferred - minLabelWidth; - return preferred - shrinkCapacity * shrinkRatio; - }); - - return { - mode: "compact", - tabWidths, - shouldScroll: false, - showLabels: true, - showCloseButtons: true, - }; + return { mode: "compact", showLabels: true, showCloseButtons: false }; } - const iconTabWidths = preferredTabWidths.map(() => input.metrics.iconOnlyTabWidth); - const iconTotal = computeTotalRowWidth({ - tabWidths: iconTabWidths, - tabGap: input.metrics.tabGap, - rowPaddingHorizontal: input.metrics.rowPaddingHorizontal, - }); - - return { - mode: "icon", - tabWidths: iconTabWidths, - shouldScroll: iconTotal > availableWidth, - showLabels: false, - showCloseButtons: false, - }; + return { mode: "icon", showLabels: false, showCloseButtons: false }; } diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index c89a6cae9..cce2b529e 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -42,6 +42,14 @@ function percentile(values: number[], p: number): number { return sorted[index]; } +function mean(values: number[]): number { + if (values.length === 0) { + return 0; + } + const total = values.reduce((sum, value) => sum + value, 0); + return total / values.length; +} + const shouldRun = !process.env.CI; (shouldRun ? describe : describe.skip)("daemon E2E terminal", () => { @@ -634,7 +642,7 @@ const shouldRun = !process.env.CI; ); test( - "measures local terminal round-trip latency via daemon client stream", + "profiles keystroke-to-echo latency via daemon terminal stream under burst load", async () => { const cwd = tmpCwd(); const created = await ctx.client.createTerminal(cwd); @@ -649,26 +657,106 @@ const shouldRun = !process.env.CI; output += decoder.decode(chunk.data, { stream: true }); }); - const samplesMs: number[] = []; - const iterations = 8; - for (let i = 0; i < iterations; i++) { - const marker = `PASEO_LAT_${i}_${Date.now()}`; - const start = performance.now(); - ctx.client.sendTerminalStreamInput(streamId, `echo ${marker}\r`); - await waitForCondition(() => output.includes(marker), 10000); - samplesMs.push(performance.now() - start); + type Trial = { + phase: "stream-only" | "stream+state-sub"; + iteration: number; + payloadBytes: number; + latencyMs: number; + }; + + async function runBurstLoop(input: { + phase: Trial["phase"]; + iterations: number; + burstRepeat: number; + }): Promise { + const trials: Trial[] = []; + let searchStart = 0; + + for (let i = 0; i < input.iterations; i += 1) { + const marker = `PASEO_LAT_${input.phase}_${i}_${Date.now()}_${Math.random() + .toString(36) + .slice(2, 8)}`; + const burstBody = "abcdefghijklmnopqrstuvwxyz0123456789".repeat(input.burstRepeat); + const payload = `${marker}_${burstBody}`; + + const start = performance.now(); + ctx.client.sendTerminalStreamInput(streamId, `${payload}\r`); + + await waitForCondition(() => output.indexOf(marker, searchStart) !== -1, 15000); + + const markerOffset = output.indexOf(marker, searchStart); + if (markerOffset !== -1) { + searchStart = markerOffset + marker.length; + } + + trials.push({ + phase: input.phase, + iteration: i + 1, + payloadBytes: payload.length, + latencyMs: performance.now() - start, + }); + + await new Promise((resolve) => setTimeout(resolve, 150)); + } + + return trials; } - const p50 = percentile(samplesMs, 50); - const p95 = percentile(samplesMs, 95); + // Warm-up: establish prompt/output activity before timed loops. + ctx.client.sendTerminalStreamInput(streamId, "echo PASEO_LAT_WARMUP\r"); + await waitForCondition(() => output.includes("PASEO_LAT_WARMUP"), 10000); - expect(samplesMs).toHaveLength(iterations); - // Localhost budget should be tight; keep margin for test noise. - expect(p95).toBeLessThan(350); + const streamOnlyTrials = await runBurstLoop({ + phase: "stream-only", + iterations: 10, + burstRepeat: 12, + }); - // Emit measurements for diagnosis when this test regresses. + await ctx.client.subscribeTerminal(terminalId); + const withStateSubscriptionTrials = await runBurstLoop({ + phase: "stream+state-sub", + iterations: 10, + burstRepeat: 12, + }); + ctx.client.unsubscribeTerminal(terminalId); + + const allTrials = [...streamOnlyTrials, ...withStateSubscriptionTrials]; + const streamOnlyLatencies = streamOnlyTrials.map((trial) => trial.latencyMs); + const withStateLatencies = withStateSubscriptionTrials.map((trial) => trial.latencyMs); + + const streamOnlyP50 = percentile(streamOnlyLatencies, 50); + const streamOnlyP95 = percentile(streamOnlyLatencies, 95); + const streamOnlyMax = percentile(streamOnlyLatencies, 100); + const withStateP50 = percentile(withStateLatencies, 50); + const withStateP95 = percentile(withStateLatencies, 95); + const withStateMax = percentile(withStateLatencies, 100); + + expect(allTrials).toHaveLength(20); + expect(streamOnlyLatencies.length).toBe(10); + expect(withStateLatencies.length).toBe(10); + + // Diagnostic output: used as verification/measurement loop in local debugging. console.log( - `[terminal-latency] samples=${samplesMs.map((n) => n.toFixed(1)).join(",")} p50=${p50.toFixed(1)}ms p95=${p95.toFixed(1)}ms` + `[terminal-latency-loop] stream-only samples=${streamOnlyLatencies + .map((n) => n.toFixed(1)) + .join(",")} mean=${mean(streamOnlyLatencies).toFixed(1)}ms p50=${streamOnlyP50.toFixed( + 1 + )}ms p95=${streamOnlyP95.toFixed(1)}ms max=${streamOnlyMax.toFixed(1)}ms` + ); + console.log( + `[terminal-latency-loop] stream+state-sub samples=${withStateLatencies + .map((n) => n.toFixed(1)) + .join(",")} mean=${mean(withStateLatencies).toFixed(1)}ms p50=${withStateP50.toFixed( + 1 + )}ms p95=${withStateP95.toFixed(1)}ms max=${withStateMax.toFixed(1)}ms` + ); + console.log( + `[terminal-latency-loop] trial-details=${allTrials + .map( + (trial) => + `${trial.phase}#${trial.iteration}:${trial.latencyMs.toFixed(1)}ms(${trial.payloadBytes}b)` + ) + .join(" ")}` ); const detach = await ctx.client.detachTerminalStream(streamId); @@ -676,6 +764,6 @@ const shouldRun = !process.env.CI; unsubscribe(); rmSync(cwd, { recursive: true, force: true }); }, - 30000 + 90000 ); }); diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 293976ae5..9d697b70f 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -8,6 +8,7 @@ import { createRequire } from "node:module"; const { Terminal } = xterm; const require = createRequire(import.meta.url); let nodePtySpawnHelperChecked = false; +const STATE_BROADCAST_INTERVAL_MS = 33; export interface Cell { char: string; @@ -275,6 +276,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise | null = null; // Create xterm.js headless terminal const terminal = new Terminal({ @@ -319,12 +322,39 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { + stateBroadcastTimer = null; + emitStateToListeners(); + }, STATE_BROADCAST_INTERVAL_MS); + } + function appendRawOutputChunk(data: string): void { const chunkBytes = Buffer.byteLength(data); if (chunkBytes <= 0) { @@ -386,11 +416,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { - // Notify listeners of changes - const state = getState(); - for (const listener of listeners) { - listener({ type: "full", state }); - } + scheduleStateBroadcast(); }); });