Subagent pane: its transcript draws in the pane's first frame (spare page, tail in the adopt event)

Merged
TE
2h ago
+616-50

Leo, 2026-11-05: "it takes EGREGIOUSLY long for me to open up a link/the pane on the right opens... there's probably some stupid flow that bottlenecks itself" and "things just don't f***ing open up fast enough on the right. / links. subagents. all". Links were fixed in #36002. This PR is the subagents part.

What was slow

A subagent pane opened cold. Opening an existing chat on the prewarmed spare page has been off since #31486 (AgentPaneSpareReadiness.adoptsExistingChats = false, HQ#5914), so every subagent pane built a new page. That page then loaded, mounted, did its handshake and connected. After that it asked the host for the chat's tail (chat.tail.get), a round trip raced against 250 ms. When the main thread was busy it lost the race, and the pane stayed empty until acpmux's attach answered. The subagent reveal also ran only after that attach.

Takes back #31486: in part, for subagent panes only. A subagent pane adopts the spare again, but only when its adopt event carries the chat's tail, which rules out both HQ#5914 failures (below). Every other existing chat still opens cold, and adoptsExistingChats stays false.

HQ#5914 risk (reviewers: please check this specifically)

#31486 turned spare adoption of existing chats off because #31033's adopted page failed in two ways (HQ#5914, measured at 098aec3a):

  1. Sidebar open showed an empty transcript. The cover left at about 370-620 ms onto a page_empty frame: the page reported the chat read before its rows were in.
  2. A reopen never painted. The adopted page never got chatRead for its session, so the cover held for the CHAT_DRAW_HOLD_MS timer (about 15 s) and the chat never drew (0/5 idle and under load).

It was not a wrong chat, leaked state or a crash. The root cause in select() was never traced, and the repair a perf-limit lane took on 11-03 never landed (no open or merged PR).

Why this change can't bring either back:

  • The adoption needs the tail. The spare is taken only when AgentPaneTailCache holds the chat's tail (AgentPaneSpareReadiness.adopts(subagentPane:keptTail:)). That tail goes inside the adopt event (adoptEventDetail). Without a tail the pane opens cold, exactly as today.
  • The page reads the chat from that tail before any round trip. keepAdoptedTail puts it into the page's kept tails, and the switch runs in the same turn. select() then draws it (drawKeptTail) before its first await, so chatRead comes from drewKeptTail, not from acpmux's attach. A stalled attach or chat list can't hold the paint (failure 2). The chat is never read without its rows (failure 1).
  • Regression test: webviews/test/subagent-pane-first-frame.test.ts, "an adopted page with its chat's tail never reads the chat without rows, and reads it with acpmux stalled". The chat is off the page's list, its attach never answers and the list is held. Every snapshot that reads the chat contains its rows, and the chat is read.
  • What the guard doesn't cover: a sidebar open or a reopen still opens cold. Turning those on remains HQ#5914's open finding 1.

With #36492 (the tail cache's disk tier)

#36492, open and not merged when this was written, gives AgentPaneTailCache a disk tier, so chat.tail.get answers after a relaunch too. It changes the same bench script as this PR, in different parts; a test merge with its head has no conflicts. Its effect here:

  • This PR's adoption reads the memory tier only, synchronously (AgentPaneTailCache.shared.tail(for:) in servesExistingChat and adoptSession).
  • A tail #36492 reads back from disk is held in memory (diskTail). The next subagent open of that chat then finds a kept tail and adopts more often. That is the same guard: the tail is a whole, bounded tail of that chat (holds), carried in the adopt event and drawn before any round trip.
  • It doesn't widen adoption to sidebar opens, reopens or links. adopts(subagentPane:keptTail:) still needs a subagent: reveal, and adoptsExistingChats stays false (that is #32448's ground).
    If #36492 lands first, this branch will be rebased onto it before merge.

