feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build

Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.

Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.

New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).

Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).

Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
This commit is contained in:
Yaojia Wang
2026-06-30 17:42:18 +02:00
parent 4f1d3ebc6b
commit d6809c65c4
54 changed files with 13171 additions and 200 deletions

View File

@@ -25,10 +25,25 @@ import { TerminalSession } from './terminal-session.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'
@@ -38,6 +53,10 @@ interface TabEntry {
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 {
@@ -45,6 +64,15 @@ interface StoredTab {
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
@@ -64,6 +92,15 @@ export class TabApp {
// 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
@@ -94,6 +131,13 @@ export class TabApp {
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.
@@ -101,6 +145,61 @@ export class TabApp {
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 ────────────────────────────────────────── */
/**
@@ -189,7 +288,8 @@ export class TabApp {
/* ── Approval bar ───────────────────────────────────────────────── */
/** Show/hide the approve/reject banner for the active tab's held request (H3). */
/** 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) {
@@ -199,25 +299,150 @@ export class TabApp {
this.approvalBar.replaceChildren()
const label = document.createElement('span')
label.className = 'approval-label'
label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}`
const approve = document.createElement('button')
approve.className = 'approval-yes'
approve.textContent = '✓ Approve'
approve.addEventListener('click', () => {
session.approve()
this.updateApprovalBar()
})
const reject = document.createElement('button')
reject.className = 'approval-no'
reject.textContent = '✗ Reject'
reject.addEventListener('click', () => {
session.reject()
this.updateApprovalBar()
})
this.approvalBar.append(label, approve, reject)
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}`
}
@@ -287,8 +512,17 @@ export class TabApp {
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 }
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)
@@ -319,10 +553,13 @@ export class TabApp {
* distinguish them (e.g. "web-terminal #2"). Mirrors newTabForResume in
* structure: addEntry → persist → rebuild → activate.
*/
openProject(repoPath: string, repoName: string, cmd = 'claude\r'): void {
openProject(repoPath: string, repoName: string, cmd = 'claude\r', mode?: PermissionMode): void {
const n = this.countOpenWithTitlePrefix(repoName)
const label = n === 0 ? repoName : `${repoName} #${n + 1}`
this.addEntry(null, label, repoPath || undefined, cmd)
// 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)
@@ -346,12 +583,14 @@ export class TabApp {
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) {
@@ -464,7 +703,9 @@ export class TabApp {
/* ── rendering ───────────────────────────────────────────────────── */
/** In-place update of one tab's classes/label/dotnever destroys the DOM. */
/** 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
@@ -473,6 +714,7 @@ export class TabApp {
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')
@@ -480,10 +722,9 @@ export class TabApp {
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' ? '✓' : ''
}
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). */
@@ -577,6 +818,11 @@ export class TabApp {
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 = '×'
@@ -603,6 +849,19 @@ export class TabApp {
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.textContent = '📜'
tl.title = '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()
}