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:
279
public/quick-reply.ts
Normal file
279
public/quick-reply.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user