diff --git a/web/src/Terminal.tsx b/web/src/Terminal.tsx index dd6b1c14..90da21bf 100644 --- a/web/src/Terminal.tsx +++ b/web/src/Terminal.tsx @@ -25,8 +25,10 @@ import { createReplayQueue, prepareReplayTail, scheduleReplay, + shouldDeferCacheReplay, snapshotStartPosition, type ReplayHandle, + type ReplayTail, } from "./terminalReplay"; import { beginForegroundReplay } from "./terminalPrewarm"; import { @@ -142,6 +144,10 @@ const TerminalView: Component = (props) => { let snapshotEndPosition: number | undefined; let cacheChunks: Uint8Array[] = []; let cacheBytes = 0; + // A parked pane's cached tail, prepared but not yet replayed. Kept in memory + // on reload and replayed lazily on first activation (F3), so retained tabs do + // not time-slice the shared replay parser with the active pane. + let deferredCacheReplay: ReplayTail | null = null; let cacheTimer: ReturnType | null = null; // The outputPosition at the last successful persist, so an unchanged ring is // not re-copied and re-written to IndexedDB. @@ -817,23 +823,18 @@ const TerminalView: Component = (props) => { const prepared = prepareReplayTail(bytes, cached.outputPosition); cacheChunks = [bytes]; cacheBytes = bytes.byteLength; + // Adopt the cache's position now so a warm reattach resumes from it even + // if the visible replay is deferred (or later cancelled by a re-park). + outputPosition = prepared.outputPosition; + if (shouldDeferCacheReplay(isParked(), true)) { + // Parked on reload: keep the tail in memory and replay it on the first + // activation, not into the shared FIFO with the active pane (F3). + deferredCacheReplay = prepared; + setReadyToConnect(true); + return; + } setStatusText("Restoring terminal..."); - replay = scheduleReplay( - props.sessionId, - [prepared.data], - (chunk, done) => { - if (!term) { - done(); - return; - } - term.write(chunk, done); - }, - { - kind: "cache", - droppedBytes: prepared.droppedBytes, - droppedLines: prepared.droppedLines, - }, - ); + replay = replayCacheTail(prepared); void replay.done.then(() => { if (destroyed) return; outputPosition = prepared.outputPosition; @@ -896,6 +897,26 @@ const TerminalView: Component = (props) => { connect(); } + /** Replay a prepared cache tail into xterm through the shared replay queue. */ + function replayCacheTail(prepared: ReplayTail): ReplayHandle { + return scheduleReplay( + props.sessionId, + [prepared.data], + (chunk, done) => { + if (!term) { + done(); + return; + } + term.write(chunk, done); + }, + { + kind: "cache", + droppedBytes: prepared.droppedBytes, + droppedLines: prepared.droppedLines, + }, + ); + } + function connect() { if (isParked()) return; if (isSessionGone()) { markSessionGone(); return; } @@ -1083,6 +1104,22 @@ const TerminalView: Component = (props) => { if (destroyed || !readyToConnect() || isParked()) return; // Resuming to the foreground: hold the pre-warm gate through this attach. enterForegroundReplay(); + // A tab parked on reload deferred its cache replay (F3); run it now, before + // attaching, so the restored scrollback is on screen when the delta arrives. + const pending = deferredCacheReplay; + if (pending) { + deferredCacheReplay = null; + setStatusText("Restoring terminal..."); + replay?.cancel(); + replay = replayCacheTail(pending); + void replay.done.then(() => { + if (destroyed || isParked()) return; + outputPosition = pending.outputPosition; + term?.scrollToBottom(); + connect(); + }); + return; + } setStatusText("Loading terminal..."); connect(); } diff --git a/web/src/__tests__/shouldDeferCacheReplay.test.ts b/web/src/__tests__/shouldDeferCacheReplay.test.ts new file mode 100644 index 00000000..1e5a1a90 --- /dev/null +++ b/web/src/__tests__/shouldDeferCacheReplay.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { shouldDeferCacheReplay } from "../terminalReplay"; + +describe("shouldDeferCacheReplay", () => { + it("defers only a parked pane that has cached bytes", () => { + // A retained-but-parked tab on reload keeps its cache in memory and replays + // it lazily on activation, not into the shared FIFO with the active pane. + expect(shouldDeferCacheReplay(true, true)).toBe(true); + }); + + it("does not defer the active pane", () => { + // The pane the user is looking at replays its cache immediately. + expect(shouldDeferCacheReplay(false, true)).toBe(false); + }); + + it("does not defer when there is nothing cached", () => { + // No cache means nothing to replay: a parked pane with an empty cache just + // waits to connect, it does not enter the deferred path. + expect(shouldDeferCacheReplay(true, false)).toBe(false); + expect(shouldDeferCacheReplay(false, false)).toBe(false); + }); +}); diff --git a/web/src/terminalReplay.ts b/web/src/terminalReplay.ts index 31552963..fe1c56e5 100644 --- a/web/src/terminalReplay.ts +++ b/web/src/terminalReplay.ts @@ -11,6 +11,23 @@ export const REPLAY_TAIL_MAX_BYTES = 1 * 1024 * 1024; /** Keep each xterm parser turn bounded and aligned with the server frame size. */ export const REPLAY_SLICE_BYTES = 64 * 1024; +/** + * On reload, should a pane defer its cached-scrollback replay? + * + * A parked pane — a retained tab that is not the active one, or the unfocused + * half of a split — must NOT replay its cache into the shared replay FIFO on + * reload: N retained tabs replaying at once time-slice the single parser N ways + * and starve the pane the user is actually looking at. A parked pane keeps its + * cache in memory and replays lazily on first activation (`resumeSocket`) + * instead. An active pane with a cache replays immediately. + */ +export function shouldDeferCacheReplay( + parked: boolean, + hasCachedBytes: boolean, +): boolean { + return parked && hasCachedBytes; +} + /** * The absolute output position at the START of a snapshot payload: the byte * offset the first snapshot byte sits at. The server reports the position at