From 1226eccf6fa60cb83f76e644b54ede562e21b1e5 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 11 Nov 2026 09:07:46 -0800 Subject: [PATCH 01/11] =?UTF-8?q?feat(cloud):=20WAKE-SCROLLBACK=20a=20woke?= =?UTF-8?q?n=20cloud=20terminal=20keeps=20its=20pre-sleep=20output=20above?= =?UTF-8?q?=20a=20"Slept=20=E2=80=A6=20=C2=B7=20processes=20stopped"=20div?= =?UTF-8?q?ider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sleep reboots the VM; the woken pane attaches to a new terminal whose snapshot replay erases scrollback (`\x1b[3J`), so everything the pane showed was gone. - Capture (asleep/waking, before the new terminal attaches): the pane's normal buffer rendered from its cells as text + SGR only (never its escape stream), capped at terminalScrollbackRows. Shell panes keep their visible rows too; agent / full-screen panes (the #451 rule) keep only the scrollback above, so no frozen TUI frame comes back. No live buffer (never mounted, restarted while asleep): the plain-text read-only copy saved at sleep, uncoloured; none at all: nothing restored, nothing said. - Restore (once the workspace left "asleep"; the woken terminal's snapshot drain): after the snapshot paints, a client-side rewrite of restored output, the dim divider, then the woken screen, cursor put back with relative moves. Nothing reaches the PTY (replay writer, replay guard); a final sanitizer keeps only text and CSI…m (OSC 52/0, DECSET, bracketed paste, cursor moves dropped). Pinned to the bottom unless the person had scrolled up. One divider per sleep. - The kept output is dropped once restored, or when its pane / tab / workspace goes away. --- .../offline/CloudWakeScrollback.fixture.tsx | 127 ++++++++++ .../output/replay-data-drain.ts | 18 ++ .../recovery/TerminalPaneRecoveryPortals.tsx | 43 +++- src/renderer/src/i18n/locales/en.json | 3 +- .../lib/orca-cloud/cloud-wake-pane-capture.ts | 55 +++++ .../cloud-wake-process-hints.test.ts | 20 +- .../orca-cloud/cloud-wake-process-hints.ts | 16 ++ .../cloud-wake-scrollback-render.test.ts | 56 +++++ .../cloud-wake-scrollback-render.ts | 224 +++++++++++++++++ .../orca-cloud/cloud-wake-scrollback.test.ts | 210 ++++++++++++++++ .../lib/orca-cloud/cloud-wake-scrollback.ts | 225 ++++++++++++++++++ 11 files changed, 985 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/components/orca-cloud/offline/CloudWakeScrollback.fixture.tsx create mode 100644 src/renderer/src/lib/orca-cloud/cloud-wake-pane-capture.ts create mode 100644 src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.test.ts create mode 100644 src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.ts create mode 100644 src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts create mode 100644 src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts diff --git a/src/renderer/src/components/orca-cloud/offline/CloudWakeScrollback.fixture.tsx b/src/renderer/src/components/orca-cloud/offline/CloudWakeScrollback.fixture.tsx new file mode 100644 index 00000000000..e5728700cf2 --- /dev/null +++ b/src/renderer/src/components/orca-cloud/offline/CloudWakeScrollback.fixture.tsx @@ -0,0 +1,127 @@ +// WAKE-SCROLLBACK: a real xterm after Sleep → Wake. Each fixture writes the pre-sleep output, runs +// the actual capture, the woken terminal's snapshot (it clears the pane) and the restore after it, +// so what shows is what the pane shows. +import React, { useEffect, useRef } from 'react' +import { Terminal } from '@xterm/xterm' +import '@xterm/xterm/css/xterm.css' +import { defineFixture } from '../../../component-fixtures/component-fixture' +import { + armWakeScrollback, + beginWakeScrollbackRestore, + captureWakeScrollback, + finishWakeScrollbackRestore +} from '@/lib/orca-cloud/cloud-wake-scrollback' + +const SLEPT_AT = new Date(2026, 10, 11, 14, 2).getTime() +const WOKE_AT = new Date(2026, 10, 11, 14, 31).getTime() +const SNAPSHOT_CLEAR = '\x1b[2J\x1b[3J\x1b[H' + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +type Sleep = { before: string; agentPane?: boolean; after: string; sleptAt?: number } + +function WokeTerminal({ id, sleeps }: { id: string; sleeps: Sleep[] }): React.JSX.Element { + const host = useRef(null) + useEffect(() => { + if (!host.current) { + return + } + const dark = document.documentElement.classList.contains('dark') + const terminal = new Terminal({ + cols: 72, + rows: 12, + fontSize: 12, + fontFamily: 'Menlo, monospace', + theme: dark + ? { background: '#0a0a0a', foreground: '#e5e5e5', cursor: '#e5e5e5' } + : { background: '#ffffff', foreground: '#1f2328', cursor: '#1f2328' } + }) + terminal.open(host.current) + void (async () => { + for (const [index, sleep] of sleeps.entries()) { + await write(terminal, sleep.before) + captureWakeScrollback(id, terminal, { + sleptAt: sleep.sleptAt ?? SLEPT_AT + index * 7_200_000, + agentPane: sleep.agentPane ?? false, + maxRows: 5000 + }) + armWakeScrollback([id]) + const restore = beginWakeScrollbackRestore(id, terminal, WOKE_AT + index * 7_200_000) + await write(terminal, `${SNAPSHOT_CLEAR}${sleep.after}`) + if (restore) { + await finishWakeScrollbackRestore(restore, terminal, (data) => write(terminal, data)) + } + } + terminal.scrollToBottom() + })() + return () => terminal.dispose() + }, [id, sleeps]) + return ( +
+
+
+ ) +} + +const PROMPT = '\x1b[32mubuntu@sbx\x1b[0m:\x1b[34m~/hello-world\x1b[0m$ ' +const testRun = [ + `${PROMPT}pnpm test`, + '', + ' \x1b[32m✓\x1b[0m src/util.test.ts \x1b[2m(4 tests)\x1b[0m', + ' \x1b[32m✓\x1b[0m src/logging.test.ts \x1b[2m(2 tests)\x1b[0m', + ' \x1b[31m✗\x1b[0m src/server.test.ts \x1b[2m(1 failed)\x1b[0m', + '', + ' \x1b[1mTest Files\x1b[0m \x1b[31m1 failed\x1b[0m | \x1b[32m2 passed\x1b[0m', + `${PROMPT}sleep 900`, + '' +].join('\r\n') + +/** A shell pane: its output (in its own colours) above the divider, the woken prompt below. */ +export const shellPaneRestored = defineFixture({ + width: 720, + render: () => +}) + +/** Two sleeps: one divider each, in order. */ +export const twoSleeps = defineFixture({ + width: 720, + render: () => ( + + ) +}) + +/** An agent pane: only the scrollback above Claude's screen; never its frozen input box. */ +export const agentPaneScrollbackOnly = defineFixture({ + width: 720, + render: () => ( + ''), + '╭──────────────────────────────────────────╮', + '│ > Try "create a util logging.py that..." │', + '╰──────────────────────────────────────────╯', + ' ? for shortcuts' + ].join('\r\n'), + agentPane: true, + after: '\x1b[2m(Claude relaunching…)\x1b[0m' + } + ]} + /> + ) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts index 60d4e7fb19c..8ca41f758c7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts @@ -19,6 +19,11 @@ import { } from '../reattach/cursor-agent-reattach-screen' import type { ConnectPanePtySession } from '../connect-pane-pty-session' +import { + beginWakeScrollbackRestore, + finishWakeScrollbackRestore, + type WakeScrollbackRestore +} from '@/lib/orca-cloud/cloud-wake-scrollback' export function bindReplayDataDrain(session: ConnectPanePtySession): void { session.sendFocusedReattachFocusInAfterReplay = ( @@ -110,7 +115,10 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { // Relay replay may overlap painted output; eager replay decides earlier so metadata-only frames preserve saved scrollback. // Clear before source-grid resize to avoid reflowing history that replay immediately discards. let replayPayload = data + let preSleep: WakeScrollbackRestore | null = null if (clearBeforeReplay) { + // WAKE-SCROLLBACK: a woken cloud pane's pre-sleep output, put back once this snapshot painted. + preSleep = beginWakeScrollbackRestore(session.pane.leafId, session.pane.terminal) if (carriesNormalBuffer) { // Wait for queued alt-screen transitions before choosing replay’s target buffer. await waitForTerminalOutputParsed(session.pane.terminal) @@ -161,6 +169,16 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { if (!isCurrentPayload()) { continue } + if (preSleep) { + await finishWakeScrollbackRestore( + preSleep, + session.pane.terminal, + session.writeReplayDataAsync + ) + if (!isCurrentPayload()) { + continue + } + } if (clearBeforeReplay || data.length > 0) { await session.writeReplayEpilogue( session.chooseReattachReplayReset(data, false, alternateScreen, terminalOwner, lastTitle), diff --git a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx index 8fc1ec46fd0..cbf249729b3 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx +++ b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import type { PtyTransport } from '../../pty-transport-types' import { useWslHostUnavailableReason } from '../../../../../runtime/hosts/wsl/wsl-host-availability' @@ -23,8 +23,6 @@ import { CloudAsleepTerminalNotice } from '@/components/orca-cloud/offline/Cloud import { CloudWokeProcessesNotice } from '@/components/orca-cloud/offline/CloudWokeProcessesNotice' import { CloudWokeAgentNotice } from '@/components/orca-cloud/offline/CloudWokeAgentNotice' import { - captureWakeProcessHint, - cursorBelowLastText, dismissWakeProcessHint, endsAtShellPrompt, getWakeCaptureTime, @@ -39,6 +37,9 @@ import { paneShowsWslHostStoppedCard } from '@/runtime/hosts/wsl/wsl-host-stoppe import { withoutWslHostStoppedLines } from '../../../../../../../shared/wsl-host-stopped-message' import { makePaneKey } from '../../../../../../../shared/workspace-layout/stable-pane-id' import { cloudWokeActionFor, runCloudWokeAction } from '@/lib/orca-cloud/cloud-wake-hint-action' +import { captureCloudPaneBeforeWake } from '@/lib/orca-cloud/cloud-wake-pane-capture' +import { armWakeScrollback, forgetWakeScrollback } from '@/lib/orca-cloud/cloud-wake-scrollback' +import { restartPaneExists } from '@/lib/orca-cloud/cloud-agent-restart-pane-watch' import { mapPaneTerminalErrors } from '../../../errors/terminal-error-accumulation' import type { TerminalPaneController } from '../../../controller/use-terminal-pane-controller' @@ -139,24 +140,46 @@ export function TerminalPaneRecoveryPortals({ // While asleep (or waking) the pane still holds the pre-sleep scrollback: note what was running, // so after the wake the pane can say it stopped and offer it back (JA-15). const isCloud = cloud !== null + const asleepSince = cloud?.asleepSince ?? null useEffect(() => { if (!isCloud) { return } if (cloudAsleep || cloudWaking) { - const agentPanes = useAppStore.getState().agentStatusByPaneKey for (const pane of managedPanes) { - // An agent TUI or full-screen app's last line is its own input, not a shell command. - captureWakeProcessHint(pane.leafId, readTerminalTail(pane.terminal), { - agentType: agentPanes[makePaneKey(tabId, pane.leafId)]?.agentType ?? null, - alternateScreen: pane.terminal.buffer.active.type === 'alternate', - cursorBelowText: cursorBelowLastText(pane.terminal) + // The Rerun hint and the pre-sleep output (WAKE-SCROLLBACK), before the new terminal attaches. + captureCloudPaneBeforeWake({ + tabId, + leafId: pane.leafId, + worktreeId: controller.worktreeId, + terminal: pane.terminal, + asleepSince }) } } else { resetWakeProcessCapture(managedPanes.map((pane) => pane.leafId)) } - }, [isCloud, cloudAsleep, cloudWaking, managedPanes, tabId]) + if (!cloudAsleep) { + // Waking or live: the next clearing replay is the woken terminal's. + armWakeScrollback(managedPanes.map((pane) => pane.leafId)) + } + }, [isCloud, cloudAsleep, cloudWaking, managedPanes, tabId, controller.worktreeId, asleepSince]) + // A pane closed, or its tab or workspace went away: drop its kept pre-sleep output. + const leafIdsRef = useRef<(typeof managedPanes)[number]['leafId'][]>([]) + useEffect(() => { + const now = managedPanes.map((pane) => pane.leafId) + forgetWakeScrollback(leafIdsRef.current.filter((id) => !now.includes(id))) + leafIdsRef.current = now + }, [managedPanes]) + useEffect( + () => () => { + const state = useAppStore.getState() + forgetWakeScrollback( + leafIdsRef.current.filter((id) => !restartPaneExists(state, makePaneKey(tabId, id))) + ) + }, + [tabId] + ) const isolated = controller.isolatedPaneKey !== null // Why: a pane waiting for SSH sign-in says so itself; "reconnecting" over it would be false. const awaitingSignInHere = managedPanes.some( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a893118aadb..76fcc8ddc41 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -27164,7 +27164,8 @@ "asleepSince": "Asleep since {{time}}", "rerun": "Rerun {{command}}", "rerunLabel": "Rerun", - "wokeProcessesStopped": "Woke up · processes stopped while asleep" + "wokeProcessesStopped": "Woke up · processes stopped while asleep", + "sleptDivider": "Slept {{from}}–{{to}} · processes stopped" }, "archivedDetailLocalEdits_one": "It goes to sleep and stays browsable: PRs, last changes and files. {{count}} changed file on this computer will be removed.", "archivedDetailLocalEdits_other": "It goes to sleep and stays browsable: PRs, last changes and files. {{count}} changed files on this computer will be removed.", diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-pane-capture.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-pane-capture.ts new file mode 100644 index 00000000000..d8ec1b46f18 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-pane-capture.ts @@ -0,0 +1,55 @@ +// What a cloud terminal pane notes while its workspace is asleep (or waking), before the woken +// terminal attaches: the Rerun hint (JA-15 / WAKE-RERUN) and its pre-sleep output (WAKE-SCROLLBACK). +import type { Terminal } from '@xterm/xterm' +import { useAppStore } from '@/store' +import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' +import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id' +import { normalizeDesktopTerminalScrollbackRows } from '../../../../shared/terminal/replay/terminal-scrollback-policy' +import { + captureWakeProcessHint, + cursorBelowLastText, + readTerminalTail, + scrollbackPaneKind, + type WakePaneFacts +} from './cloud-wake-process-hints' +import { captureWakeScrollback } from './cloud-wake-scrollback' + +/** The plain-text read-only copy saved at sleep for the pane's pre-sleep terminal, if any. */ +async function readOnlyCopyLines( + workspaceId: string, + ptyId: string | null +): Promise { + const handle = ptyId ? parseRemoteRuntimePtyId(ptyId)?.handle : null + if (!handle) { + return null + } + const snapshot = await window.api.orcaCloudOffline.getSnapshot({ workspaceId }) + return snapshot?.terminals.find((terminal) => terminal.handle === handle)?.lines ?? null +} + +export function captureCloudPaneBeforeWake(args: { + tabId: string + leafId: string + worktreeId: string + terminal: Terminal + /** When the workspace went to sleep, or null when unknown. */ + asleepSince: number | null +}): void { + const { tabId, leafId, worktreeId, terminal } = args + const state = useAppStore.getState() + const lines = readTerminalTail(terminal) + const facts: WakePaneFacts = { + agentType: state.agentStatusByPaneKey[makePaneKey(tabId, leafId)]?.agentType ?? null, + alternateScreen: terminal.buffer.active.type === 'alternate', + cursorBelowText: cursorBelowLastText(terminal) + } + captureWakeProcessHint(leafId, lines, facts) + const agentPane = scrollbackPaneKind(lines, facts) === 'agent' + const ptyId = state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] ?? null + captureWakeScrollback(leafId, terminal, { + sleptAt: args.asleepSince, + agentPane, + maxRows: normalizeDesktopTerminalScrollbackRows(state.settings?.terminalScrollbackRows), + readOnlyCopy: () => readOnlyCopyLines(worktreeId, ptyId) + }) +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-process-hints.test.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-process-hints.test.ts index 18c8f21e8fe..b6490bbb3f8 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-process-hints.test.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-process-hints.test.ts @@ -11,7 +11,8 @@ import { wakeHintForPane, readTerminalTail, resetWakeProcessCapture, - resetWakeProcessHintsForTests + resetWakeProcessHintsForTests, + scrollbackPaneKind } from './cloud-wake-process-hints' afterEach(() => resetWakeProcessHintsForTests()) @@ -238,3 +239,20 @@ describe('wake process hints', () => { expect(readTerminalTail(terminal, 2)).toEqual(['b', 'c']) }) }) + +describe('scrollbackPaneKind (WAKE-SCROLLBACK)', () => { + const claudeScreen = ['$ claude', '────────', '> Try "fix lint"', '────────', ' ? for shortcuts'] + + it('an agent still on screen, or a full-screen app, restores only its scrollback', () => { + expect(scrollbackPaneKind(claudeScreen, { agentType: 'claude' })).toBe('agent') + expect(scrollbackPaneKind(claudeScreen, { agentType: 'codex' })).toBe('agent') + expect(scrollbackPaneKind(['$ vim notes.md'], { alternateScreen: true })).toBe('agent') + }) + + it('a shell pane (no agent, or the agent quit back to its prompt) restores its visible rows too', () => { + expect(scrollbackPaneKind(['$ seq 1 3', '1', '2', '3', '$ '], {})).toBe('shell') + expect(scrollbackPaneKind(['$ claude', 'bye', 'ubuntu@sbx:~$ '], { agentType: 'claude' })).toBe( + 'shell' + ) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-process-hints.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-process-hints.ts index bb774639d13..d544feb5c9e 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-process-hints.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-process-hints.ts @@ -300,3 +300,19 @@ export function resetWakeProcessHintsForTests(): void { withHistory.clear() version = 0 } + +/** + * WAKE-SCROLLBACK: an agent pane (the same rule as the woke-up strip, #451: a Claude/Codex pane not + * back at its shell) or a full-screen app restores only the scrollback above its screen; a shell + * pane restores its visible rows too. + */ +export function scrollbackPaneKind( + lines: readonly string[], + facts: WakePaneFacts +): 'shell' | 'agent' { + if (facts.alternateScreen) { + return 'agent' + } + const hint = wakeHintForPane(lines, facts) + return hint !== null && 'restartAgent' in hint ? 'agent' : 'shell' +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.test.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.test.ts new file mode 100644 index 00000000000..72df31d3b0f --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.test.ts @@ -0,0 +1,56 @@ +import { Terminal } from '@xterm/headless' +import { describe, expect, it } from 'vitest' +import { renderBufferRows, sanitizeRestoredText } from './cloud-wake-scrollback-render' + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +describe('sanitizeRestoredText (WAKE-SCROLLBACK)', () => { + it('keeps text and SGR, and drops every other escape', () => { + const dirty = [ + 'plain ', + '\x1b[1;31mred\x1b[0m ', + '\x1b]52;c;ZXZpbA==\x07', // OSC 52 clipboard write (BEL) + '\x1b]52;c;ZXZpbA==\x1b\\', // OSC 52 (ST) + '\x1b]0;window title\x07', // OSC 0 title + '\x1b[200~pasted\x1b[201~', // bracketed paste markers + '\x1b[5A\x1b[10;3H\x1b[2C\x1b[K\x1b[2J', // cursor moves and erases + '\x1b[?1049h\x1b[?2004h\x1b[?25l', // DECSET + '\x1b(0\x1b=\x1b7\x1bc', // charset, keypad, DECSC, RIS + '\x1bPq#0;2;0;0;0\x1b\\', // DCS (sixel) + '\x9b31m\x9d0;t\x07', // C1 CSI / OSC + 'end\x07\x08\r\n' + ].join('') + expect(sanitizeRestoredText(dirty)).toBe('plain \x1b[1;31mred\x1b[0m pastedend\r\n') + }) + + it('keeps 256-colour and truecolour SGR', () => { + expect(sanitizeRestoredText('\x1b[38;5;208mo\x1b[48;2;1;2;3mb\x1b[m')).toBe( + '\x1b[38;5;208mo\x1b[48;2;1;2;3mb\x1b[m' + ) + }) +}) + +describe('renderBufferRows (WAKE-SCROLLBACK)', () => { + it('renders text with its colours and attributes, joins wrapped rows, trims padding', async () => { + const terminal = new Terminal({ cols: 10, rows: 4, allowProposedApi: true }) + await write(terminal, '\x1b[1;31mbold red\x1b[0m\r\nabcdefghijKLM\r\nplain ') + const out = renderBufferRows(terminal.buffer.normal, 0, 4) + expect(out).toBe('\x1b[0;1;31mbold red\x1b[0m\r\nabcdefghijKLM\r\nplain') + expect(sanitizeRestoredText(out)).toBe(out) + }) + + it('round-trips through a fresh terminal with the same cells', async () => { + const source = new Terminal({ cols: 20, rows: 3, allowProposedApi: true }) + await write(source, 'a \x1b[38;5;208mo\x1b[48;2;1;2;3mx\x1b[0m \x1b[7mi\x1b[0m') + const target = new Terminal({ cols: 20, rows: 3, allowProposedApi: true }) + await write(target, renderBufferRows(source.buffer.normal, 0, 1)) + const cell = (terminal: Terminal, x: number) => terminal.buffer.normal.getLine(0)!.getCell(x)! + for (const x of [2, 3, 5]) { + expect(cell(target, x).getFgColor()).toBe(cell(source, x).getFgColor()) + expect(cell(target, x).getBgColor()).toBe(cell(source, x).getBgColor()) + expect(cell(target, x).isInverse()).toBe(cell(source, x).isInverse()) + } + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.ts new file mode 100644 index 00000000000..e587684a542 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.ts @@ -0,0 +1,224 @@ +// WAKE-SCROLLBACK: a pane's pre-sleep output as rendered text and attributes only. Built from the +// xterm buffer's cells (never its escape stream), so it carries text and SGR (`CSI … m`) and +// nothing else: no OSC (52 clipboard, 0 title), no modes, no cursor moves. `sanitizeRestoredText` +// enforces that again on whatever is written back, including the plain-text read-only copy. + +/** The parts of an xterm buffer cell the renderer reads. */ +export type RestoreCell = { + getChars(): string + getWidth(): number + isBold(): number + isDim(): number + isItalic(): number + isUnderline(): number + isBlink(): number + isInverse(): number + isInvisible(): number + isStrikethrough(): number + isFgDefault(): boolean + isFgPalette(): boolean + isFgRGB(): boolean + getFgColor(): number + isBgDefault(): boolean + isBgPalette(): boolean + isBgRGB(): boolean + getBgColor(): number +} + +export type RestoreLine = { + isWrapped: boolean + length: number + getCell(x: number): RestoreCell | undefined +} + +export type RestoreBuffer = { + length: number + getLine(y: number): RestoreLine | undefined +} + +function colorParams( + kind: 'fg' | 'bg', + isDefault: boolean, + isPalette: boolean, + isRgb: boolean, + color: number +): string[] { + if (isDefault) { + return [] + } + const base = kind === 'fg' ? 30 : 40 + if (isPalette) { + if (color < 8) { + return [String(base + color)] + } + if (color < 16) { + return [String(base + 60 + color - 8)] + } + return [String(base + 8), '5', String(color)] + } + if (isRgb) { + return [ + String(base + 8), + '2', + String((color >> 16) & 255), + String((color >> 8) & 255), + String(color & 255) + ] + } + return [] +} + +/** The SGR parameters for a cell's attributes (empty for the default pen). */ +export function cellSgrParams(cell: RestoreCell): string[] { + const params: string[] = [] + const flags: [number, string][] = [ + [cell.isBold(), '1'], + [cell.isDim(), '2'], + [cell.isItalic(), '3'], + [cell.isUnderline(), '4'], + [cell.isBlink(), '5'], + [cell.isInverse(), '7'], + [cell.isInvisible(), '8'], + [cell.isStrikethrough(), '9'] + ] + for (const [on, code] of flags) { + if (on) { + params.push(code) + } + } + params.push( + ...colorParams('fg', cell.isFgDefault(), cell.isFgPalette(), cell.isFgRGB(), cell.getFgColor()), + ...colorParams('bg', cell.isBgDefault(), cell.isBgPalette(), cell.isBgRGB(), cell.getBgColor()) + ) + return params +} + +function renderLine(line: RestoreLine): string { + let out = '' + let pen = '' + // Trailing default-pen blanks are padding, not output. + let keep = 0 + const cells: { chars: string; pen: string }[] = [] + for (let x = 0; x < line.length; x++) { + const cell = line.getCell(x) + if (!cell || cell.getWidth() === 0) { + continue + } + const cellPen = cellSgrParams(cell).join(';') + const chars = cell.getChars() || ' ' + cells.push({ chars, pen: cellPen }) + if (chars !== ' ' || cellPen !== '') { + keep = cells.length + } + } + for (const cell of cells.slice(0, keep)) { + if (cell.pen !== pen) { + out += cell.pen === '' ? '\x1b[0m' : `\x1b[0;${cell.pen}m` + pen = cell.pen + } + out += cell.chars + } + return pen === '' ? out : `${out}\x1b[0m` +} + +/** Rows `[from, to)` of a buffer as text + SGR; wrapped rows join their line, others end in CRLF. */ +export function renderBufferRows(buffer: RestoreBuffer, from: number, to: number): string { + let out = '' + for (let y = Math.max(0, from); y < Math.min(to, buffer.length); y++) { + const line = buffer.getLine(y) + if (!line) { + continue + } + if (y > from && !line.isWrapped) { + out += '\r\n' + } + out += renderLine(line) + } + return out +} + +const ESC = '\x1b' +const BEL = '\x07' + +/** A CSI body starting at `start` (after ESC [ or C1 CSI): its end index and whether it is SGR. */ +function readCsi(text: string, start: number): { end: number; sgr: boolean } { + let end = start + while (end < text.length && /[0-?]/.test(text[end]!)) { + end++ + } + while (end < text.length && /[ -/]/.test(text[end]!)) { + end++ + } + return { end, sgr: text[end] === 'm' && /^[0-9;:]*$/.test(text.slice(start, end)) } +} + +/** Index just past a string control (OSC/DCS/SOS/PM/APC) starting at `start`, or the end. */ +function skipStringControl(text: string, start: number): number { + for (let index = start; index < text.length; index++) { + if (text[index] === BEL) { + return index + 1 + } + if (text[index] === ESC && text[index + 1] === '\\') { + return index + 2 + } + } + return text.length +} + +/** + * Text and SGR only: every other escape (OSC 52 / OSC 0, DECSET, bracketed paste, cursor moves, + * erases, charset and keypad modes) and every C0/C1 control but tab, CR and LF is dropped. + */ +export function sanitizeRestoredText(text: string): string { + let out = '' + let index = 0 + while (index < text.length) { + const char = text[index]! + const code = char.charCodeAt(0) + if (char === ESC) { + const next = text[index + 1] + if (next === '[') { + const csi = readCsi(text, index + 2) + if (csi.sgr) { + out += text.slice(index, csi.end + 1) + } + index = csi.end + 1 + continue + } + if (next === ']' || next === 'P' || next === 'X' || next === '^' || next === '_') { + index = skipStringControl(text, index + 2) + continue + } + // ESC + intermediates + final (charset designations, keypad modes, RIS, DECSC/DECRC). + let end = index + 1 + while (end < text.length && /[ -/]/.test(text[end]!)) { + end++ + } + index = end + 1 + continue + } + if ((code < 0x20 && char !== '\t' && char !== '\r' && char !== '\n') || code === 0x7f) { + index++ + continue + } + if (code >= 0x80 && code <= 0x9f) { + // C1 controls, dropped with their sequences: CSI (0x9b), and OSC/DCS/SOS/PM/APC strings. + if (code === 0x9b) { + index = readCsi(text, index + 1).end + 1 + } else if ([0x90, 0x98, 0x9d, 0x9e, 0x9f].includes(code)) { + index = skipStringControl(text, index + 1) + } else { + index++ + } + continue + } + out += char + index++ + } + return out +} + +/** "14:02" in the person's locale. */ +export function formatClock(at: number, locale?: string): string { + return new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(at) +} diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts new file mode 100644 index 00000000000..8d05bbb3777 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts @@ -0,0 +1,210 @@ +import { Terminal } from '@xterm/headless' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + armWakeScrollback, + beginWakeScrollbackRestore, + captureWakeScrollback, + finishWakeScrollbackRestore, + forgetWakeScrollback, + resetWakeScrollbackForTests +} from './cloud-wake-scrollback' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, params?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (_slot: string, name: string) => params?.[name] ?? '') +})) + +afterEach(() => resetWakeScrollbackForTests()) + +const SLEPT_AT = new Date(2026, 10, 11, 14, 2).getTime() +const DIVIDER = /^── Slept .+–.+ · processes stopped ──$/ + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +function pane(rows = 10): Terminal { + return new Terminal({ cols: 60, rows, scrollback: 5000, allowProposedApi: true }) +} + +function text(terminal: Terminal): string[] { + const buffer = terminal.buffer.normal + const out: string[] = [] + for (let y = 0; y < buffer.length; y++) { + out.push(buffer.getLine(y)!.translateToString(true).trimEnd()) + } + while (out.length > 0 && out.at(-1) === '') { + out.pop() + } + return out +} + +function cursorRowText(terminal: Terminal): string { + const active = terminal.buffer.active + return active + .getLine(active.baseY + active.cursorY)! + .translateToString(true) + .trimEnd() +} + +/** What the drain does with the woken terminal's snapshot: begin, the snapshot (it clears), finish. */ +async function wake(terminal: Terminal, leafId: string, newScreen: string): Promise { + const restore = beginWakeScrollbackRestore(leafId, terminal) + await write(terminal, `\x1b[2J\x1b[3J\x1b[H${newScreen}`) + if (!restore) { + return false + } + return finishWakeScrollbackRestore(restore, terminal, (data) => write(terminal, data)) +} + +function capture(terminal: Terminal, leafId: string, agentPane = false): void { + captureWakeScrollback(leafId, terminal, { sleptAt: SLEPT_AT, agentPane, maxRows: 5000 }) +} + +describe('WAKE-SCROLLBACK', () => { + it('a shell pane: `seq 1 2000` above the divider, the woken prompt right below, pinned to the bottom', async () => { + const terminal = pane() + const seq = Array.from({ length: 2000 }, (_, index) => String(index + 1)).join('\r\n') + await write(terminal, `ubuntu@sbx:~$ seq 1 2000\r\n${seq}\r\nubuntu@sbx:~$ sleep 900\r\n`) + capture(terminal, 'leaf-1') + armWakeScrollback(['leaf-1']) + expect(await wake(terminal, 'leaf-1', 'ubuntu@sbx:~$ ')).toBe(true) + const lines = text(terminal) + expect(lines[0]).toBe('ubuntu@sbx:~$ seq 1 2000') + expect(lines[1]).toBe('1') + expect(lines.at(-3)).toBe('ubuntu@sbx:~$ sleep 900') + expect(lines.at(-2)).toMatch(DIVIDER) + expect(lines.at(-1)).toBe('ubuntu@sbx:~$') + const active = terminal.buffer.active + expect(active.viewportY).toBe(active.baseY) + // The cursor is where the woken shell left it: after its prompt. + expect(cursorRowText(terminal)).toBe('ubuntu@sbx:~$') + expect(active.cursorX).toBe('ubuntu@sbx:~$ '.length) + }) + + it('a woken screen with rows below the cursor keeps them, and the cursor goes back to its row', async () => { + const terminal = pane(8) + await write(terminal, '$ ls\r\nREADME\r\n$ ') + capture(terminal, 'leaf-1') + armWakeScrollback(['leaf-1']) + await wake(terminal, 'leaf-1', '$ \x1b[3;1Hstatus line\x1b[1;3H') + const lines = text(terminal) + expect(lines.at(-4)).toMatch(DIVIDER) + expect(lines.slice(-3)).toEqual(['$', '', 'status line']) + expect(cursorRowText(terminal)).toBe('$') + expect(terminal.buffer.active.cursorX).toBe(2) + }) + + it('keeps colours (rendered attributes), never escapes like OSC 52 the pane received', async () => { + const terminal = pane(4) + await write(terminal, '\x1b]52;c;ZXZpbA==\x07\x1b[32mgreen ok\x1b[0m\r\n$ ') + capture(terminal, 'leaf-1') + armWakeScrollback(['leaf-1']) + const restore = beginWakeScrollbackRestore('leaf-1', terminal)! + expect(restore.block).not.toContain('\x1b]') + expect(restore.block).toContain('\x1b[0;32mgreen ok') + }) + + it('an agent pane keeps only the scrollback above its screen, never the frozen TUI frame', async () => { + const terminal = pane(5) + const earlier = Array.from({ length: 8 }, (_, index) => `earlier ${index + 1}`).join('\r\n') + await write(terminal, `${earlier}\r\n`) + await write(terminal, '╭────────╮\r\n│ > Try │\r\n╰────────╯\r\n ? for shortcuts') + capture(terminal, 'leaf-a', true) + armWakeScrollback(['leaf-a']) + const block = beginWakeScrollbackRestore('leaf-a', terminal)!.block + expect(block).toContain('earlier 1') + expect(block).not.toContain('> Try') + expect(block).not.toContain('for shortcuts') + }) + + it('repeated sleeps: one divider per sleep, in order', async () => { + const terminal = pane(6) + await write(terminal, '$ echo one\r\none\r\n$ ') + capture(terminal, 'leaf-1') + armWakeScrollback(['leaf-1']) + await wake(terminal, 'leaf-1', '$ ') + await write(terminal, 'echo two\r\ntwo\r\n$ ') + captureWakeScrollback('leaf-1', terminal, { + sleptAt: SLEPT_AT + 3_600_000, + agentPane: false, + maxRows: 5000 + }) + armWakeScrollback(['leaf-1']) + await wake(terminal, 'leaf-1', '$ ') + const lines = text(terminal) + const dividers = lines.flatMap((line, index) => (DIVIDER.test(line) ? [index] : [])) + expect(dividers).toHaveLength(2) + expect(lines.indexOf('one')).toBeLessThan(dividers[0]!) + expect(lines.indexOf('two')).toBeGreaterThan(dividers[0]!) + expect(lines.indexOf('two')).toBeLessThan(dividers[1]!) + }) + + it('a replay before the workspace leaves "asleep" (a cold restore) does not take it', async () => { + const terminal = pane(4) + await write(terminal, '$ ls\r\nREADME\r\n$ ') + capture(terminal, 'leaf-1') + expect(beginWakeScrollbackRestore('leaf-1', terminal)).toBeNull() + armWakeScrollback(['leaf-1']) + expect(beginWakeScrollbackRestore('leaf-1', terminal)?.block).toContain('README') + // Done: taken once. + expect(beginWakeScrollbackRestore('leaf-1', terminal)).toBeNull() + }) + + it('no live buffer (never mounted / restarted while asleep): the plain read-only copy, uncoloured', async () => { + const terminal = pane(6) + const readOnlyCopy = vi.fn(async () => ['$ npm test', 'ok 12 tests', '$ ']) + captureWakeScrollback('leaf-1', terminal, { + sleptAt: SLEPT_AT, + agentPane: false, + maxRows: 5000, + readOnlyCopy + }) + await vi.waitFor(() => expect(readOnlyCopy).toHaveBeenCalled()) + await Promise.resolve() + armWakeScrollback(['leaf-1']) + expect(await wake(terminal, 'leaf-1', '$ ')).toBe(true) + const lines = text(terminal) + expect(lines[0]).toBe('$ npm test') + expect(lines).toContain('ok 12 tests') + expect(lines.at(-2)).toMatch(DIVIDER) + }) + + it('neither a live buffer nor a read-only copy: restores nothing and says nothing', async () => { + const terminal = pane(4) + captureWakeScrollback('leaf-1', terminal, { + sleptAt: SLEPT_AT, + agentPane: false, + maxRows: 5000, + readOnlyCopy: async () => null + }) + await Promise.resolve() + armWakeScrollback(['leaf-1']) + expect(await wake(terminal, 'leaf-1', '$ ')).toBe(false) + expect(text(terminal)).toEqual(['$']) + }) + + it('a person who had scrolled up keeps their place instead of jumping', async () => { + const terminal = pane(5) + await write(terminal, Array.from({ length: 50 }, (_, index) => `row ${index}`).join('\r\n')) + terminal.scrollToLine(10) + capture(terminal, 'leaf-1') + armWakeScrollback(['leaf-1']) + await wake(terminal, 'leaf-1', '$ ') + expect(terminal.buffer.active.viewportY).toBe(10) + }) + + it('forgets a closed pane, and caps the kept rows', async () => { + const terminal = pane(4) + await write(terminal, Array.from({ length: 30 }, (_, index) => `row ${index}`).join('\r\n')) + captureWakeScrollback('leaf-1', terminal, { sleptAt: SLEPT_AT, agentPane: false, maxRows: 5 }) + armWakeScrollback(['leaf-1']) + const block = beginWakeScrollbackRestore('leaf-1', terminal)!.block + expect(block).toContain('row 29') + expect(block).not.toContain('row 24\r') + captureWakeScrollback('leaf-2', terminal, { sleptAt: SLEPT_AT, agentPane: false, maxRows: 5 }) + forgetWakeScrollback(['leaf-2']) + armWakeScrollback(['leaf-2']) + expect(beginWakeScrollbackRestore('leaf-2', terminal)).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts new file mode 100644 index 00000000000..85fb016b311 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts @@ -0,0 +1,225 @@ +// WAKE-SCROLLBACK: a cloud terminal pane keeps its pre-sleep output across a wake. Sleep reboots the +// VM and the woken pane attaches to a new terminal whose snapshot replay erases scrollback; the +// output the pane showed before the sleep is written back above a "Slept 14:02–14:31 · processes +// stopped" divider, client-side only (nothing reaches the PTY), as rendered text + SGR. +import { translate } from '@/i18n/i18n' +import { + formatClock, + renderBufferRows, + sanitizeRestoredText, + type RestoreBuffer +} from './cloud-wake-scrollback-render' + +type PendingHistory = { + /** Which sleep this is from (its start); one capture per sleep. */ + sleptAt: number + /** Text + SGR (or plain text from the read-only copy); null until known, or when empty. */ + text: string | null + /** + * The workspace left "asleep": the next clearing replay is the woken terminal's. Until then a + * replay (a cold restore while still asleep) must not take it. + */ + armed: boolean +} + +// Keyed by the pane's durable leaf id; cleared once restored, or when the pane goes away. +const SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') +const pending = new Map() + +export type ScrollbackSource = { + rows: number + buffer: { normal: RestoreBuffer & { baseY: number } } +} + +function lastTextRow(buffer: RestoreBuffer): number { + for (let y = buffer.length - 1; y >= 0; y--) { + const line = buffer.getLine(y) + for (let x = 0; line && x < line.length; x++) { + const chars = line.getCell(x)?.getChars() ?? '' + if (chars !== '' && chars !== ' ') { + return y + } + } + } + return -1 +} + +/** + * While asleep (or waking), before the new terminal attaches: keep the pane's normal-buffer output. + * A shell pane keeps its visible rows too (its last output is usually on screen); an agent or + * full-screen pane keeps only the scrollback above its screen, so no frozen TUI frame comes back. + * With nothing in the pane (never mounted, app or renderer restarted while asleep), the plain-text + * read-only copy saved at sleep stands in, uncoloured. + */ +export function captureWakeScrollback( + leafId: string, + terminal: ScrollbackSource, + options: { + /** When the workspace went to sleep; null when unknown (the first capture's time stands in). */ + sleptAt: number | null + agentPane: boolean + maxRows: number + readOnlyCopy?: () => Promise + } +): void { + const existing = pending.get(leafId) + if (existing && (options.sleptAt === null || existing.sleptAt === options.sleptAt)) { + return + } + const normal = terminal.buffer.normal + const end = options.agentPane ? normal.baseY : lastTextRow(normal) + 1 + const text = renderBufferRows(normal, Math.max(0, end - options.maxRows), end) + const entry: PendingHistory = { + sleptAt: options.sleptAt ?? Date.now(), + armed: false, + text: text.replace(SGR, '').trim() === '' ? null : text + } + pending.set(leafId, entry) + if (entry.text !== null || !options.readOnlyCopy) { + return + } + void options + .readOnlyCopy() + .then((lines) => { + if (pending.get(leafId) !== entry || !lines) { + return + } + const kept = options.agentPane + ? lines.slice(0, Math.max(0, lines.length - terminal.rows)) + : lines + const plain = kept.slice(-options.maxRows) + entry.text = plain.join('').trim() === '' ? null : plain.join('\r\n') + }) + .catch(() => undefined) +} + +/** The divider, dim, between the restored output and the woken terminal. */ +export function wakeDividerLine(sleptAt: number, wokeAt: number): string { + const label = translate( + 'orcaCloudWorkspace.terminal.sleptDivider', + 'Slept {{from}}–{{to}} · processes stopped', + { from: formatClock(sleptAt), to: formatClock(wokeAt) } + ) + return `\x1b[0;2m── ${label} ──\x1b[0m` +} + +/** The workspace is waking or live: these panes' next clearing replay restores their output. */ +export function armWakeScrollback(leafIds: Iterable): void { + for (const id of leafIds) { + const entry = pending.get(id) + if (entry) { + entry.armed = true + } + } +} + +/** The pane closed or its workspace was deleted. */ +export function forgetWakeScrollback(leafIds: Iterable): void { + for (const id of leafIds) { + pending.delete(id) + } +} + +type WokenTerminal = { + rows: number + buffer: { + active: RestoreBuffer & { + type: string + viewportY: number + baseY: number + cursorX: number + cursorY: number + } + } + scrollToLine: (line: number) => void +} + +/** A restore started on the woken terminal's snapshot, finished once that snapshot painted. */ +export type WakeScrollbackRestore = { + /** The restored output (text + SGR only) and its divider. */ + block: string + /** Where a person who had scrolled up was, or null when pinned to the bottom. */ + scrolledTo: number | null +} + +/** + * The woken terminal's snapshot is about to paint: takes (and forgets) the pane's armed restore. + * Null when there is none, or nothing to restore (then nothing is said either). + */ +export function beginWakeScrollbackRestore( + leafId: string, + terminal: WokenTerminal, + wokeAt = Date.now() +): WakeScrollbackRestore | null { + const entry = pending.get(leafId) + if (!entry?.armed) { + return null + } + pending.delete(leafId) + if (entry.text === null) { + return null + } + const { viewportY, baseY } = terminal.buffer.active + return { + block: `\x1b[0m${sanitizeRestoredText(entry.text)}\x1b[0m\r\n${wakeDividerLine(entry.sleptAt, wokeAt)}`, + scrolledTo: viewportY < baseY ? viewportY : null + } +} + +function rowHasText(buffer: RestoreBuffer, y: number): boolean { + const line = buffer.getLine(y) + for (let x = 0; line && x < line.length; x++) { + const chars = line.getCell(x)?.getChars() ?? '' + if (chars !== '' && chars !== ' ') { + return true + } + } + return false +} + +/** + * The client-side rewrite once the snapshot painted: the restored output, the divider, then the + * woken screen's rows, with the cursor put back where the snapshot left it (relative moves only). + * Null on the alternate screen (a full-screen app owns the pane; its normal buffer is not shown). + */ +export function composeWakeRestore(block: string, terminal: WokenTerminal): string | null { + const active = terminal.buffer.active + if (active.type !== 'normal') { + return null + } + let last = active.cursorY + for (let row = terminal.rows - 1; row > active.cursorY; row--) { + if (rowHasText(active, active.baseY + row)) { + last = row + break + } + } + const screen = renderBufferRows(active, active.baseY, active.baseY + last + 1) + const up = last - active.cursorY + return `\x1b[2J\x1b[3J\x1b[H${block}\r\n${screen}\x1b[0m${up > 0 ? `\x1b[${up}A` : ''}\x1b[${active.cursorX + 1}G` +} + +/** + * Writes the rewrite through `write` (the replay writer: client-side, under the replay guard; + * nothing reaches the PTY). Stays pinned to the bottom; a person who had scrolled up keeps their place. + */ +export async function finishWakeScrollbackRestore( + restore: WakeScrollbackRestore, + terminal: WokenTerminal, + write: (data: string) => Promise +): Promise { + const data = composeWakeRestore(restore.block, terminal) + if (data === null) { + return false + } + await write(data) + if (restore.scrolledTo !== null) { + terminal.scrollToLine(restore.scrolledTo) + } + return true +} + +/** Tests only. */ +export function resetWakeScrollbackForTests(): void { + pending.clear() +} From 685d83a9f77f780c9b85d3b4d51efb086c6050af Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 11 Nov 2026 09:09:26 -0800 Subject: [PATCH 02/11] =?UTF-8?q?fix(cloud):=20WAKE-SCROLLBACK=20divider?= =?UTF-8?q?=20time=20as=20a=20locale=20range=20(2:02=E2=80=932:31=20PM);?= =?UTF-8?q?=20render=20held=20(DEC=202026)=20from=20the=20snapshot's=20cle?= =?UTF-8?q?ar=20to=20the=20restore,=20so=20no=20blank=20or=20history-less?= =?UTF-8?q?=20frame;=20fixture=20filler=20labelled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../offline/CloudWakeScrollback.fixture.tsx | 8 +++++++- .../pty-connection/output/replay-data-drain.ts | 9 +++++++-- .../orca-cloud/cloud-wake-scrollback-render.ts | 15 ++++++++++++--- .../src/lib/orca-cloud/cloud-wake-scrollback.ts | 16 ++++++++++++---- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/orca-cloud/offline/CloudWakeScrollback.fixture.tsx b/src/renderer/src/components/orca-cloud/offline/CloudWakeScrollback.fixture.tsx index e5728700cf2..ee96358bd09 100644 --- a/src/renderer/src/components/orca-cloud/offline/CloudWakeScrollback.fixture.tsx +++ b/src/renderer/src/components/orca-cloud/offline/CloudWakeScrollback.fixture.tsx @@ -119,7 +119,13 @@ export const agentPaneScrollbackOnly = defineFixture({ ' ? for shortcuts' ].join('\r\n'), agentPane: true, - after: '\x1b[2m(Claude relaunching…)\x1b[0m' + // Fixture stand-in for the relaunched Claude's own screen (the app writes no such text). + after: [ + '╭───────────────────────────────╮', + '│ ✻ Welcome back to Claude Code │', + '╰───────────────────────────────╯', + '> ' + ].join('\r\n') } ]} /> diff --git a/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts index 8ca41f758c7..10e08d112a2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts @@ -22,6 +22,7 @@ import type { ConnectPanePtySession } from '../connect-pane-pty-session' import { beginWakeScrollbackRestore, finishWakeScrollbackRestore, + HOLD_RENDER_UNTIL_RESTORED, type WakeScrollbackRestore } from '@/lib/orca-cloud/cloud-wake-scrollback' @@ -131,10 +132,14 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { carriesHistory === true ) replayPayload = image.payload - await session.writeReplayDataAsync(image.preamble) + await session.writeReplayDataAsync( + preSleep ? `${image.preamble}${HOLD_RENDER_UNTIL_RESTORED}` : image.preamble + ) } else { // Reconnect can sever DEC 2026 mid-frame; clear-screen does not release its latch, leaving replay invisible until timeout. - await session.writeReplayDataAsync(`${RELEASE_SYNCHRONIZED_OUTPUT}\x1b[2J\x1b[3J\x1b[H`) + await session.writeReplayDataAsync( + `${RELEASE_SYNCHRONIZED_OUTPUT}\x1b[2J\x1b[3J\x1b[H${preSleep ? HOLD_RENDER_UNTIL_RESTORED : ''}` + ) } if (!isCurrentPayload()) { continue diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.ts index e587684a542..2131aabd747 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.ts @@ -218,7 +218,16 @@ export function sanitizeRestoredText(text: string): string { return out } -/** "14:02" in the person's locale. */ -export function formatClock(at: number, locale?: string): string { - return new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(at) +/** + * "2:02–2:31 PM" / "11:50 AM–12:10 PM" / "14:02–14:31": the locale's short times, no leading zero, + * a shared AM/PM said once; across days each side keeps its weekday ("Mon 11:50 PM–Tue 12:10 AM"). + */ +export function formatSleptRange(from: number, to: number, locale?: string): string { + const sameDay = new Date(from).toDateString() === new Date(to).toDateString() + const format = new Intl.DateTimeFormat(locale, { + hour: 'numeric', + minute: '2-digit', + ...(sameDay ? {} : { weekday: 'short' }) + }) + return format.formatRange(Math.min(from, to), to).replace(/\s*[–-]\s*/u, '–') } diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts index 85fb016b311..acad98c8e31 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts @@ -4,7 +4,7 @@ // stopped" divider, client-side only (nothing reaches the PTY), as rendered text + SGR. import { translate } from '@/i18n/i18n' import { - formatClock, + formatSleptRange, renderBufferRows, sanitizeRestoredText, type RestoreBuffer @@ -97,8 +97,8 @@ export function captureWakeScrollback( export function wakeDividerLine(sleptAt: number, wokeAt: number): string { const label = translate( 'orcaCloudWorkspace.terminal.sleptDivider', - 'Slept {{from}}–{{to}} · processes stopped', - { from: formatClock(sleptAt), to: formatClock(wokeAt) } + 'Slept {{range}} · processes stopped', + { range: formatSleptRange(sleptAt, wokeAt) } ) return `\x1b[0;2m── ${label} ──\x1b[0m` } @@ -177,6 +177,13 @@ function rowHasText(buffer: RestoreBuffer, y: number): boolean { return false } +/** + * Appended to the snapshot's own clear when a restore follows: holds rendering (DEC 2026) from that + * clear until the rewrite, so no frame shows a blank pane or the woken prompt without its history. + */ +export const HOLD_RENDER_UNTIL_RESTORED = '\x1b[?2026h' +const RELEASE_RENDER = '\x1b[?2026l' + /** * The client-side rewrite once the snapshot painted: the restored output, the divider, then the * woken screen's rows, with the cursor put back where the snapshot left it (relative moves only). @@ -209,10 +216,11 @@ export async function finishWakeScrollbackRestore( write: (data: string) => Promise ): Promise { const data = composeWakeRestore(restore.block, terminal) + // Always release the render hold, restored or not. + await write(`${data ?? ''}${RELEASE_RENDER}`) if (data === null) { return false } - await write(data) if (restore.scrolledTo !== null) { terminal.scrollToLine(restore.scrolledTo) } From 66546e7cd7228a66ed8052bc72d4460bebd4ebd5 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 11 Nov 2026 09:24:57 -0800 Subject: [PATCH 03/11] fix(cloud): WAKE-SCROLLBACK the woken terminal's first snapshot takes the restore (new PTY id), no race with React arming; test times relative to now --- .../output/replay-data-drain.ts | 7 +++++- .../lib/orca-cloud/cloud-wake-pane-capture.ts | 3 ++- .../orca-cloud/cloud-wake-scrollback.test.ts | 23 +++++++++++++++++-- .../lib/orca-cloud/cloud-wake-scrollback.ts | 14 +++++++++-- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts index 10e08d112a2..8c47b08efc5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts @@ -119,7 +119,12 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { let preSleep: WakeScrollbackRestore | null = null if (clearBeforeReplay) { // WAKE-SCROLLBACK: a woken cloud pane's pre-sleep output, put back once this snapshot painted. - preSleep = beginWakeScrollbackRestore(session.pane.leafId, session.pane.terminal) + preSleep = beginWakeScrollbackRestore( + session.pane.leafId, + session.pane.terminal, + Date.now(), + payload.ptyId + ) if (carriesNormalBuffer) { // Wait for queued alt-screen transitions before choosing replay’s target buffer. await waitForTerminalOutputParsed(session.pane.terminal) diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-pane-capture.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-pane-capture.ts index d8ec1b46f18..7298e8e5068 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-pane-capture.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-pane-capture.ts @@ -50,6 +50,7 @@ export function captureCloudPaneBeforeWake(args: { sleptAt: args.asleepSince, agentPane, maxRows: normalizeDesktopTerminalScrollbackRows(state.settings?.terminalScrollbackRows), - readOnlyCopy: () => readOnlyCopyLines(worktreeId, ptyId) + readOnlyCopy: () => readOnlyCopyLines(worktreeId, ptyId), + ptyId }) } diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts index 8d05bbb3777..dba35089c30 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts @@ -16,7 +16,7 @@ vi.mock('@/i18n/i18n', () => ({ afterEach(() => resetWakeScrollbackForTests()) -const SLEPT_AT = new Date(2026, 10, 11, 14, 2).getTime() +const SLEPT_AT = Date.now() - 29 * 60_000 const DIVIDER = /^── Slept .+–.+ · processes stopped ──$/ function write(terminal: Terminal, data: string): Promise { @@ -126,7 +126,7 @@ describe('WAKE-SCROLLBACK', () => { await wake(terminal, 'leaf-1', '$ ') await write(terminal, 'echo two\r\ntwo\r\n$ ') captureWakeScrollback('leaf-1', terminal, { - sleptAt: SLEPT_AT + 3_600_000, + sleptAt: SLEPT_AT + 10 * 60_000, agentPane: false, maxRows: 5000 }) @@ -140,6 +140,25 @@ describe('WAKE-SCROLLBACK', () => { expect(lines.indexOf('two')).toBeLessThan(dividers[1]!) }) + it('a snapshot for the new (woken) terminal takes it at once, before React arms it', async () => { + const terminal = pane(4) + await write(terminal, '$ ls\r\nREADME\r\n$ ') + captureWakeScrollback('leaf-1', terminal, { + sleptAt: SLEPT_AT, + agentPane: false, + maxRows: 5000, + ptyId: 'remote:env::term-old' + }) + // A cold restore of the old terminal while still asleep: not taken. + expect( + beginWakeScrollbackRestore('leaf-1', terminal, Date.now(), 'remote:env::term-old') + ).toBeNull() + // The woken terminal's snapshot: taken, even though nothing armed it yet. + expect( + beginWakeScrollbackRestore('leaf-1', terminal, Date.now(), 'remote:env::term-new')?.block + ).toContain('README') + }) + it('a replay before the workspace leaves "asleep" (a cold restore) does not take it', async () => { const terminal = pane(4) await write(terminal, '$ ls\r\nREADME\r\n$ ') diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts index acad98c8e31..0859a2e48c8 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts @@ -20,6 +20,8 @@ type PendingHistory = { * replay (a cold restore while still asleep) must not take it. */ armed: boolean + /** The pre-sleep terminal: a snapshot for a different one belongs to the woken terminal. */ + ptyId: string | null } // Keyed by the pane's durable leaf id; cleared once restored, or when the pane goes away. @@ -60,6 +62,8 @@ export function captureWakeScrollback( agentPane: boolean maxRows: number readOnlyCopy?: () => Promise + /** The pane's terminal before the sleep; a replay for any other one is the woken terminal's. */ + ptyId?: string | null } ): void { const existing = pending.get(leafId) @@ -72,6 +76,7 @@ export function captureWakeScrollback( const entry: PendingHistory = { sleptAt: options.sleptAt ?? Date.now(), armed: false, + ptyId: options.ptyId ?? null, text: text.replace(SGR, '').trim() === '' ? null : text } pending.set(leafId, entry) @@ -149,10 +154,15 @@ export type WakeScrollbackRestore = { export function beginWakeScrollbackRestore( leafId: string, terminal: WokenTerminal, - wokeAt = Date.now() + wokeAt = Date.now(), + /** The terminal this snapshot is for. */ + ptyId: string | null = null ): WakeScrollbackRestore | null { const entry = pending.get(leafId) - if (!entry?.armed) { + // Armed by the workspace leaving "asleep", or (sooner, with no race against React) by the + // snapshot being for a new terminal: a cold restore while asleep is for the old one. + const newTerminal = entry?.ptyId != null && ptyId !== null && ptyId !== entry.ptyId + if (!entry || !(entry.armed || newTerminal)) { return null } pending.delete(leafId) From 1d5a9f5e7d73a0d6fe8f35d6a4d3d40cc758d2e5 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 11 Nov 2026 09:40:06 -0800 Subject: [PATCH 04/11] fix(cloud): WAKE-SCROLLBACK no blank frame on attach, and the whole pre-sleep history (m4air proof) Two defects the real-app proof found: - ~400 ms blank pane on attach: the woken terminal's fresh-spawn viewport blanking ran before the snapshot drain. A pane with pre-sleep output to put back now holds rendering (DEC 2026) there instead of blanking; it keeps its greyed history until the restore rewrites it. - History cut to ~880 rows: by "asleep" the transport had resubscribed and replaced the buffer with the host's 1000-row snapshot, and the capture could re-run on it. It now captures when sleep is asked for (going to sleep), once per sleep (a later look only refines the start time), and arms only after the workspace was actually asleep. --- .../output/fresh-spawn-follow-reset.ts | 10 +++++ .../fresh-spawn-wake-scrollback.test.ts | 45 +++++++++++++++++++ .../recovery/TerminalPaneRecoveryPortals.tsx | 31 ++++++++++--- .../orca-cloud/cloud-wake-scrollback.test.ts | 43 ++++++++++++++++++ .../lib/orca-cloud/cloud-wake-scrollback.ts | 13 +++++- 5 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/output/fresh-spawn-wake-scrollback.test.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection/output/fresh-spawn-follow-reset.ts b/src/renderer/src/components/terminal-pane/pty-connection/output/fresh-spawn-follow-reset.ts index 41213984226..f780daf7c3f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/output/fresh-spawn-follow-reset.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/output/fresh-spawn-follow-reset.ts @@ -1,4 +1,8 @@ import { replayIntoTerminal, replayIntoTerminalAsync } from '../../snapshots/replay-guard' +import { + HOLD_RENDER_UNTIL_RESTORED, + hasPendingWakeScrollback +} from '@/lib/orca-cloud/cloud-wake-scrollback' import { terminalOutputPrefersRenderRefresh } from '@/lib/pane-manager/terminal-complex-script' import { buildPostReplayLiveAgentReattachReset, @@ -186,6 +190,12 @@ export function bindFreshSpawnFollowReset(session: ConnectPanePtySession): void if (!options.forceBlankRestoredViewport && !hadRestoredViewport) { return } + // WAKE-SCROLLBACK: a woken cloud pane keeps showing its pre-sleep output (render held) until its + // restore rewrites the whole pane; blanking here is the blank frame before it. + if (hasPendingWakeScrollback(session.pane.leafId)) { + session.writeReplayData(HOLD_RENDER_UNTIL_RESTORED) + return + } // Move restored rows into scrollback before coordinate-based Windows ConPTY prompt output. session.writeFreshShellViewportBlanking() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/output/fresh-spawn-wake-scrollback.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/output/fresh-spawn-wake-scrollback.test.ts new file mode 100644 index 00000000000..9990ca25e3a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/output/fresh-spawn-wake-scrollback.test.ts @@ -0,0 +1,45 @@ +import { Terminal } from '@xterm/headless' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + captureWakeScrollback, + HOLD_RENDER_UNTIL_RESTORED, + resetWakeScrollbackForTests +} from '@/lib/orca-cloud/cloud-wake-scrollback' +import type { ConnectPanePtySession } from '../connect-pane-pty-session' +import { bindFreshSpawnFollowReset } from './fresh-spawn-follow-reset' + +afterEach(() => resetWakeScrollbackForTests()) + +function session(leafId: string): { session: ConnectPanePtySession; writes: string[] } { + const writes: string[] = [] + const stub = { + pane: { id: 1, leafId, terminal: { rows: 4 } }, + deps: { restoredViewportBlankingPanesRef: { current: new Set() } } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a stub with only the fields the binder's viewport-prepare path reads; writeReplayData is replaced below. + const typed = stub as unknown as ConnectPanePtySession + bindFreshSpawnFollowReset(typed) + typed.writeReplayData = vi.fn((data: string) => { + writes.push(data) + }) + return { session: typed, writes } +} + +describe('fresh-spawn viewport blanking on a woken cloud pane (WAKE-SCROLLBACK)', () => { + it('a pane with pre-sleep output to put back is held, not blanked (no blank frame before the restore)', async () => { + const terminal = new Terminal({ cols: 40, rows: 4, allowProposedApi: true }) + await new Promise((resolve) => terminal.write('$ seq 1 3\r\n1\r\n2\r\n3\r\n$ ', resolve)) + captureWakeScrollback('leaf-1', terminal, { sleptAt: 1, agentPane: false, maxRows: 5000 }) + const { session: s, writes } = session('leaf-1') + s.prepareFreshShellViewportForSpawn({ forceBlankRestoredViewport: true }) + expect(writes).toEqual([HOLD_RENDER_UNTIL_RESTORED]) + }) + + it('any other pane is blanked as before', () => { + const { session: s, writes } = session('leaf-2') + s.prepareFreshShellViewportForSpawn({ forceBlankRestoredViewport: true }) + expect(writes).toHaveLength(1) + expect(writes[0]).not.toBe(HOLD_RENDER_UNTIL_RESTORED) + expect(writes[0]).toContain('\x1b[H') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx index cbf249729b3..52de93a4521 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx +++ b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx @@ -38,6 +38,7 @@ import { withoutWslHostStoppedLines } from '../../../../../../../shared/wsl-host import { makePaneKey } from '../../../../../../../shared/workspace-layout/stable-pane-id' import { cloudWokeActionFor, runCloudWokeAction } from '@/lib/orca-cloud/cloud-wake-hint-action' import { captureCloudPaneBeforeWake } from '@/lib/orca-cloud/cloud-wake-pane-capture' +import { useIsWorktreeGoingToSleep } from '@/lib/worktree-sleep-view' import { armWakeScrollback, forgetWakeScrollback } from '@/lib/orca-cloud/cloud-wake-scrollback' import { restartPaneExists } from '@/lib/orca-cloud/cloud-agent-restart-pane-watch' import { mapPaneTerminalErrors } from '../../../errors/terminal-error-accumulation' @@ -141,13 +142,18 @@ export function TerminalPaneRecoveryPortals({ // so after the wake the pane can say it stopped and offer it back (JA-15). const isCloud = cloud !== null const asleepSince = cloud?.asleepSince ?? null + // Sleep was asked for: look now, before the transport's resubscribes (while the VM suspends) + // replace the pane's buffer with the host's 1000-row snapshot (WAKE-SCROLLBACK). + const goingToSleep = useIsWorktreeGoingToSleep(controller.worktreeId) + // Set once the workspace was asleep this cycle: only then is a replay the woken terminal's. + const sleptRef = useRef(false) useEffect(() => { if (!isCloud) { return } - if (cloudAsleep || cloudWaking) { + if (goingToSleep || cloudAsleep || cloudWaking) { for (const pane of managedPanes) { - // The Rerun hint and the pre-sleep output (WAKE-SCROLLBACK), before the new terminal attaches. + // The Rerun hint and the pre-sleep output, before the new terminal attaches. captureCloudPaneBeforeWake({ tabId, leafId: pane.leafId, @@ -159,11 +165,26 @@ export function TerminalPaneRecoveryPortals({ } else { resetWakeProcessCapture(managedPanes.map((pane) => pane.leafId)) } - if (!cloudAsleep) { - // Waking or live: the next clearing replay is the woken terminal's. + if (cloudAsleep) { + sleptRef.current = true + } else if (sleptRef.current && (cloudWaking || cloudAwake)) { + // Waking or live after a sleep: the next clearing replay is the woken terminal's. armWakeScrollback(managedPanes.map((pane) => pane.leafId)) + if (cloudAwake) { + sleptRef.current = false + } } - }, [isCloud, cloudAsleep, cloudWaking, managedPanes, tabId, controller.worktreeId, asleepSince]) + }, [ + isCloud, + goingToSleep, + cloudAsleep, + cloudWaking, + cloudAwake, + managedPanes, + tabId, + controller.worktreeId, + asleepSince + ]) // A pane closed, or its tab or workspace went away: drop its kept pre-sleep output. const leafIdsRef = useRef<(typeof managedPanes)[number]['leafId'][]>([]) useEffect(() => { diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts index dba35089c30..88c187fb83f 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts @@ -6,6 +6,7 @@ import { captureWakeScrollback, finishWakeScrollbackRestore, forgetWakeScrollback, + hasPendingWakeScrollback, resetWakeScrollbackForTests } from './cloud-wake-scrollback' @@ -203,6 +204,48 @@ describe('WAKE-SCROLLBACK', () => { expect(text(terminal)).toEqual(['$']) }) + // m4air run: by "asleep" a resubscribe had replaced 2000 rows with the host's 1000-row snapshot. + it('captures once per sleep (at going to sleep): a later look at a replaced buffer changes nothing', async () => { + const terminal = pane(10) + const seq = Array.from({ length: 2000 }, (_, index) => String(index + 1)).join('\r\n') + await write(terminal, `$ seq 1 2000\r\n${seq}\r\n$ sleep 900\r\n`) + captureWakeScrollback('leaf-1', terminal, { sleptAt: null, agentPane: false, maxRows: 5000 }) + // The transport resubscribes while the VM suspends: a cleared, host-limited image. + const last1000 = Array.from({ length: 1000 }, (_, index) => String(index + 1001)).join('\r\n') + await write(terminal, `\x1b[2J\x1b[3J\x1b[H${last1000}\r\n$ sleep 900\r\n`) + captureWakeScrollback('leaf-1', terminal, { + sleptAt: SLEPT_AT, + agentPane: false, + maxRows: 5000 + }) + armWakeScrollback(['leaf-1']) + expect(await wake(terminal, 'leaf-1', '$ ')).toBe(true) + const lines = text(terminal) + expect(lines[0]).toBe('$ seq 1 2000') + expect(lines[1]).toBe('1') + }) + + it("a past sleep's woken-but-untaken capture is replaced by the next sleep's", async () => { + const terminal = pane(6) + await write(terminal, '$ echo old\r\nold\r\n$ ') + capture(terminal, 'leaf-1') + armWakeScrollback(['leaf-1']) + await write(terminal, 'echo new\r\nnew\r\n$ ') + capture(terminal, 'leaf-1') + armWakeScrollback(['leaf-1']) + expect(beginWakeScrollbackRestore('leaf-1', terminal)?.block).toContain('new') + }) + + it('reports a pending restore only when there is output to put back', async () => { + const empty = pane(4) + captureWakeScrollback('leaf-e', empty, { sleptAt: SLEPT_AT, agentPane: false, maxRows: 5000 }) + expect(hasPendingWakeScrollback('leaf-e')).toBe(false) + const full = pane(4) + await write(full, '$ ls\r\nREADME\r\n$ ') + capture(full, 'leaf-f') + expect(hasPendingWakeScrollback('leaf-f')).toBe(true) + }) + it('a person who had scrolled up keeps their place instead of jumping', async () => { const terminal = pane(5) await write(terminal, Array.from({ length: 50 }, (_, index) => `row ${index}`).join('\r\n')) diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts index 0859a2e48c8..b693d6d1d7c 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts @@ -67,7 +67,13 @@ export function captureWakeScrollback( } ): void { const existing = pending.get(leafId) - if (existing && (options.sleptAt === null || existing.sleptAt === options.sleptAt)) { + // One capture per sleep, the earliest: by "asleep" the transport may already have resubscribed + // and replaced the buffer with the host's 1000-row snapshot. A later look only refines the time. + // An armed (woken) but untaken capture is a past sleep's; a new sleep captures afresh. + if (existing && !existing.armed) { + if (options.sleptAt !== null) { + existing.sleptAt = options.sleptAt + } return } const normal = terminal.buffer.normal @@ -118,6 +124,11 @@ export function armWakeScrollback(leafIds: Iterable): void { } } +/** The pane has pre-sleep output waiting to be put back after its wake. */ +export function hasPendingWakeScrollback(leafId: string): boolean { + return (pending.get(leafId)?.text ?? null) !== null +} + /** The pane closed or its workspace was deleted. */ export function forgetWakeScrollback(leafIds: Iterable): void { for (const id of leafIds) { From 40d2c4d1f37fe633153ebce7b9be21ea3d10f876 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 11 Nov 2026 09:43:04 -0800 Subject: [PATCH 05/11] fix(cloud): WAKE-SCROLLBACK divider copy on its own key (the catalog value renders; the old key had from/to) --- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 76fcc8ddc41..4451455e021 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -27165,7 +27165,7 @@ "rerun": "Rerun {{command}}", "rerunLabel": "Rerun", "wokeProcessesStopped": "Woke up · processes stopped while asleep", - "sleptDivider": "Slept {{from}}–{{to}} · processes stopped" + "sleptRangeDivider": "Slept {{range}} · processes stopped" }, "archivedDetailLocalEdits_one": "It goes to sleep and stays browsable: PRs, last changes and files. {{count}} changed file on this computer will be removed.", "archivedDetailLocalEdits_other": "It goes to sleep and stays browsable: PRs, last changes and files. {{count}} changed files on this computer will be removed.", diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts index b693d6d1d7c..5029062f552 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts @@ -107,7 +107,7 @@ export function captureWakeScrollback( /** The divider, dim, between the restored output and the woken terminal. */ export function wakeDividerLine(sleptAt: number, wokeAt: number): string { const label = translate( - 'orcaCloudWorkspace.terminal.sleptDivider', + 'orcaCloudWorkspace.terminal.sleptRangeDivider', 'Slept {{range}} · processes stopped', { range: formatSleptRange(sleptAt, wokeAt) } ) From 1ed60a9774f33ef0ace48ae982d496937fa71da5 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 11 Nov 2026 09:47:25 -0800 Subject: [PATCH 06/11] fix(cloud): WAKE-SCROLLBACK hold rendering from the woken terminal's bind (the process-boundary ground released it), so no pre-restore clear is painted; tests on a fixed clock --- .../output/replaced-process-input-modes.ts | 13 +++++++++++- .../orca-cloud/cloud-wake-scrollback.test.ts | 21 ++++++++++++------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/output/replaced-process-input-modes.ts b/src/renderer/src/components/terminal-pane/pty-connection/output/replaced-process-input-modes.ts index c3cb5c88c9a..b1daae3cbe9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/output/replaced-process-input-modes.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/output/replaced-process-input-modes.ts @@ -1,5 +1,9 @@ import { PROCESS_BOUNDARY_GROUND } from '../../../../../../shared/terminal/protocol/input-modes/terminal-mode-reset-profiles' import type { ConnectPanePtySession } from '../connect-pane-pty-session' +import { + HOLD_RENDER_UNTIL_RESTORED, + hasPendingWakeScrollback +} from '@/lib/orca-cloud/cloud-wake-scrollback' /** A replaced process must not inherit the old one's input modes (#35559). */ export function bindReplacedProcessInputModes(session: ConnectPanePtySession): void { @@ -8,7 +12,14 @@ export function bindReplacedProcessInputModes(session: ConnectPanePtySession): v session.groundInputModesForNewProcess = (): void => { session.inputModeOwnerIncarnationId = null - session.writeInputModeGround(PROCESS_BOUNDARY_GROUND) + // WAKE-SCROLLBACK: the ground releases DEC 2026; a woken cloud pane with pre-sleep output to put + // back re-holds rendering here, at the new terminal's bind, so no clear before its restore is + // ever painted (the restore releases it). + session.writeInputModeGround( + hasPendingWakeScrollback(session.pane.leafId) + ? `${PROCESS_BOUNDARY_GROUND}${HOLD_RENDER_UNTIL_RESTORED}` + : PROCESS_BOUNDARY_GROUND + ) // The new process never subscribed to mode 2031; a reconnect catch-up would land in its input. session.clearPaneMode2031State() } diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts index 88c187fb83f..daab28205e0 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts @@ -1,5 +1,5 @@ import { Terminal } from '@xterm/headless' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { armWakeScrollback, beginWakeScrollbackRestore, @@ -15,9 +15,18 @@ vi.mock('@/i18n/i18n', () => ({ fallback.replace(/\{\{(\w+)\}\}/g, (_slot: string, name: string) => params?.[name] ?? '') })) -afterEach(() => resetWakeScrollbackForTests()) +// A fixed clock (Date only; xterm's own timers stay real): the divider reads 2:02–2:31 PM. +const NOW = new Date(2026, 10, 11, 14, 31).getTime() +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) +}) +afterEach(() => { + resetWakeScrollbackForTests() + vi.useRealTimers() +}) -const SLEPT_AT = Date.now() - 29 * 60_000 +const SLEPT_AT = NOW - 29 * 60_000 const DIVIDER = /^── Slept .+–.+ · processes stopped ──$/ function write(terminal: Terminal, data: string): Promise { @@ -151,12 +160,10 @@ describe('WAKE-SCROLLBACK', () => { ptyId: 'remote:env::term-old' }) // A cold restore of the old terminal while still asleep: not taken. - expect( - beginWakeScrollbackRestore('leaf-1', terminal, Date.now(), 'remote:env::term-old') - ).toBeNull() + expect(beginWakeScrollbackRestore('leaf-1', terminal, NOW, 'remote:env::term-old')).toBeNull() // The woken terminal's snapshot: taken, even though nothing armed it yet. expect( - beginWakeScrollbackRestore('leaf-1', terminal, Date.now(), 'remote:env::term-new')?.block + beginWakeScrollbackRestore('leaf-1', terminal, NOW, 'remote:env::term-new')?.block ).toContain('README') }) From a07ca09b41d295c95034cf443c2e5cdfc63b3ee0 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 11 Nov 2026 09:53:09 -0800 Subject: [PATCH 07/11] fix(cloud): WAKE-SCROLLBACK a pane remounted on wake paints its kept output at once (the blank frames were the fresh xterm before the snapshot) --- .../layout/terminal-pane-layout-restore.ts | 9 +++++++++ .../src/lib/orca-cloud/cloud-wake-scrollback.test.ts | 11 +++++++++++ .../src/lib/orca-cloud/cloud-wake-scrollback.ts | 9 +++++++++ 3 files changed, 29 insertions(+) diff --git a/src/renderer/src/components/terminal-pane/layout/terminal-pane-layout-restore.ts b/src/renderer/src/components/terminal-pane/layout/terminal-pane-layout-restore.ts index 09518d91f8d..5713ce708c4 100644 --- a/src/renderer/src/components/terminal-pane/layout/terminal-pane-layout-restore.ts +++ b/src/renderer/src/components/terminal-pane/layout/terminal-pane-layout-restore.ts @@ -1,5 +1,6 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { useAppStore } from '@/store/index' +import { wakeScrollbackPreview } from '@/lib/orca-cloud/cloud-wake-scrollback' import { applyExpandedLayoutTo } from './expand/expanded-pane-layout' import { replayTerminalLayout } from './terminal-layout-replay' import { restoreScrollbackBuffers } from '../snapshots/saved-terminal-output-replay' @@ -48,6 +49,14 @@ export function restoreTerminalPaneLayout(args: { refs.restoredViewportBlankingPanesRef, liveAltScreenLeafIds ) + // WAKE-SCROLLBACK: waking remounts a cloud pane with a fresh, empty xterm; paint its kept pre-sleep + // output now (client-side, text + SGR) so it never shows blank before the woken snapshot's restore. + for (const pane of manager.getPanes()) { + const preview = wakeScrollbackPreview(pane.leafId) + if (preview !== null) { + pane.terminal.write(preview) + } + } const hasScrollbackRefs = Boolean(initialLayoutRef.current.scrollbackRefsByLeafId) if ( restoredBuffers && diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts index daab28205e0..8dcb1d7a63b 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts @@ -7,6 +7,7 @@ import { finishWakeScrollbackRestore, forgetWakeScrollback, hasPendingWakeScrollback, + wakeScrollbackPreview, resetWakeScrollbackForTests } from './cloud-wake-scrollback' @@ -243,6 +244,16 @@ describe('WAKE-SCROLLBACK', () => { expect(beginWakeScrollbackRestore('leaf-1', terminal)?.block).toContain('new') }) + it('a pane remounted on wake (fresh, empty xterm) is painted with its kept output at once', async () => { + const before = pane(6) + await write(before, '\x1b[32m$ ls\x1b[0m\r\nREADME\r\n$ ') + capture(before, 'leaf-1') + const remounted = pane(6) + await write(remounted, wakeScrollbackPreview('leaf-1')!) + expect(text(remounted)).toEqual(['$ ls', 'README', '$']) + expect(wakeScrollbackPreview('leaf-none')).toBeNull() + }) + it('reports a pending restore only when there is output to put back', async () => { const empty = pane(4) captureWakeScrollback('leaf-e', empty, { sleptAt: SLEPT_AT, agentPane: false, maxRows: 5000 }) diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts index 5029062f552..473f1ac8e0d 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts @@ -124,6 +124,15 @@ export function armWakeScrollback(leafIds: Iterable): void { } } +/** + * What a freshly mounted pane shows until its woken snapshot's restore: the kept output alone + * (text + SGR only), or null when there is none. + */ +export function wakeScrollbackPreview(leafId: string): string | null { + const text = pending.get(leafId)?.text ?? null + return text === null ? null : `\x1b[0m${sanitizeRestoredText(text)}\x1b[0m` +} + /** The pane has pre-sleep output waiting to be put back after its wake. */ export function hasPendingWakeScrollback(leafId: string): boolean { return (pending.get(leafId)?.text ?? null) !== null From 80c180c8fbf92fc1458264c02aae7d5e78e0f364 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 11 Nov 2026 09:58:23 -0800 Subject: [PATCH 08/11] fix(cloud): WAKE-SCROLLBACK the restore drops the scrollback with xterm's clear() so a remount preview is never duplicated --- src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts index 473f1ac8e0d..864c1dcd93f 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts @@ -157,6 +157,8 @@ type WokenTerminal = { } } scrollToLine: (line: number) => void + /** Drops the scrollback (xterm's own API; an ED3 sent through the replay writer may not). */ + clear?: () => void } /** A restore started on the woken terminal's snapshot, finished once that snapshot painted. */ @@ -246,6 +248,11 @@ export async function finishWakeScrollbackRestore( write: (data: string) => Promise ): Promise { const data = composeWakeRestore(restore.block, terminal) + if (data !== null) { + // The woken screen is read; drop everything above it (a remount's preview, the old image) so + // the rewrite below is the pane's only copy of its history. + terminal.clear?.() + } // Always release the render hold, restored or not. await write(`${data ?? ''}${RELEASE_RENDER}`) if (data === null) { From a974f6120b5a4e176ebe7620c66b8c5284383045 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 11 Nov 2026 10:03:30 -0800 Subject: [PATCH 09/11] fix(cloud): WAKE-SCROLLBACK capture only at going-to-sleep/asleep (never while waking, when a remount holds restored copies); preview only into a remount with no replayed saved output --- .../terminal-pane/layout/terminal-pane-layout-restore.ts | 3 ++- .../remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/layout/terminal-pane-layout-restore.ts b/src/renderer/src/components/terminal-pane/layout/terminal-pane-layout-restore.ts index 5713ce708c4..e7bf0c9df4b 100644 --- a/src/renderer/src/components/terminal-pane/layout/terminal-pane-layout-restore.ts +++ b/src/renderer/src/components/terminal-pane/layout/terminal-pane-layout-restore.ts @@ -52,7 +52,8 @@ export function restoreTerminalPaneLayout(args: { // WAKE-SCROLLBACK: waking remounts a cloud pane with a fresh, empty xterm; paint its kept pre-sleep // output now (client-side, text + SGR) so it never shows blank before the woken snapshot's restore. for (const pane of manager.getPanes()) { - const preview = wakeScrollbackPreview(pane.leafId) + // A pane whose saved output was just replayed already shows it; a second copy would double it. + const preview = restoredBuffers?.[pane.leafId] ? null : wakeScrollbackPreview(pane.leafId) if (preview !== null) { pane.terminal.write(preview) } diff --git a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx index 52de93a4521..f90517fa723 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx +++ b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx @@ -151,7 +151,8 @@ export function TerminalPaneRecoveryPortals({ if (!isCloud) { return } - if (goingToSleep || cloudAsleep || cloudWaking) { + // Never while waking: a remount may already hold restored copies; the capture is the sleep's. + if (goingToSleep || cloudAsleep) { for (const pane of managedPanes) { // The Rerun hint and the pre-sleep output, before the new terminal attaches. captureCloudPaneBeforeWake({ From c4bb9d26b550cbdfc7f7e3cda8ec1c460142c77a Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 11 Nov 2026 10:16:23 -0800 Subject: [PATCH 10/11] fix(cloud): WAKE-SCROLLBACK any sleep keeps the full history (pre-resubscribe copy for sleeps not clicked here), a scroll made while asleep/waking survives the wake remount --- .../output/replay-data-drain.ts | 16 +++- .../recovery/TerminalPaneRecoveryPortals.tsx | 22 ++++- .../cloud-wake-scrollback-render.ts | 21 +++++ .../orca-cloud/cloud-wake-scrollback.test.ts | 74 ++++++++++++++++ .../lib/orca-cloud/cloud-wake-scrollback.ts | 85 +++++++++++++++++-- 5 files changed, 211 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts index 8c47b08efc5..539f858349c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts @@ -23,6 +23,7 @@ import { beginWakeScrollbackRestore, finishWakeScrollbackRestore, HOLD_RENDER_UNTIL_RESTORED, + noteBeforeCloudReplay, type WakeScrollbackRestore } from '@/lib/orca-cloud/cloud-wake-scrollback' @@ -118,7 +119,20 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { let replayPayload = data let preSleep: WakeScrollbackRestore | null = null if (clearBeforeReplay) { - // WAKE-SCROLLBACK: a woken cloud pane's pre-sleep output, put back once this snapshot painted. + // WAKE-SCROLLBACK: a resubscribe keeps only the host's 1000 rows; keep the fuller copy first + // in case a sleep nobody clicked here follows (a teammate, idle auto-sleep). + const keptBeforeReplay = noteBeforeCloudReplay( + session.pane.leafId, + session.pane.terminal, + payload.ptyId + ) + if (keptBeforeReplay) { + await keptBeforeReplay + if (!isCurrentPayload()) { + continue + } + } + // A woken cloud pane's pre-sleep output, put back once this snapshot painted. preSleep = beginWakeScrollbackRestore( session.pane.leafId, session.pane.terminal, diff --git a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx index f90517fa723..8a73efc054d 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx +++ b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/TerminalPaneRecoveryPortals.tsx @@ -39,7 +39,11 @@ import { makePaneKey } from '../../../../../../../shared/workspace-layout/stable import { cloudWokeActionFor, runCloudWokeAction } from '@/lib/orca-cloud/cloud-wake-hint-action' import { captureCloudPaneBeforeWake } from '@/lib/orca-cloud/cloud-wake-pane-capture' import { useIsWorktreeGoingToSleep } from '@/lib/worktree-sleep-view' -import { armWakeScrollback, forgetWakeScrollback } from '@/lib/orca-cloud/cloud-wake-scrollback' +import { + armWakeScrollback, + forgetWakeScrollback, + noteWakeViewport +} from '@/lib/orca-cloud/cloud-wake-scrollback' import { restartPaneExists } from '@/lib/orca-cloud/cloud-agent-restart-pane-watch' import { mapPaneTerminalErrors } from '../../../errors/terminal-error-accumulation' import type { TerminalPaneController } from '../../../controller/use-terminal-pane-controller' @@ -186,6 +190,22 @@ export function TerminalPaneRecoveryPortals({ controller.worktreeId, asleepSince ]) + // WAKE-SCROLLBACK: a person scrolling the pane while it sleeps or wakes keeps that place after the + // restore, across the wake's remount. Wheel only: writes also scroll, and must not count. + useEffect(() => { + if (!isCloud || !(cloudAsleep || cloudWaking)) { + return + } + const listening = new AbortController() + for (const pane of managedPanes) { + pane.container.addEventListener( + 'wheel', + () => requestAnimationFrame(() => noteWakeViewport(pane.leafId, pane.terminal)), + { passive: true, signal: listening.signal } + ) + } + return () => listening.abort() + }, [isCloud, cloudAsleep, cloudWaking, managedPanes]) // A pane closed, or its tab or workspace went away: drop its kept pre-sleep output. const leafIdsRef = useRef<(typeof managedPanes)[number]['leafId'][]>([]) useEffect(() => { diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.ts index 2131aabd747..acc88f8d12e 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback-render.ts @@ -137,6 +137,27 @@ export function renderBufferRows(buffer: RestoreBuffer, from: number, to: number return out } +/** `renderBufferRows` in 1000-row chunks, yielding between them (off the input path). */ +export async function renderBufferRowsAsync( + buffer: RestoreBuffer, + from: number, + to: number, + chunk = 1000 +): Promise { + let out = '' + for (let start = Math.max(0, from); start < to; start += chunk) { + const end = Math.min(to, start + chunk) + if (start > from && !buffer.getLine(start)?.isWrapped) { + out += '\r\n' + } + out += renderBufferRows(buffer, start, end) + if (end < to) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } + return out +} + const ESC = '\x1b' const BEL = '\x07' diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts index 8dcb1d7a63b..0ddb2835d07 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.test.ts @@ -7,6 +7,8 @@ import { finishWakeScrollbackRestore, forgetWakeScrollback, hasPendingWakeScrollback, + noteBeforeCloudReplay, + noteWakeViewport, wakeScrollbackPreview, resetWakeScrollbackForTests } from './cloud-wake-scrollback' @@ -264,6 +266,78 @@ describe('WAKE-SCROLLBACK', () => { expect(hasPendingWakeScrollback('leaf-f')).toBe(true) }) + // A sleep nobody clicked on this desktop: no early capture; the VM going down makes the transport + // resubscribe, and that replay keeps only the host's 1000 rows before the pane hears "asleep". + it("a sleep nobody clicked here: what the pane held before the host's 1000-row resubscribe comes back", async () => { + const terminal = pane(10) + const seq = Array.from({ length: 2000 }, (_, index) => String(index + 1)).join('\r\n') + await write(terminal, `$ seq 1 2000\r\n${seq}\r\n$ sleep 900\r\n`) + const cloud = (): boolean => true + // The drain, just before the resubscribe's clearing replay. + await noteBeforeCloudReplay('leaf-1', terminal, 'remote:env@@term-old', cloud) + const last1000 = Array.from({ length: 1000 }, (_, index) => String(index + 1001)).join('\r\n') + await write(terminal, `\x1b[2J\x1b[3J\x1b[H${last1000}\r\n$ sleep 900\r\n`) + // Only now does the pane hear the workspace is asleep. + captureWakeScrollback('leaf-1', terminal, { + sleptAt: SLEPT_AT, + agentPane: false, + maxRows: 5000, + ptyId: 'remote:env@@term-old' + }) + armWakeScrollback(['leaf-1']) + expect(await wake(terminal, 'leaf-1', '$ ')).toBe(true) + const lines = text(terminal) + expect(lines[0]).toBe('$ seq 1 2000') + expect(lines[1]).toBe('1') + expect(lines.at(-2)).toMatch(DIVIDER) + }) + + it('keeps the pre-replay copy cheap: only over the host replay size, and only for cloud panes', async () => { + const small = pane(10) + await write(small, '$ ls\r\nREADME\r\n$ ') + // Under the host's 1000 rows: the replay keeps it all, so nothing is copied (and no await added). + expect(noteBeforeCloudReplay('leaf-s', small, 'remote:env@@t', () => true)).toBeNull() + const big = pane(10) + await write(big, Array.from({ length: 1500 }, (_, index) => `row ${index}`).join('\r\n')) + expect(noteBeforeCloudReplay('leaf-b', big, 'remote:env@@t', () => false)).toBeNull() + expect(noteBeforeCloudReplay('leaf-b', big, 'remote:env@@t', () => true)).not.toBeNull() + }) + + it('times the pre-replay copy of a 10k-line pane (rendered in chunks, off the input path)', async () => { + const terminal = new Terminal({ + cols: 80, + rows: 24, + scrollback: 10_000, + allowProposedApi: true + }) + const lines = Array.from( + { length: 10_000 }, + (_, index) => `\x1b[3${index % 8}mline ${index}\x1b[0m` + ) + await write(terminal, lines.join('\r\n')) + vi.useRealTimers() + const started = performance.now() + await noteBeforeCloudReplay('leaf-10k', terminal, 'remote:env@@t', () => true) + const elapsed = performance.now() - started + console.info(`[WAKE-SCROLLBACK] pre-replay copy of a 10k-line pane: ${elapsed.toFixed(1)} ms`) + expect(elapsed).toBeLessThan(2000) + }) + + // m4air (c): waking remounts the pane at the bottom; the place the person scrolled to comes back. + it('a scroll made while waking survives the remount and is put back after the restore', async () => { + const before = pane(5) + await write(before, Array.from({ length: 50 }, (_, index) => `row ${index}`).join('\r\n')) + capture(before, 'leaf-1') + before.scrollToLine(12) + noteWakeViewport('leaf-1', before) + armWakeScrollback(['leaf-1']) + const remounted = pane(5) + expect(await wake(remounted, 'leaf-1', '$ ')).toBe(true) + const active = remounted.buffer.active + expect(active.viewportY).toBe(12) + expect(active.getLine(active.viewportY)!.translateToString(true).trimEnd()).toBe('row 12') + }) + it('a person who had scrolled up keeps their place instead of jumping', async () => { const terminal = pane(5) await write(terminal, Array.from({ length: 50 }, (_, index) => `row ${index}`).join('\r\n')) diff --git a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts index 864c1dcd93f..c97c0ff3eb9 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts @@ -3,9 +3,12 @@ // output the pane showed before the sleep is written back above a "Slept 14:02–14:31 · processes // stopped" divider, client-side only (nothing reaches the PTY), as rendered text + SGR. import { translate } from '@/i18n/i18n' +import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id' +import { isOrcaCloudEnvironmentId } from './orca-cloud-environment-id' import { formatSleptRange, renderBufferRows, + renderBufferRowsAsync, sanitizeRestoredText, type RestoreBuffer } from './cloud-wake-scrollback-render' @@ -22,15 +25,31 @@ type PendingHistory = { armed: boolean /** The pre-sleep terminal: a snapshot for a different one belongs to the woken terminal. */ ptyId: string | null + /** The line the person had scrolled up to (kept across the wake's remount), or null at the bottom. */ + scrolledTo: number | null } +/** + * The pane just before a clearing replay of its own terminal (a resubscribe, e.g. while the VM + * suspends): that replay keeps only the host's 1000 rows. One per pane, replaced, never piled up. + */ +type PreReplaySnapshot = { ptyId: string; at: number; aboveScreen: string; screen: string } +const preReplay = new Map() +/** The host's subscribe/recovery replay keeps this many rows of history. */ +const HOST_REPLAY_ROWS = 1000 +/** A sleep that follows a resubscribe within this long uses what the pane held before it. */ +const PRE_REPLAY_MAX_AGE_MS = 10 * 60_000 + // Keyed by the pane's durable leaf id; cleared once restored, or when the pane goes away. const SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') const pending = new Map() export type ScrollbackSource = { rows: number - buffer: { normal: RestoreBuffer & { baseY: number } } + buffer: { + normal: RestoreBuffer & { baseY: number } + active?: { viewportY: number; baseY: number } + } } function lastTextRow(buffer: RestoreBuffer): number { @@ -78,12 +97,27 @@ export function captureWakeScrollback( } const normal = terminal.buffer.normal const end = options.agentPane ? normal.baseY : lastTextRow(normal) + 1 - const text = renderBufferRows(normal, Math.max(0, end - options.maxRows), end) + let text = renderBufferRows(normal, Math.max(0, end - options.maxRows), end) + // A sleep nobody clicked here (a teammate, idle auto-sleep): the VM was already going down when + // the pane heard, and a resubscribe may have cut it to the host's 1000 rows. What it held just + // before that replay is the fuller copy. + const before = preReplay.get(leafId) + if (before && before.ptyId === options.ptyId && Date.now() - before.at < PRE_REPLAY_MAX_AGE_MS) { + const fuller = options.agentPane + ? before.aboveScreen + : [before.aboveScreen, before.screen].filter((part) => part !== '').join('\r\n') + if (fuller.split('\r\n').length > text.split('\r\n').length) { + text = fuller.split('\r\n').slice(-options.maxRows).join('\r\n') + } + } + preReplay.delete(leafId) + const active = terminal.buffer.active const entry: PendingHistory = { sleptAt: options.sleptAt ?? Date.now(), armed: false, ptyId: options.ptyId ?? null, - text: text.replace(SGR, '').trim() === '' ? null : text + text: text.replace(SGR, '').trim() === '' ? null : text, + scrolledTo: active && active.viewportY < active.baseY ? active.viewportY : null } pending.set(leafId, entry) if (entry.text !== null || !options.readOnlyCopy) { @@ -142,6 +176,45 @@ export function hasPendingWakeScrollback(leafId: string): boolean { export function forgetWakeScrollback(leafIds: Iterable): void { for (const id of leafIds) { pending.delete(id) + preReplay.delete(id) + } +} + +/** + * Before a clearing replay on an Orca Cloud pane with no capture pending: keep what the pane holds, + * if it holds more than the host's replay keeps. Rendered in chunks, off the input path. + */ +export function noteBeforeCloudReplay( + leafId: string, + terminal: ScrollbackSource, + ptyId: string | null, + isCloudPane: (ptyId: string) => boolean = (id) => + isOrcaCloudEnvironmentId(parseRemoteRuntimePtyId(id)?.environmentId ?? '') +): Promise | null { + // Decided synchronously, cheapest facts first: a skipped pane adds no await to the replay path. + if (!ptyId || pending.has(leafId) || !isCloudPane(ptyId)) { + return null + } + const normal = terminal.buffer?.normal + if (!normal || normal.length - terminal.rows <= HOST_REPLAY_ROWS) { + return null + } + return (async () => { + const aboveScreen = await renderBufferRowsAsync(normal, 0, normal.baseY) + const screen = renderBufferRows(normal, normal.baseY, lastTextRow(normal) + 1) + preReplay.set(leafId, { ptyId, at: Date.now(), aboveScreen, screen }) + })() +} + +/** The person scrolled the waking pane: where to put them back after the restore. */ +export function noteWakeViewport( + leafId: string, + terminal: { buffer: { active: { viewportY: number; baseY: number } } } +): void { + const entry = pending.get(leafId) + if (entry) { + const { viewportY, baseY } = terminal.buffer.active + entry.scrolledTo = viewportY < baseY ? viewportY : null } } @@ -193,8 +266,9 @@ export function beginWakeScrollbackRestore( } const { viewportY, baseY } = terminal.buffer.active return { - block: `\x1b[0m${sanitizeRestoredText(entry.text)}\x1b[0m\r\n${wakeDividerLine(entry.sleptAt, wokeAt)}`, - scrolledTo: viewportY < baseY ? viewportY : null + // A remount on wake starts at the bottom: the person's scroll is the one noted before it. + scrolledTo: viewportY < baseY ? viewportY : entry.scrolledTo, + block: `\x1b[0m${sanitizeRestoredText(entry.text)}\x1b[0m\r\n${wakeDividerLine(entry.sleptAt, wokeAt)}` } } @@ -267,4 +341,5 @@ export async function finishWakeScrollbackRestore( /** Tests only. */ export function resetWakeScrollbackForTests(): void { pending.clear() + preReplay.clear() } From f1f0cfc5d3ea35288a4d1f7fe5f0d15a32f5bfe0 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 11 Nov 2026 10:24:54 -0800 Subject: [PATCH 11/11] fix(cloud): WAKE-SCROLLBACK the kept scroll is recorded as a pinned scroll intent, so post-fit intent restores keep it --- .../pty-connection/output/replay-data-drain.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts index 539f858349c..00427d046a1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/output/replay-data-drain.ts @@ -1,4 +1,5 @@ import { RELEASE_SYNCHRONIZED_OUTPUT } from '../../../../../../shared/terminal/protocol/input-modes/terminal-mode-reset-profiles' +import { syncTerminalScrollIntentFromViewport } from '@/lib/pane-manager/terminal-scroll-intent' import { waitForTerminalOutputParsed } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-fit' import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' @@ -199,6 +200,11 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { session.pane.terminal, session.writeReplayDataAsync ) + if (preSleep.scrolledTo !== null) { + // Record the person's place as a pin, so post-fit scroll-intent restores keep it there + // instead of following output to the bottom. + syncTerminalScrollIntentFromViewport(session.pane.terminal, { allowBufferShrink: true }) + } if (!isCurrentPayload()) { continue }