cmux-next: TabDragSession, glass ghost, one command per drop outcome

Merged
LA
1mo ago
+1688-309

Tab drag session for cmux-next (plans/cmux-next/REWRITE.md "Tab drag"): one drag, every destination.

What changes

  • TabDragSession (CmuxNextApp/Drag) replaces the first cut. One borderless non-activating panel carries the ghost across every window and outside them. Over a strip it folds into an inline tab, and the strip opens a gap. Everywhere else it unfolds into a Liquid Glass card with a live terminal or browser snapshot. The ghost follows the pointer with no lag. Every discontinuity springs: card to tab, slot change, spring back, drop.
  • The session asks every TabDropTargetProviding in every window by screen point, in this order: sidebar, strips, layout. The sidebar and the layout get adapters (TabDropTargets.swift). A pane center maps to "join that pane's strip".
  • Each outcome commits ONE daemon command with a client transaction and an optimistic patch: move-tab, tab-to-new-split, tab-to-new-column, tab-to-new-workspace (placed at the sidebar slot or group), move-tab-to-workspace. Release outside every window tears off: a new workspace opens in a new window whose strip lands under the pointer, and WindowManager persists the window. A drag that carries the whole workspace moves its window instead. Escape, no target, or a rejected command springs the tab back.
  • Whole-group drags use the tab-groups-v1 group moves.
  • TabDragLifecycle makes sure every drag ends exactly once. It calls restoreDetachedTab/restoreDetachedGroup unless the transaction settled.
  • Auto-scroll: strips and the sidebar already scroll themselves. The new LayoutRootView.autoscrollTabDrag scrolls niri columns near the edges. This is a small additive API in CmuxNextLayout.
  • The display link runs only while the ghost animates or columns auto-scroll, and stops when the drag ends. Measured idle CPU is 0.0%.

Fixes

  • Fallback split on the left or top edge failed with swap-pane: unknown pane/target on daemons without tab-drag-v1. Reproduced against a live cmux-tui: dragging a pane's only tab onto its own left edge moves the tab into the new pane, the source pane closes, and the swap targets a pane that no longer exists. The resolver now refuses that no-op drop. The fallback swaps only when the target pane still exists, and uses split {tab} when the daemon honors it.
  • Same-pane move-tab sent the final index, but the daemon expects pre-move insertion coordinates. Moving a tab one slot right did nothing, and longer moves landed one slot short. TabMoveIndex converts the index.

Verification

  • swift build --build-tests passes. swift test passes for the whole package. The new TabDragTests cover outcome resolution, cancel and restore invariants, transaction settle, ghost motion, index conversion, and tear-off geometry. check-no-godfiles.sh passes.
  • Fleet build tabdrag-v1 at d2e6984 was launched locally with a clean environment and CMUX_NEXT_NO_ACTIVATE=1. Through the control socket, tab move-right by one slot now moves the tab; before this fix it was a no-op. With 5 tabs and 2 panes, the app idles at 0.0% CPU and 117 MB RSS.
  • UNVERIFIED: the drag itself (ghost, drop targets, tear-off, spring-load, auto-scroll). The app ignored mouse events posted to its pid while inactive, so a background click did not reach the strip. cmux-cua onboarding is not complete, and a foreground drag would take over the user's display. No recording or mid-drag screenshot was taken.

Known limits

  • Dropping in front of the first column is refused: no daemon command can express it.
  • A strip drop that changes group membership sends a second, group command.
  • With the current daemon (no tab-drag-v1), a new split or column first spawns a PTY, moves the tab in, and then closes that PTY.

Changelog

none

🤖 Generated with Claude Code


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


Summary by cubic

Replaces the first tab-drag cut with a full TabDragSession: one ghost panel follows the pointer across every window and outside them, and every drop outcome commits exactly one daemon command.

Drag session

  • The ghost folds into an inline tab over strips (opening a gap) and unfolds into a Liquid Glass preview card with a live thumbnail elsewhere.
  • Every surface in every window is hit-tested by screen point, in priority order: sidebar, strips, layout; whole-group drags use tab-groups-v1 moves, and releases outside every window tear off into a new window under the pointer, or move the source window when the drag carries the whole workspace.
  • Each outcome sends one command with a client transaction and optimistic patch; TabDragLifecycle guarantees every drag ends exactly once and restores the source tab unless the transaction settled.
  • The new LayoutRootView.autoscrollTabDrag scrolls niri columns near the edges; the display link runs only while the ghost animates or a screen auto-scrolls.

Fixes

  • Same-pane move-tab now converts final indices to the daemon's pre-move insertion coordinates (a one-slot move right was a no-op, longer moves landed a slot short).
  • Fallback split on left/top edges no longer swaps with a pane that moving the pane's only tab closed (unknown pane/target); the resolver also refuses splitting the source pane with its last tab.