/** * 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, type ProjectsPanel } from './projects.js' import type { ApprovalPreview, ClaudeStatus, 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 { 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' 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) // 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 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), }) 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 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) }) } /** 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 } /** 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 } } 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) —