Files
web-terminal/public/grid-layout.ts
Yaojia Wang 5475b661ae feat(grid): split-grid v2 — 1×3/2×3, drag-to-quadrant, Ctrl+` cycle, maximize
Builds on the v1 watch board:
- Two more layouts: row-3 (1×3) and grid-6 (2×3). A single `grid` marker class on
  #term now carries the shared cell chrome so it no longer enumerates each lay-*.
- Ctrl+` cycles the focused quadrant (Ctrl+Shift+` reverses); matchFocusCycleKey
  is an exported pure helper so it's unit-tested. No-op / passthrough in single mode.
- Per-quadrant ⛶ maximize: the focused cell expands to fill the grid as an absolute
  overlay while siblings stay live behind it; follows focus, resets on layout
  change / tab close.
- Drag a tab from the tab bar onto a quadrant to assign it there (reuses the
  existing tab-drag dragIndex); grid-only.

Adversarial review (3 lenses → per-finding verify, incl. a headless-Chrome repro)
caught a HIGH: maximizing via `grid-column/row: 1/-1` shoved siblings into implicit
rows — a strip instead of fullscreen AND a spurious resize to backgrounded live
PTYs. Fixed with an absolute-overlay (`position:absolute; inset:0`), then verified
in real Chromium (Playwright): maximized cell fills #term, siblings 0px size delta.
Also: silence a covered pane's pending pulse under maximize; coarse-pointer target
for .cell-max. Verified: typecheck + build:web clean, 1579 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:49:39 +02:00

204 lines
7.1 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' | 'row-3' | 'grid-4' | 'grid-6'
/** All layouts, in the order the toggle presents them (by capacity). */
export const GRID_LAYOUTS: readonly GridLayout[] = [
'single',
'split-2',
'row-3',
'grid-4',
'grid-6',
]
const CAPACITY: Readonly<Record<GridLayout, number>> = {
single: 1,
'split-2': 2,
'row-3': 3,
'grid-4': 4,
'grid-6': 6,
}
/** 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))
}
/**
* Match the split-grid focus-cycle keybinding (Ctrl+` forward, Ctrl+Shift+`
* reverse). Returns the cycle direction, or null if the event isn't the binding.
* Extracted from the DOM listener so it's unit-testable in isolation.
*/
export function matchFocusCycleKey(e: KeyboardEvent): 1 | -1 | null {
if (!e.ctrlKey || e.altKey || e.metaKey) return null
if (e.key !== '`' && e.code !== 'Backquote') return null
return e.shiftKey ? -1 : 1
}
/** 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 === 'row-3' || v === 'grid-4' || v === 'grid-6'
)
}
/** 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 },
'row-3': { title: '1×3 row — three across', cells: 3 },
'grid-4': { title: '2×2 watch board', cells: 4 },
'grid-6': { title: '2×3 grid — six sessions', cells: 6 },
}
/** 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()
},
}
}