feat(grid): desktop split-grid watch board — v1 (single/1×2/2×2)
When several sessions are open on a large screen (≥1024px), the terminal
area can split into 1×2 or 2×2 so multiple LIVE, interactive terminals show
at once — a monitoring convenience for vibe-coding several Claude sessions.
Design keeps activeIndex as the single focused pane, so keybar/voice/approval
routing is unchanged; a new gridLayout + derived visible-set lets several
.term-cell wrappers show together inside a CSS-grid #term. The server and WS
protocol are untouched.
- public/grid-layout.ts (new): layout types/capacity, visibleIndices,
matchMedia desktop gate (GRID_MIN_WIDTH=1024), persistence, toolbar toggle.
- tabs.ts: pane→.term-cell wrapper (header + terminal + inline-approve footer);
applyLayout() owns show/hide + grid class + cell order + placeholders;
board-aware activate() (never focuses a hidden pane); setFocused/setGridLayout
delegate to it; renderCell/renderInlineApprove; refitVisible; notification
suppression for on-screen panes (factoring in the home overlay).
- terminal-session.ts: show({focus}) so non-focused quadrants don't steal
keyboard focus; onFocus callback (capture-phase pointerdown).
- main.ts: mount the toggle; window-focus refit → refitVisible.
- style.css: cell/grid model (.term-pane → relative flex child), focus ring,
pending pulse, inline approve, placeholder, toggle + coarse-pointer targets.
- tests: grid-layout.test.ts + split-grid block in tabs.test.ts (+33).
Adversarial review (4 lenses → per-finding verify) caught and fixed a HIGH:
activate() was not board-aware, so opening a tab on a full grid focused a
hidden pane (typing into an invisible session). Verified: typecheck + build:web
clean, 1566 tests pass, grid-layout 95% / tabs 94% coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,17 @@
|
||||
|
||||
> 新会话读到的第一块。保持准确,只描述"此刻"。
|
||||
|
||||
### 🖥️ SPLIT-GRID 看板 — 桌面多 session 分屏(2026-07-11,当前活跃)
|
||||
- **需求**: web/Mac 大屏、开多个 tab 时,把 `#term` 大窗切成 1×2 / 2×2 宫格,多个 **live 可交互**终端同屏,方便"vibe coding"时盯多个 Claude session。手机不做(<1024px 强制 single)。
|
||||
- **分支**: `feat/split-grid-view`(自 `feat/tunnel-automation`)。**用户决策(AskUserQuestion)**:全部阶段(v1→v2→v3)用多 agent + loop 完成;审批用**每格内联 ✓/✗**;成员=**前 N 个 tab(拖拽换序控制)**;布局=**single + 1×2 + 2×2**。
|
||||
- **编排**: orchestrator 亲写互锁的 5 文件(并行 builder 会互相踩),每阶段后**并行对抗式 review workflow**(4 lens → 逐条 verify)→ 修 confirmed → 复验绿 → commit → loop 下一阶段。
|
||||
- **[x] v1 DONE(2026-07-11)** — orchestrator 独立复验全绿:`npm run typecheck`(前后端两 config)干净、`build:web` 干净、**全套 1566 测试通过**(+33 新增),覆盖率 grid-layout.ts 95%/tabs.ts 94%(≥80 门槛)。
|
||||
- **核心设计**: `activeIndex` 语义**不变**=聚焦格(keybar/voice/approval 全部零改动);新增 `gridLayout` + 派生 `visibleIndices`;每个 pane 包进 `.term-cell`(header + 终端 + 可选内联审批 footer);`applyLayout()` 独占 pane 显隐/grid class/cell 排序/占位符;`#term` 变 CSS grid。**服务端/WS 协议零改动**。
|
||||
- **文件**: 新 `public/grid-layout.ts`(纯逻辑 + matchMedia 门 + 持久化 + toolbar 分段控件)、`tabs.ts`(applyLayout/renderCell/renderInlineApprove/setFocused/setGridLayout/refitVisible + `activate()` 改为**board-aware**)、`terminal-session.ts`(`show({focus})` 防 4 格抢焦点 + `onFocus` 回调)、`main.ts`(挂 toggle + refitVisible)、`style.css`(cell/grid/焦点环/pending 脉冲/内联审批/占位符/toggle + `.term-pane` 改 position:relative flex 子)。新测 `test/grid-layout.test.ts` + `tabs.test.ts` 追加 split-grid 块。
|
||||
- **交叉验证抓修 3 真缺陷**:**HIGH** — `activate()` 原先非 board-aware:满格(4/4)时点 "+" 会让新 tab 成为 activeIndex 但 `display:none`,用户对着看不见的 session 打字(空最常见路径触发)。修:把 off-board→moveTab 上板逻辑折进 `activate()`,`setFocused`/`setGridLayout` 委托之。**LOW** — 通知抑制未算 `homeForced`(⌂ 覆盖时 pane 实际不在屏);修:`onScreen = !homeForced && isVisible`。**LOW** — toggle 触控尺寸(coarse-pointer 平板);修:`@media (pointer:coarse)` 加大。全部补了回归测试。
|
||||
- **[~] 下一步**: v2(1×3/2×3 布局、拖 tab 到格、Ctrl+` 循环焦点、单格最大化)→ v3(可拖拽分隔条、只读 monitor 格走 `/live-sessions/:id/preview` 免共享 PTY 缩放、布局预设)。
|
||||
- **探索产物**: 交互式原型 artifact(single/1×2/2×2 切换、点击移焦、内联审批)已给用户看过并据此拍板方向。
|
||||
|
||||
### 🔐 TUNNEL AUTOMATION — 零接触隧道注入(客户永不碰证书/密钥;2026-07-08)
|
||||
- **计划**: [PLAN_TUNNEL_AUTOMATION.md](./PLAN_TUNNEL_AUTOMATION.md)(design-locked)。目标:host 一条命令 onboard、device 登录一次即在硬件里生成不可导出密钥→CSR→拿证书,**无 .p12/AirDrop,私钥永不离设备**。三轨:A 控制面/PKI、B host agent、C 原生客户端(iOS/Android/desktop)。基础设施(frps/device-CA/frp-client-CA/nginx:8470 mTLS)已在 VPS M1 上线;本工作流建的是**自动化**(签发端点、njs cert→Host 绑定、硬件 keygen、host onboard)。
|
||||
- **分支**: `feat/tunnel-automation`(自 develop)。走 **MVP fast-path §7**(10 个 tracked task,见任务表 #1–#10),依赖图强制:A1 crypto → A2 契约 → A3 nginx 绑定为顺序地基,之后按职责(backend/host/iOS)扇出。
|
||||
|
||||
180
public/grid-layout.ts
Normal file
180
public/grid-layout.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* public/grid-layout.ts — desktop split-grid ("watch board") layout logic + toggle.
|
||||
*
|
||||
* When several sessions are open on a large screen, the user can split #term into
|
||||
* a 2×2 grid (or 1×2 side-by-side) so multiple LIVE terminals are visible at once
|
||||
* — a monitoring/multitasking convenience for vibe-coding several Claude sessions.
|
||||
*
|
||||
* This module owns the PURE layout logic (capacity, which tabs are visible, the
|
||||
* matchMedia desktop gate, persistence) plus the toolbar toggle control. The DOM
|
||||
* of the panes themselves stays in tabs.ts (TabApp) — this keeps tabs.ts from
|
||||
* growing past its size budget and makes the layout math unit-testable in isolation.
|
||||
*
|
||||
* v1 layouts: 'single' (today's behavior) · 'split-2' (side-by-side) · 'grid-4' (2×2).
|
||||
* The design is deliberately extensible: adding 'row-3'/'grid-6' in v2 is a
|
||||
* CAPACITY entry + a LAYOUT_META entry + CSS, with no change to the state model.
|
||||
*/
|
||||
|
||||
/** A screen layout for the terminal area. Ordered by pane capacity. */
|
||||
export type GridLayout = 'single' | 'split-2' | 'grid-4'
|
||||
|
||||
/** All layouts, in the order the toggle presents them. */
|
||||
export const GRID_LAYOUTS: readonly GridLayout[] = ['single', 'split-2', 'grid-4']
|
||||
|
||||
const CAPACITY: Readonly<Record<GridLayout, number>> = {
|
||||
single: 1,
|
||||
'split-2': 2,
|
||||
'grid-4': 4,
|
||||
}
|
||||
|
||||
/** How many panes a layout shows at once. */
|
||||
export function layoutCapacity(layout: GridLayout): number {
|
||||
return CAPACITY[layout] ?? 1
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab indices visible under `layout`: the first `min(tabCount, capacity)` in
|
||||
* tab order. Membership is controlled by reordering tabs (v1) — drag a tab into
|
||||
* the first N and it joins the board.
|
||||
*/
|
||||
export function visibleIndices(tabCount: number, layout: GridLayout): number[] {
|
||||
const n = Math.min(Math.max(0, tabCount), layoutCapacity(layout))
|
||||
return Array.from({ length: n }, (_, i) => i)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether tab `idx` is on-screen. In 'single' that's only the focused pane; in a
|
||||
* grid it's any of the first-N visible tabs. Used for both rendering and to
|
||||
* suppress OS notifications for panes the user can already see.
|
||||
*/
|
||||
export function isVisibleIndex(
|
||||
idx: number,
|
||||
activeIndex: number,
|
||||
tabCount: number,
|
||||
layout: GridLayout,
|
||||
): boolean {
|
||||
if (layout === 'single') return idx === activeIndex
|
||||
return idx >= 0 && idx < Math.min(Math.max(0, tabCount), layoutCapacity(layout))
|
||||
}
|
||||
|
||||
/** Minimum viewport width (px) at which multi-pane layouts are offered. Four
|
||||
* terminals need real width; below this the board falls back to 'single'. */
|
||||
export const GRID_MIN_WIDTH = 1024
|
||||
|
||||
/** True when the screen is wide enough for split layouts (desktop/large only). */
|
||||
export function gridAllowed(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
if (typeof window.matchMedia === 'function') {
|
||||
return window.matchMedia(`(min-width: ${GRID_MIN_WIDTH}px)`).matches
|
||||
}
|
||||
return (window.innerWidth ?? 0) >= GRID_MIN_WIDTH
|
||||
}
|
||||
|
||||
const GRID_LAYOUT_KEY = 'web-terminal:grid-layout'
|
||||
|
||||
function isGridLayout(v: unknown): v is GridLayout {
|
||||
return v === 'single' || v === 'split-2' || v === 'grid-4'
|
||||
}
|
||||
|
||||
/** Load the persisted layout. Forced to 'single' when the screen is too narrow
|
||||
* (a stored grid choice must not resurrect on a phone). */
|
||||
export function loadGridLayout(): GridLayout {
|
||||
if (!gridAllowed()) return 'single'
|
||||
try {
|
||||
const stored = localStorage.getItem(GRID_LAYOUT_KEY)
|
||||
if (isGridLayout(stored)) return stored
|
||||
} catch {
|
||||
// localStorage unavailable — use default
|
||||
}
|
||||
return 'single'
|
||||
}
|
||||
|
||||
/** Persist the chosen layout (mirrors the other web-terminal:* prefs). */
|
||||
export function saveGridLayout(layout: GridLayout): void {
|
||||
try {
|
||||
localStorage.setItem(GRID_LAYOUT_KEY, layout)
|
||||
} catch {
|
||||
// localStorage unavailable — run without persistence
|
||||
}
|
||||
}
|
||||
|
||||
export interface GridToggleHooks {
|
||||
getLayout: () => GridLayout
|
||||
setLayout: (layout: GridLayout) => void
|
||||
}
|
||||
|
||||
export interface GridToggle {
|
||||
/** Re-sync the control's pressed state + visibility (after setLayout / resize). */
|
||||
refresh: () => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
/** Per-layout label/title for the toggle. The glyph is drawn in CSS from the
|
||||
* cell count (see makeGlyph), not from a font, so it renders identically everywhere. */
|
||||
const LAYOUT_META: Readonly<Record<GridLayout, { title: string; cells: number }>> = {
|
||||
single: { title: 'Single pane', cells: 1 },
|
||||
'split-2': { title: 'Side by side — compare two', cells: 2 },
|
||||
'grid-4': { title: '2×2 watch board', cells: 4 },
|
||||
}
|
||||
|
||||
/** A small CSS-drawn layout glyph (N cells), reliable across platforms. */
|
||||
function makeGlyph(layout: GridLayout): HTMLElement {
|
||||
const g = document.createElement('span')
|
||||
g.className = `grid-glyph gl-${layout}`
|
||||
g.setAttribute('aria-hidden', 'true')
|
||||
for (let i = 0; i < LAYOUT_META[layout].cells; i++) g.appendChild(document.createElement('i'))
|
||||
return g
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the layout segmented control into the toolbar. Desktop-only: it hides
|
||||
* below GRID_MIN_WIDTH, and if the window narrows past the threshold while a grid
|
||||
* is active it forces the layout back to 'single' (the caller re-renders).
|
||||
*/
|
||||
export function mountGridToggle(toolbar: HTMLElement, hooks: GridToggleHooks): GridToggle {
|
||||
const seg = document.createElement('div')
|
||||
seg.className = 'grid-toggle'
|
||||
seg.setAttribute('role', 'group')
|
||||
seg.setAttribute('aria-label', 'Terminal layout')
|
||||
|
||||
const buttons = GRID_LAYOUTS.map((layout) => {
|
||||
const btn = document.createElement('button')
|
||||
btn.className = 'grid-toggle-btn'
|
||||
btn.dataset.layout = layout
|
||||
btn.title = LAYOUT_META[layout].title
|
||||
btn.setAttribute('aria-label', LAYOUT_META[layout].title)
|
||||
btn.appendChild(makeGlyph(layout))
|
||||
btn.addEventListener('click', () => hooks.setLayout(layout))
|
||||
seg.appendChild(btn)
|
||||
return btn
|
||||
})
|
||||
|
||||
const refresh = (): void => {
|
||||
seg.style.display = gridAllowed() ? 'inline-flex' : 'none'
|
||||
const current = hooks.getLayout()
|
||||
for (const btn of buttons) {
|
||||
btn.setAttribute('aria-pressed', String(btn.dataset.layout === current))
|
||||
}
|
||||
}
|
||||
|
||||
const mql =
|
||||
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia(`(min-width: ${GRID_MIN_WIDTH}px)`)
|
||||
: null
|
||||
const onChange = (): void => {
|
||||
if (!gridAllowed() && hooks.getLayout() !== 'single') hooks.setLayout('single')
|
||||
refresh()
|
||||
}
|
||||
mql?.addEventListener('change', onChange)
|
||||
|
||||
toolbar.appendChild(seg)
|
||||
refresh()
|
||||
|
||||
return {
|
||||
refresh,
|
||||
dispose: (): void => {
|
||||
mql?.removeEventListener('change', onChange)
|
||||
seg.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { mountDashboard } from './dashboard.js'
|
||||
import { mountHistory } from './history.js'
|
||||
import { mountShortcuts } from './shortcuts.js'
|
||||
import { mountShareSession } from './share.js'
|
||||
import { mountGridToggle } from './grid-layout.js'
|
||||
|
||||
const paneHost = document.getElementById('term')
|
||||
const tabs = document.getElementById('tabs')
|
||||
@@ -50,12 +51,12 @@ mountKeybar((data) => app.sendToActive(data), {
|
||||
onVoiceTrigger: (action) => app.handleVoiceTrigger(action),
|
||||
})
|
||||
|
||||
// Multi-device: when this device regains focus, re-assert the active tab's size
|
||||
// so a shared session snaps to THIS screen (latest-writer-wins) — full-screen on
|
||||
// whichever device you're currently using.
|
||||
window.addEventListener('focus', () => app.refitActive())
|
||||
// Multi-device: when this device regains focus, re-assert the size of every
|
||||
// VISIBLE pane so a shared session snaps to THIS screen (latest-writer-wins) —
|
||||
// full-screen on whichever device you're using (all quadrants in a split grid).
|
||||
window.addEventListener('focus', () => app.refitVisible())
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) app.refitActive()
|
||||
if (!document.hidden) app.refitVisible()
|
||||
})
|
||||
|
||||
// Toolbar utilities.
|
||||
@@ -81,6 +82,15 @@ mountHistory(toolbar, {
|
||||
mountShortcuts(toolbar)
|
||||
mountShareSession(toolbar, () => app.activeSessionId())
|
||||
|
||||
// Split-grid layout toggle (desktop-only; hidden below GRID_MIN_WIDTH). The
|
||||
// control drives app.setGridLayout and is registered back so a programmatic
|
||||
// layout change (e.g. auto-fallback on window narrow) re-syncs its pressed state.
|
||||
const gridToggle = mountGridToggle(toolbar, {
|
||||
getLayout: () => app.getGridLayout(),
|
||||
setLayout: (layout) => app.setGridLayout(layout),
|
||||
})
|
||||
app.setGridToggle(gridToggle)
|
||||
|
||||
// Session management lives on the home Sessions chooser now (open / kill /
|
||||
// new), so the standalone /manage.html page was removed.
|
||||
|
||||
|
||||
264
public/style.css
264
public/style.css
@@ -298,14 +298,276 @@ body {
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.term-pane {
|
||||
/* Each pane lives in a .term-cell wrapper (header + terminal + optional inline
|
||||
* approve). In single mode the cell fills #term absolutely (only the focused one
|
||||
* is shown) so the classic one-pane look is unchanged; split layouts turn #term
|
||||
* into a CSS grid and the cells flow into tracks. */
|
||||
.term-cell {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.term-pane {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 6px 8px 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Cell header + inline-approve footer are hidden in single mode. */
|
||||
.cell-head,
|
||||
.cell-approve {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Split-grid layouts (desktop watch board) ────────────────────────── */
|
||||
#term.lay-split-2,
|
||||
#term.lay-grid-4 {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
#term.lay-split-2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
#term.lay-grid-4 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
#term.lay-split-2 .term-cell,
|
||||
#term.lay-grid-4 .term-cell {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
}
|
||||
#term.lay-split-2 .cell-head,
|
||||
#term.lay-grid-4 .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-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.lay-split-2 .term-cell.focused,
|
||||
#term.lay-grid-4 .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.lay-split-2 .term-cell.pending:not(.focused),
|
||||
#term.lay-grid-4 .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.lay-split-2 .term-cell.pending,
|
||||
#term.lay-grid-4 .term-cell.pending {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline per-quadrant approve footer. */
|
||||
#term.lay-split-2 .cell-approve,
|
||||
#term.lay-grid-4 .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-grid-4 {
|
||||
grid-template-columns: 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Key bar (rounded chips) ─────────────────────────────────────── */
|
||||
#keybar {
|
||||
position: fixed;
|
||||
|
||||
271
public/tabs.ts
271
public/tabs.ts
@@ -34,6 +34,14 @@ import { mountTimeline, type TimelineHandle } from './timeline.js'
|
||||
import { createVoiceInput, type VoiceInput } from './voice.js'
|
||||
import { matchCommand, type VoiceMatchContext } from './voice-commands.js'
|
||||
import { createApproveConfirm, type ApproveConfirm } from './voice-confirm.js'
|
||||
import {
|
||||
type GridLayout,
|
||||
type GridToggle,
|
||||
isVisibleIndex,
|
||||
layoutCapacity,
|
||||
loadGridLayout,
|
||||
saveGridLayout,
|
||||
} from './grid-layout.js'
|
||||
|
||||
const TABS_KEY = 'web-terminal:tabs'
|
||||
const ACTIVE_KEY = 'web-terminal:active'
|
||||
@@ -59,6 +67,9 @@ interface TabEntry {
|
||||
autoTitle: string | null // current folder from the terminal title
|
||||
hasActivity: boolean // inactive tab got output since last viewed
|
||||
el: HTMLDivElement | null // the .tab element (updated in place)
|
||||
// Split-grid: the .term-cell wrapper around session.el (header + terminal +
|
||||
// optional inline-approve footer). One per tab; the grid lays these out.
|
||||
cell: HTMLDivElement | null
|
||||
// A4: live timeline handle while this tab's timeline is open (disposed on
|
||||
// switch/close). Telemetry is NOT cached here — refreshTab reads the single
|
||||
// source of truth session.telemetry (review #15).
|
||||
@@ -93,6 +104,11 @@ export class TabApp {
|
||||
private readonly projects: ProjectsPanel
|
||||
private homeView: HomeView = 'sessions'
|
||||
private readonly segControl: HTMLElement
|
||||
// Split-grid ("watch board") state. Layout persists in localStorage; the
|
||||
// toolbar toggle (mounted in main.ts) drives setGridLayout and is refreshed
|
||||
// back via gridToggle. 'single' preserves the original one-pane behavior.
|
||||
private gridLayout: GridLayout = 'single'
|
||||
private gridToggle: GridToggle | null = null
|
||||
// When true, the home chooser is overlaid on top of the open tabs (so you can
|
||||
// jump back to Sessions/Projects without closing anything). Reset whenever a
|
||||
// tab is activated. Irrelevant while no tab is open (home shows regardless).
|
||||
@@ -142,6 +158,10 @@ export class TabApp {
|
||||
this.segControl = this.buildSegControl()
|
||||
this.paneHost.appendChild(this.segControl)
|
||||
|
||||
// Split-grid: restore the persisted layout (forced to 'single' on screens
|
||||
// too narrow for a multi-pane board — see loadGridLayout).
|
||||
this.gridLayout = loadGridLayout()
|
||||
|
||||
// v0.7 Walk-away Workbench panels (mounted once; survive tab rebuilds):
|
||||
this.setupPushToggle() // A1 🔔
|
||||
this.setupQuickReply() // A3 chips above the key bar
|
||||
@@ -247,8 +267,7 @@ export class TabApp {
|
||||
document.body.classList.toggle('home-open', showHome)
|
||||
|
||||
if (showHome) {
|
||||
// Overlay home: hide every terminal pane so the chooser is on top.
|
||||
for (const t of this.tabs) t.session.hide()
|
||||
// Overlay home: the chooser is on top; applyLayout hides every pane below.
|
||||
this.segControl.style.display = 'flex'
|
||||
this.launcher.setVisible(this.homeView === 'sessions')
|
||||
this.projects.setVisible(this.homeView === 'projects')
|
||||
@@ -257,6 +276,10 @@ export class TabApp {
|
||||
this.launcher.setVisible(false)
|
||||
this.projects.setVisible(false)
|
||||
}
|
||||
|
||||
// Pane/cell visibility, the grid layout class, focus ring and placeholders
|
||||
// are all owned by applyLayout — home just decides whether panes show at all.
|
||||
this.applyLayout(showHome)
|
||||
}
|
||||
|
||||
/** Toggle the home chooser overlay over the open tabs (the ⌂ button). No-op
|
||||
@@ -630,13 +653,20 @@ export class TabApp {
|
||||
onClaudeStatus: (status) => {
|
||||
this.refreshTab(entry)
|
||||
this.updateApprovalBar()
|
||||
// Notify when a background tab needs approval (H2/H4).
|
||||
if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) {
|
||||
// Notify only when a tab that needs approval is NOT on screen. In a split
|
||||
// grid an on-board quadrant is visible and glows amber in place, so an OS
|
||||
// notification would be redundant — but a pane hidden behind the ⌂ home
|
||||
// overlay is NOT on screen even if isVisible() (which only knows the
|
||||
// layout) would say so, so factor in homeForced too.
|
||||
const onScreen = !this.homeForced && this.isVisible(this.tabs.indexOf(entry))
|
||||
if (status === 'waiting' && !onScreen) {
|
||||
this.notify(entry)
|
||||
}
|
||||
},
|
||||
// B2: telemetry is the single source of truth on the session; just re-render.
|
||||
onTelemetry: () => this.refreshTab(entry),
|
||||
// Split-grid: clicking anywhere in this pane makes it the focused quadrant.
|
||||
onFocus: () => this.setFocused(this.tabs.indexOf(entry)),
|
||||
})
|
||||
entry = {
|
||||
session,
|
||||
@@ -644,9 +674,20 @@ export class TabApp {
|
||||
autoTitle: null,
|
||||
hasActivity: false,
|
||||
el: null,
|
||||
cell: 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)))
|
||||
cell.append(head, session.el)
|
||||
entry.cell = cell
|
||||
this.paneHost.appendChild(cell)
|
||||
this.tabs.push(entry)
|
||||
session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize)
|
||||
session.connect()
|
||||
@@ -697,13 +738,24 @@ export class TabApp {
|
||||
|
||||
activate(i: number): void {
|
||||
if (i < 0 || i >= this.tabs.length) return
|
||||
// Board-aware focus: in a split grid the focused pane must be ON the board,
|
||||
// else keybar/voice/approval would target an invisible (display:none) session
|
||||
// with no focus ring. Pull an off-board tab onto the last slot first, then
|
||||
// resolve its new index. (Single mode has no board, so this is a no-op.)
|
||||
if (this.gridLayout !== 'single' && !this.isVisible(i)) {
|
||||
const entry = this.tabs[i]
|
||||
this.moveTab(i, layoutCapacity(this.gridLayout) - 1)
|
||||
i = entry ? this.tabs.indexOf(entry) : layoutCapacity(this.gridLayout) - 1
|
||||
}
|
||||
this.maybeAskNotify() // first switch is a user gesture — request notif permission
|
||||
this.homeForced = false // showing a terminal dismisses any home overlay
|
||||
this.activeIndex = i
|
||||
const entry = this.tabs[i]
|
||||
if (entry) entry.hasActivity = false // viewing clears the unread dot
|
||||
this.tabs.forEach((t, idx) => (idx === i ? t.session.show() : t.session.hide()))
|
||||
this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild
|
||||
// Pane show/hide + focus is owned by applyLayout (via updateHomeView): in
|
||||
// single mode only the active pane shows; in a grid the first-N show and the
|
||||
// active one is the focused quadrant.
|
||||
this.updateHomeView() // hide the seg control + home panels now a tab is active
|
||||
this.updateApprovalBar()
|
||||
if (this.timelineOpen) this.openTimelineForActive() // A4: follow the active session
|
||||
@@ -714,7 +766,8 @@ export class TabApp {
|
||||
if (i < 0 || i >= this.tabs.length) return
|
||||
const [entry] = this.tabs.splice(i, 1)
|
||||
entry?.timelineHandle?.dispose() // A4: stop polling for the closed tab
|
||||
entry?.session.dispose()
|
||||
entry?.session.dispose() // removes session.el (the .term-pane)
|
||||
entry?.cell?.remove() // also drop the grid cell wrapper
|
||||
if (this.editingIndex === i) this.editingIndex = -1
|
||||
if (this.tabs.length === 0) {
|
||||
// v0.5: closing the last tab returns to the home screen — no auto-blank tab.
|
||||
@@ -824,12 +877,216 @@ export class TabApp {
|
||||
new Notification(`Claude needs you — ${title}`, { body: 'Waiting for approval' })
|
||||
}
|
||||
|
||||
/* ── split-grid (watch board) ────────────────────────────────────── */
|
||||
|
||||
/** Whether tab `idx` is on-screen (the focused pane in single mode; any of the
|
||||
* first-N in a grid). Also gates OS-notification suppression. */
|
||||
private isVisible(idx: number): boolean {
|
||||
return isVisibleIndex(idx, this.activeIndex, this.tabs.length, this.gridLayout)
|
||||
}
|
||||
|
||||
/** Make tab `idx` the focused pane (via the board-aware activate). */
|
||||
private setFocused(idx: number): void {
|
||||
if (idx < 0 || idx >= this.tabs.length) return
|
||||
if (idx === this.activeIndex) return // already focused — avoid churn
|
||||
this.activate(idx) // activate() pulls an off-board tab onto the grid first
|
||||
}
|
||||
|
||||
/** The current split-grid layout (for the toolbar toggle). */
|
||||
getGridLayout(): GridLayout {
|
||||
return this.gridLayout
|
||||
}
|
||||
|
||||
/** Switch layout (from the toolbar toggle). Persists, keeps the focused pane
|
||||
* on-board under a smaller capacity, then re-renders. */
|
||||
setGridLayout(layout: GridLayout): void {
|
||||
if (layout === this.gridLayout) {
|
||||
this.gridToggle?.refresh()
|
||||
return
|
||||
}
|
||||
this.gridLayout = layout
|
||||
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
|
||||
}
|
||||
|
||||
/** 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()
|
||||
})
|
||||
}
|
||||
|
||||
/** 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-grid-4')
|
||||
this.paneHost.classList.add(`lay-${layout}`)
|
||||
|
||||
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) {
|
||||
cell.style.display = 'flex'
|
||||
entry.session.show({ focus: idx === this.activeIndex })
|
||||
} else {
|
||||
cell.style.display = 'none'
|
||||
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)
|
||||
}
|
||||
|
||||
/** 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
|
||||
cell.classList.toggle('focused', grid && focused)
|
||||
cell.classList.toggle('pending', grid && entry.session.pendingApproval)
|
||||
|
||||
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 ───────────────────────────────────────────────────── */
|
||||
|
||||
/** In-place update of one tab's classes/label/dot/gauge (never destroys DOM).
|
||||
* Single `refreshTab` owner for status dot, stuck badge (A5) and telemetry
|
||||
* gauge (B2), per SP10/M4. */
|
||||
private refreshTab(entry: TabEntry): void {
|
||||
// Keep the grid cell (header text, focus ring, pending glow, inline approve)
|
||||
// in sync too — independent of whether the tab-bar element exists yet.
|
||||
this.renderCell(entry)
|
||||
const el = entry.el
|
||||
if (!el) return
|
||||
const idx = this.tabs.indexOf(entry)
|
||||
|
||||
@@ -66,6 +66,9 @@ export interface TerminalSessionOpts {
|
||||
/** Optional: fired when new statusLine telemetry arrives (B2). Single source of
|
||||
* truth — T-tabs reads session.telemetry via the getter, not its own copy. */
|
||||
onTelemetry?: (telemetry: StatusTelemetry) => void
|
||||
/** Optional: fired on a pointerdown anywhere in this pane (split-grid focus).
|
||||
* Lets TabApp move the focused-pane (activeIndex) to the clicked quadrant. */
|
||||
onFocus?: () => void
|
||||
/** Optional: spawn a NEW session in this directory ("new tab here", M6). */
|
||||
cwd?: string
|
||||
/** Optional: type this once the new session's shell is ready (O2 resume). */
|
||||
@@ -85,6 +88,7 @@ export class TerminalSession {
|
||||
private readonly onStatus: ((status: SessionStatus) => void) | undefined
|
||||
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
|
||||
private readonly onTelemetry: ((telemetry: StatusTelemetry) => void) | undefined
|
||||
private readonly onFocus: (() => void) | undefined
|
||||
private readonly spawnCwd: string | undefined
|
||||
private readonly initialInput: string | undefined
|
||||
private initialSent = false
|
||||
@@ -121,12 +125,16 @@ export class TerminalSession {
|
||||
this.onStatus = opts.onStatus
|
||||
this.onClaudeStatus = opts.onClaudeStatus
|
||||
this.onTelemetry = opts.onTelemetry
|
||||
this.onFocus = opts.onFocus
|
||||
this.spawnCwd = opts.cwd
|
||||
this.initialInput = opts.initialInput
|
||||
|
||||
this.el = document.createElement('div')
|
||||
this.el.className = 'term-pane'
|
||||
this.el.style.display = 'none'
|
||||
// Split-grid: a pointerdown anywhere in the pane makes it the focused quadrant.
|
||||
// Capture phase so it fires before xterm's own handling; never blocks input.
|
||||
this.el.addEventListener('pointerdown', () => this.onFocus?.(), { capture: true })
|
||||
|
||||
this.term = new Terminal({
|
||||
scrollback: 5000,
|
||||
@@ -403,13 +411,18 @@ export class TerminalSession {
|
||||
}
|
||||
}
|
||||
|
||||
/** Make this pane visible, fit it, and focus it. */
|
||||
show(): void {
|
||||
/**
|
||||
* Make this pane visible and fit it. Focuses the terminal unless `focus: false`
|
||||
* — in a split grid several panes are shown at once, so only the focused
|
||||
* quadrant should grab keyboard focus (four panes calling focus() would fight).
|
||||
*/
|
||||
show(opts?: { focus?: boolean }): void {
|
||||
this.el.style.display = 'block'
|
||||
const shouldFocus = opts?.focus !== false
|
||||
requestAnimationFrame(() => {
|
||||
const dims = this.safefit()
|
||||
if (dims !== null) this.sendResize(dims.cols, dims.rows)
|
||||
this.term.focus()
|
||||
if (shouldFocus) this.term.focus()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
184
test/grid-layout.test.ts
Normal file
184
test/grid-layout.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
// @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,
|
||||
} 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('grid-4')).toBe(4)
|
||||
})
|
||||
|
||||
it('GRID_LAYOUTS is ordered single → split-2 → grid-4', () => {
|
||||
expect(GRID_LAYOUTS).toEqual(['single', 'split-2', 'grid-4'])
|
||||
})
|
||||
|
||||
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: 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')
|
||||
})
|
||||
})
|
||||
@@ -1032,3 +1032,240 @@ describe('TabApp — VC voice command mapping (context-gated confirm/reject)', (
|
||||
expect(session.approve).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — split-grid watch board (v1)', () => {
|
||||
/** Open `n` tabs and return the TabApp + its paneHost/tabBar. */
|
||||
function withTabs(n: number): {
|
||||
app: InstanceType<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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ export default defineConfig({
|
||||
'src/**/*.ts',
|
||||
'public/terminal-session.ts',
|
||||
'public/tabs.ts',
|
||||
'public/grid-layout.ts',
|
||||
'public/preview-grid.ts',
|
||||
'public/title-util.ts',
|
||||
'public/voice-commands.ts',
|
||||
|
||||
Reference in New Issue
Block a user