Files
web-terminal/public/tabs.ts
Yaojia Wang 9683a16f4f feat(fanout): worktree fan-out board — race N agent lanes of one repo (W5)
Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.

- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
  groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
  "grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
  wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
  + caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
  ≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
  via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
  + extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
  One-click merge deferred (winner session stays open for a manual merge).

Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
2026-07-13 05:09:19 +02:00

1824 lines
74 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* public/tabs.ts — multi-tab manager + tab bar UI.
*
* Each tab owns an independent TerminalSession (own WS + server session).
*
* Titles: auto from the terminal (current folder, via folderFromTitle) →
* double-click to rename inline → falls back to "Term N". Manual wins.
* Interaction: pointerdown to switch (robust with draggable), drag to reorder,
* middle-click / × to close, status dot (connection color), unread dot.
*
* DOM is built once per tab and updated in place. A full rebuild happens ONLY
* on structural changes (add / close / reorder / rename) — never on a plain
* activate or on background output — so clicks are never eaten by a re-render.
*
* Closing a tab only disconnects its WS (a *detach*); the server PTY keeps
* running and is reclaimed by IDLE_TTL.
*
* v0.6 (P6): the home screen has a segmented control ("Sessions" / "Projects")
* that switches between the session launcher and the projects panel. State is
* persisted to localStorage. openProject() spawns a new claude session in a
* repo directory, labelled with the repo name (deduped with #N suffixes).
*/
import { TerminalSession } from './terminal-session.js'
import { ICON_TIMELINE } from './icons.js'
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
import { mountLauncher, type Launcher } from './launcher.js'
import {
mountProjects,
createWorktreeReq,
removeWorktreeReq,
validateBranchNameClient,
type ProjectsPanel,
} from './projects.js'
import { buildFanoutCmd, laneBranch, FANOUT_MAX_LANES } from './fanout.js'
import type {
ApprovalPreview,
ClaudeStatus,
FanoutLaunchOpts,
PermissionMode,
UiConfig,
} from '../src/types.js'
import { renderDiffFile } from './diff.js'
import { renderTelemetryGauge } from './preview-grid.js'
import { mountPushToggle } from './push.js'
import { mountQuickReply } from './quick-reply.js'
import { enqueueFollowup } from './queue.js'
import { mountTimeline, type TimelineHandle } from './timeline.js'
import { createVoiceInput, type VoiceInput } from './voice.js'
import { matchCommand, type VoiceMatchContext } from './voice-commands.js'
import { createApproveConfirm, type ApproveConfirm } from './voice-confirm.js'
import {
type GridLayout,
type GridToggle,
type GridSplits,
type TrackSplit,
isVisibleIndex,
layoutCapacity,
layoutTracks,
loadGridLayout,
saveGridLayout,
adjustSplit,
tracksToTemplate,
loadGridSplits,
saveGridSplits,
splitForLayout,
} from './grid-layout.js'
import { mountCellMonitor, type CellMonitor } from './cell-monitor.js'
const TABS_KEY = 'web-terminal:tabs'
const ACTIVE_KEY = 'web-terminal:active'
const HOME_VIEW_KEY = 'web-terminal:home-view'
const PERMISSION_MODE_KEY = 'web-terminal:permission-mode'
/** Mirrors the server STATUSLINE_TTL_MS default — the per-tab gauge greys out
* (class `tg-stale`) once telemetry is older than this (B2). */
const STATUSLINE_TTL_MS = 30_000
/** VC decision C: how long the "未连接" not-connected flash stays visible. */
const VOICE_NOT_CONNECTED_FLASH_MS = 1500
/** All `--permission-mode` values (B4). 'auto' is high-risk and only offered
* when the server's ALLOW_AUTO_MODE allows it (SEC-M5, gated via /config/ui). */
const ALL_PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'plan', 'auto']
type HomeView = 'sessions' | 'projects'
/** W5: one lane of a fan-out group — its worktree + branch (the session that runs
* in it is tracked via the tagged TabEntry, matched by TabEntry.fanoutGroupId). */
interface FanoutLane {
branch: string
worktreePath: string
}
/** W5: an in-memory fan-out group — one prompt fanned across N lanes of one repo.
* Lives only for the session (v1): reload does not reconstruct the board (matches
* v0.5 "no auto-restore"); keepFanoutWinner operates on the currently-open lanes. */
interface FanoutGroup {
id: string
repoPath: string
repoName: string
prompt: string
lanes: FanoutLane[]
}
interface TabEntry {
session: TerminalSession
customTitle: string | null // user-set; null = use auto/fallback (persisted)
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)
// W5: when set, this tab is a lane of a fan-out group (id) with its own worktree
// + branch; the cell gets a 🏆 Keep button that resolves the race for this lane.
fanoutGroupId?: string
worktreePath?: string
branch?: string
// Split-grid: the .term-cell wrapper around session.el (header + terminal +
// optional inline-approve footer). One per tab; the grid lays these out.
cell: HTMLDivElement | null
// v3: when true, this quadrant shows a read-only polled preview (monitor mode)
// instead of the live pane, so it never drives the shared PTY size.
monitor: boolean
monitorHandle: CellMonitor | null
monitorEl: HTMLElement | null
// A4: live timeline handle while this tab's timeline is open (disposed on
// switch/close). Telemetry is NOT cached here — refreshTab reads the single
// source of truth session.telemetry (review #15).
timelineHandle: TimelineHandle | null
}
interface StoredTab {
id: string | null
title: string | null
}
/** Compact glyph for a tab's Claude activity, incl. 'stuck' ⚠ (A5). */
function claudeIcon(cs: ClaudeStatus): string {
if (cs === 'working') return '⚙'
if (cs === 'waiting') return '⏳'
if (cs === 'idle') return '✓'
if (cs === 'stuck') return '⚠'
return ''
}
export class TabApp {
private readonly tabs: TabEntry[] = []
private activeIndex = -1
private editingIndex = -1
private dragIndex = -1
private notifyAsked = false
private settings: Settings = DEFAULT_SETTINGS
private readonly paneHost: HTMLElement
private readonly tabBar: HTMLElement
private readonly approvalBar: HTMLDivElement
private readonly launcher: Launcher
private readonly projects: ProjectsPanel
private homeView: HomeView = 'sessions'
private readonly segControl: HTMLElement
// Split-grid ("watch board") state. Layout persists in localStorage; the
// toolbar toggle (mounted in main.ts) drives setGridLayout and is refreshed
// back via gridToggle. 'single' preserves the original one-pane behavior.
private gridLayout: GridLayout = 'single'
private gridToggle: GridToggle | null = null
// v2: when true, the focused quadrant is expanded to fill the grid (others stay
// live behind it). Follows the focused pane; reset on layout change / tab close.
private maximized = false
// v3: persisted per-layout column/row track fractions (draggable splitters).
private gridSplits: GridSplits = {}
// v3: true while a gutter is being dragged, so a concurrent applyLayout does
// not destroy-and-recreate the handle (which would drop the drag listeners).
private draggingGutter = false
// When true, the home chooser is overlaid on top of the open tabs (so you can
// jump back to Sessions/Projects without closing anything). Reset whenever a
// tab is activated. Irrelevant while no tab is open (home shows regardless).
private homeForced = false
private homeBtn: HTMLButtonElement | null = null
// B4: mirrors the server ALLOW_AUTO_MODE gate (from /config/ui); when false the
// high-risk 'auto' permission mode is hidden/refused (SEC-M5).
private allowAutoMode = false
// W3(b): the server COST_BUDGET_USD (from /config/ui); 0 = disabled. The per-tab
// gauge warn-styles the cost chip once costUsd >= this budget.
private costBudgetUsd = 0
// W5 fan-out board: the server MAX_FANOUT_LANES (from /config/ui) — the hard cap
// launchFanout clamps to (alongside grid-6 capacity + the server DoS cap).
private maxFanoutLanes = FANOUT_MAX_LANES
// W5: in-memory fan-out group model (session-scoped; not persisted in v1).
private readonly fanoutGroups = new Map<string, FanoutGroup>()
private fanoutBanner: HTMLElement | null = null
private pushHost!: HTMLElement // A1: 🔔 host, mounted once, re-parented per rebuild
private timelinePanel!: HTMLElement // A4: shared timeline panel (one mounted at a time)
private timelineOpen = false
private timelineBtn: HTMLButtonElement | null = null
private voice: VoiceInput | null = null // A2: push-to-talk recognizer
private voiceOverlay: HTMLElement | null = null // A2: interim-transcript overlay
// VC: context-gated confirm/reject command mapping (PLAN_VOICE_COMMANDS.md).
private readonly approveConfirm: ApproveConfirm = createApproveConfirm() // decision B
private voiceConfirmOverlay: HTMLElement | null = null // "听到:批准" / "未连接" (B, C)
private voicePttSession: TerminalSession | null = null // session captured at PTT-start (safeguard 5)
private voicePttEpoch = 0 // session.pendingEpoch captured at PTT-start (safeguard 5)
constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
this.paneHost = paneHost
this.tabBar = tabBar
this.approvalBar = document.createElement('div')
this.approvalBar.id = 'approvalbar'
this.approvalBar.style.display = 'none'
document.body.appendChild(this.approvalBar)
this.launcher = mountLauncher(this.paneHost, {
onOpen: (id) => this.openSession(id),
onNew: () => this.newTab(),
})
// Load persisted home view choice.
try {
const stored = localStorage.getItem(HOME_VIEW_KEY)
if (stored === 'sessions' || stored === 'projects') this.homeView = stored
} catch {
// localStorage unavailable — use default
}
this.projects = mountProjects(this.paneHost, {
onOpenProject: (repoPath, repoName, cmd) => this.openProject(repoPath, repoName, cmd),
onEnterSession: (id) => this.openSession(id),
onFanout: (repoPath, repoName, opts) => void this.launchFanout(repoPath, repoName, opts),
})
this.segControl = this.buildSegControl()
this.paneHost.appendChild(this.segControl)
// Split-grid: restore the persisted layout (forced to 'single' on screens
// too narrow for a multi-pane board — see loadGridLayout) + custom splits.
this.gridLayout = loadGridLayout()
this.gridSplits = loadGridSplits()
// v0.7 Walk-away Workbench panels (mounted once; survive tab rebuilds):
this.setupPushToggle() // A1 🔔
this.setupQuickReply() // A3 chips above the key bar
this.setupTimelinePanel() // A4 activity timeline
this.setupVoiceOverlay() // A2 interim-transcript overlay
this.setupFanoutBanner() // W5 fan-out status/error banner
void this.loadUiConfig() // B4 allowAutoMode gate (best-effort)
// v0.5: do NOT auto-create or auto-restore tabs. Land on the home screen;
// the user picks which session to open. Sessions persist server-side, so
// opening one replays its full scrollback.
this.rebuild()
this.updateHomeView()
}
/* ── v0.7 panel setup (A1/A2/A3/A4/B4) ──────────────────────────── */
/** A1: mount the 🔔 push toggle once into a host re-parented on every rebuild. */
private setupPushToggle(): void {
this.pushHost = document.createElement('div')
this.pushHost.className = 'push-host'
mountPushToggle(this.pushHost)
}
/** A3: quick-reply chips row, inserted directly above the #keybar. */
private setupQuickReply(): void {
const host = document.createElement('div')
host.id = 'quickreply'
const keybar = document.getElementById('keybar')
if (keybar?.parentElement) keybar.parentElement.insertBefore(host, keybar)
else document.body.appendChild(host)
mountQuickReply(host, {
onSend: (data) => this.sendToActive(data),
// W2: "Queue" in the snippet editor enqueues a follow-up for the active
// session instead of sending it now (fires when Claude next goes idle).
onQueue: (text, appendEnter) => this.enqueueToActive(text, appendEnter),
})
}
/** W2: enqueue a follow-up prompt for the active session (best-effort — the
* POST is Origin-guarded server-side; failures are surfaced only via the badge
* not updating). No-op when no tab has attached yet. */
enqueueToActive(text: string, appendEnter: boolean): void {
const id = this.activeSessionId()
if (id === null) return
void enqueueFollowup(id, text, appendEnter)
}
/** A4: hidden activity-timeline panel; toggled per active session. */
private setupTimelinePanel(): void {
this.timelinePanel = document.createElement('div')
this.timelinePanel.id = 'timeline-panel'
this.timelinePanel.style.display = 'none'
document.body.appendChild(this.timelinePanel)
}
/** A2: overlay that shows the live interim transcript while dictating. VC: a
* SEPARATE overlay carries "听到:批准" / "未连接" so hideInterim() (which
* fires on PTT release) never clobbers it mid-confirm-window (§6). */
private setupVoiceOverlay(): void {
const overlay = document.createElement('div')
overlay.id = 'voice-interim'
overlay.style.display = 'none'
document.body.appendChild(overlay)
this.voiceOverlay = overlay
const confirmOverlay = document.createElement('div')
confirmOverlay.id = 'voice-confirm'
confirmOverlay.style.display = 'none'
// Tap-to-cancel an armed approve confirm window (§4 cancel trigger).
confirmOverlay.addEventListener('click', () => this.cancelArmedApprove())
document.body.appendChild(confirmOverlay)
this.voiceConfirmOverlay = confirmOverlay
}
/** W5: a dismissible banner for fan-out status/errors (partial-launch, keep-
* winner failures). textContent only (SEC-L3/H6); tap or auto-hide to clear. */
private setupFanoutBanner(): void {
const banner = document.createElement('div')
banner.id = 'fanout-banner'
banner.className = 'fanout-banner'
banner.style.display = 'none'
banner.addEventListener('click', () => {
banner.style.display = 'none'
})
document.body.appendChild(banner)
this.fanoutBanner = banner
}
private fanoutBannerTimer: ReturnType<typeof setTimeout> | null = null
/** Show a fan-out banner message (textContent only). Auto-hides after 8s. */
private showFanoutBanner(msg: string): void {
const banner = this.fanoutBanner
if (!banner) return
banner.textContent = msg // SEC-L3/H6: textContent, never innerHTML
banner.style.display = 'block'
if (this.fanoutBannerTimer !== null) clearTimeout(this.fanoutBannerTimer)
this.fanoutBannerTimer = setTimeout(() => {
banner.style.display = 'none'
this.fanoutBannerTimer = null
}, 8000)
}
/** B4: fetch the server UI config (allowAutoMode). Best-effort, never throws. */
private async loadUiConfig(): Promise<void> {
try {
if (typeof fetch === 'undefined') return
const res = await fetch('/config/ui', { credentials: 'same-origin' })
if (!res.ok) return
const data: unknown = await res.json()
if (
data !== null &&
typeof data === 'object' &&
typeof (data as Record<string, unknown>)['allowAutoMode'] === 'boolean'
) {
this.allowAutoMode = (data as UiConfig).allowAutoMode
}
// W3(b): read the cost budget (optional, present only when > 0 server-side).
const budget = (data as Record<string, unknown>)?.['costBudgetUsd']
if (typeof budget === 'number' && Number.isFinite(budget) && budget > 0) {
this.costBudgetUsd = budget
}
// W5: read the fan-out lane cap (optional; older servers omit → keep default).
const lanes = (data as Record<string, unknown>)?.['maxFanoutLanes']
if (typeof lanes === 'number' && Number.isFinite(lanes) && lanes >= 2) {
this.maxFanoutLanes = Math.floor(lanes)
}
} catch {
// best-effort — leave allowAutoMode false (auto hidden) on any failure
}
}
/* ── Home-view visibility ────────────────────────────────────────── */
/**
* Controls visibility of the segmented control and both home sub-panels.
* When tabs are open: hide everything (the terminal panes own the screen).
* When no tab is open: show the seg control and the selected sub-panel.
*/
private updateHomeView(): void {
// Home shows when there is no tab OR the user explicitly forced it (the ⌂
// button), letting you reach Sessions/Projects without closing a session.
const showHome = this.tabs.length === 0 || this.homeForced
// Sync active-class on segmented control buttons.
const btns = this.segControl.querySelectorAll<HTMLButtonElement>('.home-seg-btn')
btns.forEach((btn, i) => {
const view: HomeView = i === 0 ? 'sessions' : 'projects'
btn.classList.toggle('active', view === this.homeView)
})
// The ⌂ button is "pressed" only while overlaying home on top of open tabs.
this.homeBtn?.classList.toggle('active', this.homeForced && this.tabs.length > 0)
// Hide the bottom key-bar on the home chooser — it only sends keys to an
// active terminal, so it's noise on the Sessions/Projects views.
document.body.classList.toggle('home-open', showHome)
if (showHome) {
// 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')
} else {
this.segControl.style.display = 'none'
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
* when no tab is open (home is already showing). */
private toggleHome(): void {
if (this.tabs.length === 0) return
this.homeForced = !this.homeForced
if (this.homeForced) {
this.updateHomeView()
} else {
// Return to the active terminal (activate re-shows its pane + hides home).
this.activate(this.activeIndex)
}
}
private saveHomeView(): void {
try {
localStorage.setItem(HOME_VIEW_KEY, this.homeView)
} catch {
// localStorage unavailable
}
}
private buildSegControl(): HTMLElement {
const seg = document.createElement('div')
seg.className = 'home-seg'
seg.style.display = 'none' // updateHomeView() will show it when needed
const sessBtn = document.createElement('button')
sessBtn.className = 'home-seg-btn'
sessBtn.textContent = 'Sessions'
sessBtn.addEventListener('click', () => {
this.homeView = 'sessions'
this.saveHomeView()
this.updateHomeView()
})
const projBtn = document.createElement('button')
projBtn.className = 'home-seg-btn'
projBtn.textContent = 'Projects'
projBtn.addEventListener('click', () => {
this.homeView = 'projects'
this.saveHomeView()
this.updateHomeView()
})
seg.append(sessBtn, projBtn)
return seg
}
/* ── Approval bar ───────────────────────────────────────────────── */
/** Show/hide the approve/reject banner (H3). B4: a 'plan' gate renders THREE
* choices; an ordinary 'tool' gate keeps the two buttons (no regression). */
private updateApprovalBar(): void {
const session = this.tabs[this.activeIndex]?.session
if (!session || !session.pendingApproval) {
this.approvalBar.style.display = 'none'
return
}
this.approvalBar.replaceChildren()
const label = document.createElement('span')
label.className = 'approval-label'
// W1: show WHAT will run (command / diff) between the label and the buttons,
// so a one-tap remote approval is no longer blind. Absent for plan gates and
// unknown tools (no reviewable command/diff) → today's name-only bar.
const preview = session.pendingPreview
const previewNode = preview ? this.renderApprovalPreview(preview) : null
const middle = previewNode ? [previewNode] : []
if (session.pendingGate === 'plan') {
label.textContent = 'Claude finished planning — how should it proceed?'
this.approvalBar.append(label, ...middle, ...this.planGateButtons(session))
} else {
label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}`
this.approvalBar.append(label, ...middle, ...this.toolGateButtons(session))
}
this.approvalBar.style.display = 'flex'
}
/** W1: build the command/diff preview node for the approval bar. Untrusted,
* server-sanitized content is rendered via textContent / renderDiffFile ONLY
* (never innerHTML) — <script>, ANSI, & etc. appear as literal characters. */
private renderApprovalPreview(p: ApprovalPreview): HTMLElement {
const container = document.createElement('div')
container.className = 'approval-preview'
if (p.kind === 'command') {
const pre = document.createElement('pre')
pre.className = 'approval-cmd'
pre.textContent = p.text // textContent — attacker-influenced command bytes
container.append(pre)
} else {
container.append(renderDiffFile(p.file)) // diff.ts is innerHTML-free (SEC-H4)
}
if (p.truncated) {
const note = document.createElement('div')
note.className = 'approval-truncated'
note.textContent = '… truncated'
container.append(note)
}
return container
}
/** Ordinary tool gate: Approve / Reject (two buttons, unchanged). */
private toolGateButtons(session: TerminalSession): HTMLButtonElement[] {
return [
this.approvalButton('approval-yes', '✓ Approve', () => session.approve()),
this.approvalButton('approval-no', '✗ Reject', () => session.reject()),
]
}
/** B4 plan gate: Approve+Auto (acceptEdits) / Approve+Review (default) / Keep
* Planning (reject — stays in plan mode). Three buttons. */
private planGateButtons(session: TerminalSession): HTMLButtonElement[] {
return [
this.approvalButton('approval-yes', '✓ Approve + Auto', () => session.approve('acceptEdits')),
this.approvalButton('approval-review', '✓ Approve + Review', () => session.approve('default')),
this.approvalButton('approval-no', '✎ Keep Planning', () => session.reject()),
]
}
/** Build one approval-bar button that runs `onClick` then re-renders the bar. */
private approvalButton(cls: string, text: string, onClick: () => void): HTMLButtonElement {
const btn = document.createElement('button')
btn.className = cls
btn.textContent = text
btn.addEventListener('click', () => {
onClick()
this.updateApprovalBar()
})
return btn
}
/* ── A2 voice · A4 timeline · B4 permission mode ─────────────────── */
/** Push-to-talk handler for the key-bar 🎤 (A2). Wire into mountKeybar via
* `{ onVoiceTrigger: (a) => app.handleVoiceTrigger(a) }`. */
handleVoiceTrigger(action: 'start' | 'stop'): void {
if (action === 'start') this.startVoice()
else this.stopVoice()
}
private startVoice(): void {
if (!this.voice) {
this.voice = createVoiceInput(
(text, confidence) => this.handleVoiceTranscript(text, confidence),
{ onInterim: (t) => this.showInterim(t) },
)
}
if (!this.voice) return // SpeechRecognition unsupported — no-op (AC-A2.3)
// VC safeguard 5: bind this utterance to the CURRENTLY active session/epoch
// so a slow transcript can't approve a newer gate or a different tab.
const active = this.tabs[this.activeIndex]?.session ?? null
this.voicePttSession = active
this.voicePttEpoch = active?.pendingEpoch ?? 0
this.showInterim('')
this.voice.start()
}
private stopVoice(): void {
this.voice?.stop()
this.hideInterim()
}
private showInterim(text: string): void {
if (!this.voiceOverlay) return
this.voiceOverlay.textContent = text // textContent — transcript is untrusted
this.voiceOverlay.style.display = 'block'
}
private hideInterim(): void {
if (this.voiceOverlay) this.voiceOverlay.style.display = 'none'
}
/**
* VC — dispatch a final voice transcript (PLAN_VOICE_COMMANDS.md §6):
* context-gated confirm/reject resolve the held permission via the existing
* approve()/reject() WS channel; everything else (including a stale gate or
* a tab switch mid-utterance) falls through to ordinary dictation, byte-for-
* byte unchanged. Raw bytes are NEVER synthesized for approve/reject.
*/
private handleVoiceTranscript(text: string, confidence?: number): void {
// A NEW utterance supersedes any pending approve confirm window: the user
// has spoken again, so never let a stale timer commit behind a fresh
// command (this is the "say another word" cancel trigger, §4). Combined
// with tap-to-cancel and the commit-time re-check below, it closes the
// TOCTOU race where a delayed approve could resolve a different gate.
this.cancelArmedApprove()
const session = this.tabs[this.activeIndex]?.session
if (!session) {
this.sendToActive(text)
return
}
const ctx: VoiceMatchContext = {
pendingApproval: session.pendingApproval,
gate: session.pendingGate,
confidence,
}
const action = matchCommand(text, ctx)
// Stale-gate guard: a command can only resolve the SAME session+gate it
// was spoken against; anything else (tab switch, a new pending flip) is
// downgraded to plain dictation rather than silently dropped.
const stale = session !== this.voicePttSession || session.pendingEpoch !== this.voicePttEpoch
if (action !== 'text' && stale) {
this.sendToActive(text)
return
}
if (action === 'reject') {
if (!this.isVoiceConnected(session)) return this.flashVoiceStatus('未连接', VOICE_NOT_CONNECTED_FLASH_MS)
session.reject()
this.updateApprovalBar()
return
}
if (action === 'approve') {
if (!this.isVoiceConnected(session)) return this.flashVoiceStatus('未连接', VOICE_NOT_CONNECTED_FLASH_MS)
// Decision B: arm a cancellable window instead of committing immediately.
// The commit RE-VALIDATES the gate identity (TOCTOU fix): if the held
// permission resolved and a NEW gate was raised during the ~1.5s window
// (pendingEpoch bumps), or the tab/WS changed, drop the approve — never
// silently approve a different, unseen/unheard gate.
const armedSession = session
const armedEpoch = session.pendingEpoch
this.approveConfirm.arm(
() => this.flashVoiceStatus('听到:批准(点此取消)'),
() => {
this.hideVoiceStatus()
const fresh =
armedSession.pendingApproval &&
armedSession.pendingGate === 'tool' &&
armedSession.pendingEpoch === armedEpoch &&
this.isVoiceConnected(armedSession)
if (!fresh) return // gate changed / WS dropped during the window → drop
armedSession.approve()
this.updateApprovalBar()
},
)
return
}
this.sendToActive(text) // UNCHANGED dictation path
}
/** Cancel a still-armed approve confirm window and clear its overlay. Invoked
* on a new utterance, a reject, and a tap on the overlay (§4 cancel triggers). */
private cancelArmedApprove(): void {
if (!this.approveConfirm.isArmed()) return
this.approveConfirm.cancel()
this.hideVoiceStatus()
}
/** Decision C: approve()/reject() are silently dropped by sendMsg when the WS
* isn't open — flash feedback instead of a silent no-op. */
private isVoiceConnected(session: TerminalSession): boolean {
return session.status === 'connected'
}
/** Transient voice-status text ("听到:批准" / "未连接") on a DIFFERENT overlay
* than the interim transcript, so hideInterim() (PTT release) never clears
* it mid-confirm-window. XSS-safe: textContent only. */
private flashVoiceStatus(text: string, autoHideMs?: number): void {
if (!this.voiceConfirmOverlay) return
this.voiceConfirmOverlay.textContent = text
this.voiceConfirmOverlay.style.display = 'block'
if (autoHideMs !== undefined) setTimeout(() => this.hideVoiceStatus(), autoHideMs)
}
private hideVoiceStatus(): void {
if (this.voiceConfirmOverlay) this.voiceConfirmOverlay.style.display = 'none'
}
/** Toggle the activity-timeline panel for the active session (A4). */
private toggleTimeline(): void {
this.timelineOpen = !this.timelineOpen
if (this.timelineOpen) this.openTimelineForActive()
else this.closeTimeline()
this.timelineBtn?.classList.toggle('active', this.timelineOpen)
}
/** Mount the timeline for the active session (one panel at a time, A4). */
private openTimelineForActive(): void {
for (const t of this.tabs) {
t.timelineHandle?.dispose()
t.timelineHandle = null
}
this.timelinePanel.style.display = 'block'
this.timelinePanel.replaceChildren()
const entry = this.tabs[this.activeIndex]
const id = entry?.session.id
if (entry && id) entry.timelineHandle = mountTimeline(this.timelinePanel, id)
}
private closeTimeline(): void {
this.timelinePanel.style.display = 'none'
for (const t of this.tabs) {
t.timelineHandle?.dispose()
t.timelineHandle = null
}
}
/** Permission modes the user may pick (B4). 'auto' is filtered out unless the
* server allows it (SEC-M5). */
availablePermissionModes(): PermissionMode[] {
return ALL_PERMISSION_MODES.filter((m) => m !== 'auto' || this.allowAutoMode)
}
/** B4: persisted default permission mode; 'auto' downgrades to 'default' when
* the server forbids it (SEC-M5). Invalid stored values fall back to 'default'. */
loadDefaultMode(): PermissionMode {
let mode: PermissionMode = 'default'
try {
const stored = localStorage.getItem(PERMISSION_MODE_KEY)
if (stored !== null && (ALL_PERMISSION_MODES as readonly string[]).includes(stored)) {
mode = stored as PermissionMode
}
} catch {
// localStorage unavailable — use 'default'
}
return this.resolveMode(mode)
}
/** Persist the chosen default permission mode (B4-FR7). */
saveDefaultMode(mode: PermissionMode): void {
try {
localStorage.setItem(PERMISSION_MODE_KEY, mode)
} catch {
// localStorage unavailable — run without persistence
}
}
/** SEC-M5: never honor 'auto' when the server forbids it. */
private resolveMode(mode: PermissionMode): PermissionMode {
return mode === 'auto' && !this.allowAutoMode ? 'default' : mode
}
/** B4: `claude` launch command for a permission mode (default → plain claude). */
private buildClaudeCmd(mode: PermissionMode): string {
return mode === 'default' ? 'claude\r' : `claude --permission-mode ${mode}\r`
}
private displayTitle(entry: TabEntry, idx: number): string {
return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}`
}
/* ── persistence ─────────────────────────────────────────────────── */
private persist(): void {
try {
const stored: StoredTab[] = this.tabs.map((t) => ({ id: t.session.id, title: t.customTitle }))
localStorage.setItem(TABS_KEY, JSON.stringify(stored))
localStorage.setItem(ACTIVE_KEY, String(this.activeIndex))
} catch {
// localStorage unavailable — run without persistence
}
}
/** The active tab's server session id (null until it has attached). */
activeSessionId(): string | null {
return this.tabs[this.activeIndex]?.session.id ?? null
}
/** Focus the tab for `sessionId`, or open one that joins it (share-link target). */
openSession(sessionId: string): void {
const existing = this.tabs.findIndex((t) => t.session.id === sessionId)
if (existing >= 0) {
this.activate(existing)
return
}
this.addEntry(sessionId, null)
this.persist()
this.rebuild()
this.activate(this.tabs.length - 1)
}
/* ── tab lifecycle ───────────────────────────────────────────────── */
private addEntry(
sessionId: string | null,
customTitle: string | null,
cwd?: string,
initialInput?: string,
): TabEntry {
// Build the session FIRST so `entry` is never typed with a null session.
// The callbacks below capture `entry` by reference; they only fire after
// construction (async), by which point `entry` is assigned.
let entry: TabEntry
const session = new TerminalSession({
sessionId,
...(cwd !== undefined ? { cwd } : {}),
...(initialInput !== undefined ? { initialInput } : {}),
onSessionId: () => {
this.persist()
// v3: a monitor toggled before attach completed (session.id was null, so
// applyLayout's monitor branch fell through to the live pane) engages now
// that the id is available — reconcile instead of waiting for the next
// unrelated structural re-render.
if (entry.monitor && this.isVisible(this.tabs.indexOf(entry))) this.updateHomeView()
},
// onActivity only fires for hidden (inactive) panes (see TerminalSession).
onActivity: () => {
entry.hasActivity = true
this.refreshTab(entry)
},
onTitle: (title) => {
entry.autoTitle = title.trim() || null
this.refreshTab(entry)
},
onStatus: () => this.refreshTab(entry),
onClaudeStatus: (status) => {
this.refreshTab(entry)
this.updateApprovalBar()
// 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),
// W2: pending inject-queue depth changed — re-render the "N queued" badge.
onQueue: () => this.refreshTab(entry),
// Split-grid: clicking anywhere in this pane makes it the focused quadrant.
onFocus: () => this.setFocused(this.tabs.indexOf(entry)),
})
entry = {
session,
customTitle,
autoTitle: null,
hasActivity: false,
el: null,
cell: null,
monitor: false,
monitorHandle: null,
monitorEl: null,
timelineHandle: null,
}
// 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))
})
// W5: 🏆 keep-winner — only shown (via renderCell) when this tab is a fan-out
// lane. Resolves the race: keep this lane, discard the others' worktrees.
const keepBtn = document.createElement('button')
keepBtn.type = 'button'
keepBtn.className = 'cell-keep'
keepBtn.textContent = '🏆'
keepBtn.title = 'Keep this lane, discard the others'
keepBtn.setAttribute('aria-label', 'Keep this fan-out lane as the winner')
keepBtn.style.display = 'none'
keepBtn.addEventListener('pointerdown', (e) => e.stopPropagation())
keepBtn.addEventListener('click', (e) => {
e.stopPropagation()
if (entry.fanoutGroupId !== undefined) {
void this.keepFanoutWinner(entry.fanoutGroupId, entry.session.id ?? '')
}
})
head.append(monBtn, keepBtn, maxBtn)
// v2: drag a tab from the tab bar onto this quadrant to assign it here.
this.wireCellDropTarget(cell, () => this.tabs.indexOf(entry))
cell.append(head, session.el)
entry.cell = cell
this.paneHost.appendChild(cell)
this.tabs.push(entry)
session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize)
session.connect()
return entry
}
newTab(): void {
// M6: open the new tab in the active tab's current directory, if known.
const cwd = this.tabs[this.activeIndex]?.session.cwd ?? undefined
this.addEntry(null, null, cwd)
this.persist()
this.rebuild()
this.activate(this.tabs.length - 1)
}
/** O2: open a new tab in `cwd` and run `claude --resume <id>`. */
newTabForResume(cwd: string, sessionId: string): void {
this.addEntry(null, null, cwd || undefined, `claude --resume ${sessionId}\r`)
this.persist()
this.rebuild()
this.activate(this.tabs.length - 1)
}
/**
* v0.6 (P6): spawn a new claude session in `repoPath`, labelled `repoName`.
* If tabs with the same base name already exist, appends a #N suffix to
* distinguish them (e.g. "web-terminal #2"). Mirrors newTabForResume in
* structure: addEntry → persist → rebuild → activate.
*/
openProject(repoPath: string, repoName: string, cmd = 'claude\r', mode?: PermissionMode): void {
const n = this.countOpenWithTitlePrefix(repoName)
const label = n === 0 ? repoName : `${repoName} #${n + 1}`
// B4-FR2: when a permission mode is chosen, upgrade the launch command to
// `claude --permission-mode <mode>` (non-default only); 'auto' is gated (M5).
const effectiveCmd = mode !== undefined ? this.buildClaudeCmd(this.resolveMode(mode)) : cmd
this.addEntry(null, label, repoPath || undefined, effectiveCmd)
this.persist()
this.rebuild()
this.activate(this.tabs.length - 1)
}
/** Count tabs whose customTitle is exactly `name` or starts with `name + ' #'`. */
private countOpenWithTitlePrefix(name: string): number {
return this.tabs.filter(
(t) => t.customTitle === name || t.customTitle?.startsWith(`${name} #`) === true,
).length
}
/* ── W5 fan-out board ────────────────────────────────────────────────── */
/**
* Fan ONE prompt across N lanes of `repoPath`: create N worktrees SEQUENTIALLY
* (git's `worktree add` takes a repo lock — parallel adds race), then open one
* Claude session per created lane pre-injected with the same shell-quoted prompt,
* and lay them out on the split-grid board. Composition of shipped parts —
* createWorktreeReq + addEntry (openProject shape) + setGridLayout. Partial
* success is surfaced in the banner (no auto-rollback in v1); never throws.
*/
async launchFanout(repoPath: string, repoName: string, opts: FanoutLaunchOpts): Promise<void> {
const base = opts.branchBase.trim()
const branchErr = validateBranchNameClient(base)
if (branchErr !== null) {
this.showFanoutBanner(`Cannot fan out — ${branchErr}.`)
return
}
// Effective N: min(requested, server cap, grid-6 capacity). The remaining
// maxSessions liveCount bound is enforced server-side (assertUnderSessionCap
// throws → that lane shows exit(-1); reported as "started K of N" below).
const n = Math.min(
Math.max(2, Math.floor(opts.lanes)),
this.maxFanoutLanes,
FANOUT_MAX_LANES,
)
// Build the launch command ONCE — every lane runs the identical prompt.
const cmd = buildFanoutCmd(opts.prompt, this.resolveMode(opts.mode ?? 'default'), this.allowAutoMode)
const groupId = `fanout-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const lanes: FanoutLane[] = []
const failures: string[] = []
for (let i = 1; i <= n; i++) {
const branch = laneBranch(base, i)
const r = await createWorktreeReq(repoPath, branch) // sequential (repo lock)
if (r.ok && typeof r.path === 'string') {
lanes.push({ branch, worktreePath: r.path })
} else {
failures.push(`${branch}: ${r.error ?? 'failed'}`)
}
}
if (lanes.length === 0) {
this.showFanoutBanner(`Fan-out failed — no lanes created. ${failures.join('; ')}`)
return
}
const group: FanoutGroup = { id: groupId, repoPath, repoName, prompt: opts.prompt, lanes }
this.fanoutGroups.set(groupId, group)
const created: TabEntry[] = []
for (const lane of lanes) {
const entry = this.addEntry(null, `${repoName}·${lane.branch}`, lane.worktreePath || undefined, cmd)
entry.fanoutGroupId = groupId
entry.worktreePath = lane.worktreePath
entry.branch = lane.branch
created.push(entry)
}
this.persist()
this.rebuild()
// Watch board: 2×2 up to 4 lanes, else 2×3 (grid-6 holds up to 6).
this.setGridLayout(lanes.length <= 4 ? 'grid-4' : 'grid-6')
const first = created[0]
if (first) this.activate(this.tabs.indexOf(first))
if (failures.length > 0) {
this.showFanoutBanner(`Started ${lanes.length} of ${n} lanes. Failed: ${failures.join('; ')}`)
}
}
/**
* Keep the winning lane and discard the others: a single batch confirm, then
* each losing lane's tab is closed (detach — its PTY is reclaimed by IDLE_TTL)
* and its worktree removed (auto-forcing on a 409 dirty tree, inside the one
* confirm — no per-lane prompt). The winner's tab + worktree stay so the user
* runs the merge inside it (one-click merge is out of v1 scope). Never throws.
*/
async keepFanoutWinner(groupId: string, winnerSessionId: string): Promise<void> {
const group = this.fanoutGroups.get(groupId)
if (!group) return
const laneTabs = this.tabs.filter((t) => t.fanoutGroupId === groupId)
const winner = laneTabs.find((t) => t.session.id === winnerSessionId)
if (!winner) {
// No lane matches — refuse rather than treat every lane (incl. the intended
// winner) as a loser and delete them all. Happens if the winner hasn't
// attached yet (session.id still null).
this.showFanoutBanner('Cannot keep this lane yet — it has not connected.')
return
}
const losers = laneTabs.filter((t) => t !== winner)
const winnerBranch = winner.branch ?? winnerSessionId
if (
!window.confirm(
`Keep ${winnerBranch} and discard the other ${losers.length} lane(s) (delete their worktrees)?`,
)
) {
return
}
const failures: string[] = []
for (const loser of losers) {
const idx = this.tabs.indexOf(loser)
if (idx >= 0) this.closeTab(idx) // detach; PTY reaped by IDLE_TTL
const wt = loser.worktreePath
if (wt !== undefined && wt !== '') {
let r = await removeWorktreeReq(group.repoPath, wt, false)
if (!r.ok && r.status === 409) {
r = await removeWorktreeReq(group.repoPath, wt, true) // dirty → force (batch-confirmed)
}
if (!r.ok) failures.push(`${loser.branch ?? wt}: ${r.error ?? 'failed'}`)
}
}
this.fanoutGroups.delete(groupId)
winner.fanoutGroupId = undefined // drop the 🏆 button — the race is resolved
if (losers.length > 0) {
this.setGridLayout('single')
const wi = this.tabs.indexOf(winner)
if (wi >= 0) this.activate(wi)
}
this.persist()
this.rebuild()
if (failures.length > 0) {
this.showFanoutBanner(`Kept ${winnerBranch}. Some worktrees could not be removed: ${failures.join('; ')}`)
}
}
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) => 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
this.persist()
}
closeTab(i: number): void {
if (i < 0 || i >= this.tabs.length) return
this.maximized = false // structural change exits maximize
const [entry] = this.tabs.splice(i, 1)
entry?.timelineHandle?.dispose() // A4: stop polling for the closed tab
if (entry) this.stopMonitor(entry) // v3: stop the monitor poll + preview term
entry?.session.dispose() // removes session.el (the .term-pane)
entry?.cell?.remove() // also drop the grid cell wrapper
if (this.editingIndex === i) this.editingIndex = -1
if (this.tabs.length === 0) {
// v0.5: closing the last tab returns to the home screen — no auto-blank tab.
this.activeIndex = -1
this.homeForced = false // back to the natural "no tabs → home" state
this.persist()
this.rebuild() // updateHomeView() (in rebuild) shows the chooser
this.updateApprovalBar()
return
}
const next = Math.min(i, this.tabs.length - 1)
this.persist()
this.rebuild()
this.activate(next)
}
private moveTab(from: number, to: number): void {
if (from < 0 || to < 0 || from === to || from >= this.tabs.length || to >= this.tabs.length) {
return
}
const activeEntry = this.tabs[this.activeIndex]
const [moved] = this.tabs.splice(from, 1)
if (!moved) return
this.tabs.splice(to, 0, moved)
if (activeEntry) this.activeIndex = this.tabs.indexOf(activeEntry)
this.persist()
this.rebuild()
}
private commitRename(i: number, value: string): void {
const entry = this.tabs[i]
if (entry) {
const trimmed = value.trim()
entry.customTitle = trimmed.length > 0 ? trimmed : null // empty → revert to auto
}
this.editingIndex = -1
this.persist()
this.rebuild()
}
/** Route key-bar input to the currently active tab. */
sendToActive(data: string): void {
this.tabs[this.activeIndex]?.session.send(data)
}
/** Re-assert the active tab's size (latest-writer-wins) when this device
* regains focus, so a shared session snaps back to this screen's size. */
refitActive(): void {
this.tabs[this.activeIndex]?.session.refit()
}
/** Apply theme + font settings to every terminal (M3). */
applySettings(s: Settings): void {
this.settings = s
const theme = THEMES[s.theme] ?? THEMES['dark']!
for (const t of this.tabs) t.session.applyTheme(theme, s.fontSize)
}
/** A read-only view of all tabs for the dashboard (M7). */
snapshot(): Array<{
idx: number
title: string
conn: string
claude: string
active: boolean
pending: boolean
}> {
return this.tabs.map((t, idx) => ({
idx,
title: this.displayTitle(t, idx),
conn: t.session.status,
claude: t.session.claudeStatus,
active: idx === this.activeIndex,
pending: t.session.pendingApproval,
}))
}
focusTab(idx: number): void {
this.activate(idx)
}
/** Scrollback search in the active tab (M1). */
findInActive(query: string, dir: 'next' | 'prev'): void {
const s = this.tabs[this.activeIndex]?.session
if (!s) return
if (dir === 'next') s.findNext(query)
else s.findPrevious(query)
}
clearActiveSearch(): void {
this.tabs[this.activeIndex]?.session.clearSearch()
}
/** Ask for notification permission once, on a user gesture (H2/H4). */
private maybeAskNotify(): void {
if (this.notifyAsked) return
this.notifyAsked = true
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
void Notification.requestPermission()
}
}
/** Browser notification for a background tab needing approval. */
private notify(entry: TabEntry): void {
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return
const title = this.displayTitle(entry, this.tabs.indexOf(entry))
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)
// W5: the 🏆 keep button appears only on a fan-out lane's cell (any layout).
const keepBtn = cell.querySelector<HTMLElement>('.cell-keep')
if (keepBtn) keepBtn.style.display = entry.fanoutGroupId !== undefined ? '' : 'none'
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)
el.classList.toggle('active', idx === this.activeIndex)
el.classList.toggle('unread', entry.hasActivity && idx !== this.activeIndex)
const cs = entry.session.claudeStatus
el.classList.toggle('claude-waiting', cs === 'waiting' && idx !== this.activeIndex)
el.classList.toggle('claude-stuck', cs === 'stuck' && idx !== this.activeIndex) // A5
const title = this.displayTitle(entry, idx)
el.title = title
const dot = el.querySelector('.tab-dot')
if (dot) dot.className = `tab-dot dot-${entry.session.status}`
const label = el.querySelector('.tab-label')
if (label) label.textContent = title
const claude = el.querySelector('.tab-claude')
if (claude) claude.textContent = claudeIcon(cs)
// W2: "⧗N" when the session has queued follow-ups, else empty (badge hidden).
const queue = el.querySelector('.tab-queue')
if (queue) {
const n = entry.session.queueLength
queue.textContent = n > 0 ? `${n}` : ''
}
const gauge = el.querySelector<HTMLElement>('.tab-gauge')
if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS, this.costBudgetUsd) // B2 + W3(b)
}
/** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */
private rebuild(): void {
this.tabBar.replaceChildren()
// Home (⌂): overlay the Sessions/Projects chooser over the open tabs so you
// can start another session/project without closing the current one.
const home = document.createElement('button')
home.className = 'tab-home'
home.textContent = '⌂'
home.title = 'Home — Sessions & Projects'
home.setAttribute('aria-label', 'Home — Sessions & Projects')
home.addEventListener('click', () => this.toggleHome())
this.tabBar.appendChild(home)
this.homeBtn = home
this.tabs.forEach((entry, idx) => {
const tabEl = document.createElement('div')
entry.el = tabEl
tabEl.className = 'tab'
// Switch on pointerdown (fires before any drag; reliable with draggable, works on touch).
tabEl.addEventListener('pointerdown', () => this.activate(idx))
// Middle-click closes.
tabEl.addEventListener('auxclick', (e) => {
if (e.button === 1) {
e.preventDefault()
this.closeTab(idx)
}
})
// Drag-to-reorder (disabled while renaming).
if (idx !== this.editingIndex) {
tabEl.draggable = true
tabEl.addEventListener('dragstart', (e) => {
this.dragIndex = idx
e.dataTransfer?.setData('text/plain', String(idx))
tabEl.classList.add('dragging')
})
tabEl.addEventListener('dragover', (e) => {
e.preventDefault()
tabEl.classList.add('drag-over')
})
tabEl.addEventListener('dragleave', () => tabEl.classList.remove('drag-over'))
tabEl.addEventListener('drop', (e) => {
e.preventDefault()
tabEl.classList.remove('drag-over')
this.moveTab(this.dragIndex, idx)
})
tabEl.addEventListener('dragend', () => {
this.dragIndex = -1
tabEl.classList.remove('dragging')
})
}
const dot = document.createElement('span')
dot.className = `tab-dot dot-${entry.session.status}`
tabEl.appendChild(dot)
if (idx === this.editingIndex) {
const input = document.createElement('input')
input.className = 'tab-rename'
input.value = this.displayTitle(entry, idx)
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') this.commitRename(idx, input.value)
else if (e.key === 'Escape') {
this.editingIndex = -1
this.rebuild()
}
e.stopPropagation()
})
input.addEventListener('blur', () => this.commitRename(idx, input.value))
input.addEventListener('pointerdown', (e) => e.stopPropagation()) // don't re-activate
tabEl.appendChild(input)
requestAnimationFrame(() => {
input.focus()
input.select()
})
} else {
const label = document.createElement('span')
label.className = 'tab-label'
label.textContent = this.displayTitle(entry, idx)
label.addEventListener('dblclick', () => {
this.editingIndex = idx
this.rebuild()
})
tabEl.appendChild(label)
const claude = document.createElement('span')
claude.className = 'tab-claude'
tabEl.appendChild(claude)
// W2: pending inject-queue depth badge (filled in by refreshTab).
const queue = document.createElement('span')
queue.className = 'tab-queue'
tabEl.appendChild(queue)
// B2: per-tab telemetry gauge container (filled in by refreshTab).
const gauge = document.createElement('span')
gauge.className = 'tab-gauge'
tabEl.appendChild(gauge)
const close = document.createElement('button')
close.className = 'tab-close'
close.textContent = '×'
close.title = 'Close tab (the shell keeps running on the server)'
close.setAttribute('aria-label', 'Close tab')
close.addEventListener('pointerdown', (e) => e.stopPropagation())
close.addEventListener('click', (e) => {
e.stopPropagation()
this.closeTab(idx)
})
tabEl.appendChild(close)
}
// Apply current active/unread state.
this.refreshTab(entry)
this.tabBar.appendChild(tabEl)
})
const add = document.createElement('button')
add.className = 'tab-add'
add.textContent = '+'
add.title = 'New session'
add.setAttribute('aria-label', 'New session')
add.addEventListener('click', () => this.newTab())
this.tabBar.appendChild(add)
// A4 timeline toggle + A1 push bell — re-appended each rebuild so they
// survive tabBar.replaceChildren() (the bell's subtree is mounted once).
const tl = document.createElement('button')
tl.className = 'tab-timeline'
tl.innerHTML = ICON_TIMELINE
tl.dataset.tip = 'Activity timeline'
tl.setAttribute('aria-label', 'Activity timeline')
tl.classList.toggle('active', this.timelineOpen)
tl.addEventListener('click', () => this.toggleTimeline())
this.timelineBtn = tl
this.tabBar.appendChild(tl)
this.tabBar.appendChild(this.pushHost)
// Show/hide the home screen based on whether any tab is open.
this.updateHomeView()
}
}