Repository navigation
cmux-next agent chat: a chat opened in a new tab is instant too, from a tail the app host keeps in memory - #23131
Conversation
…nd above the composer
Leo, 2026-10-21: he reopened a closed chat (the coordinator's) and got a big
blank gap between the transcript and the composer with only the last messages in
it, no older history, and no way to scroll up.
A reopened closed tab is an ordinary COLD chat: closing it released its page and
its wake state (AgentTabs+Lifecycle release), so it comes back as a fresh
handshake naming the session, then connect and attach. What it shows is one
attach page, and acpmux ends a page before its records' JSON passes maxBytes
(hub/paging.rs events_page), 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, a tool run with long output — so its page can be a single
record that draws no transcript row at all, and everything else has to arrive
through the older-history loader.
This opens three such chats in the real pane page against a stand-in acpmux that
pages exactly as `events_page` does (the open capped at 128 KiB, older pages at
256 KiB, 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.
queuedPaste (14 turns, then a 160 KiB queued paste): 34 rows, 0 px above the
composer, at the bottom, first turn reached after 3 older reads
manyPastes (three 300 KiB pastes in a row): 34 rows, 0 px, at the bottom,
first turn reached after 5 older reads
longText (40 turns, no oversized record): 6 rows, -0.25 px, at the bottom,
first turn reached after 4 older reads
Green on this base, so it stands as the regression guard for the shapes Leo hit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cb8-bbf0-7010-8d04-f9f2c0cb55df&host=big-red
…the app host kept Work in progress: the test is next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cb8-bbf0-7010-8d04-f9f2c0cb55df&host=big-red
…il, and a cut tail still offers what it cut Five tests at the client's own boundary, with a stand-in acpmux and a stand-in app host that holds tails the way AgentPaneTailCache does (the most recent chats, 400 records each, one over that refused whole rather than cut). Two of them are the A/B: the same chat, the same daemon, one new tab with the host's tail and one without. The first draws the chat with its attach still in flight; the second cannot, and waits for acpmux. The rest hold the parts that could go wrong quietly: the tail is bounded in bytes before it crosses, a tail the bound cut never claims the transcript is whole (which would hide the older turns, the bug of test/reopened-chat-history.test.ts), a chat whose attach fails is dropped here and in the host, and a host answering anything but a page tail leaves the chat to open cold. Two defects they caught in the change they cover: - a new tab whose attach failed kept the host's tail on screen: the client cleared its rows but never told the pane, so the pane went on drawing the snapshot that carried them. It emits now, as the chat-switch path does. - the client also runs with no page around it (its own tests, tools), where `window` is not defined at all and asking the host threw instead of answering "no tail". agent-pane-history-rowless-page went red on that; it and every other suite that drives the client are green now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cb8-bbf0-7010-8d04-f9f2c0cb55df&host=big-red
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cb8-bbf0-7010-8d04-f9f2c0cb55df&host=big-red
Bugbot needs on-demand usage enabledBugbot uses usage-based billing for this team and requires on-demand usage to be enabled. A team admin can enable on-demand usage in the Cursor dashboard. |
|
Owned by chat-open-perf-6c on big-red · session |
|
cmux status (one comment, edited in place; each line is updated by the job that owns it) Review App of this head (app.zip, kept 1 day)
Gallery before/after for
States
Pick other states with a Not verified: macOS jobs did not run: compile and app tests were skipped on CI passes on Written by web and size checks: green
Tour of |
|
Found 4 test failures on Blacksmith runners: Failures
|
|
|
|
Bead |
|
So its base comparison had nothing to compare against. The 24 red steps are Checked on an untouched tree (big-red, detached worktree at plain
Same count on the base tip and at two earlier base points, so it is standing base debt rather than something that broke today. It does not hold this PR. The guard job's own base comparison is worth fixing separately: while |
…-tab-tail-cache # Conflicts: # Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift
…r its god-file budget on base) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cb8-bbf0-7010-8d04-f9f2c0cb55df&host=big-red
…-tab-tail-cache # Conflicts: # webviews/test/reopened-chat-history.test.ts
…ests of their own From an independent review of the tail cache. One red of this change's own, and three soundness fixes: - `tailCache.ts`'s "no page" reject message is English in a pane source, which `pane-english.test.ts` is right to refuse. Nobody reads it (every caller catches), so it carries the reason as an `l10n-allow`. - The host read had no bound. `readHostTail` now races HOST_TAIL_READ_MS (250 ms, as `SCROLL_ANCHOR_READ_MS` does for the reading place): a host that does not answer costs the chat nothing, and a late answer is still kept for the next tab. A cache must never be the reason an open waits. - The connect path drew the host's tail without re-checking the selection after its await, where `select` does. A switch landing in that window could paint the old chat's rows under the new one until its attach returned. - Two the review called free: a record dropped by the read-side filter now clears `historyExhausted` (the write side already compared lengths; a cut tail calling the history read through is #23064's bug), and a tail whose newest record alone passes the byte bound forgets the host's older tail instead of leaving it under a chat this page has read on. `ChatTailCacheTests` covers the store itself, which only a JS fake stood for: the hand-off between two pages, the LRU and its recency, `clear()` for sign-out, both hosts over the one store, a request with no chat — and the bound that keeps #23064 off this side, a tail over 400 records refused WHOLE, with exactly 400 kept. Observed, big-red: `pane-english` 1 pass (red before this), `agent-pane-new-tab-cached-chat` 5 pass / 27 expects, `bun run check` green (no warnings, lint or type errors in 2037 files). Swift runs on the fleet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cb8-bbf0-7010-8d04-f9f2c0cb55df&host=big-red
…-tab-tail-cache # Conflicts: # Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift
|
codex-review: SHIP at 31f8aec
|
…-tab-tail-cache # Conflicts: # tests/test-execution.toml
…ost's bound is the one its doc promises An independent review of this PR found three real defects. All three are fixed here, and each is now held by a test that goes red without the fix. 1. The feature did not fire for the flow it is named for. `rememberCurrentTail` had exactly one caller — the top of `select`, on the chat being LEFT — so the host only ever learned a chat the reader had switched off. Opening the chat on screen in a new tab (`chat.openTab`) keeps it selected, so nothing handed it over and the new tab opened cold, exactly as before this PR. The suite missed it because the fixture has to `select(b)` to make a tail exist at all, which is the workaround the product does not have. The chat on screen is now handed over by the two actions that open a chat in a tab, by `close`, and on `pagehide` (which is how keptAttachments.ts hands the composer's chips over). 2. The byte bound was not the bound two doc comments promised. `writeHostTail` bounded `tail.events` and then spread `...tail`, so `queue`, `summary` and `turnOpener` crossed uncounted — and `queue` carries the whole text of every prompt waiting to be sent. On the host, `holds` checked a record count and no bytes at all. A reader who queued a pasted log could hand over megabytes per chat against a comment that said 2 MiB. The page now counts the rest of the tail before bounding the records, and the host enforces its own byte bound, so the invariant lives where its doc claims it. The host's number is 4x the page's on purpose: the two sides count differently (UTF-16 units there, UTF-8 bytes here, 1 vs 3 for a CJK character), and an equal number would silently refuse a correct page's tail for a chat that is not in English. It is a backstop against a page that bounds wrongly, not a second opinion on a page that bounds right. 3. The one production call site was uncovered, which is the defect #23232 was blocked on, in a second place. `AgentPaneBridge.userContentController` is the only wiring that serves `chat.tail` on the host `agent.pageHost: false` makes production use, and no test could reach it: `WKScriptMessage` has no public initializer, and every test calls `AgentPaneTailCache.reply` directly. Delete those five lines and every suite stayed green while every new tab opened cold. Held now by reading the call site out of the source, as #23232 does. The same hole existed one layer up in the fix for (1): a test that calls `handOverCurrentTail` itself proves the client, not that the product asks. The two `chat.openTab` sites in App.tsx are React code no test here drives, so they are read out of the source too — verified red with both calls removed. Also corrected: `remember`'s doc did not say that a refused tail drops the tail already kept for that chat (the behavior ChatTailCacheTests already asserts), and `init` is private, because a second store would split the cache and a chat would open cold for no visible reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cb8-bbf0-7010-8d04-f9f2c0cb55df&host=big-red
Bugbot needs on-demand usage enabledBugbot uses usage-based billing for this team and requires on-demand usage to be enabled. A team admin can enable on-demand usage in the Cursor dashboard. |
…the next client in that page draws no chat it never read The hand-over on `close` wrote this page's own tail map as well as the host's. The page is the one going away, so that entry serves nobody — but a later client in the same page reads it, and draws a chat it never attached to: its rows, its queued prompts and its turn's usage, under a chat id that by then can mean another chat. CI found it rather than my own suite, because the suite's fixture gives every tab a fresh page: `agent-pane-turn-tokens` showed 225,000 tokens for a chat whose family reports none, and `failedPromptRetry` drew two bubbles for one refused prompt. Both were green on the merge base and red at d0f99de. `rememberTail` now takes `forThisPage`, false from `close` and `pagehide`, and the host's copy is what a later page reads — which is what the cache is for. The one test that is about the page's map keeps it with `samePage`, takes the host away, and holds the chat to opening cold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cb8-bbf0-7010-8d04-f9f2c0cb55df&host=big-red
…-tab-tail-cache # Conflicts: # Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift # Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneBridge.swift
… the prefix, and a late host read cannot put back a forgotten tail A review's findings, none of them reachable at this head: - `chat.tail*` was routed by prefix on both hosts, two lines under the comment that explains why `scrollAnchor` is matched by name. `reply(to:params:)` takes anything that is not `.get` as a write, so a later `chat.tail.forget` would have erased a chat's tail instead of being refused — and on the pane's own host a prefix would swallow it before `AgentPaneRequest` saw it. Both hosts now branch on `AgentPaneTailCache.handles(method:)`, with a test that names what it must and must not take. - `forgetTail` deletes the in-flight read's entry but cannot cancel it, so a late answer could put the tail back for a chat this page no longer stands behind. The insert and the cleanup now check that the read is still the chat's. - The comment on `rememberTail` justified `forThisPage` with a session id meaning another chat, which is not true: ids are not reused, and with a host the next client reads the same tail straight back. It now says what the flag actually does and where it matters — a page with no host, and a tail the byte bound cut to nothing. `CloudService.swift` keeps the sign-out clear, now one line rather than two; it is still one line of growth on a file 33 over the god-file ceiling, which is a warning this change owns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cb8-bbf0-7010-8d04-f9f2c0cb55df&host=big-red
34ec343 to
663e486
Compare
…-tab-tail-cache # Conflicts: # webviews/src/agent-session/acpmux/App.tsx
Bugbot needs on-demand usage enabledBugbot uses usage-based billing for this team and requires on-demand usage to be enabled. A team admin can enable on-demand usage in the Cursor dashboard. |
…he type it routes to
A `hasPrefix("chat.tail")` in `AgentPaneBridge.userContentController` still mentions
`AgentPaneTailCache` and still opens a cached chat, so the guard passed while a later
`chat.tail.forget` would be taken as a write and erase the chat's tail. It now reads the
`handles(method:)` call, which is the part that names its two ops.
`rememberTail`'s note said the next client in the page "draws the same tail" from the host.
It draws at most that: the byte bound may have cut it, and the host's chat cap is shared by
every page where the page's own is not. Each is a colder draw of the same chat, which is
the trade the flag makes; the note now says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cb8-bbf0-7010-8d04-f9f2c0cb55df&host=big-red
…e does Wrap drift from the last edit: one line of `rememberTail`'s note ran to 155 characters where every neighbour stops near 100. Oxfmt does not reflow `///` comments, so nothing caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cb8-bbf0-7010-8d04-f9f2c0cb55df&host=big-red
…-tab-tail-cache # Conflicts: # Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift
|
|
|
Shipped in:
|
…nly a cold chat fades in (#23481) * test(agent chat): a cached chat open is held to one frame, not to the cold fade (red) The pane's loading state fades its background out over the page in 120 ms (`MotionFade.fadeIn`) when a chat's page first paints. Leo asked for that on 2026-10-14 for a chat the app had not loaded ("quick but seen"). A chat the page draws from a kept tail is already on screen in that first frame (#23131), so the fade is 120 ms of softness over the one open that is instant — the opposite of "instant if cached" (Leo 2026-10-20). Red on both sides of the one call that would carry the fact: - `agent-pane-new-tab-cached-chat.test.ts`: a new tab that drew the host's tail says so in its snapshot, a tab with nothing kept never claims it, and the first-paint report passes it on (read out of App.tsx, as the hand-over call sites are: React code no test here drives). - `CachedChatPaintFadeTests`: `pane.painted` parses the fact, an older page that says nothing means cold, the model keeps it, and only a chat read cold fades in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cb8-bbf0-7010-8d04-f9f2c0cb55df&host=big-red * fix(next): a chat drawn from a kept tail shows in one frame; only a cold chat fades in The page is the only side that knows which of the two it drew, so its first-paint report carries it: `pane.painted` takes `drewKeptTail`, which the client sets when `drawKeptTail` put a kept tail on screen before any attach answered (tailCache.ts). The pane's host keeps it on the model and `AgentPaneView.pageFades` reads it: a chat read cold still fades in over the loading state, a chat already drawn shows in one frame, and the two cases that never faded (a New Tab page, a page a pane holds hidden for its swap) are unchanged. An older page says nothing, which means cold, so the fade is what it was wherever the fact does not come. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cb8-bbf0-7010-8d04-f9f2c0cb55df&host=big-red * fix(next): the kept-tail fact means what is drawn now, and the pane is held to acting on it Review of #23481 found the flag could outlive the rows it described, and that the one line the change exists for had no test. - `AgentPaneColdChatRevealTests` now covers `endLoadingState` itself, the twin of its New Tab test: a chat whose paint report carries the fact shows in one frame, a chat read cold still fades. Mutating that line back passed every test before this. - The client sets the fact where the caller decides rows drew, not where the tail is read: a kept tail that rebuilt to nothing was a cold open the host was told about as a cached one. - It clears again wherever those rows leave the screen: `resetSessionState`, and both attach-failure paths, which drop a drawn tail the page can no longer stand behind. A failure drawn in its place is a first draw, so it fades like one. - A restored session snapshot no longer carries it. `cachedSnapshot` already forces `connection: "connecting"` because a restored field is not live state; this is another such field. The tightened source-reading test reads the value and not only the word: a report passing `drewKeptTail: false`, or naming it in a comment, used to pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cb8-bbf0-7010-8d04-f9f2c0cb55df&host=big-red * test(next): the one-frame test runs only where a fade would have been seen Review of #23481: with motion off — Reduce Motion, which the CI VMs have on, or a test window with no screen — `Motion.home.animate` runs its completion at once, so the loading state leaves in one frame whether or not the fade was skipped, and `aChatDrawnFromAKeptTailShowsInOneFrame` would have passed over the bug it exists for. It now requires motion, as its cold sibling already did. Also from the review: `paintedFromKeptTail` is written on the first report only. `markPainted` is already guarded that way; the field describes that same first frame, and a later report (a remount, a dev-host replay) describes a frame nothing is waiting on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cb8-bbf0-7010-8d04-f9f2c0cb55df&host=big-red * test(next): the fade tests read the decision, so they bite on a headless host too The mutation run settles it: with `endLoadingState` reverted to the old expression, both suites still passed on a fleet mini (step `nofade-mutant-1`, `aws-m4pro-1`). So the test added for the one line this PR exists for did not bite, exactly as the review predicted — with motion off, `Motion.home.animate` runs its completion at once, so a fade and no fade both end in one frame. The `#require` guard I added made that honest and left the test covering nothing on CI, where Reduce Motion is on. The loading view now records the decision it was given (`leftFading`), and the two tests read that instead of the frame it produced. The decision is the whole of this change, it is what a mutation breaks, and it is readable with no animation at all — which is also what the repo asks of motion code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cb8-bbf0-7010-8d04-f9f2c0cb55df&host=big-red * test(next): the kept-tail test's frame check is one that can fail Review found the assertion under it vacuous: `leave` sets the page's alpha to 1 for any page it is not hidden over, fading or not, so `pageContent.alphaValue == 1` held with the fix reverted and read like a frame check while checking nothing. Where a fade can be seen at all, the loading view's departure is the frame fact worth asserting, which is how the cold test next to it reads; the `leftFading` seam carries the same claim on a host with motion off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cb8-bbf0-7010-8d04-f9f2c0cb55df&host=big-red --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A chat is instant when it is cached — in a new tab too, not only in the tab that read it.
Leo, 2026-10-20: a chat "should be instant if cached". #22946 made that true inside a pane: the page keeps the tails of the chats it read (
keptTails), and a switch back draws the chat on the frame it lands on while the attach refreshes it behind that (cached 69-111 ms against 258-289 ms cold). It left one gap, noted on that PR: each agent pane is its own page, so a chat opened in a new tab lands on a page whose map is empty and opens cold.This closes it. Every page now hands its tails to the app host and asks the host for one before it attaches, so the tails outlive the page that read them.
In memory only
The host's store is
AgentPaneTailCache: nothing is written to disk, so this adds no place a transcript persists, no setting to turn off, and no privacy or size policy to decide. Quitting the app forgets every tail; signing out clears them with the rest of the session's state. What it holds is bounded on both sides:KEPT_TAILS)AgentPaneTailCache.chats)KEPT_EVENTS)AgentPaneTailCache.events)HOST_TAIL_BYTES)AgentPaneTailCache.bytes)The page's bound is the number that describes real use, because the page has the strings; the host's is a backstop against a page that bounds wrongly or not at all. It is deliberately four times the page's rather than equal, because the two sides count differently — the page measures
JSON.stringify().lengthin UTF-16 units and the host measures UTF-8 bytes, and one CJK character is 1 unit there and 3 bytes here. An equal number would silently refuse a correct page's tail for any chat not in English, and that chat would quietly open cold in the next tab.A chat whose newest record alone is over the page's bound hands over nothing rather than make the bound one paste wide; it opens cold in the next tab, as it did before. A tail that arrives over either bound is refused whole, never cut: a cut tail that still called its history exhausted would hide the chat's older turns, which is exactly the bug
test/reopened-chat-history.test.tsguards (#23064). A refusal also drops whatever was kept for that chat, because the page has read on since.Both hosts serve it
New agent tabs are on the pane's own host unless the
agent.pageHostDebug tunable is on, which it is not by default, so the two ops are served on both paths from the one store:AgentPaneBridgefor the pane's own host andAgentPageProvider+Tailfor the shared page host. A chat's tail is the chat's, not the pane's, soAgentPaneModelnever sees it on either path — the same shapechat.scrollAnchoruses.The page asks through the seam the pane's other host calls go through (
window.cmuxAcpmuxActions, else the real host), and treats any refusal as "no tail kept": a host without these ops, an older app, the gallery, a page with no host at all. A cache never fails an open.The read is started before the socket connects and awaited at the draw, so it overlaps the connect and the chat list and costs the open no round trip of its own.
The proof: red on the mechanism, green with it
webviews/test/agent-pane-new-tab-cached-chat.test.ts— nine tests at the client's own boundary, with a stand-in acpmux and a stand-in host that holds tails the way the Swift one does.Two are the A/B: the same chat, the same daemon, one new tab with the host's tail and one without.
With
readHostTailstubbed out to answer nothing — the mechanism removed, everything else the same — the tests that describe the cached path fail and the two that describe the cold path still pass.The rest hold what could go wrong quietly: the tail is bounded in bytes before it crosses and a tail the bound cut still offers the turns it cut (
canLoadOlder), a chat whose attach fails is dropped here and in the host so no later tab draws it, a host answering anything but a page tail leaves the chat to open cold, and the chat on screen is handed over when a tab opens it elsewhere or closes on it.What the reviews changed
The first review found one red of this change's own and three soundness gaps, all fixed in
8cdac6e2eebe1: the "no page" reject message carries itsl10n-allowreason;readHostTailracesHOST_TAIL_READ_MS(250 ms), so a cache is never why an open waits; the connect path re-checks the selection after its await, asselectdoes; and two the review called free — a record dropped by the read-side filter clearshistoryExhausted, and a tail whose newest record alone passes the byte bound forgets the host's older tail. It also found the store had no Swift test, soChatTailCacheTestsnow covers the hand-off between two pages, the LRU and its recency,clear()for sign-out, both hosts over the one store, a request with no chat, and the record cap refused whole.The second review was BLOCKING, and it was right on all three. Each fix is held by a test proven red without it:
rememberCurrentTailhad exactly one caller: the top ofselect(), on the chat being left. Opening the chat on screen in a new tab keeps it selected, so nothing handed it over and the new tab opened cold — the one flow this cache exists for. My own suite missed it because its fixture has toselect(b)for a tail to exist at all. The chat on screen is now handed over by bothchat.openTabactions, byclose()and onpagehide.writeHostTailboundedtail.eventsand then spread...tail, soqueuecrossed uncounted — and a queued prompt carries its whole text. The Swift side checked only the record count. A reader who queued a pasted log handed over megabytes that neither side counted. The page now counts the rest of the tail before bounding records, and the host enforces its own byte bound.WKScriptMessagehas no public initializer, so no test can driveuserContentController: deleting those five lines left every suite green while every new tab went cold, on the hostagent.pageHost: falsemakes production use. My own fix for (1) had the same hole one layer up, since a test callinghandOverCurrentTail()proves the client and not that the product asks. Both call sites are now read out of the source, and each guard names what to rewrite it to if the symbol is renamed.Then CI caught a regression my fix for (1) introduced, and it is fixed here. The hand-over on
closewrote this page's own tail map as well as the host's. The page is the one going away, so that entry serves nobody — but a later client in the same page reads it and draws a chat it never attached to.agent-pane-turn-tokensshowed 225,000 tokens for a chat whose family reports none, andfailedPromptRetrydrew two bubbles for one refused prompt. Both suites were green on the merge base and red atd0f99de4f33f; they pass at this head.rememberTailnow takesforThisPage, false fromcloseandpagehide, so the host's copy — the one a later page reads — is what survives. Those two suites catch this by accident, so a test holds it on purpose: "a closed client leaves its tail with the host alone, not in its own page" takes the host away and holds the chat to opening cold. It fails without the fix.The third review passed and found three things in the delta; two are fixed here and one is disclosed. Both hosts now branch on
AgentPaneTailCache.handles(method:), which names the two ops rather than matching thechat.tailprefix:reply(to:params:)takes anything that is not.getas a write, so a laterchat.tail.forgetcaught by a prefix would have erased that chat's tail instead of being refused, and on the pane's own host a prefix would swallow it beforeAgentPaneRequestcould see it. The source guard reads that call and not the type's name, because a prefix match mentions the type too — it passed on a mutation that reverted the branch. A late host read can no longer put back a tailforgetTaildropped, and its cleanup clears only its own entry.rememberTail's note no longer promises the next client in the page "the same tail": it is at most that, because the byte bound may have cut it and the host's chat cap is shared by every page where the page's own is not.Disclosed, not fixed:
CloudService.swiftgrows by two lines — the sign-out clear and the import it needs — on a file already over the god-file ceiling (433 to 435 lines, limit 400).check-no-godfiles.shreports it as a warning, not a red; splitting a file this PR touches for one statement is not this PR's business.Changelog
Changed: a chat you have opened before draws at once in a new tab, not only in the tab that read it.
Observed / Not run
Observed, on big-red at this head:
test/agent-pane-new-tab-cached-chat.test.ts9 pass / 0 fail; the two suites the regression broke green again (agent-pane-turn-tokens13,failedPromptRetry4), after being red atd0f99de4f33fin CI and green on the merge base5f84bd023b11when I ran them there. The new page-map test fails with the fix reverted, with the message it exists for. webviewsbun run check: the whole chain passes, includingcheck:browser-lanes— the base red that forced a skip reason on the previous push is fixed on base by #23325, so this push needed none.vp check: 2155 files formatted, no type or lint errors in 2087.prepush-gate.py: no new violations.ChatTailCacheTests+ChatScrollRestoreDefaultPathTestson the fleet at this head (b954f8389350d, steptailcache-green-8): both suites passed. They passed the same way at the pre-merge head200de5c49ea01(steptailcache-green-7); this head is that one with the base merged in, and the merge touched Swift (theAgentPageOps.methodsop list), so it got its own compile rather than inheriting that one. The lane before it did not compile, on a base defect this PR does not touch —CmuxNextActionsTests/PaletteMenuVocabularyTests.swift:74wrote#expectover a throwing key path, which fails the whole package's test build — already fixed on base and merged in here.Base findings on this head, not this PR's:
cmux-next checksis red only on itsCompanion workflowsstep, red the same way on base pushes;agent-pane-scrollbar-full-height.test.ts:375(Expected <= 1, received 20) is red on #23335, #23336, #23339 and #23343 at the same line — four unrelated PRs.Not run by me: the Swift compile and the macOS suites beyond the fleet lane above — no Mac build runs on this host. The browser perf sampler (
chat-open-perf.test.ts) measures the cached open inside one page and is not extended here; a new tab's timing would need a second page sharing a host stand-in, which I did not build. The claim this PR makes about timing is the frame-level one the A/B above shows: the chat is drawn before the attach answers.No capture: nothing here draws differently. The native files are an in-memory tail store, its two host ops and a sign-out clear, and none of them paints. The one visible difference is in the first frames of a chat opened in a new tab, where the loading state gives way to the chat before the attach answers — a scene still shows that only if it lands on exactly that frame, so what stands behind the claim is the frame-level test above: the page draws the kept tail with the attach still pending, and fails with the fix reverted.
🤖 Generated with Claude Code
Agent session: big-red
Need help on this PR? Tag
@codesmith-botwith what you need.Summary by cubic
A chat you've opened before now draws at once in a new tab, not only in the tab that read it: every agent page hands the tails of the chats it read to the app host and asks for one before attaching, so a new tab draws the chat while its own attach still fetches beneath it.
Tails live in memory only — no disk writes, no new setting — and quitting or signing out forgets them. Both hosts serve the two named ops (
chat.tail,chat.tail.get) from one store, and a chat's tail never reaches the pane model. The store is bounded per chat (8 chats, 400 records, 1 MiB host-side / 256 KiB page-side, counted in UTF-8), and a tail over a bound is refused whole so a cut transcript never hides older turns. The host read races a 250 ms bound so a stuck or absent host never holds an open, and the selection is re-checked once it answers. A missing or unwilling host leaves the chat to open cold, as before.Review fixes, each held by a test that fails without it:
chat.openTab, on close, and onpagehide.chat.tailprefix.agent-pane-turn-tokensandfailedPromptRetry. A late host read cannot restore a tailforgetTaildropped.Tests
ChatTailCacheTestscovers the store itself: the page-to-page hand-off, the LRU and its recency, sign-out clearing, both hosts over the one store, the 400-record cap refused whole, and the named ops.Written for commit b954f83. Summary will update on new commits.