Files
web-terminal/public/tabs.ts
Yaojia Wang 7fd2ef3fc8 polish(v0.7): styled hover tooltips on timeline + push toggle (explain the icon)
Give the two tab-bar line-icon buttons the same styled data-tip hover tooltip as
the toolbar icons, so their meaning is clear on hover (native title was slow/ugly):
- timeline → "Activity timeline"
- push toggle → "Notifications off — tap to turn on" / "…on — tap to turn off"
Left-anchored (they sit on the left of the tab bar). 158 push+tabs tests green.
2026-06-30 19:28:15 +02:00

870 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* public/tabs.ts — multi-tab manager + tab bar UI.
*
* Each tab owns an independent TerminalSession (own WS + server session).
*
* Titles: auto from the terminal (current folder, via folderFromTitle) →
* double-click to rename inline → falls back to "Term N". Manual wins.
* Interaction: pointerdown to switch (robust with draggable), drag to reorder,
* middle-click / × to close, status dot (connection color), unread dot.
*
* DOM is built once per tab and updated in place. A full rebuild happens ONLY
* on structural changes (add / close / reorder / rename) — never on a plain
* activate or on background output — so clicks are never eaten by a re-render.
*
* Closing a tab only disconnects its WS (a *detach*); the server PTY keeps
* running and is reclaimed by IDLE_TTL.
*
* v0.6 (P6): the home screen has a segmented control ("Sessions" / "Projects")
* that switches between the session launcher and the projects panel. State is
* persisted to localStorage. openProject() spawns a new claude session in a
* repo directory, labelled with the repo name (deduped with #N suffixes).
*/
import { TerminalSession } from './terminal-session.js'
import { ICON_TIMELINE } from './icons.js'
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
import { mountLauncher, type Launcher } from './launcher.js'
import { mountProjects, type ProjectsPanel } from './projects.js'
import type { ClaudeStatus, PermissionMode, UiConfig } from '../src/types.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'
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
/** 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)
// 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
// 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
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)
// 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. */
private setupVoiceOverlay(): void {
const overlay = document.createElement('div')
overlay.id = 'voice-interim'
overlay.style.display = 'none'
document.body.appendChild(overlay)
this.voiceOverlay = overlay
}
/** 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
}
} 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: hide every terminal pane so the chooser is on top.
for (const t of this.tabs) t.session.hide()
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)
}
}
/** 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'
if (session.pendingGate === 'plan') {
label.textContent = 'Claude finished planning — how should it proceed?'
this.approvalBar.append(label, ...this.planGateButtons(session))
} else {
label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}`
this.approvalBar.append(label, ...this.toolGateButtons(session))
}
this.approvalBar.style.display = 'flex'
}
/** 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) => this.sendToActive(text), {
onInterim: (t) => this.showInterim(t),
})
}
if (!this.voice) return // SpeechRecognition unsupported — no-op (AC-A2.3)
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'
}
/** 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(),
// 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 when a background tab needs approval (H2/H4).
if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) {
this.notify(entry)
}
},
// B2: telemetry is the single source of truth on the session; just re-render.
onTelemetry: () => this.refreshTab(entry),
})
entry = {
session,
customTitle,
autoTitle: null,
hasActivity: false,
el: null,
timelineHandle: null,
}
this.paneHost.appendChild(session.el)
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
this.maybeAskNotify() // first switch is a user gesture — request notif permission
this.homeForced = false // showing a terminal dismisses any home overlay
this.activeIndex = i
const entry = this.tabs[i]
if (entry) entry.hasActivity = false // viewing clears the unread dot
this.tabs.forEach((t, idx) => (idx === i ? t.session.show() : t.session.hide()))
this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild
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
const [entry] = this.tabs.splice(i, 1)
entry?.timelineHandle?.dispose() // A4: stop polling for the closed tab
entry?.session.dispose()
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' })
}
/* ── 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 {
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)
const gauge = el.querySelector<HTMLElement>('.tab-gauge')
if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS) // B2
}
/** 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)
// 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()
}
}