fix(terminal): load tmux history instead of paging Claude after a hollow tab switch

After a server restart the byte recording a tab switch replays (`?tail=`) holds only the tmux client's attach repaint, so an inline Claude session came back with an empty local buffer while tmux still held its scrollback. The hollow-buffer fallback then sent PageUp/PageDown to Claude, which moved the composer cursor instead of the transcript, until a page reload. Pre-existing on an unmodified build; every restart of the server showed it.

The paging fallback now stands down when the last capture reported tmux scrollback (`paneHistoryLines` > 0), dropping the gesture debt and any page keys already queued, so the gesture falls through to the local path, whose scroll-to-top pull fetches `full=1` and the gesture scrolls on from there. Zero and unknown counts keep paging (fullscreen Claude, codex, opencode, remote and Docker panes). The pull no longer inherits a downgrade refusal's 60 s latch for such a buffer, drops a completion that outlived a tab switch back to the same session (select generation, not only the session id), records no count from an error envelope (a JSON 5xx would otherwise forget it and hand the next gesture back to paging), and re-positions the local-echo overlay after its rewrite. The refresh path records its count only once it still owns the terminal, for the same reason. The `[scroll]` line prints `paneHistoryLines` so an unloaded buffer reads apart from a hollow one, and the invariant docs record the exception.

Measured with phone emulation after a restart: before, the second visit landed at baseY 0 and every swipe sent PageUp; after, the first swipe fetched the history once with nothing sent to Claude and the gesture scrolled 21 rows, with an unsent draft intact on the overlay.
This commit is contained in:
JD committed 2026-10-22 15:13:01 -04:00
1 parent 002de32a08
commit 84adb701d7
9 files changed
+301 -16

No files matched your search

