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:
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)
|
||||
|
||||
Reference in New Issue
Block a user