Merge feat/split-grid-view: desktop split-grid watch board (v1–v3)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled

Multi-session 2×2/1×2/1×3/2×3 grid for the web/Mac client so several LIVE
terminals show at once. activeIndex stays the focused pane (keybar/voice/approval
unchanged); server + WS protocol untouched; single-pane mode unchanged.

v1  single/1×2/2×2, click-to-focus, per-quadrant inline ✓/✗, desktop gate + persist
v2  1×3/2×3, Ctrl+` focus cycle, per-quadrant maximize, drag-a-tab-to-quadrant
v3  read-only monitor quadrants (no shared-PTY shrink), resizable splitters, presets

Each phase: TDD → adversarial multi-lens review + per-finding verify → fixes.
Verified: typecheck + build:web clean, 1615 tests pass, coverage 89%/82%; the
maximize geometry fix and a full 9-flow live QA verified in real headless Chrome.
This commit is contained in:
Yaojia Wang
2026-07-12 04:54:59 +02:00
13 changed files with 2765 additions and 17 deletions

View File

@@ -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)**:全部阶段(v1v2v3)用多 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-boardmoveTab 上板逻辑折进 `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)**:每格 👁 切换 liveread-only`public/cell-monitor.ts` 轮询 `GET /live-sessions/:id/preview` 写只读 xterm,** attach WS不发 resize**,故不驱动共享 PTY 尺寸——解决"小格盯 session 会缩掉别的设备全屏"的跨设备 shrinkmonitor 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复验commitmaximize HIGH 几何缺陷经** headless Chrome(Playwright)实测**证实修复服务端/WS 协议零改动;单格模式行为不变
- **探索产物**: 交互式原型 artifact(single/1×2/2×2 切换点击移焦内联审批)已给用户看过并据此拍板方向
### 🔐 TUNNEL AUTOMATION — 零接触隧道注入(客户永不碰证书/密钥;2026-07-08) ### 🔐 TUNNEL AUTOMATION — 零接触隧道注入(客户永不碰证书/密钥;2026-07-08)
- **计划**: [PLAN_TUNNEL_AUTOMATION.md](./PLAN_TUNNEL_AUTOMATION.md)(design-locked)。目标:host 一条命令 onboarddevice 登录一次即在硬件里生成不可导出密钥CSR拿证书,** .p12/AirDrop,私钥永不离设备**。三轨:A 控制面/PKIB host agentC 原生客户端(iOS/Android/desktop)。基础设施(frps/device-CA/frp-client-CA/nginx:8470 mTLS)已在 VPS M1 上线;本工作流建的是**自动化**(签发端点njs certHost 绑定硬件 keygenhost onboard)。 - **计划**: [PLAN_TUNNEL_AUTOMATION.md](./PLAN_TUNNEL_AUTOMATION.md)(design-locked)。目标:host 一条命令 onboarddevice 登录一次即在硬件里生成不可导出密钥CSR拿证书,** .p12/AirDrop,私钥永不离设备**。三轨:A 控制面/PKIB host agentC 原生客户端(iOS/Android/desktop)。基础设施(frps/device-CA/frp-client-CA/nginx:8470 mTLS)已在 VPS M1 上线;本工作流建的是**自动化**(签发端点njs certHost 绑定硬件 keygenhost onboard)。
- **分支**: `feat/tunnel-automation`( develop)。 **MVP fast-path §7**(10 tracked task,见任务表 #1#10),依赖图强制:A1 crypto A2 契约 A3 nginx 绑定为顺序地基,之后按职责(backend/host/iOS)扇出 - **分支**: `feat/tunnel-automation`( develop)。 **MVP fast-path §7**(10 tracked task,见任务表 #1#10),依赖图强制:A1 crypto A2 契约 A3 nginx 绑定为顺序地基,之后按职责(backend/host/iOS)扇出

82
public/cell-monitor.ts Normal file
View File

@@ -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<typeof setTimeout> | null = null
const poll = async (): Promise<void> => {
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()
},
}
}

314
public/grid-layout.ts Normal file
View File

