Refine tab-drag UX: workspace-only merging, one-icon split rows, fading titles - #235
Conversation
…anes (#227) Dragging a sidebar tab onto another tab row merges the two into a split: on a single-pane destination the hovered half of the row picks which side the dragged tab lands on, and an already-split destination highlights whole and appends. Dropping a tab into the workspace reuses the pane drop zones, so the dragged tab's tree lands on the chosen side of the target pane. Panes and their live surfaces are reused as-is throughout — the merge is a pure tree reshape, mirroring movePane. The inverse is a Separate Panes context-menu item on split tabs: every pane after the first moves into its own fresh tab right after the source, shells intact. Split tabs also render one container per pane in the sidebar instead of concatenating titles with a pipe.
…ane split Where the cursor sits picks the globality of the split, not just its side (#227 discussion). Near a workspace edge on the root's own axis the tab becomes a new top-level column (thirds for all); on a divider it inserts between the siblings (thirds); just past the midline on the perpendicular axis it takes the whole edge (a full-width bottom pane); anywhere deeper it splits the hovered pane locally (1/4 1/4 1/2). TabDropPlacer resolves a normalized cursor point against the split tree and returns the target plus the exact region the tab would occupy, which the workspace-level drop overlay renders as the preview — so what you see is what you get. Local drops keep the halving pref; divider and edge drops always equalize, because their meaning is positional parity.
…bands The root-axis edge and divider bands were gated behind the hovered pane's corner triangles, so near the top or bottom of a pane the vertical zones swallowed the cursor and a side-by-side drop had almost no hitbox. Those bands now claim their margins at full cross-axis extent, and the perpendicular whole-edge band goes back to its original midline-to-75% size.
The three-dots pane drag used its own per-leaf drop targets that could only reorder relative to a single pane. It now lands on the same workspace-level WorkspaceDropTarget as sidebar tab drops, resolved by the same TabDropPlacer bands — so a dragged pane can take a whole edge, insert at a divider as an equal sibling, or split a pane locally, with the identical what-you-see preview. TerminalTab.movePane(to:) detaches the pane and re-merges it through mergeTree, and the legacy movePane(onto:zone:) is now a one-line alias for the .pane target. The per-leaf PaneDropDelegate, its zone highlight, and the onMovePane plumbing through SplitTreeView are gone.
…rgets immediately AppKit only delivers dragging-entered on a transition INTO a destination, so the single whole-workspace target never heard about a grab-handle drag that began inside it — the preview stayed dead until the cursor left the window and came back. Pane payloads are now captured by one small target per leaf (the dragged pane's own leaf carries none), each converting its local location into workspace space and resolving through the same TabDropPlacer into one shared resolution, so the placement bands and the workspace-level preview are unchanged and unduplicated. Tab drags keep the whole-area target, which works because they start outside it. Also pins the reorder contract with a test: a moved pane vanishes from its old spot and every pane survives exactly once.
SwiftUI routes a drag to the topmost drop target by geometry and does not fall through on a type mismatch, so the tab-only target layered over the workspace swallowed every pane-drag session before the leaf targets could see it — the drag went fully dead. The leaves (which tile the workspace exactly) now accept BOTH payloads and are the sole drop layer; the workspace overlay is a pure preview renderer with no target at all. Placement still resolves through the one TabDropPlacer path either way.
…s down Drag-and-drop reorders left panes blank: shells alive, views gone. Traced live (SurfaceAttach debug logging, kept in-tree), it was three distinct teardown behaviors around the pane-owned NSView: 1. SwiftUI builds a reshape's new hierarchy before tearing down the old one, and the old teardown yanks the shared view out of its NEW superview. The representable now returns a disposable container per identity and re-parents the pane's scroll view into it explicitly. 2. SwiftUI creates several transient containers per pane in one remount, and make/update call order says nothing about which survives — so the container that lands in the window claims the view unconditionally. 3. SwiftUI can also deallocate an adopted container outright, killing any weak re-attach pointer. The backstop is model-driven: an orphaned view with no live host bumps Pane.surfaceReattachTick, re-rendering the pane's subtree so SwiftUI re-attaches via a container it guarantees is alive. A view that lands in a window without a surface also creates it (the healing paths bypass the make/update path that normally would). Reproduced and verified headlessly via new DEBUG-only CLI verbs that drive the same code paths as the drags: pane move (movePane(to:)) and tab merge (mergeTab(at:)).
… splittable With four side-by-side panes the fixed workspace-relative bands (0.15 on each side) swallowed every pixel of every 0.25-wide column — the edge band claimed x < 0.15 and the divider band claimed x > 0.10 — so no drop could ever express a top/bottom placement. Each band now claims at most 30% of the hovered pane's extent on its axis, leaving the middle 40% of any pane for the zone-driven placements. At two panes the cap equals the workspace band, so wide layouts are numerically unchanged (every existing placement test passes untouched).
Dragging a tab's sidebar row over the workspace that is already rendering that tab showed the split drop preview, but releasing could only self-merge — which AppState.mergeTab refuses — so the overlay promised a split that never happens. The drop context now carries the rendered tab's id and the leaf delegates reject a matching MovableTab payload outright (no preview, drag image animates back). When the Transferable payload isn't on the drag pasteboard yet the check falls open and the drop lands on mergeTab's existing guard, the same degradation the row-level merge delegate documents.
The per-pane pills of a 2-3 pane tab row carried a filled rounded-rect container with its own padding, which read as heavier chrome than the surrounding native rows. Drop the fill and inset so each segment is just its icon and title, spaced apart to keep the segments legible.
…rows A hand-rolled HStack put the raw SidebarRowIcon next to the text, missing the List's sidebar Label styling that sizes row icons — so a split tab's segment icons rendered smaller than a single tab's. Each segment is now a Label (icon slot + title), inheriting the same metrics.
Hovering a tab drag over a single-pane row slid the row's content aside with a structural drop-slot preview (the hovered half picking the landing side), while a multi-pane row just highlighted — the same drop read as two different interactions depending on the destination's pane count. Every destination now shows the plain whole-row highlight and the merged tab always lands on the right, matching what multi-pane destinations already did. The delegate keeps its DropDelegate shape for the synchronous self-merge rejection.
With the tab icon set to None and the status indicator on, an idle tab's row rendered the "none" sentinel as an SF Symbol — an invisible Image that still reserves the Label's icon column, nudging the title right of every other icon-less row. The label now gets an icon only while the status glyph actually draws something (spinner, done dot, or agent logo); idle falls back to the plain icon-less title.
The tab-onto-tab merge target was a hand-rolled DropDelegate with a custom overlay highlight. Now that every destination takes the same treatment, the row uses the native dropDestination directly: SwiftUI owns the session, a row refuses its own tab by returning false (the drag animates back natively), and the delegate, its state machine, and the pasteboard fallback all go away. macOS draws nothing of its own for a row-level drop target, so the isTargeted callback restores #227's structural preview as the hover feedback: the row's content slides into the left half and an accent drop slot marks the right half — the side the merged tab actually lands on (.second) — now uniform for single- and multi-pane destinations alike. The original variant existed only for one-pane rows (the hovered half picked the landing side, which needed a raw delegate for the cursor location) and made the same drop feel like two different interactions.
A split segment's title slot is narrow, and an ellipsis burns several characters of it. FadingTitle renders two copies of the text: the layout element is a normal truncating Text drawn invisibly, so the title stays compressible and the segments keep dividing the row equally as panes come and go (a fixedSize text in the layout makes every segment demand its full title width — segments stop re-balancing and an overflowing title gets hard-clipped by the row edge outside its own mask). The visible copy draws at intrinsic width in a layout-neutral overlay, clipped to the slot by a mask whose 18pt gradient ramp engages only when the text actually overflows (measured via onGeometryChange) — a short title masked unconditionally would fade its own last letters. The segments also take a 10pt trailing inset: the row's content region extends past the selection highlight's trailing edge, and unlike a plain Label the segments fill the row, so without it the last title read as overflowing the highlight.
A split tab's segments each carried their own icon (the pane's agent logo, else the tab icon) while a renamed tab showed a single one — the same row read differently depending on how it was named. The segments are now a title variant inside the row's normal Label, divided by hairlines so adjacent titles don't read as one run-on name, and every tab row carries exactly one icon; the status-indicator/agent-icon logic now applies to split rows too instead of being bypassed. The overflow handling consolidates with it: FadingText moves to its own file as a reusable one-line Text stand-in and now also covers the single-row tab title and the project name — every sidebar title fades instead of ellipsizing. The highlight-containment inset becomes one named rule (rowTrailingInset) applied at each row's root, replacing the per-call-site paddings, which also brings the merge drop slot inside the highlight.
The slide-aside hover preview always gave the incoming tab half the row, even over a destination already split three ways. Both tabs hold one segment per pane, so the slot now takes the incoming panes' share of the combined row — a 1-pane tab over a 3-pane destination claims a quarter, a 2-pane tab over a 2-pane destination claims half — and the destination's own segments compress to exactly their post-drop share. The slot also joins the row with the segments' own spacing and hairline, so the preview reads as the row after the drop rather than a box appended to it. The row width is measured outside the hover branch so the slot has its size the moment a drag enters, not a frame later.
…only Dropping a tab onto another tab's sidebar row merged them, with a hover preview that tried to mirror the destination's layout — but the row is a poor canvas for previewing a split, and the interaction kept fighting the sidebar's other drop roles (reorder between rows, append on headers). The workspace drop already does this job with an honest placement grammar — whole-edge, divider, or local pane split, previewed at real size — so the sidebar keeps only reordering and cross-project moves, and merging happens by dragging a tab's row into the workspace. Removes the row drop target and its preview machinery wholesale, plus the now-unreachable AppState.mergeTab(intoTab:side:) and TerminalTab.adoptTree(side:) and their tests. The workspace merge path (mergeTab(at:), mergeTree) is untouched.
…-pills-a4a0cb # Conflicts: # CLI/MactermCommand.swift # Macterm/App/AppState.swift # Macterm/Control/ControlProtocol.swift # Macterm/Model/Workspace.swift # Macterm/Views/MainWindow.swift # Macterm/Views/PaneDragDrop.swift # Macterm/Views/Sidebar.swift # MactermTests/App/AppStateTests.swift # MactermTests/Model/TerminalTabTests.swift
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
macterm | 71b07f8 | Commit Preview URL Branch Preview URL |
Aug 07 2026, 04:09 PM |
Window-state benchmark
Reported value is the median of 3×10s windows per state (splitting the window and taking the median keeps one co-scheduled spike from skewing a state); CPU % is the process CPU-time delta over a window. Runs land on different shared runners, so treat small deltas as noise — 🔺/🔻 marks changes ≥25% that also clear the metric's absolute noise floor (CPU % ≥0.5, Memory (RSS MB) ≥25, CPU ms/s ≥5, Wakeups/s ≥50); CPU deltas off a noise-dominated baseline aren't flagged (CPU % baseline ≥1.5, CPU ms/s baseline ≥15). The |
Replaces #230 — after #228 merged, GitHub's stacked-PR machinery permanently blocked retargeting that PR to
main("part of a stack", even once closed), so this is the same branch reopened as a fresh PR.mainhas been merged in; the diff is this work alone.Refinements to #228's tab-drag/split UX, verified in the running app with synthesized drags and screenshots against a hermetic instance.
Tab merging happens on the workspace surface only
Dropping a tab onto another tab's sidebar row merged them, with a hover preview that tried to mirror the destination's layout — but a sidebar row is a poor canvas for previewing a split, and the interaction kept fighting the sidebar's other drop roles (reorder between rows, append on headers). The workspace drop already does this job with an honest placement grammar (whole-edge / divider / local pane split, previewed at real size), so within-sidebar merging is removed: the sidebar keeps reordering and cross-project moves, and merging happens by dragging a tab's row into the workspace. The row drop target, its preview machinery, the now-unreachable
AppState.mergeTab(intoTab:side:)/TerminalTab.adoptTree(side:), and their tests go with it; the workspace merge path is untouched.No split overlay for a tab dragged over its own workspace
Dragging a tab's sidebar row over the workspace that is already rendering that tab showed the split drop preview, but releasing could only self-merge — which
AppState.mergeTabrefuses — so the overlay promised a split that never happens.PaneDropContextnow carries the rendered tab's id and the leaf delegates reject a matchingMovableTabpayload outright: no preview, drag image animates back.Split rows: one tab icon, chromeless divided title segments
A split tab's row used to give each pane segment its own icon (agent logo or tab icon) while a renamed tab showed a single one — the same row read differently depending on how it was named. The segments are now a title variant inside the row's normal
Label: every tab row carries exactly one icon (so the status-indicator/agent-icon logic applies to split rows too), and the per-pane titles share the row in equal widths, divided by hairlines, with no pill chrome.Every sidebar title fades on overflow instead of ellipsizing
FadingText(its own file, a drop-in one-lineTextstand-in) fades an overflowing title at its trailing edge — an ellipsis burns several characters of an already-narrow slot. The layout element is a normal truncating text rendered invisibly so containers stay compressible (equal-width segments keep re-balancing as panes come and go); the visible copy draws in a layout-neutral overlay masked to the slot, and the ramp engages only when the text genuinely overflows (measured viaonGeometryChange). Applied to tab titles, split segments, and project names alike. A singlerowTrailingInsetrule at each row's root keeps row content inside the selection highlight, whose region ends before the row content's edge.No idle-status indent on icon-less titles
With the tab icon set to None and the status indicator on, an idle tab's title sat nudged right: the "none" sentinel rendered as an invalid SF Symbol — invisible, but still reserving the Label's icon column. The label now gets an icon only while the status glyph actually draws something.
Testing
mise run format/lintclean; full unit suite passes (the removed feature's tests removed with it).🤖 Generated with Claude Code