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:
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user