diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index c326732..510c2e9 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -24,6 +24,24 @@ > 新会话读到的第一块。保持准确,只描述"此刻"。 +### 🖥️ SPLIT-GRID 看板 — 桌面多 session 分屏(2026-07-11,当前活跃) +- **需求**: web/Mac 大屏、开多个 tab 时,把 `#term` 大窗切成 1×2 / 2×2 宫格,多个 **live 可交互**终端同屏,方便"vibe coding"时盯多个 Claude session。手机不做(<1024px 强制 single)。 +- **分支**: `feat/split-grid-view`(自 `feat/tunnel-automation`)。**用户决策(AskUserQuestion)**:全部阶段(v1→v2→v3)用多 agent + loop 完成;审批用**每格内联 ✓/✗**;成员=**前 N 个 tab(拖拽换序控制)**;布局=**single + 1×2 + 2×2**。 +- **编排**: orchestrator 亲写互锁的 5 文件(并行 builder 会互相踩),每阶段后**并行对抗式 review workflow**(4 lens → 逐条 verify)→ 修 confirmed → 复验绿 → commit → loop 下一阶段。 +- **[x] v1 DONE(2026-07-11)** — orchestrator 独立复验全绿:`npm run typecheck`(前后端两 config)干净、`build:web` 干净、**全套 1566 测试通过**(+33 新增),覆盖率 grid-layout.ts 95%/tabs.ts 94%(≥80 门槛)。 + - **核心设计**: `activeIndex` 语义**不变**=聚焦格(keybar/voice/approval 全部零改动);新增 `gridLayout` + 派生 `visibleIndices`;每个 pane 包进 `.term-cell`(header + 终端 + 可选内联审批 footer);`applyLayout()` 独占 pane 显隐/grid class/cell 排序/占位符;`#term` 变 CSS grid。**服务端/WS 协议零改动**。 + - **文件**: 新 `public/grid-layout.ts`(纯逻辑 + matchMedia 门 + 持久化 + toolbar 分段控件)、`tabs.ts`(applyLayout/renderCell/renderInlineApprove/setFocused/setGridLayout/refitVisible + `activate()` 改为**board-aware**)、`terminal-session.ts`(`show({focus})` 防 4 格抢焦点 + `onFocus` 回调)、`main.ts`(挂 toggle + refitVisible)、`style.css`(cell/grid/焦点环/pending 脉冲/内联审批/占位符/toggle + `.term-pane` 改 position:relative flex 子)。新测 `test/grid-layout.test.ts` + `tabs.test.ts` 追加 split-grid 块。 + - **交叉验证抓修 3 真缺陷**:**HIGH** — `activate()` 原先非 board-aware:满格(4/4)时点 "+" 会让新 tab 成为 activeIndex 但 `display:none`,用户对着看不见的 session 打字(空最常见路径触发)。修:把 off-board→moveTab 上板逻辑折进 `activate()`,`setFocused`/`setGridLayout` 委托之。**LOW** — 通知抑制未算 `homeForced`(⌂ 覆盖时 pane 实际不在屏);修:`onScreen = !homeForced && isVisible`。**LOW** — toggle 触控尺寸(coarse-pointer 平板);修:`@media (pointer:coarse)` 加大。全部补了回归测试。 +- **[x] v2 DONE(2026-07-11)** — orchestrator 独立复验全绿:typecheck 两 config 干净、build:web 干净、**全套 1579 测试**(+13),覆盖率 grid-layout 95%/tabs 93%。加了:**1×3(row-3)/2×3(grid-6)布局**、**Ctrl+`/Ctrl+Shift+` 循环焦点**(main.ts capture keydown → cycleFocus,单格模式不吞键)、**每格最大化 ⛶**(`.maximized` 覆盖层)、**拖 tab 到格**(wireCellDropTarget 复用 dragIndex)。用单个 `grid` 标记类承载共享 cell 样式(不再枚举每个 lay-*)。 + - **交叉验证抓修 3 真缺陷**:**HIGH** — 最大化原用 `grid-column/row: 1/-1` **span 网格**是错的:CSS Grid 把兄弟格挤进隐式行→"最大化"格变成细条(非全屏)+ 兄弟格 box 变化触发 ResizeObserver→**给后台 live PTY 发错误 resize(伪 SIGWINCH)**。评审在**真 headless Chrome 里实测复现**。修:`.maximized` 改 `position:absolute; inset:0; z-index:4` 脱离网格流做覆盖层。**独立 Playwright 复验**:最大化格填满 #term(1000×700)、兄弟格尺寸 0px 变化,对照组证实旧规则确会 891×572 细条+兄弟塌成 52px。**MED** — 最大化时被盖住的 pending 格 amber 脉冲外溢;修:`(!maximized || focused)` 门。**LOW** — `.cell-max` 缺 coarse-pointer 触控尺寸。均补回归测试;并把 Ctrl+` 键匹配抽成 `matchFocusCycleKey` 纯函数单测。 +- **[x] v3 DONE(2026-07-11)** — 三个特性,orchestrator 独立复验全绿(typecheck 两 config + build:web 干净、**全套 1615 测试**、全局覆盖率 89% stmts/82% branch 过 80 门槛)。分三 commit: + - **v3a 只读 monitor 格(cd97114)**:每格 👁 切换 live↔read-only。`public/cell-monitor.ts` 轮询 `GET /live-sessions/:id/preview` 写只读 xterm,**不 attach WS、不发 resize**,故不驱动共享 PTY 尺寸——解决"小格盯 session 会缩掉别的设备全屏"的跨设备 shrink。monitor 时 live pane 保持 hidden;toggle-off/关 tab/离开 grid 皆清理。 + - **v3b 可拖拽分隔条(007e598)**:格间 gutter 拖拽调每布局 col/row 的 fr 比例(`adjustSplit` 纯函数:相邻轨道互让、clamp 0.3、守恒),内联 grid-template + 持久化 `web-terminal:grid-splits`;拖拽中 reposition 不重建 handle。 + - **v3c 布局预设(007e598)**:`public/grid-presets.ts` toolbar 下拉,存/应用/删命名的 布局+split(`web-terminal:grid-presets`);外点/Esc 关、同名替换、XSS-safe(textContent)。 + - **交叉验证抓修 4 类真缺陷(6 confirmed,2 dup)**:**HIGH** — `splitForLayout({'grid-4':null})` 抛 TypeError(`null!==undefined` 过守卫后读 `null.cols`),每次渲染都炸→整个 tab UI 砖掉;修:守卫加 `!==null && typeof==='object'`。**MED** — monitor 在 attach 前(id=null)切换:按钮显 active 但格仍 live 且发 resize,且不自愈;修:`onSessionId` 到达时 reconcile(re-render 使 startMonitor 生效)。**MED** — `renderGutters` 拖拽中被并发 applyLayout 销毁重建→丢拖拽监听;修:`draggingGutter` 标志跳过重建。**LOW** — grid-presets 硬编码 1024→改用 `GRID_MIN_WIDTH`。均补回归测试(含 null-split 不抛、reconnect-reconcile、拖拽中重渲染 handle 存活)。 +- **✅ 全部阶段(v1+v2+v3)完成**:5 commit(06814ba/5475b66/cd97114/007e598 + fix)。每阶段 = 实现→typecheck/test/build 绿→**并行多 lens 对抗式 review workflow + 逐条 verify**→修 confirmed→复验→commit。maximize 的 HIGH 几何缺陷经**真 headless Chrome(Playwright)实测**证实修复。服务端/WS 协议零改动;单格模式行为不变。 +- **探索产物**: 交互式原型 artifact(single/1×2/2×2 切换、点击移焦、内联审批)已给用户看过并据此拍板方向。 + ### 🔐 TUNNEL AUTOMATION — 零接触隧道注入(客户永不碰证书/密钥;2026-07-08) - **计划**: [PLAN_TUNNEL_AUTOMATION.md](./PLAN_TUNNEL_AUTOMATION.md)(design-locked)。目标:host 一条命令 onboard、device 登录一次即在硬件里生成不可导出密钥→CSR→拿证书,**无 .p12/AirDrop,私钥永不离设备**。三轨:A 控制面/PKI、B host agent、C 原生客户端(iOS/Android/desktop)。基础设施(frps/device-CA/frp-client-CA/nginx:8470 mTLS)已在 VPS M1 上线;本工作流建的是**自动化**(签发端点、njs cert→Host 绑定、硬件 keygen、host onboard)。 - **分支**: `feat/tunnel-automation`(自 develop)。走 **MVP fast-path §7**(10 个 tracked task,见任务表 #1–#10),依赖图强制:A1 crypto → A2 契约 → A3 nginx 绑定为顺序地基,之后按职责(backend/host/iOS)扇出。 diff --git a/public/cell-monitor.ts b/public/cell-monitor.ts new file mode 100644 index 0000000..fe586cf --- /dev/null +++ b/public/cell-monitor.ts @@ -0,0 +1,82 @@ +/** + * public/cell-monitor.ts — read-only "monitor" view of a session in a grid cell. + * + * A monitored quadrant renders the session's current screen via periodic + * GET /live-sessions/:id/preview snapshots (RingBuffer.tail on the server) into a + * read-only xterm. It NEVER attaches a WebSocket and NEVER sends a resize, so — + * unlike a live interactive quadrant (latest-writer-wins) — it does not drive the + * shared PTY size. That lets you WATCH a session in a small quadrant without + * shrinking it for another device that is using it full-screen (the cross-device + * shrink the split-grid design flagged). + * + * The snapshot is scaled with a CSS transform to fit the cell, like the launcher + * thumbnails, so the real screen stays readable regardless of the cell size. + */ + +import { Terminal } from '@xterm/xterm' +import { fetchPreview, PREVIEW_CLEAR, PREVIEW_THEME } from './preview-grid.js' + +/** How often a monitored quadrant re-fetches the session's screen snapshot. */ +const MONITOR_POLL_MS = 2000 + +export interface CellMonitor { + dispose(): void +} + +/** Scale the rendered term to fit `host`, top-left anchored (no upscaling). */ +function fitMonitor(host: HTMLElement, screen: HTMLElement): void { + const w = screen.offsetWidth + const h = screen.offsetHeight + if (w === 0 || h === 0 || host.clientWidth === 0 || host.clientHeight === 0) return + const scale = Math.min(1, host.clientWidth / w, host.clientHeight / h) + screen.style.transformOrigin = 'top left' + screen.style.transform = `scale(${scale})` +} + +/** + * Mount a polling read-only preview of `sessionId` into `host`. Returns a handle + * whose dispose() stops the poll and tears down the terminal. Best-effort: a + * failed fetch just skips that tick (no throw). + */ +export function mountCellMonitor(host: HTMLElement, sessionId: string): CellMonitor { + const term = new Terminal({ + disableStdin: true, + cursorBlink: false, + fontFamily: 'Menlo, Consolas, monospace', + fontSize: 12, + scrollback: 0, + theme: PREVIEW_THEME, + }) + term.open(host) + + let disposed = false + let timer: ReturnType | null = null + + const poll = async (): Promise => { + const p = await fetchPreview(sessionId) + if (disposed) return + if (p) { + const cols = Math.max(2, p.cols) + const rows = Math.max(2, p.rows) + if (term.cols !== cols || term.rows !== rows) term.resize(cols, rows) + term.reset() + term.write(PREVIEW_CLEAR + p.data, () => { + const screen = host.firstElementChild + if (screen instanceof HTMLElement) fitMonitor(host, screen) + }) + } + if (!disposed) timer = setTimeout(() => void poll(), MONITOR_POLL_MS) + } + void poll() + + return { + dispose(): void { + disposed = true + if (timer !== null) { + clearTimeout(timer) + timer = null + } + term.dispose() + }, + } +} diff --git a/public/grid-layout.ts b/public/grid-layout.ts new file mode 100644 index 0000000..3d5ce8d --- /dev/null +++ b/public/grid-layout.ts @@ -0,0 +1,314 @@ +/** + * 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> = { + 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> = { + 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() + }, + } +} + +/* ── Resizable splitters (v3) ────────────────────────────────────────── */ + +/** The (columns, rows) track counts a layout lays its panes out in. */ +export function layoutTracks(layout: GridLayout): { cols: number; rows: number } { + switch (layout) { + case 'single': + return { cols: 1, rows: 1 } + case 'split-2': + return { cols: 2, rows: 1 } + case 'row-3': + return { cols: 3, rows: 1 } + case 'grid-4': + return { cols: 2, rows: 2 } + case 'grid-6': + return { cols: 3, rows: 2 } + } +} + +/** Per-axis track fractions (relative fr units) for one layout. */ +export interface TrackSplit { + cols: number[] + rows: number[] +} + +/** Persisted custom track fractions, per layout (missing → equal tracks). */ +export type GridSplits = Partial> + +/** Equal-fraction split for a layout (every track = 1fr). */ +export function defaultSplit(layout: GridLayout): TrackSplit { + const { cols, rows } = layoutTracks(layout) + return { cols: Array(cols).fill(1), rows: Array(rows).fill(1) } +} + +/** Smallest fraction a track may shrink to while its neighbor grows. */ +export const MIN_TRACK_FRACTION = 0.3 + +/** + * Move the boundary between track `gutterIndex` and `gutterIndex+1` by `delta` + * (in fr units), trading the delta between the two adjacent tracks and clamping + * both to MIN_TRACK_FRACTION. Pure — returns a new array (the input untouched). + */ +export function adjustSplit( + fractions: readonly number[], + gutterIndex: number, + delta: number, + min = MIN_TRACK_FRACTION, +): number[] { + if (gutterIndex < 0 || gutterIndex >= fractions.length - 1) return fractions.slice() + let a = (fractions[gutterIndex] ?? 1) + delta + let b = (fractions[gutterIndex + 1] ?? 1) - delta + if (a < min) { + b -= min - a + a = min + } + if (b < min) { + a -= min - b + b = min + } + if (a < min || b < min) return fractions.slice() // can't satisfy both + const next = fractions.slice() + next[gutterIndex] = a + next[gutterIndex + 1] = b + return next +} + +/** Render fractions as a grid-template value (e.g. "1fr 1.4fr"). */ +export function tracksToTemplate(fractions: readonly number[]): string { + return fractions.map((f) => `${f}fr`).join(' ') +} + +const GRID_SPLITS_KEY = 'web-terminal:grid-splits' + +/** Load persisted splits (best-effort; shape validated per-layout at use). */ +export function loadGridSplits(): GridSplits { + try { + const raw = localStorage.getItem(GRID_SPLITS_KEY) + if (raw === null) return {} + const parsed: unknown = JSON.parse(raw) + if (parsed !== null && typeof parsed === 'object') return parsed as GridSplits + } catch { + // localStorage / JSON unavailable — use equal tracks + } + return {} +} + +/** Persist the custom splits map. */ +export function saveGridSplits(splits: GridSplits): void { + try { + localStorage.setItem(GRID_SPLITS_KEY, JSON.stringify(splits)) + } catch { + // localStorage unavailable — run without persistence + } +} + +/** The valid split for a layout: the stored one if its shape matches the layout's + * track counts and all fractions are positive, else the equal default. */ +export function splitForLayout(splits: GridSplits, layout: GridLayout): TrackSplit { + const { cols, rows } = layoutTracks(layout) + const stored = splits[layout] + const ok = + stored !== undefined && + stored !== null && // a null entry ({"grid-4":null}) is valid JSON but not a TrackSplit + typeof stored === 'object' && + Array.isArray(stored.cols) && + stored.cols.length === cols && + Array.isArray(stored.rows) && + stored.rows.length === rows && + [...stored.cols, ...stored.rows].every((n) => typeof n === 'number' && n > 0) + return ok ? stored : defaultSplit(layout) +} diff --git a/public/grid-presets.ts b/public/grid-presets.ts new file mode 100644 index 0000000..ad44245 --- /dev/null +++ b/public/grid-presets.ts @@ -0,0 +1,214 @@ +/** + * public/grid-presets.ts — saved split-grid layout presets (v3). + * + * A preset bundles a layout + its custom track fractions under a name, so you can + * save a board arrangement you like (e.g. "2×2, left column wide") and re-apply it + * in one click. Persistence + the tiny toolbar dropdown live here; TabApp exposes + * the current arrangement and an apply() via hooks, keeping tabs.ts lean. + */ + +import { type GridLayout, type TrackSplit, gridAllowed, GRID_MIN_WIDTH } from './grid-layout.js' + +export interface GridPreset { + name: string + layout: GridLayout + split: TrackSplit +} + +const PRESETS_KEY = 'web-terminal:grid-presets' +const VALID_LAYOUTS: readonly string[] = ['single', 'split-2', 'row-3', 'grid-4', 'grid-6'] + +function isPreset(v: unknown): v is GridPreset { + if (v === null || typeof v !== 'object') return false + const p = v as Record + return ( + typeof p['name'] === 'string' && + typeof p['layout'] === 'string' && + VALID_LAYOUTS.includes(p['layout']) && + typeof p['split'] === 'object' && + p['split'] !== null + ) +} + +/** Load saved presets (best-effort; drops malformed entries). */ +export function loadPresets(): GridPreset[] { + try { + const raw = localStorage.getItem(PRESETS_KEY) + if (raw === null) return [] + const parsed: unknown = JSON.parse(raw) + if (Array.isArray(parsed)) return parsed.filter(isPreset) + } catch { + // localStorage / JSON unavailable + } + return [] +} + +/** Persist the presets list. */ +export function savePresets(list: readonly GridPreset[]): void { + try { + localStorage.setItem(PRESETS_KEY, JSON.stringify(list)) + } catch { + // localStorage unavailable — run without persistence + } +} + +export interface PresetHooks { + /** The current layout + its split, to snapshot into a new preset. */ + current: () => { layout: GridLayout; split: TrackSplit } + /** Apply a chosen preset (set layout + splits + re-render). */ + apply: (preset: GridPreset) => void +} + +export interface PresetMenu { + /** Re-sync visibility (desktop gate) — call on layout/resize changes. */ + refresh: () => void + dispose: () => void +} + +/** Short human label for a layout in a preset row. */ +const LAYOUT_LABEL: Readonly> = { + single: 'single', + 'split-2': '1×2', + 'row-3': '1×3', + 'grid-4': '2×2', + 'grid-6': '2×3', +} + +/** Mount the presets dropdown into the toolbar. Desktop-only (hidden below the + * grid width gate, like the layout toggle). */ +export function mountGridPresets(toolbar: HTMLElement, hooks: PresetHooks): PresetMenu { + let presets = loadPresets() + + const root = document.createElement('div') + root.className = 'grid-presets' + + const btn = document.createElement('button') + btn.type = 'button' + btn.className = 'grid-presets-btn' + btn.textContent = '☰' + btn.title = 'Layout presets' + btn.setAttribute('aria-label', 'Layout presets') + + const menu = document.createElement('div') + menu.className = 'grid-presets-menu' + menu.hidden = true + + const list = document.createElement('div') + list.className = 'gp-list' + + const saveRow = document.createElement('div') + saveRow.className = 'gp-save' + const nameInput = document.createElement('input') + nameInput.className = 'gp-name' + nameInput.type = 'text' + nameInput.placeholder = 'Save current as…' + nameInput.maxLength = 40 + const saveBtn = document.createElement('button') + saveBtn.type = 'button' + saveBtn.className = 'gp-save-btn' + saveBtn.textContent = 'Save' + saveRow.append(nameInput, saveBtn) + + menu.append(list, saveRow) + root.append(btn, menu) + toolbar.appendChild(root) + + const renderList = (): void => { + list.replaceChildren() + if (presets.length === 0) { + list.appendChild(el('div', 'gp-empty', 'No saved presets')) + return + } + presets.forEach((preset, i) => { + const row = el('div', 'gp-row') + const apply = el('button', 'gp-apply') + apply.append( + el('span', 'gp-apply-name', preset.name), + el('span', 'gp-apply-layout', LAYOUT_LABEL[preset.layout]), + ) + apply.addEventListener('click', () => { + hooks.apply(preset) + close() + }) + const del = el('button', 'gp-del', '×') + del.title = 'Delete preset' + del.setAttribute('aria-label', `Delete preset ${preset.name}`) + del.addEventListener('click', (e) => { + e.stopPropagation() + presets = presets.filter((_, j) => j !== i) + savePresets(presets) + renderList() + }) + row.append(apply, del) + list.appendChild(row) + }) + } + + const save = (): void => { + const name = nameInput.value.trim() + if (name === '') return + const { layout, split } = hooks.current() + // Replace a same-named preset rather than duplicating. + presets = [...presets.filter((p) => p.name !== name), { name, layout, split }] + savePresets(presets) + nameInput.value = '' + renderList() + } + saveBtn.addEventListener('click', save) + nameInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') save() + e.stopPropagation() + }) + + const open = (): void => { + renderList() + menu.hidden = false + document.addEventListener('pointerdown', onOutside, true) + document.addEventListener('keydown', onKey, true) + } + const close = (): void => { + menu.hidden = true + document.removeEventListener('pointerdown', onOutside, true) + document.removeEventListener('keydown', onKey, true) + } + const onOutside = (e: Event): void => { + if (!root.contains(e.target as Node)) close() + } + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') close() + } + btn.addEventListener('click', () => (menu.hidden ? open() : close())) + + const refresh = (): void => { + root.style.display = gridAllowed() ? 'inline-flex' : 'none' + if (!gridAllowed()) close() + } + + const mql = + typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia(`(min-width: ${GRID_MIN_WIDTH}px)`) + : null + mql?.addEventListener('change', refresh) + refresh() + + return { + refresh, + dispose: (): void => { + mql?.removeEventListener('change', refresh) + close() + root.remove() + }, + } +} + +/** Local element helper (kept tiny to avoid a cross-module import cycle). */ +function el( + tag: K, + cls: string, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag) + node.className = cls + if (text !== undefined) node.textContent = text + return node +} diff --git a/public/main.ts b/public/main.ts index 0810ef2..06aa011 100644 --- a/public/main.ts +++ b/public/main.ts @@ -20,6 +20,8 @@ import { mountDashboard } from './dashboard.js' import { mountHistory } from './history.js' import { mountShortcuts } from './shortcuts.js' import { mountShareSession } from './share.js' +import { mountGridToggle, matchFocusCycleKey } from './grid-layout.js' +import { mountGridPresets } from './grid-presets.js' const paneHost = document.getElementById('term') const tabs = document.getElementById('tabs') @@ -50,14 +52,29 @@ mountKeybar((data) => app.sendToActive(data), { onVoiceTrigger: (action) => app.handleVoiceTrigger(action), }) -// Multi-device: when this device regains focus, re-assert the active tab's size -// so a shared session snaps to THIS screen (latest-writer-wins) — full-screen on -// whichever device you're currently using. -window.addEventListener('focus', () => app.refitActive()) +// Multi-device: when this device regains focus, re-assert the size of every +// VISIBLE pane so a shared session snaps to THIS screen (latest-writer-wins) — +// full-screen on whichever device you're using (all quadrants in a split grid). +window.addEventListener('focus', () => app.refitVisible()) document.addEventListener('visibilitychange', () => { - if (!document.hidden) app.refitActive() + if (!document.hidden) app.refitVisible() }) +// v2: Ctrl+` cycles the focused quadrant in a split grid (Ctrl+Shift+` reverses). +// Capture phase so xterm doesn't swallow it; left untouched (no preventDefault) +// in single mode so the terminal keeps the keystroke. +document.addEventListener( + 'keydown', + (e) => { + const dir = matchFocusCycleKey(e) + if (dir === null) return + if (app.getGridLayout() === 'single') return // let the terminal keep the key + e.preventDefault() + app.cycleFocus(dir) + }, + { capture: true }, +) + // Toolbar utilities. mountSearch(toolbar, { find: (query, dir) => app.findInActive(query, dir), @@ -81,6 +98,21 @@ mountHistory(toolbar, { mountShortcuts(toolbar) mountShareSession(toolbar, () => app.activeSessionId()) +// Split-grid layout toggle (desktop-only; hidden below GRID_MIN_WIDTH). The +// control drives app.setGridLayout and is registered back so a programmatic +// layout change (e.g. auto-fallback on window narrow) re-syncs its pressed state. +const gridToggle = mountGridToggle(toolbar, { + getLayout: () => app.getGridLayout(), + setLayout: (layout) => app.setGridLayout(layout), +}) +app.setGridToggle(gridToggle) + +// Saved layout presets (desktop-only, next to the layout toggle). +mountGridPresets(toolbar, { + current: () => app.gridArrangement(), + apply: (preset) => app.applyGridPreset(preset), +}) + // Session management lives on the home Sessions chooser now (open / kill / // new), so the standalone /manage.html page was removed. diff --git a/public/style.css b/public/style.css index 0d2c3cc..9c519a7 100644 --- a/public/style.css +++ b/public/style.css @@ -298,14 +298,396 @@ body { background: var(--bg); overflow: hidden; } -.term-pane { +/* Each pane lives in a .term-cell wrapper (header + terminal + optional inline + * approve). In single mode the cell fills #term absolutely (only the focused one + * is shown) so the classic one-pane look is unchanged; split layouts turn #term + * into a CSS grid and the cells flow into tracks. */ +.term-cell { position: absolute; inset: 0; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; +} +.term-pane { + position: relative; + flex: 1 1 auto; + min-height: 0; padding: 6px 8px 0; box-sizing: border-box; overflow: hidden; } +/* Cell header + inline-approve footer are hidden in single mode. */ +.cell-head, +.cell-approve { + display: none; +} + +/* ── Split-grid layouts (desktop watch board) ────────────────────────── */ +/* Shared cell chrome keys off the single `grid` marker so it is independent of + * how many concrete layouts exist; each `lay-*` only sets the grid template. */ +#term.grid { + display: grid; + gap: 6px; + padding: 6px; +} +#term.lay-split-2 { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr; +} +#term.lay-row-3 { + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 1fr; +} +#term.lay-grid-4 { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; +} +#term.lay-grid-6 { + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 1fr 1fr; +} +#term.grid .term-cell { + position: relative; + inset: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); +} +/* Maximized quadrant fills the whole grid as an ABSOLUTE overlay (taken out of + * grid flow so the siblings keep their placement — spanning grid tracks instead + * would shove auto-placed siblings into implicit rows, resizing their live PTYs). + * #term is a positioned containing block, so inset:0 covers the whole area. */ +#term.grid .term-cell.maximized { + position: absolute; + inset: 0; + z-index: 4; +} +/* A tab dragged from the tab bar is hovering this quadrant. */ +#term.grid .term-cell.drag-target { + outline: 2px dashed var(--accent); + outline-offset: -3px; +} + +/* Draggable splitter handles overlaying the track boundaries (v3). */ +.grid-gutter { + position: absolute; + z-index: 3; + touch-action: none; +} +.grid-gutter::after { + content: ''; + position: absolute; + background: var(--border-strong); + border-radius: 2px; + opacity: 0; + transition: opacity 0.15s; +} +.grid-gutter:hover::after, +.grid-gutter:active::after { + opacity: 1; + background: var(--accent); +} +.grid-gutter-col { + top: 0; + bottom: 0; + width: 12px; + transform: translateX(-50%); + cursor: col-resize; +} +.grid-gutter-col::after { + top: 15%; + bottom: 15%; + left: 50%; + width: 3px; + transform: translateX(-50%); +} +.grid-gutter-row { + left: 0; + right: 0; + height: 12px; + transform: translateY(-50%); + cursor: row-resize; +} +.grid-gutter-row::after { + left: 15%; + right: 15%; + top: 50%; + height: 3px; + transform: translateY(-50%); +} +#term.grid .cell-head { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 9px; + background: linear-gradient(180deg, var(--surface-2), var(--surface-1)); + border-bottom: 1px solid var(--border); + font-size: 12px; + cursor: pointer; + user-select: none; +} +.cell-max { + border: 0; + background: transparent; + color: var(--text-faint); + cursor: pointer; + font-size: 13px; + line-height: 1; + padding: 2px 4px; + border-radius: 5px; +} +.cell-max:hover { + color: var(--text); + background: var(--surface-3); +} +.cell-monitor-btn { + border: 0; + background: transparent; + color: var(--text-faint); + cursor: pointer; + font-size: 12px; + line-height: 1; + padding: 2px 4px; + border-radius: 5px; + opacity: 0.7; +} +.cell-monitor-btn:hover { + color: var(--text); + background: var(--surface-3); + opacity: 1; +} +.cell-monitor-btn.active { + color: var(--accent); + opacity: 1; +} +/* Read-only monitor preview fills the cell below the header. */ +.cell-monitor { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + position: relative; + padding: 6px 8px 0; + box-sizing: border-box; +} +.cell-name { + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.cell-status { + margin-left: auto; + font-size: 11px; + color: var(--text-dim); + white-space: nowrap; +} +.cell-status.cs-working { + color: var(--amber); +} +.cell-status.cs-waiting { + color: var(--accent); +} +.cell-status.cs-idle { + color: var(--green); +} +.cell-status.cs-stuck { + color: var(--red); +} + +/* Focus ring — the quadrant that owns keyboard / keybar / voice / approvals. */ +#term.grid .term-cell.focused { + border-color: var(--accent); + box-shadow: + 0 0 0 1px var(--accent), + var(--shadow); +} +/* Pending glow — a non-focused quadrant is waiting for approval. */ +#term.grid .term-cell.pending:not(.focused) { + border-color: var(--amber); + animation: cell-pending 2.4s ease-in-out infinite; +} +@keyframes cell-pending { + 0%, + 100% { + box-shadow: 0 0 0 1px rgba(245, 177, 76, 0.35); + } + 50% { + box-shadow: + 0 0 0 1px rgba(245, 177, 76, 0.7), + 0 0 26px -8px rgba(245, 177, 76, 0.6); + } +} +@media (prefers-reduced-motion: reduce) { + #term.grid .term-cell.pending { + animation: none; + } +} + +/* Inline per-quadrant approve footer. */ +#term.grid .cell-approve { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 9px; + background: rgba(245, 177, 76, 0.08); + border-top: 1px solid rgba(245, 177, 76, 0.35); + font-size: 11.5px; +} +.cell-approve-label { + color: var(--amber); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.cell-approve-btns { + margin-left: auto; + display: flex; + gap: 6px; +} +.cell-approve-yes, +.cell-approve-no { + border: 0; + border-radius: 6px; + padding: 3px 11px; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} +.cell-approve-yes { + background: var(--green); + color: #06231a; +} +.cell-approve-no { + background: var(--surface-3); + color: var(--text-dim); +} +.cell-approve-yes:hover { + filter: brightness(1.1); +} +.cell-approve-no:hover { + color: var(--text); +} + +/* Empty-slot placeholder (fewer sessions than the layout holds). */ +.term-cell.slot-empty { + border: 1px dashed var(--border-strong); + background: repeating-linear-gradient( + -45deg, + transparent, + transparent 10px, + rgba(255, 255, 255, 0.02) 10px, + rgba(255, 255, 255, 0.02) 20px + ); + align-items: center; + justify-content: center; + cursor: pointer; +} +.term-cell.slot-empty:hover { + border-color: var(--accent); +} +.slot-new { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + background: none; + border: 0; + color: var(--text-faint); + font-size: 12px; + font-family: var(--ui-font); + cursor: pointer; +} +.slot-new b { + font-size: 26px; + font-weight: 300; + line-height: 1; + color: var(--text-dim); +} +.term-cell.slot-empty:hover .slot-new { + color: var(--text-dim); +} + +/* ── Grid-toggle segmented control (toolbar) ─────────────────────────── */ +.grid-toggle { + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: 4px; + padding: 2px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 8px; +} +.grid-toggle-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 26px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-faint); + cursor: pointer; +} +.grid-toggle-btn:hover { + color: var(--text); + background: var(--surface-3); +} +.grid-toggle-btn[aria-pressed='true'] { + color: var(--on-accent); + background: var(--accent); +} +.grid-glyph { + display: grid; + gap: 1.5px; + width: 14px; + height: 12px; +} +.grid-glyph.gl-single { + grid-template-columns: 1fr; +} +.grid-glyph.gl-split-2 { + grid-template-columns: 1fr 1fr; +} +.grid-glyph.gl-row-3 { + grid-template-columns: 1fr 1fr 1fr; +} +.grid-glyph.gl-grid-4 { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; +} +.grid-glyph.gl-grid-6 { + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 1fr 1fr; +} +.grid-glyph i { + background: currentColor; + border-radius: 1px; +} +/* Touch tablets clear the 1024px width gate but need bigger tap targets — mirror + * the project's existing coarse-pointer bump for the tab/key bars. */ +@media (pointer: coarse) { + .grid-toggle { + gap: 3px; + } + .grid-toggle-btn { + width: 36px; + height: 34px; + } + .cell-max, + .cell-monitor-btn { + padding: 6px 9px; + font-size: 15px; + } +} + /* ── Key bar (rounded chips) ─────────────────────────────────────── */ #keybar { position: fixed; @@ -1674,3 +2056,129 @@ body.home-open #term { .push-toggle-btn[data-tip]:hover::after { opacity: 1; } + +/* ── Saved layout presets dropdown (v3) ──────────────────────────────── */ +.grid-presets { + position: relative; + display: inline-flex; + margin-left: 2px; +} +.grid-presets-btn { + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text-dim); + width: 30px; + height: 30px; + border-radius: 8px; + cursor: pointer; + font-size: 13px; +} +.grid-presets-btn:hover { + color: var(--text); + background: var(--surface-3); +} +.grid-presets-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 220px; + background: var(--surface-1); + border: 1px solid var(--border-strong); + border-radius: 10px; + box-shadow: var(--shadow); + padding: 6px; + z-index: 40; +} +.grid-presets-menu[hidden] { + display: none; +} +.gp-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 240px; + overflow-y: auto; +} +.gp-empty { + color: var(--text-faint); + font-size: 12px; + padding: 8px; + text-align: center; +} +.gp-row { + display: flex; + align-items: center; + gap: 4px; +} +.gp-apply { + flex: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border: 0; + background: transparent; + color: var(--text); + padding: 6px 8px; + border-radius: 6px; + cursor: pointer; + font-size: 12.5px; + text-align: left; +} +.gp-apply:hover { + background: var(--surface-2); +} +.gp-apply-layout { + color: var(--text-faint); + font-size: 11px; +} +.gp-del { + border: 0; + background: transparent; + color: var(--text-faint); + width: 22px; + height: 22px; + border-radius: 5px; + cursor: pointer; + font-size: 15px; + line-height: 1; +} +.gp-del:hover { + color: var(--red); + background: var(--surface-2); +} +.gp-save { + display: flex; + gap: 4px; + margin-top: 6px; + border-top: 1px solid var(--border); + padding-top: 6px; +} +.gp-name { + flex: 1; + min-width: 0; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + padding: 5px 8px; + font-size: 12px; + font-family: var(--ui-font); +} +.gp-name:focus { + outline: none; + border-color: var(--accent); +} +.gp-save-btn { + border: 0; + background: var(--accent); + color: var(--on-accent); + border-radius: 6px; + padding: 5px 10px; + cursor: pointer; + font-size: 12px; + font-weight: 600; +} +.gp-save-btn:hover { + filter: brightness(1.05); +} diff --git a/public/tabs.ts b/public/tabs.ts index 80a8db0..fb2890b 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -34,6 +34,23 @@ import { mountTimeline, type TimelineHandle } from './timeline.js' import { createVoiceInput, type VoiceInput } from './voice.js' import { matchCommand, type VoiceMatchContext } from './voice-commands.js' import { createApproveConfirm, type ApproveConfirm } from './voice-confirm.js' +import { + type GridLayout, + type GridToggle, + type GridSplits, + type TrackSplit, + isVisibleIndex, + layoutCapacity, + layoutTracks, + loadGridLayout, + saveGridLayout, + adjustSplit, + tracksToTemplate, + loadGridSplits, + saveGridSplits, + splitForLayout, +} from './grid-layout.js' +import { mountCellMonitor, type CellMonitor } from './cell-monitor.js' const TABS_KEY = 'web-terminal:tabs' const ACTIVE_KEY = 'web-terminal:active' @@ -59,6 +76,14 @@ interface TabEntry { autoTitle: string | null // current folder from the terminal title hasActivity: boolean // inactive tab got output since last viewed el: HTMLDivElement | null // the .tab element (updated in place) + // Split-grid: the .term-cell wrapper around session.el (header + terminal + + // optional inline-approve footer). One per tab; the grid lays these out. + cell: HTMLDivElement | null + // v3: when true, this quadrant shows a read-only polled preview (monitor mode) + // instead of the live pane, so it never drives the shared PTY size. + monitor: boolean + monitorHandle: CellMonitor | null + monitorEl: HTMLElement | null // A4: live timeline handle while this tab's timeline is open (disposed on // switch/close). Telemetry is NOT cached here — refreshTab reads the single // source of truth session.telemetry (review #15). @@ -93,6 +118,19 @@ export class TabApp { private readonly projects: ProjectsPanel private homeView: HomeView = 'sessions' private readonly segControl: HTMLElement + // Split-grid ("watch board") state. Layout persists in localStorage; the + // toolbar toggle (mounted in main.ts) drives setGridLayout and is refreshed + // back via gridToggle. 'single' preserves the original one-pane behavior. + private gridLayout: GridLayout = 'single' + private gridToggle: GridToggle | null = null + // v2: when true, the focused quadrant is expanded to fill the grid (others stay + // live behind it). Follows the focused pane; reset on layout change / tab close. + private maximized = false + // v3: persisted per-layout column/row track fractions (draggable splitters). + private gridSplits: GridSplits = {} + // v3: true while a gutter is being dragged, so a concurrent applyLayout does + // not destroy-and-recreate the handle (which would drop the drag listeners). + private draggingGutter = false // When true, the home chooser is overlaid on top of the open tabs (so you can // jump back to Sessions/Projects without closing anything). Reset whenever a // tab is activated. Irrelevant while no tab is open (home shows regardless). @@ -142,6 +180,11 @@ export class TabApp { this.segControl = this.buildSegControl() this.paneHost.appendChild(this.segControl) + // Split-grid: restore the persisted layout (forced to 'single' on screens + // too narrow for a multi-pane board — see loadGridLayout) + custom splits. + this.gridLayout = loadGridLayout() + this.gridSplits = loadGridSplits() + // v0.7 Walk-away Workbench panels (mounted once; survive tab rebuilds): this.setupPushToggle() // A1 🔔 this.setupQuickReply() // A3 chips above the key bar @@ -247,8 +290,7 @@ export class TabApp { document.body.classList.toggle('home-open', showHome) if (showHome) { - // Overlay home: hide every terminal pane so the chooser is on top. - for (const t of this.tabs) t.session.hide() + // Overlay home: the chooser is on top; applyLayout hides every pane below. this.segControl.style.display = 'flex' this.launcher.setVisible(this.homeView === 'sessions') this.projects.setVisible(this.homeView === 'projects') @@ -257,6 +299,10 @@ export class TabApp { this.launcher.setVisible(false) this.projects.setVisible(false) } + + // Pane/cell visibility, the grid layout class, focus ring and placeholders + // are all owned by applyLayout — home just decides whether panes show at all. + this.applyLayout(showHome) } /** Toggle the home chooser overlay over the open tabs (the ⌂ button). No-op @@ -616,7 +662,14 @@ export class TabApp { sessionId, ...(cwd !== undefined ? { cwd } : {}), ...(initialInput !== undefined ? { initialInput } : {}), - onSessionId: () => this.persist(), + onSessionId: () => { + this.persist() + // v3: a monitor toggled before attach completed (session.id was null, so + // applyLayout's monitor branch fell through to the live pane) engages now + // that the id is available — reconcile instead of waiting for the next + // unrelated structural re-render. + if (entry.monitor && this.isVisible(this.tabs.indexOf(entry))) this.updateHomeView() + }, // onActivity only fires for hidden (inactive) panes (see TerminalSession). onActivity: () => { entry.hasActivity = true @@ -630,13 +683,20 @@ export class TabApp { onClaudeStatus: (status) => { this.refreshTab(entry) this.updateApprovalBar() - // Notify when a background tab needs approval (H2/H4). - if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) { + // Notify only when a tab that needs approval is NOT on screen. In a split + // grid an on-board quadrant is visible and glows amber in place, so an OS + // notification would be redundant — but a pane hidden behind the ⌂ home + // overlay is NOT on screen even if isVisible() (which only knows the + // layout) would say so, so factor in homeForced too. + const onScreen = !this.homeForced && this.isVisible(this.tabs.indexOf(entry)) + if (status === 'waiting' && !onScreen) { this.notify(entry) } }, // B2: telemetry is the single source of truth on the session; just re-render. onTelemetry: () => this.refreshTab(entry), + // Split-grid: clicking anywhere in this pane makes it the focused quadrant. + onFocus: () => this.setFocused(this.tabs.indexOf(entry)), }) entry = { session, @@ -644,9 +704,51 @@ export class TabApp { autoTitle: null, hasActivity: false, el: null, + cell: null, + monitor: false, + monitorHandle: null, + monitorEl: null, timelineHandle: null, } - this.paneHost.appendChild(session.el) + // Wrap the pane in a grid cell (header + terminal + optional inline-approve). + // The header is hidden by CSS in single mode, so the classic one-pane look is + // unchanged; in a grid it labels each quadrant and carries the focus ring. + const cell = document.createElement('div') + cell.className = 'term-cell' + cell.style.display = 'none' + const head = this.buildCellHead() + head.addEventListener('pointerdown', () => this.setFocused(this.tabs.indexOf(entry))) + // v2: ⛶ maximize/restore this quadrant (focuses it first). + const maxBtn = document.createElement('button') + maxBtn.type = 'button' + maxBtn.className = 'cell-max' + maxBtn.title = 'Maximize / restore' + maxBtn.setAttribute('aria-label', 'Maximize or restore this pane') + maxBtn.addEventListener('pointerdown', (e) => e.stopPropagation()) + maxBtn.addEventListener('click', (e) => { + e.stopPropagation() + const idx = this.tabs.indexOf(entry) + if (idx !== this.activeIndex) this.setFocused(idx) + this.toggleMaximize() + }) + // v3: 👁 monitor toggle — read-only preview (no PTY resize) vs live pane. + const monBtn = document.createElement('button') + monBtn.type = 'button' + monBtn.className = 'cell-monitor-btn' + monBtn.textContent = '👁' + monBtn.title = 'Monitor (read-only) / go live' + monBtn.setAttribute('aria-label', 'Toggle read-only monitor for this pane') + monBtn.addEventListener('pointerdown', (e) => e.stopPropagation()) + monBtn.addEventListener('click', (e) => { + e.stopPropagation() + this.toggleMonitor(this.tabs.indexOf(entry)) + }) + head.append(monBtn, maxBtn) + // v2: drag a tab from the tab bar onto this quadrant to assign it here. + this.wireCellDropTarget(cell, () => this.tabs.indexOf(entry)) + cell.append(head, session.el) + entry.cell = cell + this.paneHost.appendChild(cell) this.tabs.push(entry) session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize) session.connect() @@ -697,13 +799,24 @@ export class TabApp { activate(i: number): void { if (i < 0 || i >= this.tabs.length) return + // Board-aware focus: in a split grid the focused pane must be ON the board, + // else keybar/voice/approval would target an invisible (display:none) session + // with no focus ring. Pull an off-board tab onto the last slot first, then + // resolve its new index. (Single mode has no board, so this is a no-op.) + if (this.gridLayout !== 'single' && !this.isVisible(i)) { + const entry = this.tabs[i] + this.moveTab(i, layoutCapacity(this.gridLayout) - 1) + i = entry ? this.tabs.indexOf(entry) : layoutCapacity(this.gridLayout) - 1 + } this.maybeAskNotify() // first switch is a user gesture — request notif permission this.homeForced = false // showing a terminal dismisses any home overlay this.activeIndex = i const entry = this.tabs[i] if (entry) entry.hasActivity = false // viewing clears the unread dot - this.tabs.forEach((t, idx) => (idx === i ? t.session.show() : t.session.hide())) this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild + // Pane show/hide + focus is owned by applyLayout (via updateHomeView): in + // single mode only the active pane shows; in a grid the first-N show and the + // active one is the focused quadrant. this.updateHomeView() // hide the seg control + home panels now a tab is active this.updateApprovalBar() if (this.timelineOpen) this.openTimelineForActive() // A4: follow the active session @@ -712,9 +825,12 @@ export class TabApp { closeTab(i: number): void { if (i < 0 || i >= this.tabs.length) return + this.maximized = false // structural change exits maximize const [entry] = this.tabs.splice(i, 1) entry?.timelineHandle?.dispose() // A4: stop polling for the closed tab - entry?.session.dispose() + if (entry) this.stopMonitor(entry) // v3: stop the monitor poll + preview term + entry?.session.dispose() // removes session.el (the .term-pane) + entry?.cell?.remove() // also drop the grid cell wrapper if (this.editingIndex === i) this.editingIndex = -1 if (this.tabs.length === 0) { // v0.5: closing the last tab returns to the home screen — no auto-blank tab. @@ -824,12 +940,437 @@ export class TabApp { new Notification(`Claude needs you — ${title}`, { body: 'Waiting for approval' }) } + /* ── split-grid (watch board) ────────────────────────────────────── */ + + /** Whether tab `idx` is on-screen (the focused pane in single mode; any of the + * first-N in a grid). Also gates OS-notification suppression. */ + private isVisible(idx: number): boolean { + return isVisibleIndex(idx, this.activeIndex, this.tabs.length, this.gridLayout) + } + + /** Make tab `idx` the focused pane (via the board-aware activate). */ + private setFocused(idx: number): void { + if (idx < 0 || idx >= this.tabs.length) return + if (idx === this.activeIndex) return // already focused — avoid churn + this.activate(idx) // activate() pulls an off-board tab onto the grid first + } + + /** The current split-grid layout (for the toolbar toggle). */ + getGridLayout(): GridLayout { + return this.gridLayout + } + + /** Switch layout (from the toolbar toggle). Persists, keeps the focused pane + * on-board under a smaller capacity, then re-renders. */ + setGridLayout(layout: GridLayout): void { + if (layout === this.gridLayout) { + this.gridToggle?.refresh() + return + } + this.gridLayout = layout + this.maximized = false // a layout change exits maximize + saveGridLayout(layout) + // Re-activate the focused pane under the new layout: activate() is board-aware, + // so if the capacity shrank and the pane is now off-board it gets pulled on; + // otherwise it's just a re-render. With no active tab (home), re-apply the class. + if (this.activeIndex >= 0 && this.activeIndex < this.tabs.length) { + this.activate(this.activeIndex) + } else { + this.updateHomeView() + } + this.gridToggle?.refresh() + } + + /** Register the toolbar toggle so setGridLayout can re-sync its pressed state. */ + setGridToggle(toggle: GridToggle): void { + this.gridToggle = toggle + } + + /** v3: the current layout + its split, for saving as a preset. */ + gridArrangement(): { layout: GridLayout; split: TrackSplit } { + return { layout: this.gridLayout, split: splitForLayout(this.gridSplits, this.gridLayout) } + } + + /** v3: apply a saved preset — its layout plus its custom track fractions. */ + applyGridPreset(preset: { layout: GridLayout; split: TrackSplit }): void { + this.gridSplits = { ...this.gridSplits, [preset.layout]: preset.split } + saveGridSplits(this.gridSplits) + if (preset.layout === this.gridLayout) { + this.updateHomeView() // same layout, new split → just re-apply the template + this.gridToggle?.refresh() + } else { + this.setGridLayout(preset.layout) + } + } + + /** Re-assert every VISIBLE pane's size (latest-writer-wins) when this device + * regains focus — in a grid all visible quadrants reclaim size, not just the + * focused one. */ + refitVisible(): void { + this.tabs.forEach((entry, idx) => { + if (this.isVisible(idx)) entry.session.refit() + }) + } + + /** v2: cycle the focused quadrant among the visible panes (wired to Ctrl+`). */ + cycleFocus(dir: 1 | -1): void { + if (this.gridLayout === 'single') return + const cap = Math.min(this.tabs.length, layoutCapacity(this.gridLayout)) + if (cap <= 1) return + const cur = this.activeIndex >= 0 && this.activeIndex < cap ? this.activeIndex : 0 + this.setFocused((cur + dir + cap) % cap) + } + + /** v2: expand/restore the focused quadrant to fill the grid (others stay live + * behind it via CSS grid-area + z-index). No-op in single mode. */ + toggleMaximize(): void { + if (this.gridLayout === 'single') return + this.maximized = !this.maximized + this.updateHomeView() + } + + /** v3: flip a quadrant between live (interactive) and read-only monitor mode. */ + private toggleMonitor(idx: number): void { + const entry = this.tabs[idx] + if (!entry || this.gridLayout === 'single') return + entry.monitor = !entry.monitor + this.updateHomeView() // applyLayout starts/stops the monitor + } + + /** Begin polling a read-only preview into the cell (idempotent). */ + private startMonitor(entry: TabEntry, cell: HTMLDivElement): void { + if (entry.monitorHandle || entry.session.id === null) return + const host = document.createElement('div') + host.className = 'cell-monitor' + cell.insertBefore(host, entry.session.el) + entry.monitorEl = host + entry.monitorHandle = mountCellMonitor(host, entry.session.id) + } + + /** Stop monitoring and tear down the preview (idempotent). */ + private stopMonitor(entry: TabEntry): void { + if (entry.monitorHandle) { + entry.monitorHandle.dispose() + entry.monitorHandle = null + } + if (entry.monitorEl) { + entry.monitorEl.remove() + entry.monitorEl = null + } + } + + /** v2: make a grid cell a drop target for a tab dragged from the tab bar — + * dropping assigns that tab to this cell's slot (grid only) and focuses it. */ + private wireCellDropTarget(cell: HTMLElement, slotIndex: () => number): void { + cell.addEventListener('dragover', (e) => { + if (this.dragIndex < 0 || this.gridLayout === 'single') return + e.preventDefault() + cell.classList.add('drag-target') + }) + cell.addEventListener('dragleave', () => cell.classList.remove('drag-target')) + cell.addEventListener('drop', (e) => { + cell.classList.remove('drag-target') + if (this.dragIndex < 0 || this.gridLayout === 'single') return + e.preventDefault() + const moved = this.tabs[this.dragIndex] + this.moveTab(this.dragIndex, slotIndex()) + this.dragIndex = -1 + if (moved) this.setFocused(this.tabs.indexOf(moved)) + }) + } + + /** Show/hide panes, set the grid class, order cells, and render placeholders. + * Single source of pane visibility (called via updateHomeView). */ + private applyLayout(showHome: boolean): void { + const layout: GridLayout = showHome ? 'single' : this.gridLayout + this.paneHost.classList.remove( + 'lay-single', + 'lay-split-2', + 'lay-row-3', + 'lay-grid-4', + 'lay-grid-6', + ) + this.paneHost.classList.add(`lay-${layout}`) + // A single 'grid' marker keeps the shared cell CSS (headers, focus ring, + // inline approve, …) independent of how many layouts exist. + this.paneHost.classList.toggle('grid', layout !== 'single') + + // v3: apply custom track fractions (draggable splitters) as inline templates; + // clear them in single mode so #term is not a grid. + if (layout !== 'single') { + const sp = splitForLayout(this.gridSplits, layout) + this.paneHost.style.gridTemplateColumns = tracksToTemplate(sp.cols) + this.paneHost.style.gridTemplateRows = tracksToTemplate(sp.rows) + } else { + this.paneHost.style.gridTemplateColumns = '' + this.paneHost.style.gridTemplateRows = '' + } + + const cap = layoutCapacity(this.gridLayout) + const visibleCount = showHome ? 0 : Math.min(this.tabs.length, cap) + + this.tabs.forEach((entry, idx) => { + const cell = entry.cell + if (!cell) return + const visible = !showHome && this.isVisible(idx) + cell.style.order = String(idx) // grid places visible cells in tab order + if (visible && entry.monitor && entry.session.id !== null && layout !== 'single') { + // Monitor mode: keep the live pane hidden (so it never fits/resizes the + // shared PTY) and show a polled read-only preview instead. + cell.style.display = 'flex' + entry.session.hide() + this.startMonitor(entry, cell) + } else if (visible) { + cell.style.display = 'flex' + this.stopMonitor(entry) + entry.session.show({ focus: idx === this.activeIndex }) + } else { + cell.style.display = 'none' + this.stopMonitor(entry) + entry.session.hide() + } + this.renderCell(entry) + }) + + // Fill empty grid slots (fewer sessions than the layout holds) with a + // "+ New session" placeholder so the board reads as a fixed N-up grid. + const placeholders = + showHome || this.gridLayout === 'single' ? 0 : Math.max(0, cap - visibleCount) + this.renderPlaceholders(placeholders, visibleCount) + this.renderGutters(layout) + } + + /* ── v3: draggable splitters ──────────────────────────────────────── */ + + /** Replace the gutter handles for the current layout (none in single mode). */ + private renderGutters(layout: GridLayout): void { + // Never rebuild handles mid-drag — the dragged element owns the pointer + // listeners and would lose them (existing handles stay valid for this layout). + if (this.draggingGutter) return + this.paneHost.querySelectorAll('.grid-gutter').forEach((g) => g.remove()) + if (layout === 'single') return + const { cols, rows } = layoutTracks(layout) + const sp = splitForLayout(this.gridSplits, layout) + this.placeAxisGutters('col', cols, sp.cols, true) + this.placeAxisGutters('row', rows, sp.rows, true) + } + + /** Create (create=true) or reposition the gutter handles for one axis. */ + private placeAxisGutters( + axis: 'col' | 'row', + count: number, + fractions: number[], + create: boolean, + ): void { + const total = fractions.reduce((a, b) => a + b, 0) || 1 + const existing = create + ? null + : this.paneHost.querySelectorAll(`.grid-gutter-${axis}`) + let acc = 0 + for (let g = 0; g < count - 1; g++) { + acc += fractions[g] ?? 1 + const pct = (acc / total) * 100 + const handle = create ? this.makeGutter(axis, g) : existing?.[g] + if (!handle) continue + if (axis === 'col') handle.style.left = `${pct}%` + else handle.style.top = `${pct}%` + if (create) this.paneHost.appendChild(handle) + } + } + + private makeGutter(axis: 'col' | 'row', gutterIndex: number): HTMLElement { + const g = document.createElement('div') + g.className = `grid-gutter grid-gutter-${axis}` + g.setAttribute('role', 'separator') + g.setAttribute('aria-orientation', axis === 'col' ? 'vertical' : 'horizontal') + g.addEventListener('pointerdown', (e) => this.beginGutterDrag(e, axis, gutterIndex)) + return g + } + + /** Drag a gutter: translate pixel motion into an fr delta and re-template live. */ + private beginGutterDrag(e: PointerEvent, axis: 'col' | 'row', gutterIndex: number): void { + const layout = this.gridLayout + if (layout === 'single') return + e.preventDefault() + e.stopPropagation() + const handle = e.currentTarget as HTMLElement + const rect = this.paneHost.getBoundingClientRect() + const area = axis === 'col' ? rect.width : rect.height + const start = axis === 'col' ? e.clientX : e.clientY + const base = splitForLayout(this.gridSplits, layout) + const startFractions = (axis === 'col' ? base.cols : base.rows).slice() + const total = startFractions.reduce((a, b) => a + b, 0) + this.draggingGutter = true + try { + handle.setPointerCapture(e.pointerId) + } catch { + // setPointerCapture may be unavailable (e.g. jsdom) — drag still works + } + const move = (ev: PointerEvent): void => { + if (area <= 0) return + const pos = axis === 'col' ? ev.clientX : ev.clientY + const delta = ((pos - start) / area) * total + this.setSplit(layout, axis, adjustSplit(startFractions, gutterIndex, delta)) + } + const up = (): void => { + this.draggingGutter = false + handle.removeEventListener('pointermove', move) + handle.removeEventListener('pointerup', up) + handle.removeEventListener('pointercancel', up) + saveGridSplits(this.gridSplits) // persist only on release + } + handle.addEventListener('pointermove', move) + handle.addEventListener('pointerup', up) + handle.addEventListener('pointercancel', up) + } + + /** Apply a new fraction array live (template + gutter positions), no persist. */ + private setSplit(layout: GridLayout, axis: 'col' | 'row', fractions: number[]): void { + const cur = splitForLayout(this.gridSplits, layout) + const next: TrackSplit = + axis === 'col' ? { cols: fractions, rows: cur.rows } : { cols: cur.cols, rows: fractions } + this.gridSplits = { ...this.gridSplits, [layout]: next } + if (axis === 'col') this.paneHost.style.gridTemplateColumns = tracksToTemplate(fractions) + else this.paneHost.style.gridTemplateRows = tracksToTemplate(fractions) + const { cols, rows } = layoutTracks(layout) + this.placeAxisGutters(axis, axis === 'col' ? cols : rows, fractions, false) + } + + /** Replace the placeholder cells with `count` new ones ordered after the panes. */ + private renderPlaceholders(count: number, startOrder: number): void { + this.paneHost.querySelectorAll('.term-cell.slot-empty').forEach((e) => e.remove()) + for (let i = 0; i < count; i++) { + const slot = document.createElement('div') + slot.className = 'term-cell slot-empty' + slot.style.order = String(startOrder + i) + const btn = document.createElement('button') + btn.type = 'button' + btn.className = 'slot-new' + const plus = document.createElement('b') + plus.textContent = '+' + const label = document.createElement('span') + label.textContent = 'New session' + btn.append(plus, label) + btn.addEventListener('click', () => this.newTab()) + slot.appendChild(btn) + this.paneHost.appendChild(slot) + } + } + + /** Build a cell header (name + Claude-status chip). Hidden by CSS in single mode. */ + private buildCellHead(): HTMLDivElement { + const head = document.createElement('div') + head.className = 'cell-head' + const name = document.createElement('span') + name.className = 'cell-name' + const status = document.createElement('span') + status.className = 'cell-status' + head.append(name, status) + return head + } + + /** Sync one cell's header text, focus ring, pending glow, and inline approve. */ + private renderCell(entry: TabEntry): void { + const cell = entry.cell + if (!cell) return + const idx = this.tabs.indexOf(entry) + const focused = idx === this.activeIndex + const grid = this.gridLayout !== 'single' && !this.homeForced && this.tabs.length > 0 + const maximized = grid && this.maximized && focused + cell.classList.toggle('focused', grid && focused) + // While a quadrant is maximized, a covered (non-focused) pane's amber pulse + // would bleed around the overlay — keep it silent until un-maximized. + cell.classList.toggle( + 'pending', + grid && entry.session.pendingApproval && (!this.maximized || focused), + ) + cell.classList.toggle('maximized', maximized) + const maxBtn = cell.querySelector('.cell-max') + if (maxBtn) maxBtn.textContent = maximized ? '⤡' : '⛶' + const monBtn = cell.querySelector('.cell-monitor-btn') + if (monBtn) monBtn.classList.toggle('active', grid && entry.monitor) + + const nameEl = cell.querySelector('.cell-name') + if (nameEl) nameEl.textContent = this.displayTitle(entry, idx) + const chip = cell.querySelector('.cell-status') + if (chip) { + const cs = entry.session.claudeStatus + chip.className = `cell-status cs-${cs}` + const glyph = claudeIcon(cs) + chip.textContent = cs === 'unknown' ? '' : glyph ? `${glyph} ${cs}` : cs + } + this.renderInlineApprove(entry, cell, idx, focused, grid) + } + + /** Inline ✓/✗ footer for a visible NON-focused quadrant with a simple 'tool' + * gate. The focused pane uses the global approval bar (which also handles the + * 3-choice 'plan' gate); a non-focused 'plan' gate just glows amber — click to + * focus and resolve via the bar. */ + private renderInlineApprove( + entry: TabEntry, + cell: HTMLDivElement, + idx: number, + focused: boolean, + grid: boolean, + ): void { + const session = entry.session + const wantInline = + grid && + !focused && + this.isVisible(idx) && + session.pendingApproval && + session.pendingGate === 'tool' + + let footer = cell.querySelector('.cell-approve') + if (!wantInline) { + footer?.remove() + return + } + if (!footer) { + footer = document.createElement('div') + footer.className = 'cell-approve' + footer.addEventListener('pointerdown', (e) => e.stopPropagation()) // don't refocus + const label = document.createElement('span') + label.className = 'cell-approve-label' + const btns = document.createElement('span') + btns.className = 'cell-approve-btns' + const yes = document.createElement('button') + yes.type = 'button' + yes.className = 'cell-approve-yes' + yes.textContent = '✓' + yes.title = 'Approve' + yes.addEventListener('click', () => { + session.approve() + this.updateApprovalBar() + this.renderCell(entry) + }) + const no = document.createElement('button') + no.type = 'button' + no.className = 'cell-approve-no' + no.textContent = '✗' + no.title = 'Reject' + no.addEventListener('click', () => { + session.reject() + this.updateApprovalBar() + this.renderCell(entry) + }) + btns.append(yes, no) + footer.append(label, btns) + cell.appendChild(footer) + } + const label = footer.querySelector('.cell-approve-label') + if (label) label.textContent = session.pendingTool ? `Approve ${session.pendingTool}?` : 'Approve?' + } + /* ── rendering ───────────────────────────────────────────────────── */ /** In-place update of one tab's classes/label/dot/gauge (never destroys DOM). * Single `refreshTab` owner for status dot, stuck badge (A5) and telemetry * gauge (B2), per SP10/M4. */ private refreshTab(entry: TabEntry): void { + // Keep the grid cell (header text, focus ring, pending glow, inline approve) + // in sync too — independent of whether the tab-bar element exists yet. + this.renderCell(entry) const el = entry.el if (!el) return const idx = this.tabs.indexOf(entry) diff --git a/public/terminal-session.ts b/public/terminal-session.ts index d1f28f4..4a2797d 100644 --- a/public/terminal-session.ts +++ b/public/terminal-session.ts @@ -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() }) } diff --git a/test/cell-monitor.test.ts b/test/cell-monitor.test.ts new file mode 100644 index 0000000..10b8687 --- /dev/null +++ b/test/cell-monitor.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +/** + * test/cell-monitor.test.ts — read-only monitor preview for a grid quadrant. + * + * Stubs xterm + preview-grid.fetchPreview so we can assert the poll→write loop and + * the dispose teardown without a real terminal or server. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const terms: FakeTerm[] = [] +class FakeTerm { + cols = 80 + rows = 24 + open = vi.fn() + write = vi.fn((_data: string, cb?: () => void) => cb?.()) + reset = vi.fn() + resize = vi.fn() + dispose = vi.fn() + constructor() { + terms.push(this) + } +} +vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerm })) + +const fetchPreview = vi.fn() +vi.mock('../public/preview-grid.js', () => ({ + fetchPreview, + PREVIEW_CLEAR: '', + PREVIEW_THEME: {}, +})) + +const { mountCellMonitor } = await import('../public/cell-monitor.js') + +beforeEach(() => { + terms.length = 0 + fetchPreview.mockReset() +}) +afterEach(() => vi.restoreAllMocks()) + +describe('cell-monitor', () => { + it('opens a read-only term and writes the fetched preview into it', async () => { + fetchPreview.mockResolvedValue({ id: 's1', cols: 100, rows: 30, data: 'HELLO' }) + const host = document.createElement('div') + const m = mountCellMonitor(host, 's1') + + expect(terms).toHaveLength(1) + expect(terms[0]!.open).toHaveBeenCalledWith(host) + expect(fetchPreview).toHaveBeenCalledWith('s1') + + await vi.waitFor(() => expect(terms[0]!.write).toHaveBeenCalled()) + expect(terms[0]!.resize).toHaveBeenCalledWith(100, 30) + expect(terms[0]!.write.mock.calls[0]![0]).toBe('HELLO') + m.dispose() + }) + + it('dispose() tears down the term', () => { + fetchPreview.mockResolvedValue({ id: 's1', cols: 80, rows: 24, data: 'X' }) + const m = mountCellMonitor(document.createElement('div'), 's1') + m.dispose() + expect(terms[0]!.dispose).toHaveBeenCalled() + }) + + it('a failed preview fetch skips the write (best-effort)', async () => { + fetchPreview.mockResolvedValue(null) + const m = mountCellMonitor(document.createElement('div'), 's1') + await Promise.resolve() + await Promise.resolve() + expect(terms[0]!.write).not.toHaveBeenCalled() + m.dispose() + }) + + it('does not write after dispose (guards a late resolve)', async () => { + let resolve!: (v: unknown) => void + fetchPreview.mockReturnValue(new Promise((r) => (resolve = r))) + const m = mountCellMonitor(document.createElement('div'), 's1') + m.dispose() // dispose BEFORE the fetch resolves + resolve({ id: 's1', cols: 80, rows: 24, data: 'LATE' }) + await Promise.resolve() + await Promise.resolve() + expect(terms[0]!.write).not.toHaveBeenCalled() + }) +}) diff --git a/test/grid-layout.test.ts b/test/grid-layout.test.ts new file mode 100644 index 0000000..55cca2c --- /dev/null +++ b/test/grid-layout.test.ts @@ -0,0 +1,275 @@ +// @vitest-environment jsdom +/** + * test/grid-layout.test.ts — split-grid layout logic + toolbar toggle. + * + * Covers the pure layout math (capacity, visible set, isVisible), the desktop + * matchMedia gate, localStorage persistence (incl. the narrow-screen force-single + * rule), and the segmented toggle control (pressed state, click → setLayout, + * hide-when-too-narrow, and the auto-fallback-to-single on window narrow). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + type GridLayout, + GRID_LAYOUTS, + GRID_MIN_WIDTH, + layoutCapacity, + visibleIndices, + isVisibleIndex, + gridAllowed, + loadGridLayout, + saveGridLayout, + mountGridToggle, + matchFocusCycleKey, + layoutTracks, + defaultSplit, + adjustSplit, + tracksToTemplate, + splitForLayout, + loadGridSplits, + saveGridSplits, +} from '../public/grid-layout.js' + +const KEY = 'web-terminal:grid-layout' + +/** Install a matchMedia stub whose `(min-width: N)` matches iff width >= N. + * Returns a fire() to simulate a viewport resize crossing the threshold. */ +function stubMatchMedia(width: number): { setWidth: (w: number) => void } { + let current = width + const lists: Array<{ q: string; list: { matches: boolean }; cbs: Array<() => void> }> = [] + const parse = (q: string): number => Number(/min-width:\s*(\d+)px/.exec(q)?.[1] ?? '0') + window.matchMedia = ((q: string) => { + const cbs: Array<() => void> = [] + const list = { + get matches() { + return current >= parse(q) + }, + media: q, + addEventListener: (_t: string, cb: () => void) => cbs.push(cb), + removeEventListener: (_t: string, cb: () => void) => { + const i = cbs.indexOf(cb) + if (i >= 0) cbs.splice(i, 1) + }, + addListener: (cb: () => void) => cbs.push(cb), + removeListener: () => {}, + dispatchEvent: () => true, + onchange: null, + } + lists.push({ q, list, cbs }) + return list + }) as unknown as typeof window.matchMedia + return { + setWidth: (w: number) => { + current = w + for (const { cbs } of lists) for (const cb of cbs) cb() + }, + } +} + +describe('grid-layout: pure logic', () => { + it('layoutCapacity maps each layout to its pane count', () => { + expect(layoutCapacity('single')).toBe(1) + expect(layoutCapacity('split-2')).toBe(2) + expect(layoutCapacity('row-3')).toBe(3) + expect(layoutCapacity('grid-4')).toBe(4) + expect(layoutCapacity('grid-6')).toBe(6) + }) + + it('GRID_LAYOUTS is ordered by capacity', () => { + expect(GRID_LAYOUTS).toEqual(['single', 'split-2', 'row-3', 'grid-4', 'grid-6']) + }) + + it('visibleIndices honors the higher-capacity layouts', () => { + expect(visibleIndices(9, 'row-3')).toEqual([0, 1, 2]) + expect(visibleIndices(9, 'grid-6')).toEqual([0, 1, 2, 3, 4, 5]) + expect(visibleIndices(4, 'grid-6')).toEqual([0, 1, 2, 3]) + }) + + it('matchFocusCycleKey recognizes Ctrl+` (fwd) and Ctrl+Shift+` (rev), else null', () => { + const k = (init: KeyboardEventInit): KeyboardEvent => new KeyboardEvent('keydown', init) + expect(matchFocusCycleKey(k({ ctrlKey: true, key: '`' }))).toBe(1) + expect(matchFocusCycleKey(k({ ctrlKey: true, shiftKey: true, key: '`' }))).toBe(-1) + expect(matchFocusCycleKey(k({ ctrlKey: true, code: 'Backquote' }))).toBe(1) + expect(matchFocusCycleKey(k({ key: '`' }))).toBeNull() // no ctrl + expect(matchFocusCycleKey(k({ ctrlKey: true, metaKey: true, key: '`' }))).toBeNull() // meta excluded + expect(matchFocusCycleKey(k({ ctrlKey: true, altKey: true, key: '`' }))).toBeNull() // alt excluded + expect(matchFocusCycleKey(k({ ctrlKey: true, key: 'a' }))).toBeNull() // wrong key + }) + + it('visibleIndices returns the first min(tabCount, capacity) indices', () => { + expect(visibleIndices(0, 'grid-4')).toEqual([]) + expect(visibleIndices(3, 'grid-4')).toEqual([0, 1, 2]) + expect(visibleIndices(9, 'grid-4')).toEqual([0, 1, 2, 3]) + expect(visibleIndices(9, 'split-2')).toEqual([0, 1]) + expect(visibleIndices(9, 'single')).toEqual([0]) + }) + + it('isVisibleIndex: single mode shows only the active pane', () => { + expect(isVisibleIndex(2, 2, 5, 'single')).toBe(true) + expect(isVisibleIndex(0, 2, 5, 'single')).toBe(false) + }) + + it('isVisibleIndex: grid shows the first-N regardless of which is active', () => { + expect(isVisibleIndex(0, 3, 9, 'grid-4')).toBe(true) + expect(isVisibleIndex(3, 3, 9, 'grid-4')).toBe(true) + expect(isVisibleIndex(4, 3, 9, 'grid-4')).toBe(false) // off-board + expect(isVisibleIndex(2, 0, 9, 'split-2')).toBe(false) + }) +}) + +describe('grid-layout: resizable splitters (v3)', () => { + beforeEach(() => localStorage.clear()) + + it('layoutTracks gives each layout its column/row counts', () => { + expect(layoutTracks('single')).toEqual({ cols: 1, rows: 1 }) + expect(layoutTracks('split-2')).toEqual({ cols: 2, rows: 1 }) + expect(layoutTracks('row-3')).toEqual({ cols: 3, rows: 1 }) + expect(layoutTracks('grid-4')).toEqual({ cols: 2, rows: 2 }) + expect(layoutTracks('grid-6')).toEqual({ cols: 3, rows: 2 }) + }) + + it('defaultSplit is equal fractions matching the track counts', () => { + expect(defaultSplit('grid-4')).toEqual({ cols: [1, 1], rows: [1, 1] }) + expect(defaultSplit('grid-6')).toEqual({ cols: [1, 1, 1], rows: [1, 1] }) + }) + + it('adjustSplit trades the delta between adjacent tracks, conserving the total', () => { + expect(adjustSplit([1, 1], 0, 0.5)).toEqual([1.5, 0.5]) + expect(adjustSplit([1, 1, 1], 1, 0.4)).toEqual([1, 1.4, 0.6]) + }) + + it('adjustSplit clamps both tracks to the minimum (no collapse)', () => { + const out = adjustSplit([1, 1], 0, 5, 0.3) // huge delta + expect(out[1]).toBeCloseTo(0.3) + expect(out[0]! + out[1]!).toBeCloseTo(2) // total preserved + expect(Math.min(...out)).toBeGreaterThanOrEqual(0.3) + }) + + it('adjustSplit ignores an out-of-range gutter and never mutates the input', () => { + const input = [1, 1] + expect(adjustSplit(input, 1, 0.5)).toEqual([1, 1]) // gutter 1 has no right neighbor + expect(input).toEqual([1, 1]) // untouched + }) + + it('tracksToTemplate renders an fr template', () => { + expect(tracksToTemplate([1, 1.4])).toBe('1fr 1.4fr') + }) + + it('splitForLayout returns the stored split only when its shape matches', () => { + const good = { 'grid-4': { cols: [1.3, 0.7], rows: [1, 1] } } + expect(splitForLayout(good, 'grid-4')).toEqual({ cols: [1.3, 0.7], rows: [1, 1] }) + // wrong column count for grid-4 → falls back to equal default + expect(splitForLayout({ 'grid-4': { cols: [1, 1, 1], rows: [1, 1] } }, 'grid-4')).toEqual( + defaultSplit('grid-4'), + ) + expect(splitForLayout({}, 'split-2')).toEqual(defaultSplit('split-2')) + }) + + it('splitForLayout falls back to default for a null stored entry (no throw)', () => { + // {"grid-4":null} is valid JSON a corrupt/tampered store could hold. + expect(splitForLayout({ 'grid-4': null } as never, 'grid-4')).toEqual(defaultSplit('grid-4')) + }) + + it('saveGridSplits + loadGridSplits round-trip', () => { + saveGridSplits({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } }) + expect(loadGridSplits()).toEqual({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } }) + }) + + it('loadGridSplits tolerates garbage', () => { + localStorage.setItem('web-terminal:grid-splits', 'not json') + expect(loadGridSplits()).toEqual({}) + }) +}) + +describe('grid-layout: gate + persistence', () => { + beforeEach(() => { + localStorage.clear() + }) + afterEach(() => { + vi.restoreAllMocks() + // @ts-expect-error — reset the stub between tests + delete window.matchMedia + }) + + it('gridAllowed is true at/above GRID_MIN_WIDTH and false below', () => { + stubMatchMedia(GRID_MIN_WIDTH) + expect(gridAllowed()).toBe(true) + stubMatchMedia(GRID_MIN_WIDTH - 1) + expect(gridAllowed()).toBe(false) + }) + + it('saveGridLayout + loadGridLayout round-trips on a wide screen', () => { + stubMatchMedia(1440) + saveGridLayout('grid-4') + expect(localStorage.getItem(KEY)).toBe('grid-4') + expect(loadGridLayout()).toBe('grid-4') + }) + + it('loadGridLayout forces single on a narrow screen (no grid on a phone)', () => { + stubMatchMedia(1440) + saveGridLayout('grid-4') + stubMatchMedia(800) // window shrank / opened on a small device + expect(loadGridLayout()).toBe('single') + }) + + it('loadGridLayout ignores a garbage stored value', () => { + stubMatchMedia(1440) + localStorage.setItem(KEY, 'not-a-layout') + expect(loadGridLayout()).toBe('single') + }) +}) + +describe('grid-layout: mountGridToggle', () => { + let toolbar: HTMLElement + beforeEach(() => { + localStorage.clear() + toolbar = document.createElement('div') + document.body.appendChild(toolbar) + }) + afterEach(() => { + toolbar.remove() + // @ts-expect-error — reset the stub + delete window.matchMedia + }) + + it('renders one button per layout with the current one pressed', () => { + stubMatchMedia(1440) + let layout: GridLayout = 'split-2' + mountGridToggle(toolbar, { getLayout: () => layout, setLayout: (l) => (layout = l) }) + const btns = toolbar.querySelectorAll('.grid-toggle-btn') + expect(btns).toHaveLength(GRID_LAYOUTS.length) + const pressed = [...btns].filter((b) => b.getAttribute('aria-pressed') === 'true') + expect(pressed).toHaveLength(1) + expect(pressed[0]!.dataset.layout).toBe('split-2') + }) + + it('clicking a button calls setLayout with that layout', () => { + stubMatchMedia(1440) + const setLayout = vi.fn() + mountGridToggle(toolbar, { getLayout: () => 'single', setLayout }) + const grid = toolbar.querySelector('.grid-toggle-btn[data-layout="grid-4"]')! + grid.click() + expect(setLayout).toHaveBeenCalledWith('grid-4') + }) + + it('is hidden below the desktop threshold', () => { + stubMatchMedia(800) + mountGridToggle(toolbar, { getLayout: () => 'single', setLayout: vi.fn() }) + const seg = toolbar.querySelector('.grid-toggle')! + expect(seg.style.display).toBe('none') + }) + + it('forces single + hides itself when the window narrows past the threshold', () => { + const mm = stubMatchMedia(1440) + let layout: GridLayout = 'grid-4' + const setLayout = vi.fn((l: GridLayout) => { + layout = l + }) + mountGridToggle(toolbar, { getLayout: () => layout, setLayout }) + const seg = toolbar.querySelector('.grid-toggle')! + expect(seg.style.display).toBe('inline-flex') + mm.setWidth(700) // simulate a resize below the threshold + expect(setLayout).toHaveBeenCalledWith('single') + expect(seg.style.display).toBe('none') + }) +}) diff --git a/test/grid-presets.test.ts b/test/grid-presets.test.ts new file mode 100644 index 0000000..3e84c96 --- /dev/null +++ b/test/grid-presets.test.ts @@ -0,0 +1,133 @@ +// @vitest-environment jsdom +/** + * test/grid-presets.test.ts — saved split-grid layout presets + dropdown. + * + * Covers persistence (round-trip, garbage tolerance, malformed drop) and the + * dropdown behavior: empty state, save current, apply (calls back), delete. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { loadPresets, savePresets, mountGridPresets, type GridPreset } from '../public/grid-presets.js' + +const KEY = 'web-terminal:grid-presets' +const CURRENT: { layout: GridPreset['layout']; split: GridPreset['split'] } = { + layout: 'grid-4', + split: { cols: [1.5, 0.5], rows: [1, 1] }, +} + +beforeEach(() => localStorage.clear()) + +describe('grid-presets: persistence', () => { + it('round-trips presets through localStorage', () => { + const list: GridPreset[] = [{ name: 'wide-left', layout: 'grid-4', split: CURRENT.split }] + savePresets(list) + expect(loadPresets()).toEqual(list) + }) + + it('returns [] for missing or garbage storage', () => { + expect(loadPresets()).toEqual([]) + localStorage.setItem(KEY, 'not json') + expect(loadPresets()).toEqual([]) + }) + + it('drops malformed entries', () => { + localStorage.setItem( + KEY, + JSON.stringify([ + { name: 'ok', layout: 'grid-4', split: { cols: [1, 1], rows: [1, 1] } }, + { name: 'bad-layout', layout: 'nope', split: {} }, + { nope: true }, + ]), + ) + const out = loadPresets() + expect(out).toHaveLength(1) + expect(out[0]!.name).toBe('ok') + }) +}) + +describe('grid-presets: dropdown', () => { + let toolbar: HTMLElement + let apply: ReturnType + const mount = () => { + apply = vi.fn() + return mountGridPresets(toolbar, { current: () => CURRENT, apply }) + } + beforeEach(() => { + toolbar = document.createElement('div') + document.body.replaceChildren(toolbar) + }) + + const openMenu = (): HTMLElement => { + toolbar.querySelector('.grid-presets-btn')!.click() + return toolbar.querySelector('.grid-presets-menu')! + } + + it('shows an empty state when there are no presets', () => { + mount() + const menu = openMenu() + expect(menu.hidden).toBe(false) + expect(menu.querySelector('.gp-empty')).not.toBeNull() + }) + + it('saves the current arrangement under a typed name', () => { + mount() + openMenu() + const input = toolbar.querySelector('.gp-name')! + input.value = 'wide-left' + toolbar.querySelector('.gp-save-btn')!.click() + expect(loadPresets()).toEqual([{ name: 'wide-left', layout: 'grid-4', split: CURRENT.split }]) + expect(toolbar.querySelector('.gp-apply-name')?.textContent).toBe('wide-left') + }) + + it('does not save a blank name', () => { + mount() + openMenu() + toolbar.querySelector('.gp-save-btn')!.click() + expect(loadPresets()).toEqual([]) + }) + + it('applies a preset (calls back) and closes the menu', () => { + savePresets([{ name: 'p1', layout: 'grid-4', split: CURRENT.split }]) + mount() + const menu = openMenu() + toolbar.querySelector('.gp-apply')!.click() + expect(apply).toHaveBeenCalledWith({ name: 'p1', layout: 'grid-4', split: CURRENT.split }) + expect(menu.hidden).toBe(true) + }) + + it('deletes a preset', () => { + savePresets([{ name: 'p1', layout: 'grid-4', split: CURRENT.split }]) + mount() + openMenu() + toolbar.querySelector('.gp-del')!.click() + expect(loadPresets()).toEqual([]) + expect(toolbar.querySelector('.gp-empty')).not.toBeNull() + }) + + it('closes on an outside pointerdown', () => { + mount() + const menu = openMenu() + expect(menu.hidden).toBe(false) + document.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })) + expect(menu.hidden).toBe(true) + }) + + it('closes on Escape', () => { + mount() + const menu = openMenu() + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(menu.hidden).toBe(true) + }) + + it('replaces a same-named preset instead of duplicating', () => { + savePresets([{ name: 'dup', layout: 'split-2', split: { cols: [1, 1], rows: [1] } }]) + mount() + openMenu() + const input = toolbar.querySelector('.gp-name')! + input.value = 'dup' + toolbar.querySelector('.gp-save-btn')!.click() + const out = loadPresets() + expect(out).toHaveLength(1) + expect(out[0]!.layout).toBe('grid-4') // overwritten with the current arrangement + }) +}) diff --git a/test/tabs.test.ts b/test/tabs.test.ts index 712ba04..bcf1dec 100644 --- a/test/tabs.test.ts +++ b/test/tabs.test.ts @@ -74,6 +74,15 @@ vi.mock('../public/terminal-session.js', () => ({ TerminalSession: FakeTerminalS const renderTelemetryGauge = vi.fn() vi.mock('../public/preview-grid.js', () => ({ renderTelemetryGauge })) +// ── Mock the v3 cell-monitor (real one pulls in xterm; we assert wiring only) ── +const monitorHandles: Array<{ dispose: ReturnType }> = [] +const mountCellMonitor = vi.fn((_host: HTMLElement, _id: string) => { + const handle = { dispose: vi.fn() } + monitorHandles.push(handle) + return handle +}) +vi.mock('../public/cell-monitor.js', () => ({ mountCellMonitor })) + const mountPushToggle = vi.fn() vi.mock('../public/push.js', () => ({ mountPushToggle })) @@ -157,6 +166,8 @@ beforeEach(() => { document.body.replaceChildren() localStorage.clear() renderTelemetryGauge.mockClear() + mountCellMonitor.mockClear() + monitorHandles.length = 0 mountPushToggle.mockClear() mountQuickReply.mockClear() mountTimeline.mockClear() @@ -1032,3 +1043,524 @@ describe('TabApp — VC voice command mapping (context-gated confirm/reject)', ( expect(session.approve).toHaveBeenCalledTimes(1) }) }) + +describe('TabApp — split-grid watch board (v1)', () => { + /** Open `n` tabs and return the TabApp + its paneHost/tabBar. */ + function withTabs(n: number): { + app: InstanceType + paneHost: HTMLElement + tabBar: HTMLElement + } { + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + for (let i = 0; i < n; i++) app.newTab() + return { app, paneHost, tabBar } + } + const realCells = (host: HTMLElement): HTMLElement[] => + [...host.querySelectorAll('.term-cell:not(.slot-empty)')] + const visibleReal = (host: HTMLElement): HTMLElement[] => + realCells(host).filter((c) => c.style.display !== 'none') + + it('defaults to single layout — one visible cell, no grid class, no placeholders', () => { + const { paneHost } = withTabs(3) + expect(paneHost.classList.contains('lay-single')).toBe(true) + expect(visibleReal(paneHost)).toHaveLength(1) + expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(0) + }) + + it('setGridLayout("grid-4") applies the class, persists, and getGridLayout reflects it', () => { + const { app, paneHost } = withTabs(2) + app.setGridLayout('grid-4') + expect(app.getGridLayout()).toBe('grid-4') + expect(paneHost.classList.contains('lay-grid-4')).toBe(true) + expect(localStorage.getItem('web-terminal:grid-layout')).toBe('grid-4') + }) + + it('grid-4 with 2 tabs shows 2 live cells + 2 empty placeholders', () => { + const { app, paneHost } = withTabs(2) + app.setGridLayout('grid-4') + expect(visibleReal(paneHost)).toHaveLength(2) + expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(2) + }) + + it('grid-4 with 5 tabs shows exactly the first 4, none as placeholders', () => { + const { app, paneHost } = withTabs(5) + app.focusTab(0) + app.setGridLayout('grid-4') + expect(visibleReal(paneHost)).toHaveLength(4) + expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(0) + }) + + it('exactly one visible cell carries the focus ring', () => { + const { app, paneHost } = withTabs(3) + app.setGridLayout('grid-4') + expect(paneHost.querySelectorAll('.term-cell.focused')).toHaveLength(1) + }) + + it('clicking a quadrant (onFocus) moves the focus ring to it', () => { + const { app, paneHost } = withTabs(3) + app.focusTab(0) + app.setGridLayout('grid-4') + // Simulate a pointerdown inside tab 2's pane. + const inst2 = FakeTerminalSession.instances[2]! + ;(inst2.cbs as { onFocus?: () => void }).onFocus?.() + const focused = paneHost.querySelector('.term-cell.focused .cell-name') + expect(focused?.textContent).toBe('Term 3') + }) + + it('a non-focused pending tool-gate quadrant shows inline ✓/✗ that resolve it', () => { + const { app, paneHost } = withTabs(2) + app.focusTab(1) // focus tab 1; tab 0 is the background quadrant + app.setGridLayout('grid-4') + const bg = FakeTerminalSession.instances[0]! + bg.pendingApproval = true + bg.pendingGate = 'tool' + bg.pendingTool = 'Bash' + bg.claudeStatus = 'waiting' + bg.cbs.onClaudeStatus?.('waiting') + + const cell0 = realCells(paneHost)[0]! + const approve = cell0.querySelector('.cell-approve-yes') + const reject = cell0.querySelector('.cell-approve-no') + expect(approve).not.toBeNull() + approve!.click() + expect(bg.approve).toHaveBeenCalledTimes(1) + reject!.click() + expect(bg.reject).toHaveBeenCalledTimes(1) + }) + + it('the FOCUSED pending pane gets no inline footer (it uses the global bar)', () => { + const { app, paneHost } = withTabs(2) + app.setGridLayout('grid-4') // activeIndex = 1 (last opened) + const focused = FakeTerminalSession.instances[1]! + focused.pendingApproval = true + focused.pendingGate = 'tool' + focused.claudeStatus = 'waiting' + focused.cbs.onClaudeStatus?.('waiting') + const cell1 = realCells(paneHost)[1]! + expect(cell1.querySelector('.cell-approve')).toBeNull() + }) + + it('suppresses the OS notification for an on-board (visible) pending quadrant', () => { + const NotificationMock = vi.fn() + ;(NotificationMock as unknown as { permission: string }).permission = 'granted' + vi.stubGlobal('Notification', NotificationMock) + const { app } = withTabs(2) + app.focusTab(1) + app.setGridLayout('grid-4') + const bg = FakeTerminalSession.instances[0]! // visible, non-focused + bg.claudeStatus = 'waiting' + bg.cbs.onClaudeStatus?.('waiting') + expect(NotificationMock).not.toHaveBeenCalled() + vi.unstubAllGlobals() + }) + + it('still notifies for an OFF-board tab that needs approval', () => { + const NotificationMock = vi.fn() + ;(NotificationMock as unknown as { permission: string }).permission = 'granted' + vi.stubGlobal('Notification', NotificationMock) + const { app } = withTabs(5) + app.focusTab(0) // keep order; tab 4 stays off-board + app.setGridLayout('grid-4') + const off = FakeTerminalSession.instances[4]! // idx 4 — off the 2×2 board + off.claudeStatus = 'waiting' + off.cbs.onClaudeStatus?.('waiting') + expect(NotificationMock).toHaveBeenCalled() + vi.unstubAllGlobals() + }) + + it('a placeholder "+ New session" opens another tab', () => { + const { app, paneHost } = withTabs(2) + app.setGridLayout('grid-4') + expect(app.snapshot()).toHaveLength(2) + const slot = paneHost.querySelector('.slot-empty .slot-new')! + slot.click() + expect(app.snapshot()).toHaveLength(3) + }) + + it('closing the focused quadrant removes its cell wrapper', () => { + const { app, paneHost } = withTabs(3) + app.setGridLayout('grid-4') + expect(realCells(paneHost)).toHaveLength(3) + app.closeTab(app.snapshot().findIndex((t) => t.active)) + expect(realCells(paneHost)).toHaveLength(2) + }) + + it('narrowing back to single restores one visible cell and drops placeholders', () => { + const { app, paneHost } = withTabs(2) + app.setGridLayout('grid-4') + expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(2) + app.setGridLayout('single') + expect(paneHost.classList.contains('lay-single')).toBe(true) + expect(visibleReal(paneHost)).toHaveLength(1) + expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(0) + }) + + // ── Board-aware focus (regression: activate() must never focus a hidden pane) ── + + it('opening a new tab while the grid board is full keeps the focused pane visible', () => { + const { app, paneHost } = withTabs(4) + app.focusTab(0) + app.setGridLayout('grid-4') // board full (4/4) + app.newTab() // 5th tab — must be pulled onto the board, not left hidden + const focused = paneHost.querySelector('.term-cell.focused') + expect(focused).not.toBeNull() + expect(focused!.style.display).not.toBe('none') + expect(paneHost.querySelectorAll('.term-cell.focused')).toHaveLength(1) + }) + + it('clicking an off-board tab in the tab bar pulls it onto the board and focuses it', () => { + const { app, tabBar } = withTabs(5) + app.focusTab(0) + app.setGridLayout('grid-4') // tab 4 is off the 2×2 board + const offSession = FakeTerminalSession.instances[4]! // identity survives reordering + tabBar.querySelectorAll('.tab')[4]!.dispatchEvent( + new Event('pointerdown', { bubbles: true }), + ) + const cell = offSession.el.closest('.term-cell')! + expect(cell.classList.contains('focused')).toBe(true) + expect(cell.style.display).not.toBe('none') + }) + + it('shrinking the layout moves an off-board active pane back onto the board', () => { + const { app } = withTabs(4) + app.focusTab(3) // active index 3 (the 4th session) + const active = FakeTerminalSession.instances[3]! + app.setGridLayout('split-2') // cap 2 → index 3 is now off-board + expect(app.getGridLayout()).toBe('split-2') + const cell = active.el.closest('.term-cell')! + expect(cell.classList.contains('focused')).toBe(true) + expect(cell.style.display).not.toBe('none') + }) + + // ── refitVisible / show(focus) / home overlay ──────────────────────────────── + + it('refitVisible refits only the on-board panes', () => { + const { app } = withTabs(5) + app.focusTab(0) + app.setGridLayout('grid-4') // tabs 0-3 visible, tab 4 off-board + FakeTerminalSession.instances.forEach((i) => i.refit.mockClear()) + app.refitVisible() + for (let i = 0; i < 4; i++) expect(FakeTerminalSession.instances[i]!.refit).toHaveBeenCalled() + expect(FakeTerminalSession.instances[4]!.refit).not.toHaveBeenCalled() + }) + + it('only the focused quadrant is shown with keyboard focus (focus: false for the rest)', () => { + const { app } = withTabs(3) + app.setGridLayout('grid-4') // active = tab 2 (last opened) + expect(FakeTerminalSession.instances[2]!.show.mock.lastCall).toEqual([{ focus: true }]) + expect(FakeTerminalSession.instances[0]!.show.mock.lastCall).toEqual([{ focus: false }]) + expect(FakeTerminalSession.instances[1]!.show.mock.lastCall).toEqual([{ focus: false }]) + }) + + it('the ⌂ home overlay hides every grid pane, then restores the board on toggle back', () => { + const { app, paneHost, tabBar } = withTabs(3) + app.setGridLayout('grid-4') + expect(visibleReal(paneHost).length).toBeGreaterThan(0) + const home = tabBar.querySelector('.tab-home')! + home.click() // overlay home + expect(visibleReal(paneHost)).toHaveLength(0) + expect(paneHost.querySelectorAll('.slot-empty')).toHaveLength(0) + home.click() // back to the terminals + expect(visibleReal(paneHost)).toHaveLength(3) + expect(paneHost.querySelectorAll('.term-cell.focused')).toHaveLength(1) + }) + + it('does NOT suppress the notification for a pending pane hidden behind the home overlay', () => { + const NotificationMock = vi.fn() + ;(NotificationMock as unknown as { permission: string }).permission = 'granted' + vi.stubGlobal('Notification', NotificationMock) + const { app, tabBar } = withTabs(2) + app.setGridLayout('grid-4') + tabBar.querySelector('.tab-home')!.click() // home overlay up + const bg = FakeTerminalSession.instances[0]! // on-board by layout, but behind home + bg.claudeStatus = 'waiting' + bg.cbs.onClaudeStatus?.('waiting') + expect(NotificationMock).toHaveBeenCalled() + vi.unstubAllGlobals() + }) + + // ── v2: higher-capacity layouts, focus cycling, maximize, drag-to-quadrant ──── + + it('grid-6 shows up to six live panes', () => { + const { app, paneHost } = withTabs(6) + app.focusTab(0) + app.setGridLayout('grid-6') + expect(visibleReal(paneHost)).toHaveLength(6) + expect(paneHost.classList.contains('lay-grid-6')).toBe(true) + expect(paneHost.classList.contains('grid')).toBe(true) + }) + + it('row-3 shows exactly three panes', () => { + const { app, paneHost } = withTabs(5) + app.focusTab(0) + app.setGridLayout('row-3') + expect(visibleReal(paneHost)).toHaveLength(3) + }) + + it('cycleFocus moves the focus ring around the visible panes and wraps', () => { + const { app } = withTabs(3) + app.focusTab(0) + app.setGridLayout('grid-4') // 3 visible, active 0 + app.cycleFocus(1) + expect(app.snapshot().findIndex((t) => t.active)).toBe(1) + app.cycleFocus(1) + expect(app.snapshot().findIndex((t) => t.active)).toBe(2) + app.cycleFocus(1) // wrap + expect(app.snapshot().findIndex((t) => t.active)).toBe(0) + app.cycleFocus(-1) // reverse wrap + expect(app.snapshot().findIndex((t) => t.active)).toBe(2) + }) + + it('cycleFocus is a no-op in single mode', () => { + const { app } = withTabs(3) // single mode, active 2 + app.cycleFocus(1) + expect(app.snapshot().findIndex((t) => t.active)).toBe(2) + }) + + it('toggleMaximize marks the focused cell and toggling off clears it', () => { + const { app, paneHost } = withTabs(3) + app.setGridLayout('grid-4') + app.toggleMaximize() + expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(1) + expect(paneHost.querySelector('.term-cell.maximized')!.classList.contains('focused')).toBe(true) + app.toggleMaximize() + expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(0) + }) + + it('maximize follows the focused pane as you cycle', () => { + const { app, paneHost } = withTabs(3) + app.focusTab(0) + app.setGridLayout('grid-4') + app.toggleMaximize() + const first = FakeTerminalSession.instances[0]!.el.closest('.term-cell') + expect(first!.classList.contains('maximized')).toBe(true) + app.cycleFocus(1) // now tab 1 is focused + expect(first!.classList.contains('maximized')).toBe(false) + expect(FakeTerminalSession.instances[1]!.el.closest('.term-cell')!.classList.contains('maximized')).toBe(true) + }) + + it('changing layout exits maximize', () => { + const { app, paneHost } = withTabs(3) + app.setGridLayout('grid-4') + app.toggleMaximize() + expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(1) + app.setGridLayout('split-2') + expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(0) + }) + + it('dragging an off-board tab onto a quadrant assigns it there and focuses it', () => { + const { app, paneHost, tabBar } = withTabs(5) + app.focusTab(0) + app.setGridLayout('grid-4') // tab 4 off-board + const dragged = FakeTerminalSession.instances[4]! + const targetCell = FakeTerminalSession.instances[0]!.el.closest('.term-cell')! + // Start dragging tab 4 from the tab bar (sets dragIndex), drop on quadrant 0. + tabBar.querySelectorAll('.tab')[4]!.dispatchEvent( + new Event('dragstart', { bubbles: true }), + ) + targetCell.dispatchEvent(new Event('dragover', { bubbles: true })) + targetCell.dispatchEvent(new Event('drop', { bubbles: true })) + const cell = dragged.el.closest('.term-cell')! + expect(cell.style.display).not.toBe('none') // now on-board + expect(cell.classList.contains('focused')).toBe(true) // and focused + }) + + it('closing a tab while a quadrant is maximized clears the maximized state', () => { + const { app, paneHost } = withTabs(3) + app.focusTab(0) + app.setGridLayout('grid-4') + app.toggleMaximize() + expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(1) + app.closeTab(1) // close a different (non-focused) tab + expect(paneHost.querySelectorAll('.term-cell.maximized')).toHaveLength(0) + }) + + it('drag-to-quadrant is a no-op in single mode (no reorder, no drag-target)', () => { + const { app, paneHost, tabBar } = withTabs(3) // default single layout + const before = app.snapshot().map((t) => t.title) + tabBar.querySelectorAll('.tab')[1]!.dispatchEvent( + new Event('dragstart', { bubbles: true }), + ) + const cell = FakeTerminalSession.instances[0]!.el.closest('.term-cell')! + cell.dispatchEvent(new Event('dragover', { bubbles: true })) + cell.dispatchEvent(new Event('drop', { bubbles: true })) + expect(cell.classList.contains('drag-target')).toBe(false) + expect(app.snapshot().map((t) => t.title)).toEqual(before) // order unchanged + }) + + it('the ⛶ button focuses its quadrant and maximizes it in one click', () => { + const { app } = withTabs(3) + app.focusTab(0) + app.setGridLayout('grid-4') + const cell2 = FakeTerminalSession.instances[2]!.el.closest('.term-cell')! + const maxBtn = cell2.querySelector('.cell-max')! + expect(maxBtn.textContent).toBe('⛶') + maxBtn.click() + expect(cell2.classList.contains('focused')).toBe(true) // focused the clicked quadrant + expect(cell2.classList.contains('maximized')).toBe(true) // and maximized it + expect(maxBtn.textContent).toBe('⤡') // glyph flips to restore + }) + + // ── v3: read-only monitor quadrants ────────────────────────────────────────── + + /** Open n tabs, give each a server id (as if attached), and return the app. */ + function withAttachedTabs(n: number): { + app: InstanceType + paneHost: HTMLElement + tabBar: HTMLElement + } { + const hosts = withTabs(n) + FakeTerminalSession.instances.forEach((inst, i) => (inst.id = `sess-${i}`)) + return hosts + } + const monBtnOf = (paneHost: HTMLElement, i: number): HTMLButtonElement => + FakeTerminalSession.instances[i]!.el.closest('.term-cell')!.querySelector('.cell-monitor-btn')! + + it('the 👁 button switches a quadrant to a read-only monitor preview', () => { + const { app, paneHost } = withAttachedTabs(3) + app.focusTab(0) + app.setGridLayout('grid-4') + const cell1 = FakeTerminalSession.instances[1]!.el.closest('.term-cell')! + monBtnOf(paneHost, 1).click() + expect(mountCellMonitor).toHaveBeenCalled() + expect(mountCellMonitor.mock.lastCall?.[1]).toBe('sess-1') // polls that session + expect(cell1.querySelector('.cell-monitor')).not.toBeNull() + expect(monBtnOf(paneHost, 1).classList.contains('active')).toBe(true) + }) + + it('toggling monitor off disposes the preview and restores the live pane', () => { + const { app, paneHost } = withAttachedTabs(3) + app.focusTab(0) + app.setGridLayout('grid-4') + monBtnOf(paneHost, 1).click() // on + expect(monitorHandles).toHaveLength(1) + monBtnOf(paneHost, 1).click() // off + expect(monitorHandles[0]!.dispose).toHaveBeenCalled() + const cell1 = FakeTerminalSession.instances[1]!.el.closest('.term-cell')! + expect(cell1.querySelector('.cell-monitor')).toBeNull() + expect(monBtnOf(paneHost, 1).classList.contains('active')).toBe(false) + }) + + it('closing a monitored quadrant disposes its monitor', () => { + const { app, paneHost } = withAttachedTabs(3) + app.focusTab(0) + app.setGridLayout('grid-4') + monBtnOf(paneHost, 1).click() + app.closeTab(1) + expect(monitorHandles[0]!.dispose).toHaveBeenCalled() + }) + + it('leaving grid mode stops any monitor', () => { + const { app, paneHost } = withAttachedTabs(3) + app.focusTab(0) + app.setGridLayout('grid-4') + monBtnOf(paneHost, 1).click() + expect(monitorHandles).toHaveLength(1) + app.setGridLayout('single') + expect(monitorHandles[0]!.dispose).toHaveBeenCalled() + }) + + // ── v3: resizable splitters ────────────────────────────────────────────────── + + it('grid-4 renders one column + one row splitter and sets an fr template', () => { + const { app, paneHost } = withTabs(4) + app.focusTab(0) + app.setGridLayout('grid-4') + expect(paneHost.querySelectorAll('.grid-gutter-col')).toHaveLength(1) + expect(paneHost.querySelectorAll('.grid-gutter-row')).toHaveLength(1) + expect(paneHost.style.gridTemplateColumns).toBe('1fr 1fr') + expect(paneHost.style.gridTemplateRows).toBe('1fr 1fr') + }) + + it('grid-6 renders two column + one row splitters', () => { + const { app, paneHost } = withTabs(6) + app.focusTab(0) + app.setGridLayout('grid-6') + expect(paneHost.querySelectorAll('.grid-gutter-col')).toHaveLength(2) + expect(paneHost.querySelectorAll('.grid-gutter-row')).toHaveLength(1) + }) + + it('single mode has no splitters and clears the inline grid template', () => { + const { app, paneHost } = withTabs(3) + app.setGridLayout('grid-4') + app.setGridLayout('single') + expect(paneHost.querySelectorAll('.grid-gutter')).toHaveLength(0) + expect(paneHost.style.gridTemplateColumns).toBe('') + }) + + it('a persisted custom split is applied to the grid template on load', () => { + localStorage.setItem( + 'web-terminal:grid-splits', + JSON.stringify({ 'grid-4': { cols: [1.5, 0.5], rows: [1, 1] } }), + ) + const { paneHost, tabBar } = makeHosts() + const app = new TabApp(paneHost, tabBar) + app.newTab() + app.newTab() + app.setGridLayout('grid-4') + expect(paneHost.style.gridTemplateColumns).toBe('1.5fr 0.5fr') + }) + + it('dragging a column splitter re-templates the grid and persists on release', () => { + const { app, paneHost } = withTabs(4) + app.focusTab(0) + app.setGridLayout('grid-4') + // jsdom has no layout — give #term a measurable width for the drag math. + vi.spyOn(paneHost, 'getBoundingClientRect').mockReturnValue({ + width: 800, + height: 600, + left: 0, + top: 0, + right: 800, + bottom: 600, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect) + const gutter = paneHost.querySelector('.grid-gutter-col')! + gutter.dispatchEvent(new MouseEvent('pointerdown', { clientX: 400, bubbles: true })) + gutter.dispatchEvent(new MouseEvent('pointermove', { clientX: 480, bubbles: true })) // +0.2fr + gutter.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })) + expect(paneHost.style.gridTemplateColumns).toBe('1.2fr 0.8fr') + const saved = JSON.parse(localStorage.getItem('web-terminal:grid-splits')!) + expect(saved['grid-4'].cols[0]).toBeCloseTo(1.2) + }) + + it('a re-render during a gutter drag does not destroy the dragged handle', () => { + const { app, paneHost } = withTabs(4) + app.focusTab(0) + app.setGridLayout('grid-4') + vi.spyOn(paneHost, 'getBoundingClientRect').mockReturnValue({ + width: 800, + height: 600, + left: 0, + top: 0, + right: 800, + bottom: 600, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect) + const gutter = paneHost.querySelector('.grid-gutter-col')! + gutter.dispatchEvent(new MouseEvent('pointerdown', { clientX: 400, bubbles: true })) + app.toggleMaximize() // triggers applyLayout → renderGutters (must skip while dragging) + expect(paneHost.contains(gutter)).toBe(true) // same handle survives + gutter.dispatchEvent(new MouseEvent('pointermove', { clientX: 480, bubbles: true })) + gutter.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })) + expect(paneHost.style.gridTemplateColumns).toBe('1.2fr 0.8fr') + }) + + it('a monitor toggled before attach engages once the session id arrives', () => { + const { app } = withTabs(3) // fresh sessions — ids are null until attached + app.focusTab(0) + app.setGridLayout('grid-4') + const inst = FakeTerminalSession.instances[1]! + inst.el.closest('.term-cell')!.querySelector('.cell-monitor-btn')!.click() + expect(mountCellMonitor).not.toHaveBeenCalled() // id still null → not engaged + inst.id = 'sess-1' // simulate 'attached' + ;(inst.cbs as { onSessionId?: (id: string) => void }).onSessionId?.('sess-1') + expect(mountCellMonitor).toHaveBeenCalled() + expect(mountCellMonitor.mock.lastCall?.[1]).toBe('sess-1') + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 6a1ee82..f55beaf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -24,6 +24,9 @@ export default defineConfig({ 'src/**/*.ts', 'public/terminal-session.ts', 'public/tabs.ts', + 'public/grid-layout.ts', + 'public/grid-presets.ts', + 'public/cell-monitor.ts', 'public/preview-grid.ts', 'public/title-util.ts', 'public/voice-commands.ts',