From ee038ad83cc6546d7a762ec06b4372ca3485bbc5 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 13 Oct 2026 17:48:11 +0200 Subject: [PATCH] fix(terminal): the history notice waits for the pull already in flight (#583 merge-time fix) _smoothScrollBy and the touch paths call _maybeLoadMoreHistoryOnScroll on every frame. Once the viewport sat at row 0, the next frame hit the real pull's in-flight early return, so its reveal ran at once and the notice showed the pre-pull tail state for the whole pull. A later frame now leaves the reveal to the call that started the pull. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/terminal-ui.js | 3 +++ test/history-notice-reveal.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 1aefe843..75a37f76 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -3681,6 +3681,9 @@ Object.assign(CodemanApp.prototype, { return; } if (lines === 0 || this.terminal?.buffer?.active?.viewportY !== 0) 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; const sessionId = this.activeSessionId; Promise.resolve(this._maybeRefetchFullHistory?.()) .catch(() => {}) diff --git a/test/history-notice-reveal.test.ts b/test/history-notice-reveal.test.ts index c543a84b..435a3954 100644 --- a/test/history-notice-reveal.test.ts +++ b/test/history-notice-reveal.test.ts @@ -184,6 +184,32 @@ describe('partial-history notice: lazy reveal', () => { expect(loadButton(bar)?.textContent).toBe('Load full history'); }); + it('a later frame of the same gesture does not flash the pre-pull state while the pull runs', async () => { + const { app, bar, buffer } = makeApp(); + app._setHistoryTruncation('s1', TAIL); + // Like the real pull: it marks itself in flight and returns at once to every later caller. + const pull = { release: () => {} }; + app._maybeRefetchFullHistory.mockImplementation(() => { + if (app._fullHistoryRepullInFlight) return Promise.resolve(); + app._fullHistoryRepullInFlight = true; + return new Promise((res) => { + pull.release = () => { + app._fullHistoryRepullInFlight = false; + res(); + }; + }); + }); + buffer.viewportY = 0; + app._maybeLoadMoreHistoryOnScroll(-500); + app._maybeLoadMoreHistoryOnScroll(-1); // the next animation frame, still at row 0 + for (let i = 0; i < 5; i++) await Promise.resolve(); + expect(bar.hidden).toBe(true); + + pull.release(); + for (let i = 0; i < 5; i++) await Promise.resolve(); + expect(bar.hidden).toBe(false); + }); + it('shows what the pull left: nothing, when the pull brought everything back', async () => { const { app, bar, buffer } = makeApp(); app._setHistoryTruncation('s1', TAIL);