+35 -7
View File
@@ -3351,11 +3351,15 @@ class CodemanApp {
headersReceivedAt = capture.headersAt;
data = capture.json?.data ?? {};
}
this._notePaneHistory?.(sessionId, data);
// Bail on a tab switch mid-fetch: writing here would paint this session's
// history into the terminal the user is now looking at. The window is two
// fetches wide in the fallback case, so this guard is not optional.
// fetches wide in the fallback case, so this guard is not optional. The
// pane count is recorded only past it: a refresh that lost its owner is
// older than whatever the switch back read (it would have overwritten a
// fresh 0 with a stale count and held the paging fallback off a hollow
// pane), and an error envelope is not a capture at all.
if (this.activeSessionId !== sessionId || this._terminalRefreshOwner !== refreshOwner) return false;
if (capture.json?.success !== false) this._notePaneHistory?.(sessionId, data);
if (data.terminalBuffer) {
// This refresh is SERVER-triggered, so a user quietly reading scrollback
// did not ask for it and must not be dragged to the bottom by it (#259).
@@ -9238,10 +9242,23 @@ class CodemanApp {
// `force` is the user pressing "Load full history" (#258): they asked once,
// explicitly, so the scroll-gesture cooldown does not apply. The downgrade
// guard below still does — a forced pull must not destroy history either.
const cooldown = this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000;
// A refused pull latches 60 s because the browser then held MORE than tmux
// (the downgrade guard below). A tab switch that replayed a hollow tail has
// voided that premise: one screen in the browser while the pane holds
// scrollback, the state `_maybePageCliTranscript` hands to this pull instead
// of paging the CLI. Hold such a buffer to the normal cooldown only, or the
// gesture is dead for up to a minute.
const unloaded =
(this._paneHistoryLines?.get(sessionId) ?? 0) > 0 && (this.terminal?.buffer?.active?.baseY ?? 0) === 0;
const cooldown = !unloaded && this._fullHistoryRepullUseless?.has(sessionId) ? 60000 : 4000;
if (!force && now - (this._fullHistoryRepullAt.get(sessionId) || 0) < cooldown) return;
this._fullHistoryRepullAt.set(sessionId, now);
this._fullHistoryRepullInFlight = true;
// The session-id check below cannot see a tab switch that came BACK during
// the fetch (A -> B -> A): the id matches again while `selectSession` is
// 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;
try {
const requestStartedAt = performance.now();
const capture = await this._fetchTerminalCapture(
@@ -9253,7 +9270,13 @@ class CodemanApp {
const headersReceivedAt = capture.headersAt;
const payload = capture.json?.data ?? {};
const bodyParsedAt = performance.now();
this._notePaneHistory?.(sessionId, payload);
// A completion that outlived a tab switch is older than whatever that
// switch recorded about the pane (a switch back re-reads it): keep its
// count out of the memory, or it can overwrite a fresh count with a stale one.
// An error envelope (a JSON 5xx, `success: false`) is not a capture:
// recording its empty data would forget the count and hand the next
// gesture back to the paging fallback, the bug this pull exists to avoid.
if (selectGen === this._selectGeneration && capture.json?.success !== false) this._notePaneHistory?.(sessionId, payload);
const buffer = payload.terminalBuffer;
const timing = {
trigger: force ? 'full-history-button' : 'full-history-scroll',
@@ -9269,8 +9292,9 @@ class CodemanApp {
refused: false,
};
// Bail on a tab switch mid-fetch: writing here would paint another session's
// history into the terminal the user is now looking at.
if (!buffer || this.activeSessionId !== sessionId) return;
// history into the terminal the user is now looking at, or this session's
// over the load a switch back has started.
if (!buffer || this.activeSessionId !== sessionId || selectGen !== this._selectGeneration) return;
const windowRows = this._estimateReplayRows(buffer, this.terminal.cols);
// A bounded window no longer than the browser's buffer buys nothing, and
// resetting to rewrite it would jump the viewport on every scroll that
@@ -9342,7 +9366,7 @@ class CodemanApp {
this._bufferLoadFinishOpts(payload, headersReceivedAt)
);
timing.resetAndParseMs = parsedAt - replayStartedAt;
if (!completed || this.activeSessionId !== sessionId) return;
if (!completed || this.activeSessionId !== sessionId || selectGen !== this._selectGeneration) return;
// 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.
@@ -9364,6 +9388,10 @@ class CodemanApp {
// reached only from a scroll-up gesture, so being dragged down is the
// exact opposite of what the user asked for.
this._syncStickyScrollBaseline();
// The prompt row moved with the rewrite: re-position the local-echo
// overlay there, as the refresh path does (a phone swipe reaches this
// pull with unsent text on the overlay).
this._localEchoOverlay?.rerender();
timing.totalMs = performance.now() - requestStartedAt;
this._recordTerminalLoadTiming(timing);
} catch {
+4
View File
@@ -1176,6 +1176,10 @@
// names no CLI. The gesture rules (first-event page, notch accumulation,
// pinch and sideways-swipe consume) are the shared pure
// CodemanTerminalInput.pageKeysForGesture, run on this tile's own state.
// The primary pane's unloaded-scrollback fall-through (`paneHistoryLines`
// > 0 with baseY 0, after a restart's tail replay) has no twin here: a tile
// loads tmux's rendered history (`full=1`), so it does not share that
// tail-select failure, and its non-shell wheel has no pull to fall through to.
// Returns true when the wheel was consumed here.
_maybePageCliTranscript(ev) {
if (this._destroyed || !this.terminal || !ev || ev.shiftKey) return false;
+29 -5
View File
@@ -5883,7 +5883,8 @@ Object.assign(CodemanApp.prototype, {
// a fix. Scoping the setting away from those modes would be the other
// option, but it would override an explicit user choice; instead the caller
// falls through to _maybePageCliTranscript, so the gesture still pages the
// CLI's transcript and the setting keeps meaning exactly what it says.
// CLI's transcript (or loads unloaded tmux scrollback, see there) and the
// setting keeps meaning exactly what it says.
if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false;
const mode = (target.terminal || this.terminal)?.modes?.mouseTrackingMode;
if (mode && mode !== 'none') return false;
@@ -6038,8 +6039,11 @@ Object.assign(CodemanApp.prototype, {
*
* Guarded by `_localScrollbackIsHollow()` (the `transcriptPageKeys` capability +
* `baseY === 0`) plus a false forwarding gate, so a session with real local
* scrollback is never touched. Shift is excluded on purpose: it is the explicit
* "give me local scrollback" gesture and must keep that meaning.
* scrollback is never touched, and neither is one whose scrollback tmux still
* holds but the last replay did not carry (`paneHistoryLines` > 0): that one
* falls through to the local path, whose scroll-to-top pull loads it. Shift is
* excluded on purpose: it is the explicit "give me local scrollback" gesture
* and must keep that meaning.
*
* The FIRST event of a trackpad-sized gesture (opening under
* PAGE_KEY_IMMEDIATE_MAX_ROWS) pages at once. Waiting for half a screen of
@@ -6083,6 +6087,22 @@ Object.assign(CodemanApp.prototype, {
this._logScrollRouting('page-keys-dialog-open');
return true;
}
// A hollow buffer whose PANE still holds tmux scrollback is unloaded, not
// hollow: after a server restart a tab switch replays a byte tail that is
// only the attach repaint (baseY 0), and paging from there moved an inline
// Claude's composer cursor instead of its transcript. The remembered
// `paneHistoryLines` (tmux `#{history_size}`) says a `full=1` pull can add
// it, so fall through to local scrolling, whose top-of-buffer pull fetches
// it (_maybeLoadMoreHistoryOnScroll -> _maybeRefetchFullHistory). 0 and
// unknown keep paging. Page keys already queued belong to the abandoned
// route and go with the gesture debt. Not logged as a decision of its own
// (it would alternate with the caller's line every frame until the pull
// lands): the caller's `local-scrollback` line carries `paneHistoryLines`.
if ((this._paneHistoryLines?.get(this.activeSessionId) ?? 0) > 0) {
this._pageKeyGesture = null;
this._wheelSgrQueue = '';
return false;
}
// Leftover travel and the gesture in progress belong to the tab they were made on.
if (this._pageKeySession !== this.activeSessionId) {
this._pageKeySession = this.activeSessionId;
@@ -6124,16 +6144,20 @@ Object.assign(CodemanApp.prototype, {
// the DECSETs), so the CLI's real tracking state is reported separately.
const cliTracking = session?.cliMouseTracking === true;
const baseY = this.terminal?.buffer?.active?.baseY ?? -1;
// What tmux still holds for this pane (the last capture's `paneHistoryLines`):
// `localScrollbackRows=0` beside a positive count is an UNLOADED buffer,
// which _maybePageCliTranscript hands to the local path instead of paging.
const paneHistory = this._paneHistoryLines?.get(sessionId);
const signature =
`${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|` +
`${tracking}|${encoding}|${cliTracking}|${baseY > 0}`;
`${tracking}|${encoding}|${cliTracking}|${baseY > 0}|${(paneHistory ?? 0) > 0}`;
if (!this._scrollRoutingLogged) this._scrollRoutingLogged = new Map();
if (this._scrollRoutingLogged.get(sessionId) === signature) return;
this._scrollRoutingLogged.set(sessionId, signature);
console.log(
`[scroll] ${sessionId} → ${decision} (mode=${session?.mode || '?'}, cliVersion=${session?.cliVersion || 'unknown'}, ` +
`localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, mouseEncoding=${encoding}, ` +
`cliMouseTracking=${cliTracking}, localScrollbackRows=${baseY})`
`cliMouseTracking=${cliTracking}, localScrollbackRows=${baseY}, paneHistoryLines=${paneHistory ?? 'unknown'})`
);
},