Skip to content

feat(THI-246 PR 2/3): Split rail features — tree, divider, collapse, new-tab, drag-reorder#114

Merged
tdody merged 7 commits into
mainfrom
thibaultdody/thi-246-split-rail-features
Jun 8, 2026
Merged

feat(THI-246 PR 2/3): Split rail features — tree, divider, collapse, new-tab, drag-reorder#114
tdody merged 7 commits into
mainfrom
thibaultdody/thi-246-split-rail-features

Conversation

@tdody

@tdody tdody commented Jun 7, 2026

Copy link
Copy Markdown
Owner

Summary

Stacked on PR #113 (THI-246 PR 1/3 — foundation). PR 1 shipped the layout
switcher + click-to-select persistence + skeleton; this PR fills in the
rail's interactive behaviors. Detail-pane work (inline xterm, sidebar)
stays for PR 3.

Five features, each isolated in its own commit so review can slice if
needed:

Commit Feature
74ba0d4 Tree honors groupingMode (sessions or repos)
847a458 Divider resize (pointer + keyboard, clamp 200–460)
0a00f00 Collapse to 44px dot strip
189f199 "+ New tab" row per group
d71a6ad Drag-to-reorder groups; persist per mode
743ea8c Typing fix: onNewWindow takes a session ID (folds into 189f199 conceptually)

What ships

