Agent pane: one DOM insertion no longer restyles the whole pane (pane-level :has() rules to state attributes)

Merged
TE
5d ago
+817-35

Leo (rulebook): "when it comes to the left scroll rail thing, i already said we want it ultra fast ultra responsive, but the same goes for EVERYTHING IN THE WEB UI".

What was wrong

Any node inserted in the agent pane made Chromium restyle the whole pane. That covers a streamed reply's rows and tokens, a session rail tick, and a menu row. It cost 678 elements on the normal chat page and 2,186 on the 3,000-turn page, 4–9 ms of style per insertion. The draft rail investigation (#28540) found the effect. This PR measures it per rule and removes it.

The cause is one selector shape: a :has() before a combinator whose last compound has no class or attribute (… :has(…) > *, … :has(…) > :not(…), … :has(…) > span:not(…), label:has(…) *). With such a rule anywhere in the page, Chromium invalidates whole subtrees under the elements it tracks for :has() on every insertion. :has() on the subject itself, or followed by a compound with a class (.x:has(…) > .y), is cheap. The opt-in Chromium trace below measures each of the page's 143 :has() rules. With all 143 out, an insertion restyles 2 elements. With only the five below out, it restyles 43. Each of the five alone brings it back to about 870–900.

Changes (five rules)

rulewherereplaced by
.acpmux-composer-box:has(> .acpmux-chrome-area > .acpmux-composer-inner) > .acpmux-composer-field-menu > * (and the 11 sibling rules on the same :has, on the box, the form and the dock)composerLocation.cssdata-composer-grid that ComposerBox sets on the box and Composer sets on its form, for Claude's look (look !== "codex", the condition that already adds .acpmux-composer-inner). The asks stack's radius uses a sibling :has(~ .acpmux-composer[data-composer-grid])
… .cv-turn-actions…:has(> .acpmux-pr-dock) > :not(.acpmux-pr-dock), … .cv-reply-actions:has(> .acpmux-pr-dock) > :not(…) and the matching opacity: 1 rulesummary/summary.cssdata-pr-dock on the action row, set by TurnFooter and ReplyActions through useHasPullRequestDock(), the same prs?.length condition under which PullRequestDock draws
.cv-codeblock__header:has(.acpmux-pr-dock) > span:not(.cv-codeblock__actions)summary/summary.cssremoved: it repeated, declaration for declaration, the label's unconditional rule in conversation.css
:where(…, label[for], label:has(checkbox, radio)) * { cursor: inherit }pages/shared/desktop.css:where(…, label) *: everything inside any label takes the label's cursor
label:not([for]):has(disabled):not(:has(enabled)) :not(clickables) { cursor: default !important }pages/shared/desktop.cssthe subject rule alone. The locked label's contents inherit its arrow through the label rule above

The two desktop.css rewrites cascade to the same cursors in every fixture of clickable-cursor.test.ts. That test gains four selectable-text label fixtures (a choice, a locked choice, a field label and its text field), which pass before and after. One edge differs: a descendant of a label that has its own non-clickable surface cursor rule now keeps that rule. Before, !important default overrode it for a locked label, and auto overrode it for other labels inside .selectable. No current fixture or source hits that case.

Specificity: the composer rules drop from :has(…) to one attribute. I scanned every rule that sets the same properties on the same elements, and none sits in the range that drop would change. The style dumps below check this per state.

Overlap: #28533 (bottom stack) is not touched. Its new :has() rules are the cheap shapes (subject, or a class after the combinator). bottomStack.css's own composer-inner :has rules are cheap too and stay. #27616 edits composerLocation.css lines 257–360 (the access menu). This PR edits lines 1280–1395.

No new observer, timer or listener.

Performance

Same harness, same CI job and same runner for both arms: test/pane-has-restyle.browser.test.ts (PANE_HAS_MEASURE=1), a Chromium trace of UpdateLayoutTree.elementCount and duration. The job ran after, before, after, before by applying and reverting this PR's webviews/src diff. The table gives the median of the two runs per arm. Pages: flows.html?mock&recents=demo (normal) and flows.html?mock&turns=3000&resident (3,000 turns).

metricbeforeafterdeltaharness or run link
elements restyled per inserted row node, normal page67841−94%run
style ms per inserted node, normal page3.6–4.20.55–0.72−83%same
elements restyled per inserted row node, 3,000 turns2,18641–46−98%same
style ms per inserted node, 3,000 turns8.6–9.00.77–1.01−90%same
elements restyled per streamed delta (12 chars / 16 ms, 2 s), normal18545−76%same
style ms per streamed delta, normal1.420.64−55%same
largest recalc while streaming (p95 elements), 3,000 turns38626−93%same
style ms per streamed delta, 3,000 turns1.490.69−54%same
rail wheel step, elements restyled (p50), 3,000 turns2,615166−94%same
rail wheel step style ms p50 / p95, 3,000 turns9.8 / 10.51.4 / 4.1−86% / −61%same
slash menu open, elements restyled / style ms, normal1,474 / 7.5259 / 2.2−82% / −71%same
slash menu open, elements restyled / style ms, 3,000 turns4,466 / 17.3243 / 2.5−95% / −86%same
streaming frame p95 (headless 60 Hz)16.8 ms16.8 ms0same; headless Chromium paces frames at 60 Hz and dropped none in either arm, so this metric shows nothing here

Visual parity

test/pane-has-parity.browser.test.ts (PANE_HAS_PARITY=1) captured 17 gallery states, each in light and dark: the composer (idle, focused, right-click menu open, long draft, image attached, queue, Codex harness), Claude's and Codex's looks, the three draft scrollbar placements, and the PR links (inline, hovered turn, one-first, no footer, code tail). All 34 stills are byte-identical between the arms in both rounds.

statebeforeafter
Composer, right-click menu open (dark)beforeafter
PR links beside a turn (light)beforeafter

Viewed after image: the PR links beside the turn's footer show #131 atlas-web, #130 atlas-web and +2, with the footer's own buttons hidden until hover; in the composer the right-click menu sits over the prompt and the prompt keeps its grid (+ at left, mic and Send at right). Each is byte-identical to its before still.

Tests

  • src/agent-session/acpmux/summary/prDockAttribute.test.tsx: a turn footer and a reply's actions carry data-pr-dock exactly when the dock draws links. Red on the base half of the job, green on this PR.
  • test/composer-grid-attribute.browser.test.ts: in Claude's look and Codex's, the mark stands exactly where the old :has() matched, and the box is still a grid. Red on base (Claude states), green here.
  • test/clickable-cursor.test.ts: new label fixtures, green before and after.
  • test/pane-has-restyle.browser.test.ts and test/pane-has-parity.browser.test.ts: opt-in measurements, skipped unless their env var is set (no CI gate).

Observed / Not run

  • Observed: the runs above. bun run check passed on the head (in the same job).
  • Not run: a lint for new costly :has() shapes. The repo rules say no new CI gates, so the rule is documented in desktop.css's comment instead. Also not run: a native capture. Nothing native changed, and the webview stills above are pixel-identical.

Changelog

Fixed: the agent pane no longer restyles the whole pane on every streamed row, rail tick or menu row.

Agent session: agent session

🤖 Generated with Claude Code