The walk-away primitive: queue follow-up prompts and let a session advance itself
while you're gone — "run the tests" drains, and when Claude next goes idle "open a
PR" drains. Injection reuses writeInput (byte-identical to a keystroke, broadcasts
to all mirrored devices); the byte-shuttle is untouched.
- POST/GET/DELETE /live-sessions/:id/queue — POST/DELETE behind requireAllowedOrigin
+ a per-IP rate limit (QUEUE_RATE_MAX=20/min) + SESSION_ID_RE; text non-empty,
byte-capped (QUEUE_ITEM_MAX_BYTES=4096, 16kb body), count-capped (QUEUE_MAX_ITEMS=10).
- Bounded per-session queue in manager (enqueueFollowup/drainOne/clearQueue);
new optional queueLength on LiveSessionInfo.
- Idle drain: the Stop/SessionEnd /hook branch scheduleDrain()s a debounced timer
(QUEUE_SETTLE_MS=1500). It drains exactly one entry only if the session still
exists, hasn't exited, produced no output during settle, and is idle — three
guards (debounced single timer + pop-one + settle re-check) → one entry per idle.
- New additive ServerMessage {type:'queue',length} broadcasts the count to mirrors
(⧗N badge); FE enqueue via the quick-reply editor + TabApp.enqueueToActive.
Injected bytes go verbatim to the PTY (never built into a shell command). Verified
independently: typecheck + build:web clean, 1737 tests pass (real-PTY integration
covers idle-drain / one-per-idle / settle-guard). Foundation for templated launches,
auto-continue, and issue-intake (docs/ROADMAP.md).
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
/**
|
|
* 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
|
|
/** W2: optional. When provided, the snippet editor gains a "Queue" button that
|
|
* enqueues the typed text as a follow-up prompt (fired on next idle) instead of
|
|
* sending it now. `appendEnter` mirrors the editor checkbox. */
|
|
onQueue?: (text: string, appendEnter: boolean) => 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, onQueue } = 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)
|
|
|
|
// W2: "Queue" defers the typed text as a follow-up prompt (fires on next
|
|
// idle) rather than saving it as a chip. Shown only when a handler is wired.
|
|
if (onQueue !== undefined) {
|
|
const queueBtn = el('button', 'qr-editor-queue', 'Queue')
|
|
queueBtn.title = 'Queue as a follow-up (runs when Claude next goes idle)'
|
|
function onQueueClick() {
|
|
const text = textInput.value.trim()
|
|
if (!text) return // empty text — no-op, editor stays open
|
|
onQueue?.(text, enterCheck.checked)
|
|
editor.remove()
|
|
}
|
|
queueBtn.addEventListener('click', onQueueClick)
|
|
editor.appendChild(queueBtn)
|
|
}
|
|
|
|
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)
|
|
},
|
|
}
|
|
}
|