fix(desktop): the tab strip fades only edges that hide a tab, and a dragged tab reaches either end

Merged
DA
8d ago
+358-87

Summary

Two fixes to the side panel's tab strip, both from Dean.

1. "The gradient doesn't go away when I expand the right side panel"

Why it stuck. #1947 faded the clipped edges with an animation on scroll(self inline). A scroll timeline counts any overflow, a sub-pixel included. Tab labels have fractional widths, so as the panel widens, the strip passes through widths where it is a fraction of a pixel narrower than its tabs. Measured in Electron 43's own Chromium (150) at a 951px panel: the strip is 816.70px over 817.34px of tabs. scrollWidth === clientWidth === 817, so every tab shows, but the timeline has a 0.64px range and sits at 0%, so the end fade is the full 26px. A width that changes in whole pixels can stop inside that band, and the fade then stays on a strip that hides nothing. I could not get a fade to persist at whole-pixel widths beyond that band, after resizing in steps or in a smooth sweep, in Chrome 154 or in Electron 43. If the Mac shows it more broadly, the cause is the same thing: a fade that hangs on the timeline's state rather than on what the strip hides. Nothing in JS or a test could observe that state.

Fix. The strip now measures itself. markOverflow sets data-overflow-start and data-overflow-end from scrollLeft, scrollWidth, and clientWidth, with 1px of slack, so sub-pixel overflow fades nothing. It runs:

  • on the strip's scroll
  • from the ResizeObserver the strip already had (panel drag, window resize, full screen, sidebar collapse)
  • after every render (a tab opened, closed, or renamed while the strip is already at its narrowest)

The stylesheet maps the two attributes onto the same registered --tab-strip-fade-* lengths with a --duration-hover transition (0s under reduced motion and in a capture run). The keyframes and the animation-timeline are gone.

2. "I have trouble dragging a tab to the first position sometimes"

Reproduced in Electron with real (CDP) pointer drags: 21 drags covering both ends, slow and fast, different grab points, fixed + agent + subagent tabs, scrolled and not, and a partly clipped tab. On main, 16 of the 21 miss. With this PR all 21 land. Three causes:

  1. Middle against middle. A tab passed a neighbour only when its middle crossed the neighbour's middle, but it was held between the first tab's left edge and the last tab's right edge. So a tab wider than the first one (Transcript over Board, Board with its × over Code) could never reach index 0, and the same held for the last place. And with the tab grabbed near its start, letting go just left of Board's middle still landed it second. Now a tab passes a neighbour once its leading edge crosses the neighbour's middle: its left edge going left, its right edge going right. Letting go anywhere left of the first tab's middle lands at index 0, and anywhere right of the last tab's middle lands last, wherever the tab was grabbed.
  2. Scrolled strip. Since #1947 the tab was held inside the strip's visible area, measured in viewport coordinates, and nothing scrolled during a drag, so a hidden first tab was out of reach. Now slots are measured along the scrolled row, and travel includes the strip's scroll since the press, so the tab stays under the pointer, and the drag redraws on any scroll of the strip, a wheel's included. With the pointer within 32px of an edge, or past it, while movable tabs are hidden that way, the strip scrolls toward them each frame (requestAnimationFrame, up to 12px/frame, faster nearer the edge, as VS Code does). The loop stops at the end and is cancelled on release.
  3. Partly clipped tab. A drag starting on a tab half under an edge no longer snaps it into view at the threshold: the travel bounds always include the tab's own place.

What moves (#1945's model, unchanged in intent): only the fixed tabs move, and only among themselves. Agent and subagent tabs stay in place after them. Before, the drag didn't know this. An agent tab could be lifted and slid, then changed nothing on drop. A fixed tab dragged out among the agents slid past them, then dropped nowhere. ⇧⌘→ on the last fixed tab announced a move that didn't happen. Now TabStrip takes movable (the side panel passes panel.tabs.length):

  • a press on a tab past that count is just a click
  • the drag's room, its slide-aside, and the drop cover only the movable tabs, so a fixed tab dragged toward the agents lands in the last fixed place
  • ⇧⌘←/→ stops at the last fixed tab and says nothing

The side panel's onMove loses its to < panel.tabs.length guard, which is now unreachable.

Tests

In desktop-plans.test.ts, through the real page. Each new test fails on main (checked by reverting the source with the tests kept) and passes here:

  • The strip fades exactly the edges it hides tabs beyond: neither edge when every tab fits; the end when narrowed; the start, then both, as it scrolls; neither once widened again; neither over a 1px rounding overflow; and the end fade follows a tab closed and reopened with no resize.
  • A tab let go short of the first tab's middle lands first, and past the last tab's middle lands last, grabbed near its far end, where its own middle doesn't cross.
  • A tab held at the edge of a scrolled strip scrolls it back to the hidden tabs and lands first. requestAnimationFrame is replaced with held frames the test runs (vi.stubGlobal, undone in afterEach).
  • A tab held still while the strip scrolls under it lands where the row has brought it: the drag redraws on the strip's scroll, so a wheel or a reveal under a pointer held still moves the landing (from Devin's review).
  • Only the fixed tabs move: a fixed tab dragged past an agent tab lands last of the fixed, and the agent tab doesn't slide aside; an agent tab can't be dragged; ⇧⌘→ neither steps nor announces past the last fixed tab.

./scripts/check.sh exits 0.

Verification

CI could not verify this UI change. CI builds nothing for the Mac, and ./scripts/verify.sh was not run (this is a Linux cloud VM; LUKE-159 is the manual Mac pass).

Instead: the real TabStrip/Tab components inside the side panel's real row markup (.side-panel-bar › .side-panel-tabs › strip, "+", full-screen button), with the built stylesheet. The fade frames were captured in Electron 43.3.0 (Chromium 150), the app's own runtime, at 2x. The drag frames were captured in headless Chrome 154, because a CDP screenshot taken during a drag drops Electron's pointer capture. The drag runs themselves (21 drags, all landing) were done in Electron. "Before" is main's JS and CSS on the same page.

The sub-pixel band: every tab shown, and main still fades the end:

fade zoom

The fades still show where they should: narrowed, scrolled, and gone once widened:

fade

Dragging to the first place, unscrolled and scrolled:

drag

🤖 Generated with Claude Code