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.
180 lines
5.3 KiB
TypeScript
180 lines
5.3 KiB
TypeScript
/**
|
|
* public/voice.ts — Web Speech API wrapper for push-to-talk voice input (A2).
|
|
*
|
|
* Pure frontend module. Audio NEVER reaches this server — the browser's speech
|
|
* engine handles it (Chrome sends audio to Google; see SEC-L2 / AC-A2.5).
|
|
* The final transcript is delivered to the caller, who sends it as raw terminal
|
|
* input via the existing WS input path (byte-shuttle unchanged).
|
|
*
|
|
* Usage:
|
|
* const voice = createVoiceInput((text) => sendToTerminal(text), { autoSend: true })
|
|
* if (!voice) { /* hide mic UI *\/ }
|
|
* micBtn.addEventListener('touchstart', () => voice.start())
|
|
* micBtn.addEventListener('touchend', () => voice.stop())
|
|
*/
|
|
|
|
// ── Local SpeechRecognition interface (not in TypeScript 6.x DOM lib) ─────────
|
|
|
|
/** Minimal interface for the Web Speech recognition object. */
|
|
interface ISpeechRecognition {
|
|
continuous: boolean
|
|
interimResults: boolean
|
|
lang: string
|
|
onresult: ((event: ISpeechRecognitionEvent) => void) | null
|
|
onend: (() => void) | null
|
|
onerror: ((event: { error: string }) => void) | null
|
|
start(): void
|
|
stop(): void
|
|
abort(): void
|
|
}
|
|
|
|
interface ISpeechRecognitionEvent {
|
|
resultIndex: number
|
|
results: ISpeechRecognitionResultList
|
|
}
|
|
|
|
interface ISpeechRecognitionResultList {
|
|
length: number
|
|
[index: number]: ISpeechRecognitionResult | undefined
|
|
}
|
|
|
|
interface ISpeechRecognitionResult {
|
|
isFinal: boolean
|
|
[index: number]: ISpeechRecognitionAlternative | undefined
|
|
}
|
|
|
|
interface ISpeechRecognitionAlternative {
|
|
transcript: string
|
|
confidence: number
|
|
}
|
|
|
|
type SpeechRecognitionCtor = new () => ISpeechRecognition
|
|
|
|
type SpeechRecognitionWindow = Window & {
|
|
SpeechRecognition?: SpeechRecognitionCtor
|
|
webkitSpeechRecognition?: SpeechRecognitionCtor
|
|
}
|
|
|
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
|
|
/** Options for createVoiceInput. */
|
|
export interface VoiceInputOptions {
|
|
/** Called with intermediate (non-final) text while the user is still speaking. */
|
|
onInterim?: (text: string) => void
|
|
/**
|
|
* When true, the final transcript is suffixed with \r (carriage return) so
|
|
* Claude receives it immediately without the user pressing Enter (A2-FR3).
|
|
* Default: false — insert text only; user presses Enter manually.
|
|
*/
|
|
autoSend?: boolean
|
|
/**
|
|
* BCP-47 language tag, e.g. 'en-US', 'ja-JP'.
|
|
* Default: navigator.language (the browser's UI language).
|
|
*/
|
|
lang?: string
|
|
}
|
|
|
|
/**
|
|
* A live voice recognition session. Obtain via createVoiceInput().
|
|
* Call dispose() when the owning component unmounts.
|
|
*/
|
|
export interface VoiceInput {
|
|
/** Begin listening (no-op if already active or disposed). */
|
|
start(): void
|
|
/** Stop listening and emit the final result (no-op if not active). */
|
|
stop(): void
|
|
/** Abort and clean up; no callbacks fire after this call. */
|
|
dispose(): void
|
|
/** True while recognition is in progress. */
|
|
isActive(): boolean
|
|
}
|
|
|
|
/** True if this browser supports the Web Speech API (standard or webkit prefix). */
|
|
export function isSpeechSupported(): boolean {
|
|
if (typeof window === 'undefined') return false
|
|
return 'SpeechRecognition' in window || 'webkitSpeechRecognition' in window
|
|
}
|
|
|
|
/** Retrieve the SpeechRecognition constructor, handling the webkit prefix. */
|
|
function getSRConstructor(): SpeechRecognitionCtor | undefined {
|
|
if (!isSpeechSupported()) return undefined
|
|
const w = window as SpeechRecognitionWindow
|
|
return w.SpeechRecognition ?? w.webkitSpeechRecognition
|
|
}
|
|
|
|
/**
|
|
* Create a push-to-talk voice input session.
|
|
*
|
|
* Returns null when the browser does not support SpeechRecognition — the
|
|
* caller should hide the mic UI in that case (AC-A2.3).
|
|
*
|
|
* @param onTranscript Receives the final recognised text (+ '\r' if autoSend).
|
|
* @param opts See VoiceInputOptions.
|
|
*/
|
|
export function createVoiceInput(
|
|
onTranscript: (text: string) => void,
|
|
opts?: VoiceInputOptions,
|
|
): VoiceInput | null {
|
|
const SRClass = getSRConstructor()
|
|
if (!SRClass) return null
|
|
|
|
const recognition = new SRClass()
|
|
recognition.continuous = false
|
|
recognition.interimResults = opts?.onInterim !== undefined
|
|
recognition.lang = opts?.lang ?? navigator.language
|
|
|
|
let active = false
|
|
let disposed = false
|
|
|
|
recognition.onresult = (event: ISpeechRecognitionEvent) => {
|
|
if (disposed) return
|
|
let interimText = ''
|
|
let finalText = ''
|
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
const result = event.results[i]
|
|
const text = result?.[0]?.transcript ?? ''
|
|
if (result?.isFinal) {
|
|
finalText += text
|
|
} else {
|
|
interimText += text
|
|
}
|
|
}
|
|
if (interimText && opts?.onInterim) {
|
|
opts.onInterim(interimText)
|
|
}
|
|
if (finalText) {
|
|
onTranscript(opts?.autoSend ? finalText + '\r' : finalText)
|
|
}
|
|
}
|
|
|
|
recognition.onend = () => {
|
|
active = false
|
|
}
|
|
|
|
recognition.onerror = () => {
|
|
active = false
|
|
}
|
|
|
|
return {
|
|
start(): void {
|
|
if (disposed || active) return
|
|
active = true
|
|
recognition.start()
|
|
},
|
|
stop(): void {
|
|
if (disposed || !active) return
|
|
recognition.stop()
|
|
active = false
|
|
},
|
|
dispose(): void {
|
|
if (disposed) return
|
|
disposed = true
|
|
if (active) recognition.abort()
|
|
active = false
|
|
},
|
|
isActive(): boolean {
|
|
return active
|
|
},
|
|
}
|
|
}
|