Agent chat: a chat the page already read opens instantly (perf test for opening chats)

Merged
TE
17d ago
+1067-5

Leo, 2026-10-20: "do chats load fast yet? even with a high number of chats and high load? does it still flash or flicker? It should have no layout shift, then a fast fade-in, and be instant if cached."

Answer, measured: the layout was already right โ€” nothing shifts, nothing flashes โ€” but a chat you open again paid acpmux's attach round trip over, so it was never instant. This adds the measurement and makes a re-open draw on the next frame.

chat opens: before (left) and after (right)

Left: before. Right: after. Same script in both โ€” six chats picked out of 120, opened cold, then every one of them opened again. Full quality:

The measurement

webviews/test/chat-open-perf.test.ts (Chromium, the real pane page) opens chats against a stand-in
acpmux with 120 chats in the list that answers like a busy daemon: every call 20 ms, an attach
150 ms. It samples every frame from the select until the chat has been drawn for 700 ms, and reports
when the chat first drew, how many frames moved a row it had already drawn, and how many frames left a
hole in the transcript's view.

first drawshifted framesblank frames
cold, before337/251/249/263/244/275 ms00
cold, after252/272/278/323/337/354 ms00
cached, before263/258/289/279/282/281 ms00
cached, after56/66/69/74/115/131 ms00

A cached open is now a single frame after the select (the log's frames to the draw is one entry):
the page's own render of the chat, with no round trip in it. A cold open stays inside the brief's 400 ms,
and no open in either column shifted a row or left a blank frame โ€” the row estimates and the switch
hold that landed earlier today (#22234, #22754, #22910's estimates) hold up under 120 chats and a busy
daemon.

The fix

AcpmuxDirectClient.select() dropped the chat's events and drew nothing until the attach page was in.
The client now keeps the tail of the last 8 chats it read on this page (events, summary, queue, turn
opener: one attach page each) and draws a chat from that on the frame the switch lands on, while the
attach goes out behind it and lays what acpmux holds now over it:

  • the chat's running state comes from the session list, which stays live, so a chat that moved on
    draws as it is now, not as it was;
  • an unchanged chat replays to the same rows, so nothing moves when the attach lands;
  • a purged chat's tail is dropped, and only a chat whose own attach landed is ever kept, so a
    half-read chat is never drawn again.

Same shape as the permission groups' cache (#22234: "a chat this client read before shows what it last
read at once; the refresh that follows replaces it").

The rest of the brief needs no change: the native pane already fades a chat's page in over its loading
mark in MotionFade.fadeIn = 120 ms (MotionTunables.swift), and only for a page that has not
painted, so a chat already on screen is instant with no fade and no spinner.

Review

An independent review subagent found four ways the frame back could draw something no longer true, and
fixed in 0b25acc8cac6a, each with a test that is red without the fix
(webviews/test/agent-pane-cached-chat-open.test.ts, JSDOM + a stand-in acpmux, 8 tests, 5 of them
red before):

  • a chat that ran on past one attach page came back with a hole in the middle of its transcript
    (tail at seq 6, page from 998) that no later read could fill. The attach now takes the tail's newest
    seq and drops the tail when its page does not continue it.
  • an ask answered elsewhere while away came back as a card, and answering it errored. Only acpmux
    says what is pending now.
  • a chat whose turn ended while away came back as working, with the old turn's clock. Running or idle
    comes from the live list, applied after the replay.
  • a queued prompt lost its text to acpmux's 200-character preview.
  • a chat whose attach fails stayed on screen from the page's own memory; now the tail is dropped and
    the pane empties as it does without one.

Also capped at the newest 400 events per chat, so a deeply scrolled chat leaves no megabytes behind.

Not in this PR

A chat opened in a new tab is still cold: each pane is its own page (agent.pageHost off), so the
page's cache is empty there. Persisting a chat's tail across pages (web storage, or the host handing the
page the tail it has) is the follow-up; it needs a size and privacy call this PR should not make on its own.

Changelog

Fixed - A chat you have already opened in the agent pane now opens on the next frame instead of waiting for the daemon.

Observed / Not run

Observed, on big-red (Chromium via Playwright, CMUX_BROWSER_TESTS=1 bun test test/chat-open-perf.test.ts):
the numbers above; the file red on its cached budget at the first commit, green at the second. On CI's
browser lane it passed in 14 s (react-apps-check (1), head 828237f347: PASS test/chat-open-perf.test.ts).
test/agent-pane-cached-chat-open.test.ts: 8 pass on the head, 5 of them red with the previous commit's
direct.ts (verified by reverting that file and re-running).
test/agent-pane-switch-frames.test.ts (3 pass: 0 frames in between on a switch, 0 frames not whole on a
first open, 0 frames without the chat on a reconnect), agent-pane-hold-acting-chat,
agent-pane-view-race, agent-pane-agent-pair, agent-pane-subagent-groups-open (18 pass), webviews
bun run typecheck, scripts/web-lint.sh, scripts/cmux-next/prepush-gate.py.

Not run: anything native (no Swift is touched), the webviews suite beyond those files (CI runs it), and
this file on CI's browser lane, which this push is the first run of.

๐Ÿค– Generated with Claude Code

Agent session: big-red