Files
web-terminal/public/grid-layout.ts
Yaojia Wang 06814ba276 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>
2026-07-11 19:19:53 +02:00

181 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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()
},
}
}