From ede2d9f8d17d1451450429c7dde131fc5e46bd57 Mon Sep 17 00:00:00 2001 From: Hadrien Kerlero <199662351+HadrienKerlero@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:26:52 +0100 Subject: [PATCH 1/2] fix(web): recover the tmux repaint gap above the live screen on the first scroll-up (#814) A tmux repaint burst overwrites rows that never reach xterm scrollback, leaving a hole just above the live screen. The #205 re-pull only fired at viewportY 0, so the user had to scroll past the hole first. Mark a non-shell session dirty on each live write; the first upward scroll from the bottom of a dirty session runs the existing full-history re-pull anchored to the bottom, keeping the viewport's distance from live output and replaying travel made during the replay. Cooldown, downgrade guard and the top-of-buffer path are unchanged. --- src/web/public/app.js | 36 ++++- src/web/public/terminal-ui.js | 38 ++++- test/shell-scroll-history-pull.test.ts | 184 ++++++++++++++++++++++++- 3 files changed, 250 insertions(+), 8 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index e2b4c68ce..935341e6c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -804,6 +804,9 @@ class CodemanApp { // Cooldown per session for the scroll-to-top "load more history" re-pull. this._fullHistoryRepullAt = new Map(); // Map this._fullHistoryRepullInFlight = false; + this._liveOutputSinceFullPull = new Set(); // Set + this._liveOutputGen = new Map(); // Map + this._earlyReplayTravel = null; // Sessions whose last re-pull came back THINNER than the live buffer (a // repaint-mode CLI pane, where tmux keeps no history of its own). The pull is // refused for those and retried far more slowly — see _maybeRefetchFullHistory. @@ -9603,7 +9606,7 @@ class CodemanApp { * gets a much longer cooldown so a hollow pane stops re-fetching megabytes on * every scroll-up (issue #205, round 2). */ - async _maybeRefetchFullHistory({ force = false } = {}) { + async _maybeRefetchFullHistory({ force = false, anchor = 'top' } = {}) { const sessionId = this.activeSessionId; if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return; if (this.detachedSessions?.has(sessionId)) return; @@ -9737,6 +9740,9 @@ class CodemanApp { this._fullHistoryRepullUseless?.delete(sessionId); const rowsBefore = this.terminal.buffer.active.length; const replayStartedAt = performance.now(); + const rowsFromBottom = this.terminal.buffer.active.baseY - this.terminal.buffer.active.viewportY; + const liveOutputGen = this._liveOutputGen?.get(sessionId) || 0; + if (anchor === 'bottom') this._earlyReplayTravel = 0; this._resetTerminalForReplay(); const { parsedAt, @@ -9750,6 +9756,9 @@ class CodemanApp { ); timing.resetAndParseMs = parsedAt - replayStartedAt; if (!completed || this.activeSessionId !== sessionId || selectGen !== this._selectGeneration) return; + if ((this._liveOutputGen?.get(sessionId) || 0) === liveOutputGen) { + this._liveOutputSinceFullPull?.delete(sessionId); + } // Keep shell tab restores bounded too. A user-triggered full-history pull // may be tens of MB; caching it would replay that whole payload again on // the next tab switch before the normal 1MB tail fetch replaces it. @@ -9762,9 +9771,17 @@ class CodemanApp { // UPWARD, so what used to be row 0 (what they were looking at) is now `delta` // rows down; scrolling there reveals the recovered history above it instead // of teleporting them to the bottom the way a normal buffer load does. - const delta = parsedBufferLength - rowsBefore; - if (delta > 0) this.terminal.scrollToLine(delta); - else this.terminal.scrollToTop(); + if (anchor === 'bottom') { + // Recover the tmux repaint gap above the live screen without a jump (#814). + const baseY = this.terminal.buffer.active.baseY; + const distance = rowsFromBottom - this._earlyReplayTravel; + this.terminal.scrollToLine(Math.max(0, Math.min(baseY, baseY - distance))); + this._earlyReplayTravel = null; + } else { + const delta = parsedBufferLength - rowsBefore; + if (delta > 0) this.terminal.scrollToLine(delta); + else this.terminal.scrollToTop(); + } // The load's own replay sampled the sticky-scroll baseline while the // terminal sat at the bottom of a just-rewritten buffer, so the next // flush would scroll back down and undo the restore above. This path is @@ -9780,6 +9797,7 @@ class CodemanApp { } catch { // Transient (offline, 5xx) — the next scroll-up past the cooldown retries. } finally { + if (anchor === 'bottom') this._earlyReplayTravel = null; this._fullHistoryRepullInFlight = false; } } @@ -10333,6 +10351,7 @@ class CodemanApp { const fetchStartedAt = performance.now(); const tailUrl = `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`; let capture; + let fullHistoryReplay = useFullHistory; try { capture = await this._fetchTerminalCapture( useFullHistory @@ -10353,6 +10372,7 @@ class CodemanApp { _crashDiag.log('FULL CAPTURE ABORTED → tail'); // It never loaded, so the next select must be allowed to try again. this._fullHistoryLoaded.delete(sessionId); + fullHistoryReplay = false; capture = await this._fetchTerminalCapture(tailUrl); } const headersReceivedAt = capture.headersAt; @@ -10391,6 +10411,7 @@ class CodemanApp { _crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`); this._setTerminalLoadState(sessionId, selectGen, 'replaying'); const replayStartedAt = performance.now(); + const liveOutputGen = this._liveOutputGen?.get(sessionId) || 0; this._resetTerminalForReplay(); // Truncation is reported OUT OF BAND (#258). This used to write a grey // "... earlier output truncated ..." line into the @@ -10398,7 +10419,7 @@ class CodemanApp { // cannot be actioned, and is indistinguishable from real CLI output. this._setHistoryTruncation(sessionId, data); // Use chunked write for large buffers to avoid UI jank - const { parsedAt: freshParsedAt } = await this.chunkedTerminalWrite( + const { parsedAt: freshParsedAt, completed } = await this.chunkedTerminalWrite( data.terminalBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner, @@ -10409,6 +10430,9 @@ class CodemanApp { this._clearTerminalLoadState(sessionId, selectGen); return; } + if (fullHistoryReplay && completed && (this._liveOutputGen?.get(sessionId) || 0) === liveOutputGen) { + this._liveOutputSinceFullPull?.delete(sessionId); + } // Ensure terminal is scrolled to bottom after buffer load this.terminal.scrollToBottom(); } @@ -10816,6 +10840,8 @@ class CodemanApp { } this.terminalBuffers.delete(sessionId); this.terminalBufferCache.delete(sessionId); + this._liveOutputSinceFullPull?.delete(sessionId); + this._liveOutputGen?.delete(sessionId); this._clearHistoryTruncation(sessionId); this._xtermSnapshots?.delete(sessionId); try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {} diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index eab9a73e5..f3c87df04 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -4064,11 +4064,41 @@ Object.assign(CodemanApp.prototype, { * what the pull left rather than flashing the state it is about to replace. */ _maybeLoadMoreHistoryOnScroll(lines) { + if (typeof this._earlyReplayTravel === 'number') { + this._earlyReplayTravel += lines; + return; + } if (lines > 0) { if (this.isTerminalAtBottom()) this._setHistoryNoticeRevealed?.(null); return; } - if (lines === 0 || this.terminal?.buffer?.active?.viewportY !== 0) return; + if (lines === 0) return; + const buffer = this.terminal?.buffer?.active; + if (buffer?.viewportY !== 0) { + const sessionId = this.activeSessionId; + // tmux repaint bursts can leave a gap just above live output (#814). + // Called after scrollLines: include this gesture's travel in the bottom check. + if ( + lines < 0 && + sessionId && + this._liveOutputSinceFullPull?.has(sessionId) && + this.sessions.get(sessionId)?.mode !== 'shell' && + buffer?.viewportY >= buffer.baseY - 2 - Math.abs(lines) && + !this._fullHistoryRepullInFlight && + !this._isLoadingBuffer + ) { + Promise.resolve(this._maybeRefetchFullHistory?.({ anchor: 'bottom' })) + .catch(() => {}) + .then(() => { + // Travel during the replay is swallowed above, so a gesture that + // reached the top meanwhile still owes the notice its reveal. + if (this.activeSessionId === sessionId && this.terminal?.buffer?.active?.viewportY === 0) { + this._setHistoryNoticeRevealed?.(sessionId); + } + }); + } + return; + } // An earlier frame of this gesture already started the pull; its own // completion reveals the notice, so do not flash the pre-pull state now. if (this._fullHistoryRepullInFlight || this._isLoadingBuffer) return; @@ -4654,6 +4684,12 @@ Object.assign(CodemanApp.prototype, { this._scheduleTerminalWriteFlush(); this._noteMobileImeAuthoritativeOutput(flushedOutputSeq, flushSessionId); }); + // Only live writes can open a new tmux repaint gap; replays cannot (#814). + if (writeChunk.length && flushSessionId && !this._isLoadingBuffer) { + (this._liveOutputSinceFullPull ||= new Set()).add(flushSessionId); + const generations = (this._liveOutputGen ||= new Map()); + generations.set(flushSessionId, (generations.get(flushSessionId) || 0) + 1); + } } catch (err) { this._terminalWriteInFlight = false; this._terminalWriteInFlightBytes = 0; diff --git a/test/shell-scroll-history-pull.test.ts b/test/shell-scroll-history-pull.test.ts index 2b38c3f3f..e80345d59 100644 --- a/test/shell-scroll-history-pull.test.ts +++ b/test/shell-scroll-history-pull.test.ts @@ -68,7 +68,7 @@ function loadTerminalMixin(): Record { return FakeCodemanApp.prototype; } -function loadRefetch(): (this: unknown, opts?: { force?: boolean }) => Promise { +function loadRefetch(): (this: unknown, opts?: { force?: boolean; anchor?: string }) => Promise { const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); const body = methodSource(app, '_maybeRefetchFullHistory'); const context = vm.createContext({ performance, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE: 32 * 1024 }); @@ -134,7 +134,7 @@ function makeApp( rows: 30, // xterm's scrollback option; 0 leaves the browser-cap check out of a test. options: { scrollback }, - buffer: { active: { length: bufferRows, baseY: 0 } }, + buffer: { active: { length: bufferRows, baseY: 0, viewportY: 0 } }, scrollToLine: vi.fn(), scrollToTop: vi.fn(), }, @@ -256,6 +256,186 @@ describe('shell scroll-up pulls a bounded window of tmux history', () => { }); }); +describe('early full-history recovery above live output (#814)', () => { + function makeScrollApp(mode = 'omp') { + const { app: base, urls } = makeApp(mode, { bufferRows: 130, capture: lines(400) }); + const app = Object.assign(base, { + _liveOutputSinceFullPull: new Set(), + _liveOutputGen: new Map(), + _earlyReplayTravel: null as number | null, + pendingWrites: [] as string[], + _wasAtBottomBeforeWrite: false, + _scheduleTerminalWriteFlush: vi.fn(), + _restoreTerminalViewport: vi.fn(), + _noteMobileImeAuthoritativeOutput: vi.fn(), + _hasRecentUserScrollUp: () => false, + isTerminalAtBottom: () => app.terminal.buffer.active.viewportY >= app.terminal.buffer.active.baseY - 2, + _setHistoryNoticeRevealed: vi.fn(), + _maybeRefetchFullHistory: vi.fn((opts?: { anchor?: string }) => refetch.call(app, opts)), + }); + Object.assign(app.terminal.buffer.active, { baseY: 100, viewportY: 100 }); + const active = app.terminal.buffer.active as typeof app.terminal.buffer.active & { viewportY: number }; + Object.assign(app.terminal, { + write: vi.fn((_text: string, done: () => void) => done()), + scrollToBottom: vi.fn(), + scrollLines: (n: number) => { + active.viewportY = Math.max(0, Math.min(active.baseY, active.viewportY + n)); + }, + }); + app.terminal.scrollToLine.mockImplementation((row: number) => { + active.viewportY = row; + }); + app.chunkedTerminalWrite.mockImplementation(async () => { + active.baseY = 370; + active.viewportY = 370; + active.length = 400; + return { parsedAt: performance.now(), bufferLength: 400, completed: true }; + }); + const liveOutput = () => { + (mixin.batchTerminalWrite as Function).call(app, 'live repaint'); + (mixin.flushPendingWrites as Function).call(app); + }; + const scroll = async (n: number) => { + active.viewportY = Math.max(0, Math.min(active.baseY, active.viewportY + n)); + (mixin._maybeLoadMoreHistoryOnScroll as Function).call(app, n); + const pull = app._maybeRefetchFullHistory.mock.results.at(-1); + if (pull) await pull.value; + }; + function pauseReplay(beforeComplete = () => {}) { + const replay = app.chunkedTerminalWrite.getMockImplementation()!; + const { promise: gate, resolve: release } = Promise.withResolvers(); + const { promise: ready, resolve: started } = Promise.withResolvers(); + app.chunkedTerminalWrite.mockImplementationOnce(async () => { + app._isLoadingBuffer = true; + active.baseY = 0; + active.viewportY = 0; + started(); + await gate; + const result = await replay(); + app._isLoadingBuffer = false; + beforeComplete(); + return result; + }); + return { ready, release }; + } + return { app, active, urls, liveOutput, scroll, pauseReplay }; + } + + it('pulls on the first upward scroll after live output and preserves distance from the new bottom', async () => { + const { app, active, urls, liveOutput, scroll } = makeScrollApp(); + liveOutput(); + await scroll(-5); + expect(urls).toEqual(['/api/sessions/s1/terminal?full=1']); + expect(active.viewportY).toBe(365); + expect(active.baseY - active.viewportY).toBe(5); + expect(app._liveOutputSinceFullPull.has('s1')).toBe(false); + expect(app._syncStickyScrollBaseline).toHaveBeenCalledOnce(); + // No second early pull until another live write dirties this session. + active.viewportY = active.baseY; + app._fullHistoryRepullAt.clear(); + await scroll(-3); + expect(urls).toHaveLength(1); + }); + + it.each([ + { travel: -12, distance: 17 }, + { travel: 20, distance: 0 }, + { travel: -1000, distance: 370 }, + ])('preserves travel $travel during an unfinished replay, clamped to the buffer', async ({ travel, distance }) => { + const { app, active, liveOutput, scroll, pauseReplay } = makeScrollApp(); + liveOutput(); + const paused = pauseReplay(); + const pull = scroll(-5); + await paused.ready; + // The temporary reset buffer cannot scroll, but the gesture still counts. + active.viewportY = Math.max(0, Math.min(active.baseY, active.viewportY + travel)); + (mixin._maybeLoadMoreHistoryOnScroll as Function).call(app, travel); + expect(app._maybeRefetchFullHistory).toHaveBeenCalledTimes(1); + paused.release(); + await pull; + expect(active.baseY - active.viewportY).toBe(distance); + expect(app._earlyReplayTravel).toBeNull(); + }); + + it('keeps live output flushed before the final restore dirty for the next early pull', async () => { + const { app, active, urls, liveOutput, scroll, pauseReplay } = makeScrollApp(); + liveOutput(); + const generation = app._liveOutputGen.get('s1'); + const paused = pauseReplay(liveOutput); + const pull = scroll(-5); + await paused.ready; + paused.release(); + await pull; + expect(app._liveOutputGen.get('s1')).toBe(generation! + 1); + expect(app._liveOutputSinceFullPull.has('s1')).toBe(true); + app._fullHistoryRepullAt.clear(); + active.viewportY = active.baseY; + await scroll(-3); + expect(urls).toEqual(['/api/sessions/s1/terminal?full=1', '/api/sessions/s1/terminal?full=1']); + expect(app._liveOutputSinceFullPull.has('s1')).toBe(false); + }); + + it('keeps a clean non-shell session local until the existing top path is reached', async () => { + const { app, active, urls, scroll } = makeScrollApp(); + await scroll(-5); + expect(urls).toEqual([]); + await scroll(-active.viewportY); + expect(urls).toEqual(['/api/sessions/s1/terminal?full=1']); + expect(app._maybeRefetchFullHistory).toHaveBeenCalledWith(); + expect(app.terminal.scrollToLine).toHaveBeenCalledWith(270); + }); + + it('never pulls early for a shell, even after live output', async () => { + const { urls, liveOutput, scroll } = makeScrollApp('shell'); + liveOutput(); + await scroll(-5); + expect(urls).toEqual([]); + await scroll(-95); + expect(urls).toEqual([`/api/sessions/s1/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`]); + }); + + it('does not dirty a session while replay loading queues its output', () => { + const { app, liveOutput } = makeScrollApp(); + app._isLoadingBuffer = true; + liveOutput(); + expect(app._liveOutputSinceFullPull.size).toBe(0); + }); + + it('does not pull early for downward travel or while already reading older rows', async () => { + const { active, urls, liveOutput, scroll } = makeScrollApp(); + liveOutput(); + active.viewportY = 50; + await scroll(-5); + active.viewportY = 95; + await scroll(3); + expect(urls).toEqual([]); + }); + + it.each(['_isLoadingBuffer', '_fullHistoryRepullInFlight'] as const)('respects the %s guard', async (guard) => { + const { app, urls, liveOutput, scroll } = makeScrollApp(); + liveOutput(); + app[guard] = true; + await scroll(-5); + expect(urls).toEqual([]); + }); + + it('keeps the cooldown and downgrade guard on an early pull', async () => { + const { app, active, urls, liveOutput, scroll } = makeScrollApp(); + liveOutput(); + app._fullHistoryRepullAt.set('s1', Date.now()); + await scroll(-3); + expect(urls).toEqual([]); + app._fullHistoryRepullAt.clear(); + active.viewportY = active.baseY; + active.length = 1000; + await scroll(-3); + expect(urls).toHaveLength(1); + expect(app._resetTerminalForReplay).not.toHaveBeenCalled(); + expect(app._liveOutputSinceFullPull.has('s1')).toBe(true); + expect(app._fullHistoryRepullUseless.has('s1')).toBe(true); + }); +}); + describe('a skipped bounded window never damages the Load full history banner', () => { it('a tail-cut window smaller than the browser is not replayed and never marks the session exhausted', async () => { // The browser holds far more rows than a 1 MiB window carries, and tmux holds From a9a3c6f3eba62e0def1fa737c0ec343e3edb6e17 Mon Sep 17 00:00:00 2001 From: Hadrien Kerlero <199662351+HadrienKerlero@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:56:45 +0100 Subject: [PATCH 2/2] fix(web): keep fetch-time output dirty and clamp replay travel per move (#814) Review follow-up. The early re-pull sampled the live-output generation after its fetch, but nothing gates live output while the capture downloads, so output newer than the capture was overwritten and the session marked clean. Sample before the fetch. Travel made during the replay was one signed sum clamped once, so scrolling to the bottom and back up cancelled out. Record moves in order and clamp after each, as live scrolling does. --- src/web/public/app.js | 17 ++++++++++----- src/web/public/terminal-ui.js | 4 ++-- test/shell-scroll-history-pull.test.ts | 30 ++++++++++++++++++++------ 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 935341e6c..74a93d15e 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -806,7 +806,7 @@ class CodemanApp { this._fullHistoryRepullInFlight = false; this._liveOutputSinceFullPull = new Set(); // Set this._liveOutputGen = new Map(); // Map - this._earlyReplayTravel = null; + this._earlyReplayTravel = null; // number[] while an early (#814) replay runs // Sessions whose last re-pull came back THINNER than the live buffer (a // repaint-mode CLI pane, where tmux keeps no history of its own). The pull is // refused for those and retried far more slowly — see _maybeRefetchFullHistory. @@ -9645,6 +9645,10 @@ class CodemanApp { // replaying its own capture, and the reset below would land in the middle // of that write. The select generation moves on every switch, so it can. const selectGen = this._selectGeneration; + // Sampled BEFORE the fetch: this path does not gate live output while the + // capture downloads, so output flushed after tmux captured would otherwise + // be overwritten by the replay and still count as covered by it (#814). + const liveOutputGen = this._liveOutputGen?.get(sessionId) || 0; try { const requestStartedAt = performance.now(); const capture = await this._fetchTerminalCapture( @@ -9741,8 +9745,9 @@ class CodemanApp { const rowsBefore = this.terminal.buffer.active.length; const replayStartedAt = performance.now(); const rowsFromBottom = this.terminal.buffer.active.baseY - this.terminal.buffer.active.viewportY; - const liveOutputGen = this._liveOutputGen?.get(sessionId) || 0; - if (anchor === 'bottom') this._earlyReplayTravel = 0; + // Ordered, not summed: each move clamps at the buffer edge as it would + // have live, so a reversal after hitting the bottom is not cancelled out. + if (anchor === 'bottom') this._earlyReplayTravel = []; this._resetTerminalForReplay(); const { parsedAt, @@ -9774,8 +9779,10 @@ class CodemanApp { if (anchor === 'bottom') { // Recover the tmux repaint gap above the live screen without a jump (#814). const baseY = this.terminal.buffer.active.baseY; - const distance = rowsFromBottom - this._earlyReplayTravel; - this.terminal.scrollToLine(Math.max(0, Math.min(baseY, baseY - distance))); + const clamp = (row) => Math.max(0, Math.min(baseY, row)); + let row = clamp(baseY - rowsFromBottom); + for (const move of this._earlyReplayTravel) row = clamp(row + move); + this.terminal.scrollToLine(row); this._earlyReplayTravel = null; } else { const delta = parsedBufferLength - rowsBefore; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index f3c87df04..2896aeb75 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -4064,8 +4064,8 @@ Object.assign(CodemanApp.prototype, { * what the pull left rather than flashing the state it is about to replace. */ _maybeLoadMoreHistoryOnScroll(lines) { - if (typeof this._earlyReplayTravel === 'number') { - this._earlyReplayTravel += lines; + if (Array.isArray(this._earlyReplayTravel)) { + this._earlyReplayTravel.push(lines); return; } if (lines > 0) { diff --git a/test/shell-scroll-history-pull.test.ts b/test/shell-scroll-history-pull.test.ts index e80345d59..19c5d1f8a 100644 --- a/test/shell-scroll-history-pull.test.ts +++ b/test/shell-scroll-history-pull.test.ts @@ -262,7 +262,7 @@ describe('early full-history recovery above live output (#814)', () => { const app = Object.assign(base, { _liveOutputSinceFullPull: new Set(), _liveOutputGen: new Map(), - _earlyReplayTravel: null as number | null, + _earlyReplayTravel: null as number[] | null, pendingWrites: [] as string[], _wasAtBottomBeforeWrite: false, _scheduleTerminalWriteFlush: vi.fn(), @@ -338,18 +338,20 @@ describe('early full-history recovery above live output (#814)', () => { }); it.each([ - { travel: -12, distance: 17 }, - { travel: 20, distance: 0 }, - { travel: -1000, distance: 370 }, - ])('preserves travel $travel during an unfinished replay, clamped to the buffer', async ({ travel, distance }) => { + { moves: [-12], distance: 17 }, + { moves: [20], distance: 0 }, + { moves: [-1000], distance: 370 }, + // Reversal after an edge: each move clamps as it would have live. + { moves: [20, -12], distance: 12 }, + { moves: [-1000, 30], distance: 340 }, + ])('replays travel $moves made during an unfinished replay, clamped per move', async ({ moves, distance }) => { const { app, active, liveOutput, scroll, pauseReplay } = makeScrollApp(); liveOutput(); const paused = pauseReplay(); const pull = scroll(-5); await paused.ready; // The temporary reset buffer cannot scroll, but the gesture still counts. - active.viewportY = Math.max(0, Math.min(active.baseY, active.viewportY + travel)); - (mixin._maybeLoadMoreHistoryOnScroll as Function).call(app, travel); + for (const move of moves) (mixin._maybeLoadMoreHistoryOnScroll as Function).call(app, move); expect(app._maybeRefetchFullHistory).toHaveBeenCalledTimes(1); paused.release(); await pull; @@ -357,6 +359,20 @@ describe('early full-history recovery above live output (#814)', () => { expect(app._earlyReplayTravel).toBeNull(); }); + it('keeps a session dirty when live output lands while the capture is still downloading', async () => { + const { app, urls, liveOutput, scroll } = makeScrollApp(); + liveOutput(); + const fetchCapture = app._fetchTerminalCapture.getMockImplementation()!; + app._fetchTerminalCapture.mockImplementationOnce(async (url: string) => { + const capture = await fetchCapture(url); + // tmux has captured; this output is newer than the capture the replay writes. + liveOutput(); + return capture; + }); + await scroll(-5); + expect(urls).toHaveLength(1); + expect(app._liveOutputSinceFullPull.has('s1')).toBe(true); + }); it('keeps live output flushed before the final restore dirty for the next early pull', async () => { const { app, active, urls, liveOutput, scroll, pauseReplay } = makeScrollApp(); liveOutput();