cmux NEXT sidebar: the show/hide slide sizes the content once, not on every frame
Held for dogfooding, do not land yet. Leo (06:38Z), relaying Lawrence: "dont merge the sidebar things yet we can maek pr(s) and test it out and dogfood it a lot". This PR gets no ready-to-land until that hold lifts.
Dogfood build of the head: fleet job ffe11898be65f1250facb17d. Get it with cmux-ci artifact ffe11898be65f1250facb17d app.zip.
Leo (ask-42f0d9ab8d) had earlier lifted the hold on sidebar virtualization and the show/hide animation ("honestly dont think we should be holding these"); the hold above replaces that. The original complaint (ask-995a5ef560) was that split, Cmd-Shift-D, sidebar add and sidebar show/hide "feel slow". The GPU audit (leo-workspace notes/perf/gpu-audit-2026-10-21.md) ranks the sidebar slide as offender #1.
What was slow
While the sidebar slid open or closed, AppKit stepped its width constraint on every display frame. That moved the main pane card's edge, and the card resized its content to match. On every frame, then:
- every pane was laid out again (
ScreenContentView.setFrameSize→reconcile/applyPresentation); - every terminal surface was resized, with a synchronous Metal wait;
- every Chromium page window was resized.
What changed
- The sidebar's own animation is untouched: the same constraint animation, the same
Motion.sidebarappear/disappear tokens, curve and duration. - One width for the whole slide: the content inside the card keeps the widest width the card reaches during the slide (
MainPaneCardView.contentHold,WindowRootView+ContentAreaMotion). It rides the moving edge, so panes still slide with the sidebar, and the card clips them at the far edge.- A hide lays the content out once, at the start.
- A show lays it out once, at the landing.
- A reversed slide keeps the widest width either direction needs.
- The card clips while it holds, square or rounded (
masksToBounds, taken off again at the landing). The app's default chrome style has a square card.- Chromium pages read that clip (
CEFClipShape.clips(around:)). - A rounded clip whose straight edge crosses a page now cuts it (
CEFClipShape.cutsSide). - An adopted overlay plane masks to a square clipping ancestor above its home (
OverlayPlane.roundedClip).
- Chromium pages read that clip (
- Terminals settle once, at the final size: at the landing, the root clears the hold and lays out its subtree at the landed width (a workspace sizes its layout in
layout()). Only then does it tell terminals the motion settled. Each terminal reports one grid, the final one, even after a reversed slide. - Pages keep their size during the slide, which takes care of most of the audit's #5 (CEF geometry fan-out).
CEFHostViewand the CEF pump are not touched. - Cases left alone:
- The rail style opens over the content and never resized it.
- Several workspaces shown side by side (
arrangeContent) keep their own arrangement. - With floating panes, the shade's pane cutouts are clipped to the card.
- Debug setting: Debug Settings → Sidebar → "Hold content size in sidebar slide" (
sidebar.slideHoldsContent, on by default) restores live resizing.
Timers, observers, animations
None added. The hold starts with the width animation (SidebarContainerView.onWidthMotionStart, from apply()). It ends on the existing isWidthAnimating = false edge, which fires on all four existing paths: the completion, snap(), a resize drag, and the existing sidebar.width-motion-watchdog (500 ms past due). Each path clears contentHold, drops the clip, lays out once and settles terminals.
Before / after
Harness: the GPU-audit kit (leo-workspace notes/perf/gpu-audit-2026-10-21/, sidebar-only phase).
- Builds: fleet DEV builds of base
1f4dec7637d1and head1f42c175669f. The two head commits after1f42c175669fchange only the order at the landing (one subtree layout) and the tests. - Machine: one capture mini, cmux-mac-mini-cua-2 (M4, 1x 1920x1080 display, 60 Hz), with base and head runs interleaved.
- Runs: 4 base and 3 head.
- Workload: three terminals with 3000 lines of scrollback each, then the same with a Chromium tab and a WebKit tab added; the default chrome style.
- Method: 10 s of show/hide loops per phase, then the same with
ui.animations.sidebaroff. Main-thread CPU comes fromps -Mwith no sampler attached. Frames come fromdebug.frames, 8 toggles per run. - Reading the table: "slide cost" is the main-thread ms per toggle with the animation on minus with it off. Per-frame values assume about 8.6 frames per slide, as in the audit. All values are medians.
| Terminals, base | Terminals, head | With web tabs, base | With web tabs, head | |
|---|---|---|---|---|
| Main thread per toggle, animation on | 106 ms | 92 ms | 153 ms | 133 ms |
| Main thread per toggle, animation off | 63 ms | 63 ms | 76 ms | 79 ms |
| Slide cost (on − off) | 43 ms | 28 ms (−35%) | 77 ms | 54 ms (−30%) |
| Per frame (share of a 120 Hz frame) | 5.0 ms (60%) | 3.3 ms (40%) | 8.9 ms (107%) | 6.3 ms (75%) |
| Longest frame per toggle, median / worst | 35 / 53 ms | 17 / 38 ms | 33 / 50 ms | 33 / 47 ms |
| Missed frames per toggle (mean) | 2.5 | 0.1 | 2.1 | 0.7 |
| Chromium renderer CPU per toggle | 20 ms | 23 ms | 29 ms | 15 ms (the animation-off level) |
| WindowServer CPU per toggle | 81 ms | 84 ms | 132 ms | 126 ms |
What remains of the slide's cost is the sidebar's own frames: its width constraint still steps a window layout pass every frame, and the card, shade and overlay follow. Panes, terminals and pages no longer resize each frame. The Chromium renderer's extra work during the slide is gone. WindowServer did not change measurably on this 60 Hz 1x display.
Capture
Base and head look the same: the change is in what runs during the slide, not in how it looks. The stills show the landed state after a hide, with two terminal panes and a page.
Capture steps-7ddd567ef9 at e347ce3a351b (fleet build, capture mini, text checked for private data)
| Light | Dark |
|---|---|
![]() | ![]() |
Tests
SidebarSlideHoldsContentTests (new) covers these cases:
- a hide sizes the content once, and the content rides the edge;
- a show keeps the wide content until the landing;
- a reversed slide keeps one width;
- with the hold off, the content resizes on every frame;
- a right-side sidebar slide rides the trailing edge;
- a withdrawn workspace is left alone;
- a square card clips only while it holds, and a page sees its edge;
- a page that crosses the rounded card's straight edge is cut;
- after a reversed slide, deferred-layout content settles once, at the landed width.
Compile: cmux-ci compile-check --ref e347ce3a351b1bb49a9de324154697c3514d2ea3 --app passed.
Review: Codex at xhigh. Its two BLOCKs were addressed:
- square-card and straight-edge clipping;
- terminals settling before the layout at the landing.
The fleet package-test run at 1f42c17 failed only one check of this PR's own, a setup precondition, which is now fixed. Its other failures are in suites this PR does not touch (ClassicUpgradeSimulation, SettingsSchemaExport, ManagedPreferencesManifest, HomeChiefSettingsPopoverPlacement, BackgroundAccessoryPolicy and others). MainPane*, BrowserPaneOneContainer, WebviewPaneOneEdge and PaneZoomTerminalResize passed.
Changelog
Changed: Showing and hiding the cmux NEXT sidebar no longer resizes every terminal and web page on every frame of the slide.
Leo-ask: ask-42f0d9ab8d
Leo-ask: ask-995a5ef560
🤖 Generated with Claude Code
Agent session: cosmic-gray

