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
This commit is contained in:
Neil
2026-11-06 00:56:00 -08:00
committed by GitHub
parent 95af9000e3
commit f5bdd62f41
2 changed files with 47 additions and 7 deletions
@@ -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)
}
}
@@ -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',