Skip motion while the display sleeps so inserted rows show

Merged
TE
23d ago
+349-22

Summary

In cmux NEXT, a sidebar row inserted while the display sleeps never finished its open animation. A sleeping display draws no frames, so the AppKit animation never advanced and the row stayed at alpha 0 and zero height until Reduce Motion was turned on. A row already opening or closing when the display went to sleep stopped where it was in the same way, and a closing row's completion (which removes it) never ran.

Two changes in CmuxNextDesign:

  • Motion.canAnimate(in:) returns false when the window's display is asleep, so a change started there applies at once and its completion runs now. This covers every Motion.animate / animateTimed / animateExit caller. CGDisplayIsAsleep for the window's display decides. That covers a launch while the display already sleeps, and a sleep notification with no matching wake can't leave later changes snapping.
  • A change already running when the display goes to sleep ends at once only if it opts in: Motion.animate(_:in:_:settleAtSleep:completion:). MotionInFlight tracks those until their completion runs. On screensDidSleepNotification or screensDidWakeNotification (for one that froze in the gap), each one's settleAtSleep runs in a zero-length group and its completion runs. settleAtSleep applies the current target, read when it runs, never the values the animation captured. A completion runs exactly once, from finish() or from AppKit, whichever comes first. An entry 2 s past its expected end is no longer settled, but AppKit's completion for it still runs.

Only the sidebar row move and open (RowMotion.run, RowMotion.place) opt in. Their settle is list.reload(animated: false), so rows take the places the list holds at that moment, and the move's completion then recycles the rows still leaving. Every other caller keeps the skip at start and is never replayed. That includes the overlay fade, the viewport scroll, SidebarRegionView.relayout and the constraint animators. Their change blocks don't escape. CA-based helpers (Motion.set, layer actions) set model values first; CADisplayLink users are covered by FrameScheduler's stall timer. Window occlusion isn't part of the check: a freshly ordered window can report itself occluded for a moment, and automation launches keep occluded windows drawing on purpose.

The first commit fixes a base compile red in SpareNewTabChatOpensTabsTests (AgentTabStore(tag:) is now AgentTabStore(build:)); without it no CmuxNextAppTests suite builds.

Proof

SidebarInsertWhileDisplayAsleepTests (CmuxNextAppTests) runs a sidebar in an on-screen window. The tests are gated on an awake display with animations on, so a sleeping or speed-off host skips them instead of failing. Every run below is from a display host (aws-m4pro-7/8/9), not a headless skip.

  • aRowInsertedWhileTheDisplaySleepsShowsWhole: with every display reading as asleep, a new row is at alpha 1 and full height without a frame. Red at ba7ff07b0f3e (test only), step 6ef5cd220103bce5f284c867: alpha 0.0, height 0.0.
  • rowsStillMovingWhenTheDisplaySleepsEndAtOnce: with the display awake, workspace 4 opens and workspace 2 closes. The test posts screensDidSleepNotification mid-animation and checks the new row is at alpha 1 and full height and the closing row has left the list. Red at 7b088a27c533 (test only), step 230d5e998fb6068aebe376d5: alpha 0.0, height 0.0, and the closing row still in the list.
  • rowsANewerReloadPlacedKeepTheirPlacesWhenTheDisplaySleeps: an insert animates, then two reloads without motion remove workspace 2 (its view goes to the pool) and add workspace 5. Sleep must leave every row where the list now places it. aClosingRowLeavesEvenAfterItsReplayExpired: a closing row whose entry has expired still leaves when AppKit ends its animation. Red at f3f938126bac against the global replay (test and the expirySlack seam only), step 2066c44bc67ba9ff9ffa8ca9. Two rows went back to their old slots (y 82 vs 56, y 56 vs 30), and the closed row stayed in the list.
  • aRowInsertedWhileTheDisplayIsAwakeStillOpens: the awake look is unchanged, and the row still opens with motion. It passed in every run.
  • Green at fce1d0b77f8a: full run-swift-testing-suites.sh Packages/macOS/CmuxNext on aws-m4pro-9, step 1ca0e17c1c0fbd47e250cb1f. All five tests and the whole step passed.
  • Release compile at fce1d0b77f8a: scripts/cmux-next/check-release-compile.sh (isolated), step d8f8a3f0af46db5596e652f9, passed.

Capture: a DEV build of an earlier head (fleet job 9b568f2772b49210064b70d2, before the sleep settle, which doesn't touch the awake look) on a cua-4 capture slot, speed "fast", three workspace.newBelow inserts with the display awake. debug.motion recorded three appkit.move spans of 334 to 350 ms. Sidebar crop only:

sidebar row inserts, display awake (head)

Full quality:

A live insert during real display sleep: unverified. Putting a shared capture mini's display to sleep would affect other lanes, so that case rests on the tests above.

Changelog

  • Fixed: Sidebar rows added while the display is asleep now show right away instead of staying invisible.

🤖 Generated with Claude Code