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

279
public/quick-reply.ts Normal file
View File

@@ -0,0 +1,279 @@
/**
* public/quick-reply.ts — N-quickreply: quick-reply chips + saved-prompt palette.
*
* Feature: A3 (Walk-away Workbench — "Quick-Reply Chips + Saved Prompt Palette")
*
* Provides:
* - BUILT_IN_CHIPS — pre-set chips for Claude Code's most common responses.
* - Immutable CRUD — addChip / removeChip / reorderChip / updateChip.
* - Palette I/O — loadPalette / savePalette (localStorage, never throw).
* - mountQuickReply — DOM mount: chips + inline editor.
*
* Security: SEC-L3 — all chip labels are set via textContent, never innerHTML,
* so a snippet label such as `<script>alert(1)</script>` appears as inert text.
*/
/** A single sendable snippet. */
export interface Chip {
/** Stable identifier (built-in chips use a double-underscore prefix). */
id: string
/** Raw bytes to send (without the Enter suffix). */
text: string
/** Display label on the chip button. Set via textContent — never innerHTML. */
label: string
/** If true, \r is appended to `text` when sending. */
appendEnter: boolean
}
// ── constants ─────────────────────────────────────────────────────────────────
/** localStorage key used to persist the user palette. */
export const PALETTE_KEY = 'web-terminal:quick-reply-palette'
/** The six built-in chips for Claude Code interaction. Immutable. */
export const BUILT_IN_CHIPS: readonly Chip[] = Object.freeze([
{ id: '__yes', text: 'yes', label: 'yes', appendEnter: true },
{ id: '__continue', text: 'continue', label: 'continue', appendEnter: true },
{ id: '__1', text: '1', label: '1', appendEnter: true },
{ id: '__2', text: '2', label: '2', appendEnter: true },
{ id: '__3', text: '3', label: '3', appendEnter: true },
{ id: '__esc', text: '\x1b', label: 'Esc', appendEnter: false },
])
// ── immutable CRUD ────────────────────────────────────────────────────────────
/** Return a new array with `chip` appended. Does not mutate `chips`. */
export function addChip(chips: readonly Chip[], chip: Chip): Chip[] {
return [...chips, chip]
}
/** Return a new array with the chip matching `id` removed. Does not mutate. */
export function removeChip(chips: readonly Chip[], id: string): Chip[] {
return chips.filter((c) => c.id !== id)
}
/**
* Return a new array with the chip at `fromIndex` moved to `toIndex`.
* Returns a shallow copy unchanged if either index is out of bounds.
* Does not mutate `chips`.
*/
export function reorderChip(chips: readonly Chip[], fromIndex: number, toIndex: number): Chip[] {
if (fromIndex < 0 || fromIndex >= chips.length) return [...chips]
if (toIndex < 0 || toIndex >= chips.length) return [...chips]
const result = [...chips]
const [moved] = result.splice(fromIndex, 1)
// moved is defined because fromIndex is valid (checked above)
result.splice(toIndex, 0, moved as Chip)
return result
}
/**
* Return a new array where the chip with `id` is shallow-merged with `patch`.
* The id field cannot be changed. Does not mutate `chips` or the chip objects.
*/
export function updateChip(
chips: readonly Chip[],
id: string,
patch: Partial<Omit<Chip, 'id'>>,
): Chip[] {
return chips.map((c) => (c.id === id ? { ...c, ...patch } : c))
}
// ── localStorage persistence ──────────────────────────────────────────────────
function isValidChip(item: unknown): item is Chip {
if (item === null || typeof item !== 'object') return false
const o = item as Record<string, unknown>
return (
typeof o['id'] === 'string' &&
typeof o['text'] === 'string' &&
typeof o['label'] === 'string' &&
typeof o['appendEnter'] === 'boolean'
)
}
/**
* Load the user palette from localStorage.
* Returns [] on missing key, bad JSON, or unexpected shape. Never throws.
*/
export function loadPalette(): Chip[] {
try {
const raw = localStorage.getItem(PALETTE_KEY)
if (!raw) return []
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(isValidChip)
} catch {
return []
}
}
/**
* Persist the user palette to localStorage.
* Silently ignores storage errors (quota exceeded, private mode). Never throws.
*/
export function savePalette(chips: readonly Chip[]): void {
try {
localStorage.setItem(PALETTE_KEY, JSON.stringify(chips))
} catch {
// Quota exceeded or storage unavailable — best-effort, ignore.
}
}
// ── DOM helpers ───────────────────────────────────────────────────────────────
/** Create a typed element with an optional CSS class and text content. */
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls?: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag)
if (cls) node.className = cls
if (text !== undefined) node.textContent = text // SEC-L3: textContent only
return node
}
/** Remove all children of `parent`. */
function clearChildren(parent: HTMLElement): void {
while (parent.firstChild) {
parent.removeChild(parent.firstChild)
}
}
// ── mountQuickReply ───────────────────────────────────────────────────────────
/** Options for mountQuickReply. */
export interface QuickReplyOpts {
/** Called with the full byte string to inject into the terminal. */
onSend: (data: string) => void
}
/** Returned by mountQuickReply to allow cleanup. */
export interface QuickReplyHandle {
/** Remove all DOM content and event listeners created by mountQuickReply. */
dispose: () => void
}
/**
* Mount the quick-reply chip row + palette into `container`.
*
* Renders built-in chips, then user palette chips, then a `+` button that
* opens an inline editor to add new snippets. Clicking a chip calls
* `opts.onSend(text [+ '\r'])`.
*
* Labels are set via textContent — never innerHTML (SEC-L3).
*/
export function mountQuickReply(container: HTMLElement, opts: QuickReplyOpts): QuickReplyHandle {
const { onSend } = opts
// Track disposers for all event listeners so dispose() is clean.
const disposers: Array<() => void> = []
function makeChipButton(chip: Chip): HTMLButtonElement {
const btn = el('button', 'qr-chip')
btn.textContent = chip.label // SEC-L3: textContent, never innerHTML
btn.title = chip.appendEnter ? `${chip.text}` : chip.text
function handler() {
onSend(chip.text + (chip.appendEnter ? '\r' : ''))
}
btn.addEventListener('click', handler)
disposers.push(() => btn.removeEventListener('click', handler))
return btn
}
function openEditor(): void {
const editor = el('div', 'qr-editor')
const textInput = document.createElement('input')
textInput.type = 'text'
textInput.className = 'qr-editor-text'
textInput.placeholder = 'Text to send'
const labelInput = document.createElement('input')
labelInput.type = 'text'
labelInput.className = 'qr-editor-label'
labelInput.placeholder = 'Label (optional, defaults to text)'
const enterWrap = el('label', 'qr-editor-enter-label')
const enterCheck = document.createElement('input')
enterCheck.type = 'checkbox'
enterCheck.className = 'qr-editor-enter'
enterCheck.checked = true
enterWrap.appendChild(enterCheck)
enterWrap.appendChild(document.createTextNode(' Append Enter'))
const saveBtn = el('button', 'qr-editor-save', 'Save')
const cancelBtn = el('button', 'qr-editor-cancel', 'Cancel')
function onSaveClick() {
const text = textInput.value.trim()
if (!text) return // empty text — no-op, editor stays open
const rawLabel = labelInput.value.trim()
const label = rawLabel !== '' ? rawLabel : text
const newChip: Chip = {
id: `user_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`,
text,
label,
appendEnter: enterCheck.checked,
}
savePalette(addChip(loadPalette(), newChip))
editor.remove()
render()
}
function onCancelClick() {
editor.remove()
}
saveBtn.addEventListener('click', onSaveClick)
cancelBtn.addEventListener('click', onCancelClick)
editor.appendChild(textInput)
editor.appendChild(labelInput)
editor.appendChild(enterWrap)
editor.appendChild(saveBtn)
editor.appendChild(cancelBtn)
container.appendChild(editor)
}
function render(): void {
clearChildren(container)
const row = el('div', 'qr-row')
// Built-in chips first
for (const chip of BUILT_IN_CHIPS) {
row.appendChild(makeChipButton(chip))
}
// User palette chips
for (const chip of loadPalette()) {
row.appendChild(makeChipButton(chip))
}
// Add-snippet button
const addBtn = el('button', 'qr-add-btn', '+')
addBtn.title = 'Add custom snippet'
addBtn.setAttribute('aria-label', 'Add custom snippet')
function onAddClick() {
openEditor()
}
addBtn.addEventListener('click', onAddClick)
disposers.push(() => addBtn.removeEventListener('click', onAddClick))
row.appendChild(addBtn)
container.appendChild(row)
}
render()
return {
dispose() {
for (const cleanup of disposers) cleanup()
clearChildren(container)
},
}
}