diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 133e9a070..d9439d07c 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1888,6 +1888,10 @@ function Terminal(): React.JSX.Element | null { // Keeping `isVisible` true for the portaled tab lets // xterm fit and stream foreground output in-place. isVisible={isActiveTerminalTab || isActivityPortalTab} + // Why: inactive tabs in the visible legacy surface + // are tab-hidden, not worktree-hidden, so they need + // the same light resume path as split-group overlays. + isWorktreeActive={isVisible || isActivityPortalTab} // Why: when portaled to Activity for a specific agent // pane, isolate that leaf so split siblings stay // hidden. Workspace renders pass null → no override. diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index e8d919711..e8079e8ca 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -146,6 +146,7 @@ type TerminalPaneProps = { cwd?: string isActive: boolean isVisible?: boolean + isWorktreeActive?: boolean // Why: when set (Activity portal), this pane visually isolates the given // split pane so only that leaf is shown. Implemented as a transient layout // override (separate snapshot ref) — does NOT touch expandedPaneId state @@ -220,6 +221,7 @@ export default function TerminalPane({ cwd, isActive, isVisible = true, + isWorktreeActive = isVisible, isolatedPaneKey = null, onPtyExit, onCloseTab @@ -1318,6 +1320,7 @@ export default function TerminalPane({ cwd, isActive, isVisible, + isWorktreeActive, // Why: hidden startup probes are opacity-hidden but measurable; ordinary // hidden tabs are display:none and refit on visibility resume instead. isSyncFitEnabled: isVisible || shouldMeasureHiddenStartup, diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 4bcf72a93..0eeb013a3 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -26,6 +26,8 @@ const HAS_CSS_ANCHOR_POSITIONING = CSS.supports('position-anchor', '--orca-terminal-overlay-probe') && CSS.supports('top', 'anchor(--orca-terminal-overlay-probe top)') && CSS.supports('width', 'anchor-size(--orca-terminal-overlay-probe width)') +const MIN_OVERLAY_FIT_WIDTH_PX = 48 +const MIN_OVERLAY_FIT_HEIGHT_PX = 24 function shouldUseCssAnchorPositioning(): boolean { return ( @@ -47,6 +49,7 @@ type TerminalOverlaySlotProps = { worktreeId: string worktreePath: string groupId: string | undefined + isWorktreeActive: boolean isVisible: boolean isActive: boolean activityTerminalPortal: ActivityTerminalPortalTarget | null @@ -62,6 +65,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ worktreeId, worktreePath, groupId, + isWorktreeActive, isVisible, isActive, activityTerminalPortal, @@ -128,21 +132,37 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ }, [anchorName, groupId, isVisible]) useLayoutEffect(() => { - if (!isVisible || !anchorName || shouldUseCssAnchorPositioning()) { + if (!isVisible || !anchorName) { return } - // Why: worktree switches resume visibility before fallback positioning - // settles. Re-fit on show and again after the measured rect lands so the - // PTY never stays pinned at a stale ~2-col width. - const frameId = requestAnimationFrame(() => { + const dispatchFitIfMeasurable = (): void => { + const rect = overlayRef.current?.getBoundingClientRect() + if ( + !rect || + rect.width < MIN_OVERLAY_FIT_WIDTH_PX || + rect.height < MIN_OVERLAY_FIT_HEIGHT_PX + ) { + return + } window.dispatchEvent(new Event(SYNC_FIT_PANES_EVENT)) + } + + // Why: tab switches can resume visibility before anchor/fallback geometry + // settles. Re-fit only after the overlay has real dimensions so the PTY + // never stays pinned at a stale ~2-col width. + const frameId = requestAnimationFrame(() => { + dispatchFitIfMeasurable() }) const retryId = window.setTimeout(() => { - window.dispatchEvent(new Event(SYNC_FIT_PANES_EVENT)) + dispatchFitIfMeasurable() }, 50) + const settledRetryId = window.setTimeout(() => { + dispatchFitIfMeasurable() + }, 150) return () => { cancelAnimationFrame(frameId) window.clearTimeout(retryId) + window.clearTimeout(settledRetryId) } }, [anchorName, isVisible, measuredFallbackRect]) @@ -202,6 +222,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ // TerminalPane mounted here preserves alt-screen TUI state while this // flag still lets hidden tabs throttle rendering. isVisible={isVisible || activityTerminalPortal !== null} + isWorktreeActive={isWorktreeActive || activityTerminalPortal !== null} isolatedPaneKey={activityTerminalPortal?.paneKey ?? null} onPtyExit={(ptyId) => { if (consumeSuppressedPtyExit(ptyId)) { @@ -332,6 +353,7 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ worktreeId={worktreeId} worktreePath={worktreePath} groupId={assignment?.groupId} + isWorktreeActive={isWorktreeActive} isVisible={isVisible} isActive={isActive} activityTerminalPortal={activityTerminalPortal} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index d6b2d548c..69b24da6a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4786,6 +4786,48 @@ describe('connectPanePty', () => { binding.dispose() }) + it('keeps hidden Grok telemetry startup output parsing briefly', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { + command: 'wrapped-agent', + telemetry: { + agent_kind: 'grok', + launch_source: 'tab_bar_quick_launch', + request_kind: 'new' + } + } + }) as never + ) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + '\x1b]11;?\x1b\\startup frame\r\n', + expect.any(Function) + ) + + binding.dispose() + }) + it('keeps hidden bare Codex startup commands parsing briefly', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -4821,6 +4863,41 @@ describe('connectPanePty', () => { binding.dispose() }) + it('keeps hidden bare Grok startup commands parsing briefly', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: '/Users/me/.grok/bin/grok --permission-mode bypassPermissions' } + }) as never + ) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + '\x1b]11;?\x1b\\startup frame\r\n', + expect.any(Function) + ) + + binding.dispose() + }) + it('skips arbitrary hidden startup output parsing', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 2bb0555d6..e33355fbc 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -84,7 +84,7 @@ import { import { executeTerminalStartupCommandPaste } from './terminal-startup-command-paste' import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform' import { resolveTerminalPasteRuntime } from './terminal-paste-runtime' -import { isCodexTerminalStartupCommand } from './terminal-startup-command-classifier' +import { isKnownTuiAgentTerminalStartupCommand } from './terminal-startup-command-classifier' import { createCommandCodeOutputStatusDetector } from './command-code-output-status' import type { PtyDataMeta } from './pty-dispatcher' import { getEagerPtyBufferHandle } from './pty-dispatcher' @@ -316,8 +316,8 @@ function shouldKeepHiddenStartupRendererQueriesLive( startup: PtyConnectionDeps['startup'] ): boolean { return ( - startup?.telemetry?.agent_kind === 'codex' || - isCodexTerminalStartupCommand(startup?.command ?? '') + Boolean(startup?.telemetry?.agent_kind && startup.telemetry.agent_kind !== 'other') || + isKnownTuiAgentTerminalStartupCommand(startup?.command ?? '') ) } diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts index c6851a920..3444bf412 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getTerminalStartupCommandToken, isCodexTerminalStartupCommand, + isKnownTuiAgentTerminalStartupCommand, TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS } from './terminal-startup-command-classifier' @@ -22,6 +23,7 @@ describe('terminal startup command classifier', () => { expect(getTerminalStartupCommandToken(command)).toBe('codex') expect(isCodexTerminalStartupCommand(command)).toBe(true) + expect(isKnownTuiAgentTerminalStartupCommand(command)).toBe(true) expect(getRegexWhitespaceSplitCalls(split)).toHaveLength(0) }) @@ -30,6 +32,7 @@ describe('terminal startup command classifier', () => { expect(getTerminalStartupCommandToken(command)).toBe('C:\\Program Files\\Orca\\codex.cmd') expect(isCodexTerminalStartupCommand(command)).toBe(true) + expect(isKnownTuiAgentTerminalStartupCommand(command)).toBe(true) }) it('recognizes POSIX Codex wrapper names', () => { @@ -37,6 +40,18 @@ describe('terminal startup command classifier', () => { expect(isCodexTerminalStartupCommand('/usr/local/bin/not-codex --continue')).toBe(false) }) + it('recognizes non-Codex Orca agent startup commands', () => { + expect(isKnownTuiAgentTerminalStartupCommand('grok --permission-mode bypassPermissions')).toBe( + true + ) + expect(isKnownTuiAgentTerminalStartupCommand('/Users/me/.grok/bin/grok --resume abc')).toBe( + true + ) + expect(isKnownTuiAgentTerminalStartupCommand('/usr/local/bin/not-grok --resume abc')).toBe( + false + ) + }) + it('bounds pathological single-token startup commands', () => { const split = vi.spyOn(String.prototype, 'split') const command = 'codex'.repeat(TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS) @@ -45,6 +60,7 @@ describe('terminal startup command classifier', () => { TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS ) expect(isCodexTerminalStartupCommand(command)).toBe(false) + expect(isKnownTuiAgentTerminalStartupCommand(command)).toBe(false) expect(getRegexWhitespaceSplitCalls(split)).toHaveLength(0) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts index 9bfd85818..f171efc01 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts @@ -1,7 +1,24 @@ +import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' + const TERMINAL_STARTUP_COMMAND_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i // Why: startup commands can carry pasted scripts; classifier work should stay bounded. export const TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS = 4096 +const KNOWN_TUI_AGENT_EXECUTABLES = new Set() + +for (const config of Object.values(TUI_AGENT_CONFIG)) { + for (const candidate of [ + config.detectCmd, + config.expectedProcess, + ...getTuiAgentDetectCommands(config) + ]) { + const executable = normalizeTerminalStartupCommandExecutableName(candidate) + if (executable) { + KNOWN_TUI_AGENT_EXECUTABLES.add(executable) + } + } +} + export function getTerminalStartupCommandToken(command: string): string { const scanLimit = Math.min(command.length, TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS) let index = 0 @@ -38,10 +55,23 @@ export function isCodexTerminalStartupCommand(command: string): boolean { return executable === 'codex' || executable.startsWith('codex-') } +export function isKnownTuiAgentTerminalStartupCommand(command: string): boolean { + const executable = getTerminalStartupCommandExecutableName(command) + return ( + KNOWN_TUI_AGENT_EXECUTABLES.has(executable) || + executable.startsWith('codex-') || + executable.startsWith('grok-') + ) +} + function getTerminalStartupCommandExecutableName(command: string): string { const token = getTerminalStartupCommandToken(command) const segmentStart = getTerminalStartupCommandPathSegmentStart(token) - return token.slice(segmentStart).toLowerCase().replace(TERMINAL_STARTUP_COMMAND_EXTENSION_RE, '') + return normalizeTerminalStartupCommandExecutableName(token.slice(segmentStart)) +} + +function normalizeTerminalStartupCommandExecutableName(executable: string): string { + return executable.toLowerCase().replace(TERMINAL_STARTUP_COMMAND_EXTENSION_RE, '') } function getTerminalStartupCommandPathSegmentStart(token: string): number { diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts new file mode 100644 index 000000000..87db96c3a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -0,0 +1,150 @@ +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { ScrollState } from '@/lib/pane-manager/pane-manager-types' +import { resetAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry' +import { + flushTerminalOutput, + requestTerminalBacklogRecovery +} from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll' +import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers' + +const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 + +export type TerminalHiddenReason = 'surface' | 'tab' + +type ResumeTerminalVisibilityArgs = { + manager: PaneManager + isActive: boolean + wasVisible: boolean + shouldUseLightTabResume: boolean + captureViewportPositions: (useRememberedSnapshots: boolean) => Map + withSuppressedScrollTracking: (callback: () => void) => void +} + +type HideTerminalVisibilityArgs = { + manager: PaneManager + wasVisible: boolean + wasWorktreeActive: boolean + isWorktreeActive: boolean + hasCompletedVisibleResume: boolean + captureViewportPositions: (useRememberedSnapshots: boolean) => Map +} + +type HideTerminalVisibilityResult = { + hiddenReason: TerminalHiddenReason | null + renderingSuspended: boolean +} + +export function resumeTerminalVisibility({ + manager, + isActive, + wasVisible, + shouldUseLightTabResume, + captureViewportPositions, + withSuppressedScrollTracking +}: ResumeTerminalVisibilityArgs): void { + // Why: WebGL resume can disturb xterm's viewport bookkeeping before the + // post-resume fit runs. Capture numeric viewport positions first; the + // restore path avoids content matching so duplicate agent log lines do + // not jump to the wrong history entry. + const viewportPositions = captureViewportPositions(!wasVisible) + withSuppressedScrollTracking(() => { + if (shouldUseLightTabResume) { + // Why: intra-worktree tab switches only toggle the overlay. Keeping + // synchronous drain and atlas rebuilds off this path avoids racing the + // overlay's delayed geometry fit. Still request hidden-output recovery: + // agent TUIs can suppress hidden bytes until the pane is foregrounded. + requestLightTabBacklogRecovery(manager) + if (isActive) { + focusActivePane(manager) + } + } else { + resumeTerminalVisibilityHeavy(manager, isActive) + } + restoreTerminalViewportPositions(manager, viewportPositions) + if (!shouldUseLightTabResume) { + // Why: this clear wipes the glyph atlas shared with other same-config + // terminals; the global reset rebuilds their render models too. + resetAllTerminalWebglAtlases() + } + }) +} + +export function hideTerminalVisibility({ + manager, + wasVisible, + wasWorktreeActive, + isWorktreeActive, + hasCompletedVisibleResume, + captureViewportPositions +}: HideTerminalVisibilityArgs): HideTerminalVisibilityResult { + const surfaceBecameHidden = wasWorktreeActive && !isWorktreeActive + if (wasVisible) { + // Why: hidden DOM/layout churn can mutate xterm's viewport before the + // pane becomes visible again. Preserve the last visible position. + captureViewportPositions(false) + } + if (!isWorktreeActive && (wasVisible || surfaceBecameHidden)) { + // Suspend WebGL when going hidden. xterm.write() continues to land in + // the (now DOM-renderer-fallback or paused-canvas) terminal; the + // suspend is purely a GPU resource decision. + manager.suspendRendering() + return { hiddenReason: 'surface', renderingSuspended: true } + } + if (!hasCompletedVisibleResume && wasVisible && wasWorktreeActive && isWorktreeActive) { + // Why: the visibility hook starts wasVisible=true so terminal tabs that + // first mount hidden still release WebGL contexts instead of exhausting + // Chromium's small context budget. + manager.suspendRendering() + return { hiddenReason: 'tab', renderingSuspended: true } + } + if (wasVisible && isWorktreeActive) { + return { hiddenReason: 'tab', renderingSuspended: false } + } + if (!isWorktreeActive) { + return { hiddenReason: 'surface', renderingSuspended: false } + } + return { hiddenReason: null, renderingSuspended: false } +} + +function requestLightTabBacklogRecovery(manager: PaneManager): void { + for (const pane of manager.getPanes()) { + requestTerminalBacklogRecovery(pane.terminal) + } +} + +function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean): void { + // Why: hidden panes can accumulate large PTY bursts while Chromium is + // occluded. Drain a bounded slice before fitting; the scheduler keeps + // ordering and continues the rest asynchronously so return-to-app does + // not beachball behind an entire backlog. + for (const pane of manager.getPanes()) { + requestTerminalBacklogRecovery(pane.terminal) + flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS }) + } + // Resume WebGL immediately so the terminal shows its last-known state + // on the first painted frame. macOS context creation is ~5 ms; on + // Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume + // would paint a stretched DOM-fallback flash, which is worse UX. + manager.resumeRendering() + // Single fit on resume. Background bytes have been pushed into xterm + // above, so this fit only absorbs container dimension changes that + // happened while hidden (e.g. sidebar toggle on another worktree). + if (isActive) { + fitAndFocusPanes(manager) + } else { + fitPanes(manager) + } +} + +function restoreTerminalViewportPositions( + manager: PaneManager, + viewportPositions: Map +): void { + for (const pane of manager.getPanes()) { + const position = viewportPositions.get(pane.id) + if (position) { + restoreScrollStateAfterLayout(pane.terminal, position) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 82a185783..df9f4ba45 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ captureScrollState: vi.fn(), fitAndFocusPanes: vi.fn(), fitPanes: vi.fn(), + focusActivePane: vi.fn(), flushTerminalOutput: vi.fn(), getTerminalOutputEpoch: vi.fn(() => 0), handleTerminalFileDrop: vi.fn(), @@ -58,7 +59,8 @@ vi.mock('react', async (importOriginal) => { vi.mock('./pane-helpers', () => ({ fitAndFocusPanes: mocks.fitAndFocusPanes, - fitPanes: mocks.fitPanes + fitPanes: mocks.fitPanes, + focusActivePane: mocks.focusActivePane })) vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ @@ -113,6 +115,7 @@ function useMountForFileDrop( cwd?: string isActive?: boolean isVisible?: boolean + isWorktreeActive?: boolean isSyncFitEnabled?: boolean paneCount?: number } = {} @@ -150,6 +153,7 @@ function useMountForFileDrop( cwd: options.cwd, isActive: options.isActive ?? true, isVisible: options.isVisible ?? true, + isWorktreeActive: options.isWorktreeActive ?? options.isVisible ?? true, isSyncFitEnabled: options.isSyncFitEnabled ?? options.isVisible ?? true, paneCount: options.paneCount ?? 0, managerRef: { current: manager as never }, @@ -278,6 +282,257 @@ describe('useTerminalPaneGlobalEffects', () => { expect(isVisibleRef.current).toBe(true) }) + it('uses a light resume for tab switches while the worktree stays active', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + registerManagerForReset(manager) + const baseArgs = { + tabId: 'tab-1', + worktreeId: 'wt-1', + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + paneCount: 1, + isSyncFitEnabled: true, + isWorktreeActive: true, + toggleExpandPane: vi.fn() + } + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true + }) + + manager.resumeRendering.mockClear() + manager.resetWebglTextureAtlases.mockClear() + manager.suspendRendering.mockClear() + mocks.fitAndFocusPanes.mockClear() + mocks.fitPanes.mockClear() + mocks.focusActivePane.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: false + }) + + expect(manager.suspendRendering).not.toHaveBeenCalled() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true + }) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).not.toHaveBeenCalled() + expect(manager.resumeRendering).not.toHaveBeenCalled() + expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled() + expect(mocks.fitPanes).not.toHaveBeenCalled() + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(mocks.focusActivePane).toHaveBeenCalledWith(manager) + }) + + it('keeps visible active-state updates on the light resume path', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + registerManagerForReset(manager) + const baseArgs = { + tabId: 'tab-1', + worktreeId: 'wt-1', + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + paneCount: 1, + isSyncFitEnabled: true, + isWorktreeActive: true, + toggleExpandPane: vi.fn() + } + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: true + }) + + manager.resumeRendering.mockClear() + manager.resetWebglTextureAtlases.mockClear() + mocks.fitAndFocusPanes.mockClear() + mocks.fitPanes.mockClear() + mocks.focusActivePane.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true + }) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).not.toHaveBeenCalled() + expect(manager.resumeRendering).not.toHaveBeenCalled() + expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled() + expect(mocks.fitPanes).not.toHaveBeenCalled() + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(mocks.focusActivePane).toHaveBeenCalledWith(manager) + }) + + it('suspends rendering when a terminal tab first mounts hidden', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + registerManagerForReset(manager) + const baseArgs = { + tabId: 'tab-1', + worktreeId: 'wt-1', + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + paneCount: 1, + isSyncFitEnabled: true, + isWorktreeActive: true, + toggleExpandPane: vi.fn() + } + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: false + }) + + expect(manager.suspendRendering).toHaveBeenCalledTimes(1) + + manager.suspendRendering.mockClear() + manager.resumeRendering.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true + }) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, { maxChars: 256 * 1024 }) + expect(manager.resumeRendering).toHaveBeenCalledTimes(1) + }) + + it('suspends a tab-hidden terminal when its worktree surface becomes hidden', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + registerManagerForReset(manager) + const baseArgs = { + tabId: 'tab-1', + worktreeId: 'wt-1', + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + paneCount: 1, + isSyncFitEnabled: true, + toggleExpandPane: vi.fn() + } + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true, + isWorktreeActive: true + }) + + manager.suspendRendering.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: false, + isWorktreeActive: true + }) + expect(manager.suspendRendering).not.toHaveBeenCalled() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: false, + isWorktreeActive: false + }) + + expect(manager.suspendRendering).toHaveBeenCalledTimes(1) + + manager.resumeRendering.mockClear() + manager.resetWebglTextureAtlases.mockClear() + mocks.fitAndFocusPanes.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true, + isWorktreeActive: true + }) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, { maxChars: 256 * 1024 }) + expect(manager.resumeRendering).toHaveBeenCalledTimes(1) + expect(mocks.fitAndFocusPanes).toHaveBeenCalledWith(manager) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + }) + it('reports the active local PTY to the main output scheduler', () => { const manager = { getPanes: vi.fn(() => [{ id: 1, terminal: { name: 'terminal-a' } }]), diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 5fc62839a..23a03049d 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -8,22 +8,19 @@ import { } from '@/constants/terminal' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { resetAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry' -import { fitAndFocusPanes, fitPanes } from './pane-helpers' import type { PtyTransport } from './pty-transport' import { handleTerminalFileDrop } from './terminal-drop-handler' -import { - flushTerminalOutput, - requestTerminalBacklogRecovery -} from '@/lib/pane-manager/pane-terminal-output-scheduler' import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event' import { surfaceStaleAgentRow } from './stale-agent-row' import { useAppStore } from '@/store' -import { restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll' import { useTerminalScrollVisibilityMemory } from './use-terminal-scroll-visibility-memory' import { useTerminalContainerFitSync } from './use-terminal-container-fit-sync' import { handleTerminalProgrammaticTextPaste } from './terminal-programmatic-text-paste' - -const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 +import { + hideTerminalVisibility, + resumeTerminalVisibility, + type TerminalHiddenReason +} from './terminal-visibility-resume' type UseTerminalPaneGlobalEffectsArgs = { tabId: string @@ -31,6 +28,7 @@ type UseTerminalPaneGlobalEffectsArgs = { cwd?: string isActive: boolean isVisible: boolean + isWorktreeActive?: boolean isSyncFitEnabled: boolean paneCount: number managerRef: React.RefObject @@ -47,6 +45,7 @@ export function useTerminalPaneGlobalEffects({ cwd, isActive, isVisible, + isWorktreeActive = isVisible, isSyncFitEnabled, paneCount, managerRef, @@ -65,6 +64,10 @@ export function useTerminalPaneGlobalEffects({ // otherwise leak WebGL contexts — openTerminal() unconditionally creates // one — and exhaust Chromium's ~8-context budget across worktrees. const wasVisibleRef = useRef(true) + const wasWorktreeActiveRef = useRef(isWorktreeActive) + const hasCompletedVisibleResumeRef = useRef(false) + const renderingSuspendedByVisibilityRef = useRef(false) + const hiddenReasonRef = useRef(null) const { captureViewportPositions, withSuppressedScrollTracking, @@ -83,61 +86,47 @@ export function useTerminalPaneGlobalEffects({ if (!manager) { return } + const wasVisible = wasVisibleRef.current + const wasWorktreeActive = wasWorktreeActiveRef.current isActiveRef.current = isActive isVisibleRef.current = isVisible if (isVisible) { - // Why: WebGL resume can disturb xterm's viewport bookkeeping before the - // post-resume fit runs. Capture numeric viewport positions first; the - // restore path avoids content matching so duplicate agent log lines do - // not jump to the wrong history entry. - const viewportPositions = captureViewportPositions(!wasVisibleRef.current) - withSuppressedScrollTracking(() => { - // Why: hidden panes can accumulate large PTY bursts while Chromium is - // occluded. Drain a bounded slice before fitting; the scheduler keeps - // ordering and continues the rest asynchronously so return-to-app does - // not beachball behind an entire backlog. - for (const pane of manager.getPanes()) { - requestTerminalBacklogRecovery(pane.terminal) - flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS }) - } - // Resume WebGL immediately so the terminal shows its last-known state - // on the first painted frame. macOS context creation is ~5 ms; on - // Windows (ANGLE → D3D11) it can be 100–500 ms but a deferred resume - // would paint a stretched DOM-fallback flash, which is worse UX. - manager.resumeRendering() - // Single fit on resume. Background bytes have been pushed into xterm - // above, so this fit only absorbs container dimension changes that - // happened while hidden (e.g. sidebar toggle on another worktree). - if (isActive) { - fitAndFocusPanes(manager) - } else { - fitPanes(manager) - } - for (const pane of manager.getPanes()) { - const position = viewportPositions.get(pane.id) - if (position) { - restoreScrollStateAfterLayout(pane.terminal, position) - } - } - // Why: this clear wipes the glyph atlas shared with other same-config - // terminals; the global reset rebuilds their render models too. - resetAllTerminalWebglAtlases() + const shouldUseLightTabResume = + isWorktreeActive && + hasCompletedVisibleResumeRef.current && + !renderingSuspendedByVisibilityRef.current && + (wasVisible || hiddenReasonRef.current === 'tab') + resumeTerminalVisibility({ + manager, + isActive, + wasVisible, + shouldUseLightTabResume, + captureViewportPositions, + withSuppressedScrollTracking }) + renderingSuspendedByVisibilityRef.current = false wasVisibleRef.current = true + wasWorktreeActiveRef.current = isWorktreeActive + hasCompletedVisibleResumeRef.current = true + hiddenReasonRef.current = null applyPendingFollowOutputRequests() return - } else if (wasVisibleRef.current) { - // Why: hidden DOM/layout churn can mutate xterm's viewport before the - // pane becomes visible again. Preserve the last visible position. - captureViewportPositions(false) - // Suspend WebGL when going hidden. xterm.write() continues to land in - // the (now DOM-renderer-fallback or paused-canvas) terminal; the - // suspend is purely a GPU resource decision. - manager.suspendRendering() + } else { + const hiddenState = hideTerminalVisibility({ + manager, + wasVisible, + wasWorktreeActive, + isWorktreeActive, + hasCompletedVisibleResume: hasCompletedVisibleResumeRef.current, + captureViewportPositions + }) + renderingSuspendedByVisibilityRef.current = hiddenState.renderingSuspended + hiddenReasonRef.current = hiddenState.hiddenReason } wasVisibleRef.current = false + wasWorktreeActiveRef.current = isWorktreeActive // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isActive, isVisible]) + }, [isActive, isVisible, isWorktreeActive]) useEffect(() => { if (!isVisible) { diff --git a/tests/e2e/terminal-screenshot-diff.ts b/tests/e2e/terminal-screenshot-diff.ts new file mode 100644 index 000000000..1a3819a26 --- /dev/null +++ b/tests/e2e/terminal-screenshot-diff.ts @@ -0,0 +1,47 @@ +import { PNG } from 'pngjs' + +export type ScreenshotDiffSummary = { + matches: boolean + diffPixels: number + diffRatio: number + width: number + height: number +} + +export function compareTerminalScreenshots( + baselineBuffer: Buffer, + candidateBuffer: Buffer +): ScreenshotDiffSummary { + const baseline = PNG.sync.read(baselineBuffer) + const candidate = PNG.sync.read(candidateBuffer) + if (baseline.width !== candidate.width || baseline.height !== candidate.height) { + return { + matches: false, + diffPixels: Number.POSITIVE_INFINITY, + diffRatio: Number.POSITIVE_INFINITY, + width: candidate.width, + height: candidate.height + } + } + + let diffPixels = 0 + for (let offset = 0; offset < baseline.data.length; offset += 4) { + const redDiff = Math.abs((baseline.data[offset] ?? 0) - (candidate.data[offset] ?? 0)) + const greenDiff = Math.abs((baseline.data[offset + 1] ?? 0) - (candidate.data[offset + 1] ?? 0)) + const blueDiff = Math.abs((baseline.data[offset + 2] ?? 0) - (candidate.data[offset + 2] ?? 0)) + const alphaDiff = Math.abs((baseline.data[offset + 3] ?? 0) - (candidate.data[offset + 3] ?? 0)) + if (redDiff + greenDiff + blueDiff + alphaDiff > 48) { + diffPixels += 1 + } + } + + const pixelCount = baseline.width * baseline.height + const diffRatio = pixelCount > 0 ? diffPixels / pixelCount : Number.POSITIVE_INFINITY + return { + matches: diffRatio <= 0.015, + diffPixels, + diffRatio, + width: baseline.width, + height: baseline.height + } +} diff --git a/tests/e2e/terminal-tab-screenshot.ts b/tests/e2e/terminal-tab-screenshot.ts new file mode 100644 index 000000000..506ed1a20 --- /dev/null +++ b/tests/e2e/terminal-tab-screenshot.ts @@ -0,0 +1,21 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from './helpers/orca-app' + +function tabScreenLocator(page: Page, tabId: string): ReturnType { + return page.locator(`[data-terminal-tab-id="${tabId}"] .xterm-screen`).first() +} + +export async function captureStableTabScreenshot(page: Page, tabId: string): Promise { + const screen = tabScreenLocator(page, tabId) + await expect(screen).toBeVisible() + let previous = await screen.screenshot({ animations: 'disabled' }) + for (let attempt = 0; attempt < 10; attempt += 1) { + await page.waitForTimeout(250) + const next = await screen.screenshot({ animations: 'disabled' }) + if (next.equals(previous)) { + return next + } + previous = next + } + throw new Error(`Terminal surface for tab ${tabId} did not stabilize for screenshot`) +} diff --git a/tests/e2e/terminal-tab-switch-visual-restore.spec.ts b/tests/e2e/terminal-tab-switch-visual-restore.spec.ts new file mode 100644 index 000000000..d154882ec --- /dev/null +++ b/tests/e2e/terminal-tab-switch-visual-restore.spec.ts @@ -0,0 +1,867 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + getActiveWorktreeId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager +} from './helpers/terminal' +import { compareTerminalScreenshots } from './terminal-screenshot-diff' +import { captureStableTabScreenshot } from './terminal-tab-screenshot' + +const SILENT_FOREGROUND_COMMAND = 'node -e "setInterval(() => {}, 1000)"\r' +const TAB_A_GLYPH_ROW = 'abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\#@%&*+=~' +const TAB_B_GLYPH_ROW = 'ZYXWVUTSRQPONMLKJIHGFEDCBA 9876543210 !?^"\'();:,.|$_-' + +type TabTerminalGeometry = { + tabId: string + overlayWidth: number + overlayHeight: number + overlayDisplay: string + cols: number + rows: number + cellWidth: number + screenWidth: number + screenRight: number + rowRight: number + contentWidthRatio: number + markerPresent: boolean + hasWebgl: boolean +} + +const TAB_SWITCH_MARKER_PREFIX = 'TAB_SWITCH_VISUAL_RESTORE' + +type TerminalOutputSchedulerSnapshot = { + backgroundEnqueueCount: number + scheduledDrainCount: number + queuedChars: number +} + +type SchedulerDebugWindow = Window & { + __terminalOutputSchedulerDebug?: { + reset: () => void + snapshot: () => TerminalOutputSchedulerSnapshot + } +} + +type HiddenOutputDebugSnapshot = { + hiddenRendererSkipCount: number + hiddenRendererSkippedChars: number + hiddenRendererMode2031ReplyCount: number +} + +type HiddenOutputRecoveryWindow = Window & { + __terminalPtyDataInjection?: { + inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean + } + __terminalPtyOutputDebug?: { + reset: () => void + snapshot: () => HiddenOutputDebugSnapshot + } + __terminalHiddenSnapshotOverride?: { + setPending: ( + ptyId: string, + snapshot: { data: string; cols: number; rows: number; seq?: number } + ) => void + resolve: (ptyId: string) => void + clear: (ptyId: string) => void + } +} + +async function forceWebglOnActiveTab(page: Page): Promise { + await page.evaluate(() => { + const state = window.__store?.getState() + if (!state?.settings) { + throw new Error('Store unavailable') + } + window.__store?.setState({ + settings: { + ...state.settings, + terminalGpuAcceleration: 'on' + } + }) + const worktreeId = state.activeWorktreeId + const tabId = + state.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + window.__paneManagers?.get(tabId ?? '')?.setTerminalGpuAcceleration?.('on') + }) +} + +async function ensureTwoTerminalTabs( + page: Page +): Promise<{ firstTabId: string; secondTabId: string }> { + const worktreeId = (await getActiveWorktreeId(page))! + if ((await page.locator('[data-testid="sortable-tab"]').count()) < 2) { + await page.getByRole('button', { name: 'New tab' }).click({ force: true }) + await page + .getByRole('menuitem', { name: /New Terminal/i }) + .first() + .click({ force: true }) + await expect + .poll(() => page.locator('[data-testid="sortable-tab"]').count(), { timeout: 5_000 }) + .toBeGreaterThanOrEqual(2) + } + const firstTabId = (await getActiveTabId(page))! + const secondTabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const tabs = state.tabsByWorktree[worktreeId] ?? [] + const other = tabs.find((tab) => tab.id !== state.activeTabId) + return other?.id ?? null + }, worktreeId) + if (!secondTabId) { + throw new Error('Expected a second terminal tab') + } + return { firstTabId, secondTabId } +} + +async function createAgentMarkedTerminalTab( + page: Page, + agent: 'codex' | 'grok', + command: string +): Promise { + const worktreeId = (await getActiveWorktreeId(page))! + return page.evaluate( + ({ worktreeId, agent, command }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { + launchAgent: agent + }) + state.queueTabStartupCommand(tab.id, { + command, + launchAgent: agent, + telemetry: { + agent_kind: agent, + launch_source: 'tab_bar_quick_launch', + request_kind: 'new' + } + }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, + { worktreeId, agent, command } + ) +} + +async function createCodexMarkedTerminalTab(page: Page): Promise { + return createAgentMarkedTerminalTab(page, 'codex', 'node -e "setInterval(() => {}, 1000)"') +} + +async function createGrokMarkedTerminalTab(page: Page): Promise { + return createAgentMarkedTerminalTab(page, 'grok', 'node -e "setInterval(() => {}, 1000)"') +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.getState().setActiveTab(id) + store.getState().setActiveTabType('terminal') + }, tabId) + await expect + .poll( + () => + page + .locator(`[data-testid="sortable-tab"][data-active="true"]`) + .getAttribute('data-tab-id'), + { + timeout: 3_000 + } + ) + .toBe(tabId) +} + +async function waitForWebglOnTab(page: Page, tabId: string): Promise { + return page + .waitForFunction( + (id) => { + const diagnostics = window.__paneManagers?.get(id)?.getRenderingDiagnostics?.() ?? [] + return diagnostics.some((entry) => entry.hasWebgl) + }, + tabId, + { timeout: 15_000 } + ) + .then(() => true) + .catch(() => false) +} + +async function waitForPanePtyIdOnTab(page: Page, tabId: string): Promise { + await expect + .poll( + () => + page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container?.dataset?.ptyId ?? null + }, tabId), + { timeout: 15_000, message: `Pane for tab ${tabId} did not receive a PTY binding` } + ) + .not.toBeNull() + const ptyId = await page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container?.dataset?.ptyId ?? null + }, tabId) + if (!ptyId) { + throw new Error(`Pane for tab ${tabId} has no PTY binding`) + } + return ptyId +} + +async function readPaneIdentityOnTab( + page: Page, + tabId: string +): Promise<{ leafId: string; ptyId: string; cols: number; rows: number }> { + const identity = await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + return null + } + return { + leafId: pane.container.dataset.leafId ?? null, + ptyId: pane.container.dataset.ptyId ?? null, + cols: pane.terminal.cols, + rows: pane.terminal.rows + } + }, tabId) + if (!identity?.leafId || !identity.ptyId) { + throw new Error(`Pane identity for tab ${tabId} is incomplete`) + } + return { + leafId: identity.leafId, + ptyId: identity.ptyId, + cols: identity.cols, + rows: identity.rows + } +} + +async function resetHiddenOutputDebug(page: Page): Promise { + await page.evaluate(() => { + ;(window as HiddenOutputRecoveryWindow).__terminalPtyOutputDebug?.reset() + }) +} + +async function readHiddenOutputDebug(page: Page): Promise { + return page.evaluate(() => { + return (window as HiddenOutputRecoveryWindow).__terminalPtyOutputDebug?.snapshot() ?? null + }) +} + +async function injectPaneData( + page: Page, + paneKey: string, + data: string, + meta?: { seq?: number; rawLength?: number } +): Promise { + const injected = await page.evaluate( + ({ paneKey, data, meta }) => + (window as HiddenOutputRecoveryWindow).__terminalPtyDataInjection?.inject( + paneKey, + data, + meta + ) ?? false, + { paneKey, data, meta } + ) + if (!injected) { + throw new Error(`No terminal PTY data injector registered for ${paneKey}`) + } +} + +async function setHiddenSnapshotOverride( + page: Page, + ptyId: string, + snapshot: { data: string; cols: number; rows: number; seq?: number } +): Promise { + await page.evaluate( + ({ ptyId, snapshot }) => { + const api = (window as HiddenOutputRecoveryWindow).__terminalHiddenSnapshotOverride + if (!api) { + throw new Error('Hidden snapshot override API unavailable') + } + api.setPending(ptyId, snapshot) + api.resolve(ptyId) + }, + { ptyId, snapshot } + ) +} + +async function resetTerminalOutputSchedulerDebug(page: Page): Promise { + await page.evaluate(() => { + const debug = (window as SchedulerDebugWindow).__terminalOutputSchedulerDebug + if (!debug) { + throw new Error('Terminal output scheduler debug API unavailable') + } + debug.reset() + }) +} + +async function waitForHiddenOutputSchedulerActivity( + page: Page +): Promise { + await expect + .poll( + () => + page.evaluate(() => { + const snapshot = ( + window as SchedulerDebugWindow + ).__terminalOutputSchedulerDebug?.snapshot() + return snapshot?.backgroundEnqueueCount ?? 0 + }), + { + timeout: 5_000, + message: 'hidden PTY output did not reach the background output scheduler' + } + ) + .toBeGreaterThan(0) + return page.evaluate(() => { + const snapshot = (window as SchedulerDebugWindow).__terminalOutputSchedulerDebug?.snapshot() + if (!snapshot) { + throw new Error('Terminal output scheduler debug API unavailable') + } + return { + backgroundEnqueueCount: snapshot.backgroundEnqueueCount, + scheduledDrainCount: snapshot.scheduledDrainCount, + queuedChars: snapshot.queuedChars + } + }) +} + +async function startHiddenPtyOutputBurst(page: Page, ptyId: string, runId: string): Promise { + const marker = `${TAB_SWITCH_MARKER_PREFIX}_PTY_${runId}` + const script = [ + `const marker=${JSON.stringify(marker)};`, + 'setTimeout(()=>{', + 'let frame=0;', + 'const timer=setInterval(()=>{', + 'console.log(`${marker} frame=${String(frame).padStart(3,"0")} abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\\\#@%&*+=~`);', + 'frame+=1;', + 'if(frame>=180) clearInterval(timer);', + '},1);', + '},30);' + ].join('') + await sendToTerminal(page, ptyId, `node -e ${JSON.stringify(script)}\r`) +} + +async function writeStaticTabContent( + page: Page, + tabId: string, + marker: string, + glyphRow: string +): Promise { + await page.evaluate( + async ({ id, marker, glyphRow }) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error(`Pane unavailable for tab ${id}`) + } + const rows = Array.from( + { length: 14 }, + (_, row) => `${marker} row ${row} | ${glyphRow} |\r\n` + ).join('') + await new Promise((resolve) => + pane.terminal.write(`\x1b[2J\x1b[3J\x1b[H\x1b[?25l${rows}`, resolve) + ) + pane.terminal.refresh(0, pane.terminal.rows - 1) + }, + { id: tabId, marker, glyphRow } + ) + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + ) +} + +async function resetAtlasOnTab(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + window.__paneManagers?.get(id)?.resetWebglTextureAtlases?.() + }, tabId) +} + +async function injectHiddenStreamingBurst(page: Page, tabId: string, runId: string): Promise { + const marker = `${TAB_SWITCH_MARKER_PREFIX}_${runId}` + await page.evaluate( + ({ tabId, marker }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + // Why: Grok sessions can emit large formatted bursts while the tab is + // hidden; stress the visibility-resume flush path beyond a few lines. + const burst = Array.from({ length: 400 }, (_, frame) => { + const progress = `${'█'.repeat((frame % 16) + 1)}${'░'.repeat(16 - ((frame % 16) + 1))}` + return [ + `hidden_stream frame=${String(frame).padStart(3, '0')} ${marker}`, + `Dimension │ Rating │`, + `status ${frame % 2 === 0 ? 'thinking' : 'streaming'} ${progress}`, + `abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\#@%&*+=~` + ].join('\r\n') + }).join('\r\n') + return new Promise((resolve) => { + pane.terminal.write(`${burst}\r\n`, resolve) + }) + }, + { tabId, marker } + ) +} + +async function readTabTerminalGeometry( + page: Page, + tabId: string, + runId: string +): Promise { + const marker = `${TAB_SWITCH_MARKER_PREFIX}_${runId}` + return page.evaluate( + ({ tabId, marker }) => { + const overlay = document.querySelector( + `[data-terminal-overlay-tab-id="${tabId}"]` + ) + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + const overlayRect = overlay?.getBoundingClientRect() + const screen = pane.container.querySelector('.xterm-screen') + if (!screen) { + throw new Error(`No xterm screen for tab ${tabId}`) + } + const screenRect = screen.getBoundingClientRect() + const cellWidth = pane.terminal._core?._renderService?.dimensions?.css?.cell?.width ?? 0 + const renderedContentWidth = pane.terminal.cols * cellWidth + const rowRight = screenRect.left + renderedContentWidth + const contentWidthRatio = screenRect.width > 0 ? renderedContentWidth / screenRect.width : 0 + const buffer = pane.terminal.buffer.active + let markerPresent = false + for (let row = 0; row < pane.terminal.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '' + if (line.includes(marker)) { + markerPresent = true + break + } + } + const diagnostics = manager?.getRenderingDiagnostics?.() ?? [] + const hasWebgl = diagnostics.some((entry) => entry.hasWebgl) + return { + tabId, + overlayWidth: overlayRect?.width ?? 0, + overlayHeight: overlayRect?.height ?? 0, + overlayDisplay: overlay ? window.getComputedStyle(overlay).display : 'missing', + cols: pane.terminal.cols, + rows: pane.terminal.rows, + cellWidth, + screenWidth: screenRect.width, + screenRight: screenRect.right, + rowRight, + contentWidthRatio, + markerPresent, + hasWebgl + } + }, + { tabId, marker } + ) +} + +function geometryLooksCorrupted(geometry: TabTerminalGeometry): string | null { + if (geometry.overlayDisplay === 'none') { + return 'overlay still display:none after activation' + } + if (geometry.overlayWidth < 200 || geometry.overlayHeight <= 0) { + return `overlay dimensions invalid (${geometry.overlayWidth}x${geometry.overlayHeight}px)` + } + if (geometry.cols < 40 || geometry.rows <= 0) { + return `terminal grid invalid (${geometry.cols}x${geometry.rows})` + } + // Why: half-width bug paints content in only ~50% of the screen; rowRight + // lags far behind screenRight when cols are stale. + if (geometry.contentWidthRatio > 0 && geometry.contentWidthRatio < 0.82) { + return `content width ratio ${geometry.contentWidthRatio.toFixed(3)} < 0.82` + } + if ( + geometry.screenWidth > 0 && + geometry.rowRight < geometry.screenRight - geometry.cellWidth * 4 + ) { + return `rowRight ${geometry.rowRight.toFixed(1)} lags screenRight ${geometry.screenRight.toFixed(1)}` + } + return null +} + +async function captureTabScreenshot( + page: Page, + tabId: string, + testInfo: TestInfo, + label: string +): Promise { + const overlay = page.locator(`[data-terminal-overlay-tab-id="${tabId}"]`) + const path = testInfo.outputPath(`${label}-${tabId}.png`) + await overlay.screenshot({ path }) + await testInfo.attach(`${label}.png`, { path, contentType: 'image/png' }) +} + +test.describe('Terminal tab switch visual restore', () => { + test.describe.configure({ mode: 'serial' }) + + test('keeps full-width geometry after switching away and back', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage) + await forceWebglOnActiveTab(orcaPage) + + const runId = `${Date.now()}` + const marker = `${TAB_SWITCH_MARKER_PREFIX}_${runId}` + const firstPtyId = await waitForPanePtyIdOnTab(orcaPage, firstTabId) + await writeStaticTabContent(orcaPage, firstTabId, marker, TAB_A_GLYPH_ROW) + + const baseline = await readTabTerminalGeometry(orcaPage, firstTabId, runId) + expect(baseline.markerPresent).toBe(true) + expect(baseline.overlayWidth).toBeGreaterThan(300) + expect(geometryLooksCorrupted(baseline)).toBeNull() + + const corruptionReports: string[] = [] + await resetTerminalOutputSchedulerDebug(orcaPage) + await startHiddenPtyOutputBurst(orcaPage, firstPtyId, runId) + + for (let cycle = 0; cycle < 12; cycle += 1) { + await activateTerminalTab(orcaPage, secondTabId) + await injectHiddenStreamingBurst(orcaPage, firstTabId, runId) + // Why: rapid back-to-back switches mirror the user's leave/return pattern + // and race the overlay's rAF/50ms refit retries. + await activateTerminalTab(orcaPage, firstTabId) + if (cycle % 3 === 0) { + await activateTerminalTab(orcaPage, secondTabId) + await activateTerminalTab(orcaPage, firstTabId) + } + + // Sample immediately — bug often shows before the 50ms overlay refit retry. + const immediate = await readTabTerminalGeometry(orcaPage, firstTabId, runId) + const immediateIssue = geometryLooksCorrupted(immediate) + if (immediateIssue) { + corruptionReports.push(`cycle ${cycle} immediate: ${immediateIssue}`) + await captureTabScreenshot( + orcaPage, + firstTabId, + testInfo, + `tab-switch-corrupt-immediate-cycle-${cycle}` + ) + } + + await orcaPage.waitForTimeout(60) + const settled = await readTabTerminalGeometry(orcaPage, firstTabId, runId) + const settledIssue = geometryLooksCorrupted(settled) + if (settledIssue) { + corruptionReports.push(`cycle ${cycle} settled: ${settledIssue}`) + await captureTabScreenshot( + orcaPage, + firstTabId, + testInfo, + `tab-switch-corrupt-settled-cycle-${cycle}` + ) + } + } + const schedulerActivity = await waitForHiddenOutputSchedulerActivity(orcaPage) + expect(schedulerActivity.scheduledDrainCount).toBeGreaterThan(0) + + if (corruptionReports.length > 0) { + console.log('[tab-switch-repro] corruption reports:', corruptionReports) + } + + expect( + corruptionReports, + corruptionReports.length > 0 + ? `tab switch left stale terminal geometry:\n${corruptionReports.join('\n')}` + : undefined + ).toEqual([]) + }) + + test('keeps geometry after hidden alt-screen TUI redraws during tab switches', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage) + await forceWebglOnActiveTab(orcaPage) + + const runId = `${Date.now()}` + const finalMarker = `${TAB_SWITCH_MARKER_PREFIX}_${runId}_ALT_24` + + await orcaPage.evaluate( + ({ tabId, finalMarker }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + const frames = Array.from({ length: 25 }, (_, frame) => { + const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` + return [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + `╭────────────────────────────────────────────────────────────────────╮`, + `│ ${finalMarker} frame ${String(frame).padStart(3, '0')} ${progress} │`, + `│ Dimension │ Rating │`, + `╰────────────────────────────────────────────────────────────────────╯`, + '\x1b[?2026l' + ].join('\r\n') + }).join('') + return new Promise((resolve) => pane.terminal.write(frames, resolve)) + }, + { tabId: firstTabId, finalMarker } + ) + + const corruptionReports: string[] = [] + for (let cycle = 0; cycle < 6; cycle += 1) { + await activateTerminalTab(orcaPage, secondTabId) + await orcaPage.evaluate( + ({ tabId, finalMarker, cycle }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + const frame = cycle * 4 + const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` + const redraw = [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + `╭────────────────────────────────────────────────────────────────────╮`, + `│ ${finalMarker} frame ${String(frame).padStart(3, '0')} ${progress} │`, + `│ Dimension │ Rating │`, + `╰────────────────────────────────────────────────────────────────────╯`, + '\x1b[?2026l' + ].join('\r\n') + return new Promise((resolve) => pane.terminal.write(redraw, resolve)) + }, + { tabId: firstTabId, finalMarker, cycle } + ) + await activateTerminalTab(orcaPage, firstTabId) + + const geometry = await readTabTerminalGeometry(orcaPage, firstTabId, `${runId}_ALT`) + const issue = geometryLooksCorrupted(geometry) + if (issue || !geometry.markerPresent) { + corruptionReports.push( + `cycle ${cycle}: ${issue ?? 'marker missing after alt-screen redraw'}` + ) + await captureTabScreenshot( + orcaPage, + firstTabId, + testInfo, + `alt-screen-corrupt-cycle-${cycle}` + ) + } + } + + expect( + corruptionReports, + corruptionReports.length > 0 + ? `alt-screen hidden redraw left corrupted geometry:\n${corruptionReports.join('\n')}` + : undefined + ).toEqual([]) + }) + + test('restores skipped hidden agent output on light tab resume', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const shellTabId = (await getActiveTabId(orcaPage))! + const agentTabId = await createCodexMarkedTerminalTab(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPanePtyIdOnTab(orcaPage, agentTabId) + const paneIdentity = await readPaneIdentityOnTab(orcaPage, agentTabId) + const paneKey = `${agentTabId}:${paneIdentity.leafId}` + + await activateTerminalTab(orcaPage, shellTabId) + const runId = `${Date.now()}` + const marker = `${TAB_SWITCH_MARKER_PREFIX}_SKIPPED_AGENT_${runId}` + const hiddenFrame = [ + '\x1b[?2026h', + `${marker} hidden renderer frame`, + 'status=streaming while tab-hidden', + '\x1b[?2026l' + ].join('\r\n') + await resetHiddenOutputDebug(orcaPage) + await injectPaneData(orcaPage, paneKey, hiddenFrame, { + seq: hiddenFrame.length, + rawLength: hiddenFrame.length + }) + + await expect + .poll(async () => (await readHiddenOutputDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + timeout: 5_000, + message: 'Codex-marked hidden output did not take the skipped renderer path' + }) + .toBeGreaterThan(0) + await setHiddenSnapshotOverride(orcaPage, paneIdentity.ptyId, { + data: `${marker} restored from main snapshot\r\n`, + cols: paneIdentity.cols, + rows: paneIdentity.rows, + seq: hiddenFrame.length + }) + + await activateTerminalTab(orcaPage, agentTabId) + + await expect + .poll(() => getTerminalContent(orcaPage, 8_000), { + timeout: 10_000, + message: 'light tab resume did not request skipped hidden-output recovery' + }) + .toContain(marker) + }) + + test('restores skipped hidden Grok output on light tab resume', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const shellTabId = (await getActiveTabId(orcaPage))! + const grokTabId = await createGrokMarkedTerminalTab(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPanePtyIdOnTab(orcaPage, grokTabId) + const paneIdentity = await readPaneIdentityOnTab(orcaPage, grokTabId) + const paneKey = `${grokTabId}:${paneIdentity.leafId}` + + await activateTerminalTab(orcaPage, shellTabId) + const runId = `${Date.now()}` + const marker = `${TAB_SWITCH_MARKER_PREFIX}_SKIPPED_GROK_${runId}` + // Why: synchronized-output mode exercises the hidden renderer skip path + // used by agent TUIs before light tab resume requests recovery. + const hiddenFrame = [ + '\x1b[?2026h', + `${marker} hidden renderer frame`, + 'status=streaming while tab-hidden', + '\x1b[?2026l' + ].join('\r\n') + await resetHiddenOutputDebug(orcaPage) + await injectPaneData(orcaPage, paneKey, hiddenFrame, { + seq: hiddenFrame.length, + rawLength: hiddenFrame.length + }) + + await expect + .poll(async () => (await readHiddenOutputDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + timeout: 5_000, + message: 'Grok-marked hidden output did not take the skipped renderer path' + }) + .toBeGreaterThan(0) + await setHiddenSnapshotOverride(orcaPage, paneIdentity.ptyId, { + data: `${marker} restored from main snapshot\r\n`, + cols: paneIdentity.cols, + rows: paneIdentity.rows, + seq: hiddenFrame.length + }) + + await activateTerminalTab(orcaPage, grokTabId) + + await expect + .poll(() => getTerminalContent(orcaPage, 8_000), { + timeout: 10_000, + message: 'light tab resume did not request skipped Grok hidden-output recovery' + }) + .toContain(marker) + }) + + test('keeps returned tab glyphs intact across tab switches', async ({ orcaPage }, testInfo) => { + // Why: screenshot equality catches WebGL atlas corruption on the tab being + // resumed, not just stale cols/rows geometry checks. + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage) + await forceWebglOnActiveTab(orcaPage) + await activateTerminalTab(orcaPage, firstTabId) + const firstWebgl = await waitForWebglOnTab(orcaPage, firstTabId) + await activateTerminalTab(orcaPage, secondTabId) + await orcaPage.evaluate((id) => { + window.__paneManagers?.get(id)?.setTerminalGpuAcceleration?.('on') + }, secondTabId) + const secondWebgl = await waitForWebglOnTab(orcaPage, secondTabId) + if (!firstWebgl || !secondWebgl) { + test.skip(true, 'WebGL never attached on both tabs') + return + } + + const firstPtyId = await waitForPanePtyIdOnTab(orcaPage, firstTabId) + const secondPtyId = await waitForPanePtyIdOnTab(orcaPage, secondTabId) + await sendToTerminal(orcaPage, firstPtyId, SILENT_FOREGROUND_COMMAND) + await sendToTerminal(orcaPage, secondPtyId, SILENT_FOREGROUND_COMMAND) + await orcaPage.waitForTimeout(1_000) + + const runId = `${Date.now()}` + const markerA = `${TAB_SWITCH_MARKER_PREFIX}_A_${runId}` + const markerB = `${TAB_SWITCH_MARKER_PREFIX}_B_${runId}` + await writeStaticTabContent(orcaPage, firstTabId, markerA, TAB_A_GLYPH_ROW) + await activateTerminalTab(orcaPage, secondTabId) + await writeStaticTabContent(orcaPage, secondTabId, markerB, TAB_B_GLYPH_ROW) + + await activateTerminalTab(orcaPage, firstTabId) + await resetAtlasOnTab(orcaPage, firstTabId) + await orcaPage.waitForTimeout(800) + const baseline = await captureStableTabScreenshot(orcaPage, firstTabId) + + const screenshotMismatches: string[] = [] + for (let cycle = 0; cycle < 8; cycle += 1) { + await activateTerminalTab(orcaPage, secondTabId) + // Why: do not write into the hidden tab here — new bytes would change the + // screenshot even when rendering is healthy. This cycle only exercises the + // suspend/resume + atlas reset path on unchanged content. + await activateTerminalTab(orcaPage, firstTabId) + await orcaPage.waitForTimeout(100) + const afterReturn = await captureStableTabScreenshot(orcaPage, firstTabId) + const diff = compareTerminalScreenshots(baseline, afterReturn) + if (!diff.matches) { + screenshotMismatches.push( + `cycle ${cycle}: ${diff.diffPixels} px (${(diff.diffRatio * 100).toFixed(2)}%)` + ) + await testInfo.attach(`after-return-cycle-${cycle}`, { + body: afterReturn, + contentType: 'image/png' + }) + } + } + + await testInfo.attach('baseline', { body: baseline, contentType: 'image/png' }) + expect( + screenshotMismatches, + screenshotMismatches.length > 0 + ? `returned tab glyphs changed after switch cycles: ${screenshotMismatches.join(', ')}` + : undefined + ).toEqual([]) + }) +})