Four small, high-delight features that turn passive capture into glanceable signals.
- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
into the existing concurrent per-repo metadata pass (git rev-list --count
--left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
(Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
/config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.
All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
1599 lines
65 KiB
TypeScript
1599 lines
65 KiB
TypeScript
/**
|
||
* 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 { 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'
|
||
|
||
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
|
||
// 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
|
||
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),
|
||
// 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
|
||
}
|
||
|
||
/** 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
|
||
}
|
||
} 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))
|
||
})
|
||
head.append(monBtn, 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
|
||
}
|
||
|
||
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)
|
||
|
||
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()
|
||
}
|
||
}
|