diff --git a/src/renderer/src/lib/orca-cloud/cloud-replay-depth-guard.test.ts b/src/renderer/src/lib/orca-cloud/cloud-replay-depth-guard.test.ts index 6cb4d5d6192..e2eb8869382 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-replay-depth-guard.test.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-replay-depth-guard.test.ts @@ -69,6 +69,60 @@ describe('a shallower replay never shrinks a cloud pane (N-02)', () => { expect(after.filter((row) => row === '05000 line')).toHaveLength(1) }) + // V3-02: asleep, woken, then the app restarted. Everything above the divider lived only in this + // pane; the host's (new) terminal has just what came after the wake. + it('keeps the output from before a sleep when the host replay after a restart has none of it', async () => { + const terminal = new Terminal({ cols: 80, rows: 10, scrollback: 6000, allowProposedApi: true }) + const divider = '── Slept 10:46–10:52 AM · processes stopped ──' + const postWake = ['$ seq 5001 5003', '5001', '5002', '5003', 'N02W-POSTWAKE'] + await write(terminal, `${[...lines(1, 300), divider, ...postWake].join('\r\n')}\r\n$ `) + + const kept = await shallowReplay(terminal, `${postWake.join('\r\n')}\r\n$ `) + + expect(kept).toBe(true) + const after = rows(terminal) + expect(after[0]).toBe('00001 line') + expect(after).toContain(divider) + expect(after.filter((row) => row === 'N02W-POSTWAKE')).toHaveLength(1) + expect(after.indexOf(divider)).toBeLessThan(after.indexOf('5001')) + }) + + it('keeps the output from before a sleep when the restarted host shows only a fresh prompt', async () => { + const terminal = new Terminal({ cols: 80, rows: 10, scrollback: 6000, allowProposedApi: true }) + const divider = '── Slept 10:46–10:52 AM · processes stopped ──' + await write(terminal, `${[...lines(1, 40), divider].join('\r\n')}\r\n$ `) + + const kept = await shallowReplay(terminal, '$ ') + + expect(kept).toBe(true) + const after = rows(terminal) + expect(after[0]).toBe('00001 line') + expect(after.at(-2)).toBe(divider) + expect(after.at(-1)).toBe('$') + }) + + it('two sleeps before the restart: keeps everything up to the last divider, nothing doubled', async () => { + const terminal = new Terminal({ cols: 80, rows: 10, scrollback: 6000, allowProposedApi: true }) + const first = '── Slept 9:00–9:30 AM · processes stopped ──' + const second = '── Slept 10:46–10:52 AM · processes stopped ──' + const postWake = ['$ echo after-second', 'after-second'] + await write( + terminal, + `${[...lines(1, 50), first, ...lines(51, 80), second, ...postWake].join('\r\n')}\r\n$ ` + ) + + const kept = await shallowReplay(terminal, `${postWake.join('\r\n')}\r\n$ `) + + expect(kept).toBe(true) + const after = rows(terminal) + expect(after[0]).toBe('00001 line') + expect(after.filter((row) => row === first)).toHaveLength(1) + expect(after.filter((row) => row === second)).toHaveLength(1) + expect(after.filter((row) => row === '00080 line')).toHaveLength(1) + expect(after.filter((row) => row === 'after-second')).toHaveLength(1) + expect(after.indexOf(first)).toBeLessThan(after.indexOf(second)) + }) + it('leaves the replay alone when it does not overlap what the pane held', async () => { const terminal = new Terminal({ cols: 80, rows: 10, scrollback: 6000, allowProposedApi: true }) await write(terminal, `${lines(1, 3000).join('\r\n')}\r\n$ `) @@ -83,6 +137,8 @@ describe('a shallower replay never shrinks a cloud pane (N-02)', () => { const before = ['a', 'b', 'c', 'd', 'e', 'f', 'g'].join('\r\n') expect(mergeDeeperHistory(before, ['tail-of-d', 'e', 'f', 'g'].join('\r\n'))).toBe(before) expect(mergeDeeperHistory(before, ['x', 'y', 'z', 'w'].join('\r\n'))).toBeNull() - expect(mergeDeeperHistory(['a', 'b'].join('\r\n'), ['a', 'b', 'c', 'd'].join('\r\n'))).toBeNull() + expect( + mergeDeeperHistory(['a', 'b'].join('\r\n'), ['a', 'b', 'c', 'd'].join('\r\n')) + ).toBeNull() }) }) diff --git a/src/renderer/src/lib/orca-cloud/cloud-replay-depth-guard.ts b/src/renderer/src/lib/orca-cloud/cloud-replay-depth-guard.ts index 0aa577ec91c..8ffa973a36d 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-replay-depth-guard.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-replay-depth-guard.ts @@ -14,6 +14,11 @@ import { const ANCHOR_LINES = 3 const SGR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') +/** The wake divider ("── Slept 2:02–2:31 PM ──" in any of its forms, or a bare rule). */ +function isWakeDivider(line: string | undefined): boolean { + return /^──( .+ ──|─*)$/.test(plain(line)) +} + function plain(line: string | undefined): string { return (line ?? '').replace(SGR, '').trimEnd() } @@ -27,6 +32,16 @@ function plain(line: string | undefined): string { export function mergeDeeperHistory(paneCopy: string, replayHistory: string): string | null { const before = paneCopy.split('\r\n') const after = replayHistory.split('\r\n') + // V3-02: output from before a sleep lives only in this pane (sleep stopped the host's processes). + // A host replay never carries the wake divider, so everything up to it is kept, then the host's. + let dividerAt = before.length - 1 + while (dividerAt >= 0 && !isWakeDivider(before[dividerAt])) { + dividerAt-- + } + if (dividerAt !== -1 && !after.some(isWakeDivider)) { + const hostLines = after.length === 1 && plain(after[0]) === '' ? [] : after + return [...before.slice(0, dividerAt + 1), ...hostLines].join('\r\n') + } if (after.length < ANCHOR_LINES + 1 || before.length <= after.length) { return null } 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 3e2cf962f65..1a00292bad2 100644 --- a/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts +++ b/src/renderer/src/lib/orca-cloud/cloud-wake-scrollback.ts @@ -39,7 +39,6 @@ type PendingHistory = { 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 @@ -219,7 +218,8 @@ export function noteBeforeCloudReplay( return null } const normal = terminal.buffer?.normal - if (!normal || normal.length - terminal.rows <= HOST_REPLAY_ROWS) { + // Any scrollback: a pane woken from sleep can hold output the host never had (V3-02), at any depth. + if (!normal || normal.baseY <= 0) { return null } return (async () => {