cmux NEXT: opening an existing chat adopts the prewarmed, connected page

Closed
TE
4d ago
+745-3

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_session use: it draws the host's kept tail (chat.tail.get) when there is one, then attaches. A subagent pane's revealTurn 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 (hasPainted reset).
  • Until the page reports the chat drawn (pane.painted, now gated on chatRead for 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: drawLastPage still runs for an unpainted page, and coverMove (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 / remoteChat pages are refused in adoptSpareSession.
  • 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, no newSession). 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.get is 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.
  • 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.

metricbeforeafterdeltaharness or run link
sidebar chat first_content_ms (idle)TBDTBD
sidebar chat first_content_ms (all-core load)TBDTBD
subagent pane first_content_ms (idle)TBDTBD
subagent pane first_content_ms (all-core load)TBDTBD
longest_empty_ms / flashesTBDTBD

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 browserChildWindowPagesNeedUpdate for the window.

🤖 Generated with Claude Code

Agent session: agent session