Skip to content

feat(titlebar): group a host's sessions into one tab, with a pinnable session panel (#318) - #323

Merged
kipavy merged 20 commits into
devfrom
feat/host-tab-stacks
Sep 20, 2026
Merged

kipavy merged 20 commits into
devfrom
feat/host-tab-stacks

Conversation

@kipavy

@kipavy kipavy commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Closes #318.

Sessions on the same host collapse into one titlebar pill; its session list can be pinned as a panel beside the terminal — the shape the reporter confirmed on the issue.

What it does

  • Stack pill. Sessions sharing a host render as one tab showing the host, the session on screen and a count. A host with one session looks exactly as before. Untitled sessions are numbered in open order (prod-web-01, prod-web-01 (2)), like Termius.
  • Session list. Hover the pill (250 ms) or click its chevron. Switch, close, rename, open in split, or start a new session on that host. Built on PickerSurface, so it flips near the screen edge and becomes a bottom sheet on Android.
  • Pinned panel. Pin the list and it docks left of the terminal, following the focused session's host, listing every session of that host including those inside split tabs. Its width changes only on pin/unpin, never on a tab switch.
  • Setting. Appearance › "Group tabs by host", on by default, synced like the other toggles. Off restores today's flat tabs exactly.

How it is built

  • A stack is a view over layoutStore.titlebarOrder; no stack key is ever stored, so nothing migrates and the workspace snapshot is unchanged. stackGroupKey is the single grouping rule (per connection, except container-exec and ephemeral-serial sessions, which key on themselves).
  • Dragging a pill moves its members as one block; dropping a list row on the terminal splits, and a pane dragged back to the bar rejoins its host.
  • The layout store now refuses to place one session in two split tabs, and hydrate repairs snapshots saved by older builds that already contain that state.
  • The titlebar tab pill, tab activation, the session rows and the side-panel column each exist once and are shared, rather than copied per caller.

Checks

  • Unit, store and component tests across the touched areas: green (~2500 in the affected directories), tsc clean.
  • Live pass in a headless container driven with real mouse input: hover timing, pill→list crossing, drag to split, block drag, pin/unpin, rename with spaces, and the setting off/on.
  • Windows ARM64 portable build tested by hand on real hardware.

Known minor

Once one of a host's sessions sits in a split, the pill numbers only the unsplit members while the panel numbers every row, so the same session can read "(2)" on the pill and "(3)" in the panel.

- Escape closes the hover list
- hover intent holds open while a row's context menu or rename is active
- exempt the row context menu from PickerSurface's outside-mousedown dismiss
- chevron and compact row actions are keyboard-operable
- fix the hover surface's dead zone against PickerSurface's own padding
- extract lastActiveByHost/sessionTabHandlers wiring into hooks
- prune lastActiveByHost, dedupe worstStatus/stackMemberLabels calls
- Open in split closes the hover list
- add hover-intent test coverage
- drop narrative comments
Releasing hold (a row's context menu closing, or a rename ending) now
re-arms the close timer itself, since the mouseleave that would have
started it already fired and was skipped while held.
…ter left it

Approach (b) closed the list 300ms after any menu action even with the
pointer resting on it. useHoverIntent now tracks the last pointer
position and its own anchor/surface refs, and only arms the close
timer on hold-release when that position is outside both.
- DetachedPanePreview now uses sessionTabIcon/tabSurfaceStyle instead of
  its own copy of the distro-icon/local-terminal/StatusDot and active
  background/color/border logic
- HostStackMenu resolves its shown member via the shared shownMember()
  instead of its own find-or-first
- StackTab's worst-status corner badge uses the shared StatusDot
  (corner + halo) instead of a second hand-built dot
- HostSessionsPanel's two header icon buttons share one PanelIconButton
  instead of repeating the same class string twice
- HostSessionRows's compact/panel padding classes share one rowPadding
  value instead of repeating the ternary twice
- sessionStatusLine's switch gets a default: ... satisfies never guard
  so a future status is a compile error, not a silent undefined
- usePaneDragController drops a redundant useLayoutStore.getState()
  call, reusing the layout snapshot already captured in onUp
openInSplit already had unit tests; pinHostList did not.
The pinned panel's Open in split could pick an in-split session as its
base, and a drag from an in-split row could split it again, so one
session ended up in two split tabs and the snapshot persisted it.

- createSplitTab and splitPane refuse a session already in any split tab
- the drag controller focuses a session that already lives in a split
  instead of splitting it again
- panel rows start no drag when in a split, and Open in split only
  offers unsplit members as its base
Docker/LXC exec sessions copy their parent's connectionId, so they joined
the parent's stack and headed its pill with "exec: nginx". Every
ephemeral serial session shares "serial-ephemeral", so different ports
stacked together.

Every grouping site now goes through one stackGroupKey(session): the
session itself for exec and ephemeral serial, the connection otherwise.
The stack's host name comes from a non-exec member, and member numbers
follow open order rather than bar order, so reordering no longer
renumbers.
- a row menu's hold is released when the list unmounts or closes under
  it, so the list closes on mouse-leave again
- the list closes when its host gets pinned while it is open
- a drag that started on a list row draws no cue on the bar
- in-split panel rows set no drop target, and leaving the panel rows
  clears it, so a drop elsewhere no longer reorders
- the list's close callback is stable across titlebar renders
… new-session action

- a panel row's status is one truncated line with the full text in its
  title, and the in-split marker shares that run
- a stack pill has no close button (Close all N is in its menu)
- the stack chevron carries aria-label, aria-haspopup and aria-expanded
- New session on <host> is one helper used by the pill menu, the list
  footer and the panel header
- the pane menu's Split right/down picks a session that is in no split
  tab, instead of one the store now refuses (which left focus on a
  hidden session)
- openSessions skips sessions already in a split and, with fewer than
  two left, focuses the existing pane instead of building a tab
- hydrate drops a session's pane from any later tab of an older
  corrupted snapshot, collapsing tabs left with one pane
- middle-click on a stack pill no longer closes its hidden shown member
@kipavy
kipavy merged commit bc7c3ff into dev Sep 20, 2026
4 checks passed
@kipavy
kipavy deleted the feat/host-tab-stacks branch September 20, 2026 08:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant