cmux-next: a chat's first frame waits on one daemon round trip

Merged
TE
6d ago
+616-62

Why

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 :(" / "but i also wonder how much of this is simply putting the desktop at higher priority?" / "you're looking in the session transcripts in what cmux next is reading?" / "like the gui rendering the tui which comes from the daemon" / "idk what the claude code cli has but that's a lot of what im running in cmux next". Earlier: "but do chats load fast yet? even with high # of chats, high load? and does it still flash/flicker?"

Opening a chat asked the daemon one thing at a time before the first row: initialize, then _acpmux/status, then the chat list (_acpmux/watch), then _acpmux/attach, then the turn opener (_acpmux/events), permission groups and handoff. On a busy daemon each of those is a full round trip, so a cold open waited on five daemon replies in a row.

What changes

The first frame now waits on the handshake plus one round trip.

  • Webview (direct.ts). After initialize answers, _acpmux/status, the chat list and the chat's attach go out together. The permission-group and handoff reads go out with the attach rather than after it. acpmux serves a connection's requests side by side (server/mod.rs spawns each), so the frame waits on the slowest of them, not their sum. initialize still goes first: the app's bridge sends the person proof as the connection's second request and holds every other frame behind it.
    • The early attach (askEarly) is used only when the list then confirms it: the same chat and selection, a chat on this machine, and a page that holds nothing of the chat yet. In every other case it is dropped, and the chat attaches exactly as before. That covers another chat chosen, a tab-session rebind, a missing chat, and a chat on another machine (which may draw the Mac's copy first). A dropped attach of a chat no longer shown is detached once it has answered (acpmux runs each request on its own task, so an earlier detach could run first).
    • Live records that arrive before the open takes the early attach are held, then applied in order at the point the old code sent the attach. So a kept tail drawn from the host is still drawn first, and copyLive and keptThrough reconciliation see the same sequence as before.
    • An early reply is checked against the session the list names (createdAt). A reply of another session is retried, like a recreated row, and so is one whose row changed after it had streamed records (those may be of the session before it). A dropped early attach's held records are discarded by the next attach of the chat, whose page already holds them. The guards stay as they were: stale selection and generation checks, strayedFrom, historyExhausted, peer chats, and copyAttaches.
    • A kept tail from the host no longer holds the open once the attach has answered: the open waits on whichever comes first. The host read is still at most 250 ms.
  • acpmux (_acpmux/attach). The reply now carries turnOpener: the prompt that opened the turn the page starts inside. It is computed only when the page has older records and starts mid-turn, using the same 16-record user_message read the pane used to make itself (server/requests/turn_opener.rs). Otherwise it is null.
  • Older daemons. One that leaves turnOpener out still gets the pane's own read. This is detected by whether the key is present, so a mixed fleet and a forwarded peer attach both work.
  • Protocol and generated types. spec/acpmux-protocol.json and protocol/generated/types.ts are updated for AttachResult.turnOpener. The mock daemon answers the same way.

No new timer, observer or loop. The early attach's live buffer is bounded by the records the chat streams between the handshake and the list's reply. It is cleared when the open takes or drops the attach, and in open's finally. No new per-token work.

Tests (red on base, green here)

  • webviews/src/agent-session/acpmux/chatOpenTrips.test.ts: a mock daemon holds every reply until the test answers what is out as one wave, and the test counts the waves before the first frame with rows.
    • a chat's first frame waits on the handshake and one round trip: expects ≤2 waves, with initialize alone and then status, watch and attach together. Base takes 5.
    • the turn's opener comes with the attach page: a chat whose newest page starts inside a 450-record turn opens in ≤2 waves, with no _acpmux/events read.
    • an acpmux that does not name the opener still has it read: the fallback for an older daemon.
  • cmux-tui/crates/acpmux/tests/hub_integration/transcript_events.rs an_attach_page_names_the_prompt_of_the_turn_it_starts_inside: on pages starting inside the last turn, turnOpener equals what the pane's own read finds, and it is null for a whole log or a page that starts with the prompt.

Performance

metricbeforeafterdeltaharness or run link
daemon round-trip waves before a cold chat's first row (mock daemon)52-3chatOpenTrips.test.ts
same, chat opened mid-turn (served opener)62-4chatOpenTrips.test.ts
median first content, mock daemon with 5 ms per reply26.5 ms11.0 ms-58%Bun open bench on Linux CI (cmux-ci test)
same, 20 ms per reply101.3 ms41.0 ms-60%same
same, 50 ms per reply251.6 ms101.1 ms-60%same
same, 50 ms per reply, chat opened mid-turn252.1 ms101.4 ms-60%same

The bench opens the mock chat with every daemon reply delayed by D ms: base pays about 5 x D before the first row and this head pays 2 x D (the handshake, then one wave). A fleet-Mac run of scripts/measure/agent-engine.sh was refused at a PR ref (the script measures only refs on its list), so the Mac number comes from the nightly once this lands.

test/agent-pane-peer-copy.test.ts's stand-in daemon runs its once-only scripted hooks (records during the attach, a recreation, refusals) on the attach the open asks of the chat as listed, and answers an attach asked before the list plainly; two tests count only the listed attaches.

test/agent-pane-new-tab-cached-chat.test.ts now lets the host's tail read land after the held attach is asked: the attach goes out with the list, before that read.

Base reds: the src/agent-session/acpmux directory run fails the same 105 tests by name at base and here (the head fails none of its own; base also fails the new tests and one long-chat paging test this head passes). transcript_events::orchestration_fields_reach_subagent_events_and_session_summary fails identically on base.

Risk

Medium. This reorders the open handshake in direct.ts. The guards are unchanged, and every case the early attach does not fit falls back to the old path. #27614 (acpmux page compaction) touches the same attach handler a few lines away; the hunks do not overlap.

šŸ¤– Generated with Claude Code

Agent session: agent session