feat(grid): desktop split-grid watch board — v1 (single/1×2/2×2)

When several sessions are open on a large screen (≥1024px), the terminal
area can split into 1×2 or 2×2 so multiple LIVE, interactive terminals show
at once — a monitoring convenience for vibe-coding several Claude sessions.

Design keeps activeIndex as the single focused pane, so keybar/voice/approval
routing is unchanged; a new gridLayout + derived visible-set lets several
.term-cell wrappers show together inside a CSS-grid #term. The server and WS
protocol are untouched.

- public/grid-layout.ts (new): layout types/capacity, visibleIndices,
  matchMedia desktop gate (GRID_MIN_WIDTH=1024), persistence, toolbar toggle.
- tabs.ts: pane→.term-cell wrapper (header + terminal + inline-approve footer);
  applyLayout() owns show/hide + grid class + cell order + placeholders;
  board-aware activate() (never focuses a hidden pane); setFocused/setGridLayout
  delegate to it; renderCell/renderInlineApprove; refitVisible; notification
  suppression for on-screen panes (factoring in the home overlay).
- terminal-session.ts: show({focus}) so non-focused quadrants don't steal
  keyboard focus; onFocus callback (capture-phase pointerdown).
- main.ts: mount the toggle; window-focus refit → refitVisible.
- style.css: cell/grid model (.term-pane → relative flex child), focus ring,
  pending pulse, inline approve, placeholder, toggle + coarse-pointer targets.
- tests: grid-layout.test.ts + split-grid block in tabs.test.ts (+33).

Adversarial review (4 lenses → per-finding verify) caught and fixed a HIGH:
activate() was not board-aware, so opening a tab on a full grid focused a
hidden pane (typing into an invisible session). Verified: typecheck + build:web
clean, 1566 tests pass, grid-layout 95% / tabs 94% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-07-11 19:19:53 +02:00
parent e254918b1c
commit 06814ba276
9 changed files with 1171 additions and 16 deletions

View File

@@ -66,6 +66,9 @@ export interface TerminalSessionOpts {
/** Optional: fired when new statusLine telemetry arrives (B2). Single source of
* truth — T-tabs reads session.telemetry via the getter, not its own copy. */
onTelemetry?: (telemetry: StatusTelemetry) => void
/** Optional: fired on a pointerdown anywhere in this pane (split-grid focus).
* Lets TabApp move the focused-pane (activeIndex) to the clicked quadrant. */
onFocus?: () => void
/** Optional: spawn a NEW session in this directory ("new tab here", M6). */
cwd?: string
/** Optional: type this once the new session's shell is ready (O2 resume). */
@@ -85,6 +88,7 @@ export class TerminalSession {
private readonly onStatus: ((status: SessionStatus) => void) | undefined
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
private readonly onTelemetry: ((telemetry: StatusTelemetry) => void) | undefined
private readonly onFocus: (() => void) | undefined
private readonly spawnCwd: string | undefined
private readonly initialInput: string | undefined
private initialSent = false
@@ -121,12 +125,16 @@ export class TerminalSession {
this.onStatus = opts.onStatus
this.onClaudeStatus = opts.onClaudeStatus
this.onTelemetry = opts.onTelemetry
this.onFocus = opts.onFocus
this.spawnCwd = opts.cwd
this.initialInput = opts.initialInput
this.el = document.createElement('div')
this.el.className = 'term-pane'
this.el.style.display = 'none'
// Split-grid: a pointerdown anywhere in the pane makes it the focused quadrant.
// Capture phase so it fires before xterm's own handling; never blocks input.
this.el.addEventListener('pointerdown', () => this.onFocus?.(), { capture: true })
this.term = new Terminal({
scrollback: 5000,
@@ -403,13 +411,18 @@ export class TerminalSession {
}
}
/** Make this pane visible, fit it, and focus it. */
show(): void {
/**
* Make this pane visible and fit it. Focuses the terminal unless `focus: false`
* — in a split grid several panes are shown at once, so only the focused
* quadrant should grab keyboard focus (four panes calling focus() would fight).
*/
show(opts?: { focus?: boolean }): void {
this.el.style.display = 'block'
const shouldFocus = opts?.focus !== false
requestAnimationFrame(() => {
const dims = this.safefit()
if (dims !== null) this.sendResize(dims.cols, dims.rows)
this.term.focus()
if (shouldFocus) this.term.focus()
})
}