cmux-next: TabDragSession, glass ghost, one command per drop outcome
Merged
LA
1mo ago+1688+1688+0123456789012345678901234567890123456789-309-309-012345678901234567890123456789
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
TabDropTargetProvidingin 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
WindowManagerpersists 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-v1group moves. TabDragLifecyclemakes sure every drag ends exactly once. It callsrestoreDetachedTab/restoreDetachedGroupunless the transaction settled.- Auto-scroll: strips and the sidebar already scroll themselves. The new
LayoutRootView.autoscrollTabDragscrolls 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/targeton daemons withouttab-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 usessplit {tab}when the daemon honors it. - Same-pane
move-tabsent 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.TabMoveIndexconverts the index.
Verification
swift build --build-testspasses.swift testpasses for the whole package. The newTabDragTestscover outcome resolution, cancel and restore invariants, transaction settle, ghost motion, index conversion, and tear-off geometry.check-no-godfiles.shpasses.- Fleet build
tabdrag-v1at d2e6984 was launched locally with a clean environment andCMUX_NEXT_NO_ACTIVATE=1. Through the control socket,tab move-rightby 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
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-v1moves, 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;
TabDragLifecycleguarantees every drag ends exactly once and restores the source tab unless the transaction settled. - The new
LayoutRootView.autoscrollTabDragscrolls niri columns near the edges; the display link runs only while the ghost animates or a screen auto-scrolls.
Fixes
- Same-pane
move-tabnow 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.