cmux NEXT: an existing chat opens on the warm spare page, which keeps drawing while it is held off screen

Merged
TE
1d ago
+110-21

Leo-ask: ask-78e06958ab

Takes back #31486: on purpose. #31486 switched off spare adoption for existing chats; this PR switches it back on with the root-cause fix, and the same-harness bench below shows adopted opens faster than cold with 0 flashes.

Leo, 2026-11-01: "and i also want things to load faster"

Opening an existing chat (a sidebar click or a reopen) now takes the warm, connected New Tab spare page again instead of starting a cold page. A cold open is page boot, socket, handshake and attach. In the Linux chat-open bench (#32365) the page reaches its socket at about 156 ms and paints the newest message at about 330 ms, whatever the chat's length. #31486 switched adoption off (HQ#5914) and this PR switches it back on. It carries #31619's commits, whose lane has been idle since 05:08Z, and adds the root-cause fix that PR's own bench pointed at.

Cause

An adopted spare waits 100 000 pt outside the window until it has drawn the chat (AgentPaneView.adoptSession). Meanwhile, the pane's paint hold (PanePaintHold.begin) makes the incoming pane transparent and re-syncs Chromium visibility. A transparent ancestor hides a Chromium page (#28002), because its child window ignores alpha. So the hold hid a page nobody could see:

  • Chromium throttled it;
  • it ran no animation frames, so it drew and reported the chat only once the hold gave up at 500 ms.

#28980 hit the same thing for the parked spare and excused only PageOffscreenParking. This matches #31619's measurements:

  • about 800 ms from adoption to paint, against about 600 ms for a whole cold open;
  • visibilityState "hidden" in the adopted page.

Fix

  • PageWebView.setChromiumVisibility: no ancestor's alpha hides a page laid out wholly outside its window's content (liesOutsideWindowContent; it is false for a page or window with no size yet). Its child window shows nothing there. In place, a paint hold still hides it (#28002), so nothing stale shows over the outgoing pane.
  • Carried from #31619:
    • adoptsExistingChats is gone, so existing local chats take the spare again (servesChat still refuses an unconnected spare, Low Power Mode and serious thermal state).
    • The adopted page reads the chat list again only when its attach fails, not beside it on the same socket.
    • The chat-open trace counts an adopted page as drawn once its pane painted.
  • AgentPaneView.adoptSession turns the loading cover on before the loading state begins. The chat-open trace binds there and samples at once; with the order the other way round, it read the New Tab page's still-hidden cover as the chat's saved frame and logged the cover that followed in the same turn as a flash. No such frame was drawn: the pixel-judged click case shows the adopted open reaching its final frame sooner, with fewer in-between frames than the cold one.
  • Not carried: #31619's paint report on a task while the page is hidden. It reported a chat Chromium had not drawn, and the reveal then showed the page's previous frame. That is the likely source of its 3/5 and 5/5 flashes. With the held page drawing, the report waits for a real frame again.

The right chat, and no stale flash:

  • adoptSpareSession takes only an untouched, connected New Tab page with no session (newTab cleared, start folder and paint flags reset).
  • The page leaves New Tab in the adopt event's own render.
  • The pane shows its cover until the page reports the chat drawn.
  • spare-session-adopt.test.ts covers that the page never falls back to another chat, that a missing chat is said to be missing, and that a kept tail draws before the attach.

Tests

  • AgentPaneChromiumVisibilityTests.anAdoptedPageHeldAwayKeepsDrawingUnderAPaintHold: red at d6e45f2c (the hold hides the held-away page), green at the head. It also checks that a page in place under the hold stays hidden.
  • webviews/test/spare-session-adopt.test.ts: 7 pass, including #31619's red-first list-read test.

Performance

The harness is scripts/measure/chat-open.sh (chat-open-bench.py). Both arms ran on the same fleet Mac with --runs=10 --conditions=idle,load. Before is base 622fc891 (adoption off, cold); after is this head, 0e5d0e45 (adopted). Cells are medians of 10 opens.

  • Sidebar and reopen rows time the action to the first frame of chat content, from the chat-open trace. "Flashes" counts the opens that went back to an empty surface after drawing content.
  • Click rows are judged from screen pixels: click to the first content frame, then to the final (settled) frame.
metricbefore (622fc891, cold)after (0e5d0e45, adopted)deltaharness or run link
sidebar open, idle (ms)670.1, 0/10 flashes508.0, 0/9 flashes-162 (-24%)chat-open.sh, steps 7963eab0 / eff65ad4
sidebar open, load (ms)826.7, 0/10 flashes632.2, 0/10 flashes-195 (-24%)chat-open.sh
reopen, idle (ms)758.4, 0/10 flashes489.6, 0/10 flashes-269 (-35%)chat-open.sh
reopen, load (ms)819.2, 0/10 flashes568.0, 0/10 flashes-251 (-31%)chat-open.sh
click, idle: first content / final (ms)409.5 / 988.5367.5 / 648.5final -340 (-34%)chat-open.sh, steps f851bdc1 / eff65ad4
click, load: first content / final (ms)457.0 / 1101.0417.0 / 702.5final -399 (-36%)chat-open.sh
click: frames between first content and final, 10 clicks (idle / load)353 / 386168 / 170about halfchat-open.sh

Notes on the after run:

  • Sidebar idle: one of the 10 opens produced no trace frames (the harness saw no pane), so the bench reports that cell as incomplete. The median is over the other 9.
  • Click under load: the bench reports it as failed because its layout watch timed out on a DevTools evaluate. All 10 clicks drew and settled in pixels, and the median is computed from those.

The first adopted run, at 72374b6d, counted flashes on 7 to 10 of every 10 opens. Those were trace artifacts from the ordering fixed above, and they are gone at this head. The previous attempt (#31619 at fe669616, same harness, a fleet Mac) measured adopted sidebar 780 against cold 771 ms, adopted reopen 1115 against cold 806 ms, and under load 2 of 5 valid.

Timers and observers

None added. #31619's visibilitychange listener is not carried.

Risk: normal. This turns the warm-spare open back on for every existing local chat.

Changelog

  • Opening an existing chat is faster: it takes the warm New Tab page instead of starting a cold one.

Agent session: agent session

🤖 Generated with Claude Code


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Re-enables warm-spare adoption for existing chats, so a sidebar click or reopen takes the connected New Tab spare instead of starting a cold page.

The root cause: an adopted spare waits outside the window until it draws the chat, but the pane's paint hold makes its incoming view transparent, and a transparent ancestor hides a Chromium page (#28002). Chromium throttled the hidden page, which drew the chat only when the hold gave up at 500 ms, so the adopted open was slower than a cold one. PageWebView.setChromiumVisibility now ignores ancestor alpha for a page laid out wholly outside its window's content, so the held-away page keeps drawing while a page in place under the hold stays hidden.

Other changes

  • Existing local chats take the spare again; servesChat still refuses an unconnected spare, Low Power Mode, and serious thermal state.
  • The adopted page reads the chat list again only when its attach fails, so a slow list read no longer holds up the open.
  • The chat-open trace counts an adopted page as drawn once its pane painted.
  • #31619's hidden-page paint report is dropped: reporting without a real frame showed the page's previous frame on reveal.
  • A failed adoption's list read can no longer clear a chat selected after that read.
  • The adopted spare's cover turns on before the loading state begins, so the trace's first sample reads the cover and the open no longer flashes.