feat(desktop): drag the side panel's tabs into a new order, or move one with ⇧⌘←/⇧⌘→
Dean asked for drag-and-drop reordering of the side panel's tabs (Board / Code / Transcript / Work), the way Cursor, VS Code and Chrome do it.
Behaviour
- Click or drag. A press on a tab stays a click (it chooses the tab) until the pointer moves 4px along the strip. Past that, the tab lifts: opaque chosen-tab fill, subtle shadow, drawn above the others. It follows the pointer horizontally, held within the strip, and never past what the strip shows when it has scrolled.
- Neighbours make room. The tabs it passes slide aside on
transformonly, on--duration-fast/--spring-fast, so reduced motion and capture runs hold them still like everything else. No width or left animation. - Drop commits the new order. It is persisted through
use-side-panel.ts's stored state, so the next launch keeps it. The dropped tab glides into its slot (FLIP on transform). - The dragged tab becomes the chosen tab. This matches Chrome and VS Code, where pressing a tab activates it. It is chosen on drop, not on press, so in compact mode the tab doesn't widen under the pointer mid-drag.
- Escape or a pointer cancel slides every tab home and changes neither the order nor the choice. Escape belongs to the drag while one is under way, so it doesn't also step the window back a layer (e.g. exit full screen).
- What never drags:
- The ×: pressing it starts no drag.
- The "+": it sits outside the tablist, so it is neither draggable nor a drop target.
- The plan column's single tab: it passes no
onMove, so it is not draggable.
- Mid-drag changes are safe. If a tab opens or closes under a drag, the drag is put back rather than dropped at a slot that moved.
- Works with compact (icon-only) tabs and the unread dot (frames below).
- No library, and no HTML5 drag and drop. It uses pointer events, with the pointer captured on the strip only once the press has become a drag. A short press therefore still clicks normally, and Electron's native ghost image never appears. The tabs keep
-webkit-app-region: no-dragand the row's DOM order (#1840) is unchanged, so a drag neither moves the window nor reaches the panel's resize edge. - Keyboard. With a panel tab focused, ⇧⌘← / ⇧⌘→ moves it one place. These are the keys VS Code's Move Editor Left/Right end in. They fit the shortcut table's rule that every chord holds ⌘, and they don't clash with anything in #1844's table (⌥⌘↑/↓, ⌥⌘1–4, ⇧⌘↩ are all different).
- The chord is offered only while a tab in the strip has focus, so it never takes ⇧⌘← from a text field.
- Focus stays on the moved tab, and the choice doesn't change.
- The plain arrow keys still move focus, but now ignore any modifier.
- Both chords are listed under Window on the Keyboard shortcuts page (they come from the shortcut table).
- Screen readers. Every move, by drag or by key, is announced through a visually hidden
role="status"line beside the tablist, e.g. "Code moved to position 4 of 4". The line sits before the strip, so the "+" is still where a closed last tab hands focus.
Code
desktop/use-tab-drag.ts(new): the pointer drag, drawn imperatively on each tab'stransform. It applies the new order withflushSyncbefore taking the transforms down, so the drop doesn't flash.desktop/tab-strip.tsx:TabStriptakes an optionalonMove(from, to). It wires up the drag, the ⇧⌘←/→ commands (throughuseAppCommand), and the status line.planning/use-side-panel.ts:SidePanelControl.onMove(tab, to), kept like any other change to the panel.shared/shortcuts.ts:MOVE_TAB_LEFT/MOVE_TAB_RIGHTand the ←/→ keys. They are not added to the menu bar.desktop.css:- A transform transition on
.tab. - The lifted
[data-dragging]look. - The strip gets 8px of block padding, cancelled by an equal negative margin, so the lifted tab's shadow isn't clipped by the strip's horizontal scrolling. The row's layout is unchanged.
- A transform transition on
Tests
All in desktop-plans.test.ts, through the real DesktopPlans and useSidePanel:
- A press that wavers under 4px is a click: it chooses the tab and moves none.
- Dragging past a neighbour reorders, chooses the dragged tab, announces the move, and survives a relaunch.
- Escape and pointercancel put a lifted drag back. The test asserts the tab was lifted mid-drag first.
- A tab closed under a drag puts the drag back.
- The × starts no drag.
- ⇧⌘→ / ⇧⌘← move the focused tab and keep focus, with no change to the choice. Each edge is a no-op, and the chord is unclaimed with no tab focused.
The drag, Escape, guard and keyboard tests each fail without the change. The click and × tests are guards that the drag doesn't swallow either one.
./scripts/check.sh exits 0.
Frames (headless Chrome, real DesktopShell + side panel + built stylesheet, panel 460px then 280px)
At rest, Code chosen, Transcript carrying the unread dot:

Board lifted and dragged right, past Code, which has slid left:

Further, past Transcript and Work:

Dropped at the end and chosen:

Work dragged left (Transcript and Code slid aside)…

…then Escape: everything back. The ring is the browser's focus-visible after a key press.

Compact panel (280px): Transcript (icon only, unread dot) lifted over Board…

…dropped first and chosen:

Keyboard shortcuts page, Window group. The ⌘/⇧/⌥ keycaps render as boxes only because this Linux VM has no font with those glyphs:

Mac verification
CI could not verify the Mac UI: it builds nothing for the Mac. ./scripts/verify.sh was not run, because this work was done on a Linux cloud VM. The frames above come from headless Chrome on Linux. LUKE-159 is the manual Mac pass.
🤖 Generated with Claude Code