@@ -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<Record<GridLayout, number>> = {
single: 1,
'split-2': 2,
'row-3': 3,
'grid-4': 4,
'grid-6': 6,
}
/** How many panes a layout shows at once. */
export function layoutCapacity(layout: GridLayout): number {
return CAPACITY[layout] ?? 1
}
/**
* The tab indices visible under `layout`: the first `min(tabCount, capacity)` in
* tab order. Membership is controlled by reordering tabs (v1) — drag a tab into
* the first N and it joins the board.
*/
export function visibleIndices(tabCount: number, layout: GridLayout): number[] {
const n = Math.min(Math.max(0, tabCount), layoutCapacity(layout))
return Array.from({ length: n }, (_, i) => i)
}
/**
* Whether tab `idx` is on-screen. In 'single' that's only the focused pane; in a
* grid it's any of the first-N visible tabs. Used for both rendering and to
* suppress OS notifications for panes the user can already see.
*/
export function isVisibleIndex(
idx: number,
activeIndex: number,
tabCount: number,
layout: GridLayout,
): boolean {
if (layout === 'single') return idx === activeIndex
return idx >= 0 && idx < Math.min(Math.max(0, tabCount), layoutCapacity(layout))
}
/**
* Match the split-grid focus-cycle keybinding (Ctrl+` forward, Ctrl+Shift+`
* reverse). Returns the cycle direction, or null if the event isn't the binding.
* Extracted from the DOM listener so it's unit-testable in isolation.
*/
export function matchFocusCycleKey(e: KeyboardEvent): 1 | -1 | null {
if (!e.ctrlKey || e.altKey || e.metaKey) return null
if (e.key !== '`' && e.code !== 'Backquote') return null
return e.shiftKey ? -1 : 1
}
/** Minimum viewport width (px) at which multi-pane layouts are offered. Four
* terminals need real width; below this the board falls back to 'single'. */
export const GRID_MIN_WIDTH = 1024
/** True when the screen is wide enough for split layouts (desktop/large only). */
export function gridAllowed(): boolean {
if (typeof window === 'undefined') return false
if (typeof window.matchMedia === 'function') {
return window.matchMedia(`(min-width: ${GRID_MIN_WIDTH}px)`).matches
}
return (window.innerWidth ?? 0) >= GRID_MIN_WIDTH
}
const GRID_LAYOUT_KEY = 'web-terminal:grid-layout'
function isGridLayout(v: unknown): v is GridLayout {
return (
v === 'single' || v === 'split-2' || v === 'row-3' || v === 'grid-4' || v === 'grid-6'
)
}
/** Load the persisted layout. Forced to 'single' when the screen is too narrow
* (a stored grid choice must not resurrect on a phone). */
export function loadGridLayout(): GridLayout {
if (!gridAllowed()) return 'single'
try {
const stored = localStorage.getItem(GRID_LAYOUT_KEY)
if (isGridLayout(stored)) return stored
} catch {
// localStorage unavailable — use default
}
return 'single'
}
/** Persist the chosen layout (mirrors the other web-terminal:* prefs). */
export function saveGridLayout(layout: GridLayout): void {
try {
localStorage.setItem(GRID_LAYOUT_KEY, layout)
} catch {
// localStorage unavailable — run without persistence
}
}
export interface GridToggleHooks {
getLayout: () => GridLayout
setLayout: (layout: GridLayout) => void
}
export interface GridToggle {
/** Re-sync the control's pressed state + visibility (after setLayout / resize). */
refresh: () => void
dispose: () => void
}
/** Per-layout label/title for the toggle. The glyph is drawn in CSS from the
* cell count (see makeGlyph), not from a font, so it renders identically everywhere. */
const LAYOUT_META: Readonly<Record<GridLayout, { title: string; cells: number }>> = {
single: { title: 'Single pane', cells: 1 },
'split-2': { title: 'Side by side — compare two', cells: 2 },
'row-3': { title: '1×3 row — three across', cells: 3 },
'grid-4': { title: '2×2 watch board', cells: 4 },
'grid-6': { title: '2×3 grid — six sessions', cells: 6 },
}
/** A small CSS-drawn layout glyph (N cells), reliable across platforms. */
function makeGlyph(layout: GridLayout): HTMLElement {
const g = document.createElement('span')
g.className = `grid-glyph gl-${layout}`
g.setAttribute('aria-hidden', 'true')
for (let i = 0; i < LAYOUT_META[layout].cells; i++) g.appendChild(document.createElement('i'))
return g
}
/**
* Mount the layout segmented control into the toolbar. Desktop-only: it hides
* below GRID_MIN_WIDTH, and if the window narrows past the threshold while a grid
* is active it forces the layout back to 'single' (the caller re-renders).
*/
export function mountGridToggle(toolbar: HTMLElement, hooks: GridToggleHooks): GridToggle {
const seg = document.createElement('div')
seg.className = 'grid-toggle'
seg.setAttribute('role', 'group')
seg.setAttribute('aria-label', 'Terminal layout')
const buttons = GRID_LAYOUTS.map((layout) => {
const btn = document.createElement('button')
btn.className = 'grid-toggle-btn'
btn.dataset.layout = layout
btn.title = LAYOUT_META[layout].title
btn.setAttribute('aria-label', LAYOUT_META[layout].title)
btn.appendChild(makeGlyph(layout))
btn.addEventListener('click', () => hooks.setLayout(layout))
seg.appendChild(btn)
return btn
})
const refresh = (): void => {
seg.style.display = gridAllowed() ? 'inline-flex' : 'none'
const current = hooks.getLayout()
for (const btn of buttons) {
btn.setAttribute('aria-pressed', String(btn.dataset.layout === current))
}
}
const mql =
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia(`(min-width: ${GRID_MIN_WIDTH}px)`)
: null
const onChange = (): void => {
if (!gridAllowed() && hooks.getLayout() !== 'single') hooks.setLayout('single')
refresh()
}
mql?.addEventListener('change', onChange)
toolbar.appendChild(seg)
refresh()
return {
refresh,
dispose: (): void => {
mql?.removeEventListener('change', onChange)
seg.remove()
},
}
}
/* ── 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<Record<GridLayout, TrackSplit>>
/** Equal-fraction split for a layout (every track = 1fr). */
export function defaultSplit(layout: GridLayout): TrackSplit {
const { cols, rows } = layoutTracks(layout)
return { cols: Array<number>(cols).fill(1), rows: Array<number>(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)
}

214
public/grid-presets.ts Normal file
View File

@@ -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<string, unknown>
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<Record<GridLayout, string>> = {
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<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag)
node.className = cls
if (text !== undefined) node.textContent = text
return node
}

