Agent chat: a reopened chat shows its whole history, with no band above the composer
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:
| PR | merged | in c78e7b2? |
|---|---|---|
| #22794 older history keeps loading past a big paste | 02:46Z | no |
| #22831 history with an oversized record at the top | 03:01Z | no |
| #22858 transcript stays pinned while a reply streams | 03:35Z | no |
| #22915 deleting a queued prompt leaves no gap | 04:12Z | no |
| #22910 incoming messages estimated as drawn | 05:02Z | no |
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.
| chat | rows | gap above composer | at bottom | first turn reached |
|---|---|---|---|---|
queuedPaste — 14 turns, then a 160 KiB queued paste | 34 | 0 px | yes | yes, 1 older read |
manyPastes — three 300 KiB pastes in a row | 34 | 0 px | yes | yes, 3 older reads |
pasteWall — ten 300 KiB pastes, then two older turns | 34 | 0 px | yes | yes, 11 older reads |
longText — 40 turns, no oversized record | 6 | −0.25 px | yes | yes, 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:
manyPastes | rows | gap above composer | scrollable | first turn reached |
|---|---|---|---|---|
nightly c78e7b2 (what Leo ran) | 2 | 507 px | 0 px | no |
7819daf21^ (just before #22794) | 2 | 542 px | 0 px | no |
7819daf21 (#22794's merge) | 33 | 0 px | 1493 px | yes |
base 07c4dc77e (now) | 34 | 0 px | 1421 px | yes |
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 throttle | result |
|---|---|
| new gate, plain | 4 pass, 22.6 s |
new gate, CMUX_REOPEN_TEST_THROTTLE=24 | 4 pass, 95.9 s |
old time-based gate + pasteWall, throttle 24 | pasteWall 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