Files
web-terminal/public/tabs.ts
Yaojia Wang c81cebdc47 feat(v0.3): H4 — live Claude status badge + notifications + setup-hooks
- terminal-session: handle 'status' frame → claudeStatus + onClaudeStatus
- tabs: per-tab Claude glyph (⚙ working /  waiting / ✓ idle); inactive
  'waiting' tab gets an amber highlight + a browser notification (permission
  requested on first tab switch)
- scripts/setup-hooks.mjs + 'npm run setup-hooks': idempotently install/remove
  command hooks in ~/.claude/settings.json (inline curl to $WEBTERM_HOOK_URL with
  $WEBTERM_SESSION; no-op outside web-terminal); backs up settings.json
- Browser-verified end-to-end: PermissionRequest→, PreToolUse→⚙, Stop→✓;
  inactive waiting tab shows amber + glyph. 205 tests green.
2026-06-17 19:00:34 +02:00

377 lines
13 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.
*/
import { TerminalSession } from './terminal-session.js'
const TABS_KEY = 'web-terminal:tabs'
const ACTIVE_KEY = 'web-terminal:active'
const LEGACY_KEY = 'web-terminal:sessionId' // single-session key from v0.1
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)
}
interface StoredTab {
id: string | null
title: string | null
}
export class TabApp {
private readonly tabs: TabEntry[] = []
private activeIndex = -1
private editingIndex = -1
private dragIndex = -1
private notifyAsked = false
private readonly paneHost: HTMLElement
private readonly tabBar: HTMLElement
constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
this.paneHost = paneHost
this.tabBar = tabBar
this.restore()
}
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
}
}
private restore(): void {
let stored: StoredTab[] = []
try {
const raw = localStorage.getItem(TABS_KEY)
if (raw) {
const parsed: unknown = JSON.parse(raw)
if (Array.isArray(parsed)) {
stored = parsed.map((v): StoredTab => {
if (typeof v === 'string') return { id: v, title: null } // v0.2.0 format
if (v && typeof v === 'object' && 'id' in v) {
const o = v as { id: unknown; title?: unknown }
return {
id: typeof o.id === 'string' ? o.id : null,
title: typeof o.title === 'string' ? o.title : null,
}
}
return { id: null, title: null }
})
}
}
} catch {
// ignore malformed state
}
if (stored.length === 0) {
let legacy: string | null = null
try {
legacy = localStorage.getItem(LEGACY_KEY)
} catch {
legacy = null
}
stored = [{ id: legacy, title: null }]
}
for (const s of stored) this.addEntry(s.id, s.title)
let active = 0
try {
active = parseInt(localStorage.getItem(ACTIVE_KEY) ?? '0', 10)
} catch {
active = 0
}
if (!Number.isInteger(active) || active < 0 || active >= this.tabs.length) active = 0
this.rebuild()
this.activate(active)
}
/* ── tab lifecycle ───────────────────────────────────────────── */
private addEntry(sessionId: string | null, customTitle: string | null): TabEntry {
const entry: TabEntry = {
session: null as unknown as TerminalSession,
customTitle,
autoTitle: null,
hasActivity: false,
el: null,
}
entry.session = new TerminalSession({
sessionId,
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)
// Notify when a background tab needs approval (H2/H4).
if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) {
this.notify(entry)
}
},
})
this.paneHost.appendChild(entry.session.el)
this.tabs.push(entry)
entry.session.connect()
return entry
}
newTab(): void {
this.addEntry(null, null)
this.persist()
this.rebuild()
this.activate(this.tabs.length - 1)
}
activate(i: number): void {
if (i < 0 || i >= this.tabs.length) return
this.maybeAskNotify() // first switch is a user gesture — request notif permission
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.persist()
}
closeTab(i: number): void {
if (i < 0 || i >= this.tabs.length) return
const [entry] = this.tabs.splice(i, 1)
entry?.session.dispose()
if (this.editingIndex === i) this.editingIndex = -1
if (this.tabs.length === 0) {
this.addEntry(null, null)
this.persist()
this.rebuild()
this.activate(0)
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)
}
/** 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 — never destroys the DOM. */
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)
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 =
cs === 'working' ? '⚙' : cs === 'waiting' ? '⏳' : cs === 'idle' ? '✓' : ''
}
}
/** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */
private rebuild(): void {
this.tabBar.replaceChildren()
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)
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)
}
}