diff --git a/src/web/public/app.js b/src/web/public/app.js index e2b4c68ce..74a93d15e 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; // 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. @@ -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; @@ -9642,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( @@ -9737,6 +9744,10 @@ 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; + // 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, @@ -9750,6 +9761,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 +9776,19 @@ 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 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; + 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 +9804,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 +10358,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 +10379,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 +10418,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 +10426,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 +10437,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 +10847,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..2896aeb75 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 (Array.isArray(this._earlyReplayTravel)) { + this._earlyReplayTravel.push(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..19c5d1f8a 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,202 @@ 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([ + { 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. + for (const move of moves) (mixin._maybeLoadMoreHistoryOnScroll as Function).call(app, move); + expect(app._maybeRefetchFullHistory).toHaveBeenCalledTimes(1); + paused.release(); + await pull; + expect(active.baseY - active.viewportY).toBe(distance); + 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(); + 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