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:
309
public/tabs.ts
309
public/tabs.ts
@@ -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/dot — never 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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user