Fix terminal tab switch resume rendering (#6041)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-23 00:23:34 -07:00 committed by GitHub
parent 20a3999968
commit 17dee0861f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1545 additions and 64 deletions

View File

@ -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.

View File

@ -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,

View File

@ -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}

View File

@ -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')

View File

@ -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 ?? '')
)
}

View File

@ -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)
})
})

View File

@ -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<string>()
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 {

View File

@ -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<number, ScrollState>
withSuppressedScrollTracking: (callback: () => void) => void
}
type HideTerminalVisibilityArgs = {
manager: PaneManager
wasVisible: boolean
wasWorktreeActive: boolean
isWorktreeActive: boolean
hasCompletedVisibleResume: boolean
captureViewportPositions: (useRememberedSnapshots: boolean) => Map<number, ScrollState>
}
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<number, ScrollState>
): void {
for (const pane of manager.getPanes()) {
const position = viewportPositions.get(pane.id)
if (position) {
restoreScrollStateAfterLayout(pane.terminal, position)
}
}
}

View File

@ -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' } }]),

View File

@ -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<PaneManager | null>
@ -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<TerminalHiddenReason | null>(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 100500 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) {

View File

@ -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
}
}

View File

@ -0,0 +1,21 @@
import type { Page } from '@stablyai/playwright-test'
import { expect } from './helpers/orca-app'
function tabScreenLocator(page: Page, tabId: string): ReturnType<Page['locator']> {
return page.locator(`[data-terminal-tab-id="${tabId}"] .xterm-screen`).first()
}
export async function captureStableTabScreenshot(page: Page, tabId: string): Promise<Buffer> {
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`)
}

View File

@ -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<void> {
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<string> {
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<string> {
return createAgentMarkedTerminalTab(page, 'codex', 'node -e "setInterval(() => {}, 1000)"')
}
async function createGrokMarkedTerminalTab(page: Page): Promise<string> {
return createAgentMarkedTerminalTab(page, 'grok', 'node -e "setInterval(() => {}, 1000)"')
}
async function activateTerminalTab(page: Page, tabId: string): Promise<void> {
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<boolean> {
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<string> {
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<void> {
await page.evaluate(() => {
;(window as HiddenOutputRecoveryWindow).__terminalPtyOutputDebug?.reset()
})
}
async function readHiddenOutputDebug(page: Page): Promise<HiddenOutputDebugSnapshot | null> {
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<void> {
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<void> {
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<void> {
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<TerminalOutputSchedulerSnapshot> {
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<void> {
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<void> {
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<void>((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<void> {
await page.evaluate((id) => {
window.__paneManagers?.get(id)?.resetWebglTextureAtlases?.()
}, tabId)
}
async function injectHiddenStreamingBurst(page: Page, tabId: string, runId: string): Promise<void> {
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<void>((resolve) => {
pane.terminal.write(`${burst}\r\n`, resolve)
})
},
{ tabId, marker }
)
}
async function readTabTerminalGeometry(
page: Page,
tabId: string,
runId: string
): Promise<TabTerminalGeometry> {
const marker = `${TAB_SWITCH_MARKER_PREFIX}_${runId}`
return page.evaluate(
({ tabId, marker }) => {
const overlay = document.querySelector<HTMLElement>(
`[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<HTMLElement>('.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<void> {
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<void>((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<void>((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([])
})
})