The fix

  • Spare page for subagent panes (Chrome's spare-renderer model). A subagent pane (a subagent: reveal) takes the prewarmed, connected spare when the host already holds its chat's tail. The chat hands the tail over as it asks for the pane (beforeOpen → handOverCurrentTail). Every other existing chat still opens cold, so the HQ#5914 paths are unchanged (AgentPaneSpareReadiness.adopts(subagentPane:keptTail:)).
  • The tail travels in the adopt event. AgentPaneView.adoptSession puts the host's kept tail into the acpmux-session-adopt detail (adoptEventDetail). The page keeps it (keepAdoptedTail → tailCache.keepHandedTail), and its switch draws the chat in the event's own turn. There is no host round trip and no acpmux round trip before the first draw, so there is no empty transcript, which was HQ#5914's failure.
  • The first frame is the subagent's. The page reveals the subagent before the switch, not after the attach. It skips the chat's reading-place read (adoptionPlan), which the subagent view doesn't use. It also sets the requested subagent in the render that first shows the chat, instead of in an effect after it, so no frame draws the parent chat's rows first.

Main thread: the adopt event's JSON is built once with JSONValue.appendCompactText, which is linear in the tail. The cache bounds the tail (the page sends at most 256 KiB). This is the same serialization chat.tail.get's reply did, minus the bridge hop.

Spare stop condition and cleanup are the pool's own (NewTabSparePool, AgentPaneSpareRefiller):

  • One spare is adopted per open.
  • Its replacement is made only after the open settled, once input is quiet (AgentPaneSpareRefill.whenQuiet).
  • The pool holds at most two spares.
  • All spares are dropped on memory pressure or when the last window closes (AgentPaneSpareSlot.dropAll).
  • No spare is adopted in Low Power Mode, at serious thermal state, or before its client is connected (AgentPaneSpareReadiness.servesChat).

No layout shift: the pane keeps its cover with the page held outside the window until the page reports the chat drawn (holdPageAway). This path is unchanged. No new animation.

Red / green

Commits, in order:

  • red 8306fdd7 (tests and stubs only), fix da5d7efb;
  • f84e03e2: the HQ#5914 regression test (webview test file only);
  • f5c3ce57: the chat-open bench fix (Python only);
  • red 9acb26b7, fix e6b34d1f: chat-open trace, the opened pane's bind takes over from a page that marked before it;
  • red 2c98f1ae, fix 47f82c23: chat-open trace, a shown adopted page counts once the pane has the page's painted answer (see "How the change is measured");
  • red e933f1db, fix ca8bfe74: an adopted spare's cover shows before it goes up, so its first frame is the cover (see "The cover order").

The last three pairs touch Swift, so the Swift green and the compile were rerun at the head.

  • Swift: cmux-ci run --class light --script scripts/ci/run-swift-testing-suites.sh --arg=Packages/macOS/CmuxNext --ref <sha> --env CMUX_SWIFT_TEST_SUITES=<suites>
    • red at 8306fdd7: AgentPaneSpareAdoptionTests fails 2 of 4 on their own expectations:
      • aSubagentPaneWithItsChatsTailTakesTheSpare at line 53: adopts(subagentPane: true, keptTail: true) is false;
      • theAdoptEventCarriesTheChatsTail at line 69: detail["tail"] is nil.
    • red at 9acb26b7: ChatOpenTraceTests.theOpenedPanesPageTakesOverFromAPageThatMarkedBeforeIt fails on its own 6 expectations (lines 74-79); the other 6 tests pass.
    • red at 2c98f1ae: ChatOpenTraceTests.anAdoptedPageThatPaintedBeforeTheTraceShowsOnceThePaneHasPainted fails on its own expectation (shownPage(paintedInOpen: false, paneHasPainted: true) is .pageEmpty).
    • red at e933f1db: AgentPaneColdChatRevealTests.anAdoptedSpareShowsItsCoverFromItsFirstFrame fails on its own 2 expectations (the frames were [image, cover]).
    • green at the head ca8bfe74: AgentPaneColdChatRevealTests, ChatOpenTraceTests, AgentPaneSpareAdoptionTests and SpareSessionAdoptionTests pass (4 suites, 0 failed).
  • Webviews: cmux-ci test -- bash -c 'cd webviews && bun test test/subagent-pane-first-frame.test.ts test/spare-session-adopt.test.ts test/reopened-chat-history.test.ts'
    • red: the fix's webviews/src changes reverted on the head snapshot (git apply of the reverse patch to 8306fdd7's sources, head test files kept), then bun test test/subagent-pane-first-frame.test.ts: 0 pass, 5 fail, each on its own expect.
    • green at f84e03e2: 15 pass, 0 fail across the three files. No webview file changed after it.
  • App compile: cmux-ci compile-check --ref ca8bfe74… --app: passed (143 s).
  • Bench unit tests: python3 -m unittest tests.test_chat_open_bench (in scripts/cmux-next): the subagent click tests pass, including the new closed-group test. 4 OpenTests fail the same way on base 79bdcd40; this PR doesn't touch them.

How the change is measured (this PR changes the measurement too)

This PR changes both the open and the trace that measures it. Every measurement change is also on the "before" ref, so both sides are measured the same way. The before ref is 792630c6: base 79bdcd40 plus the bench fix (f5c3ce57) and the two trace fixes (9acb26b7/e6b34d1f, 2c98f1ae/47f82c23), with none of the open's changes.

Bench (f5c3ce57). The subagent case gave 0 of 10 valid opens at base. Every click answered "no visible element matches" while the chat showed its 13 rows. The seeded chat's subagents have all ended, so their group (SubagentGroup.tsx) starts closed, and none of their lines is in the page. The bench now opens each closed group before it arms the trace, so the expand isn't measured. Its failure probe asked debug.agent_pane hover by selector, which takes only a point; it now reports chat_state alone.

Trace, first pane takes the page (e6b34d1f). Under load, a spare page warming during the open marked first, the trace latched onto it, and the opened pane's bind was refused. No pane marks and no surfaces were recorded, so the load arm was 0/5 valid. The first pane bound in a trace now takes the page over and drops the earlier page's marks.

