Skip to content

cmux-next agent chat: a chat opened in a new tab is instant too, from a tail the app host keeps in memory - #23131

Merged
teamleaderleo merged 20 commits into
feat-cmux-nextfrom
cmux/new-tab-tail-cache
Oct 21, 2026
Merged

teamleaderleo merged 20 commits into
feat-cmux-nextfrom
cmux/new-tab-tail-cache

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

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:

bound page host
chats 8 most recently read (KEPT_TAILS) 8 (AgentPaneTailCache.chats)
records per chat 400 (KEPT_EVENTS) 400 (AgentPaneTailCache.events)
bytes per chat 256 KiB of the whole tail, newest records first (HOST_TAIL_BYTES) 1 MiB of its compact UTF-8 (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().length in 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.ts guards (#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.pageHost Debug tunable is on, which it is not by default, so the two ops are served on both paths from the one store: AgentPaneBridge for the pane's own host and AgentPageProvider+Tail for the shared page host. A chat's tail is the chat's, not the pane's, so AgentPaneModel never sees it on either path — the same shape chat.scrollAnchor uses.

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.

new tab drawn with the attach still in flight
the host kept this chat's tail the chat ("Retry added.")
the host kept nothing nothing, until acpmux answers

With readHostTail stubbed 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 its l10n-allow reason; readHostTail races HOST_TAIL_READ_MS (250 ms), so a cache is never why an open waits; the connect path re-checks the selection after its await, as select does; and two the review called free — a record dropped by the read-side filter clears historyExhausted, 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, so ChatTailCacheTests now 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:

  1. The feature did not work for the flow it is named for. rememberCurrentTail had exactly one caller: the top of select(), 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 to select(b) for a tail to exist at all. The chat on screen is now handed over by both chat.openTab actions, by close() and on pagehide.
  2. The byte bound was not the bound two doc comments promised. writeHostTail bounded tail.events and then spread ...tail, so queue crossed 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.
  3. The bridge call site was uncovered — the same defect as cmux-next: a chat reopens where the reader left it on the host it actually opens on (cx-6bgzj) #23232 in a second place. WKScriptMessage has no public initializer, so no test can drive userContentController: deleting those five lines left every suite green while every new tab went cold, on the host agent.pageHost: false makes production use. My own fix for (1) had the same hole one layer up, since a test calling handOverCurrentTail() 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 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. 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 suites were green on the merge base and red at d0f99de4f33f; they pass at this head. rememberTail now takes forThisPage, false from close and pagehide, 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 the chat.tail prefix: reply(to:params:) takes anything that is not .get as a write, so a later chat.tail.forget caught 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 before AgentPaneRequest could 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 tail forgetTail dropped, 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.swift grows 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.sh reports 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.ts 9 pass / 0 fail; the two suites the regression broke green again (agent-pane-turn-tokens 13, failedPromptRetry 4), after being red at d0f99de4f33f in CI and green on the merge base 5f84bd023b11 when I ran them there. The new page-map test fails with the fix reverted, with the message it exists for. webviews bun run check: the whole chain passes, including check: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 + ChatScrollRestoreDefaultPathTests on the fleet at this head (b954f8389350d, step tailcache-green-8): both suites passed. They passed the same way at the pre-merge head 200de5c49ea01 (step tailcache-green-7); this head is that one with the base merged in, and the merge touched Swift (the AgentPageOps.methods op 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:74 wrote #expect over 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 checks is red only on its Companion workflows step, 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


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with 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:

  • Only a switched-off chat reached the host before; the chat on screen is now handed over before chat.openTab, on close, and on pagehide.
  • The byte bound covers the whole tail on both sides, including queued prompts, and both hosts branch on the named ops rather than the chat.tail prefix.
  • A closing page's tail goes to the host alone rather than into its own page map — a stale map entry made the next client in that page draw rows it never attached to, a regression CI caught in agent-pane-turn-tokens and failedPromptRetry. A late host read cannot restore a tail forgetTail dropped.

Tests

  • ChatTailCacheTests covers 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.
  • Five client boundary tests cover the new-tab draw vs the cold path, the byte bound, attach failures dropping a tail here and in the host, and defensive host reads.

Written for commit b954f83. Summary will update on new commits.

View guided diff Turn on auto-fix

teamleaderleo and others added 4 commits October 20, 2026 23:32
…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
@cursor

cursor Bot commented Oct 21, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot 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.

@teamleaderleo teamleaderleo added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Oct 21, 2026
@teamleaderleo

teamleaderleo commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Owned by chat-open-perf-6c on big-red · session

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review b954f83: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr23131 -f ref=cmux/new-tab-tail-cache -f platform=macos -f backend_mode=local

Gallery before/after for b954f83: 16 states rendered, 1 images differ, 1414 more touched states not rendered. Images are in the run's artifact, not on this public PR.

gh run download 39610386934 -R manaflow-ai/cmux -n gallery-advisory-23131-b954f8389350df9426c29dbd3721d58d9b5015b1 (then read gallery-review.json)

States
State Theme Status Diff Picked by
agent-pane.chat-width/standard Apple System Colors Light unchanged 0% own App.tsx
agent-pane.chat-width/standard Apple System Colors unchanged 0% own App.tsx
agent-pane.chat-width/wide Apple System Colors Light unchanged 0% own App.tsx
agent-pane.chat-width/wide Apple System Colors unchanged 0% own App.tsx
agent-pane.chat-width/wide-blocks Apple System Colors Light unchanged 0% own App.tsx
agent-pane.chat-width/wide-blocks Apple System Colors unchanged 0% own App.tsx
agent-pane.composer-floats/scrolled-to-end Apple System Colors Light unchanged 0% own App.tsx
agent-pane.composer-floats/scrolled-to-end Apple System Colors unchanged 0% own App.tsx
agent-pane.composer-floats/composer-grown Apple System Colors Light unchanged 0% own App.tsx
agent-pane.composer-floats/composer-grown Apple System Colors unchanged 0% own App.tsx
agent-pane.docked-chat/new-chat Apple System Colors Light unchanged 0% own App.tsx
agent-pane.docked-chat/new-chat Apple System Colors unchanged 0% own App.tsx
agent-pane.docked-chat/after-turn Apple System Colors Light unchanged 0% own App.tsx
agent-pane.docked-chat/after-turn Apple System Colors unchanged 0% own App.tsx
agent-pane.docked-chat/working Apple System Colors Light unchanged 0% own App.tsx
agent-pane.docked-chat/working Apple System Colors unchanged 0% own App.tsx
agent-pane.docked-chat/typing-in-dock Apple System Colors Light unchanged 0% own App.tsx
agent-pane.docked-chat/typing-in-dock Apple System Colors unchanged 0% own App.tsx
agent-pane.docked-chat/long-title Apple System Colors Light unchanged 0% own App.tsx
agent-pane.docked-chat/long-title Apple System Colors unchanged 0% own App.tsx
agent-pane.orchestration-lab/local-queue Apple System Colors Light unchanged 0% own App.tsx
agent-pane.orchestration-lab/local-queue Apple System Colors unchanged 0% own App.tsx
agent-pane.orchestration-lab/huge-blue Apple System Colors Light unchanged 0% own App.tsx
agent-pane.orchestration-lab/huge-blue Apple System Colors unchanged 0% own App.tsx
agent-pane.orchestration-lab/cloud-fair-share Apple System Colors Light unchanged 0% own App.tsx
agent-pane.orchestration-lab/cloud-fair-share Apple System Colors unchanged 0% own App.tsx
agent-pane.streaming-follow-motion/stream Apple System Colors Light unchanged 0% own App.tsx
agent-pane.streaming-follow-motion/stream Apple System Colors unchanged 0% own App.tsx
agent-pane.thread-minimap/rest Apple System Colors changed 0.0027% own App.tsx
agent-pane.thread-minimap/rest Apple System Colors Light unchanged 0% own App.tsx
agent-pane.thread-minimap/scrubbing Apple System Colors Light unchanged 0% own App.tsx
agent-pane.thread-minimap/scrubbing Apple System Colors unchanged 0% own App.tsx

Pick other states with a Gallery: entry.id, entry.id/variant line in the PR body.

Not verified: macOS jobs did not run: compile and app tests were skipped on b954f83893; ci-status is green without them.

CI passes on b954f83893 (run 39610387086 attempt 1).
macOS jobs did not run: compile and app tests were skipped: Fast static checks skipped (the macos job needs both to succeed). Re-run CI once they pass, before merging.

Written by scripts/ci/classify_failures.py (ci-failure-attribution.yml); signatures are its SIGNATURES table. A machine verdict is the runner's fault, not this PR's; yours means the failing file is one this PR changes, also red on main that main's latest full suite fails the same way, seen on other PRs that it failed on another pull request's run lately.

web and size checks: green

CI fast guards passes on b954f83893 (run). This covers only the fast guards, not CI: CI's result is ci-status.

Tour of b954f83893 (run): no media. The tour job failed before the tour ran (build or setup); the run log has the output.

@blacksmith-sh

blacksmith-sh Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Found 4 test failures on Blacksmith runners:

Failures

Test View Logs
test/agent-pane-stream-jitter.test.ts/agent-pane-stream-jitter.test View Logs
test/agent-pane-stream-jitter.test.ts/agent-pane-stream-jitter.test View Logs
test/secret-card.test.ts/secret-card.test View Logs
test/secret-card.test.ts/secret-card.test View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

cmux-next crash-class rules / cmux-next crash-class rules is red on 12 of 12 fresh feat-cmux-next PR heads in the last 2:00:00, so it is most likely a base red, not this PR's. The base-reds lane has it; a base merge picks up the fix once it lands.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Bead cx-hkcgw created for this PR by the cmux coordinator.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

cmux-next guards / cmux-next CI guard group is red on the base, not on this change, and the job says why it blamed the change:

no base results in /home/runner/_work/_temp/guards-base.json: every red step counts as this change's

So its base comparison had nothing to compare against. The 24 red steps are workflow-guard-tests ones (workflow linting, Xcode pinning, runner labels, signing secrets) in cmux-next.yml, macos-matrix-fuzz.yml, cmux-next-ios/scripts/demo-build.sh, cmux-next-perf-bench.yml, cmux-next-terminal.yml — none of which this PR touches.

Checked on an untouched tree (big-red, detached worktree at plain origin/feat-cmux-next, scripts/ci/guards-local.sh --no-stamp --keep-going --jobs 8):

tree result
origin/feat-cmux-next (tip) 110 steps passed, 24 failed, 0 skipped (Linux only) in 35.1s
07c4dc77e875e 110 steps passed, 24 failed, 0 skipped (Linux only) in 35.5s
1e2bcb7a81e99 110 steps passed, 24 failed, 0 skipped (Linux only) in 36.2s

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 guards-base.json is missing it reports base reds as every PR's.

teamleaderleo and others added 4 commits October 21, 2026 00:54
…-tab-tail-cache

# Conflicts:
#	Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift
…-tab-tail-cache

# Conflicts:
#	webviews/test/reopened-chat-history.test.ts
teamleaderleo and others added 2 commits October 21, 2026 02:31
…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
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

codex-review: SHIP at 31f8aec

  • Reviewed the current diff, its focused tests, and the affected call paths; no correctness bug found.

…-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
@cursor

cursor Bot commented Oct 21, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot 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.

teamleaderleo and others added 3 commits October 21, 2026 04:51
…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
@teamleaderleo
teamleaderleo force-pushed the cmux/new-tab-tail-cache branch from 34ec343 to 663e486 Compare October 21, 2026 12:19
@cursor

cursor Bot commented Oct 21, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot 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.

teamleaderleo and others added 3 commits October 21, 2026 05:30
…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
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

cmux-next guards / defaults-snapshot is red on 20 of 23 fresh feat-cmux-next PR heads in the last 2:00:00, so it is most likely a base red, not this PR's. The base-reds lane has it; a base merge picks up the fix once it lands.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

teamleaderleo added a commit that referenced this pull request Oct 21, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant