From f5bdd62f41507b2feb8e1a94decc59c55525b001 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:56:00 -0800 Subject: [PATCH] fix(terminal): keep cold-restored scrollback in a tab hidden at relaunch (#38878) * fix(terminal): keep cold-restored scrollback in a tab hidden at relaunch (STA-14063) When the window predeclares its pane serializer, main skipped seeding its host copy of the terminal, including for cold-restore payloads. A tab hidden at relaunch paints the restored scrollback, but when the fresh shell's prompt arrives while it is hidden, revealing the tab repaints from main's host copy, which held only that prompt, so the pane came back blank. Seed the host copy from a cold-restore payload even while the window owns serialization; the window's xterm holds only this same payload, so it has no richer history to prefer. Reattach snapshots and replays keep the old rule. * test(terminal): verify cold restore through recovery snapshot --- .../ipc/pty/pane/terminal-spawn-restore.ts | 22 +++++++++---- .../headless-snapshots.spec.ts | 32 +++++++++++++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/main/ipc/pty/pane/terminal-spawn-restore.ts b/src/main/ipc/pty/pane/terminal-spawn-restore.ts index 64bd819f568..52254555946 100644 --- a/src/main/ipc/pty/pane/terminal-spawn-restore.ts +++ b/src/main/ipc/pty/pane/terminal-spawn-restore.ts @@ -9,15 +9,17 @@ export function seedHeadlessTerminalFromSpawnResult( result: PtySpawnResult, paneKey: string | null ): void { - // Renderer-owned layout prefers its richer history; main-owned layout must seed the host copy every view restores. - if ( - !runtime || - (!ORCA_LAYOUT_OWNER_IS_MAIN && paneKey && pendingByPaneKey.has(paneKey)) || - (result.isReattach === true && rendererSerializerReadiness.has(result.id)) - ) { + if (!runtime || (result.isReattach === true && rendererSerializerReadiness.has(result.id))) { return } + // Renderer-owned layout prefers its richer history; main-owned layout must seed the host copy every view restores. + const rendererOwnsHistory = Boolean( + !ORCA_LAYOUT_OWNER_IS_MAIN && paneKey && pendingByPaneKey.has(paneKey) + ) if (typeof result.snapshot === 'string' && result.snapshot.length > 0) { + if (rendererOwnsHistory) { + return + } const size = typeof result.snapshotCols === 'number' && typeof result.snapshotRows === 'number' ? { cols: result.snapshotCols, rows: result.snapshotRows } @@ -29,6 +31,8 @@ export function seedHeadlessTerminalFromSpawnResult( ...(result.snapshotTerminalOwner ? { terminalOwner: result.snapshotTerminalOwner } : {}) }) } else if ( + // Seed even when the renderer owns history: its xterm holds only this same payload, and a pane + // revealed after output arrived while hidden repaints from the host copy (STA-14063). result.coldRestore && typeof result.coldRestore.scrollback === 'string' && result.coldRestore.scrollback.length > 0 @@ -47,7 +51,11 @@ export function seedHeadlessTerminalFromSpawnResult( oscLinks: result.coldRestore.oscLinks, preferProviderIfExisting: true }) - } else if (typeof result.replay === 'string' && result.replay.length > 0) { + } else if ( + !rendererOwnsHistory && + typeof result.replay === 'string' && + result.replay.length > 0 + ) { runtime.seedHeadlessTerminal(result.id, result.replay) } } diff --git a/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts b/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts index 79f6142e3b9..f25fb288f30 100644 --- a/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts +++ b/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts @@ -4,6 +4,12 @@ import { OrcaRuntimeService } from '../orca-runtime' import { setPlatform } from '../orca-runtime-test-mocks/setup.spec' import type { RuntimeTerminalAgentStatusEvent } from '../terminal/output/terminal-agent-status-events' import type { HeadlessEmulator } from '../../daemon/emulation/headless-emulator' +import { makePaneKey } from '../../../shared/workspace-layout/stable-pane-id' +import { + declarePendingPaneSerializer, + settlePendingPaneSerializer +} from '../../ipc/pty/pane/serializer-state' +import { seedHeadlessTerminalFromSpawnResult } from '../../ipc/pty/pane/terminal-spawn-restore' import { TEST_WORKTREE_ID, @@ -240,6 +246,32 @@ describe('OrcaRuntimeService', () => { expect(runtime.resolveTerminalFileUriHostname(terminal.handle)).toBe('remote-host') }) + it('retains cold-restored history in hidden-output recovery while renderer serialization is pending', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'cold-pty') + const paneKey = makePaneKey('tab-1', '44444444-4444-4444-8444-444444444444') + const generation = declarePendingPaneSerializer(paneKey, undefined) + try { + seedHeadlessTerminalFromSpawnResult( + runtime, + { + id: 'cold-pty', + coldRestore: { scrollback: 'restored history\r\n', cwd: '/tmp', cols: 40, rows: 6 } + }, + paneKey + ) + runtime.onPtyData('cold-pty', 'fresh shell prompt $ ', 100) + + const snapshot = await runtime.serializeHiddenOutputRecoveryBuffer('cold-pty', { + scrollbackRows: 100 + }) + expect(snapshot?.data).toContain('restored history') + expect(snapshot?.data).toContain('fresh shell prompt $ ') + } finally { + settlePendingPaneSerializer(paneKey, generation) + } + }) + it('falls back to the renderer snapshot for hidden-output recovery without headless state', async () => { const serializeBuffer = vi.fn().mockResolvedValue({ data: '\x1b[?1049hRenderer TUI\r\nStill running\r\n',