Trace, an adopted page's surface (47f82c23). AgentPaneView.traceSurface counted a shown page as content only after the page's own first-paint mark (PagePaintProbe, .pagePainted) in this trace. An adopted spare page painted its first frame while it warmed, before the trace armed, so every adopted open read page_empty to the end: 0/5 valid in both arms at e6b34d1f, with 13 rows on screen. A shown page now counts once it painted in this open, or once the pane has the page's painted answer for this chat (ChatOpenTrace.Surface.shownPage(paintedInOpen:paneHasPainted:)).

What "the pane has the page's painted answer for this chat" means (reviewers: please check this). It must mean that chat's transcript is on screen, not merely that the spare page is alive. Otherwise adopted opens would read as content too early, and the after would look better than it is.

  • AgentPaneModel.adoptSpareSession resets hasPainted to false when the spare becomes the chat's. The New Tab page's earlier paint doesn't carry over, and the pane puts its cover up again.
  • hasPainted is set again by the page's pane.painted report (AgentPaneModel, .painted) or by the cover's give-up timer (giveUpWhenSeenUnpainted).
  • On the page, adoptSession re-arms that report (paintReported.current = false), clears newTab and sets adoptingSession. pane.painted then fires only when showsWhatItIs, which needs clientSnapshot.sessionId === adoptingSession && chatRead === true. It fires one rAF plus a timeout later. So for an adopted page the answer means the adopted chat was read and drawn.
  • The exceptions are a failed or disconnected connection, the 10 s CHAT_DRAW_HOLD_MS hold, and the native give-up. All of them are seconds later than the opens measured here.
  • That answer also ends the cover (endLoadingState, the pane_painted mark). So for an adopted page, first content is when the cover leaves over that chat's drawn rows.
  • A cold page's numbers don't change. Its cover leaves on the same answer, and its first-paint mark comes before that answer.

Not cross-checked in pixels: the bench's pixel columns (click to first content frame, blank frames, from the composited window) are recorded only for the sidebar click cases. The subagent case's click is the page's own DevTools press and runs no frame strip. Adding one is a follow-up, tracked with the load arm's fix.

The cover order (ca8bfe74)

The first after run showed one flash in every adopted open, and none before. The surfaces were image (about 5 ms, at the moment the pane was made), then cover, then page. adoptSession put the cover up (beginLoadingState(), which also starts the open's trace and samples the pane) one line before it un-hid it (showsLoadingState = true). The spare had drawn with its cover hidden, as every New Tab page does, so that first sample read the hidden cover. Both lines ran in one main-thread turn, so most likely no frame was drawn in that state, but the order was wrong either way. The cover now shows before it goes up. The pair below has 0 flashes on both sides.

Performance

Chat-open bench, subagent case: cmux-ci run --class exclusive --measurement --display --label measure --script scripts/measure/chat-open.sh --ref <sha> --arg=--cases=subagent --arg=--conditions=idle,load --arg=--runs=5. Before 792630c6 and after ca8bfe74 ran back to back, pinned to one display worker. 5 of 5 opens valid on each side, idle. Medians from the click:

metricbeforeafterdeltarun
first content (idle)1356 ms (1283-1683)753 ms (705-1137)-603 ms (-44%)before d463dc2b, after 1d58bd78
longest empty (idle)950 ms288 ms-662 ms (-70%)same
flashes (idle)000same
how the pane openedcold page (spare_unready)spare adopted (spare_adopted)same
cover leaves (pane_painted)1353 ms752 ms-601 mssame

Before, the cover waited on a new page: the Chromium window was made at about 407 ms, navigation started at 543, first contentful paint came at 1160 and the chat was drawn at 1353. After, the spare is adopted 4 ms after the pane is made (464 ms), and the cover leaves over the chat's rows at 752 ms.

The load arm is invalid in both runs (0/5 each, no_content). The trace never binds a pane while it is armed under load: no pane_made mark and no surfaces, only cover-leave marks from a pane made before arming. That is a bench fault on both sides, not this change. It is tracked in cmuxterm-hq#7270 (private), and load numbers come with its fix.

Observed / Not run

Observed:

  • the bench pair above (idle, valid, same worker);
  • Swift red and green, webview red and green, and the app compile, as listed under Red / green.

Not run:

  • the load arm's numbers (bench fault, above);
  • a pixel cross-check of the subagent open (the subagent case runs no frame strip);
  • a hands-on open in the app (no local app builds here).

scripts/web-lint.sh passes except check:switch-fetches. That check is red on base 79bdcd40 at webviews/src/agent-session/acpmux/flickerLog.ts:179 (from #36188), a file this PR doesn't touch.

Changelog

Fixed: a subagent opened on the right draws its transcript in the pane's first frame instead of after a pause.

šŸ¤– Generated with Claude Code

Agent session: agent session