Skip to content

fix(ui): cut the main thread cost of working Wisps and panel drags - #332

Merged
gustmrg merged 1 commit into
fix/wisp-idle-blinkfrom
fix/composited-wisp-motion
Nov 1, 2026
Merged

gustmrg merged 1 commit into
fix/wisp-idle-blinkfrom
fix/composited-wisp-motion

Conversation

@gustmrg

@gustmrg gustmrg commented Oct 31, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #330.

While a Wisp worked, its movement took about a fifth of the main thread, right when replies are streaming in. Dragging a panel edge rendered the whole app on every move and went over the frame budget. Both now cost far less and look the same. The recording meter and the connection bar also stopped animating layout, and the meter's bars no longer tween between samples.

Causes and fixes

  • Working Wisp and blink: wrong property. Chromium lays out every frame for the individual rotate, translate, and scale properties on SVG elements, but runs transform off the main thread. wisp-sway, wisp-look, wisp-bob, and BLINK_KEYFRAMES now use transform. will-change and contain made no difference, so neither was added.
  • Panel drag: rendering per move. useResizablePanel called setWidth on every pointermove, at the top of App. It now writes to the panel's custom property, named by the new cssVariable in ResizablePanelLayout, and commits to state when the drag ends.
  • Recording meter: layout per sample. The bars keep a fixed height and use scaleY instead of height. The 60ms transition is gone because it ran constantly at the 60ms sample rate: keeping it with scaleY cost more than height did, since all 100 bars animated at once.
  • Connection bar: layout and repaint. The progress fill uses scaleX instead of width, and the shimmer moves an ::before gradient with transform instead of animating background-position.

Main thread time, before and after

Scenario Before After
2 working Wisps, per 5s 1.07s 0.09s
40 idle Wisps blinking, per 10s 2.06s 0.92s
Panel drag, per move 19.4ms (10ms script) 9.4ms (0.13ms script)
Meter with 100 bars at 60ms, per 5s 1.74s 0.55s

These are single runs under xvfb on a desktop, so read them as ratios. A phone will differ.

Testing

  • npm run typecheck, npm run lint, npm run format:check, and npm run build pass. Lint reports the same 2 warnings as main.
  • npm test on Node 22: 2449 tests pass.
  • New test in use-resizable-panel.test.ts: a drag doesn't render until it ends. The existing tests now read the width from the panel during a drag.
  • Built app with the fake agent, measured over DevTools:
    • The working scenario forced data-state="working" on two Wisps. Five screenshots of the header Wisp over two seconds all differ, so it still moves off the main thread.
    • A drag keeps its width when it ends and after a reload. The drag used synthetic pointer events on the sidebar, because DevTools mouse input didn't start a drag under xvfb. The details panel shares the hook and the same handle structure, but wasn't driven.
    • The meter numbers come from 100 test bars with the same styles. Recording needs a speech-to-text key, so the real meter wasn't run.
    • The shimmer was checked on an element with the real class: it animates only transform, on ::before.

To check by hand before merging:

  • Dragging both panel edges with a real mouse feels right.
  • A working Wisp looks the same as before, on a real display.
  • The recording meter still reads well without the tween. Quiet bars are now a squashed version of the tall bar, not a small rounded one.
  • A recording on a mid-range phone in the browser app shows no dropped frames.

Not in this PR

  • Blinks still take about 9% of the main thread with 40 Wisps. Skipping rows off screen would have cut it by about 45% before this change. It may matter less now, so it needs a new measurement.
  • useRevealedText parses a reply's Markdown up to 30 times a second, a limit that was set on purpose. A long reply may still drop frames, which needs a recording of a real reply.
  • ui/accordion.tsx animates height because its content has an unknown size. That one stays.

Refs SWE-268

A working Wisp took about a fifth of the main thread while replies
streamed in. Chromium lays out every frame for the individual rotate,
translate, and scale properties on SVG elements, but runs transform off
the main thread, so the Wisp's keyframes and blink now use transform.

Dragging a panel edge set state at the top of the app on every move,
which rendered everything and went over the frame budget. The drag now
writes to the panel's custom property and commits when it ends.

The recording meter and connection bar stop animating layout too. The
meter's bars lose their 60ms tween, which ran constantly at that sample
rate and cost more than the layout it would have replaced.
@gustmrg
gustmrg force-pushed the fix/composited-wisp-motion branch from 77021b9 to 7aaee14 Compare November 1, 2026 03:13
@gustmrg
gustmrg merged commit 1071d40 into main Nov 1, 2026
2 checks passed
@gustmrg
gustmrg deleted the fix/composited-wisp-motion branch November 1, 2026 03:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant