cmux NEXT sidebar: rows make room on the render server, not on the main thread each frame
Leo-ask: ask-995a5ef560
Leo-ask: ask-42f0d9ab8d
Leo (2026-10-21): "i imagine we're not really virtualizing too much of our sidebar. right now adding a workspace at the top ... it pushes all the stuff down"
Leo (2026-10-21): "sidebar i know it also pushes all the workspaces below down pretty hard so when you create a workspace at the top it's a huge cascade..."
Leo (2026-10-22, on the sidebar hold): "honestly dont think we should be holding these"
What was slow
The list already makes views only for the rows near the viewport (rowPool, realizationRect, binary search by y), so "virtualizing" the views was not the gap. The move was: RowMotion animated every moving row's frame with AppKit's SwiftUI spring (NSAnimationContext.animate), which writes the frame of each row below an insert on the main thread on every display frame until the spring settles. A workspace added at the top moves every realized row below it, so the main thread did O(realized rows) frame writes, and their layout passes, per frame.
What changes
RowMotion.run/RowMotion.placeset each row's model frame once and add additive.movesprings from one begin time (SidebarDragSpring.move(_:in:)). The render server moves the rows; the main thread does no per-frame work for them. Only the opacity of the rows that open, close or are still fading stays on AppKit's.movespring (a handful, whatever the list's length).- Group lines and the chat history blocks hand their frames back (
SidebarGroupLineViews.update,SidebarChatHistoryViews.place) and move on the same springs, in the rows' ownCATransaction, so a group's line still ends at its last row in every frame (cx-ai79) and the move's end waits for them too. - The move's end (recycle closed rows,
land,pruneOffscreen) runs once both legs ended (RowMotionEnd), or at once when the display sleeps. - Two loops over every row now visit only the realized rows: a width change (a live resize runs it every frame) and each favicon arrival.
- Reduce Motion, the sidebar motion switch, speed off and
ui.animations.newWorkspacebehave as before: rows, lines and history blocks land in one step.
Timers, observers, animations
- No new timer and no new observer.
- New animations: additive
CASpringAnimations on row, group-line and chat-history layers (keysmove.position.N/move.size.N; a drag's own keepdrag.*).- Stop condition: each ends on its own at the
.movespring's settling time (springAnimationsetsduration = settlingTime(within: 0.002), removed on completion). - Cleanup:
SidebarDragSpring.stoptakes them off on every change without motion (a reload without animation, Reduce Motion, the sidebar motion switch off; an unmoved view too) and when the display sleeps mid-move (rows, group lines, leaving lines and history blocks);SidebarRowView.prepareForReuseremoves all animations when a view is reused.
- Stop condition: each ends on its own at the
- Sleep tracking:
MotionGate.trackMoveregisters the springs' leg withMotionInFlight(entry ends at the settling time plusexpirySlack), next to AppKit's opacity leg, so a reorder with nothing fading (AppKit's leg ends at once) still settles at sleep. OneCATransactioncompletion per move;RowMotionEndruns the end once.
Review
Codex xhigh BLOCKed 93f82baa (3 findings: snaps left springs on, sleep missed line/history springs, an opaque reorder lost sleep tracking) and a4789e79 (2 findings: line/history springs outside the tracked transaction, unmoved history block kept its spring with movement off). All fixed with regression tests; Codex xhigh SHIP at 2f148b0b.
Tests
SidebarRowsMoveOnRenderServerTests: an insert at the top leaves each pushed row's frame at its slot with an additive position spring; a second insert stacks a second spring; a display that sleeps mid-move ends every row at its slot; a reload without motion and Reduce Motion mid-move take every row's and line's spring off; an opaque reorder the display sleeps through ends at the slots; a group line the display sleeps through ends at its rows; a line that alone moves (a member past the realized rows) still ends at sleep; a history block at its slot loses its spring when movement goes off.SidebarBackgroundChangeCalmTestsnow also reads the additive springs.- Compile:
cmux-ci compile-check --ref 2f148b0b63786c5132432225de10e80149d8956e --apppassed (job 6e1af45c364cf37e876f13aa).
Numbers
scripts/cmux-next/app-perf.py (#24173, cherry-picked onto both sides), the same harness on the same capture mini (cmux-mac-mini-cua-2, a shared capture slot, 60 Hz display), fleet DEV builds of base 850d3e0a21f6 and head 2f148b0b6378, 5 rounds each, key input. "new-top" is New Workspace at Top timed from the press until the window rests (1 s); "main ms total" is the main thread's busy time over the gesture (median of rounds), frame columns pool every round's frames.
| ws | scenario | frame p95 ms | p99 | max | drop@120 | main ms/frame p95 | max | main ms total | app commit ms | |
|---|---|---|---|---|---|---|---|---|---|---|
| 10 | new-top | base | 33.3 | 76.0 | 86.4 | 25 | 23.06 | 90.7 | 231.0 | 19.5 |
| 10 | new-top | head | 24.1 | 49.8 | 64.8 | 16 | 14.13 | 64.8 | 162.6 | 24.0 |
| 200 | new-top | base | 41.8 | 89.3 | 150.4 | 43 | 38.56 | 142.3 | 423.4 | 28.6 |
| 200 | new-top | head | 51.3 | 82.8 | 128.2 | 44 | 30.32 | 127.0 | 403.3 | 37.6 |
| 200 | sidebar-hide | base | 33.3 | 48.4 | 66.7 | 16 | 20.11 | 50.0 | 162.9 | - |
| 200 | sidebar-hide | head | 33.3 | 33.4 | 44.4 | 17 | 24.67 | 34.0 | 202.0 | - |
| 200 | sidebar-show | base | 33.3 | 36.0 | 64.0 | 18 | 23.02 | 56.4 | 206.8 | - |
| 200 | sidebar-show | head | 33.3 | 38.7 | 46.7 | 18 | 19.72 | 45.0 | 218.2 | - |
Read: at 10 workspaces the insert's main-thread work drops by about 30% (231 to 163 ms) and 120 Hz drops from 25 to 16. At 200 workspaces the main thread's worst frame and p95 drop (142 to 127 ms, 38.6 to 30.3 ms) and its total by about 5%, while frame p95 and drops are flat within this shared host's noise: the list realizes only the rows near the viewport, so the per-frame row writes this removes scale with the realized rows, not the 200, and the remaining cost of a new workspace at 200 is the insert's own commit (app commit ms, ~30 ms both sides) and the reload, which this PR does not touch. Sidebar show/hide is unchanged within noise (the PR does not touch it).
Capture
Twelve named workspaces, then two added at the top; the stills show the rows at their slots after the move (a video of the move is linked). Captured at a4789e79; the two later commits change only which transaction carries the line and history springs, nothing a still shows.
Capture steps-31241db78c at a4789e79fb6c (fleet build, capture mini, text checked for private data)
| Light | Dark |
|---|---|
![]() | ![]() |
Video:
Overlap
No overlap with azooz2003-bit's branches (none touch the sidebar list). pr-overlap names #21999 and #23933 (both teamleaderleo, different changes in the same files).
Changelog
Changed: the sidebar's rows move on the render server when a workspace is added or removed, so the main thread does no per-frame work for the rows below an insert.
Agent session: cosmic-gray
🤖 Generated with Claude Code

