/** * 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() 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 | 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 { 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)['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)?.['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)?.['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('.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) —