Fix terminal tab switch resume rendering (#6041)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
20a3999968
commit
17dee0861f
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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' } }]),
|
||||
|
|
|
|||
|
|
@ -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 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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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`)
|
||||
}
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue