cmux NEXT: opening an existing chat adopts the prewarmed, connected page
Leo, 2026-10-29: "i also like the way it just loads in the subagent stuff instantly :(" / "as in i want ours to load fast too :(" (and earlier: "but do chats load fast yet? even with high # of chats, high load? and does it still flash/flicker?").
What changes
Opening an existing chat of this Mac now uses the prewarmed, already-connected agent page (NewTabSparePool) instead of building a new Chromium page. That covers a sidebar click (ChatsOpenCoordinator → workspace), agent.openSessionTab / agent.openSessionWorkspace, chat.openTab, a subagent's pane (chat.subagentPane), and any tab shown with a session. Before this, only a New Tab page with no session adopted the spare (AgentTabs.swift ~373).
- Before: a new page was built and loaded, then connected serially (WS open → initialize → status → watch →
chat.tail.get→ attach). - After: the spare has already run WS open, initialize and status. It switches to the chat in place with the same switch
chat.select/debug.agent_pane select_sessionuse: it draws the host's kept tail (chat.tail.get) when there is one, then attaches. A subagent pane'srevealTurn subagent:<id>rides along in the adopt event. - The pool warms the next spare after each use. It keeps the idle-input gate, the memory-pressure drop and the single-slot bound (the same
take).
No New Tab frame in the chat's pane
- The page leaves its New Tab screen inside the adopt event's own render (
acpmux-session-adopt,flushSync). - The model's first paint is ahead again (
hasPaintedreset). - Until the page reports the chat drawn (
pane.painted, now gated onchatReadfor the adopted session), the pane keeps its cover: the loading state, or the tab's saved frame (drawLastPage). The page is laid out outside the window during that time, because a Chromium page draws in its own child window over any cover and alpha does not hide it (see #26594). - It comes back in the same turn the paint is reported: no fade from a kept tail, the normal fade for a chat read cold. The loading state's give-up path brings it back too, so there's no stuck hold.
- Saved covers and launch images are unchanged:
drawLastPagestill runs for an unpainted page, andcoverMove(the New Tab frame cover) no longer applies because the adopted page is unpainted.
Exclusions (each still gets its own page, as before)
- Another machine's chat (
kind != .local): its host is that machine's.neverPooled/remoteChatpages are refused inadoptSpareSession. - A Chief home chat (
chief:host), and an older subagent tab recorded with this Mac's host that the Chief home keeps on disk (AcpmuxChiefSessionRouter.keeps). The spare is connected to this Mac's acpmux, and such a chat would say "This chat isn't available". - A tab with a seed, a New Tab page, or a kept / relaunch wake state (
wakeStates,relaunchWake.mayHold). These reach the page only with its own handshake, the same reason the New Tab path excludes them. - A spare that hasn't connected and drawn yet (
hasHandshake && hasPainted): it stays in the pool. A touched spare is refused and goes back to the pool or is retired. - Bound to the session, the page then behaves like a tab whose handshake named it (
bindToSession:sessionMustExist, nonewSession). A missing chat says so instead of falling back to another one, and a reconnect reopens it.
Opening like a cold open (first review's findings)
- An adoption that arrives before the spare's client has connected is queued and runs once the client's actions are in (
chat.adopt), so a tab never keeps a New Tab page. - The chat opens where the reader left it:
chat.scrollAnchor.getis read before its rows draw, as a cold open reads it. - A chat the daemon does not list is the tab's missing chat (the "isn't available" view with Reconnect), not an empty chat. An open that fails with nothing drawn says it failed (
AcpmuxDirectClient.adoptSession).
Tests (red on base, green here)
CmuxNextAppTests/SpareSessionAdoptionTests:- an existing chat adopts the connected spare (session, no New Tab, the subagent's turn carried, cover up and page away until
pane.painted, then back in place with no fade from a kept tail); - each exclusion keeps its own page and leaves the spare;
- an unconnected spare is not adopted and goes back to the pool;
- the decision table.
- an existing chat adopts the connected spare (session, no New Tab, the subagent's turn carried, cover up and page away until
webviews/test/spare-session-adopt.test.ts:- adopt-event parsing;
- the spare's New Tab client, bound to a chat, draws the host's kept tail before its attach answers and reports it read for that chat;
- bound, it never falls back to another chat when its chat is purged.
- a chat the daemon does not list is the adopted tab's missing chat, with nothing attached;
- a listed chat opens as a pick opens it.
Performance
Fleet bench numbers are still to come (click → first content, idle and all-core load, sidebar chat and subagent pane, from debug.chat_open_trace). The bench is #27625 (open). The controller runs a measure script at a PR head only when the script is listed and its bytes match feat-cmux-next. manaflow-ai/cmuxterm-hq#5219 (landed) lists chat-open.sh, so both columns can run from that harness once #27625 lands. The numbers will go in this table and this note will be removed.
| metric | before | after | delta | harness or run link |
|---|---|---|---|---|
| sidebar chat first_content_ms (idle) | TBD | TBD | ||
| sidebar chat first_content_ms (all-core load) | TBD | TBD | ||
| subagent pane first_content_ms (idle) | TBD | TBD | ||
| subagent pane first_content_ms (all-core load) | TBD | TBD | ||
| longest_empty_ms / flashes | TBD | TBD |
Timers and observers
None new. The hold ends on the page's paint report, or on the loading state's existing give-up check (AgentPaneLoadingView.giveUpWhenSeenUnpainted) if that report never comes. Nothing new runs per token.
Risk
- Medium. It changes the open path for every existing local chat. A wrong exclusion would attach the spare to the wrong daemon: the Chief case is covered, and tested.
- CEF geometry. The hold relies on a Chromium page window following its host view out of and back into the window. Each move posts
browserChildWindowPagesNeedUpdatefor the window.
🤖 Generated with Claude Code
Agent session: agent session