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:
@@ -8,7 +8,7 @@
|
||||
* (Phase 1#5), and the v0.6 openProject / home segmented-control wiring (P6).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// ── Mock TerminalSession ──────────────────────────────────────────────────────
|
||||
let constructed = 0
|
||||
@@ -19,6 +19,11 @@ class FakeTerminalSession {
|
||||
claudeStatus = 'unknown'
|
||||
cwd: string | null = null
|
||||
pendingApproval = false
|
||||
pendingTool: string | undefined = undefined
|
||||
/** B4: which gate the held approval is — 'tool' (two buttons) or 'plan' (three). */
|
||||
pendingGate: 'tool' | 'plan' | null = null
|
||||
/** B2: latest statusLine telemetry (single source of truth). */
|
||||
telemetry: unknown = null
|
||||
/** Captured so tests can assert initialInput ends with \r (Finding 1). */
|
||||
initialInput: string | undefined = undefined
|
||||
connect = vi.fn()
|
||||
@@ -39,6 +44,7 @@ class FakeTerminalSession {
|
||||
onStatus?: (s: string) => void
|
||||
onActivity?: () => void
|
||||
onTitle?: (t: string) => void
|
||||
onTelemetry?: (t: unknown) => void
|
||||
}
|
||||
static instances: FakeTerminalSession[] = []
|
||||
constructor(opts: {
|
||||
@@ -48,6 +54,7 @@ class FakeTerminalSession {
|
||||
onStatus?: (s: string) => void
|
||||
onActivity?: () => void
|
||||
onTitle?: (t: string) => void
|
||||
onTelemetry?: (t: unknown) => void
|
||||
[key: string]: unknown
|
||||
}) {
|
||||
constructed += 1
|
||||
@@ -61,6 +68,37 @@ class FakeTerminalSession {
|
||||
|
||||
vi.mock('../public/terminal-session.js', () => ({ TerminalSession: FakeTerminalSession }))
|
||||
|
||||
// ── Mock the v0.7 panels (push / quick-reply / timeline / voice / gauge) ──────
|
||||
const renderTelemetryGauge = vi.fn()
|
||||
vi.mock('../public/preview-grid.js', () => ({ renderTelemetryGauge }))
|
||||
|
||||
const mountPushToggle = vi.fn()
|
||||
vi.mock('../public/push.js', () => ({ mountPushToggle }))
|
||||
|
||||
const quickReply: { onSend?: (d: string) => void } = {}
|
||||
const mountQuickReply = vi.fn((_c: HTMLElement, opts: { onSend: (d: string) => void }) => {
|
||||
quickReply.onSend = opts.onSend
|
||||
return { dispose: vi.fn() }
|
||||
})
|
||||
vi.mock('../public/quick-reply.js', () => ({ mountQuickReply }))
|
||||
|
||||
const timelineDispose = vi.fn()
|
||||
const mountTimeline = vi.fn(() => ({ dispose: timelineDispose }))
|
||||
vi.mock('../public/timeline.js', () => ({ mountTimeline }))
|
||||
|
||||
const voice: { onTranscript?: (t: string) => void; onInterim?: (t: string) => void } = {}
|
||||
const voiceStart = vi.fn()
|
||||
const voiceStop = vi.fn()
|
||||
const createVoiceInput = vi.fn(
|
||||
(onTranscript: (t: string) => void, opts?: { onInterim?: (t: string) => void }) => {
|
||||
voice.onTranscript = onTranscript
|
||||
voice.onInterim = opts?.onInterim
|
||||
return { start: voiceStart, stop: voiceStop, dispose: vi.fn(), isActive: () => false }
|
||||
},
|
||||
)
|
||||
const isSpeechSupported = vi.fn(() => true)
|
||||
vi.mock('../public/voice.js', () => ({ createVoiceInput, isSpeechSupported }))
|
||||
|
||||
// ── Mock the launcher (records visibility) ────────────────────────────────────
|
||||
const launcherVisible = { value: false }
|
||||
const mountLauncher = vi.fn(() => ({
|
||||
@@ -88,10 +126,21 @@ const { TabApp } = await import('../public/tabs.js')
|
||||
function makeHosts(): { paneHost: HTMLElement; tabBar: HTMLElement } {
|
||||
const paneHost = document.createElement('div')
|
||||
const tabBar = document.createElement('div')
|
||||
document.body.append(paneHost, tabBar)
|
||||
// #keybar must exist so the quick-reply row mounts directly above it (A3).
|
||||
const keybar = document.createElement('div')
|
||||
keybar.id = 'keybar'
|
||||
document.body.append(paneHost, tabBar, keybar)
|
||||
return { paneHost, tabBar }
|
||||
}
|
||||
|
||||
/** Stub fetch('/config/ui') → { allowAutoMode }. */
|
||||
function stubUiConfigFetch(allowAutoMode: boolean): void {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({ ok: true, json: async () => ({ allowAutoMode }) })),
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
constructed = 0
|
||||
FakeTerminalSession.instances = []
|
||||
@@ -99,6 +148,23 @@ beforeEach(() => {
|
||||
projectsVisible.value = false
|
||||
document.body.replaceChildren()
|
||||
localStorage.clear()
|
||||
renderTelemetryGauge.mockClear()
|
||||
mountPushToggle.mockClear()
|
||||
mountQuickReply.mockClear()
|
||||
mountTimeline.mockClear()
|
||||
timelineDispose.mockClear()
|
||||
createVoiceInput.mockClear()
|
||||
voiceStart.mockClear()
|
||||
voiceStop.mockClear()
|
||||
quickReply.onSend = undefined
|
||||
voice.onTranscript = undefined
|
||||
voice.onInterim = undefined
|
||||
// Default: server forbids 'auto' (loadUiConfig resolves to allowAutoMode=false).
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('no fetch in test') }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('TabApp — v0.5 launcher chooser', () => {
|
||||
@@ -511,3 +577,216 @@ describe('TabApp — Home (⌂) button: reach the chooser from an open session',
|
||||
expect(seg.style.display).toBe('flex') // still shown, no crash
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — v0.7 panels mount once (A1 push / A3 quick-reply)', () => {
|
||||
it('mounts the 🔔 push toggle exactly once on construction (A1)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
new TabApp(paneHost, tabBar)
|
||||
expect(mountPushToggle).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('mounts the quick-reply row directly above #keybar (A3)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
new TabApp(paneHost, tabBar)
|
||||
expect(mountQuickReply).toHaveBeenCalledTimes(1)
|
||||
const qr = document.getElementById('quickreply')
|
||||
expect(qr).not.toBeNull()
|
||||
expect(qr?.nextElementSibling).toBe(document.getElementById('keybar'))
|
||||
})
|
||||
|
||||
it('quick-reply onSend routes bytes to the active session (A3-FR3)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
quickReply.onSend?.('yes\r')
|
||||
expect(FakeTerminalSession.instances[0]!.send).toHaveBeenCalledWith('yes\r')
|
||||
})
|
||||
|
||||
it('the push bell survives a tab-bar rebuild (re-parented, not re-mounted)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab() // rebuild
|
||||
app.newTab() // rebuild again
|
||||
expect(mountPushToggle).toHaveBeenCalledTimes(1)
|
||||
expect(tabBar.querySelector('.push-host')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — B2 telemetry gauge + A5 stuck badge (SP10/M4)', () => {
|
||||
it('renders the telemetry gauge from the session.telemetry single source of truth', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
const session = FakeTerminalSession.instances[0]!
|
||||
const tele = { at: Date.now(), costUsd: 0.5, contextUsedPct: 40 }
|
||||
session.telemetry = tele
|
||||
renderTelemetryGauge.mockClear()
|
||||
session.cbs.onTelemetry?.(tele) // server pushed new telemetry
|
||||
expect(renderTelemetryGauge).toHaveBeenCalled()
|
||||
expect(renderTelemetryGauge.mock.calls.at(-1)![1]).toBe(tele)
|
||||
})
|
||||
|
||||
it('a stuck claudeStatus shows ⚠ + the claude-stuck class on a background tab (AC-A5.5)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab() // tab 0
|
||||
app.newTab() // tab 1 active → tab 0 background
|
||||
const bg = FakeTerminalSession.instances[0]!
|
||||
bg.claudeStatus = 'stuck'
|
||||
bg.cbs.onClaudeStatus?.('stuck')
|
||||
const tab0 = tabBar.querySelectorAll('.tab')[0]!
|
||||
expect(tab0.classList.contains('claude-stuck')).toBe(true)
|
||||
expect(tab0.querySelector('.tab-claude')?.textContent).toBe('⚠')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — B4 plan-gate approval bar', () => {
|
||||
function openWithGate(gate: 'tool' | 'plan', tool?: string): FakeTerminalSession {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
const session = FakeTerminalSession.instances.at(-1)!
|
||||
session.pendingApproval = true
|
||||
session.pendingGate = gate
|
||||
session.pendingTool = tool
|
||||
session.cbs.onClaudeStatus?.('waiting')
|
||||
return session
|
||||
}
|
||||
|
||||
it('a tool gate renders exactly two buttons (no regression, AC-B4.3)', () => {
|
||||
openWithGate('tool', 'Bash')
|
||||
const bar = document.getElementById('approvalbar')!
|
||||
expect(bar.style.display).toBe('flex')
|
||||
expect(bar.querySelectorAll('button')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('a plan gate maps three buttons to acceptEdits / default / reject (AC-B4.2)', () => {
|
||||
const session = openWithGate('plan')
|
||||
const bar = document.getElementById('approvalbar')!
|
||||
const click = (i: number): void => (bar.querySelectorAll('button')[i] as HTMLButtonElement).click()
|
||||
expect(bar.querySelectorAll('button')).toHaveLength(3)
|
||||
click(0) // Approve + Auto
|
||||
expect(session.approve).toHaveBeenLastCalledWith('acceptEdits')
|
||||
click(1) // Approve + Review
|
||||
expect(session.approve).toHaveBeenLastCalledWith('default')
|
||||
click(2) // Keep Planning
|
||||
expect(session.reject).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — B4 permission-mode relay', () => {
|
||||
it('openProject with a non-default mode upgrades the launch command (AC-B4.1)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openProject('/p', 'repo', 'claude\r', 'plan')
|
||||
expect(FakeTerminalSession.instances.at(-1)!.initialInput).toBe(
|
||||
'claude --permission-mode plan\r',
|
||||
)
|
||||
})
|
||||
|
||||
it('openProject with default mode keeps the plain claude command (no regression)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openProject('/p', 'repo', 'claude\r', 'default')
|
||||
expect(FakeTerminalSession.instances.at(-1)!.initialInput).toBe('claude\r')
|
||||
})
|
||||
|
||||
it('availablePermissionModes hides "auto" until the server allows it (SEC-M5/AC-B4.4)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar) // default fetch stub → allowAutoMode false
|
||||
expect(app.availablePermissionModes()).toEqual(['default', 'acceptEdits', 'plan'])
|
||||
})
|
||||
|
||||
it('availablePermissionModes includes "auto" once /config/ui allows it', async () => {
|
||||
stubUiConfigFetch(true)
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
await vi.waitFor(() => expect(app.availablePermissionModes()).toContain('auto'))
|
||||
})
|
||||
|
||||
it('loadDefaultMode downgrades stored "auto" to "default" when the server forbids it', () => {
|
||||
localStorage.setItem('web-terminal:permission-mode', 'auto')
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar) // allowAutoMode stays false
|
||||
expect(app.loadDefaultMode()).toBe('default')
|
||||
})
|
||||
|
||||
it('saveDefaultMode round-trips a valid mode; invalid stored value falls back', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.saveDefaultMode('plan')
|
||||
expect(app.loadDefaultMode()).toBe('plan')
|
||||
localStorage.setItem('web-terminal:permission-mode', 'garbage')
|
||||
expect(app.loadDefaultMode()).toBe('default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — A4 timeline panel', () => {
|
||||
const SID = '44444444-4444-4444-8444-444444444444'
|
||||
|
||||
it('toggling 📜 mounts the timeline for the active session, then disposes on close', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openSession(SID)
|
||||
const tlBtn = tabBar.querySelector('.tab-timeline') as HTMLButtonElement
|
||||
expect(tlBtn).not.toBeNull()
|
||||
|
||||
tlBtn.click() // open
|
||||
expect(mountTimeline).toHaveBeenCalledTimes(1)
|
||||
expect(document.getElementById('timeline-panel')!.style.display).toBe('block')
|
||||
|
||||
tlBtn.click() // close
|
||||
expect(timelineDispose).toHaveBeenCalled()
|
||||
expect(document.getElementById('timeline-panel')!.style.display).toBe('none')
|
||||
})
|
||||
|
||||
it('closing a tab with its timeline open disposes the polling handle', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.openSession(SID)
|
||||
;(tabBar.querySelector('.tab-timeline') as HTMLButtonElement).click() // open
|
||||
expect(mountTimeline).toHaveBeenCalled()
|
||||
app.closeTab(0)
|
||||
expect(timelineDispose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TabApp — A2 push-to-talk voice', () => {
|
||||
it('handleVoiceTrigger("start") creates a recognizer and shows the interim overlay', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.handleVoiceTrigger('start')
|
||||
expect(createVoiceInput).toHaveBeenCalledTimes(1)
|
||||
expect(voiceStart).toHaveBeenCalled()
|
||||
expect(document.getElementById('voice-interim')!.style.display).toBe('block')
|
||||
})
|
||||
|
||||
it('a final transcript is sent to the active session as raw input (A2-FR1)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.newTab()
|
||||
app.handleVoiceTrigger('start')
|
||||
voice.onTranscript?.('list the files')
|
||||
expect(FakeTerminalSession.instances[0]!.send).toHaveBeenCalledWith('list the files')
|
||||
})
|
||||
|
||||
it('interim text updates the overlay; "stop" hides it (A2-FR2)', () => {
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
app.handleVoiceTrigger('start')
|
||||
voice.onInterim?.('hello wor')
|
||||
const overlay = document.getElementById('voice-interim')!
|
||||
expect(overlay.textContent).toBe('hello wor')
|
||||
app.handleVoiceTrigger('stop')
|
||||
expect(voiceStop).toHaveBeenCalled()
|
||||
expect(overlay.style.display).toBe('none')
|
||||
})
|
||||
|
||||
it('voice is a no-op when SpeechRecognition is unsupported (AC-A2.3)', () => {
|
||||
createVoiceInput.mockReturnValueOnce(null as never)
|
||||
const { paneHost, tabBar } = makeHosts()
|
||||
const app = new TabApp(paneHost, tabBar)
|
||||
expect(() => app.handleVoiceTrigger('start')).not.toThrow()
|
||||
expect(voiceStart).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user