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:
180
public/grid-layout.ts
Normal file
180
public/grid-layout.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* public/grid-layout.ts — desktop split-grid ("watch board") layout logic + toggle.
|
||||
*
|
||||
* When several sessions are open on a large screen, the user can split #term into
|
||||
* a 2×2 grid (or 1×2 side-by-side) so multiple LIVE terminals are visible at once
|
||||
* — a monitoring/multitasking convenience for vibe-coding several Claude sessions.
|
||||
*
|
||||
* This module owns the PURE layout logic (capacity, which tabs are visible, the
|
||||
* matchMedia desktop gate, persistence) plus the toolbar toggle control. The DOM
|
||||
* of the panes themselves stays in tabs.ts (TabApp) — this keeps tabs.ts from
|
||||
* growing past its size budget and makes the layout math unit-testable in isolation.
|
||||
*
|
||||
* v1 layouts: 'single' (today's behavior) · 'split-2' (side-by-side) · 'grid-4' (2×2).
|
||||
* The design is deliberately extensible: adding 'row-3'/'grid-6' in v2 is a
|
||||
* CAPACITY entry + a LAYOUT_META entry + CSS, with no change to the state model.
|
||||
*/
|
||||
|
||||
/** A screen layout for the terminal area. Ordered by pane capacity. */
|
||||
export type GridLayout = 'single' | 'split-2' | 'grid-4'
|
||||
|
||||
/** All layouts, in the order the toggle presents them. */
|
||||
export const GRID_LAYOUTS: readonly GridLayout[] = ['single', 'split-2', 'grid-4']
|
||||
|
||||
const CAPACITY: Readonly<Record<GridLayout, number>> = {
|
||||
single: 1,
|
||||
'split-2': 2,
|
||||
'grid-4': 4,
|
||||
}
|
||||
|
||||
/** How many panes a layout shows at once. */
|
||||
export function layoutCapacity(layout: GridLayout): number {
|
||||
return CAPACITY[layout] ?? 1
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab indices visible under `layout`: the first `min(tabCount, capacity)` in
|
||||
* tab order. Membership is controlled by reordering tabs (v1) — drag a tab into
|
||||
* the first N and it joins the board.
|
||||
*/
|
||||
export function visibleIndices(tabCount: number, layout: GridLayout): number[] {
|
||||
const n = Math.min(Math.max(0, tabCount), layoutCapacity(layout))
|
||||
return Array.from({ length: n }, (_, i) => i)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether tab `idx` is on-screen. In 'single' that's only the focused pane; in a
|
||||
* grid it's any of the first-N visible tabs. Used for both rendering and to
|
||||
* suppress OS notifications for panes the user can already see.
|
||||
*/
|
||||
export function isVisibleIndex(
|
||||
idx: number,
|
||||
activeIndex: number,
|
||||
tabCount: number,
|
||||
layout: GridLayout,
|
||||
): boolean {
|
||||
if (layout === 'single') return idx === activeIndex
|
||||
return idx >= 0 && idx < Math.min(Math.max(0, tabCount), layoutCapacity(layout))
|
||||
}
|
||||
|
||||
/** Minimum viewport width (px) at which multi-pane layouts are offered. Four
|
||||
* terminals need real width; below this the board falls back to 'single'. */
|
||||
export const GRID_MIN_WIDTH = 1024
|
||||
|
||||
/** True when the screen is wide enough for split layouts (desktop/large only). */
|
||||
export function gridAllowed(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
if (typeof window.matchMedia === 'function') {
|
||||
return window.matchMedia(`(min-width: ${GRID_MIN_WIDTH}px)`).matches
|
||||
}
|
||||
return (window.innerWidth ?? 0) >= GRID_MIN_WIDTH
|
||||
}
|
||||
|
||||
const GRID_LAYOUT_KEY = 'web-terminal:grid-layout'
|
||||
|
||||
function isGridLayout(v: unknown): v is GridLayout {
|
||||
return v === 'single' || v === 'split-2' || v === 'grid-4'
|
||||
}
|
||||
|
||||
/** Load the persisted layout. Forced to 'single' when the screen is too narrow
|
||||
* (a stored grid choice must not resurrect on a phone). */
|
||||
export function loadGridLayout(): GridLayout {
|
||||
if (!gridAllowed()) return 'single'
|
||||
try {
|
||||
const stored = localStorage.getItem(GRID_LAYOUT_KEY)
|
||||
if (isGridLayout(stored)) return stored
|
||||
} catch {
|
||||
// localStorage unavailable — use default
|
||||
}
|
||||
return 'single'
|
||||
}
|
||||
|
||||
/** Persist the chosen layout (mirrors the other web-terminal:* prefs). */
|
||||
export function saveGridLayout(layout: GridLayout): void {
|
||||
try {
|
||||
localStorage.setItem(GRID_LAYOUT_KEY, layout)
|
||||
} catch {
|
||||
// localStorage unavailable — run without persistence
|
||||
}
|
||||
}
|
||||
|
||||
export interface GridToggleHooks {
|
||||
getLayout: () => GridLayout
|
||||
setLayout: (layout: GridLayout) => void
|
||||
}
|
||||
|
||||
export interface GridToggle {
|
||||
/** Re-sync the control's pressed state + visibility (after setLayout / resize). */
|
||||
refresh: () => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
/** Per-layout label/title for the toggle. The glyph is drawn in CSS from the
|
||||
* cell count (see makeGlyph), not from a font, so it renders identically everywhere. */
|
||||
const LAYOUT_META: Readonly<Record<GridLayout, { title: string; cells: number }>> = {
|
||||
single: { title: 'Single pane', cells: 1 },
|
||||
'split-2': { title: 'Side by side — compare two', cells: 2 },
|
||||
'grid-4': { title: '2×2 watch board', cells: 4 },
|
||||
}
|
||||
|
||||
/** A small CSS-drawn layout glyph (N cells), reliable across platforms. */
|
||||
function makeGlyph(layout: GridLayout): HTMLElement {
|
||||
const g = document.createElement('span')
|
||||
g.className = `grid-glyph gl-${layout}`
|
||||
g.setAttribute('aria-hidden', 'true')
|
||||
for (let i = 0; i < LAYOUT_META[layout].cells; i++) g.appendChild(document.createElement('i'))
|
||||
return g
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the layout segmented control into the toolbar. Desktop-only: it hides
|
||||
* below GRID_MIN_WIDTH, and if the window narrows past the threshold while a grid
|
||||
* is active it forces the layout back to 'single' (the caller re-renders).
|
||||
*/
|
||||
export function mountGridToggle(toolbar: HTMLElement, hooks: GridToggleHooks): GridToggle {
|
||||
const seg = document.createElement('div')
|
||||
seg.className = 'grid-toggle'
|
||||
seg.setAttribute('role', 'group')
|
||||
seg.setAttribute('aria-label', 'Terminal layout')
|
||||
|
||||
const buttons = GRID_LAYOUTS.map((layout) => {
|
||||
const btn = document.createElement('button')
|
||||
btn.className = 'grid-toggle-btn'
|
||||
btn.dataset.layout = layout
|
||||
btn.title = LAYOUT_META[layout].title
|
||||
btn.setAttribute('aria-label', LAYOUT_META[layout].title)
|
||||
btn.appendChild(makeGlyph(layout))
|
||||
btn.addEventListener('click', () => hooks.setLayout(layout))
|
||||
seg.appendChild(btn)
|
||||
return btn
|
||||
})
|
||||
|
||||
const refresh = (): void => {
|
||||
seg.style.display = gridAllowed() ? 'inline-flex' : 'none'
|
||||
const current = hooks.getLayout()
|
||||
for (const btn of buttons) {
|
||||
btn.setAttribute('aria-pressed', String(btn.dataset.layout === current))
|
||||
}
|
||||
}
|
||||
|
||||
const mql =
|
||||
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia(`(min-width: ${GRID_MIN_WIDTH}px)`)
|
||||
: null
|
||||
const onChange = (): void => {
|
||||
if (!gridAllowed() && hooks.getLayout() !== 'single') hooks.setLayout('single')
|
||||
refresh()
|
||||
}
|
||||
mql?.addEventListener('change', onChange)
|
||||
|
||||
toolbar.appendChild(seg)
|
||||
refresh()
|
||||
|
||||
return {
|
||||
refresh,
|
||||
dispose: (): void => {
|
||||
mql?.removeEventListener('change', onChange)
|
||||
seg.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user