View File

@@ -20,6 +20,8 @@ import { mountDashboard } from './dashboard.js'
import { mountHistory } from './history.js' import { mountHistory } from './history.js'
import { mountShortcuts } from './shortcuts.js' import { mountShortcuts } from './shortcuts.js'
import { mountShareSession } from './share.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 paneHost = document.getElementById('term')
const tabs = document.getElementById('tabs') const tabs = document.getElementById('tabs')
@@ -50,14 +52,29 @@ mountKeybar((data) => app.sendToActive(data), {
onVoiceTrigger: (action) => app.handleVoiceTrigger(action), onVoiceTrigger: (action) => app.handleVoiceTrigger(action),
}) })
// Multi-device: when this device regains focus, re-assert the active tab's size // Multi-device: when this device regains focus, re-assert the size of every
// so a shared session snaps to THIS screen (latest-writer-wins) — full-screen on // VISIBLE pane so a shared session snaps to THIS screen (latest-writer-wins) —
// whichever device you're currently using. // full-screen on whichever device you're using (all quadrants in a split grid).
window.addEventListener('focus', () => app.refitActive()) window.addEventListener('focus', () => app.refitVisible())
document.addEventListener('visibilitychange', () => { 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. // Toolbar utilities.
mountSearch(toolbar, { mountSearch(toolbar, {
find: (query, dir) => app.findInActive(query, dir), find: (query, dir) => app.findInActive(query, dir),
@@ -81,6 +98,21 @@ mountHistory(toolbar, {
mountShortcuts(toolbar) mountShortcuts(toolbar)
mountShareSession(toolbar, () => app.activeSessionId()) 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 / // Session management lives on the home Sessions chooser now (open / kill /
// new), so the standalone /manage.html page was removed. // new), so the standalone /manage.html page was removed.

View File

@@ -298,14 +298,396 @@ body {
background: var(--bg); background: var(--bg);
overflow: hidden; 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; position: absolute;
inset: 0; 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; padding: 6px 8px 0;
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; 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) ─────────────────────────────────────── */ /* ── Key bar (rounded chips) ─────────────────────────────────────── */
#keybar { #keybar {
position: fixed; position: fixed;
@@ -1674,3 +2056,129 @@ body.home-open #term {
.push-toggle-btn[data-tip]:hover::after { .push-toggle-btn[data-tip]:hover::after {
opacity: 1; 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);
}

View File

@@ -34,6 +34,23 @@ import { mountTimeline, type TimelineHandle } from './timeline.js'
import { createVoiceInput, type VoiceInput } from './voice.js' import { createVoiceInput, type VoiceInput } from './voice.js'
import { matchCommand, type VoiceMatchContext } from './voice-commands.js' import { matchCommand, type VoiceMatchContext } from './voice-commands.js'
import { createApproveConfirm, type ApproveConfirm } from './voice-confirm.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 TABS_KEY = 'web-terminal:tabs'
const ACTIVE_KEY = 'web-terminal:active' const ACTIVE_KEY = 'web-terminal:active'
@@ -59,6 +76,14 @@ interface TabEntry {
autoTitle: string | null // current folder from the terminal title autoTitle: string | null // current folder from the terminal title
hasActivity: boolean // inactive tab got output since last viewed hasActivity: boolean // inactive tab got output since last viewed
el: HTMLDivElement | null // the .tab element (updated in place) 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 // A4: live timeline handle while this tab's timeline is open (disposed on
// switch/close). Telemetry is NOT cached here — refreshTab reads the single // switch/close). Telemetry is NOT cached here — refreshTab reads the single
// source of truth session.telemetry (review #15). // source of truth session.telemetry (review #15).
@@ -93,6 +118,19 @@ export class TabApp {
private readonly projects: ProjectsPanel private readonly projects: ProjectsPanel
private homeView: HomeView = 'sessions' private homeView: HomeView = 'sessions'
private readonly segControl: HTMLElement 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 // 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 // jump back to Sessions/Projects without closing anything). Reset whenever a
// tab is activated. Irrelevant while no tab is open (home shows regardless). // tab is activated. Irrelevant while no tab is open (home shows regardless).
@@ -142,6 +180,11 @@ export class TabApp {
this.segControl = this.buildSegControl() this.segControl = this.buildSegControl()
this.paneHost.appendChild(this.segControl) 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): // v0.7 Walk-away Workbench panels (mounted once; survive tab rebuilds):
this.setupPushToggle() // A1 🔔 this.setupPushToggle() // A1 🔔
this.setupQuickReply() // A3 chips above the key bar this.setupQuickReply() // A3 chips above the key bar
@@ -247,8 +290,7 @@ export class TabApp {
document.body.classList.toggle('home-open', showHome) document.body.classList.toggle('home-open', showHome)
if (showHome) { if (showHome) {
// Overlay home: hide every terminal pane so the chooser is on top. // Overlay home: the chooser is on top; applyLayout hides every pane below.
for (const t of this.tabs) t.session.hide()
this.segControl.style.display = 'flex' this.segControl.style.display = 'flex'
this.launcher.setVisible(this.homeView === 'sessions') this.launcher.setVisible(this.homeView === 'sessions')
this.projects.setVisible(this.homeView === 'projects') this.projects.setVisible(this.homeView === 'projects')
@@ -257,6 +299,10 @@ export class TabApp {
this.launcher.setVisible(false) this.launcher.setVisible(false)
this.projects.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 /** Toggle the home chooser overlay over the open tabs (the ⌂ button). No-op
@@ -616,7 +662,14 @@ export class TabApp {
sessionId, sessionId,
...(cwd !== undefined ? { cwd } : {}), ...(cwd !== undefined ? { cwd } : {}),
...(initialInput !== undefined ? { initialInput } : {}), ...(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 only fires for hidden (inactive) panes (see TerminalSession).
onActivity: () => { onActivity: () => {
entry.hasActivity = true entry.hasActivity = true
@@ -630,13 +683,20 @@ export class TabApp {
onClaudeStatus: (status) => { onClaudeStatus: (status) => {
this.refreshTab(entry) this.refreshTab(entry)
this.updateApprovalBar() this.updateApprovalBar()
// Notify when a background tab needs approval (H2/H4). // Notify only when a tab that needs approval is NOT on screen. In a split
if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) { // 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) this.notify(entry)
} }
}, },
// B2: telemetry is the single source of truth on the session; just re-render. // B2: telemetry is the single source of truth on the session; just re-render.
onTelemetry: () => this.refreshTab(entry), onTelemetry: () => this.refreshTab(entry),
// Split-grid: clicking anywhere in this pane makes it the focused quadrant.
onFocus: () => this.setFocused(this.tabs.indexOf(entry)),
}) })
entry = { entry = {
session, session,
@@ -644,9 +704,51 @@ export class TabApp {
autoTitle: null, autoTitle: null,
hasActivity: false, hasActivity: false,
el: null, el: null,
cell: null,
monitor: false,
monitorHandle: null,
monitorEl: null,
timelineHandle: 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) this.tabs.push(entry)
session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize) session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize)
session.connect() session.connect()
@@ -697,13 +799,24 @@ export class TabApp {
activate(i: number): void { activate(i: number): void {
if (i < 0 || i >= this.tabs.length) return 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.maybeAskNotify() // first switch is a user gesture — request notif permission
this.homeForced = false // showing a terminal dismisses any home overlay this.homeForced = false // showing a terminal dismisses any home overlay
this.activeIndex = i this.activeIndex = i
const entry = this.tabs[i] const entry = this.tabs[i]
if (entry) entry.hasActivity = false // viewing clears the unread dot 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 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.updateHomeView() // hide the seg control + home panels now a tab is active
this.updateApprovalBar() this.updateApprovalBar()
if (this.timelineOpen) this.openTimelineForActive() // A4: follow the active session if (this.timelineOpen) this.openTimelineForActive() // A4: follow the active session
@@ -712,9 +825,12 @@ export class TabApp {
closeTab(i: number): void { closeTab(i: number): void {
if (i < 0 || i >= this.tabs.length) return if (i < 0 || i >= this.tabs.length) return
this.maximized = false // structural change exits maximize
const [entry] = this.tabs.splice(i, 1) const [entry] = this.tabs.splice(i, 1)
entry?.timelineHandle?.dispose() // A4: stop polling for the closed tab 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.editingIndex === i) this.editingIndex = -1
if (this.tabs.length === 0) { if (this.tabs.length === 0) {
// v0.5: closing the last tab returns to the home screen — no auto-blank tab. // 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' }) 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<HTMLElement>(`.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<HTMLElement>('.cell-max')
if (maxBtn) maxBtn.textContent = maximized ? '⤡' : '⛶'
const monBtn = cell.querySelector<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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 ───────────────────────────────────────────────────── */ /* ── rendering ───────────────────────────────────────────────────── */
/** In-place update of one tab's classes/label/dot/gauge (never destroys DOM). /** 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 * Single `refreshTab` owner for status dot, stuck badge (A5) and telemetry
* gauge (B2), per SP10/M4. */ * gauge (B2), per SP10/M4. */
private refreshTab(entry: TabEntry): void { 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 const el = entry.el
if (!el) return if (!el) return
const idx = this.tabs.indexOf(entry) const idx = this.tabs.indexOf(entry)

View File

@@ -66,6 +66,9 @@ export interface TerminalSessionOpts {
/** Optional: fired when new statusLine telemetry arrives (B2). Single source of /** Optional: fired when new statusLine telemetry arrives (B2). Single source of
* truth — T-tabs reads session.telemetry via the getter, not its own copy. */ * truth — T-tabs reads session.telemetry via the getter, not its own copy. */
onTelemetry?: (telemetry: StatusTelemetry) => void 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). */ /** Optional: spawn a NEW session in this directory ("new tab here", M6). */
cwd?: string cwd?: string
/** Optional: type this once the new session's shell is ready (O2 resume). */ /** 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 onStatus: ((status: SessionStatus) => void) | undefined
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
private readonly onTelemetry: ((telemetry: StatusTelemetry) => void) | undefined private readonly onTelemetry: ((telemetry: StatusTelemetry) => void) | undefined
private readonly onFocus: (() => void) | undefined
private readonly spawnCwd: string | undefined private readonly spawnCwd: string | undefined
private readonly initialInput: string | undefined private readonly initialInput: string | undefined
private initialSent = false private initialSent = false
@@ -121,12 +125,16 @@ export class TerminalSession {
this.onStatus = opts.onStatus this.onStatus = opts.onStatus
this.onClaudeStatus = opts.onClaudeStatus this.onClaudeStatus = opts.onClaudeStatus
this.onTelemetry = opts.onTelemetry this.onTelemetry = opts.onTelemetry
this.onFocus = opts.onFocus
this.spawnCwd = opts.cwd this.spawnCwd = opts.cwd
this.initialInput = opts.initialInput this.initialInput = opts.initialInput
this.el = document.createElement('div') this.el = document.createElement('div')
this.el.className = 'term-pane' this.el.className = 'term-pane'
this.el.style.display = 'none' 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({ this.term = new Terminal({
scrollback: 5000, 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' this.el.style.display = 'block'
const shouldFocus = opts?.focus !== false
requestAnimationFrame(() => { requestAnimationFrame(() => {
const dims = this.safefit() const dims = this.safefit()
if (dims !== null) this.sendResize(dims.cols, dims.rows) if (dims !== null) this.sendResize(dims.cols, dims.rows)
this.term.focus() if (shouldFocus) this.term.focus()
}) })
} }

83
test/cell-monitor.test.ts Normal file
View File

@@ -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: '<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('<CLEAR>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()
})
})

275
test/grid-layout.test.ts Normal file
View File

@@ -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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLElement>('.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<HTMLElement>('.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')
})
})

133
test/grid-presets.test.ts Normal file
View File

@@ -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<typeof vi.fn>
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<HTMLButtonElement>('.grid-presets-btn')!.click()
return toolbar.querySelector<HTMLElement>('.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<HTMLInputElement>('.gp-name')!
input.value = 'wide-left'
toolbar.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLInputElement>('.gp-name')!
input.value = 'dup'
toolbar.querySelector<HTMLButtonElement>('.gp-save-btn')!.click()
const out = loadPresets()
expect(out).toHaveLength(1)
expect(out[0]!.layout).toBe('grid-4') // overwritten with the current arrangement
})
})

View File

@@ -74,6 +74,15 @@ vi.mock('../public/terminal-session.js', () => ({ TerminalSession: FakeTerminalS
const renderTelemetryGauge = vi.fn() const renderTelemetryGauge = vi.fn()
vi.mock('../public/preview-grid.js', () => ({ renderTelemetryGauge })) 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<typeof vi.fn> }> = []
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() const mountPushToggle = vi.fn()
vi.mock('../public/push.js', () => ({ mountPushToggle })) vi.mock('../public/push.js', () => ({ mountPushToggle }))
@@ -157,6 +166,8 @@ beforeEach(() => {
document.body.replaceChildren() document.body.replaceChildren()
localStorage.clear() localStorage.clear()
renderTelemetryGauge.mockClear() renderTelemetryGauge.mockClear()
mountCellMonitor.mockClear()
monitorHandles.length = 0
mountPushToggle.mockClear() mountPushToggle.mockClear()
mountQuickReply.mockClear() mountQuickReply.mockClear()
mountTimeline.mockClear() mountTimeline.mockClear()
@@ -1032,3 +1043,524 @@ describe('TabApp — VC voice command mapping (context-gated confirm/reject)', (
expect(session.approve).toHaveBeenCalledTimes(1) 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<typeof TabApp>
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<HTMLElement>('.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<HTMLElement>('.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<HTMLButtonElement>('.cell-approve-yes')
const reject = cell0.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLElement>('.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<HTMLElement>('.tab')[4]!.dispatchEvent(
new Event('pointerdown', { bubbles: true }),
)
const cell = offSession.el.closest<HTMLElement>('.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<HTMLElement>('.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<HTMLButtonElement>('.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<HTMLButtonElement>('.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<HTMLElement>('.term-cell')!
// Start dragging tab 4 from the tab bar (sets dragIndex), drop on quadrant 0.
tabBar.querySelectorAll<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.tab')[1]!.dispatchEvent(
new Event('dragstart', { bubbles: true }),
)
const cell = FakeTerminalSession.instances[0]!.el.closest<HTMLElement>('.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<HTMLElement>('.term-cell')!
const maxBtn = cell2.querySelector<HTMLButtonElement>('.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<typeof TabApp>
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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLButtonElement>('.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')
})
})

View File

@@ -24,6 +24,9 @@ export default defineConfig({
'src/**/*.ts', 'src/**/*.ts',
'public/terminal-session.ts', 'public/terminal-session.ts',
'public/tabs.ts', 'public/tabs.ts',
'public/grid-layout.ts',
'public/grid-presets.ts',
'public/cell-monitor.ts',
'public/preview-grid.ts', 'public/preview-grid.ts',
'public/title-util.ts', 'public/title-util.ts',
'public/voice-commands.ts', 'public/voice-commands.ts',