Agent chat: a reopened chat shows its whole history, with no band above the composer

Merged
TE
17d ago
+493-0

Leo, 2026-10-21: he reopened a closed chat (the coordinator's, claude-sr2) and saw a big blank gap between the transcript and the composer, with only the last messages in it — no older history, and he could not scroll up.

First finding: his nightly predates the fixes

Leo's nightly is c78e7b2, built 00:59Z. Every history and scroll PR merged after it:

PRmergedin c78e7b2?
#22794 older history keeps loading past a big paste02:46Zno
#22831 history with an oversized record at the top03:01Zno
#22858 transcript stays pinned while a reply streams03:35Zno
#22915 deleting a queued prompt leaves no gap04:12Zno
#22910 incoming messages estimated as drawn05:02Zno

So the build he was using has none of them. That is a hypothesis about why, not proof — this PR is the proof.

Second finding: a reopened chat is an ordinary cold chat

Worth recording, because it means this is not a reopen-specific code path. Closing a chat tab releases its page and its wake state (AgentTabs+Lifecycle.swift release), so a reopened tab arrives as a fresh handshake naming the session, then connect → attach, with wake: nil and no launch image. The only thing it gets back is the reading place, from agent-scroll-anchors.json.

So everything a reopened chat shows is one attach page, and acpmux ends a page before its records' JSON passes maxBytes (hub/paging.rs events_page, 128 KiB for an open), always keeping at least one record. A coordinator chat holds records that are huge in bytes and tiny on screen — a prompt queued with a big paste (up to 4 MB), a tool run with long output — so its page can be a single record that draws no transcript row at all. Everything else has to arrive through the older-history loader. That is exactly the failure mode #22794 and #22831 fixed.

The test

webviews/test/reopened-chat-history.test.ts opens three chats of that shape in the real pane page, against a stand-in acpmux that pages exactly as events_page does (open capped at 128 KiB, older pages at 256 KiB, always at least one record), and holds each to what a reopened chat owes the reader: rows drawn, the last message just above the composer, the view at the bottom, and scrolling up reaching the chat's first turn.

chatrowsgap above composerat bottomfirst turn reached
queuedPaste — 14 turns, then a 160 KiB queued paste340 pxyesyes, 1 older read
manyPastes — three 300 KiB pastes in a row340 pxyesyes, 3 older reads
pasteWall — ten 300 KiB pastes, then two older turns340 pxyesyes, 11 older reads
longText — 40 turns, no oversized record6−0.25 pxyesyes, 2 older reads

The proof: red on Leo's nightly, green on base

I ran this same file against Leo's nightly tree (webviews/src at c78e7b2), then bisected. The
manyPastes chat reproduces both of his symptoms, exactly as he described them:

manyPastesrowsgap above composerscrollablefirst turn reached
nightly c78e7b2 (what Leo ran)2507 px0 pxno
7819daf21^ (just before #22794)2542 px0 pxno
7819daf21 (#22794's merge)330 px1493 pxyes
base 07c4dc77e (now)340 px1421 pxyes

A 507 px blank band in a 776 px viewport, two rows in it, and a transcript that cannot scroll at
all. That is Leo's screenshot.

So: fixed in base by #22794, and it reaches Leo in the next nightly. His build was 1 h 47 min
too early — c78e7b2 was cut 00:59Z, #22794 merged 02:46Z. Nothing more to fix here; this PR lands
the test as the regression guard, since nothing covered the gap above the composer before
(agent-pane-chat-column.test.ts samples that distance but only against its own resting value, so a
large resting gap passed).

Note which shape it took: queuedPaste (one big paste) and longText pass even on the nightly. It
takes consecutive oversized records — every older page is one of them, so the loader must ask
more than once with no new row to show for it — to wedge paging. That is precisely the one-ask-per-key
stall #22794 fixed, and it is why that fixture is in the file.

What took the longest: a test that can report the bug on a tree without it

Two reviews went at the settle detector, and the second one broke it. It was a wall clock — nothing
in flight, nothing answered for 250 ms, then eight still animation frames — and the review measured
the gaps that really occur: 526 ms between a reply and the read it starts on an unloaded machine,
seconds on a throttled renderer. So the clock could expire mid-load, and a chat caught between
two older pages has the bug's own numbers. It proved it by writing the fixture this file's header
describes but did not have — more rowless pages in a row than one ask reads — and running it at a
24× throttled renderer: rows 6, lastLine 373, scrollable 0 on the tree with the fix. Leo's
fingerprint, as a false red.

The gate is now what historyExhausted is. The stand-in daemon reports when it has served the
chat's oldest record through a transcript read; a chat is read through when it has drawn a row
and either that record is in, so nothing older is left and no later page can add a row above what is
drawn, or the transcript already overflows its viewport, which is where the older-history loader
stops asking. Nothing a later read can do then moves the last row off the composer. No clock decides
anything: the remaining wait only paces the scroll-up passes, and a chat that never reads through is
measured anyway, so the failure is the band with Leo's numbers and not a timeout. The review's
fixture is in the file as pasteWall, and CMUX_REOPEN_TEST_THROTTLE makes its experiment
repeatable.

run, same tree, same throttleresult
new gate, plain4 pass, 22.6 s
new gate, CMUX_REOPEN_TEST_THROTTLE=244 pass, 95.9 s
old time-based gate + pasteWall, throttle 24pasteWall fails, lastLine 373 — the false red
new gate on 7819daf21^ (before #22794)manyPastes and pasteWall fail; pasteWall reports read through false, lastLine 349, scrollable 0

That last row is the one that matters: on the broken tree the gate says the chat never read through
and the test still reports the band, which is the behavior a guard owes its reader.

Changelog

none

Observed / Not run

Observed, on big-red (Chromium via Playwright): the four rows above, green on the base this is merged with (426dcbf2258a9), and the gate table above;
the same file red on webviews/src at c78e7b2 and at 7819daf21^, green at 7819daf21 (one test
fails in each red run, manyPastes, with the numbers in the table). webviews typecheck: 0 errors,
1979 files.

bun run check on the merged head: "no warnings, lint errors, or type errors in 2015 files", all 2082 formatted; prepush-gate.py no new violations. On CI, shard 2's log says PASS test/reopened-chat-history.test.ts.

Not run: the rest of the 165-file webview suite (the shard reds on this head are a standing base set, in a comment below). No Swift is touched.

The base lint warning this PR first hit is fixed and merged (#23144), so no push of it needed CMUX_WEB_LINT_SKIP_REASON.

🤖 Generated with Claude Code

Agent session: big-red