Skip to content

Refine tab-drag UX: workspace-only merging, one-icon split rows, fading titles - #230

Closed
thdxg wants to merge 13 commits into
feat/tab-drag-to-splitfrom
claude/pr-228-overlay-pills-a4a0cb
Closed

Refine tab-drag UX: workspace-only merging, one-icon split rows, fading titles#230
thdxg wants to merge 13 commits into
feat/tab-drag-to-splitfrom
claude/pr-228-overlay-pills-a4a0cb

Conversation

@thdxg

@thdxg thdxg commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Stacked on #228 — the base branch feat/tab-drag-to-split mirrors that PR's head (68d4990) so this diff shows only the refinements. Once #228 lands, retarget this PR to main and delete the mirror branch.

Refinements to the 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.mergeTab refuses — so the overlay promised a split that never happens. PaneDropContext now carries the rendered tab's id and the leaf delegates reject a matching MovableTab payload 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-line Text stand-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 via onGeometryChange). Applied to tab titles, split segments, and project names alike. A single rowTrailingInset rule 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 / lint clean; full unit suite passes (the removed feature's tests removed with it).
  • Live-verified in a hermetic app instance with synthesized drags and screenshots: a tab dropped on another tab's row no longer merges (reorder/move still work); dragging a tab's row into the workspace still merges at the previewed placement; self-tab drag shows no overlay; split rows show one icon with divided segments; long tab titles, split-segment titles, and project names all fade inside the selection highlight; segments re-balance when a tab grows from 2 to 3 panes.

🤖 Generated with Claude Code

thdxg added 2 commits August 7, 2026 17:32
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.
thdxg added 3 commits August 7, 2026 17:54
…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.
@thdxg thdxg changed the title Polish drag-to-split: refuse self-merge previews and flatten sidebar split segments Polish the sidebar's drag-to-split UX and split-row rendering Aug 7, 2026
@jcuhnoio

jcuhnoio commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

These are all great catches!

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.
@thdxg
thdxg force-pushed the claude/pr-228-overlay-pills-a4a0cb branch from 03ffb95 to 18b7411 Compare August 7, 2026 09:48
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

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.
@thdxg
thdxg force-pushed the claude/pr-228-overlay-pills-a4a0cb branch from f6faccd to f7fae3d Compare August 7, 2026 10:10
thdxg and others added 4 commits August 7, 2026 19:22
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.
@github-actions github-actions Bot added area:state AppState, models, persistence area:tests Test changes labels Aug 7, 2026
@thdxg thdxg changed the title Polish the sidebar's drag-to-split UX and split-row rendering Refine tab-drag UX: workspace-only merging, one-icon split rows, fading titles Aug 7, 2026
jcuhnoio and others added 2 commits August 8, 2026 01:07
…e, and add Separate Panes (#228)

Co-authored-by: Juno Choi <20599165+jcuhnoio@users.noreply.github.com>
Co-authored-by: Ethan Lee <ethantlee21@gmail.com>
…-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
@github-actions github-actions Bot added the area:docs Documentation label Aug 7, 2026
@thdxg
thdxg deleted the branch feat/tab-drag-to-split August 7, 2026 16:10
@thdxg thdxg closed this Aug 7, 2026
@thdxg

thdxg commented Aug 7, 2026

Copy link
Copy Markdown
Owner Author

Superseded by the PR above after #228's merge wedged this one's base in GitHub's stack machinery — same branch, retargeted to main.

@thdxg
thdxg deleted the claude/pr-228-overlay-pills-a4a0cb branch August 7, 2026 16:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:docs Documentation area:state AppState, models, persistence area:tests Test changes area:ui Views, Settings UI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants