cmux NEXT: a closing sidebar row shifts with a viewport re-anchor

Merged
TE
22d ago
+114-4

cx-y2uf5 item 1. When the sidebar was scrolled and a row closed while the list re-anchored its viewport, the closing row stayed at its old frame and jumped one row height (26 px) against its neighbor. SidebarListView+CloseScroll.applyKeepingViewport shifted rowViews, decorations and group lines by the delta, but a leaving row is no longer in rowViews: RowMotion.run animated it to its end frame without tracking it.

Fix

  • A closing row records its end frame in SidebarRowView.closingEnd. The field is cleared on every path where its close recycles it.
  • RowMotion.shift(_:by:) moves rowViews, the closing rows and their ends by the delta, then re-targets each close on the same .move spring. The original close's completion still recycles the row.
  • Small and fast motion only: nothing new animates, and the closing row just keeps its place relative to its neighbors.

Testing

  • Red (test commit 6799b83dbe20): fleet suites on aws-m4pro-9, a display host, fail at SidebarCloseWhileScrolledTests.swift:73: (abs(now - gap) โ†’ 26.0) < 0.5. A first run on aws-m4pro-4 skipped the test, because that host has no display and motion snaps there.
  • Green, same command at 9e4d98593a34 on aws-m4pro-9: aRowStillClosingShiftsWithItsNeighborsWhenTheViewportReanchors() passes. The step still exits 1 on SidebarInsertWhileDisplayAsleepTests. That's a base red, and it fails identically on the test-only commit; #20793 fixes it.
  • Release compile passes at 9e4d98593a34.
  • The god-file check (--base origin/feat-cmux-next), check-concurrency and crash_ratchet all pass.

Captures

Real cmux NEXT DEV builds of the merge-base and this head on a capture mini, recorded with debug.window_record at 60 fps. The setup:

  • a 900x600 window with 40 workspaces, the sidebar scrolled about 4.5 rows;
  • close the third visible row, then, 50-200 ms later, a row above the viewport, so the list re-anchors while the first close animates.

What it shows:

  • Base: about 3 frames after the second close, the closing row jumps one row onto its neighbor ("sb 33" over "sb 32"). At app speed this lasts about one frame. With a slowed spring (motion.spring.move response 1.2 s, labelled), a half-faded ghost then sits in the reopened gap for about 8 frames.
  • Head: the closing row fades in its own slot, with no overlap and no ghost.

The "playback 4x slower" clip is slowed in post-processing only. Base and head events differ by up to 2 frames, because socket timing varies.

Open observation, the same on base and head: at the re-anchor frame the rows below the gap dip 10-15 px and glide back. This fix doesn't change that, and it will be a follow-up bead if it's unintended.

Changelog

Fixed: a sidebar row closing while the list scrolls no longer jumps against its neighbor

๐Ÿค– Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Fixes a sidebar row that jumps by one row height when a close above the viewport re-anchors it mid-close.

A closing row wasn't part of rowViews, so a viewport re-anchor left it at its old frame while its neighbors shifted with the scroll offset. Now each closing row records its end frame in SidebarRowView.closingEnd, and RowMotion.shift moves the closing rows and their ends with the rows, then re-targets the close on the same .move spring.

  • Adds SidebarCloseWhileScrolledTests covering two closes in quick succession in a scrolled sidebar.

Showcase

still fast light jump

still fast dark jump

still slow light ghost

still slow dark ghost

compare fast light playback4x slow

Full quality:

compare fast dark

Full quality:

Open the live gallery