Tree shape follows groupingMode

  • Sessions mode: existing Session → Window tree.
  • Repos mode: Repo → Window via groupByRepo() (reuses THI-243's helper).
    Window rows get a small session: chip so the user still sees which
    tmux session a pane belongs to without the extra hierarchy level. A
    session that spans repos shows window rows under each repo it touches,
    per the per-window bucketing rule THI-243 settled on.

Divider resize

  • ARIA role="separator" with aria-valuemin/max/now, tabIndex={0},
    focus-visible accent edge.
  • Primary-button pointer drag updates a transient dragWidth state on
    each pointer-move (live reflow) and persists the final width once on
    pointer-up.
  • Pointer capture so the cursor can leave the gutter mid-drag.
  • Keyboard nudge: arrow = ±10px, Shift+arrow = ±50px, Home/End jump to
    min/max.
  • Clamps to [200, 460] on every code path.

Collapse to dot strip

  • New persisted setting splitRailCollapsed: boolean.
  • +/- toggle in the rail header.
  • Collapsed body: one centered status-toned dot per visible window,
    ordered the same way the expanded tree orders them (groupingMode is
    honored — repos mode flattens groupByRepo(); sessions mode flattens
    per-session sortPendingFirst).
  • Click a dot → expand + select that pane in one settings write.
  • Divider pointer/keyboard handlers no-op while collapsed.

"+ New tab" rows

  • One row per session group (sessions mode) targeting that session.
  • One row per real repo bucket (repos mode) targeting the FIRST window's
    session in that bucket. The "Other" bucket skips the row.
  • Opens the existing NewWindowOverlay — same flow the other views use.
  • New optional onNewWindow(sessionId) prop on SplitView; rows hide
    when omitted.

Drag-to-reorder groups

  • Two new persisted settings: splitRailSessionOrder: string[] and
    splitRailRepoOrder: string[]. Each is the user's preferred display
    order; live-new entries append, missing entries are filtered at render
    time (periscope-style "prefs are hints, never membership").
  • Native HTML5 drag on group head rows. Identity travels on React state,
    not dataTransfer — no DOM-sibling walk to find the drag source.
  • Drop indicator = 2px accent line via ::before / ::after.
  • "Other" bucket is not draggable and stays pinned last regardless of
    persisted order.
  • Drag is no-op while the rail is collapsed.

Test plan (automated)

  • Full frontend: 596/596 tests pass (npm test). 21 new tests in
    SplitView.test.tsx cover the five features.
  • npm run typecheck: clean.
  • npm run build: clean (verified via the pre-push hook).

Manual smoke (needs a live session — only the user can run this)

Open the dashboard, switch to Split layout. The rail should already
show your session groups from PR 1.

Tree shape toggle

  • In the Subhead, flip the grouping-mode toggle from Sessions to
    Repos. The rail re-groups: instead of one group per tmux
    session, you see one group per git repo (each window goes into the
    bucket of its own cwd's git toplevel).
  • Window rows in repos mode show a small session: chip after the
    window name. In sessions mode the chip is hidden (would be
    redundant with the group header).
  • If you have a session that spans multiple projects (catch-all
    daily-driver session), it shows windows under each repo it touches
    — not collapsed under just one.
  • Windows whose cwd isn't a git repo land in an "Other" group
    pinned to the bottom of the rail.

Divider resize

  • Hover the divider (the gap between rail and detail). Cursor
    changes to col-resize. The hairline brightens to the accent
    color.
  • Pointer-drag right and left. The rail width changes live; the
    detail pane reflows.
  • Drag past 460px — width clamps. Drag past 200px — clamps.
  • Release. Reload the page. Rail width persisted.
  • Click the divider to focus it. Press and . Rail width
    nudges 10px per press. Hold Shift for 50px nudges. Home jumps
    to 200px; End jumps to 460px.

Collapse

  • Click the button in the rail header (top-right). Rail
    shrinks to a 44px dot column; the "Projects" label disappears.
  • Each visible window is one centered dot, color-toned by status
    (amber = waiting, cyan = running, gray = idle, green = done,
    red = error).
  • Selected pane's dot has an accent ring around it.
  • Click any dot. Rail expands + that pane becomes selected.
  • Re-collapse. Reload. Rail stays collapsed (setting persisted).
  • While collapsed, the divider is inert (no cursor change, no
    pointer drag, no keyboard nudge).

"+ New tab"

  • Each session group has a + New tab row at the bottom that reads
    as a dim action; hover turns it accent.
  • Click + New tab under a session. The existing New window
    overlay opens, pre-targeted to that session. Submit creates the
    pane.
  • Flip to repos mode. Each real repo group has a + New tab row;
    the Other group does not.
  • Click + New tab under a repo group. The overlay opens
    pre-targeted to the first window's session in that group. (Note:
    cwd-aware spawning so the new pane opens in the repo's path is a
    THI-244 follow-up — currently the new pane uses whatever the
    session's default cwd is.)

Drag-to-reorder

  • Hover a group head row. Cursor changes to grab.
  • Start dragging it. The head row dims to ~0.4 opacity (it's "in
    flight"). Cursor changes to grabbing.
  • Drag over another group head. A 2px accent line appears above or
    below the target depending on where the pointer sits relative to
    the row's vertical midpoint.
  • Drop. The dragged group lands at that position; persisted order
    updates.
  • Reload. New order survives.
  • Flip to repos mode. Repeat the drag. The Other group is not
    draggable and stays pinned last regardless of where you try to
    drop other groups around it.
  • Sessions mode order and repos mode order are independent settings;
    reordering in one mode doesn't affect the other.
  • Collapse the rail. Try to drag. Nothing happens (head rows aren't
    rendered).

Cross-feature

  • Flip between Kanban/List/Grid and Split. Rail width, collapse
    state, selected pane, and group orders all persist across layout
    swaps and reloads.
  • Themes: switch to Light, Contrast, Phosphor. All .sb-* surfaces
    should adapt (no hard-edged white-on-white, no invisible drop
    indicators).

Out of scope (don't test these)

  • Per-window drag-reorder within a group (deferred — only group-level
    reorder ships here).
  • Repos-mode + New tab cwd inheritance (THI-244 follow-up at the
    NewWindowOverlay layer).
  • Inline xterm in the detail pane (PR 3).
  • Detail sidebar with Linked / Notes / Activity sections (PR 3).

Sequencing

This PR is stacked on PR #113 (its base is thibaultdody/thi-246-split-foundation,
not main). Merge order: PR #113 → this PR → PR 3.

If you'd rather rebase onto main after #113 lands, this branch has no
conflicts with main today (the THI-244 merge already happened in PR 1's
branch).

🤖 Generated with Claude Code

tdody and others added 7 commits June 7, 2026 19:59
The PR 1 rail rendered a flat Session → Pane tree. Add the repos-mode
branch:

- Reads `groupingMode` from settings (the global toggle wired up in
  THI-243). Sessions mode keeps the existing Session → Pane rendering.
- Repos mode uses `groupByRepo()` over the sortPendingFirst'd window
  list and renders Repo → Window. Per the THI-246 spec rewrite, this is
  a two-level tree (worktree level collapses since `repo_key` is
  already worktree-grained), and a session can appear under multiple
  repos when it spans them.
- New `session:` chip on each window row in repos mode so the user can
  still see the tmux session a pane belongs to without the extra
  hierarchy level. The chip is hidden in sessions mode where it would
  duplicate the group header.
- Three new tests covering the repos-mode rendering, the session-chip
  conditional, and the repos-mode empty-state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .sb-divider was a 7px placeholder in PR 1. Wire it up:

- ARIA-conformant `role="separator"` with aria-valuemin/max/now, tabIndex,
  and a focus-visible accent edge so it's keyboard-reachable.
- Primary-button pointer drag updates a transient `dragWidth` state on
  each pointer-move (grid template reflows live) and persists the final
  width on pointer-up. Settings store gets written ONCE per drag instead
  of per frame.
- Pointer capture so the cursor can leave the gutter without the drag
  dropping.
- Keyboard nudge: arrow keys = ±10px, Shift+arrow = ±50px, Home = 200px
  (min), End = 460px (max). Matches the WAI-ARIA separator pattern.
- Width clamps to [200, 460] on every code path (clampRail) — even a
  corrupt persisted value gets corrected at display time.
- Four new tests: ARIA bounds, pointer drag, clamp ceiling/floor,
  keyboard nudges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New persisted setting `splitRailCollapsed: boolean` (default false).
- Header gains a small +/- toggle that flips the setting; the title
  drops while collapsed so the 44px column has room.
- Collapsed body: one centered status-toned dot per visible window,
  ordered the same way the expanded tree would order them (groupingMode
  is honored — repos mode flattens groupByRepo()'s output; sessions
  mode flattens the per-session sortPendingFirst result).
- Clicking a dot expands the rail AND selects that pane in one settings
  write, so the user lands on a useful state.
- Divider pointer/keyboard handlers no-op while collapsed.
- Four new tests: toggle flips setting, 44px column + dot-only rendering,
  dot click expands+selects, drag-disabled-while-collapsed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each session group (and each real repo bucket) ends with a "+ New tab"
affordance that opens the existing NewWindowOverlay targeting that
session.

- New optional `onNewWindow(session)` prop on SplitView. When omitted
  (legacy callers, tests), the rows are hidden entirely.
- App.tsx wires it to the existing `setNewWindowSession` setter — same
  modal the other views use, so the rail piggybacks on the well-tested
  spawn flow.
- Sessions mode: one new-tab row per session group, targeting that
  session.
- Repos mode: one new-tab row per real repo bucket, targeting the FIRST
  window's session (the one window the group is anchored on). The "Other"
  bucket skips the row since there's no repo cwd to inherit.
- Visual: same row metrics as a pane row; plus icon; dim text that
  brightens to accent on hover so it reads as an action rather than a
  destination.
- Four new tests cover sessions-mode wiring, repos-mode session
  resolution, no-handler hiding, and the Other-bucket skip.

THI-244-style cwd-aware spawning (new pane opens in the repo's path
when launched from a repo bucket) is wired at the NewWindowOverlay
layer, not the rail — out of scope for this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Group head rows are now draggable in the expanded rail. Drop onto
another head row to reorder.

- Two new persisted settings: `splitRailSessionOrder: string[]` (sessions
  mode) and `splitRailRepoOrder: string[]` (repos mode). Each is a list
  of IDs in display order; live-new entries append, missing entries are
  filtered at render time, periscope-style.
- New `applyOrder()` helper does a stable sort with persisted IDs ranked
  first and live-new appended. `reorderArray()` does the insertion math.
- "Other" bucket in repos mode is pulled out before reorder and re-pinned
  last, so it never participates in user reorder. Its head row also has
  no draggable attribute.
- Drag is no-op while the rail is collapsed (no head rows render).
- Visual: dragged row dims to 0.4; a 2px accent line above/below the
  hover target marks the drop position. CSS pseudo-elements so layout
  doesn't shift mid-drag.
- Identity travels on React state, not dataTransfer — no DOM-sibling
  walk to find the drag source. dataTransfer is still populated so
  Firefox accepts the drag start.
- Per-window reorder within a group is intentionally not in this commit
  — out of scope for the PR 2 rail-features pass.
- Six new tests: persisted-order application (both modes), "Other"
  stays last, visual states on dragStart/dragOver, drop persistence,
  Other-not-draggable, drag-disabled-while-collapsed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
setNewWindowSession (the App.tsx setter the prop wires to) signs
`(sessionId: string | null) => void`. The "+ New tab" commit had the
prop typed as `(session: Session) => void`, which only landed because
the App.tsx call site happens to be checked separately from the
component file — the full tsc pass caught it on push.

Update the prop type to `(sessionId: string) => void` and adjust both
call sites (sessions mode passes `session.id`, repos mode passes the
first window's session ID). Tests updated to expect a string instead
of the Session object.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oard nudges work

The pointer-down handler calls preventDefault() to suppress text
selection during the drag. preventDefault() ALSO suppresses the default
focus-on-click behavior — meaning after clicking the divider, the
element never received focus and the keyboard handler couldn't fire.

Restore focus explicitly via `e.currentTarget.focus()`. New test asserts
the divider becomes document.activeElement after pointerDown and stays
focused past pointerUp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tdody tdody force-pushed the thibaultdody/thi-246-split-rail-features branch from e884fe2 to 0522258 Compare June 7, 2026 23:59
@tdody tdody changed the base branch from thibaultdody/thi-246-split-foundation to main June 7, 2026 23:59
@tdody tdody closed this Jun 8, 2026
@tdody tdody reopened this Jun 8, 2026
@tdody tdody merged commit a6e3ffb into main Jun 8, 2026
3 checks passed
@tdody tdody deleted the thibaultdody/thi-246-split-rail-features branch June 8, 2026 00: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