cmux NEXT: opening an existing chat adopts the prewarmed page once its client is connected (carries #27759)
HOLD: with its gates fixed (098aec3a) the spare is adopted on every open, and the adopted page regresses: sidebar opens show an empty transcript after the cover leaves; reopens hold the cover ~15 s and never paint. Not to land until the adopted page draws its chat and a same-host bench shows it faster.
Leo, 2026-11-01: "the loading for everything is just slow end to end / loading into a chat and some of its recent messages, loading the various ui elements of the metadata, etc. it's not cached well enough nor prerendered/prefetched well enough". Earlier, 2026-10-29: "as in i want ours to load fast too :(".
Finding 1 of the chat-open plan (manaflow-ai/cmuxterm-hq#5914): every tab not yet shown opens a cold page.
This PR carries #27759 (its 10 commits, unchanged) and fixes the blocking finding of its last review. #27759's lane has been idle since 04:32Z. #27759 stays open for its owner to close.
What changes
From #27759. Opening an existing chat of this Mac uses the prewarmed agent page (NewTabSparePool) instead of building a new one. That covers a sidebar click, Open in Tab, chat.openTab and a subagent's pane. The page is already loaded, mounted and connected, so it switches to the chat in place (chat.select), drawing the host's kept tail when there is one, and then attaches.
- The pane keeps its cover, with the page laid out outside the window, until the page reports the chat drawn.
- A chat from another machine, a Chief home chat, a seeded tab and a tab with a kept or relaunch wake state each still get their own page.
See #27759's body for the full design and its three earlier review rounds.
New here (the BLOCK at cffa960a, P1). Before, a spare counted as ready once it had a handshake and a drawn New Tab frame. Both come before its acpmux client connects. An adoption that arrived then queued until connect() finished, and that includes the spare's whole first chat list. A cold page attaches its chat before the list (askEarly / attachedBeforeList). So under load, adopting could be slower than the cold path it replaces.
- The page now reports its client state (
pane.clientReady {ready}): true onceconnect()finishes and its actions answer, false when the connection is lost. Each new handshake resets it (a reload or a reconnect). NewTabSparePool.servesChatrequires it. A spare that is still connecting stays in the pool, and the tab opens cold as before.- In Low Power Mode or at serious thermal state, no chat takes the spare. Each take would mean building another page. The tab opens cold, and the spare stays for New Tab.
- When an existing chat takes the spare, its replacement is built once input has been quiet for 750 ms (
Refill.whenQuiet), not on the next main-actor turn. A new page's load on the main thread would otherwise land inside that chat's open and the next sidebar clicks. New Tab and new workspace still refill at once (Refill.now).
Spare cost
- Count. Still one spare per app (
NewTabSpareSlot, a single slot). This PR adds no page. - Memory. Unchanged: one parked page, which is one Chromium renderer.
- Idle CPU. Unchanged. perf-lead row F3 records ~2% idle CPU for Home's three renderers together. The spare is now taken more often, so pages are built more often. That build is the existing
pool.makeSpare, and it happens only after quiet input on the chat path. - Snapshot. The parked page's per-resize snapshot (gpu-audit #7) is already a fast-mode JPEG on Chromium (
PageWebView.snapshotParams). This PR changes neither when it is taken nor how. - Pressure. A memory pressure warning or critical level still drops the spare. Low Power Mode and serious thermal state keep chats off it (above).
Performance
Measured with scripts/measure/chat-open.sh (chat-open-bench.py, the app's own debug.chat_open_trace from the click to the first frame of chat content), on a fleet display Mac. Base and head run in the same harness.
| metric | before | after | delta | harness or run link |
|---|---|---|---|---|
| (pending) |
Timers and observers
- No new timers or observers.
- The deferred refill uses the pool's existing
warmTimerand idle input monitor, which stop once the spare is made (warmNowremoves the monitor). - The hold on an adopted page ends on its paint report, or on the loading state's existing give-up check.
Tests
SpareSessionAdoptionTests:thePageReportsItsClientReady: the request decodes; the state resets on a handshake; a ready report does not count as the person touching the page.whichSpareServesAChat: the readiness, Low Power Mode and thermal decision.- The connected-spare helper now reports ready.
- #27759's Swift and web tests are carried unchanged.
Risk
- Medium. This changes the open path for every existing local chat (see #27759).
- The cost of a wrong readiness answer is bounded. A spare that is not ready means a cold open, which is today's behavior.
Agent session: agent session
š¤ Generated with Claude Code
![View with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png)
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
Opening an existing chat now adopts the app's prewarmed, connected New Tab page instead of building a cold one. The page switches to the chat in place, keeping the reader's position and any subagent turn. Carries #27759's adoption path (sidebar click, Open in Tab, chat.openTab, subagent pane).
- The page reports its client state (
pane.clientReady); a spare still connecting stays in the pool and the tab opens cold. - The composer's audio report and the header's, project notice's and New Tab project list's mount reads are boot traffic now, so the spare is no longer refused as touched and stays adoptable.
- No chat takes the spare in Low Power Mode or at serious thermal state, since each take means building another page.
- A spare taken by a chat is replaced only after input is quiet (750 ms); New Tab and new workspace still refill immediately.
- A chat the daemon lacks is shown as missing, never replaced by another chat.
- Chats from another machine, Chief home chats, seeded tabs, and tabs with kept or relaunch wake states keep their own page.
- The chat-open trace records whether the spare was taken (
spare_adopted) or why not (spare_empty,spare_unready,spare_refused).