Skip to content

feat(THI-246 PR 3/3): Split detail pane — header chips, inline xterm, sidebar#115

Open
tdody wants to merge 5 commits into
mainfrom
thibaultdody/thi-246-split-detail
Open

feat(THI-246 PR 3/3): Split detail pane — header chips, inline xterm, sidebar#115
tdody wants to merge 5 commits into
mainfrom
thibaultdody/thi-246-split-detail

Conversation

@tdody

@tdody tdody commented Jun 8, 2026

Copy link
Copy Markdown
Owner

Summary

Final PR in the THI-246 Split-view trilogy. PR 1 (#113) shipped the layout
foundation; PR 2 (#114) shipped the rail features. This PR fills in the
detail pane: a chip header that mirrors the modal's at-a-glance signal, an
inline xterm with reconnect-don't-recreate on pane switch, and a sidebar
with three sections (Linked / Notes / Activity).

Five commits, each isolated so review can slice:

Commit Step
88a60e4 Detail header chips (branch/PR/spinner/ctx%, action hint)
40a9003 Extract PaneTerminal; refactor TerminalModal; mount inline with key={paneId}
8bbc6bf Sidebar shell + agent-gated toggle + Activity need-human banner
1502a35 Linked (PR card from existing fields) + Notes (per-pane localStorage + 300ms debounce)
7d1afa8 xterm-host padding fix — last-line clip at tall modal sizes

What ships

Detail header chips

The PR 1 placeholder header had just kind + name + session/index + branch + StatusPill + focus button. Replace with the layout the spec calls for, mirroring TerminalModal's header:

  • branch/PR chip with CI tint and external link to GitHub (same prUrl field the WindowCard uses)
  • spinner chip when the agent is active
  • ctx% chip from agent.contextPct (only renders when the parser surfaced it)
  • status pill + action hint for pending-input agents (amber, ellipsized)
  • focus-in-tmux button (unchanged)
  • New trailing pill shows the WS connection state in dim text when it's anything other than live

Inline xterm via PaneTerminal extraction

  • New <PaneTerminal> component lifts the xterm.js + WebSocket lifecycle out of TerminalModal (~400 lines of imperative setup) so both surfaces share one implementation.
  • TerminalModal becomes a thin wrapper that adds modal chrome (scrim, traffic lights, header chips, footer with connection pill / Reconnect / zoom / size / Kill) around the shared component.
  • Split detail mounts <PaneTerminal key={paneId}> — selecting the same pane reuses the terminal + WS; selecting a different pane tears down + remounts cleanly. This is the spec's "reconnect, don't recreate" via React's key-stable identity.
  • All 24 existing TerminalModal tests still pass — the extraction preserved every behavior.

Sidebar shell + agent gating

  • New persisted setting splitDetailSidebar: boolean (default true).
  • Sidebar toggle button in DetailHeader (uses the docs glyph). Hidden entirely on non-agent panes — shell panes don't have Linked / Notes / Activity context.
  • .sb-side: 256px wide, vertical flex of sections with uppercase mono headers.

Linked section

  • PR card hydrated from existing pr, prUrl, branch, and ci fields. Tappable link to GitHub. CI rollup colors the card border (green / red / amber).
  • Falls back to a non-link "branch with no open PR" card when only branch is set.
  • "+ Link a PR…" dashed CTA when both are empty (disabled — manual-link is a future-feature placeholder).
  • "+ Link a Linear issue…" dashed CTA, disabled with a tooltip — no Linear backend yet.

Notes section

  • Per-pane localStorage keyed switchboard:pane-notes:<paneId>.
  • 300 ms debounce on writes; the textarea owns its own controlled value.
  • Hydrates lazily on mount — parent re-renders (every /api/state poll) don't re-mount the textarea, so cursor position and focus survive.

Activity section

  • "Needs your input" banner when status === \"waiting\" (amber background).
  • Full commits / CI / alerts timeline deferred to a follow-up — the backend doesn't expose a per-pane activity feed yet.

Bug fix: xterm last-line clip

The PR 3 work surfaced a pre-existing latent bug. At tall modal sizes (data-column-size="wide" → 92vh × 940px cap), the bottom row of cells clipped at the box edge because:

  • .term-body was both the visual chrome (with padding: 6) and the xterm host (where hostRef pointed).
  • xterm-addon-fit reads clientHeight, which includes padding.
  • xterm computed rows for the full padded box but rendered cells starting at the padding-top edge. The bottom row's pixels extended past the box's content area and got clipped by overflow: hidden.

The fix: separate the visual gutter from the xterm host. .term-body is now a flex wrapper with the 6px gutter; new inner .term-host (no padding) is what xterm measures. clientHeight now matches the exact cell-rendering area.

Test plan (automated)

  • Full frontend: 607/607 tests pass (npm test).
  • npm run typecheck: clean.
  • npm run build: clean (verified via pre-push hook).
  • All 24 existing TerminalModal tests pass after the PaneTerminal extraction — the modal's behavior surface is preserved.
  • 14 new SplitView tests cover: detail header (branch/PR chip + ctx% conditional + action hint), sidebar toggle + agent gating + non-agent has no sidebar, Linked PR card + branch fallback, Notes persist + hydrate, Activity need-human banner.

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

Open the dashboard, switch to Split layout. The rail and selection should be the same as PR 1 + PR 2 shipped. Now:

Detail header

  • Select an agent pane on a branch with an open PR. The header shows a branch/PR chip with the CI tint (green/red/amber depending on the rollup) and a clickable #N linking out to the PR on GitHub.
  • If the agent is mid-turn, a spinner chip appears between the branch chip and the StatusPill.
  • If the parser surfaced a context-window percent, a ctx N% chip appears next to the spinner.
  • Status pill renders the same as everywhere else.
  • If the agent is waiting, the action question shows in amber after the status pill (ellipsized if long).
  • Focus-in-tmux button (rightmost) still jumps your real tmux client to the pane.

Inline xterm + reconnect-don't-recreate

  • The terminal renders live inside the detail pane — keystrokes go through, scrollback works, ANSI colors are themed correctly across light / dark / contrast / phosphor.
  • Select a pane → terminal mounts and connects. Select a different pane → previous terminal tears down, new one mounts cleanly.
  • Select the same pane that's already shown (e.g. flip to Kanban then back to Split with the same selection) → terminal reuses its existing connection (no [reconnected] banner, scrollback intact).
  • Reload the page → the selected pane restores from localStorage, its terminal connects fresh.
  • Connection pill in the header turns amber while reconnecting and red on disconnect.
  • Keyboard: Esc forwards to pane (single tap on a live WS); ⌘/Cmd + zoom keys still work.
  • Image-paste into an agent pane still uploads.
  • Select-to-copy on the terminal still copies and toasts.

Sidebar

  • Toggle button (top-right of detail header, docs icon) appears only for agent panes. Click it — sidebar slides in/out, preference persists across reloads.
  • Linked section shows a PR card with #N + branch when the agent's branch has an open PR. CI rollup colors the card border. Click the card → opens the PR on GitHub.
  • Linked falls back to a "(no PR)" branch chip when the branch has no open PR.
  • "+ Link a Linear issue…" button is visible but disabled (tooltip explains).
  • Notes section: type into the textarea. After ~300ms of idle, the value persists to localStorage. Reload — your notes come back.
  • Switch between two panes and back — each pane has its own independent notes.
  • Activity section: when the selected agent is waiting, an amber "Needs your input" banner shows. When the agent is running/idle/done, only the placeholder text appears.

Bug fix verification (the headline)

  • Open the terminal modal (any view's click-to-open behavior) at the wide column size (/ to open size buttons, click size+ until "wide"). Verify the last line of the active terminal is fully visible — cursor / input prompt / Claude's status line all show in full, not cut at the bottom. Pre-fix this was the visible regression.
  • Same check in the Split detail pane at any rail width.
  • Resize the modal via the size buttons across narrow → normal → wide. The last line stays fully visible at every step.

Themes

  • Switch theme to Light, Contrast, Phosphor. All new .sb-side* surfaces and the PR card use existing tokens — no white-on-white, no invisible borders.

Out of scope (don't test these)

  • Full Activity timeline (commits / CI / alerts) — backend feed deferred to a follow-up.
  • Linear linking — backend doesn't exist; CTA is a disabled placeholder.
  • Per-window drag-reorder within a group — PR 2 scope decision deferred this too.

Follow-up tickets to file

  • Sidebar Activity timeline (commits + CI + alerts feed). Needs backend support.
  • Linear linking — UI hookup once a Linear-link backend ships.
  • Per-window drag-reorder in the rail (carryover from PR 2's scope split).

🤖 Generated with Claude Code

tdody and others added 5 commits June 7, 2026 20:08
PR 1 shipped a minimal header (kind + name + session/index + branch +
StatusPill + focus button). Replace it with the chip layout the spec
calls for, mirroring TerminalModal's header so the at-a-glance signal
is the same whether the user is in modal or inline mode:

- Branch + PR + CI rollup as one `branch-pr` chip, with the CI tone
  carried in the className and the PR number rendering as an external
  link when `prUrl` is present.
- Agent activity spinner chip (when the parser surfaced one).
- Ctx% chip from `agent.contextPct` — only renders when the parser
  reports it, so non-Claude agents stay quiet.
- Status pill stays in its current position.
- Pending-input action hint surfaced as ellipsized amber text next to
  the status pill (same pattern as TerminalModal's `term-action`).
- Focus-in-tmux button stays as the trailing action.

Three new tests cover the branch/PR chip, the ctx% conditional, and
the action hint.

CSS adds `.sb-pane-action` (amber, ellipsized) and the meta `.sep`
divider; existing `.sb-pane-hd` flex/wrap already handles the chip
layout.

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

Lifts the xterm.js + WebSocket lifecycle out of TerminalModal into a
shared <PaneTerminal> component so the Split view's detail pane can
embed a live terminal without duplicating ~400 lines of imperative
setup.

What moved into PaneTerminal:
- Terminal construction (font, theme, link providers, key handler).
- WebSocket connect + auto-reconnect loop with the existing
  decideCloseAction backoff (same close-code handling, same retry
  ladder).
- Preview painting + snapshot replacement.
- Resize lifecycle: ResizeObserver + rAF-debounced FitAddon, forward
  cols/rows to tmux.
- Select-to-copy + image-paste.
- Live zoom / live theme without rebuild.
- PromptOverlay rendering (the prompt state lives next to the WS).
- Document-level ⌘=/⌘-/⌘0 zoom hotkeys.

What stays in TerminalModal:
- Modal chrome: scrim, traffic lights, term-hd chip header, term-foot
  with connection pill / Reconnect button / zoom buttons / Size buttons
  / Kill button / hint text.
- Modal-specific Esc behavior (PaneTerminal calls onEscape; modal wires
  it to onClose).

What changes for the inline path:
- SplitView's detail pane now renders <PaneTerminal key={paneId}> so
  selecting the same pane reuses the terminal + WS; selecting a different
  pane tears down + remounts cleanly. This is the spec's "reconnect,
  don't recreate" semantics, implemented via React's key-stable identity.
- New optional `onToast` prop on SplitView, wired in App.tsx to the
  existing messageToast.
- DetailHeader gains a dim connection pill that turns amber while
  reconnecting and red on disconnect — xterm itself still surfaces
  [reconnecting…] inline as the primary signal.
- DetailPlaceholder + .sb-detail-stub copy gone; the detail pane is now
  always live.

Surface guarantees: all 24 existing TerminalModal tests still pass
unchanged — the modal now wraps PaneTerminal but its assertion targets
(connection pill, Reconnect button, chip header, kill, scrim, select-to-
copy, preview placeholder behavior) all live in the modal chrome and
verify behavior PaneTerminal exposes via the onConnectionChange callback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Linked / Notes / Activity side panel skeleton to the Split
detail pane. The three sections fill in in follow-up commits within
this PR.

- New persisted setting `splitDetailSidebar: boolean` (default true).
  Tracks the user's preference; the actual visibility ANDs it with
  agent-gating so shell panes never show the panel.
- Sidebar toggle in DetailHeader: a `btn-icon btn-ghost` with the
  `docs` glyph. Hidden entirely on non-agent panes — the toggle would
  be a no-op there since the panel is gated off.
- `.sb-side` container: 256px wide, vertical flex of sections each
  with an uppercase mono header and a body. Inherits the detail pane's
  bg-elev surface so it reads as part of the chrome.
- Activity section already surfaces the spec's "Needs your input"
  banner when status === "waiting" — full timeline deferred per the
  scope decision.
- Three new tests: agent default = visible, toggle persists hidden
  state, non-agent has no sidebar AND no toggle, waiting → need-human
  banner.
- Test file gains xterm module mocks so PaneTerminal (which Detail now
  mounts) doesn't crash happy-dom on import. Same vi.hoisted pattern
  TerminalModal.test.tsx uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fills in two of the three sidebar sections; Activity's banner already
shipped in the sidebar-shell commit.

Linked:
- PR card hydrated from the existing pr/prUrl/branch/ci Window fields —
  same data the branch/PR chip on Kanban/List uses. Tappable link to
  the GitHub PR. CI rollup colors the card border (green/red/amber).
- Falls back to a non-link "branch with no open PR" card when branch
  is set but pr isn't.
- "+ Link a PR…" dashed CTA when both are empty (disabled — manual
  linking is a future-feature placeholder).
- "+ Link a Linear issue…" dashed CTA, disabled with a tooltip — the
  backend doesn't expose Linear yet. Surfaces the slot so users can
  see what's coming without a half-working feature.

Notes:
- Per-pane localStorage keyed `switchboard:pane-notes:<paneId>`.
- 300 ms debounce on writes so a flurry of keystrokes doesn't thrash
  the store.
- Hydrates lazily in useState's initializer so re-mounts pick up
  prior content without extra wiring.
- Parent re-renders (every /api/state poll) don't re-mount the
  textarea; cursor position and focus survive.

Four new tests: PR card render + href + CI tint, branch-only fallback,
debounced persist via fake timers, hydration on mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The terminal modal's last cell row clipped at the bottom edge when the
modal was tall (data-column-size="wide" → 92vh × 940px cap, or any
similar configuration where the .term-body grid slot is large enough
that the 6px padding made the difference of an extra row in fit()'s
math).

Root cause: the inline `padding: 6` sat directly on .term-body, which
was also the xterm host (hostRef pointed there). xterm-addon-fit reads
the host's `clientHeight`, which INCLUDES padding — so fit() computed
rows assuming the full padded box was renderable, but xterm's cells
start at the padding-top edge. With clientHeight tall enough, the
last row's cells extended past the host's bottom padding boundary and
got chopped by `.term-body { overflow: hidden }`.

The 100px legacy bottom padding the pre-xterm renderer used to ship
hid this; the THI-103-tracked inline-`padding: 6` override revealed it.

Fix: wrap the xterm host. .term-body is now a flex container with the
visual 6px gutter; the inner .term-host (which xterm measures) has no
padding of its own. fit()'s clientHeight read now matches the exact
cell-rendering area, so the last row stays inside the box at every
modal size.

Mouseup listener for select-to-copy and the paste handler moved with
the host to .term-host (still bubbles correctly from xterm cells). One
TerminalModal test updated to fire mouseup on .term-host instead of
.term-body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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