From ea4bd0edf6f8de9ccd2ad164c5aec3d4d7205c1f Mon Sep 17 00:00:00 2001 From: en-ver Date: Mon, 10 Aug 2026 17:08:41 +0300 Subject: [PATCH 1/9] feat(voice): add optional native voice input --- ...penai-compatible-transcription-endpoint.md | 40 ++ .adr/README.md | 1 + .env.example | 30 +- ARCHITECTURE.md | 39 +- README.md | 76 +++- bridge/config.test.ts | 51 +++ bridge/config.ts | 54 +++ bridge/index.ts | 6 +- bridge/server.integration.test.ts | 150 ++++++++ bridge/server.test.ts | 239 +++++++++++- bridge/server.ts | 165 +++++++- bridge/transcription.test.ts | 306 +++++++++++++++ bridge/transcription.ts | 157 ++++++++ bridge/types.ts | 5 + bun.lock | 3 + package.json | 1 + web/src/components/agent-chat.test.tsx | 196 +++++++++- web/src/components/agent-chat.tsx | 33 +- web/src/components/composer.test.tsx | 139 ++++++- web/src/components/composer.tsx | 127 ++++-- web/src/components/ui/chat/chat-input.tsx | 4 +- web/src/components/update-banner.test.tsx | 1 + .../components/update-check-control.test.tsx | 1 + web/src/hooks/use-polling.test.ts | 1 + web/src/hooks/use-voice-input.test.tsx | 362 ++++++++++++++++++ web/src/hooks/use-voice-input.ts | 301 +++++++++++++++ web/src/lib/api.test.ts | 133 ++++++- web/src/lib/api.ts | 76 ++++ web/src/lib/loaders.test.ts | 12 + web/src/lib/loaders.ts | 5 + web/src/lib/types.ts | 5 + web/src/routes/detail.test.tsx | 1 + web/src/routes/detail.tsx | 1 + web/src/test/handlers.ts | 3 + 34 files changed, 2651 insertions(+), 73 deletions(-) create mode 100644 .adr/0011-one-openai-compatible-transcription-endpoint.md create mode 100644 bridge/server.integration.test.ts create mode 100644 bridge/transcription.test.ts create mode 100644 bridge/transcription.ts create mode 100644 web/src/hooks/use-voice-input.test.tsx create mode 100644 web/src/hooks/use-voice-input.ts diff --git a/.adr/0011-one-openai-compatible-transcription-endpoint.md b/.adr/0011-one-openai-compatible-transcription-endpoint.md new file mode 100644 index 00000000..bcaf21cd --- /dev/null +++ b/.adr/0011-one-openai-compatible-transcription-endpoint.md @@ -0,0 +1,40 @@ +# 0011 — Voice transcription uses one OpenAI-compatible endpoint + +Status: **Accepted** (2026-08-09) + +## Context + +Microphone audio must remain behind Collie's authenticated, same-origin bridge: a phone must not +receive a provider credential or gain another listener. The browser records a completed clip, the +bridge submits it to the configured provider, and the resulting text remains an editable draft before +the existing guarded send path. + +Alternatives considered: + +- **LiteLLM** adds a Python runtime or separate proxy process, listener, supervision, and logging + boundary for a single provider call. +- **Vercel AI SDK** has experimental transcription plus provider/registry surface without removing + Collie's capture, validation, privacy, or draft-review responsibilities. +- **Bespoke fetch** would make Collie own multipart details, cancellation, retries, and + OpenAI-compatible typing. + +## Decision + +Use the official `openai` JavaScript SDK with exactly one configured OpenAI-compatible endpoint. +Do not add a registry, fallback, streaming, conversion, playback, local service, or additional ingress. + +## Consequences + +- Operators select a model identifier whose endpoint implements Collie's narrow completed-file, + final-text transcription subset; model names and transcription quality are not portable. +- The configured provider receives audio and owns its own retention and logging policy. Collie's + current configuration, request bounds, privacy, and audit behavior are canonical in the + [README](../README.md#voice-input-optional) and [Architecture](../ARCHITECTURE.md#6-security-model). +- A compatible private service may be the single upstream, but remains independently deployed and + secured; Collie does not manage it. + +## Revisit + +Revisit this decision only when a concrete provider cannot meet the narrow final-text contract, or +when product requirements genuinely need provider-specific behavior. That is the threshold for a new +decision, not a reason to pre-build a registry. diff --git a/.adr/README.md b/.adr/README.md index 73f3b143..a3c5abb2 100644 --- a/.adr/README.md +++ b/.adr/README.md @@ -72,3 +72,4 @@ A superseded ADR is never deleted or edited into agreement with the present. Mar | [0008](./0008-collie-does-not-run-a-terminal-emulator.md) | Collie does not run a terminal emulator | Accepted | | [0009](./0009-a-generic-menu-is-driven-by-the-keys-it-names.md) | A generic menu is driven by the keys it names, never by digits | Accepted | | [0010](./0010-long-sends-are-verified-via-the-paste-placeholder.md) | Long sends are verified via the paste placeholder, not by chunking them | Accepted | +| [0011](./0011-one-openai-compatible-transcription-endpoint.md) | Voice transcription uses one OpenAI-compatible endpoint | Accepted | diff --git a/.env.example b/.env.example index a38281c3..9f0279f6 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ -# Collie configuration. Copy to your plugin config dir as `.env`: +# Collie configuration. For a Herdr-installed candidate, copy this to its protected plugin config: # cp .env.example "$(herdr plugin config-dir herdr.collie)/.env" +# Do not put provider credentials in the checkout's ignored `.env`. # All values are optional; the defaults suit a single-user, tailnet-only setup. # --- Networking --- @@ -10,7 +11,8 @@ COLLIE_HOST=127.0.0.1 # How the bridge is published on the tailnet (read by collie-ctl.sh when it runs `tailscale serve`, # not by the bridge itself): "https" (default — tailnet :443, Tailscale-managed cert) or "http" # (plain HTTP on :$COLLIE_PORT — for Headscale / `.internal` domains without HTTPS certs; then set -# COLLIE_PUBLIC_HOSTS below, and note PWA install + Web Push need a secure context). +# COLLIE_PUBLIC_HOSTS below. PWA install, Web Push, and remote browser microphone capture need HTTPS; +# localhost/loopback may be treated as trustworthy by the browser). # COLLIE_SERVE_MODE=https # Skip tailscale serve entirely (set to 1 when using a reverse proxy like Caddy/Nginx). # The bridge stays on 127.0.0.1 only — your proxy handles TLS, auth, and public access. @@ -79,6 +81,30 @@ COLLIE_SUBMIT_KEYS=Enter # COLLIE_SERVE_MODE=http (no TLS = rebinding is otherwise same-origin). Unset = legacy behavior. # COLLIE_PUBLIC_HOSTS=herd.your-tailnet.ts.net +# --- Voice transcription (optional; disabled until you uncomment a setting) --- +# This root dotfile is a template: after copying it to the protected plugin config, uncomment and fill +# a setting below. Any nonblank setting opts in; an omitted model resolves to Collie's default, +# gpt-4o-transcribe. The browser sees only the resulting capability boolean; model, endpoint and key +# stay in this protected external .env and are read at bridge startup. Official OpenAI requires a key. +# COLLIE_TRANSCRIPTION_API_KEY=your_openai_api_key +# COLLIE_TRANSCRIPTION_MODEL=gpt-4o-transcribe +# Optional OpenAI-compatible base URL, including its API prefix. Custom/local endpoints may omit a +# key; set a model override when they do not support Collie's default. `http:` remains accepted for a +# trusted loopback/private service or independently encrypted transport, but remote plain HTTP exposes +# audio and any key to that network — prefer HTTPS. This remains outbound only, not a second front door. +# COLLIE_TRANSCRIPTION_BASE_URL=https://transcription.example.invalid/v1 +# To disable after enabling, clear or comment ALL THREE COLLIE_TRANSCRIPTION_* settings, then run: +# herdr plugin action invoke restart --plugin herdr.collie +# Removing only the key leaves a configured keyless custom endpoint enabled. +# A recording is capped at 5 minutes / 8 MiB (WebM or MP4). Browser Fetch sends it once to the +# same-origin bridge and refuses redirects; the bridge forwards it once to the provider, with a 60s +# provider timeout and no retry. duration_ms is browser-reported lifecycle metadata, not parsed media +# duration. Only provider success and error bodies are capped at 256 KiB decoded; bridge-to-provider +# redirects are refused separately. Collie does not intentionally persist or log audio or provider +# bodies; no audio file is written under stateDir. A successful transcript becomes the ordinary +# editable browser draft (see README for its retention). Provider and browser/Bun/OS/proxy buffering +# remain outside Collie's guarantee. + # --- Web Push (optional) --- # Generate with: bunx web-push generate-vapid-keys (after `bun add web-push`) # COLLIE_VAPID_PUBLIC= diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 957bac38..a47fa0df 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -43,10 +43,14 @@ The browser never touches the socket directly; the bridge is the only thing that Collie (this project) • static web app + small JSON API (browser polls /api/snapshot) • herdr-client adapter (the ONLY code that knows socket method names) + • optional one-shot voice transcription client (outbound only; see §4) • snapshot poll, event-poked (see §5) │ newline-delimited JSON over Unix socket ▼ Herdr server (owns panes, agents, state) + + Optional voice path: PWA MediaRecorder → same-origin bridge /api/pane/:id/transcribe + → bridge → configured OpenAI-compatible endpoint → editable browser draft ``` ## 3. Deployment model — **systemd user service, not a plugin pane** @@ -102,10 +106,14 @@ Product details that shaped the loop: `BlockingMessage`. That was never built: parsing is client-side and pattern-based, over whatever the current pane happens to show. It works because agent prompts are formulaic, and it degrades to "read the pane" when they aren't. -- **Voice needs zero special build.** It's a plain text box — Android's default keyboard provides - dictation via its mic button. No Web Speech API, no push-to-talk, no voice-specific fallback. Send - is a normal explicit button, so dictated text is naturally reviewable before it goes — that's just - how the box works, not a feature to build. +- **Keyboard dictation remains free.** The plain text box still accepts the phone keyboard's own + dictation. Separately, an optional native MediaRecorder flow records one completed WebM/MP4 clip, + posts it through the existing same-origin write gate; after validation, the bridge asks one + configured OpenAI-compatible endpoint for final text. All dedicated transcription settings blank + leaves this flow off; any nonblank setting opts in and an omitted model uses Collie's + `gpt-4o-transcribe` default. There is no Web Speech API, streaming, playback, codec conversion, + provider registry or fallback. The transcript enters the normal editable/persisted draft; only an + explicit existing Send reaches Herdr. - **Quick replies are heuristics, not guarantees.** Different agents expect different input (a Y/n prompt vs a numbered menu vs an approval phrase), so there is always a **"send exactly what I type"** fallback. @@ -227,8 +235,10 @@ default). These four are genuine RCE vectors and are **load-bearing — do not r Also shipped, as defence in depth: - **Audit log** — every write-level action appends a JSONL line (timestamp, method, truncated params) - to `/audit.log`, mode 0600 since it may echo reply text. An audit failure never fails the - user's action (`bridge/audit.ts`). + to `/audit.log`, mode 0600 since it may echo reply text. Voice transcription records only + MIME, bytes, browser-reported lifecycle duration (`reportedDurationMs`) and outcome (`ok`, `invalid`, + `timeout`, `client-aborted`, or `unavailable`) — never audio, filename, transcript or provider body. + An audit failure never fails the user's action (`bridge/audit.ts`). - **Destructive-action confirm** — a browser-side prompt when input pattern-matches `rm`, `sudo`, `git push --force`, `dd`, etc. (`web/src/lib/destructive.ts`). Prevents catastrophic mistaps. @@ -240,6 +250,23 @@ Considered, not built: where that friction would have to live: the lock is a pause on an unattended screen and deliberately gates nothing ([ADR 0007](./.adr/0007-the-idle-lock-is-a-pause-not-a-gate.md)). +Collie does not intentionally persist or log voice audio or provider bodies: no `Bun.write`, uploads +folder, reuse, backup, playback, or request/audit body. The bridge also does not persist or log +transcripts; a successful transcript enters the browser's ordinary editable `localStorage` draft, +removed on Send or pruned lazily after 48 hours. These are Collie-owned guarantees: browser, Bun, OS, +and proxy buffering remain outside them. The configured provider receives the audio, and its retention +or logging is controlled by that provider's policy, not Collie. The client stops at five minutes and +bounds the complete browser-to-bridge request, including its body, to 90 seconds; browser Fetch +refuses a front-door redirect before it can replay the multipart recording. The bridge enforces an 8 +MiB file cap plus the 12 MiB global body cap, validates MIME and browser-reported lifecycle duration +metadata (not parsed media duration) before the outbound call, and applies a 60-second bridge-to-provider +deadline through response-body consumption with zero retries. Its SDK fetch boundary independently +refuses bridge-to-provider redirects and caps decoded **provider** success and error response bodies at +256 KiB; returned text is bounded to 8192 characters. Bun labels `.webm`/`.mp4` multipart parts as +`video/*` even when MediaRecorder supplied `audio/*`, so the bridge accepts those container aliases and +canonicalises them to `audio/webm`/`audio/mp4` for the upstream; no codec inspection or conversion is +added. + Full passthrough (no command allow-list) is acceptable for a personal tool — an allow-list would defeat the purpose. **Never use `tailscale funnel`** (public exposure). diff --git a/README.md b/README.md index 1fa000ba..e573fe9d 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ A phone web UI for your [Herdr](https://herdr.dev) agent herd, served over Tailscale. Open a URL, see which agent is waiting on you, and answer it with your phone's keyboard. -The reply box is an ordinary text field, so your phone's own voice dictation works in it; Collie -ships none of its own. +The reply box remains an ordinary text field, so your phone's own voice dictation works in it. When +an operator configures transcription, its trailing action also records one completed voice clip, +returns an editable draft, and still requires the same explicit Send. - **React Router + Vite** — TypeScript, Tailwind, shadcn, and a Bun bridge - **Runs on your own machine** — loopback bind, no cloud, no account @@ -20,6 +21,7 @@ ships none of its own. - **Special-keys pad** — `Esc`, `Ctrl+C`, arrows, combinable modifiers - **Slash-command palette** per agent — tap, don't type +- **Optional voice transcription** — record one clip into an editable draft; Send stays explicit - **Send an image** from your camera roll - **Find in output** — search a pane, don't eyeball it - **Conversation history** the terminal can't scroll back to — read from the agent's own session log @@ -34,7 +36,7 @@ ships none of its own. - [Requirements](#requirements) - [Install](#install) - [First run — what you'll see](#first-run--what-youll-see) -- [Configure](#configure) +- [Configure](#configure) · [Voice input](#voice-input-optional) - [Dark mode / light mode](#dark-mode--light-mode) - [Commands](#commands) · [Herdr actions](#herdr-actions) - [Update](#update-to-a-new-release) @@ -156,7 +158,8 @@ Soft dependencies: **Node.js** (the control script uses it to extract your Magic `tailscale status --json`; without it the banner falls back to the loopback URL) and a **service supervisor** — `systemd --user` on Linux, **launchd** on macOS (both ship with the OS); a host with neither falls back to an unsupervised `nohup` process. You never install JS -deps by hand — the build runs `bun install` for you; the backend imports only Bun + `node:*`. +deps by hand — the build runs `bun install` for you. The optional voice path uses the official +[`openai`](https://www.npmjs.com/package/openai) SDK server-side; the browser uses native media APIs. [`web-push`](https://www.npmjs.com/package/web-push) is optional and lazy (see [Web Push](#web-push-optional)). @@ -252,9 +255,9 @@ QR code** you can point a camera at. It's its own subcommand rather than part of Collie is a PWA: once it's on your home screen you never need the URL again. Then install it as an app: **iOS** — Safari → share sheet → *Add to Home Screen*. **Android** — -Chrome → ⋮ menu → *Add to Home screen* (or *Install app*). Installing (and Web Push) needs the -HTTPS origin the default serve mode already provides; over `COLLIE_SERVE_MODE=http` the page works, -but service worker and install silently no-op. +Chrome → ⋮ menu → *Add to Home screen* (or *Install app*). Installing, Web Push, and remote browser +microphone capture need the HTTPS origin the default serve mode already provides; over +`COLLIE_SERVE_MODE=http` the text UI works, but service worker, install, and remote voice capture do not. ### Is it actually working? @@ -328,7 +331,8 @@ directly or via a Herdr action: cp .env.example "$(herdr plugin config-dir herdr.collie)/.env" ``` -The bridge reads `.env` only at startup — after any edit, `scripts/collie-ctl.sh restart`. See +The bridge reads `.env` only at startup — after any edit, run +`herdr plugin action invoke restart --plugin herdr.collie`. See [`.env.example`](./.env.example) for the full option list — commonly `COLLIE_PORT`, or `COLLIE_SERVE_MODE=http` (Headscale / `.internal` domains; read by the control script when it runs `tailscale serve`). @@ -343,6 +347,60 @@ your MagicDNS name works as-is, but a different hostname or TLS terminator makes COLLIE_ALLOWED_ORIGINS=https://collie.example.com ``` +### Voice input (optional) + +For a Herdr-installed candidate, configure transcription in the protected plugin config file +`"$(herdr plugin config-dir herdr.collie)/.env"` — not an `.env` in the checkout — to show a microphone +only when the composer is empty. The root [`.env.example`](./.env.example) is a hidden dotfile template: +all transcription assignments are intentionally commented, so copying it keeps voice disabled until you +uncomment and fill a setting. Any nonblank dedicated setting opts in, and an omitted model resolves to +Collie's default, `gpt-4o-transcribe`. The browser posts a completed WebM/MP4 recording to the +same-origin bridge; after validation, the bridge calls **one** OpenAI-compatible transcription endpoint. +Its narrow provider contract is `POST /audio/transcriptions` with a model/file and `response_format=json`, +returning `{ "text": "…" }`. Collie puts that text into the ordinary editable draft and waits for you to +press the existing **Send** button. It never auto-submits text to Herdr. + +```dotenv +# Official OpenAI: uncomment and fill this (the default base URL is https://api.openai.com/v1). +COLLIE_TRANSCRIPTION_API_KEY=your_openai_api_key +# Optional explicit override; gpt-4o-transcribe is Collie's default model. +# COLLIE_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe + +# Instead, a custom OpenAI-compatible endpoint may be keyless. Set its own model only when it does +# not support Collie's default: +# COLLIE_TRANSCRIPTION_MODEL=your-transcription-model +# COLLIE_TRANSCRIPTION_BASE_URL=https://transcription.example.invalid/v1 +``` + +The browser sees only an enabled/disabled capability — never model, endpoint or credentials — and a +config change takes effect after `herdr plugin action invoke restart --plugin herdr.collie`. To disable +voice later, clear or comment **all three** `COLLIE_TRANSCRIPTION_API_KEY`, +`COLLIE_TRANSCRIPTION_MODEL`, and `COLLIE_TRANSCRIPTION_BASE_URL` settings, then run that restart; +removing only the key leaves a configured keyless custom endpoint enabled. Remote browser microphone +capture requires HTTPS; localhost/loopback may be treated as trustworthy by the browser. +`COLLIE_SERVE_MODE=http` therefore remains usable for text, but cannot record from a remote phone. A +custom `http:`/`https:` endpoint is allowed without a key; include its expected API prefix (normally +`/v1`). Keep `http:` providers on trusted loopback/private or independently encrypted transport: remote +plain HTTP exposes audio and any configured key to that network, so prefer HTTPS. The provider is +outbound only, never another Collie listener or front door. + +Recording stops at about 5 minutes and is rejected above 8 MiB; only WebM/MP4 containers are +accepted. `duration_ms` is browser-reported recording lifecycle metadata, not media duration parsed by +the bridge. The browser aborts the complete browser-to-bridge request after 90 seconds and Fetch +refuses a front-door redirect before it can replay the multipart recording. The bridge bounds the +complete bridge-to-provider call, including response-body consumption, to 60 seconds with no retry; its +SDK fetch adapter independently refuses provider redirects and caps **provider** success and error +response bodies at 256 KiB decoded. + +Collie does not intentionally persist or log audio or provider bodies: no audio file is written to +`stateDir`, backups, or audit/log bodies. The bridge also does not persist or log transcripts. A +successful transcript enters the normal editable browser `localStorage` draft, which is removed on Send +or pruned lazily on a later draft access after 48 hours. These are Collie-owned guarantees; browser, +Bun, OS, and proxy buffering remain outside them. The configured provider receives the audio, and its +retention or logging is controlled by that provider's policy, not Collie. See +[ADR 0011](.adr/0011-one-openai-compatible-transcription-endpoint.md) for why this is deliberately one +narrow SDK-backed endpoint rather than a provider registry. + ## Dark mode / light mode **Collie follows your phone by default.** Flip your device to dark at sunset and Collie goes with @@ -999,7 +1057,7 @@ replaces the `tailscale serve` box; everything below the front door is identical - **One module touches the socket** (`bridge/herdr-client.ts`); everything else speaks the bridge's HTTP API. - **Polling is still the model** — the bridge polls Herdr (via `session.snapshot`, one RPC per tick) and the browser polls `/api/snapshot`; a long-lived Herdr event stream only pokes the bridge's poll to go faster, it never replaces it. No resync logic. -- **Actions are plain HTTP** — a reply or key `POST`s to `/api/pane/:id/{reply,keys}` → Herdr `pane.send_keys`, which types into a real terminal (hence the security posture). +- **Actions are plain HTTP** — a reply or key `POST`s to `/api/pane/:id/{reply,keys}` → Herdr `pane.send_keys`, which types into a real terminal (hence the security posture). Optional voice clips post only to `/api/pane/:id/transcribe`; the returned text stays in the browser draft until that same explicit reply path is used. - **The UI is a static PWA** — Vite builds `web/dist`, served from disk, so a rebuild is live with no restart. Full design rationale in [`ARCHITECTURE.md`](./ARCHITECTURE.md). diff --git a/bridge/config.test.ts b/bridge/config.test.ts index bc2cb2b0..9997fb0c 100644 --- a/bridge/config.test.ts +++ b/bridge/config.test.ts @@ -29,6 +29,9 @@ const KEYS = [ "COLLIE_DEVICE_ALLOWLIST", "COLLIE_ALLOWED_ORIGINS", "COLLIE_PUBLIC_HOSTS", + "COLLIE_TRANSCRIPTION_MODEL", + "COLLIE_TRANSCRIPTION_BASE_URL", + "COLLIE_TRANSCRIPTION_API_KEY", "COLLIE_VAPID_PUBLIC", "COLLIE_VAPID_PRIVATE", "COLLIE_VAPID_SUBJECT", @@ -79,6 +82,8 @@ describe("loadConfig", () => { expect(cfg.publicHosts).toEqual([]); // Per-device auth is off by default (empty header = feature disabled). expect(cfg.deviceHeader).toBe(""); + // With every dedicated transcription setting blank, no provider details are available to callers. + expect(cfg.transcription).toBeNull(); expect(cfg.deviceAllowlist).toEqual([]); // Multi-session support is on by default. expect(cfg.multiSession).toBe(true); @@ -86,6 +91,52 @@ describe("loadConfig", () => { expect(cfg.skipServe).toBe(false); }); + test("uses an explicit model with the official OpenAI endpoint", () => { + process.env.COLLIE_TRANSCRIPTION_MODEL = "gpt-4o-mini-transcribe"; + process.env.COLLIE_TRANSCRIPTION_API_KEY = "secret"; + expect(loadConfig().transcription).toEqual({ + model: "gpt-4o-mini-transcribe", + baseURL: "https://api.openai.com/v1", + apiKey: "secret", + }); + }); + + test("uses Collie's default model for key-only official and base-only keyless custom opt-in", () => { + process.env.COLLIE_TRANSCRIPTION_API_KEY = "secret"; + expect(loadConfig().transcription).toEqual({ + model: "gpt-4o-transcribe", + baseURL: "https://api.openai.com/v1", + apiKey: "secret", + }); + + delete process.env.COLLIE_TRANSCRIPTION_API_KEY; + process.env.COLLIE_TRANSCRIPTION_BASE_URL = "http://127.0.0.1:8000/v1/"; + expect(loadConfig().transcription).toEqual({ + model: "gpt-4o-transcribe", + baseURL: "http://127.0.0.1:8000/v1", + }); + }); + + test("allows a keyless custom endpoint to override the default model", () => { + process.env.COLLIE_TRANSCRIPTION_MODEL = "local-whisper"; + process.env.COLLIE_TRANSCRIPTION_BASE_URL = "http://127.0.0.1:8000/v1/"; + expect(loadConfig().transcription).toEqual({ + model: "local-whisper", + baseURL: "http://127.0.0.1:8000/v1", + }); + }); + + test("rejects a keyless official endpoint and an invalid configured base URL", () => { + process.env.COLLIE_TRANSCRIPTION_MODEL = "gpt-4o-transcribe"; + expect(loadConfig().transcription).toBeNull(); + + process.env.COLLIE_TRANSCRIPTION_BASE_URL = "https://api.openai.com/v1"; + expect(loadConfig().transcription).toBeNull(); + + process.env.COLLIE_TRANSCRIPTION_BASE_URL = "not a url"; + expect(loadConfig().transcription).toBeNull(); + }); + test("parses COLLIE_MULTI_SESSION as a boolean toggle (default on)", () => { // Falsey spellings turn it off (pin to the primary session only). for (const off of ["off", "0", "false", "no", "OFF", " False "]) { diff --git a/bridge/config.ts b/bridge/config.ts index 96483c0b..61b2b8b6 100644 --- a/bridge/config.ts +++ b/bridge/config.ts @@ -71,6 +71,57 @@ function envBool(name: string, fallback: boolean): boolean { return fallback; } +export interface TranscriptionConfig { + /** Model identifier understood by the configured OpenAI-compatible endpoint. */ + model: string; + /** OpenAI-compatible API base, including its version prefix (for example `/v1`). */ + baseURL: string; + /** Server-held credential. Omitted only for an explicitly configured local/custom endpoint. */ + apiKey?: string; +} + +/** Collie's default model once any dedicated transcription setting opts in. */ +const DEFAULT_TRANSCRIPTION_MODEL = "gpt-4o-transcribe"; +/** Official API base used when `COLLIE_TRANSCRIPTION_BASE_URL` is unset. */ +const DEFAULT_TRANSCRIPTION_BASE_URL = "https://api.openai.com/v1"; + +function loadTranscriptionConfig(): TranscriptionConfig | null { + const model = (process.env.COLLIE_TRANSCRIPTION_MODEL ?? "").trim(); + const rawBaseURL = (process.env.COLLIE_TRANSCRIPTION_BASE_URL ?? "").trim(); + const apiKey = (process.env.COLLIE_TRANSCRIPTION_API_KEY ?? "").trim(); + + // Voice remains off until an operator expresses intent through one of its dedicated settings. + // Once opted in, an omitted model resolves to Collie's default rather than becoming another switch. + if (!model && !rawBaseURL && !apiKey) return null; + + let url: URL; + try { + url = new URL(rawBaseURL || DEFAULT_TRANSCRIPTION_BASE_URL); + } catch { + console.warn("[config] transcription disabled: COLLIE_TRANSCRIPTION_BASE_URL must be an http(s) URL"); + return null; + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + console.warn("[config] transcription disabled: COLLIE_TRANSCRIPTION_BASE_URL must be an http(s) URL"); + return null; + } + + // A keyless public OpenAI request would only fail later after sending audio. Custom/local compatible + // services may deliberately use no authentication, so this requirement is specific to OpenAI's API. + const officialOpenAI = url.origin === "https://api.openai.com"; + if (officialOpenAI && !apiKey) { + console.warn("[config] transcription disabled: COLLIE_TRANSCRIPTION_API_KEY is required for OpenAI"); + return null; + } + + return { + model: model || DEFAULT_TRANSCRIPTION_MODEL, + // Keep a canonical no-trailing-slash base. The SDK appends `/audio/transcriptions` itself. + baseURL: url.toString().replace(/\/$/, ""), + ...(apiKey ? { apiKey } : {}), + }; +} + export interface Config { /** Path to Herdr's control socket. A non-Herdr-launched daemon must discover this itself. */ socketPath: string; @@ -155,6 +206,8 @@ export interface Config { * to your MagicDNS name (`collie..ts.net`), especially in http serve mode. */ publicHosts: string[]; + /** One configured OpenAI-compatible transcription endpoint, or null when voice input is disabled. */ + transcription: TranscriptionConfig | null; /** Web Push (VAPID). All three required to enable push; otherwise push is disabled. */ vapidPublic: string; vapidPrivate: string; @@ -237,6 +290,7 @@ export function loadConfig(): Config { deviceAllowlist: envList("COLLIE_DEVICE_ALLOWLIST"), allowedOrigins: envList("COLLIE_ALLOWED_ORIGINS"), publicHosts: envList("COLLIE_PUBLIC_HOSTS"), + transcription: loadTranscriptionConfig(), vapidPublic: process.env.COLLIE_VAPID_PUBLIC ?? "", vapidPrivate: process.env.COLLIE_VAPID_PRIVATE ?? "", vapidSubject: process.env.COLLIE_VAPID_SUBJECT ?? "mailto:admin@example.com", diff --git a/bridge/index.ts b/bridge/index.ts index 7d2613dd..dc0a0f57 100644 --- a/bridge/index.ts +++ b/bridge/index.ts @@ -19,6 +19,7 @@ import { } from "./sessions.ts"; import { Snooze } from "./snooze.ts"; import { StateEngine } from "./state-engine.ts"; +import { createTranscriber } from "./transcription.ts"; import { bridgeStampSync, githubTagsFetcher, @@ -36,6 +37,9 @@ const UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000; // Entry point: resolve config, wire the pieces, start polling and serving. const cfg = loadConfig(); +// Resolve this once: changing protected provider settings requires the same bridge restart as every +// other config change, and no provider client exists at all when the feature is disabled. +const transcriber = cfg.transcription ? createTranscriber(cfg.transcription) : null; // Ensure the state dir exists with private (0700) perms before push/snooze/uploads write into it — // it holds push subscription endpoints and uploaded images, so keep it owner-only. @@ -203,7 +207,7 @@ const sweepTimer = setInterval(() => { }, SWEEP_INTERVAL_MS); sweepTimer.unref(); -const server = startServer({ cfg, registry, push, snooze, notifyPrefs, updateMonitor, audit, activity }); +const server = startServer({ cfg, registry, push, snooze, notifyPrefs, updateMonitor, audit, activity, transcriber }); const shutdown = async () => { console.log("\n[bridge] shutting down"); diff --git a/bridge/server.integration.test.ts b/bridge/server.integration.test.ts new file mode 100644 index 00000000..053d9604 --- /dev/null +++ b/bridge/server.integration.test.ts @@ -0,0 +1,150 @@ +import { expect, test } from "bun:test"; + +import { ActivityLedger } from "./activity.ts"; +import { AuditLog } from "./audit.ts"; +import type { Config } from "./config.ts"; +import type { EventPoker } from "./event-poker.ts"; +import type { HerdrClient } from "./herdr-client.ts"; +import type { NotificationCoordinator } from "./notifications.ts"; +import type { NotifyPrefsStore } from "./notify-prefs.ts"; +import type { Push } from "./push.ts"; +import { startServer } from "./server.ts"; +import { SessionRegistry } from "./sessions.ts"; +import type { Snooze } from "./snooze.ts"; +import type { StateEngine } from "./state-engine.ts"; +import type { Transcriber } from "./transcription.ts"; +import type { UpdateMonitor } from "./update.ts"; + +function testConfig(): Config { + return { + socketPath: "/tmp/collie-transcription-test/herdr.sock", + port: 0, + host: "127.0.0.1", + pollMs: 1500, + pollIdleMs: 12_000, + notifyDelayMs: 30_000, + readLines: 200, + transcript: false, + journalRoots: { claude: "/tmp/claude", codex: "/tmp/codex", pi: "/tmp/pi", opencode: "/tmp/opencode" }, + submitKeys: ["Enter"], + trustedUser: "", + deviceHeader: "", + deviceAllowlist: [], + allowedOrigins: [], + publicHosts: [], + transcription: null, + vapidPublic: "", + vapidPrivate: "", + vapidSubject: "mailto:test@example.com", + stateDir: "/tmp/collie-transcription-test/state", + multiSession: false, + skipServe: false, + }; +} + +function registryWithPanes(paneIds: string[]): SessionRegistry { + const engine = { + current: () => ({ + agents: paneIds.map((paneId) => ({ paneId })), + shellPanes: [], + workspaces: [], + tabs: [], + bridge: "connected", + }), + stop: () => {}, + } as unknown as StateEngine; + return new SessionRegistry({ + configRoot: "/tmp/collie-transcription-test", + primarySocketPath: "/tmp/collie-transcription-test/herdr.sock", + factory: () => ({ + herdr: {} as HerdrClient, + engine, + poker: { stop: () => {} } as EventPoker, + notifications: { clearAll: () => {} } as NotificationCoordinator, + }), + multiSession: false, + listSessionDirs: () => [], + exists: () => false, + }); +} + +function audioForm(): FormData { + const form = new FormData(); + form.append("file", new File(["audio"], "recording.webm", { type: "audio/webm" })); + form.append("duration_ms", "1000"); + return form; +} + +function startVoiceServer(paneIds: string[], transcriber: Transcriber, seen: string[]) { + return startServer({ + cfg: testConfig(), + registry: registryWithPanes(paneIds), + push: {} as Push, + snooze: {} as Snooze, + notifyPrefs: {} as NotifyPrefsStore, + updateMonitor: {} as UpdateMonitor, + audit: new AuditLog(() => {}), + activity: { noteSeen: (_session: string, paneId: string) => seen.push(paneId) } as unknown as ActivityLedger, + transcriber, + }); +} + +test("rejects an unknown transcription pane before parsing audio, invoking the provider, or marking activity", async () => { + let transcriberCalls = 0; + const seen: string[] = []; + const server = startVoiceServer( + ["w1:known"], + { + transcribe: () => { + transcriberCalls += 1; + return Promise.resolve("must not run"); + }, + }, + seen, + ); + + try { + const response = await fetch(`http://127.0.0.1:${server.port}/api/pane/w1%3Amissing/transcribe`, { + method: "POST", + body: audioForm(), + }); + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ ok: false, error: "pane not found" }); + expect(transcriberCalls).toBe(0); + expect(seen).toEqual([]); + } finally { + await server.stop(true); + } +}); + +test("extends only validated transcription provider work past Bun's default route timeout", async () => { + const delayMs = 15_000; + const seen: string[] = []; + let transcriberCalls = 0; + const server = startVoiceServer( + ["w1:known"], + { + async transcribe() { + transcriberCalls += 1; + await Bun.sleep(delayMs); + return "editable transcript"; + }, + }, + seen, + ); + + try { + const startedAt = Date.now(); + const response = await fetch(`http://127.0.0.1:${server.port}/api/pane/w1%3Aknown/transcribe`, { + method: "POST", + body: audioForm(), + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true, text: "editable transcript" }); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(delayMs - 1_000); + expect(transcriberCalls).toBe(1); + expect(seen).toEqual(["w1:known"]); + } finally { + await server.stop(true); + } +}, 25_000); diff --git a/bridge/server.test.ts b/bridge/server.test.ts index d96b683b..fa10c027 100644 --- a/bridge/server.test.ts +++ b/bridge/server.test.ts @@ -5,9 +5,13 @@ import { cacheControlFor, checkAccess, marksPaneSeen, + MAX_TRANSCRIPT_CHARS, + MAX_TRANSCRIPTION_BYTES, + MAX_TRANSCRIPTION_DURATION_MS, SEEN_HEADER, deviceAuth, guard, + hasKnownPane, historyParams, isHostAllowed, isReservedAuthPath, @@ -18,12 +22,15 @@ import { resolveStaticPath, sendReplySteps, startupWarnings, + transcribePane, withBuildHeader, type ReplySender, } from "./server.ts"; import { AuditLog } from "./audit.ts"; import type { Config } from "./config.ts"; import type { HerdrClient, PaneRead } from "./herdr-client.ts"; +import { TranscriptionProviderError, type Transcriber } from "./transcription.ts"; +import type { StateEngine } from "./state-engine.ts"; // checkAccess is the API security gate (same-origin/CSRF + optional Tailscale identity). A // regression here silently opens remote shell access, so it gets the most direct coverage. @@ -58,6 +65,7 @@ function cfg(overrides: Partial = {}): Config { deviceAllowlist: [], allowedOrigins: [], publicHosts: [], + transcription: null, vapidPublic: "", vapidPrivate: "", vapidSubject: "mailto:admin@example.com", @@ -68,6 +76,235 @@ function cfg(overrides: Partial = {}): Config { }; } +describe("voice transcription — guarded, bounded, and body-free", () => { + const acceptedRequest = (form: FormData) => + new Request("http://collie.test/api/pane/w1%3Ap1/transcribe", { + method: "POST", + headers: { host: "collie.test", origin: "http://collie.test" }, + body: form, + }); + const audioForm = (opts: { type?: string; name?: string; duration?: string; bytes?: number } = {}) => { + const form = new FormData(); + form.append( + "file", + new File([new Uint8Array(opts.bytes ?? 4)], opts.name ?? "sensitive-recording.webm", { + type: opts.type ?? "audio/webm", + }), + ); + form.append("duration_ms", opts.duration ?? "1000"); + return form; + }; + const fakeTranscriber = (text = "review this transcript") => { + const files: File[] = []; + const transcriber: Transcriber = { + transcribe: (file) => { + files.push(file); + return Promise.resolve(text); + }, + }; + return { transcriber, files }; + }; + const auditEntries = () => { + const lines: string[] = []; + return { audit: new AuditLog((line) => void lines.push(line)), lines }; + }; + + test("the central write guard rejects before the transcription handler can parse multipart", () => { + let parsed = false; + const request = { + headers: new Headers({ host: "collie.test", origin: "https://evil.test" }), + formData: () => { + parsed = true; + return Promise.resolve(new FormData()); + }, + } as unknown as Request; + + // startServer applies this shared guard before dispatching any pane action, including transcribe. + const denied = guard(request, cfg(), "write"); + expect(denied?.status).toBe(403); + expect(parsed).toBe(false); + }); + + test("accepts the exact multipart declaration threshold and rejects one byte over before parsing", async () => { + const { transcriber, files } = fakeTranscriber(); + const { audit } = auditEntries(); + const declaration = MAX_TRANSCRIPTION_BYTES + 64 * 1024; + let parsed = false; + const request = (contentLength: number) => + ({ + headers: new Headers({ + "content-type": "multipart/form-data", + "content-length": String(contentLength), + }), + signal: new AbortController().signal, + formData: () => { + parsed = true; + return Promise.resolve(audioForm()); + }, + }) as unknown as Request; + + await expect( + transcribePane("w1:p1", request(declaration), audit, null, "default", transcriber), + ).resolves.toHaveProperty("status", 200); + expect(parsed).toBe(true); + expect(files).toHaveLength(1); + + parsed = false; + const rejected = await transcribePane( + "w1:p1", + request(declaration + 1), + audit, + null, + "default", + transcriber, + ); + expect(rejected.status).toBe(413); + expect(await rejected.json()).toEqual({ ok: false, error: "audio too large (max 8 MiB)" }); + expect(parsed).toBe(false); + expect(files).toHaveLength(1); + }); + + test("rejects invalid MIME, size, and reported duration before provider invocation", async () => { + const { transcriber, files } = fakeTranscriber(); + const { audit } = auditEntries(); + let beforeProviderCalls = 0; + for (const form of [ + // Bun derives multipart MIME from a filename, so use an Ogg extension for this negative case. + audioForm({ type: "audio/ogg", name: "recording.ogg" }), + audioForm({ bytes: MAX_TRANSCRIPTION_BYTES + 1 }), + audioForm({ duration: String(MAX_TRANSCRIPTION_DURATION_MS + 1) }), + audioForm({ duration: "not-a-duration" }), + ]) { + const response = await transcribePane( + "w1:p1", + acceptedRequest(form), + audit, + null, + "default", + transcriber, + () => { beforeProviderCalls += 1; }, + ); + expect(response.status).toBeGreaterThanOrEqual(400); + } + expect(files).toEqual([]); + expect(beforeProviderCalls).toBe(0); + }); + + test("waits for valid multipart parsing before extending the provider wait", async () => { + const { audit } = auditEntries(); + let releaseForm!: (form: FormData) => void; + const pendingForm = new Promise((resolve) => { releaseForm = resolve; }); + const parsingRequest = { + headers: new Headers({ "content-type": "multipart/form-data" }), + signal: new AbortController().signal, + formData: () => pendingForm, + } as unknown as Request; + const order: string[] = []; + const orderedTranscriber: Transcriber = { + transcribe: () => { + order.push("transcriber"); + return Promise.resolve("review this transcript"); + }, + }; + const pending = transcribePane( + "w1:p1", + parsingRequest, + audit, + null, + "default", + orderedTranscriber, + () => { order.push("before-provider"); }, + ); + + await Promise.resolve(); + expect(order).toEqual([]); + releaseForm(audioForm()); + + await expect(pending).resolves.toHaveProperty("status", 200); + expect(order).toEqual(["before-provider", "transcriber"]); + }); + + test("recognises both agent and shell panes in the last-known state", () => { + const engine = { + current: () => ({ + agents: [{ paneId: "w1:agent" }], + shellPanes: [{ paneId: "w1:shell" }], + workspaces: [], + tabs: [], + bridge: "connected", + }), + } as unknown as StateEngine; + expect(hasKnownPane(engine, "w1:agent")).toBe(true); + expect(hasKnownPane(engine, "w1:shell")).toBe(true); + expect(hasKnownPane(engine, "w1:missing")).toBe(false); + }); + + test("forwards only a generic provider filename and audits no audio or transcript body", async () => { + const { transcriber, files } = fakeTranscriber("private transcript text"); + const { audit, lines } = auditEntries(); + const response = await transcribePane( + "w1:p1", + acceptedRequest(audioForm({ name: "personal-note.webm" })), + audit, + "phone", + "default", + transcriber, + ); + + expect(await response.json()).toEqual({ ok: true, text: "private transcript text" }); + expect(files).toHaveLength(1); + expect(files[0]?.name).toBe("recording.webm"); + await Promise.resolve(); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('"outcome":"ok"'); + expect(lines[0]).not.toContain("personal-note"); + expect(lines[0]).not.toContain("private transcript text"); + expect(JSON.parse(lines[0]!).detail).toEqual({ + mime: "audio/webm", + bytes: 4, + reportedDurationMs: 1000, + outcome: "ok", + }); + expect(lines[0]).not.toContain("durationMs"); + }); + + test("maps deadline, caller-aborted, and unavailable provider failures to distinct outcomes", async () => { + const cases: Array<["timeout" | "client-aborted" | "unavailable", number, string]> = [ + ["timeout", 504, "transcription timed out"], + ["client-aborted", 499, "transcription cancelled"], + ["unavailable", 502, "transcription unavailable"], + ]; + for (const [kind, status, message] of cases) { + const { audit, lines } = auditEntries(); + const failing: Transcriber = { + transcribe: () => Promise.reject(new TranscriptionProviderError(kind)), + }; + const response = await transcribePane("w1:p1", acceptedRequest(audioForm()), audit, null, "default", failing); + expect(response.status).toBe(status); + expect(await response.json()).toEqual({ ok: false, error: message }); + await Promise.resolve(); + expect(JSON.parse(lines[0]!).detail.outcome).toBe(kind); + } + }); + + test("sanitizes provider failures and rejects an overlong provider result", async () => { + const { audit } = auditEntries(); + const failing: Transcriber = { + transcribe: () => Promise.reject(new Error("private provider response body")), + }; + const failed = await transcribePane("w1:p1", acceptedRequest(audioForm()), audit, null, "default", failing); + expect(failed.status).toBe(502); + const failedBody = await failed.text(); + expect(failedBody).toContain("transcription unavailable"); + expect(failedBody).not.toContain("private provider response body"); + + const { transcriber } = fakeTranscriber("x".repeat(MAX_TRANSCRIPT_CHARS + 1)); + const tooLong = await transcribePane("w1:p1", acceptedRequest(audioForm()), audit, null, "default", transcriber); + expect(tooLong.status).toBe(502); + expect(await tooLong.text()).toContain("invalid transcription result"); + }); +}); + describe("checkAccess — same-origin / CSRF gate", () => { test("allows a request with no Origin header (same-origin GET)", () => { expect(checkAccess(req({ host: "collie.example.ts.net" }), cfg())).toEqual({ ok: true }); @@ -893,7 +1130,7 @@ describe("marksPaneSeen — CSRF guard on marking a pane seen", () => { }); test("write actions count without it — they already cleared the Origin-requiring write gate", () => { - for (const action of ["reply", "keys", "upload", "close", "rename"]) { + for (const action of ["reply", "keys", "upload", "transcribe", "close", "rename"]) { expect(marksPaneSeen(withHeader(), action)).toBe(true); } }); diff --git a/bridge/server.ts b/bridge/server.ts index 72297ac5..df6700b1 100644 --- a/bridge/server.ts +++ b/bridge/server.ts @@ -4,6 +4,7 @@ import { extname, join, normalize, sep } from "node:path"; import type { ActivityLedger } from "./activity.ts"; import type { AuditLog } from "./audit.ts"; import type { Config } from "./config.ts"; +import { TranscriptionProviderError, type Transcriber } from "./transcription.ts"; import type { HerdrClient, PaneRead } from "./herdr-client.ts"; import { computeEtag, gzipJsonResponse, notModified } from "./http-cache.ts"; import type { NotifyPrefs, NotifyPrefsStore } from "./notify-prefs.ts"; @@ -30,6 +31,7 @@ import type { PaneHistoryResponse, PaneReadResponse, SnapshotResponse, + TranscriptionResponse, UploadResponse, } from "./types.ts"; @@ -37,13 +39,23 @@ import type { // terminal — instead we save it to a host file and the client references its path in the message // (the agent reads images by path). See uploadPane(). const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10 MB -// Multipart wraps the file in a boundary + part headers, so a legitimately-sized image arrives a -// little over MAX_UPLOAD_BYTES on the wire. Allow a small slack for the Content-Length pre-check. -const MAX_UPLOAD_OVERHEAD = 64 * 1024; // 64 KB +// Multipart wraps a file in a boundary + part headers, so allow a small shared slack for its +// Content-Length pre-checks. +const MAX_MULTIPART_OVERHEAD = 64 * 1024; // 64 KB // Hard cap the runtime enforces on ANY request body (Bun.serve maxRequestBodySize). Bigger than the // upload cap + overhead so the handler's own 413 fires first for honest clients; this cuts off a // chunked or lying client that never sends an accurate Content-Length. const MAX_REQUEST_BODY_BYTES = 12 * 1024 * 1024; // 12 MB +// Voice recordings stay below the global body limit. They are never written to the uploads directory +// or any other filesystem path. +export const MAX_TRANSCRIPTION_BYTES = 8 * 1024 * 1024; // 8 MB +export const MAX_TRANSCRIPTION_DURATION_MS = 5 * 60 * 1000; // 5 min +export const MAX_TRANSCRIPT_CHARS = 8192; + +function declaredMultipartTooLarge(req: Request, fileLimit: number): boolean { + const declared = Number(req.headers.get("content-length")); + return Number.isFinite(declared) && declared > fileLimit + MAX_MULTIPART_OVERHEAD; +} // Upper bound on the pane-read `lines` param — don't trust the client (or Herdr) to cap it. const MAX_READ_LINES = 10_000; const MAX_EXPECTED_PROMPT_CHARS = 8192; @@ -84,13 +96,14 @@ const CSP = const SECURITY_HEADERS: Record = { "x-content-type-options": "nosniff", "referrer-policy": "no-referrer", + "permissions-policy": "microphone=(self)", }; // Loopback Host/Origin forms (with an optional port). Loopback is always trusted — only tailscaled // (or a co-located proxy) can reach the bridge's port, so a loopback caller is the on-host operator. const LOOPBACK_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/; -const PANE_ROUTE = /^\/api\/pane\/([^/]+)(?:\/(reply|keys|upload|close|rename|history))?$/; +const PANE_ROUTE = /^\/api\/pane\/([^/]+)(?:\/(reply|keys|upload|transcribe|close|rename|history))?$/; // Turns per history page. "Show entire history" means the WHOLE conversation, so the client asks for // everything and this ceiling is a safety net against a pathological log, not the normal path — a // 1400-turn session is ~1.4 MB raw / ~400 KB gzipped, which a tailnet link serves fine. The default @@ -123,7 +136,7 @@ export const SEEN_HEADER = "x-collie-seen"; * doing so promotes it to a preflighted CORS request, and the bridge answers no preflight. Our own * same-origin `fetch` sets it freely. * - * Write actions (reply/keys/upload/close/rename) need no header: they already cleared + * Write actions (reply/keys/upload/transcribe/close/rename) need no header: they already cleared * `guard(…, "write")`, which requires an `Origin`. `history` is a read despite being an action * segment, so it needs the header like any other read. */ @@ -132,6 +145,12 @@ export function marksPaneSeen(req: Request, action: string | undefined): boolean return action !== undefined && action !== "history"; } +/** Whether a pane exists in this session's last successfully reconciled engine snapshot. */ +export function hasKnownPane(engine: StateEngine, paneId: string): boolean { + const { agents, shellPanes } = engine.current(); + return agents.some((pane) => pane.paneId === paneId) || shellPanes.some((pane) => pane.paneId === paneId); +} + export function startServer(opts: { cfg: Config; registry: SessionRegistry; @@ -141,8 +160,10 @@ export function startServer(opts: { updateMonitor: UpdateMonitor; audit: AuditLog; activity: ActivityLedger; + /** Constructed once at startup only when the protected transcription config is valid. */ + transcriber: Transcriber | null; }) { - const { cfg, registry, push, snooze, notifyPrefs, updateMonitor, audit, activity } = opts; + const { cfg, registry, push, snooze, notifyPrefs, updateMonitor, audit, activity, transcriber } = opts; // One journal registry + store for the process. The store's cache is keyed by absolute path, so // sharing it across herdr sessions AND across harnesses is correct — two sessions can front panes // whose agents write into the same root. Which harnesses have journals at all is decided in @@ -162,7 +183,7 @@ export function startServer(opts: { // Content-Length is absent or false. The upload handler still does its own precise check. maxRequestBodySize: MAX_REQUEST_BODY_BYTES, - async fetch(req) { + async fetch(req, bunServer) { const url = new URL(req.url); const { pathname } = url; @@ -208,6 +229,9 @@ export function startServer(opts: { sessions: registry.list(), notifications: { snoozedUntil: snooze.until() }, update: updateMonitor.status(), + // The browser learns only whether voice input is usable; model, base URL, key and upstream + // failures remain server-side. + transcriptionEnabled: transcriber !== null, ts: Date.now(), } satisfies SnapshotResponse, req.headers.get("accept-encoding")), await buildId(), @@ -250,7 +274,7 @@ export function startServer(opts: { const paneId = decodeURIComponent(paneMatch[1]!); const action = paneMatch[2]; // Reading a pane is allowed for any access-gated client; every action (reply/keys/upload/ - // close) types into or restructures a terminal, so it additionally needs an authorised device. + // transcribe/close) types into or restructures a terminal, so it additionally needs an authorised device. // `history` is a READ despite being an action segment — it only ever reads a log off disk. const isRead = !action || action === "history"; const denied = guard(req, cfg, isRead ? "read" : "write"); @@ -258,6 +282,14 @@ export function startServer(opts: { const rt = registry.get(sessionName); if (!rt) return unknownSession(); const { herdr, name: session } = rt; + // Transcription is pane-scoped for its audit attribution, so reject a stale or phantom pane + // from the last-known engine state before reading audio, invoking the provider, or marking + // activity. This is intentionally not a fresh Herdr RPC: normal UI requests follow this + // same polled snapshot and a newly-created pane may take one poll to appear. + const isTranscription = action === "transcribe" && req.method === "POST"; + if (isTranscription && !hasKnownPane(rt.engine, paneId)) { + return json({ ok: false, error: "pane not found" } satisfies TranscriptionResponse, req.headers.get("accept-encoding"), 404); + } // You are in this pane: reading it, replying, sending keys, browsing its history. That is // the whole definition of "seen" (.adr/0003), and this is the one place every such request // passes through. It cannot false-positive from background polling — the dashboard loader @@ -279,6 +311,8 @@ export function startServer(opts: { if (action === "reply" && req.method === "POST") return replyPane(herdr, cfg, paneId, req, audit, device, session); if (action === "keys" && req.method === "POST") return keysPane(herdr, cfg, paneId, req, audit, device, session); if (action === "upload" && req.method === "POST") return uploadPane(cfg, paneId, req, audit, device, session); + if (action === "transcribe" && req.method === "POST") + return transcribePane(paneId, req, audit, device, session, transcriber, () => bunServer.timeout(req, 90)); if (action === "close" && req.method === "POST") return closePane(herdr, paneId, req, audit, device, session); if (action === "rename" && req.method === "POST") return renamePane(herdr, paneId, req, audit, device, session); return text("method not allowed", 405); @@ -1031,6 +1065,118 @@ async function createWorkspace( } } +/** + * Validate and forward one completed voice clip without retaining it. This is deliberately separate + * from `uploadPane`: image uploads are durable host files that Herdr reads by path; audio is only a + * short-lived multipart value passed onward to the configured transcription endpoint. + */ +export async function transcribePane( + paneId: string, + req: Request, + audit: AuditLog, + device: string | null, + session: string, + transcriber: Transcriber | null, + /** Production extends only this validated provider wait past Bun's default request timeout. */ + beforeProviderCall?: () => void, +): Promise { + const accept = req.headers.get("accept-encoding"); + if (transcriber === null) { + return json({ ok: false, error: "transcription unavailable" } satisfies TranscriptionResponse, accept, 503); + } + if (!req.headers.get("content-type")?.toLowerCase().startsWith("multipart/form-data")) { + return json({ ok: false, error: "expected multipart audio" } satisfies TranscriptionResponse, accept, 400); + } + // FormData buffers the completed request, so reject an honest oversized declaration before asking + // Bun to parse it. `maxRequestBodySize` remains the hard backstop for chunked or lying requests. + if (declaredMultipartTooLarge(req, MAX_TRANSCRIPTION_BYTES)) { + return json({ ok: false, error: "audio too large (max 8 MiB)" } satisfies TranscriptionResponse, accept, 413); + } + + let form: FormData; + try { + form = await req.formData(); + } catch { + return json({ ok: false, error: "expected multipart audio" } satisfies TranscriptionResponse, accept, 400); + } + const files = form.getAll("file"); + const durations = form.getAll("duration_ms"); + if (files.length !== 1 || !(files[0] instanceof File)) { + return json({ ok: false, error: "audio file required" } satisfies TranscriptionResponse, accept, 400); + } + if (durations.length !== 1 || typeof durations[0] !== "string" || !/^\d+$/.test(durations[0])) { + return json({ ok: false, error: "invalid recording duration" } satisfies TranscriptionResponse, accept, 400); + } + // This is browser-reported recording lifecycle metadata, not media duration parsed by the bridge. + const reportedDurationMs = Number(durations[0]); + if (!Number.isSafeInteger(reportedDurationMs) || reportedDurationMs < 1) { + return json({ ok: false, error: "invalid recording duration" } satisfies TranscriptionResponse, accept, 400); + } + if (reportedDurationMs > MAX_TRANSCRIPTION_DURATION_MS) { + return json({ ok: false, error: "recording too long (max 5 minutes)" } satisfies TranscriptionResponse, accept, 413); + } + + const file = files[0]; + const receivedMime = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + // Bun's multipart parser derives `video/webm`/`video/mp4` from a normal `.webm`/`.mp4` filename + // even when MediaRecorder labelled an audio-only stream `audio/*`. Both spellings describe the + // same accepted container here; forward the canonical audio type upstream. + const mime = + receivedMime === "audio/webm" || receivedMime === "video/webm" + ? "audio/webm" + : receivedMime === "audio/mp4" || receivedMime === "video/mp4" + ? "audio/mp4" + : null; + if (mime === null) { + return json({ ok: false, error: "unsupported audio type" } satisfies TranscriptionResponse, accept, 415); + } + if (file.size < 1) { + return json({ ok: false, error: "audio file required" } satisfies TranscriptionResponse, accept, 400); + } + if (file.size > MAX_TRANSCRIPTION_BYTES) { + return json({ ok: false, error: "audio too large (max 8 MiB)" } satisfies TranscriptionResponse, accept, 413); + } + + const detail = { mime, bytes: file.size, reportedDurationMs }; + // Never forward the caller-supplied filename. It may be sensitive metadata, and the configured + // provider only needs a conventional extension to interpret the completed browser recording. + const providerFile = new File([file], mime === "audio/mp4" ? "recording.mp4" : "recording.webm", { + type: mime, + }); + try { + // Multipart validation deliberately retains Bun's normal slow-client timeout. Only the + // subsequent provider wait gets route-specific transport headroom above its own 60s deadline. + beforeProviderCall?.(); + const text = (await transcriber.transcribe(providerFile, req.signal)).trim(); + if (!text || text.length > MAX_TRANSCRIPT_CHARS) { + audit.record({ action: "transcribe", paneId, session, device, detail: { ...detail, outcome: "invalid" } }); + return json({ ok: false, error: "invalid transcription result" } satisfies TranscriptionResponse, accept, 502); + } + // Metadata only: no filename, audio bytes, provider body, or returned transcript enters audit.log. + audit.record({ action: "transcribe", paneId, session, device, detail: { ...detail, outcome: "ok" } }); + return json({ ok: true, text } satisfies TranscriptionResponse, accept); + } catch (error) { + const kind = error instanceof TranscriptionProviderError ? error.kind : "unavailable"; + audit.record({ + action: "transcribe", + paneId, + session, + device, + detail: { ...detail, outcome: kind }, + }); + // Provider messages can include account, model, or upstream error-body details; never reflect + // them to a browser or log them. A fresh recording is required after every failed request. + const status = kind === "timeout" ? 504 : kind === "client-aborted" ? 499 : 502; + const message = + kind === "timeout" + ? "transcription timed out" + : kind === "client-aborted" + ? "transcription cancelled" + : "transcription unavailable"; + return json({ ok: false, error: message } satisfies TranscriptionResponse, accept, status); + } +} + // Save an uploaded image to a host file and return its absolute path. The client then references // that path in a message; Claude Code / Codex read images by path (the terminal can't take a // pasted image over the socket). Validated by MIME and size; the filename is server-generated. @@ -1046,8 +1192,7 @@ async function uploadPane( // Reject an oversize upload by its declared Content-Length BEFORE buffering — req.formData() // reads the whole body into memory first, so a 100 MB "image" would be materialised just to fail // the size check below. Multipart adds a boundary + part headers, so allow a small slack. - const declared = Number(req.headers.get("content-length")); - if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES + MAX_UPLOAD_OVERHEAD) { + if (declaredMultipartTooLarge(req, MAX_UPLOAD_BYTES)) { return secure( new Response( JSON.stringify({ diff --git a/bridge/transcription.test.ts b/bridge/transcription.test.ts new file mode 100644 index 00000000..1ff2d2f0 --- /dev/null +++ b/bridge/transcription.test.ts @@ -0,0 +1,306 @@ +import { afterEach, describe, expect, test, vi } from "bun:test"; + +import { + createTranscriber, + MAX_PROVIDER_RESPONSE_BYTES, + TranscriptionProviderError, + TRANSCRIPTION_TIMEOUT_MS, +} from "./transcription.ts"; + +const config = (overrides: Partial<{ model: string; baseURL: string; apiKey?: string }> = {}) => ({ + model: "local-whisper", + baseURL: "http://127.0.0.1:8000/v1", + ...overrides, +}); + +function recording(): File { + return new File(["audio bytes"], "recording.webm", { type: "audio/webm" }); +} + +function responseFetch( + inspect: (request: Request) => Promise | void, + response: Response = new Response(JSON.stringify({ text: "hello from the recording" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), +) { + return async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + // The SDK's FormData probe is not the configured provider upload and must pass through untouched. + if (request.url.startsWith("data:")) return new Response(); + await inspect(request); + return response; + }; +} + +function stalledBody(signal: AbortSignal, onRead: () => void): ReadableStream { + return new ReadableStream( + { + start(controller) { + signal.addEventListener("abort", () => controller.error(signal.reason), { once: true }); + }, + pull() { + onRead(); + }, + }, + { highWaterMark: 0 }, + ); +} + +describe("createTranscriber", () => { + afterEach(() => vi.useRealTimers()); + + test("sends one configured multipart request with redirect refusal and no Authorization header for a keyless local endpoint", async () => { + let request: Request | undefined; + const transcriber = createTranscriber( + config(), + { fetch: responseFetch((seen) => { request = seen; }) }, + ); + + await expect(transcriber.transcribe(recording(), new AbortController().signal)).resolves.toBe( + "hello from the recording", + ); + + expect(request?.url).toBe("http://127.0.0.1:8000/v1/audio/transcriptions"); + expect(request?.redirect).toBe("error"); + expect(request?.headers.get("authorization")).toBeNull(); + const form = await request!.formData(); + expect(form.get("model")).toBe("local-whisper"); + expect(form.get("response_format")).toBe("json"); + expect(form.get("file")).toBeInstanceOf(File); + }); + + test("uses only the configured key and ignores unrelated OpenAI environment settings", async () => { + const saved = { + apiKey: process.env.OPENAI_API_KEY, + baseURL: process.env.OPENAI_BASE_URL, + admin: process.env.OPENAI_ADMIN_KEY, + organization: process.env.OPENAI_ORG_ID, + project: process.env.OPENAI_PROJECT_ID, + webhook: process.env.OPENAI_WEBHOOK_SECRET, + log: process.env.OPENAI_LOG, + headers: process.env.OPENAI_CUSTOM_HEADERS, + }; + try { + process.env.OPENAI_API_KEY = "ambient-key"; + process.env.OPENAI_BASE_URL = "https://ambient.invalid/v1"; + process.env.OPENAI_ADMIN_KEY = "ambient-admin"; + process.env.OPENAI_ORG_ID = "ambient-org"; + process.env.OPENAI_PROJECT_ID = "ambient-project"; + process.env.OPENAI_WEBHOOK_SECRET = "ambient-webhook"; + process.env.OPENAI_LOG = "debug"; + process.env.OPENAI_CUSTOM_HEADERS = "X-Ambient: must-not-send"; + let request: Request | undefined; + const transcriber = createTranscriber( + config({ baseURL: "https://configured.invalid/v1", apiKey: "configured-key" }), + { fetch: responseFetch((seen) => { request = seen; }) }, + ); + + await transcriber.transcribe(recording(), new AbortController().signal); + expect(request?.url).toBe("https://configured.invalid/v1/audio/transcriptions"); + expect(request?.headers.get("authorization")).toBe("Bearer configured-key"); + expect(request?.headers.get("x-ambient")).toBeNull(); + } finally { + const entries = Object.entries(saved) as Array<[keyof typeof saved, string | undefined]>; + const names: Record = { + apiKey: "OPENAI_API_KEY", + baseURL: "OPENAI_BASE_URL", + admin: "OPENAI_ADMIN_KEY", + organization: "OPENAI_ORG_ID", + project: "OPENAI_PROJECT_ID", + webhook: "OPENAI_WEBHOOK_SECRET", + log: "OPENAI_LOG", + headers: "OPENAI_CUSTOM_HEADERS", + }; + for (const [key, value] of entries) { + if (value === undefined) delete process.env[names[key]]; + else process.env[names[key]] = value; + } + } + }); + + test("refuses a provider redirect without uploading audio to its target", async () => { + let sourceUploads = 0; + let targetUploads = 0; + const target = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(req) { + if (new URL(req.url).pathname === "/audio/transcriptions") targetUploads += 1; + return new Response(JSON.stringify({ text: "must not arrive" }), { + headers: { "content-type": "application/json" }, + }); + }, + }); + const source = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(req) { + if (new URL(req.url).pathname === "/v1/audio/transcriptions") sourceUploads += 1; + return new Response(null, { + status: 307, + headers: { location: `http://127.0.0.1:${target.port}/audio/transcriptions` }, + }); + }, + }); + + try { + const transcriber = createTranscriber(config({ baseURL: `http://127.0.0.1:${source.port}/v1` })); + await expect(transcriber.transcribe(recording(), new AbortController().signal)).rejects.toMatchObject({ + kind: "unavailable", + }); + expect(sourceUploads).toBe(1); + expect(targetUploads).toBe(0); + } finally { + await source.stop(true); + await target.stop(true); + } + }); + + test("accepts a provider response at the exact decoded body limit", async () => { + const empty = JSON.stringify({ text: "" }); + const text = "x".repeat(MAX_PROVIDER_RESPONSE_BYTES - new TextEncoder().encode(empty).byteLength); + const body = JSON.stringify({ text }); + expect(new TextEncoder().encode(body)).toHaveLength(MAX_PROVIDER_RESPONSE_BYTES); + const transcriber = createTranscriber(config({ apiKey: "configured-key" }), { + fetch: responseFetch( + () => {}, + new Response(body, { status: 200, headers: { "content-type": "application/json" } }), + ), + }); + + await expect(transcriber.transcribe(recording(), new AbortController().signal)).resolves.toHaveLength(text.length); + }); + + test("bounds one-byte-over successful and error provider response streams with the fixed cancellation reason", async () => { + for (const status of [200, 500]) { + let cancelled = 0; + let cancellationReason: unknown; + const body = new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(MAX_PROVIDER_RESPONSE_BYTES + 1)); + }, + cancel(reason) { + cancelled += 1; + cancellationReason = reason; + }, + }); + const transcriber = createTranscriber(config({ apiKey: "configured-key" }), { + fetch: responseFetch( + () => {}, + new Response(body, { status, headers: { "content-type": "application/json" } }), + ), + }); + + const error = await transcriber.transcribe(recording(), new AbortController().signal).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(TranscriptionProviderError); + expect(error).toMatchObject({ kind: "unavailable" }); + expect(String(error)).not.toContain("private provider body"); + expect(cancelled).toBe(1); + expect(cancellationReason).toBeInstanceOf(Error); + expect((cancellationReason as Error).message).toBe("transcription provider response exceeds 256 KiB"); + } + }); + + test("sanitizes a provider source-stream error", async () => { + const sourceError = new Error("private provider source error"); + const body = new ReadableStream({ + pull(controller) { + controller.error(sourceError); + }, + }); + const transcriber = createTranscriber(config({ apiKey: "configured-key" }), { + fetch: responseFetch( + () => {}, + new Response(body, { status: 200, headers: { "content-type": "application/json" } }), + ), + }); + + const error = await transcriber.transcribe(recording(), new AbortController().signal).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(TranscriptionProviderError); + expect(error).toMatchObject({ kind: "unavailable" }); + expect(String(error)).not.toContain("private provider source error"); + }); + + test("aborts and classifies a stalled provider response body at the total deadline", async () => { + vi.useFakeTimers(); + let providerSignal: AbortSignal | undefined; + let startedBodyRead: () => void = () => {}; + const bodyReadStarted = new Promise((resolve) => { + startedBodyRead = resolve; + }); + const transcriber = createTranscriber(config({ apiKey: "configured-key" }), { + fetch: async (input, init) => { + if (String(input).startsWith("data:")) return new Response(); + const signal = init?.signal; + if (!signal) throw new Error("expected provider signal"); + providerSignal = signal; + return new Response(stalledBody(signal, startedBodyRead), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + const pending = transcriber.transcribe(recording(), new AbortController().signal).catch((error: unknown) => error); + + await bodyReadStarted; + vi.advanceTimersByTime(TRANSCRIPTION_TIMEOUT_MS); + + await expect(pending).resolves.toMatchObject({ kind: "timeout" }); + expect(providerSignal?.aborted).toBe(true); + }); + + test("classifies a caller abort after provider headers as client-aborted", async () => { + let providerSignal: AbortSignal | undefined; + let startedBodyRead: () => void = () => {}; + const bodyReadStarted = new Promise((resolve) => { + startedBodyRead = resolve; + }); + const transcriber = createTranscriber(config({ apiKey: "configured-key" }), { + fetch: async (input, init) => { + if (String(input).startsWith("data:")) return new Response(); + const signal = init?.signal; + if (!signal) throw new Error("expected provider signal"); + providerSignal = signal; + return new Response(stalledBody(signal, startedBodyRead), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + const caller = new AbortController(); + const pending = transcriber.transcribe(recording(), caller.signal).catch((error: unknown) => error); + + await bodyReadStarted; + caller.abort(); + + await expect(pending).resolves.toMatchObject({ kind: "client-aborted" }); + expect(providerSignal?.aborted).toBe(true); + }); + + test("never retries a failed upload", async () => { + let calls = 0; + const transcriber = createTranscriber(config({ apiKey: "configured-key" }), { + fetch: responseFetch( + (request) => { + if (request.url.includes("/audio/transcriptions")) calls += 1; + }, + new Response(JSON.stringify({ error: { message: "private provider body" } }), { + status: 500, + headers: { "content-type": "application/json" }, + }), + ), + }); + + let error: unknown; + try { + await transcriber.transcribe(recording(), new AbortController().signal); + } catch (caught) { + error = caught; + } + expect(calls).toBe(1); + expect(error).toBeInstanceOf(TranscriptionProviderError); + expect(error).toMatchObject({ kind: "unavailable" }); + expect(String(error)).not.toContain("private provider body"); + }); +}); diff --git a/bridge/transcription.ts b/bridge/transcription.ts new file mode 100644 index 00000000..55e219b7 --- /dev/null +++ b/bridge/transcription.ts @@ -0,0 +1,157 @@ +import OpenAI from "openai"; + +import type { TranscriptionConfig } from "./config.ts"; + +/** One completed audio upload sent to the configured provider; Collie does not intentionally persist it. */ +export interface Transcriber { + transcribe(file: File, signal: AbortSignal): Promise; +} + +type TranscriptionFailureKind = "timeout" | "client-aborted" | "unavailable"; + +/** A deliberately body-free provider failure safe to map onto the browser response. */ +export class TranscriptionProviderError extends Error { + constructor(readonly kind: TranscriptionFailureKind) { + super( + kind === "timeout" + ? "transcription timed out" + : kind === "client-aborted" + ? "transcription cancelled" + : "transcription unavailable", + ); + } +} + +export const TRANSCRIPTION_TIMEOUT_MS = 60_000; +/** Maximum decoded provider response body retained by the SDK, for successes and errors alike. */ +export const MAX_PROVIDER_RESPONSE_BYTES = 256 * 1024; + +// The SDK requires a credential at construction time even when an explicitly configured local +// OpenAI-compatible endpoint is unauthenticated. `Authorization: null` below removes the header; +// this value must never reach a request. +const NO_AUTH_API_KEY = "collie-no-auth"; + +type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +interface CreateTranscriberOptions { + fetch?: FetchFn; +} + +function isDataUrl(input: RequestInfo | URL): boolean { + if (typeof input === "string") return input.startsWith("data:"); + if (input instanceof URL) return input.protocol === "data:"; + return input.url.startsWith("data:"); +} + +/** + * Re-expose one provider response with a decoded-byte ceiling. The SDK owns parsing, so this keeps + * its API/error behaviour while preventing a configured endpoint from buffering an unbounded body. + */ +function limitProviderResponseBody(response: Response): Response { + if (response.body === null) return response; + + let received = 0; + const body = response.body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + received += chunk.byteLength; + if (received > MAX_PROVIDER_RESPONSE_BYTES) { + throw new Error("transcription provider response exceeds 256 KiB"); + } + controller.enqueue(chunk); + }, + }), + ); + + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} + +/** + * The SDK-compatible fetch boundary: one underlying call, no redirect follow, and bounded decoded + * response streams. It deliberately leaves the SDK's harmless `data:` FormData probe untouched. + */ +function providerFetch(underlyingFetch: FetchFn): FetchFn { + return async (input, init) => { + const response = await underlyingFetch(input, { ...init, redirect: "error" }); + return isDataUrl(input) ? response : limitProviderResponseBody(response); + }; +} + +/** + * Construct the official SDK without inheriting the process's unrelated OPENAI_* configuration. + * Every environment-backed constructor option is passed explicitly; OPENAI_CUSTOM_HEADERS has no + * option-level opt-out in this SDK release, so it is hidden only for the synchronous construction. + */ +function makeClient(config: TranscriptionConfig, options: CreateTranscriberOptions): OpenAI { + const customHeaders = process.env.OPENAI_CUSTOM_HEADERS; + try { + delete process.env.OPENAI_CUSTOM_HEADERS; + return new OpenAI({ + apiKey: config.apiKey ?? NO_AUTH_API_KEY, + adminAPIKey: null, + organization: null, + project: null, + webhookSecret: null, + baseURL: config.baseURL, + defaultHeaders: config.apiKey ? undefined : { Authorization: null }, + maxRetries: 0, + // The SDK's timeout ends once fetch receives headers. `transcribe` owns the full provider + // deadline so it also covers the response body the SDK parses afterwards. + logLevel: "off", + fetch: providerFetch(options.fetch ?? globalThis.fetch), + }); + } finally { + if (customHeaders === undefined) delete process.env.OPENAI_CUSTOM_HEADERS; + else process.env.OPENAI_CUSTOM_HEADERS = customHeaders; + } +} + +/** + * The one provider contract Collie owns: one configured OpenAI-compatible endpoint, completed + * multipart audio in, final plain text out. It does not expose provider errors, retry uploads, or + * retain the audio beyond the call. + */ +export function createTranscriber( + config: TranscriptionConfig, + options: CreateTranscriberOptions = {}, +): Transcriber { + const client = makeClient(config, options); + return { + async transcribe(file, signal) { + const controller = new AbortController(); + let abortOwner: "timeout" | "client-aborted" | null = null; + const abortForCaller = () => { + if (controller.signal.aborted) return; + abortOwner = "client-aborted"; + controller.abort(signal.reason); + }; + if (signal.aborted) abortForCaller(); + else signal.addEventListener("abort", abortForCaller, { once: true }); + + const timer = setTimeout(() => { + if (controller.signal.aborted) return; + abortOwner = "timeout"; + controller.abort(new DOMException("Transcription timed out", "TimeoutError")); + }, TRANSCRIPTION_TIMEOUT_MS); + + try { + const transcription = await client.audio.transcriptions.create( + { file, model: config.model, response_format: "json" }, + { signal: controller.signal }, + ); + // The caller may have disconnected while the SDK was consuming the final response bytes. + if (abortOwner !== null || controller.signal.aborted) throw new Error("transcription aborted"); + return transcription.text; + } catch { + throw new TranscriptionProviderError(abortOwner ?? "unavailable"); + } finally { + clearTimeout(timer); + signal.removeEventListener("abort", abortForCaller); + } + }, + }; +} diff --git a/bridge/types.ts b/bridge/types.ts index b61ceb97..a4bb1047 100644 --- a/bridge/types.ts +++ b/bridge/types.ts @@ -182,6 +182,8 @@ export interface SnapshotResponse { /** Update-availability signal. Optional — a stale bridge that predates the field simply omits it, * which the client reads as "no info" (see bridge/update.ts). */ update?: UpdateStatus; + /** True only when a protected server-side transcription endpoint is configured and usable. */ + transcriptionEnabled: boolean; ts: number; } @@ -253,6 +255,9 @@ export type ActionResponse = /** POST /api/pane/:id/upload — image saved to a host file; `path` is the absolute path to ref. */ export type UploadResponse = { ok: true; path: string } | { ok: false; error: string }; +/** POST /api/pane/:id/transcribe — one completed in-memory recording becomes editable text. */ +export type TranscriptionResponse = { ok: true; text: string } | { ok: false; error: string }; + /** A freshly-created shell pane — enough for the client to navigate into before the next poll. */ export interface CreatedPane { paneId: string; diff --git a/bun.lock b/bun.lock index 014aa7d5..aac582cd 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "herdr-bridge", "dependencies": { + "openai": "6.45.0", "qrcode-terminal": "^0.12.0", }, "devDependencies": { @@ -60,6 +61,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "openai": ["openai@6.45.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw=="], + "qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], diff --git a/package.json b/package.json index 4a7c22c9..5ddcd30c 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "typescript": "^5" }, "dependencies": { + "openai": "6.45.0", "qrcode-terminal": "^0.12.0" }, "optionalDependencies": { diff --git a/web/src/components/agent-chat.test.tsx b/web/src/components/agent-chat.test.tsx index 74c7a205..30ed9c0e 100644 --- a/web/src/components/agent-chat.test.tsx +++ b/web/src/components/agent-chat.test.tsx @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { useState, type ComponentProps } from "react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -15,11 +17,45 @@ vi.mock("@/lib/prompt-action", () => ({ vi.mock("@/lib/wizard-action", () => ({ submitWizardKeys: vi.fn(), })); +vi.mock("@/lib/preview-action", () => ({ + submitPreviewKeys: vi.fn(), + submitPreviewNote: vi.fn(), + submitPreviewOption: vi.fn(), +})); +vi.mock("@/lib/multi-select-action", () => ({ + submitMultiSelectIntent: vi.fn(), +})); +vi.mock("@/lib/menu-action", () => ({ + submitMenuKeys: vi.fn(), +})); +vi.mock("@/hooks/use-voice-input", () => ({ + useVoiceInput: vi.fn(), +})); + +// Keep the real renderer and controls, but retain its public parent callbacks so this test can call +// AgentChat's stale/direct-invocation boundary without a disabled DOM control swallowing the event. +const capturedAnsiOutputs = vi.hoisted(() => [] as AnsiOutputProps[]); +vi.mock("@/components/ansi-output", async (importOriginal) => { + const original = await importOriginal(); + const React = await import("react"); + return { + ...original, + AnsiOutput: (props: AnsiOutputProps) => { + capturedAnsiOutputs.push(props); + return React.createElement(original.AnsiOutput, props); + }, + }; +}); import { server } from "@/test/setup"; import { clearStatus } from "@/lib/status"; import { submitPromptOption } from "@/lib/prompt-action"; import { submitWizardKeys } from "@/lib/wizard-action"; +import { submitPreviewKeys, submitPreviewNote, submitPreviewOption } from "@/lib/preview-action"; +import { submitMultiSelectIntent } from "@/lib/multi-select-action"; +import { submitMenuKeys } from "@/lib/menu-action"; +import { useVoiceInput, type VoiceInput, type VoicePhase } from "@/hooks/use-voice-input"; +import type { AnsiOutputProps } from "@/components/ansi-output"; import { fixtureAgents } from "@/test/handlers"; import { AgentChat } from "./agent-chat"; @@ -31,7 +67,33 @@ beforeAll(() => { // jsdom doesn't implement scrollTo; the terminal mirror's auto-scroll calls it. if (!Element.prototype.scrollTo) Element.prototype.scrollTo = () => {}; }); -beforeEach(() => clearStatus()); +let voiceOptions: Parameters[0] | undefined; +let voice: VoiceInput; +const voiceMock = vi.mocked(useVoiceInput); + +beforeEach(() => { + clearStatus(); + capturedAnsiOutputs.length = 0; + voiceOptions = undefined; + voice = { + phase: "idle", + elapsedLabel: "0:00", + startRecording: vi.fn(), + stopRecording: vi.fn(), + cancel: vi.fn(), + }; + voiceMock.mockImplementation((options) => { + voiceOptions = options; + return voice; + }); + vi.mocked(submitPromptOption).mockResolvedValue({ status: "sent" }); + vi.mocked(submitWizardKeys).mockResolvedValue({ status: "sent" }); + vi.mocked(submitPreviewKeys).mockResolvedValue({ status: "sent" }); + vi.mocked(submitPreviewNote).mockResolvedValue({ status: "sent" }); + vi.mocked(submitPreviewOption).mockResolvedValue({ status: "sent" }); + vi.mocked(submitMultiSelectIntent).mockResolvedValue({ status: "sent" }); + vi.mocked(submitMenuKeys).mockResolvedValue({ status: "sent" }); +}); function renderChat(overrides: Partial> = {}) { const agent = fixtureAgents[0]!; // a blocked claude agent @@ -41,6 +103,7 @@ function renderChat(overrides: Partial> = {}) { agents: fixtureAgents, shellPanes: [], tabs: [], + transcriptionEnabled: false, text: "recent pane output", onBack: vi.fn(), onSelect: vi.fn(), @@ -115,6 +178,7 @@ describe("AgentChat — header title block", () => { agents={fixtureAgents} shellPanes={[]} tabs={[]} + transcriptionEnabled={false} text="out" onBack={vi.fn()} onSelect={vi.fn()} @@ -232,6 +296,134 @@ const WIZARD_TEXT = [ "Enter to select · Tab/Arrow keys to navigate · Esc to cancel", ].join("\n"); +const PANES_DIR = join(import.meta.dirname, "..", "fixtures", "panes"); +const PREVIEW_TEXT = readFileSync(join(PANES_DIR, "claude--select-preview.txt"), "utf8"); +const MULTI_SELECT_TEXT = readFileSync( + join(PANES_DIR, "claude--select-multiselect-single.txt"), + "utf8", +); +const GENERIC_MENU_TEXT = readFileSync(join(PANES_DIR, "claude--menu-model-picker.txt"), "utf8"); + +describe("AgentChat — pane voice write lock", () => { + function capturedAnsiOutput(busy: boolean): AnsiOutputProps { + const props = [...capturedAnsiOutputs].reverse().find((output) => output.promptDisabled === busy); + if (!props) throw new Error(`expected ${busy ? "busy" : "idle"} AnsiOutput callbacks`); + return props; + } + + function renderVoiceChat(text: string) { + let setVoicePhase: (phase: VoicePhase) => void = () => { + throw new Error("voice harness not mounted"); + }; + const agent = fixtureAgents[0]!; + function Harness() { + const [, setVersion] = useState(0); + setVoicePhase = (phase) => { + voice.phase = phase; + setVersion((version) => version + 1); + }; + return ( + + ); + } + const router = createMemoryRouter([{ path: "/", element: }]); + render(); + return { setVoicePhase: (phase: VoicePhase) => act(() => setVoicePhase(phase)) }; + } + + it.each([ + { + name: "prompt", + text: MENU_TEXT, + submit: () => vi.mocked(submitPromptOption), + invoke: (output: AnsiOutputProps) => + output.onPromptAction!(undefined as never, undefined as never), + }, + { + name: "wizard", + text: WIZARD_TEXT, + control: () => screen.getByRole("button", { name: /Parser/ }), + submit: () => vi.mocked(submitWizardKeys), + invoke: (output: AnsiOutputProps) => output.onWizardAction!([], undefined as never), + }, + { + name: "preview", + text: PREVIEW_TEXT, + control: () => screen.getByRole("button", { name: /Boxy/ }), + submit: () => vi.mocked(submitPreviewKeys), + invoke: (output: AnsiOutputProps) => + output.onPreviewAction!({ kind: "nav", keys: [] }, undefined as never), + }, + { + name: "multi-select", + text: MULTI_SELECT_TEXT, + control: () => screen.getByRole("checkbox", { name: /Cheese/ }), + submit: () => vi.mocked(submitMultiSelectIntent), + invoke: (output: AnsiOutputProps) => + output.onMultiSelectAction!({ kind: "advance" }, undefined as never), + }, + { + name: "generic menu", + text: GENERIC_MENU_TEXT, + control: () => screen.getByRole("button", { name: "Use this session only" }), + submit: () => vi.mocked(submitMenuKeys), + invoke: (output: AnsiOutputProps) => + output.onMenuAction!({ keys: [], nav: false }, undefined as never), + }, + ])("disables and hard-locks $name writes while voice is busy, then recovers idle", async ({ + text, + control, + submit, + invoke, + }) => { + voice.phase = "recording"; + submit().mockClear(); + const { setVoicePhase } = renderVoiceChat(text); + + const busyOutput = capturedAnsiOutput(true); + if (control) expect(control()).toBeDisabled(); + // Calling the parent callback bypasses AnsiOutput's disabled leaf control, proving AgentChat's + // own guard blocks stale/direct invocations instead of only relying on visible disabling. + await invoke(busyOutput); + expect(submit()).not.toHaveBeenCalled(); + + setVoicePhase("idle"); + const idleOutput = capturedAnsiOutput(false); + if (control) expect(control()).toBeEnabled(); + await invoke(idleOutput); + await waitFor(() => expect(submit()).toHaveBeenCalledTimes(1)); + }); + + it("routes a completed transcript to the editable draft without sending", () => { + renderChat({ transcriptionEnabled: true }); + expect(voiceOptions).toBeDefined(); + + act(() => voiceOptions!.onTranscript("review this before sending")); + + expect(screen.getByPlaceholderText(/type a reply/i)).toHaveValue("review this before sending"); + }); + + it("reports a voice failure through the existing error status tone", async () => { + renderChat({ transcriptionEnabled: true }); + expect(voiceOptions).toBeDefined(); + + act(() => voiceOptions!.onError("Microphone access was unavailable")); + + const message = await screen.findByText("Microphone access was unavailable"); + expect(message.closest("[role='status']")).toHaveClass("text-status-blocked"); + }); +}); + describe("AgentChat — prompt-select race guard wiring (frozen {text, revision} pair)", () => { const mockSubmit = vi.mocked(submitPromptOption); beforeEach(() => { @@ -256,6 +448,7 @@ describe("AgentChat — prompt-select race guard wiring (frozen {text, revision} agents={fixtureAgents} shellPanes={[]} tabs={[]} + transcriptionEnabled={false} text={pane.text} revision={pane.revision} onBack={vi.fn()} @@ -432,6 +625,7 @@ describe("AgentChat — shared header: stale-status dimming", () => { agents={fixtureAgents} shellPanes={[]} tabs={[]} + transcriptionEnabled={false} text="out" error={error} onBack={vi.fn()} diff --git a/web/src/components/agent-chat.tsx b/web/src/components/agent-chat.tsx index e88d8ff1..bde65c11 100644 --- a/web/src/components/agent-chat.tsx +++ b/web/src/components/agent-chat.tsx @@ -20,6 +20,7 @@ import { splitLines } from "@/lib/blocks"; import { adapterFor } from "@/lib/harness"; import { FindBar } from "@/components/find-bar"; import { Composer, type ComposerHandle } from "@/components/composer"; +import { useVoiceInput } from "@/hooks/use-voice-input"; import { ThreadSidebar } from "@/components/agent-sidebar"; import { AgentIcon } from "@/components/agent-icon"; import { TabStrip } from "@/components/tab-strip"; @@ -66,6 +67,8 @@ interface AgentChatProps { revision?: number; /** Per-device auth from the snapshot; an unauthorised device drops the composer to read-only. */ device?: DeviceAuth; + /** Snapshot capability for native voice input; no provider configuration reaches this component. */ + transcriptionEnabled: boolean; // Global connection state — fed straight to the shared AppHeader, which drives the header Collie // mark (gallop/rest, identically to the dashboard), and lets us dim the stale StatusBadge while not // live. Defaults describe a healthy link so tests that don't care render "live". @@ -101,6 +104,7 @@ export function AgentChat({ requestedLines = 0, revision = 0, device, + transcriptionEnabled, bridge = "connected", error = false, stalled = false, @@ -132,6 +136,16 @@ export function AgentChat({ const composerRef = useRef(null); const gone = !agent; + // Voice is pane-owned because prompt/menu controls and the composer can all write to this pane. + // Passing this one lifecycle to Composer keeps every write lock in sync without shared global state. + const voice = useVoiceInput({ + enabled: transcriptionEnabled && !(gone || readOnly), + paneId, + session, + onTranscript: (transcript) => composerRef.current?.acceptVoiceTranscript(transcript), + onError: (message) => setStatus(message, "error"), + }); + const voiceBusy = voice.phase !== "idle"; // Swipe up (or just tap) the handle above the composer to bring up the pane switcher. A lowish // threshold + a taller hit area (below) make the gesture easy to land with a thumb; tapping is the @@ -324,6 +338,7 @@ export function AgentChat({ setStatus("Read-only — device not authorised", "error"); return; } + if (voiceBusy) return; const result = await submitPromptOption({ paneId, session, @@ -345,7 +360,7 @@ export function AgentChat({ setStatus(result.error || "Send failed", "error"); } }, - [readOnly, paneId, session, requestedLines, shown.revision, agent?.agent, revalidator], + [readOnly, voiceBusy, paneId, session, requestedLines, shown.revision, agent?.agent, revalidator], ); // Tap a wizard control (an option digit, step navigation, or the review step's submit/cancel). @@ -359,6 +374,7 @@ export function AgentChat({ setStatus("Read-only — device not authorised", "error"); return; } + if (voiceBusy) return; const result = await submitWizardKeys({ paneId, session, @@ -380,7 +396,7 @@ export function AgentChat({ setStatus(result.error || "Send failed", "error"); } }, - [readOnly, paneId, session, requestedLines, shown.revision, agent?.agent, revalidator], + [readOnly, voiceBusy, paneId, session, requestedLines, shown.revision, agent?.agent, revalidator], ); // Tap a preview-dialog control (an option, the note add/edit/remove, or the wizard step nav). @@ -394,6 +410,7 @@ export function AgentChat({ setStatus("Read-only — device not authorised", "error"); return; } + if (voiceBusy) return; const base = { paneId, session, @@ -424,7 +441,7 @@ export function AgentChat({ revalidator.revalidate(); } }, - [readOnly, paneId, session, requestedLines, shown.revision, agent?.agent, revalidator], + [readOnly, voiceBusy, paneId, session, requestedLines, shown.revision, agent?.agent, revalidator], ); // Tap a multi-select control (toggle a checkbox, Submit, the "Chat about this" escape, or the @@ -438,6 +455,7 @@ export function AgentChat({ setStatus("Read-only — device not authorised", "error"); return; } + if (voiceBusy) return; const result = await submitMultiSelectIntent({ paneId, session, @@ -459,7 +477,7 @@ export function AgentChat({ setStatus(result.error || "Send failed", "error"); } }, - [readOnly, paneId, session, requestedLines, shown.revision, agent?.agent, revalidator], + [readOnly, voiceBusy, paneId, session, requestedLines, shown.revision, agent?.agent, revalidator], ); // Tap a generic-menu control (a footer-named key like Enter/s/Esc, or an arrow). Same guard-first @@ -472,6 +490,7 @@ export function AgentChat({ setStatus("Read-only — device not authorised", "error"); return; } + if (voiceBusy) return; const result = await submitMenuKeys({ paneId, session, @@ -494,7 +513,7 @@ export function AgentChat({ setStatus(result.error || "Send failed", "error"); } }, - [readOnly, paneId, session, requestedLines, shown.revision, agent?.agent, revalidator], + [readOnly, voiceBusy, paneId, session, requestedLines, shown.revision, agent?.agent, revalidator], ); // NOTE: the composer is deliberately NOT auto-focused on open/switch — that would pop the Android @@ -770,7 +789,7 @@ export function AgentChat({ onPreviewAction={handlePreviewAction} onMultiSelectAction={handleMultiSelectAction} onMenuAction={handleMenuAction} - promptDisabled={readOnly || gone} + promptDisabled={readOnly || gone || voiceBusy} /> ) : ( @@ -850,6 +869,8 @@ export function AgentChat({ isShell={isShell} gone={gone} readOnly={readOnly} + transcriptionEnabled={transcriptionEnabled} + voice={voice} dialogPresent={dialogPresent} text={text} terminalDraft={terminalDraft} diff --git a/web/src/components/composer.test.tsx b/web/src/components/composer.test.tsx index 5e93fd92..8cbd9958 100644 --- a/web/src/components/composer.test.tsx +++ b/web/src/components/composer.test.tsx @@ -1,15 +1,18 @@ -import { useState } from "react"; -import type { ComponentProps } from "react"; +import { createRef, useState } from "react"; +import type { ComponentProps, Ref } from "react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { http, HttpResponse } from "msw"; import { createMemoryRouter, RouterProvider } from "react-router"; import { clearStatus, useStatus } from "@/lib/status"; +import * as drafts from "@/lib/drafts"; import { isReloadHeld, __resetReloadGuard } from "@/lib/reload-guard"; import { server } from "@/test/setup"; import { recordReply } from "@/test/handlers"; -import { Composer } from "./composer"; + +import type { VoiceInput } from "@/hooks/use-voice-input"; +import { Composer, type ComposerHandle } from "./composer"; // A guarded send is TWO reply calls: type (submit:false), then — once the text is verified on the // input line — submit-only (empty text). Overriding the reply handler therefore has to keep the fake @@ -31,15 +34,28 @@ function replyHandler(onTyped: (text: string) => void, onSubmit?: () => void) { beforeAll(() => { if (!Element.prototype.scrollTo) Element.prototype.scrollTo = () => {}; }); +const idleVoice = (): VoiceInput => ({ + phase: "idle", + elapsedLabel: "0:00", + startRecording: vi.fn(), + stopRecording: vi.fn(), + cancel: vi.fn(), +}); + beforeEach(() => clearStatus()); -function renderComposer(overrides: Partial> = {}) { +function renderComposer( + overrides: Partial> = {}, + ref?: Ref, +) { const props: ComponentProps = { paneId: "w1:p1", agent: "claude", isShell: false, gone: false, readOnly: false, + transcriptionEnabled: false, + voice: idleVoice(), dialogPresent: false, text: "pane output", terminalDraft: null, @@ -51,7 +67,7 @@ function renderComposer(overrides: Partial> = {} onSent: vi.fn(), ...overrides, }; - const router = createMemoryRouter([{ path: "/", element: }]); + const router = createMemoryRouter([{ path: "/", element: }]); render(); return props; } @@ -69,6 +85,8 @@ function renderComposerWithStatus(overrides: Partial { + it("transitions Mic → Send → Mic by trimmed emptiness, including whitespace-only input", async () => { + const user = userEvent.setup(); + renderComposer({ transcriptionEnabled: true }); + const box = screen.getByPlaceholderText(/type a reply/i); + + expect(screen.getByRole("button", { name: "Record voice" })).toBeEnabled(); + expect(screen.queryByRole("button", { name: "Send" })).not.toBeInTheDocument(); + + await user.type(box, " "); + expect(screen.getByRole("button", { name: "Record voice" })).toBeEnabled(); + expect(screen.queryByRole("button", { name: "Send" })).not.toBeInTheDocument(); + + await user.type(box, "typed words"); + expect(screen.getByRole("button", { name: "Send" })).toBeEnabled(); + expect(screen.queryByRole("button", { name: "Record voice" })).not.toBeInTheDocument(); + + await user.clear(box); + expect(screen.getByRole("button", { name: "Record voice" })).toBeEnabled(); + }); + + it("keeps Mic and direct terminal typing mutually exclusive", async () => { + renderComposer({ transcriptionEnabled: true }); + + fireEvent.click(screen.getByRole("button", { name: /^type into terminal$/i })); + expect(screen.getByPlaceholderText(/type into the terminal/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Record voice" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /stop typing into terminal/i })); + await waitFor(() => expect(screen.getByRole("button", { name: "Record voice" })).toBeEnabled()); + }); + + it("uses an accessible stop control while recording", () => { + const recordingVoice: VoiceInput = { + ...idleVoice(), + phase: "recording", + elapsedLabel: "0:01", + }; + renderComposer({ transcriptionEnabled: true, voice: recordingVoice }); + expect(screen.getByRole("button", { name: "Stop recording" })).toBeEnabled(); + const cancel = screen.getByRole("button", { name: "Cancel voice input" }); + expect(cancel).toBeEnabled(); + const status = screen.getByRole("status"); + expect(status).toHaveTextContent("Recording"); + expect(status).not.toHaveTextContent("0:01"); + expect(status).not.toContainElement(cancel); + const timer = screen.getByRole("timer", { name: "Elapsed recording time" }); + expect(timer).toHaveTextContent("0:01"); + expect(timer).toHaveAttribute("aria-live", "off"); + expect(screen.getByPlaceholderText(/type a reply/i)).toBeDisabled(); + expect(screen.getByRole("button", { name: /^type into terminal$/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Keys" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Quick" })).toBeDisabled(); + }); + + it("shows disabled progress while transcribing", () => { + renderComposer({ + transcriptionEnabled: true, + voice: { ...idleVoice(), phase: "transcribing" }, + }); + expect(screen.getByRole("button", { name: "Transcribing voice" })).toBeDisabled(); + expect(screen.getByPlaceholderText(/type a reply/i)).toBeDisabled(); + }); + + it("inserts a transcript as an editable persisted draft and never auto-replies", async () => { + const replyCalls: unknown[] = []; + const persist = vi.spyOn(drafts, "saveDraft"); + server.use( + http.post(/\/api\/pane\/[^/]+\/reply$/, async ({ request }) => { + replyCalls.push(await request.json()); + return HttpResponse.json({ ok: true }); + }), + ); + const composerRef = createRef(); + renderComposer({ transcriptionEnabled: true }, composerRef); + + act(() => composerRef.current!.acceptVoiceTranscript("review this before sending")); + const box = screen.getByPlaceholderText(/type a reply/i); + expect(box).toHaveValue("review this before sending"); + expect(screen.getByRole("button", { name: "Send" })).toBeEnabled(); + expect(replyCalls).toEqual([]); + + await userEvent.setup().type(box, " now"); + expect(box).toHaveValue("review this before sending now"); + expect(persist).toHaveBeenLastCalledWith(undefined, "w1:p1", "review this before sending now"); + persist.mockRestore(); + }); +}); + describe("Composer — send", () => { // #34: a dialog owns the TUI's keyboard. Sending free text at one loses the message AND makes the // submit key answer the dialog, approving whatever was highlighted. Nothing may leave the phone. @@ -226,6 +333,8 @@ describe("Composer — send", () => { isShell: false, gone: false, readOnly: false, + transcriptionEnabled: false, + voice: idleVoice(), dialogPresent: false, text: "pane output", terminalDraft: null, @@ -321,6 +430,8 @@ describe("Composer — typing into the terminal", () => { isShell={false} gone={gone} readOnly={false} + transcriptionEnabled={false} + voice={idleVoice()} dialogPresent={false} text="pane output" terminalDraft={null} @@ -525,6 +636,8 @@ describe("Composer — typing into the terminal", () => { isShell={false} gone={false} readOnly={false} + transcriptionEnabled={false} + voice={idleVoice()} dialogPresent={false} text="pane output" terminalDraft={null} @@ -658,7 +771,7 @@ describe("Composer — destructive-input confirm", () => { // a stabilised draft, live host typing (raw changes while stable lags), and the line clearing — all // without real timers. `initialDraft` seeds a fully-stranded draft (raw + stable) at mount. function renderDraftHarness(overrides: Partial> = {}) { - const { terminalDraft: initialDraft = null, ...rest } = overrides; + const { terminalDraft: initialDraft = null, voice = idleVoice(), ...rest } = overrides; function Harness() { const [raw, setRaw] = useState(initialDraft); const [stable, setStable] = useState(initialDraft); @@ -668,6 +781,8 @@ function renderDraftHarness(overrides: Partial> isShell: false, gone: false, readOnly: false, + transcriptionEnabled: false, + voice, dialogPresent: false, text: "pane output", prefs: { wrap: true, fontSize: 11, rawTerminal: false }, @@ -936,6 +1051,8 @@ describe("Composer — in-flight echo suppression (match-last-sent)", () => { isShell: false, gone: false, readOnly: false, + transcriptionEnabled: false, + voice: idleVoice(), dialogPresent: false, text: "pane output", terminalDraft: draft, @@ -1056,6 +1173,14 @@ describe("Composer — reload-guard hold (no-SW self-update safety gate)", () => expect(isReloadHeld()).toBe(false); }); + it("holds while voice activity is in progress", () => { + renderComposer({ + transcriptionEnabled: true, + voice: { ...idleVoice(), phase: "recording", elapsedLabel: "0:01" }, + }); + expect(isReloadHeld()).toBe(true); + }); + it("holds while an image upload is in flight, releases once it settles", async () => { // Failing upload keeps the input empty (a successful one appends the returned path, which then // legitimately holds as real unsent text) — so the release is observable in isolation. @@ -1470,6 +1595,8 @@ describe("Composer — draft persistence", () => { isShell: false, gone: false, readOnly: false, + transcriptionEnabled: false, + voice: idleVoice(), dialogPresent: false, text: "pane output", terminalDraft: null, diff --git a/web/src/components/composer.tsx b/web/src/components/composer.tsx index 79afe386..05ee067b 100644 --- a/web/src/components/composer.tsx +++ b/web/src/components/composer.tsx @@ -1,7 +1,7 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; import type { ChangeEvent, ClipboardEvent, ReactNode } from "react"; import { useRevalidator } from "react-router"; -import { Check, ImagePlus, Keyboard, Loader2, Send, Settings2, Slash, Terminal, X, Zap } from "lucide-react"; +import { Check, ImagePlus, Keyboard, Loader2, Mic, Send, Settings2, Slash, Square, Terminal, X, Zap } from "lucide-react"; import type { DisplayPrefs } from "@/hooks/use-display-prefs"; import { usePendingConfirm } from "@/hooks/use-pending-confirm"; @@ -20,6 +20,7 @@ import { commandsFor } from "@/lib/agent-commands"; import { isDestructiveInput } from "@/lib/destructive"; import { loadDraft, saveDraft } from "@/lib/drafts"; import { useHoldReload } from "@/lib/reload-guard"; +import type { VoiceInput } from "@/hooks/use-voice-input"; import { isSelfEcho, normalizeDraft } from "@/hooks/use-terminal-draft"; import { adapterFor } from "@/lib/harness"; import { sendGuardedReply } from "@/lib/reply-action"; @@ -29,6 +30,8 @@ import { DirectTypingStrip } from "@/components/direct-typing-strip"; export interface ComposerHandle { /** Focus the input and put the caret at the end — used by the mirror-tap-to-focus in AgentChat. */ focusInput: () => void; + /** Make a completed pane-owned voice transcript the ordinary editable persisted draft. */ + acceptVoiceTranscript: (transcript: string) => void; } interface ComposerProps { @@ -43,6 +46,10 @@ interface ComposerProps { gone: boolean; /** This device isn't authorised to type — locks the composer with a distinct placeholder. */ readOnly: boolean; + /** Server-advertised voice capability only; provider settings remain on the bridge. */ + transcriptionEnabled: boolean; + /** The pane-owned native voice lifecycle, shared with AgentChat's prompt/menu write guards. */ + voice: VoiceInput; /** A dialog (prompt/wizard/preview/multi-select) is on screen, so the TUI's keyboard belongs to it. * Free-text sending is refused while true — see send(). Answer it with its own buttons instead. */ dialogPresent: boolean; @@ -134,7 +141,7 @@ function ComposerDock({ } export const Composer = forwardRef(function Composer( - { paneId, session, agent, isShell, gone, readOnly, dialogPresent, text, terminalDraft, rawTerminalDraft, prefs, setWrap, stepFontSize, setRawTerminal, onSent }, + { paneId, session, agent, isShell, gone, readOnly, transcriptionEnabled, voice, dialogPresent, text, terminalDraft, rawTerminalDraft, prefs, setWrap, stepFontSize, setRawTerminal, onSent }, ref, ) { const revalidator = useRevalidator(); @@ -166,6 +173,11 @@ export const Composer = forwardRef(function Compo saveDraft(session, paneId, value); } + // AgentChat owns this lifecycle because it is the pane's write boundary; the composer consumes + // that same object for its own controls, so prompt/menu and composer writes cannot diverge. + const voiceBusy = voice.phase !== "idle"; + const writeLocked = locked || voiceBusy; + useEffect(() => { const prev = draftPaneRef.current; if (prev.paneId === paneId && prev.session === session) return; @@ -241,12 +253,13 @@ export const Composer = forwardRef(function Compo paneKey: `${session ?? ""}\0${paneId}`, inputRef, replyDraft: input, - canActivate: () => !(locked || sending || uploading), - // `locked` covers a gone pane, a read-only device, and the idle pause. A LOST CONNECTION is - // deliberately not added here: the mode already disarms on a failed batch, which is the same + canActivate: () => !(locked || sending || uploading || voiceBusy), + // `locked` covers a gone pane, a read-only device, and the idle pause. Voice activity also + // disarms direct typing: the two modes must never target the composer at once. A LOST CONNECTION + // is deliberately not added here: the mode already disarms on a failed batch, which is the same // event observed directly rather than inferred from a timer, and it fires whether or not any // banner has decided the connection counts as lost yet. - suspended: locked, + suspended: locked || voiceBusy, sendKeys: pressKeys, onActivate: () => { sendConfirm.reset(); @@ -291,7 +304,7 @@ export const Composer = forwardRef(function Compo const effectiveStable = suppressEcho(terminalDraft); const effectiveRaw = suppressEcho(rawTerminalDraft); - useImperativeHandle(ref, () => ({ focusInput: focusInputImmediately }), []); + useImperativeHandle(ref, () => ({ focusInput: focusInputImmediately, acceptVoiceTranscript })); useEffect( () => () => { @@ -319,7 +332,7 @@ export const Composer = forwardRef(function Compo // the hold clears (see lib/self-update.ts). Keyed by pane so panes don't clobber each other's hold. useHoldReload( `composer:${paneId}`, - input.trim() !== "" || direct.active || direct.value !== "" || direct.busy || uploading, + input.trim() !== "" || direct.active || direct.value !== "" || direct.busy || uploading || voiceBusy, ); // Preview appearance latch. A STABLE, non-echo, not-already-handled draft flips the preview on — @@ -386,12 +399,20 @@ export const Composer = forwardRef(function Compo setTimeout(focusInputImmediately, 0); } + function acceptVoiceTranscript(transcript: string) { + // A transcript becomes the same ordinary editable, persisted draft as typed text. It is never + // sent here; the existing guarded Send path remains the only terminal write. + updateInput(transcript); + setStatus("Transcript ready — review before sending.", "success"); + focusInputEnd(); + } + // Resolves true only on a VERIFIED send (the text was seen in the pane's input box before the // submit key went out). The quick-reply grid consumes the verdict to drive its own ✓ and to decide // whether to close its dock, so every early return below has to answer honestly. async function send(value: string, isDraft: boolean, force = false): Promise { const t = value.trim(); - if (!t || locked || sending) return false; + if (!t || locked || sending || voiceBusy) return false; // A dialog on screen owns the TUI's keyboard: our text is swallowed and the submit key ANSWERS // the dialog, approving whatever option was highlighted (#34). Refuse BEFORE the destructive // pre-clear sweep below — those ctrl+k/Backspaces would land in the dialog too. The input is @@ -490,6 +511,7 @@ export const Composer = forwardRef(function Compo // "Really send?" state instead of sending; the confirming second tap goes through. Non-destructive // input sends immediately (and any stray armed state is cleared). function onSendClick() { + if (voiceBusy) return; // An armed override takes precedence: this tap IS the deliberate "type anyway", so it skips the // destructive re-confirm (already answered on the tap that got blocked) and the pre-flight. if (forceConfirm.pending === "force") { @@ -536,7 +558,7 @@ export const Composer = forwardRef(function Compo // path used to be silent on success, so a press looked like it went nowhere. Errors still go to // the status channel; the echo just falls back to idle. async function pressKeys(k: string[]): Promise { - if (locked) return false; + if (writeLocked) return false; try { const res = await api.sendKeys(paneId, k, session); if (!res.ok) { @@ -554,6 +576,7 @@ export const Composer = forwardRef(function Compo // Insert "/cmd " into the composer (arg-taking commands) and focus it. Appends to any draft already // typed (with a separating space) rather than clobbering it; an empty draft just gets set. function insertCommand(value: string) { + if (voiceBusy) return; direct.deactivateSilently(); updateInput((prev) => (prev.trim() ? `${prev.trimEnd()} ${value}` : value)); focusInputEnd(); @@ -562,7 +585,7 @@ export const Composer = forwardRef(function Compo // Upload an image; on success append its host path to the composer so the user can add context. // Shared by the file picker and clipboard paste. async function uploadImage(file: File) { - if (locked) return; + if (writeLocked) return; setUploading(true); try { const res = await api.uploadImage(paneId, file, session); @@ -593,7 +616,7 @@ export const Composer = forwardRef(function Compo // Only intercepts when the clipboard actually carries an image file — a plain text paste (the // common case) falls through untouched. function onPasteImage(e: ClipboardEvent) { - if (locked || direct.active) return; + if (writeLocked || direct.active) return; const items = e.clipboardData.items; for (let i = 0; i < items.length; i++) { const item = items[i]; @@ -621,6 +644,37 @@ export const Composer = forwardRef(function Compo )} + {voiceBusy && ( +
+ + {voice.phase === "recording" ? : } + {voice.phase === "recording" + ? "Recording" + : voice.phase === "requesting" + ? "Requesting microphone…" + : "Transcribing voice…"} + + {voice.phase === "recording" && ( + + {voice.elapsedLabel} + + )} + +
+ )} {/* File input stays mounted here (not inside the keyboard-only key row) so the picker callback survives the keyboard collapsing. Attach-image fires it from the reply-input row @@ -635,7 +689,7 @@ export const Composer = forwardRef(function Compo BottomSheet below (it's a palette, not a pad). */} {drawer === "keys" && ( - + )} {drawer === "quick" && ( @@ -645,7 +699,7 @@ export const Composer = forwardRef(function Compo onClose={closeDrawer} agent={agent} isShell={isShell} - disabled={locked || sending} + disabled={writeLocked || sending} /> )} @@ -680,7 +734,7 @@ export const Composer = forwardRef(function Compo variant="ghost" size="sm" className={cn("h-8 flex-1 gap-1.5", drawer === "keys" ? CONTROL_ON : CONTROL_OFF)} - disabled={locked} + disabled={writeLocked} aria-expanded={drawer === "keys"} onClick={() => requestDrawer(drawer === "keys" ? null : "keys")} > @@ -702,7 +756,7 @@ export const Composer = forwardRef(function Compo variant="ghost" size="sm" className={cn("h-8 flex-1 gap-1.5", direct.active ? CONTROL_ON : CONTROL_OFF)} - disabled={locked || sending} + disabled={locked || sending || voiceBusy} aria-pressed={direct.active} aria-label="Type into terminal" onClick={() => { @@ -724,7 +778,7 @@ export const Composer = forwardRef(function Compo variant="ghost" size="sm" className={cn("h-8 flex-1 gap-1.5", drawer === "quick" ? CONTROL_ON : CONTROL_OFF)} - disabled={locked} + disabled={writeLocked} aria-expanded={drawer === "quick"} onClick={() => requestDrawer(drawer === "quick" ? null : "quick")} > @@ -736,7 +790,7 @@ export const Composer = forwardRef(function Compo variant="ghost" size="sm" className="h-8 flex-1 gap-1.5 text-muted-foreground" - disabled={locked} + disabled={writeLocked} onClick={() => requestDrawer("cmd")} > @@ -769,7 +823,7 @@ export const Composer = forwardRef(function Compo // No Take over when the line is only the harness's own opaque token (Claude's // `[Pasted text #N +M lines]`): pulling that into the composer would send the literal // string. The preview keeps showing it — the screen really does say that. - onTakeOver={adapter?.draftIsOpaque?.(effectiveRaw) ? null : takeOverDraft} + onTakeOver={voiceBusy || adapter?.draftIsOpaque?.(effectiveRaw) ? null : takeOverDraft} /> )} {/* Armed indicator for direct typing. In the same in-flow slot as the "You sent:" strip, @@ -824,7 +878,7 @@ export const Composer = forwardRef(function Compo direct.active && "border-primary focus-visible:border-primary focus-visible:ring-primary/30", )} - disabled={locked} + disabled={locked || voiceBusy} rows={1} /> - {!direct.active && forcingSend ? ( + {voice.phase === "recording" ? ( + + ) : voice.phase === "requesting" || voice.phase === "transcribing" ? ( + + ) : !direct.active && transcriptionEnabled && input.trim().length === 0 ? ( + + ) : !direct.active && forcingSend ? ( // The pre-flight refused and the user is being offered the override. Labelled for what it // actually does — TYPE the text into whatever is on screen — not "send", because the // submit key is still conditional on the verify step behind it. diff --git a/web/src/components/ui/chat/chat-input.tsx b/web/src/components/ui/chat/chat-input.tsx index e6fce053..cad3a8f5 100644 --- a/web/src/components/ui/chat/chat-input.tsx +++ b/web/src/components/ui/chat/chat-input.tsx @@ -2,8 +2,8 @@ import * as React from "react"; import { cn } from "@/lib/utils"; -// Auto-growing message composer. It's just a styled textarea, so the phone's native keyboard — -// including voice dictation via the keyboard mic — works for free. Auto-capitalization is off: this +// Auto-growing text portion of the composer. Native keyboard dictation still works alongside the +// optional completed-clip voice input in Composer. Auto-capitalization is off: this // drives a terminal (shell commands, slash-commands, agent replies) where a forced leading capital // is usually wrong. (Callers can still override via props.) function ChatInput({ className, ref, ...props }: React.ComponentProps<"textarea">) { diff --git a/web/src/components/update-banner.test.tsx b/web/src/components/update-banner.test.tsx index a60b9736..26005300 100644 --- a/web/src/components/update-banner.test.tsx +++ b/web/src/components/update-banner.test.tsx @@ -61,6 +61,7 @@ function homeData(update: UpdateInfo | undefined): HomeData { session: undefined, snoozedUntil: null, update, + transcriptionEnabled: false, error: false, authError: false, }; diff --git a/web/src/components/update-check-control.test.tsx b/web/src/components/update-check-control.test.tsx index 95b5a609..d64f9f82 100644 --- a/web/src/components/update-check-control.test.tsx +++ b/web/src/components/update-check-control.test.tsx @@ -34,6 +34,7 @@ function homeData(update: UpdateInfo | undefined): HomeData { session: undefined, snoozedUntil: null, update, + transcriptionEnabled: false, error: false, authError: false, }; diff --git a/web/src/hooks/use-polling.test.ts b/web/src/hooks/use-polling.test.ts index fe0deddc..e1b399a2 100644 --- a/web/src/hooks/use-polling.test.ts +++ b/web/src/hooks/use-polling.test.ts @@ -57,6 +57,7 @@ function makeData(agents: AgentView[], shellPanes: AgentView[] = []): HomeData { session: undefined, snoozedUntil: null, update: undefined, + transcriptionEnabled: false, error: false, authError: false, }; diff --git a/web/src/hooks/use-voice-input.test.tsx b/web/src/hooks/use-voice-input.test.tsx new file mode 100644 index 00000000..ca176228 --- /dev/null +++ b/web/src/hooks/use-voice-input.test.tsx @@ -0,0 +1,362 @@ +import { StrictMode } from "react"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +vi.mock("@/lib/api", () => ({ transcribeAudio: vi.fn() })); + +import { transcribeAudio } from "@/lib/api"; +import { + MAX_VOICE_BYTES, + MAX_VOICE_DURATION_MS, + recordingMimeType, + useVoiceInput, +} from "./use-voice-input"; + +class MockMediaRecorder { + static supported = new Set(["audio/webm;codecs=opus"]); + static instances: MockMediaRecorder[] = []; + static isTypeSupported(type: string): boolean { + return MockMediaRecorder.supported.has(type); + } + + state: RecordingState = "inactive"; + ondataavailable: ((event: BlobEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + onstop: ((event: Event) => void) | null = null; + + readonly stream: MediaStream; + readonly options?: MediaRecorderOptions; + + constructor(stream: MediaStream, options?: MediaRecorderOptions) { + this.stream = stream; + this.options = options; + MockMediaRecorder.instances.push(this); + } + + start(): void { + this.state = "recording"; + } + + stop(): void { + if (this.state === "inactive") return; + this.state = "inactive"; + this.ondataavailable?.({ data: new Blob(["recording"], { type: this.options?.mimeType }) } as BlobEvent); + this.onstop?.(new Event("stop")); + } +} + +function streamWithTrack() { + const track = { stop: vi.fn() }; + return { stream: { getTracks: () => [track] } as unknown as MediaStream, track }; +} + +function VoiceHarness({ enabled = true, session }: { enabled?: boolean; session?: string }) { + const voice = useVoiceInput({ + enabled, + paneId: "w1:p1", + session, + onTranscript: (text) => { + document.body.dataset.transcript = text; + }, + onError: (message) => { + document.body.dataset.error = message; + }, + }); + return ( + <> + {voice.phase} + + + + + ); +} + +describe("useVoiceInput", () => { + const originalRecorder = Object.getOwnPropertyDescriptor(globalThis, "MediaRecorder"); + const originalMediaDevices = Object.getOwnPropertyDescriptor(navigator, "mediaDevices"); + + beforeEach(() => { + MockMediaRecorder.instances = []; + MockMediaRecorder.supported = new Set(["audio/webm;codecs=opus"]); + document.body.dataset.transcript = ""; + document.body.dataset.error = ""; + Object.defineProperty(globalThis, "MediaRecorder", { + configurable: true, + value: MockMediaRecorder, + }); + vi.mocked(transcribeAudio).mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + if (originalRecorder) Object.defineProperty(globalThis, "MediaRecorder", originalRecorder); + else delete (globalThis as { MediaRecorder?: unknown }).MediaRecorder; + if (originalMediaDevices) Object.defineProperty(navigator, "mediaDevices", originalMediaDevices); + else delete (navigator as { mediaDevices?: unknown }).mediaDevices; + }); + + it("prefers browser-supported WebM and rejects a browser with neither accepted container", () => { + expect(recordingMimeType()).toBe("audio/webm;codecs=opus"); + MockMediaRecorder.supported.clear(); + expect(recordingMimeType()).toBeNull(); + }); + + it("stops at five minutes and transcribes the bounded recording once", async () => { + vi.useFakeTimers(); + const { stream, track } = streamWithTrack(); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, + }); + vi.mocked(transcribeAudio).mockResolvedValue({ ok: true, text: "five minute clip" }); + render(); + + await act(async () => { + screen.getByRole("button", { name: "start" }).click(); + await Promise.resolve(); + }); + expect(screen.getByText("recording")).toBeInTheDocument(); + const recorder = MockMediaRecorder.instances[0]!; + const stop = vi.spyOn(recorder, "stop"); + + act(() => vi.advanceTimersByTime(MAX_VOICE_DURATION_MS - 1)); + expect(stop).not.toHaveBeenCalled(); + expect(vi.mocked(transcribeAudio)).not.toHaveBeenCalled(); + + act(() => vi.advanceTimersByTime(1)); + await act(async () => { + await Promise.resolve(); + }); + + expect(stop).toHaveBeenCalledTimes(1); + expect(track.stop).toHaveBeenCalledTimes(1); + expect(vi.mocked(transcribeAudio)).toHaveBeenCalledTimes(1); + expect(vi.mocked(transcribeAudio).mock.calls[0]?.[2]).toBe(MAX_VOICE_DURATION_MS); + expect(document.body.dataset.transcript).toBe("five minute clip"); + expect(screen.getByText("idle")).toBeInTheDocument(); + }); + + it("tears down an oversized chunk without transcribing it", async () => { + const user = userEvent.setup(); + const { stream, track } = streamWithTrack(); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, + }); + render(); + + await user.click(screen.getByRole("button", { name: "start" })); + await screen.findByText("recording"); + const recorder = MockMediaRecorder.instances[0]!; + act(() => { + recorder.ondataavailable?.({ + data: new Blob([new Uint8Array(MAX_VOICE_BYTES + 1)], { type: "audio/webm" }), + } as BlobEvent); + }); + + expect(recorder.state).toBe("inactive"); + expect(track.stop).toHaveBeenCalledTimes(1); + expect(document.body.dataset.error).toBe("Voice recording exceeded 8 MiB"); + expect(vi.mocked(transcribeAudio)).not.toHaveBeenCalled(); + expect(screen.getByText("idle")).toBeInTheDocument(); + }); + + it("releases a late permission stream after cancellation without recording or transcribing", async () => { + const user = userEvent.setup(); + const { stream, track } = streamWithTrack(); + let resolveStream!: (value: MediaStream) => void; + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { + getUserMedia: vi.fn().mockReturnValue( + new Promise((resolve) => { + resolveStream = resolve; + }), + ), + }, + }); + render(); + + await user.click(screen.getByRole("button", { name: "start" })); + expect(screen.getByText("requesting")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "cancel" })); + expect(screen.getByText("idle")).toBeInTheDocument(); + + await act(async () => { + resolveStream(stream); + await Promise.resolve(); + }); + + expect(track.stop).toHaveBeenCalledTimes(1); + expect(MockMediaRecorder.instances).toHaveLength(0); + expect(vi.mocked(transcribeAudio)).not.toHaveBeenCalled(); + expect(document.body.dataset.transcript).toBe(""); + }); + + it("records a completed clip, stops tracks, and hands only editable text back", async () => { + const user = userEvent.setup(); + const { stream, track } = streamWithTrack(); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, + }); + vi.mocked(transcribeAudio).mockResolvedValue({ ok: true, text: "review this first" }); + render(); + + await user.click(screen.getByRole("button", { name: "start" })); + await screen.findByText("recording"); + expect(MockMediaRecorder.instances[0]?.options?.mimeType).toBe("audio/webm;codecs=opus"); + + await user.click(screen.getByRole("button", { name: "stop" })); + await waitFor(() => expect(document.body.dataset.transcript).toBe("review this first")); + expect(track.stop).toHaveBeenCalledTimes(1); + expect(vi.mocked(transcribeAudio)).toHaveBeenCalledTimes(1); + expect(screen.getByText("idle")).toBeInTheDocument(); + }); + + it("cancels an in-flight transcription, aborts its request, and ignores a late response", async () => { + const user = userEvent.setup(); + const { stream } = streamWithTrack(); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, + }); + let resolve!: (value: { ok: true; text: string }) => void; + vi.mocked(transcribeAudio).mockReturnValue( + new Promise((done) => { + resolve = done; + }), + ); + render(); + + await user.click(screen.getByRole("button", { name: "start" })); + await screen.findByText("recording"); + await user.click(screen.getByRole("button", { name: "stop" })); + await screen.findByText("transcribing"); + const signal = vi.mocked(transcribeAudio).mock.calls[0]?.[4]; + expect(signal).toBeInstanceOf(AbortSignal); + + await user.click(screen.getByRole("button", { name: "cancel" })); + expect(signal?.aborted).toBe(true); + resolve({ ok: true, text: "late text" }); + await Promise.resolve(); + expect(document.body.dataset.transcript).toBe(""); + expect(screen.getByText("idle")).toBeInTheDocument(); + }); + + it("cancels the old StrictMode session lifecycle before recording in the new one", async () => { + const user = userEvent.setup(); + const first = streamWithTrack(); + const second = streamWithTrack(); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { + getUserMedia: vi.fn().mockResolvedValueOnce(first.stream).mockResolvedValueOnce(second.stream), + }, + }); + let resolveOld!: (value: { ok: true; text: string }) => void; + vi.mocked(transcribeAudio) + .mockReturnValueOnce( + new Promise((done) => { + resolveOld = done; + }), + ) + .mockResolvedValueOnce({ ok: true, text: "new session text" }); + const view = render( + + + , + ); + + await user.click(screen.getByRole("button", { name: "start" })); + await screen.findByText("recording"); + await user.click(screen.getByRole("button", { name: "stop" })); + await screen.findByText("transcribing"); + const oldSignal = vi.mocked(transcribeAudio).mock.calls[0]?.[4]; + expect(oldSignal).toBeInstanceOf(AbortSignal); + expect(first.track.stop).toHaveBeenCalledTimes(1); + + view.rerender( + + + , + ); + expect(oldSignal?.aborted).toBe(true); + expect(screen.getByText("idle")).toBeInTheDocument(); + resolveOld({ ok: true, text: "old session text" }); + await Promise.resolve(); + expect(document.body.dataset.transcript).toBe(""); + + await user.click(screen.getByRole("button", { name: "start" })); + await screen.findByText("recording"); + await user.click(screen.getByRole("button", { name: "stop" })); + await waitFor(() => expect(vi.mocked(transcribeAudio)).toHaveBeenCalledTimes(2)); + expect(vi.mocked(transcribeAudio).mock.calls[1]?.[0]).toBe("w1:p1"); + expect(vi.mocked(transcribeAudio).mock.calls[1]?.[3]).toBe("new"); + await waitFor(() => expect(document.body.dataset.transcript).toBe("new session text")); + }); + + it("cancels and releases microphone tracks when the page becomes hidden without uploading", async () => { + const user = userEvent.setup(); + const { stream, track } = streamWithTrack(); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, + }); + const visibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); + Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" }); + try { + render(); + await user.click(screen.getByRole("button", { name: "start" })); + await screen.findByText("recording"); + + act(() => document.dispatchEvent(new Event("visibilitychange"))); + + expect(track.stop).toHaveBeenCalledTimes(1); + expect(MockMediaRecorder.instances[0]?.state).toBe("inactive"); + expect(vi.mocked(transcribeAudio)).not.toHaveBeenCalled(); + expect(screen.getByText("idle")).toBeInTheDocument(); + } finally { + if (visibilityState) Object.defineProperty(document, "visibilityState", visibilityState); + else delete (document as { visibilityState?: unknown }).visibilityState; + } + }); + + it("cleans up a failed transcription and reports it through the existing error callback", async () => { + const user = userEvent.setup(); + const { stream, track } = streamWithTrack(); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, + }); + vi.mocked(transcribeAudio).mockRejectedValue(new Error("provider unavailable")); + render(); + + await user.click(screen.getByRole("button", { name: "start" })); + await screen.findByText("recording"); + await user.click(screen.getByRole("button", { name: "stop" })); + + await waitFor(() => expect(document.body.dataset.error).toBe("Transcription failed — record again to retry.")); + expect(track.stop).toHaveBeenCalledTimes(1); + expect(screen.getByText("idle")).toBeInTheDocument(); + }); + + it("cancels recording and releases microphone tracks on unmount without uploading", async () => { + const user = userEvent.setup(); + const { stream, track } = streamWithTrack(); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, + }); + const view = render(); + + await user.click(screen.getByRole("button", { name: "start" })); + await screen.findByText("recording"); + view.unmount(); + + expect(track.stop).toHaveBeenCalledTimes(1); + expect(MockMediaRecorder.instances[0]?.state).toBe("inactive"); + expect(vi.mocked(transcribeAudio)).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/hooks/use-voice-input.ts b/web/src/hooks/use-voice-input.ts new file mode 100644 index 00000000..d8e88cb8 --- /dev/null +++ b/web/src/hooks/use-voice-input.ts @@ -0,0 +1,301 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; + +import * as api from "@/lib/api"; + +export const MAX_VOICE_DURATION_MS = 5 * 60 * 1000; +export const MAX_VOICE_BYTES = 8 * 1024 * 1024; + +export type VoicePhase = "idle" | "requesting" | "recording" | "transcribing"; + +/** One pane's active voice lifecycle, shared by the pane write boundary and its composer controls. */ +export interface VoiceInput { + phase: VoicePhase; + elapsedLabel: string; + startRecording: () => Promise; + stopRecording: () => void; + cancel: () => void; +} + +interface UseVoiceInputOptions { + enabled: boolean; + paneId: string; + session?: string; + onTranscript: (text: string) => void; + onError: (message: string) => void; +} + +interface VoiceScope { + paneId: string; + session?: string; +} + +/** The only containers Collie records and the bridge accepts. */ +export function recordingMimeType(): string | null { + if (typeof MediaRecorder === "undefined" || typeof MediaRecorder.isTypeSupported !== "function") { + return null; + } + for (const type of ["audio/webm;codecs=opus", "audio/webm", "audio/mp4"]) { + if (MediaRecorder.isTypeSupported(type)) return type; + } + return null; +} + +function elapsedLabel(ms: number): string { + const seconds = Math.floor(ms / 1000); + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; +} + +/** + * Native microphone lifecycle for one completed clip. Audio exists only as MediaRecorder chunks and + * a final Blob/File until the one abortable request settles; cancellation discards both immediately. + */ +export function useVoiceInput({ + enabled, + paneId, + session, + onTranscript, + onError, +}: UseVoiceInputOptions): VoiceInput { + const [phase, setPhase] = useState("idle"); + const [elapsedMs, setElapsedMs] = useState(0); + const phaseRef = useRef("idle"); + const operationRef = useRef(null); + const scopeRef = useRef({ paneId, session }); + const operationScopeRef = useRef(null); + const recorderRef = useRef(null); + const streamRef = useRef(null); + const chunksRef = useRef([]); + const bytesRef = useRef(0); + const startedAtRef = useRef(0); + const elapsedTimerRef = useRef | null>(null); + const stopTimerRef = useRef | null>(null); + const onTranscriptRef = useRef(onTranscript); + const onErrorRef = useRef(onError); + onTranscriptRef.current = onTranscript; + onErrorRef.current = onError; + + const isCurrentOperation = (controller: AbortController, scope: VoiceScope) => + controller === operationRef.current && scope === scopeRef.current; + + const setVoicePhase = (next: VoicePhase) => { + phaseRef.current = next; + setPhase(next); + }; + + const clearRecordingTimers = () => { + if (elapsedTimerRef.current !== null) clearInterval(elapsedTimerRef.current); + if (stopTimerRef.current !== null) clearTimeout(stopTimerRef.current); + elapsedTimerRef.current = null; + stopTimerRef.current = null; + }; + + const stopTracks = () => { + for (const track of streamRef.current?.getTracks() ?? []) track.stop(); + streamRef.current = null; + }; + + /** Invalidate before aborting so synchronous recorder callbacks are stale before teardown. */ + const invalidateOperation = () => { + const controller = operationRef.current; + operationRef.current = null; + operationScopeRef.current = null; + controller?.abort(); + }; + + /** Release every imperative voice resource after invalidating its operation. */ + const teardownResources = (publish: boolean) => { + clearRecordingTimers(); + chunksRef.current = []; + bytesRef.current = 0; + const recorder = recorderRef.current; + recorderRef.current = null; + if (recorder && recorder.state !== "inactive") { + try { + recorder.stop(); + } catch { + // The recorder may already be stopping; tracks below still release the microphone. + } + } + stopTracks(); + if (publish) { + setElapsedMs(0); + setVoicePhase("idle"); + } + }; + + const cancelOperation = (publish = true) => { + invalidateOperation(); + teardownResources(publish); + }; + + const cancel = useCallback(() => { + // A late permission prompt, recorder stop event, or provider response cannot resurrect a + // cancelled clip or overwrite a newly typed draft after this identity is cleared. + cancelOperation(); + }, []); + + const failOperation = (controller: AbortController, scope: VoiceScope, message: string) => { + if (!isCurrentOperation(controller, scope)) return; + cancelOperation(); + onErrorRef.current(message); + }; + + const transcribe = async ( + controller: AbortController, + scope: VoiceScope, + blob: Blob, + mime: string, + reportedDurationMs: number, + ) => { + if (!isCurrentOperation(controller, scope)) return; + setVoicePhase("transcribing"); + const extension = mime.startsWith("audio/mp4") ? "mp4" : "webm"; + const file = new File([blob], `recording.${extension}`, { type: mime }); + try { + const response = await api.transcribeAudio( + scope.paneId, + file, + reportedDurationMs, + scope.session, + controller.signal, + ); + if (!isCurrentOperation(controller, scope)) return; + operationRef.current = null; + operationScopeRef.current = null; + setElapsedMs(0); + setVoicePhase("idle"); + onTranscriptRef.current(response.text); + } catch { + if (!isCurrentOperation(controller, scope)) return; + // The bridge deliberately maps provider bodies to a fixed message; network failures get the + // same safe local wording rather than exposing any transport implementation detail. + failOperation(controller, scope, "Transcription failed — record again to retry."); + } + }; + + const stopRecording = useCallback(() => { + const controller = operationRef.current; + const scope = operationScopeRef.current; + if (phaseRef.current !== "recording" || !controller || !scope || !isCurrentOperation(controller, scope)) return; + clearRecordingTimers(); + const recorder = recorderRef.current; + if (!recorder) { + failOperation(controller, scope, "Voice recording failed"); + return; + } + // Switch UI immediately so no draft/edit action can race the completed recording while the + // browser delivers its final dataavailable/stop events. + setVoicePhase("transcribing"); + try { + recorder.stop(); + } catch { + failOperation(controller, scope, "Voice recording failed"); + } + }, []); + + const startRecording = useCallback(async () => { + if (!enabled || phaseRef.current !== "idle") return; + const mime = recordingMimeType(); + if (!mime) { + onErrorRef.current("This browser cannot record WebM or MP4 audio"); + return; + } + if (!navigator.mediaDevices?.getUserMedia) { + onErrorRef.current("Microphone access is unavailable"); + return; + } + + const scope = scopeRef.current; + const controller = new AbortController(); + operationRef.current = controller; + operationScopeRef.current = scope; + setElapsedMs(0); + setVoicePhase("requesting"); + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + if (!isCurrentOperation(controller, scope)) { + for (const track of stream.getTracks()) track.stop(); + return; + } + streamRef.current = stream; + const recorder = new MediaRecorder(stream, { mimeType: mime }); + recorderRef.current = recorder; + chunksRef.current = []; + bytesRef.current = 0; + recorder.ondataavailable = (event) => { + if (!isCurrentOperation(controller, scope) || event.data.size === 0) return; + chunksRef.current.push(event.data); + bytesRef.current += event.data.size; + if (bytesRef.current > MAX_VOICE_BYTES) { + failOperation(controller, scope, "Voice recording exceeded 8 MiB"); + } + }; + recorder.onerror = () => failOperation(controller, scope, "Voice recording failed"); + recorder.onstop = () => { + if (!isCurrentOperation(controller, scope)) return; + recorderRef.current = null; + clearRecordingTimers(); + stopTracks(); + const reportedDurationMs = Math.min(Date.now() - startedAtRef.current, MAX_VOICE_DURATION_MS); + const chunks = chunksRef.current; + chunksRef.current = []; + bytesRef.current = 0; + if (reportedDurationMs < 1 || chunks.length === 0) { + failOperation(controller, scope, "Voice recording was empty"); + return; + } + const blob = new Blob(chunks, { type: mime }); + if (blob.size > MAX_VOICE_BYTES) { + failOperation(controller, scope, "Voice recording exceeded 8 MiB"); + return; + } + void transcribe(controller, scope, blob, mime, reportedDurationMs); + }; + startedAtRef.current = Date.now(); + recorder.start(1000); + setVoicePhase("recording"); + elapsedTimerRef.current = setInterval(() => { + if (isCurrentOperation(controller, scope)) { + setElapsedMs(Math.min(Date.now() - startedAtRef.current, MAX_VOICE_DURATION_MS)); + } + }, 1000); + stopTimerRef.current = setTimeout(() => stopRecording(), MAX_VOICE_DURATION_MS); + } catch { + failOperation(controller, scope, "Microphone access was unavailable"); + } + }, [enabled, stopRecording]); + + // Publish the committed scope before passive effects run, then release an outgoing operation. + // This makes old callbacks stale synchronously when a caller changes pane/session props in place. + useLayoutEffect(() => { + const previousScope = scopeRef.current; + scopeRef.current = { paneId, session }; + if (operationScopeRef.current === previousScope && operationRef.current !== null) cancel(); + }, [paneId, session, cancel]); + + useEffect(() => { + if (!enabled && phaseRef.current !== "idle") cancel(); + }, [enabled, cancel]); + + useEffect(() => { + const onPageHide = () => cancel(); + const onVisibilityChange = () => { + if (document.visibilityState === "hidden") cancel(); + }; + window.addEventListener("pagehide", onPageHide); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + window.removeEventListener("pagehide", onPageHide); + document.removeEventListener("visibilitychange", onVisibilityChange); + cancelOperation(false); + }; + }, [cancel]); + + return { + phase, + elapsedLabel: elapsedLabel(elapsedMs), + startRecording, + stopRecording, + cancel, + }; +} diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index c4bf4615..cf341cdb 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -10,6 +10,7 @@ import { fetchSnapshot, sendKeys, sendReply, + transcribeAudio, uploadImage, withTimeout, XHR_HEADER, @@ -108,6 +109,45 @@ describe("api client", () => { await expect(uploadImage("w1:p1", file)).rejects.toThrow(/413/); }); + it("transcribeAudio posts one redirect-refused multipart recording and reported duration", async () => { + let reportedDuration = ""; + let requests = 0; + server.use( + http.post(/\/api\/pane\/[^/]+\/transcribe$/, async ({ request }) => { + // MSW's Node multipart parser does not recognise jsdom's File implementation, so assert the + // actual encoded multipart contract rather than asking its Node-only parser to rehydrate it. + requests += 1; + const url = new URL(request.url); + const body = await request.text(); + reportedDuration = body.includes("1234") ? "1234" : ""; + expect(url.pathname).toBe("/api/pane/w1%3Ap1/transcribe"); + expect(url.searchParams.get("session")).toBe("collie-demo"); + expect(request.redirect).toBe("error"); + expect(request.headers.get(XHR_HEADER)).toBe(XHR_HEADER_VALUE); + expect(request.headers.get("content-type")).toMatch(/^multipart\/form-data; boundary=/); + expect(body).toContain('name="file"; filename='); + expect(body).toContain("Content-Type: audio/webm"); + expect(body).toContain('name="duration_ms"'); + return HttpResponse.json({ ok: true, text: "editable words" }); + }), + ); + const file = new File(["audio"], "recording.webm", { type: "audio/webm" }); + await expect(transcribeAudio("w1:p1", file, 1234, "collie-demo")).resolves.toEqual({ + ok: true, + text: "editable words", + }); + expect(reportedDuration).toBe("1234"); + expect(requests).toBe(1); + }); + + it("transcribeAudio preserves the bridge status error", async () => { + server.use( + http.post(/\/api\/pane\/[^/]+\/transcribe$/, () => new HttpResponse("provider down", { status: 502 })), + ); + const file = new File(["audio"], "recording.webm", { type: "audio/webm" }); + await expect(transcribeAudio("w1:p1", file, 1234)).rejects.toThrow(/502 provider down/); + }); + it("checkForUpdates POSTs (no body) and returns the fresh UpdateInfo", async () => { const info = { current: "0.11.0", @@ -203,6 +243,82 @@ describe("api client — request timeouts", () => { }); }); +// Voice owns a distinct 90-second total deadline because its completed multipart response can stall +// after headers. These cases stay local to the new endpoint; existing request paths retain main's +// native timeout coverage above. +describe("api client — transcription deadline", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + function stallTranscriptionBody() { + let signal: AbortSignal | null | undefined; + let resolveBodyRead: () => void = () => {}; + const bodyReadStarted = new Promise((resolve) => { + resolveBodyRead = resolve; + }); + + vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init?: RequestInit) => { + signal = init?.signal; + if (!signal) throw new Error("expected transcription deadline signal"); + const body = new ReadableStream( + { + start(controller) { + signal!.addEventListener("abort", () => controller.error(signal!.reason), { once: true }); + }, + pull() { + resolveBodyRead(); + }, + }, + { highWaterMark: 0 }, + ); + return new Response(body, { status: 200, headers: { "content-type": "application/json" } }); + }); + + return { + bodyReadStarted, + get signal() { + return signal; + }, + }; + } + + it("keeps the 90-second deadline through a stalled transcription body", async () => { + vi.useFakeTimers(); + const stalled = stallTranscriptionBody(); + const pending = transcribeAudio( + "w1:p1", + new File(["x"], "recording.webm", { type: "audio/webm" }), + 1_000, + ).catch((error: unknown) => error); + + await stalled.bodyReadStarted; + await vi.advanceTimersByTimeAsync(90_000); + + await expect(pending).resolves.toMatchObject({ name: "TimeoutError" }); + expect(stalled.signal?.aborted).toBe(true); + }); + + it("keeps caller cancellation through a transcription body after headers", async () => { + const caller = new AbortController(); + const stalled = stallTranscriptionBody(); + const pending = transcribeAudio( + "w1:p1", + new File(["x"], "recording.webm", { type: "audio/webm" }), + 1_000, + undefined, + caller.signal, + ); + + await stalled.bodyReadStarted; + caller.abort(); + + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(stalled.signal?.aborted).toBe(true); + }); +}); + // The browser URL uses the short `?s=`; on the wire every session-scoped endpoint takes `session=`. // A named session must append that param (composing correctly with fetchPane's `?lines=`); the // primary session (undefined) must leave the path untouched so a single-session bridge is unaffected. @@ -276,9 +392,9 @@ describe("api client — connection-health stamping", () => { // signal `isAuthError` (lib/loaders.ts) can act on: `fetch` follows the cross-origin 302, the call // rejects as a TypeError with no status, and the refusal banner — with the Sign-in link that would // restore the session — never renders. Marking requests as XHR is what makes such a proxy answer 401 -// instead. Every path that talks to the bridge must carry it, including the two that bypass `req`: -// fetchPane builds its own header bag, and uploadImage sets none at all so the browser keeps -// ownership of the multipart boundary. +// instead. Every path that talks to the bridge must carry it, including the three that bypass `req`: +// fetchPane builds its own header bag; uploadImage and transcribeAudio set only this marker so the +// browser keeps ownership of each multipart boundary. describe("api client — XHR marker for identity proxies", () => { afterEach(() => vi.restoreAllMocks()); @@ -291,19 +407,22 @@ describe("api client — XHR marker for identity proxies", () => { return seen; } - it("marks reads, mutations, pane polls and uploads alike", async () => { + it("marks reads, mutations, pane polls, uploads and transcription alike", async () => { const seen = captureHeaders(); await fetchSnapshot(); await sendReply("w1:p1", "hi"); await fetchPane("w1:p1"); await uploadImage("w1:p1", new File(["x"], "x.png", { type: "image/png" })); - expect(seen).toHaveLength(4); + await transcribeAudio("w1:p1", new File(["x"], "recording.webm", { type: "audio/webm" }), 1_000); + expect(seen).toHaveLength(5); for (const headers of seen) expect(headers.get(XHR_HEADER)).toBe(XHR_HEADER_VALUE); }); - it("leaves the multipart upload without a content-type so the boundary survives", async () => { + it("leaves multipart uploads and transcription without a content-type so boundaries survive", async () => { const seen = captureHeaders(); await uploadImage("w1:p1", new File(["x"], "x.png", { type: "image/png" })); - expect(seen[0].get("content-type")).toBeNull(); + await transcribeAudio("w1:p1", new File(["x"], "recording.webm", { type: "audio/webm" }), 1_000); + expect(seen).toHaveLength(2); + for (const headers of seen) expect(headers.get("content-type")).toBeNull(); }); }); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e79b7acc..2ed3023c 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -12,6 +12,7 @@ import type { PaneHistoryResponse, PaneReadResponse, SnapshotResponse, + TranscriptionResponse, UpdateInfo, UploadResponse, } from "./types"; @@ -69,6 +70,8 @@ const GET_TIMEOUT_MS = 10_000; const MUTATION_TIMEOUT_MS = 20_000; // - Uploads carry a whole file over the phone's uplink — the most generous budget. const UPLOAD_TIMEOUT_MS = 60_000; +// - A completed voice clip has the same uplink cost plus one bounded provider round trip. +const TRANSCRIPTION_TIMEOUT_MS = 90_000; /** * Compose the caller's abort signal (a loader's `request.signal`, used to supersede a stale poll) @@ -90,6 +93,42 @@ export function withTimeout( return AbortSignal.any([signal, timeoutSignal]); } +/** Keep voice's deadline and caller signal alive until its response body is consumed. */ +async function transcribeWithDeadline( + callerSignal: AbortSignal | undefined, + request: (signal: AbortSignal) => Promise, +): Promise { + const controller = new AbortController(); + const onCallerAbort = () => { + if (!controller.signal.aborted) controller.abort(callerSignal?.reason); + }; + + let timer: ReturnType | undefined; + if (callerSignal?.aborted) { + onCallerAbort(); + } else { + callerSignal?.addEventListener("abort", onCallerAbort, { once: true }); + timer = setTimeout(() => { + if (!controller.signal.aborted) { + controller.abort(new DOMException("Request timed out", "TimeoutError")); + } + }, TRANSCRIPTION_TIMEOUT_MS); + } + + try { + const result = await request(controller.signal); + if (controller.signal.aborted) throw controller.signal.reason; + return result; + } catch (error) { + // errorDetail falls back to statusText on a failed body read; cancellation must still win. + if (controller.signal.aborted) throw controller.signal.reason; + throw error; + } finally { + if (timer !== undefined) clearTimeout(timer); + callerSignal?.removeEventListener("abort", onCallerAbort); + } +} + // Append the `session=` query param to an API path, composing with any query already present // (fetchPane carries `?lines=`). The browser URL uses the short `?s=`; on the wire it's `session=`. // Blank / absent session → the primary session, so the path is returned untouched (no param). @@ -464,3 +503,40 @@ export function uploadImage(paneId: string, file: File, session?: string): Promi })(), ); } + +/** + * Submit one completed in-memory recording for transcription. Browser Fetch refuses a front-door + * redirect before it can replay the multipart body; no retry is attempted, so callers must make a + * fresh recording after failure rather than risk billing the provider twice. + */ +export function transcribeAudio( + paneId: string, + file: File, + reportedDurationMs: number, + session?: string, + signal?: AbortSignal, +): Promise { + return trackBusy( + (async () => { + const fd = new FormData(); + fd.append("file", file); + // The wire field is retained for bridge compatibility; its value is browser-reported lifecycle metadata. + fd.append("duration_ms", String(reportedDurationMs)); + return transcribeWithDeadline(signal, async (deadlineSignal) => { + const res = await fetch(withSession(`/api/pane/${encodeURIComponent(paneId)}/transcribe`, session), { + method: "POST", + body: fd, + // Do not set content-type: the browser adds the multipart boundary. + headers: { [XHR_HEADER]: XHR_HEADER_VALUE }, + // Do not replay a voice recording if an identity proxy/front door redirects this POST. + redirect: "error", + signal: deadlineSignal, + }); + if (!res.ok) { + throw new ApiError(`transcription → ${res.status} ${await errorDetail(res)}`, res.status); + } + return (await res.json()) as TranscriptionResponse; + }); + })(), + ); +} diff --git a/web/src/lib/loaders.test.ts b/web/src/lib/loaders.test.ts index f4029f0e..a5550107 100644 --- a/web/src/lib/loaders.test.ts +++ b/web/src/lib/loaders.test.ts @@ -109,6 +109,18 @@ describe("rootLoader", () => { const data = await rootLoader(); expect(data.update).toBeUndefined(); }); + + it("threads only the transcription capability and fails closed for an older bridge", async () => { + const { rootLoader } = await import("./loaders"); + expect((await rootLoader()).transcriptionEnabled).toBe(false); + + server.use( + http.get("/api/snapshot", () => + HttpResponse.json({ ...fixtureSnapshot, transcriptionEnabled: true }), + ), + ); + expect((await rootLoader()).transcriptionEnabled).toBe(true); + }); }); describe("paneLoader", () => { diff --git a/web/src/lib/loaders.ts b/web/src/lib/loaders.ts index c9117bce..2455dfa3 100644 --- a/web/src/lib/loaders.ts +++ b/web/src/lib/loaders.ts @@ -75,6 +75,8 @@ export interface HomeData { snoozedUntil: number | null; /** Version / upgrade status for the footer update banner; undefined on an older bridge. */ update: UpdateInfo | undefined; + /** True only when the bridge explicitly advertises the server-side voice capability. */ + transcriptionEnabled: boolean; /** True when this render is the last-good snapshot after a failed refresh. */ error: boolean; /** True when the failed refresh was rejected with HTTP 401 or 403. */ @@ -151,6 +153,8 @@ function toHomeData(snap: SnapshotResponse, session: string | undefined, error: session, snoozedUntil: snap.notifications?.snoozedUntil ?? null, update: snap.update, + // An older bridge omits the capability; fail closed to the existing text-only composer. + transcriptionEnabled: snap.transcriptionEnabled ?? false, error, authError: error && hasAuthError(session), }; @@ -175,6 +179,7 @@ function staleHome(session: string | undefined): HomeData { session, snoozedUntil: null, update: undefined, + transcriptionEnabled: false, error: true, authError: hasAuthError(session), }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 5f985fce..6a8028e5 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -171,6 +171,8 @@ export interface SnapshotResponse { sessions?: SessionSummary[]; /** Version / upgrade status. Absent on an older bridge that doesn't report it. */ update?: UpdateInfo; + /** Voice input capability only. Provider URL, model, key and errors never leave the bridge. */ + transcriptionEnabled?: boolean; ts: number; } @@ -242,6 +244,9 @@ export type ActionResponse = export type UploadResponse = { ok: true; path: string } | { ok: false; error: string }; +/** A successful voice response becomes editable text for review; non-2xx bridge failures throw. */ +export type TranscriptionResponse = { ok: true; text: string }; + /** A freshly-created shell pane — enough to navigate into before the next poll lands. */ export interface CreatedPane { paneId: string; diff --git a/web/src/routes/detail.test.tsx b/web/src/routes/detail.test.tsx index dfb1161c..8716a640 100644 --- a/web/src/routes/detail.test.tsx +++ b/web/src/routes/detail.test.tsx @@ -45,6 +45,7 @@ const connected = (agents: AgentView[], shellPanes: AgentView[] = []): HomeData session: undefined, snoozedUntil: null, update: undefined, + transcriptionEnabled: false, error: false, authError: false, }); diff --git a/web/src/routes/detail.tsx b/web/src/routes/detail.tsx index f5036c02..8dc603f7 100644 --- a/web/src/routes/detail.tsx +++ b/web/src/routes/detail.tsx @@ -72,6 +72,7 @@ export function DetailRoute() { requestedLines={pane.requestedLines} revision={pane.revision} device={root.device} + transcriptionEnabled={root.transcriptionEnabled} bridge={root.bridge} error={root.error} stalled={stalled} diff --git a/web/src/test/handlers.ts b/web/src/test/handlers.ts index c47ee891..cf67a5dc 100644 --- a/web/src/test/handlers.ts +++ b/web/src/test/handlers.ts @@ -162,6 +162,9 @@ export const handlers = [ recordReply((await request.json()) as { text?: string; submit?: boolean }); return HttpResponse.json({ ok: true }); }), + http.post(/\/api\/pane\/[^/]+\/transcribe$/, () => + HttpResponse.json({ ok: true, text: "review this transcript" }), + ), http.post(/\/api\/pane\/[^/]+\/keys$/, () => HttpResponse.json({ ok: true })), http.post(/\/api\/pane\/[^/]+\/close$/, () => HttpResponse.json({ ok: true })), http.post(/\/api\/pane\/[^/]+\/rename$/, () => HttpResponse.json({ ok: true })), From d8856a8721c612c4c28026df7f6646cea708e7b2 Mon Sep 17 00:00:00 2001 From: en-ver Date: Tue, 11 Aug 2026 19:02:11 +0300 Subject: [PATCH 2/9] fix(voice): keep screen awake while recording --- README.md | 4 + web/src/hooks/use-voice-input.test.tsx | 116 ++++++++++++++++++++++++- web/src/hooks/use-voice-input.ts | 29 +++++++ 3 files changed, 148 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e573fe9d..f6e02547 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,10 @@ Its narrow provider contract is `POST /audio/transcriptions` with a model/file a returning `{ "text": "…" }`. Collie puts that text into the ordinary editable draft and waits for you to press the existing **Send** button. It never auto-submits text to Herdr. +Compatible browsers make a best-effort attempt to keep the screen awake only during active foreground recording; +browser, OS, or manual-lock policy may override it, and hiding or closing still cancels recording with no background +guarantee. + ```dotenv # Official OpenAI: uncomment and fill this (the default base URL is https://api.openai.com/v1). COLLIE_TRANSCRIPTION_API_KEY=your_openai_api_key diff --git a/web/src/hooks/use-voice-input.test.tsx b/web/src/hooks/use-voice-input.test.tsx index ca176228..276ca91a 100644 --- a/web/src/hooks/use-voice-input.test.tsx +++ b/web/src/hooks/use-voice-input.test.tsx @@ -75,6 +75,7 @@ function VoiceHarness({ enabled = true, session }: { enabled?: boolean; session? describe("useVoiceInput", () => { const originalRecorder = Object.getOwnPropertyDescriptor(globalThis, "MediaRecorder"); const originalMediaDevices = Object.getOwnPropertyDescriptor(navigator, "mediaDevices"); + const originalWakeLock = Object.getOwnPropertyDescriptor(navigator, "wakeLock"); beforeEach(() => { MockMediaRecorder.instances = []; @@ -94,6 +95,8 @@ describe("useVoiceInput", () => { else delete (globalThis as { MediaRecorder?: unknown }).MediaRecorder; if (originalMediaDevices) Object.defineProperty(navigator, "mediaDevices", originalMediaDevices); else delete (navigator as { mediaDevices?: unknown }).mediaDevices; + if (originalWakeLock) Object.defineProperty(navigator, "wakeLock", originalWakeLock); + else delete (navigator as unknown as { wakeLock?: unknown }).wakeLock; }); it("prefers browser-supported WebM and rejects a browser with neither accepted container", () => { @@ -110,6 +113,11 @@ describe("useVoiceInput", () => { value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, }); vi.mocked(transcribeAudio).mockResolvedValue({ ok: true, text: "five minute clip" }); + const wakeLock = { release: vi.fn().mockResolvedValue(undefined) }; + Object.defineProperty(navigator, "wakeLock", { + configurable: true, + value: { request: vi.fn().mockResolvedValue(wakeLock) }, + }); render(); await act(async () => { @@ -131,6 +139,7 @@ describe("useVoiceInput", () => { expect(stop).toHaveBeenCalledTimes(1); expect(track.stop).toHaveBeenCalledTimes(1); + expect(wakeLock.release).toHaveBeenCalledTimes(1); expect(vi.mocked(transcribeAudio)).toHaveBeenCalledTimes(1); expect(vi.mocked(transcribeAudio).mock.calls[0]?.[2]).toBe(MAX_VOICE_DURATION_MS); expect(document.body.dataset.transcript).toBe("five minute clip"); @@ -215,6 +224,98 @@ describe("useVoiceInput", () => { expect(screen.getByText("idle")).toBeInTheDocument(); }); + it("requests a screen wake lock only while recording and releases it before transcription", async () => { + const user = userEvent.setup(); + const { stream } = streamWithTrack(); + const wakeLock = { release: vi.fn().mockResolvedValue(undefined) }; + const request = vi.fn().mockResolvedValue(wakeLock); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, + }); + Object.defineProperty(navigator, "wakeLock", { + configurable: true, + value: { request }, + }); + vi.mocked(transcribeAudio).mockReturnValue(new Promise(() => {})); + render(); + + await user.click(screen.getByRole("button", { name: "start" })); + await screen.findByText("recording"); + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith("screen"); + expect(wakeLock.release).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "stop" })); + await screen.findByText("transcribing"); + expect(wakeLock.release).toHaveBeenCalledTimes(1); + }); + + it("keeps unsupported and rejected wake lock requests nonfatal", async () => { + const user = userEvent.setup(); + const first = streamWithTrack(); + const second = streamWithTrack(); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { + getUserMedia: vi.fn().mockResolvedValueOnce(first.stream).mockResolvedValueOnce(second.stream), + }, + }); + delete (navigator as unknown as { wakeLock?: unknown }).wakeLock; + const view = render(); + + await user.click(screen.getByRole("button", { name: "start" })); + await screen.findByText("recording"); + expect(document.body.dataset.error).toBe(""); + await user.click(screen.getByRole("button", { name: "cancel" })); + + const request = vi.fn().mockRejectedValue(new Error("denied")); + Object.defineProperty(navigator, "wakeLock", { configurable: true, value: { request } }); + await user.click(screen.getByRole("button", { name: "start" })); + await screen.findByText("recording"); + await act(async () => { + await Promise.resolve(); + }); + + expect(request).toHaveBeenCalledWith("screen"); + expect(document.body.dataset.error).toBe(""); + expect(screen.getByText("recording")).toBeInTheDocument(); + view.unmount(); + }); + + it("releases a wake lock that resolves after cancellation", async () => { + const user = userEvent.setup(); + const { stream } = streamWithTrack(); + const wakeLock = { release: vi.fn().mockResolvedValue(undefined) }; + let resolveWakeLock!: (value: typeof wakeLock) => void; + const request = vi.fn().mockReturnValue( + new Promise((resolve) => { + resolveWakeLock = resolve; + }), + ); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, + }); + Object.defineProperty(navigator, "wakeLock", { + configurable: true, + value: { request }, + }); + render(); + + await user.click(screen.getByRole("button", { name: "start" })); + await screen.findByText("recording"); + expect(request).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole("button", { name: "cancel" })); + + await act(async () => { + resolveWakeLock(wakeLock); + await Promise.resolve(); + }); + + expect(wakeLock.release).toHaveBeenCalledTimes(1); + }); + it("cancels an in-flight transcription, aborts its request, and ignores a late response", async () => { const user = userEvent.setup(); const { stream } = streamWithTrack(); @@ -300,20 +401,27 @@ describe("useVoiceInput", () => { it("cancels and releases microphone tracks when the page becomes hidden without uploading", async () => { const user = userEvent.setup(); const { stream, track } = streamWithTrack(); + const wakeLock = { release: vi.fn().mockResolvedValue(undefined) }; Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, }); + Object.defineProperty(navigator, "wakeLock", { + configurable: true, + value: { request: vi.fn().mockResolvedValue(wakeLock) }, + }); const visibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); - Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" }); + Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); try { render(); await user.click(screen.getByRole("button", { name: "start" })); await screen.findByText("recording"); + Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" }); act(() => document.dispatchEvent(new Event("visibilitychange"))); expect(track.stop).toHaveBeenCalledTimes(1); + expect(wakeLock.release).toHaveBeenCalledTimes(1); expect(MockMediaRecorder.instances[0]?.state).toBe("inactive"); expect(vi.mocked(transcribeAudio)).not.toHaveBeenCalled(); expect(screen.getByText("idle")).toBeInTheDocument(); @@ -345,10 +453,15 @@ describe("useVoiceInput", () => { it("cancels recording and releases microphone tracks on unmount without uploading", async () => { const user = userEvent.setup(); const { stream, track } = streamWithTrack(); + const wakeLock = { release: vi.fn().mockResolvedValue(undefined) }; Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, }); + Object.defineProperty(navigator, "wakeLock", { + configurable: true, + value: { request: vi.fn().mockResolvedValue(wakeLock) }, + }); const view = render(); await user.click(screen.getByRole("button", { name: "start" })); @@ -356,6 +469,7 @@ describe("useVoiceInput", () => { view.unmount(); expect(track.stop).toHaveBeenCalledTimes(1); + expect(wakeLock.release).toHaveBeenCalledTimes(1); expect(MockMediaRecorder.instances[0]?.state).toBe("inactive"); expect(vi.mocked(transcribeAudio)).not.toHaveBeenCalled(); }); diff --git a/web/src/hooks/use-voice-input.ts b/web/src/hooks/use-voice-input.ts index d8e88cb8..b3a6c65c 100644 --- a/web/src/hooks/use-voice-input.ts +++ b/web/src/hooks/use-voice-input.ts @@ -69,6 +69,7 @@ export function useVoiceInput({ const startedAtRef = useRef(0); const elapsedTimerRef = useRef | null>(null); const stopTimerRef = useRef | null>(null); + const wakeLockRef = useRef(null); const onTranscriptRef = useRef(onTranscript); const onErrorRef = useRef(onError); onTranscriptRef.current = onTranscript; @@ -89,6 +90,30 @@ export function useVoiceInput({ stopTimerRef.current = null; }; + const releaseWakeLock = () => { + const wakeLock = wakeLockRef.current; + wakeLockRef.current = null; + if (wakeLock) void wakeLock.release().catch(() => {}); + }; + + const acquireWakeLock = (controller: AbortController, scope: VoiceScope) => { + if (!navigator.wakeLock || document.visibilityState !== "visible") return; + try { + void navigator.wakeLock + .request("screen") + .then((wakeLock) => { + if (!isCurrentOperation(controller, scope) || phaseRef.current !== "recording") { + void wakeLock.release().catch(() => {}); + return; + } + wakeLockRef.current = wakeLock; + }) + .catch(() => {}); + } catch { + // Wake Lock support is best-effort and must not alter the voice lifecycle. + } + }; + const stopTracks = () => { for (const track of streamRef.current?.getTracks() ?? []) track.stop(); streamRef.current = null; @@ -105,6 +130,7 @@ export function useVoiceInput({ /** Release every imperative voice resource after invalidating its operation. */ const teardownResources = (publish: boolean) => { clearRecordingTimers(); + releaseWakeLock(); chunksRef.current = []; bytesRef.current = 0; const recorder = recorderRef.current; @@ -186,6 +212,7 @@ export function useVoiceInput({ // Switch UI immediately so no draft/edit action can race the completed recording while the // browser delivers its final dataavailable/stop events. setVoicePhase("transcribing"); + releaseWakeLock(); try { recorder.stop(); } catch { @@ -233,6 +260,7 @@ export function useVoiceInput({ recorder.onerror = () => failOperation(controller, scope, "Voice recording failed"); recorder.onstop = () => { if (!isCurrentOperation(controller, scope)) return; + releaseWakeLock(); recorderRef.current = null; clearRecordingTimers(); stopTracks(); @@ -254,6 +282,7 @@ export function useVoiceInput({ startedAtRef.current = Date.now(); recorder.start(1000); setVoicePhase("recording"); + acquireWakeLock(controller, scope); elapsedTimerRef.current = setInterval(() => { if (isCurrentOperation(controller, scope)) { setElapsedMs(Math.min(Date.now() - startedAtRef.current, MAX_VOICE_DURATION_MS)); From 7fc3839e58041959b96ab93594e237f0c4077324 Mon Sep 17 00:00:00 2001 From: en-ver Date: Wed, 12 Aug 2026 08:16:37 +0300 Subject: [PATCH 3/9] fix(composer): retain blocked send payload --- web/src/components/composer.test.tsx | 92 +++++++++++++++++++++++ web/src/components/composer.tsx | 44 ++++++----- web/src/hooks/use-pending-confirm.test.ts | 12 +++ web/src/hooks/use-pending-confirm.ts | 14 +++- 4 files changed, 140 insertions(+), 22 deletions(-) diff --git a/web/src/components/composer.test.tsx b/web/src/components/composer.test.tsx index 361e5d5e..6a2564dd 100644 --- a/web/src/components/composer.test.tsx +++ b/web/src/components/composer.test.tsx @@ -972,6 +972,98 @@ describe("Composer — blocked pre-flight override", () => { ); } + // The first read is an omp picker; after the deliberate override types, the next live read has + // the command in omp's composer. This drives the palette → guarded send composition through its + // real pre-flight and verification phases rather than mocking either child in isolation. + const OMP_COLS = 189; + const padOmp = (open: string, body: string, close: string, filler: string) => + open + body + filler.repeat(OMP_COLS - open.length - body.length - close.length) + close; + const OMP_PICKER = [ + padOmp("╭──", " Select a model ", "╮", "─"), + padOmp("│ ", " ❯ 1. claude-opus ", " │", " "), + padOmp("╰──", "", "──╯", "─"), + ].join("\n"); + const ompComposer = (draft: string) => + [ + "transcript above the composer", + "", + padOmp("╭── ⬢ Auto > ⑂ master ", "", "╮", "─"), + padOmp("╰─ ", draft, " ─╯", " "), + ].join("\n"); + + function serveOmpPicker(calls: string[]) { + let typed = ""; + server.use( + http.get(/\/api\/pane\/[^/]+$/, () => + HttpResponse.json({ + paneId: "w1:p1", + text: typed ? ompComposer(typed) : OMP_PICKER, + truncated: false, + revision: 1, + }), + ), + http.post(/\/api\/pane\/[^/]+\/reply$/, async ({ request }) => { + const body = (await request.json()) as { text: string; submit?: boolean }; + if (body.submit) calls.push("submit"); + else { + typed = body.text; + calls.push(`type:${body.text}`); + } + return HttpResponse.json({ ok: true }); + }), + ); + } + + it("retries a blocked palette command from an empty voice composer", async () => { + const user = userEvent.setup(); + const calls: string[] = []; + serveOmpPicker(calls); + const props = renderComposerWithStatus({ agent: "omp", transcriptionEnabled: true }); + + expect(screen.getByRole("button", { name: "Record voice" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Agent" })); + await user.click(screen.getByText("/branch")); + + await waitFor(() => + expect(screen.getByTestId("status")).toHaveTextContent(/Tap Send again to type anyway/i), + ); + await waitFor(() => + expect(screen.queryByRole("dialog", { name: "Agent commands" })).not.toBeInTheDocument(), + ); + expect(screen.getByPlaceholderText(/type a reply/i)).toHaveValue(""); + expect(screen.getByRole("button", { name: "Type anyway?" })).toBeEnabled(); + expect(screen.queryByRole("button", { name: "Record voice" })).not.toBeInTheDocument(); + expect(calls).toEqual([]); + + await user.click(screen.getByRole("button", { name: "Type anyway?" })); + await waitFor(() => expect(calls).toEqual(["type:/branch", "submit"])); + expect(screen.getByPlaceholderText(/type a reply/i)).toHaveValue(""); + expect(props.onSent).toHaveBeenCalledOnce(); + }); + + it("retries the palette command without replacing an unrelated composer draft", async () => { + const user = userEvent.setup(); + const calls: string[] = []; + serveOmpPicker(calls); + const props = renderComposerWithStatus({ agent: "omp" }); + const box = screen.getByPlaceholderText(/type a reply/i); + await user.type(box, "unrelated draft"); + + await user.click(screen.getByRole("button", { name: "Agent" })); + await user.click(screen.getByText("/branch")); + await waitFor(() => + expect(screen.getByTestId("status")).toHaveTextContent(/Tap Send again to type anyway/i), + ); + expect(box).toHaveValue("unrelated draft"); + expect(screen.getByRole("button", { name: "Type anyway?" })).toBeEnabled(); + expect(calls).toEqual([]); + + await user.click(screen.getByRole("button", { name: "Type anyway?" })); + await waitFor(() => expect(calls).toEqual(["type:/branch", "submit"])); + expect(box).toHaveValue("unrelated draft"); + expect(props.onSent).toHaveBeenCalledOnce(); + }); + it("keeps the draft, explains, and types nothing on the first tap", async () => { const user = userEvent.setup(); const calls: string[] = []; diff --git a/web/src/components/composer.tsx b/web/src/components/composer.tsx index bcf3f52b..24110cfb 100644 --- a/web/src/components/composer.tsx +++ b/web/src/components/composer.tsx @@ -88,6 +88,12 @@ interface ComposerProps { // them). Find moved the other way — to the header, where its find bar already takes over the row. type ComposerDrawer = "quick" | "cmd" | "keys" | "display" | null; +/** The exact send attempt that a blocked pre-flight lets the user deliberately retry. */ +interface ForcedSend { + text: string; + isDraft: boolean; +} + // The Controls row's "on" look, authored once so an open dock and an armed mode can never drift // apart. `hover:` is pinned to the same tint: without it, hovering an already-on control repaints it // with the ghost variant's hover background and it reads as switching off under the cursor. @@ -251,8 +257,9 @@ export const Composer = forwardRef(function Compo // Two-tap override for a `blocked` pre-flight ("the input box isn't on screen"). Separate from // sendConfirm so a destructive-command confirm and an override can't clobber each other, and given // a longer window than the 3s default: unlike "Really send?", this one asks you to read a sentence - // explaining WHY nothing was typed before deciding to overrule it. - const forceConfirm = usePendingConfirm(10_000); + // explaining WHY nothing was typed before deciding to overrule it. It owns the exact attempted + // payload too: a palette command is not the phone-owned draft and must never retry as one. + const forceConfirm = usePendingConfirm(10_000); const inputRef = useRef(null); const fileRef = useRef(null); @@ -542,7 +549,7 @@ export const Composer = forwardRef(function Compo // but the adapter can only report what it can see, so the user gets a deliberate override — // the same two-tap shape as the destructive-send confirm. The second tap skips the pre-flight // ONLY; the type-then-verify guard still runs, so Enter is never fired blind either way. - forceConfirm.confirm("force"); + forceConfirm.confirm("force", { text: t, isDraft }); setStatus(`${res.error} Tap Send again to type anyway.`, "error"); return false; } else { @@ -570,8 +577,9 @@ export const Composer = forwardRef(function Compo // An armed override takes precedence: this tap IS the deliberate "type anyway", so it skips the // destructive re-confirm (already answered on the tap that got blocked) and the pre-flight. if (forceConfirm.pending === "force") { + const forced = forceConfirm.payload; forceConfirm.reset(); - send(input, true, true); + if (forced !== null) void send(forced.text, forced.isDraft, true); return; } const reason = isDestructiveInput(input); @@ -583,7 +591,7 @@ export const Composer = forwardRef(function Compo send(input, true); } const confirmingSend = sendConfirm.pending === "send"; - const forcingSend = forceConfirm.pending === "force"; + const forcingSend = forceConfirm.pending === "force" && forceConfirm.payload !== null; // Coalesce revalidations from a burst of key presses, LEADING edge first: the first press in a // burst refetches immediately, and only presses that arrive inside the window collapse into one @@ -975,29 +983,29 @@ export const Composer = forwardRef(function Compo > - ) : !direct.active && transcriptionEnabled && input.trim().length === 0 ? ( - ) : !direct.active && forcingSend ? ( // The pre-flight refused and the user is being offered the override. Labelled for what it - // actually does — TYPE the text into whatever is on screen — not "send", because the - // submit key is still conditional on the verify step behind it. + // actually does — TYPE the retained attempted payload into whatever is on screen — not + // "send", because the submit key is still conditional on the verify step behind it. + ) : !direct.active && transcriptionEnabled && input.trim().length === 0 ? ( + ) : !direct.active && confirmingSend ? ( + + + ); + } + + const router = createMemoryRouter([{ path: "/", element: }]); + render(); + + await user.click(screen.getByRole("button", { name: "Agent" })); + await user.click(screen.getByText("/branch")); + await screen.findByRole("button", { name: "Type anyway?" }); + await user.click(screen.getByRole("button", { name: "Navigate" })); + + expect(screen.getByTestId("status")).toBeEmptyDOMElement(); + expect(screen.queryByRole("button", { name: "Type anyway?" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Record voice" })).toBeEnabled(); + expect(calls).toEqual([]); + }); + it("retries a retained typed draft without clearing edits made before the override", async () => { const user = userEvent.setup(); const calls: string[] = []; diff --git a/web/src/components/composer.tsx b/web/src/components/composer.tsx index 003cb85a..d8fab3e3 100644 --- a/web/src/components/composer.tsx +++ b/web/src/components/composer.tsx @@ -6,7 +6,7 @@ import { Check, ImagePlus, Keyboard, Loader2, Mic, Send, Settings2, Slash, Squar import type { DisplayPrefs } from "@/hooks/use-display-prefs"; import { usePendingConfirm } from "@/hooks/use-pending-confirm"; import { useDirectTyping } from "@/hooks/use-direct-typing"; -import { setStatus } from "@/lib/status"; +import { clearStatus, setStatus } from "@/lib/status"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { ChatInput } from "@/components/ui/chat/chat-input"; @@ -554,8 +554,8 @@ export const Composer = forwardRef(function Compo // ONLY; the type-then-verify guard still runs, so Enter is never fired blind either way. // Latest blocked attempt wins. This is a new send attempt, not the user's second-tap // confirmation, so it replaces any armed payload and restarts the override window. - forceConfirm.arm("force", { text: t, draft }); - setStatus(`${res.error} Tap Send again to type anyway.`, "error"); + const status = setStatus(`${res.error} Tap Send again to type anyway.`, "error"); + forceConfirm.arm("force", { text: t, draft }, () => clearStatus(status.id)); return false; } else { // "stalled" = the text never reached the input box, so NO submit key was sent (a dialog was diff --git a/web/src/hooks/use-pending-confirm.test.ts b/web/src/hooks/use-pending-confirm.test.ts index b1a1ed3e..b6c8949b 100644 --- a/web/src/hooks/use-pending-confirm.test.ts +++ b/web/src/hooks/use-pending-confirm.test.ts @@ -57,6 +57,26 @@ describe("usePendingConfirm", () => { expect(result.current.pending).toBeNull(); }); + it("releases each armed cleanup when it is replaced, expires, or unmounts", () => { + const first = vi.fn(); + const second = vi.fn(); + const third = vi.fn(); + const { result, unmount } = renderHook(() => usePendingConfirm(3000)); + + act(() => { + result.current.arm("force", null, first); + result.current.arm("force", null, second); + }); + expect(first).toHaveBeenCalledOnce(); + + act(() => vi.advanceTimersByTime(3000)); + expect(second).toHaveBeenCalledOnce(); + + act(() => result.current.arm("force", null, third)); + unmount(); + expect(third).toHaveBeenCalledOnce(); + }); + it("auto-disarms after the timeout", () => { const { result } = renderHook(() => usePendingConfirm(3000)); act(() => { diff --git a/web/src/hooks/use-pending-confirm.ts b/web/src/hooks/use-pending-confirm.ts index d13371b3..96699d08 100644 --- a/web/src/hooks/use-pending-confirm.ts +++ b/web/src/hooks/use-pending-confirm.ts @@ -7,31 +7,40 @@ export function usePendingConfirm(timeoutMs = 3000) { const [pending, setPending] = useState(null); const [payload, setPayload] = useState(null); const timer = useRef | null>(null); + const onDisarm = useRef<(() => void) | null>(null); const clearTimer = useCallback(() => { if (timer.current) clearTimeout(timer.current); timer.current = null; }, []); - const reset = useCallback(() => { + // A caller can arm an exact transient alongside the control (such as an explanatory status). It + // must die with this attempt, including on replacement, expiry, confirmation, or owner unmount. + const release = useCallback(() => { clearTimer(); + const cleanup = onDisarm.current; + onDisarm.current = null; + cleanup?.(); + }, [clearTimer]); + + const reset = useCallback(() => { + release(); setPending(null); setPayload(null); - }, [clearTimer]); + }, [release]); // Arm (or replace) a pending action. Callers that discover a new guarded attempt while one is // already armed must use this rather than treating the attempt as the user's confirming second tap. + // `cleanup` owns a transient published for this exact arm. const arm = useCallback( - (id: string, nextPayload: T | null = null) => { - clearTimer(); + (id: string, nextPayload: T | null = null, cleanup?: () => void) => { + release(); + onDisarm.current = cleanup ?? null; setPending(id); setPayload(nextPayload); - timer.current = setTimeout(() => { - setPending(null); - setPayload(null); - }, timeoutMs); + timer.current = setTimeout(reset, timeoutMs); }, - [clearTimer, timeoutMs], + [release, reset, timeoutMs], ); // Returns true when `id` was already armed (this is the confirming second tap) — the caller should @@ -48,7 +57,7 @@ export function usePendingConfirm(timeoutMs = 3000) { [pending, arm, reset], ); - useEffect(() => clearTimer, [clearTimer]); + useEffect(() => release, [release]); return { pending, payload, arm, confirm, reset }; } diff --git a/web/src/lib/status.test.ts b/web/src/lib/status.test.ts index 04066352..6e126a68 100644 --- a/web/src/lib/status.test.ts +++ b/web/src/lib/status.test.ts @@ -48,6 +48,17 @@ describe("status channel", () => { expect(result.current).toBeNull(); }); + it("does not clear a newer status when targeting a stale message", () => { + const { result } = renderHook(() => useStatus()); + let first!: { id: number }; + act(() => { + first = setStatus("first", "error"); + setStatus("newer operation", "error"); + clearStatus(first.id); + }); + expect(result.current?.text).toBe("newer operation"); + }); + it("honours an explicit ttl of null (persist)", () => { const { result } = renderHook(() => useStatus()); act(() => setStatus("sticky", "info", null)); diff --git a/web/src/lib/status.ts b/web/src/lib/status.ts index bbaebcac..3b5f480a 100644 --- a/web/src/lib/status.ts +++ b/web/src/lib/status.ts @@ -25,10 +25,11 @@ function emit() { * Publish a transient status. Latest wins. Errors persist until dismissed (tap the bar); everything * else auto-clears. Pass an explicit `ttlMs` (or `null` to persist) to override the per-tone default. */ -export function setStatus(text: string, tone: StatusTone = "info", ttlMs?: number | null): void { +export function setStatus(text: string, tone: StatusTone = "info", ttlMs?: number | null): StatusMessage { if (timer) clearTimeout(timer); timer = null; - current = { id: nextId++, text, tone }; + const message = { id: nextId++, text, tone }; + current = message; emit(); const ttl = ttlMs === undefined ? (tone === "error" ? null : 2500) : ttlMs; if (ttl != null) { @@ -38,9 +39,12 @@ export function setStatus(text: string, tone: StatusTone = "info", ttlMs?: numbe emit(); }, ttl); } + return message; } -export function clearStatus(): void { +/** Clear the latest status, or only a specific message when an id is supplied. */ +export function clearStatus(id?: number): void { + if (id !== undefined && current?.id !== id) return; if (timer) { clearTimeout(timer); timer = null; From a98ca692c47a91117c63e3e23ab9bbb37232308b Mon Sep 17 00:00:00 2001 From: en-ver Date: Wed, 12 Aug 2026 09:30:45 +0300 Subject: [PATCH 6/9] fix(composer): cancel stale guarded sends --- web/src/components/composer.test.tsx | 104 +++++++++++++++++++++++++++ web/src/components/composer.tsx | 80 +++++++++++++++++++-- web/src/lib/api.ts | 4 ++ web/src/lib/reply-action.ts | 60 ++++++++++++---- 4 files changed, 229 insertions(+), 19 deletions(-) diff --git a/web/src/components/composer.test.tsx b/web/src/components/composer.test.tsx index b9b53c56..03023ac4 100644 --- a/web/src/components/composer.test.tsx +++ b/web/src/components/composer.test.tsx @@ -1018,6 +1018,110 @@ describe("Composer — blocked pre-flight override", () => { return () => probes; } + // The pre-flight is a round-trip, so leaving its owner must invalidate the result before the + // response decides anything. Drive every possible pre-flight verdict through the same window: + // blocked used to publish a stale override; ready and transport-error must not type or publish an + // error into the successor pane either. + it.each(["blocked", "ready", "error"] as const)( + "drops a deferred %s pre-flight after navigation", async (outcome) => { + const user = userEvent.setup(); + const PANE_A = "w9:stale"; + let announcePreflight!: () => void; + let releasePreflight!: () => void; + let resolvePreflightResponse!: () => void; + const preflightIssued = new Promise((resolve) => { + announcePreflight = resolve; + }); + const preflightHeld = new Promise((resolve) => { + releasePreflight = resolve; + }); + const preflightResponse = new Promise((resolve) => { + resolvePreflightResponse = resolve; + }); + const panePath = `/api/pane/${encodeURIComponent(PANE_A).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`; + const replyCalls: string[] = []; + server.use( + http.get(new RegExp(`${panePath}$`), async () => { + announcePreflight(); + await preflightHeld; + resolvePreflightResponse(); + if (outcome === "error") return HttpResponse.error(); + return HttpResponse.json({ + paneId: PANE_A, + text: outcome === "blocked" ? OMP_PICKER : ompComposer(""), + truncated: false, + revision: 1, + }); + }), + http.post(new RegExp(`${panePath}/reply$`), async ({ request }) => { + const body = (await request.json()) as { text: string }; + replyCalls.push(body.text); + return HttpResponse.json({ ok: true }); + }), + ); + + function Harness() { + const [navigated, setNavigated] = useState(false); + const paneId = navigated ? "w9:current" : PANE_A; + return ( + <> + + + + + ); + } + + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const router = createMemoryRouter([{ path: "/", element: }]); + render(); + await user.type(screen.getByPlaceholderText(/type a reply/i), "stale attempt"); + await user.click(screen.getByRole("button", { name: "Send" })); + await preflightIssued; + + await user.click(screen.getByRole("button", { name: "Navigate" })); + const currentDraft = screen.getByPlaceholderText(/type a reply/i); + await user.type(currentDraft, "current draft"); + releasePreflight(); + await preflightResponse; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(replyCalls).toEqual([]); + expect(currentDraft).toHaveValue("current draft"); + expect(drafts.loadDraft(undefined, PANE_A)).toBe("stale attempt"); + expect(screen.getByTestId("status")).toBeEmptyDOMElement(); + expect(screen.getByRole("button", { name: "Send" })).toBeEnabled(); + expect(screen.queryByRole("button", { name: "Type anyway?" })).not.toBeInTheDocument(); + expect(consoleError).not.toHaveBeenCalledWith(expect.stringMatching(/unmounted component/i)); + } finally { + consoleError.mockRestore(); + } + }, + ); + /** Capture the ten-second arm timer so the component's expiry can be driven without blocking MSW. */ function captureOverrideExpiry() { const nativeSetTimeout = globalThis.setTimeout; diff --git a/web/src/components/composer.tsx b/web/src/components/composer.tsx index d8fab3e3..ac019f53 100644 --- a/web/src/components/composer.tsx +++ b/web/src/components/composer.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; import type { ChangeEvent, ClipboardEvent, ReactNode } from "react"; import { useRevalidator } from "react-router"; import { Check, ImagePlus, Keyboard, Loader2, Mic, Send, Settings2, Slash, Square, Terminal, X, Zap } from "lucide-react"; @@ -95,6 +95,16 @@ interface ForcedSend { draft: string | null; } +interface SendScope { + paneId: string; + session: string | undefined; +} + +interface SendOperation { + controller: AbortController; + scope: SendScope; +} + // The Controls row's "on" look, authored once so an open dock and an armed mode can never drift // apart. `hover:` is pinned to the same tint: without it, hovering an already-on control repaints it // with the ghost variant's hover background and it reads as switching off under the cursor. @@ -202,6 +212,9 @@ export const Composer = forwardRef(function Compo setInput(restored); }, [session, paneId]); const [sending, setSending] = useState(false); + // State disables the controls after React renders; this ref closes the synchronous gap between a + // click and that render, so two write attempts can never share a guarded-send lifecycle. + const sendingRef = useRef(false); const [uploading, setUploading] = useState(false); // Pending-send preview: set on a successful send, cleared when the mirror catches up (next text // update) or after a 6s safety timeout. Shows "You sent: …" so the user knows the message landed. @@ -261,6 +274,39 @@ export const Composer = forwardRef(function Compo // explaining WHY nothing was typed before deciding to overrule it. It owns the exact attempted // payload too: a palette command is not the phone-owned draft and must never retry as one. const forceConfirm = usePendingConfirm(10_000); + // A guarded send belongs to the Composer instance AND its pane/session scope. Its pre-flight is + // async, so cancelling the exact operation before a pane replacement or unmount stops a late + // verdict from arming controls, publishing status, or writing into whoever owns the next pane. + const sendScopeRef = useRef({ paneId, session }); + const sendOperationRef = useRef(null); + const isCurrentSend = (operation: SendOperation) => + sendOperationRef.current === operation && + sendScopeRef.current === operation.scope && + !operation.controller.signal.aborted; + + // AgentChat normally keys this component by pane, but Composer also supports an in-place pane + // change for its persisted-draft contract. Publish that scope in layout timing, before the next + // view can accept input, and make both a pending send and an armed override die with the old pane. + useLayoutEffect(() => { + const previousScope = sendScopeRef.current; + if (previousScope.paneId === paneId && previousScope.session === session) return; + sendScopeRef.current = { paneId, session }; + const operation = sendOperationRef.current; + sendOperationRef.current = null; + operation?.controller.abort(); + sendingRef.current = false; + setSending(false); + forceConfirm.reset(); + }, [paneId, session, forceConfirm.reset]); + + useLayoutEffect( + () => () => { + const operation = sendOperationRef.current; + sendOperationRef.current = null; + operation?.controller.abort(); + }, + [], + ); const inputRef = useRef(null); const fileRef = useRef(null); @@ -429,7 +475,7 @@ export const Composer = forwardRef(function Compo // early return below has to answer honestly. async function send(value: string, draft: string | null, force = false): Promise { const t = value.trim(); - if (!t || locked || sending || voiceBusy) return false; + if (!t || locked || sendingRef.current || voiceBusy) return false; // A dialog on screen owns the TUI's keyboard: our text is swallowed and the submit key ANSWERS // the dialog, approving whatever option was highlighted (#34). Refuse BEFORE the destructive // pre-clear sweep below — those ctrl+k/Backspaces would land in the dialog too. The input is @@ -440,6 +486,14 @@ export const Composer = forwardRef(function Compo setStatus("A dialog is waiting — answer it first, then send.", "error"); return false; } + const operation: SendOperation = { + controller: new AbortController(), + scope: sendScopeRef.current, + }; + const previousOperation = sendOperationRef.current; + sendOperationRef.current = operation; + previousOperation?.controller.abort(); + sendingRef.current = true; setSending(true); try { // Guarded: types the text, verifies it reached the input box, and only THEN sends the submit @@ -450,6 +504,7 @@ export const Composer = forwardRef(function Compo agent, session, force, + signal: operation.controller.signal, // Clear a stranded draft on the terminal's "❯" line before pane.send_text appends at cursor — // ctrl+k kills cursor→end, Backspace sweep kills the head (preview-action.ts pattern). Skip // when there's no draft: a blind sweep races the TUI and Enter can fire before the PTY @@ -470,6 +525,9 @@ export const Composer = forwardRef(function Compo // really did hold a draft — which is what it did anyway, since the same detector that could // not see the box cannot read our text back out of it either. onComposerSeen: async ({ promptRegion }) => { + if (!isCurrentSend(operation)) { + return { ok: false as const, error: "The pane changed before its input could be cleared" }; + } if (effectiveRaw === null) return { ok: true as const, keysSent: false }; // The props that lock this composer are a SNAPSHOT too, and `send()` read them before the // pre-flight's round-trip. A pane that died or a device that lost write access inside that @@ -497,7 +555,11 @@ export const Composer = forwardRef(function Compo ["ctrl+k", ...Array(clearCount).fill("Backspace")], session, promptRegion ?? undefined, + operation.controller.signal, ); + if (!isCurrentSend(operation)) { + return { ok: false as const, error: "The pane changed before its input could be cleared" }; + } if (!clearRes.ok) { // A refused binding is the guard doing its job, not a transport failure — say so, because // the user's next move is to look at the pane rather than to retry into whatever is now @@ -512,12 +574,16 @@ export const Composer = forwardRef(function Compo } scheduleKeyRevalidate(); await new Promise((resolve) => setTimeout(resolve, TUI_SETTLE_MS)); + if (!isCurrentSend(operation)) { + return { ok: false as const, error: "The pane changed before its input could be cleared" }; + } // `keysSent` — the burst plus this settle is exactly the window the guard re-reads across // before it types, so the message doesn't follow the keys into a dialog that opened inside // it. return { ok: true as const, keysSent: true }; }, }); + if (!isCurrentSend(operation)) return false; if (res.status === "sent") { // Clear only the exact phone draft this attempt owns. A user can edit while a blocked // attempt is awaiting its deliberate override; that newer persisted draft must survive. @@ -557,7 +623,7 @@ export const Composer = forwardRef(function Compo const status = setStatus(`${res.error} Tap Send again to type anyway.`, "error"); forceConfirm.arm("force", { text: t, draft }, () => clearStatus(status.id)); return false; - } else { + } else if (res.status === "stalled" || res.status === "error") { // "stalled" = the text never reached the input box, so NO submit key was sent (a dialog was // probably holding focus). "error" with textDelivered = the text is in the pane but the // submit failed. Either way the draft stays put: the user checks the pane rather than @@ -566,11 +632,17 @@ export const Composer = forwardRef(function Compo setStatus(res.error, "error"); return false; } + return false; // cancelled — the operation's owner is already gone } catch (e) { + if (!isCurrentSend(operation)) return false; setStatus(e instanceof Error ? e.message : String(e), "error"); return false; } finally { - setSending(false); + if (sendOperationRef.current === operation) { + sendOperationRef.current = null; + sendingRef.current = false; + setSending(false); + } } } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2ed3023c..28046ad3 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -343,6 +343,7 @@ export function sendReply( submit = true, session?: string, expectedPrompt?: string, + signal?: AbortSignal, ): Promise { return req( withSession(`/api/pane/${encodeURIComponent(paneId)}/reply`, session), @@ -353,6 +354,7 @@ export function sendReply( submit, ...(expectedPrompt !== undefined ? { expected_prompt: expectedPrompt } : {}), }), + signal, }, recoverPromptChanged, ); @@ -363,6 +365,7 @@ export function sendKeys( keys: string[], session?: string, expectedPrompt?: string, + signal?: AbortSignal, ): Promise { return req( withSession(`/api/pane/${encodeURIComponent(paneId)}/keys`, session), @@ -372,6 +375,7 @@ export function sendKeys( keys, ...(expectedPrompt !== undefined ? { expected_prompt: expectedPrompt } : {}), }), + signal, }, recoverPromptChanged, ); diff --git a/web/src/lib/reply-action.ts b/web/src/lib/reply-action.ts index 390a1a73..ff207528 100644 --- a/web/src/lib/reply-action.ts +++ b/web/src/lib/reply-action.ts @@ -38,7 +38,9 @@ export type ReplyOutcome = /** Text never reached the input box — NO submit key was sent. The caller MUST keep the draft. */ | { status: "stalled"; error: string } /** Transport/RPC failure. `textDelivered` = text is in the pane but unsubmitted; don't resend. */ - | { status: "error"; error: string; textDelivered?: boolean }; + | { status: "error"; error: string; textDelivered?: boolean } + /** The owning Composer/pane went away; callers must make no UI decision from this stale run. */ + | { status: "cancelled" }; /** Minimum visible characters that must match before we believe the input box holds OUR text. */ export const MIN_MATCH_CHARS = 8; @@ -176,6 +178,9 @@ export interface GuardedReplyArgs { requestedLines?: number; /** Test seam for the poll pacing. */ sleep?: Sleep; + /** Composer-owned cancellation. A pane/view replacement aborts every pending read/write before a + * stale completion can decide what the current Composer should render. */ + signal?: AbortSignal; /** * Override the PRE-FLIGHT'S REFUSAL and type anyway — the user's deliberate second tap after a * `blocked` outcome (a mis-detected screen, an adapter that can't see a box it really has). The @@ -243,8 +248,16 @@ export type ComposerPrepResult = /** Abort the send with this error, nothing typed. */ | { ok: false; error: string }; +const CANCELLED: ReplyOutcome = { status: "cancelled" }; + +function cancelled(signal: AbortSignal | undefined): ReplyOutcome | null { + return signal?.aborted ? CANCELLED : null; +} + export async function sendGuardedReply(args: GuardedReplyArgs): Promise { const adapter = adapterFor(args.agent ?? undefined); + const aborted = cancelled(args.signal); + if (aborted) return aborted; // No grammar for this harness → the input box is unreadable, so there is nothing to verify // against and the guard cannot run. Keep the legacy one-shot send rather than guess: a heuristic // over the raw mirror has a false-negative that is worse than the bug — a no-echo input (a shell's @@ -258,6 +271,8 @@ export async function sendGuardedReply(args: GuardedReplyArgs): Promise 0) await sleep(POLL_DELAY_MS); + const abortedBeforeRead = cancelled(args.signal); + if (abortedBeforeRead) return abortedBeforeRead; let draft: string | null = null; try { - const fresh = await fetchPane(args.paneId, args.requestedLines, args.session); + const fresh = await fetchPane(args.paneId, args.requestedLines, args.session, args.signal); draft = adapter.extractInputDraft(splitLines(parseAnsi(fresh.text))); } catch { + const abortedAfterRead = cancelled(args.signal); + if (abortedAfterRead) return abortedAfterRead; continue; // transient read failure — the bounded loop is the timeout } + const abortedAfterRead = cancelled(args.signal); + if (abortedAfterRead) return abortedAfterRead; if (draftCarriesSend(args.text, draft)) return submitOnly(args); // The adapter gets a second look, and only a second look: a harness can SWALLOW what we typed and // paint a token of its own instead (Claude collapses anything past its paste threshold into @@ -351,6 +376,7 @@ interface Preflight { */ async function preflight(adapter: HarnessAdapter, args: GuardedReplyArgs): Promise { const blind = (refuse: ReplyOutcome | null): Preflight => ({ refuse, runPreType: null }); + if (cancelled(args.signal)) return blind(CANCELLED); // Nothing here can read this harness's input box, so there is no evidence to be had — and no // refusal to make either. Same behaviour as before an adapter grows a `composerReady`, minus the @@ -360,10 +386,11 @@ async function preflight(adapter: HarnessAdapter, args: GuardedReplyArgs): Promi const composerReady = adapter.composerReady.bind(adapter); let probe; try { - probe = await fetchPane(args.paneId, args.requestedLines, args.session); + probe = await fetchPane(args.paneId, args.requestedLines, args.session, args.signal); } catch { - return blind(null); // transient read failure + return blind(cancelled(args.signal)); // transient read failure } + if (cancelled(args.signal)) return blind(CANCELLED); const seen = splitLines(parseAnsi(probe.text)); if (!composerReady(seen)) { // `force` is the user's deliberate "type anyway", so it overrides the refusal — but this is the @@ -394,10 +421,11 @@ async function preflight(adapter: HarnessAdapter, args: GuardedReplyArgs): Promi // otherwise this ordering, which exists to stop keys reaching a dialog, would hand the dialog // the reply instead. Still fail-open on a throw: the submit key is guarded downstream. try { - const fresh = await fetchPane(args.paneId, args.requestedLines, args.session); + const fresh = await fetchPane(args.paneId, args.requestedLines, args.session, args.signal); + if (cancelled(args.signal)) return CANCELLED; if (composerReady(splitLines(parseAnsi(fresh.text)))) return null; } catch { - return null; + return cancelled(args.signal); } return { status: "blocked", @@ -416,10 +444,10 @@ async function oneShot(args: GuardedReplyArgs): Promise { // `adapterFor(agent)?.extractInputDraft`, so a pane with no adapter has no draft to sweep and the // composer's callback was already a no-op here. try { - const res = await sendReply(args.paneId, args.text, true, args.session); - return res.ok ? { status: "sent" } : { status: "error", error: res.error }; + const res = await sendReply(args.paneId, args.text, true, args.session, undefined, args.signal); + return cancelled(args.signal) ?? (res.ok ? { status: "sent" } : { status: "error", error: res.error }); } catch (e) { - return { status: "error", error: message(e) }; + return cancelled(args.signal) ?? { status: "error", error: message(e) }; } } @@ -430,7 +458,9 @@ async function oneShot(args: GuardedReplyArgs): Promise { */ async function submitOnly(args: GuardedReplyArgs): Promise { try { - const res = await sendReply(args.paneId, "", true, args.session); + const res = await sendReply(args.paneId, "", true, args.session, undefined, args.signal); + const aborted = cancelled(args.signal); + if (aborted) return aborted; if (res.ok) return { status: "sent" }; // The text is verifiably sitting in the input box and only the submit key failed — same shape as // the bridge's own partial-failure case. Tell the caller not to resend. @@ -440,7 +470,7 @@ async function submitOnly(args: GuardedReplyArgs): Promise { textDelivered: true, }; } catch (e) { - return { status: "error", error: message(e), textDelivered: true }; + return cancelled(args.signal) ?? { status: "error", error: message(e), textDelivered: true }; } } From 2ac3e33f0c413381a00da66b136c3b960e322aae Mon Sep 17 00:00:00 2001 From: en-ver Date: Sun, 16 Aug 2026 06:05:16 +0300 Subject: [PATCH 7/9] fix(voice): correct slow-mobile uploads and freshness --- .adr/0012-synchronous-one-shot-voice.md | 52 +++ .adr/README.md | 1 + ARCHITECTURE.md | 97 ++-- README.md | 53 ++- bridge/server.integration.test.ts | 275 +++++++++++- bridge/server.test.ts | 421 +++++++++++++++--- bridge/server.ts | 296 ++++++++++-- web/src/components/agent-chat.test.tsx | 89 +++- web/src/components/agent-chat.tsx | 75 ++-- web/src/components/agent-list.test.tsx | 35 +- web/src/components/agent-list.tsx | 14 +- web/src/components/app-header.test.tsx | 112 ++--- web/src/components/app-header.tsx | 56 +-- web/src/components/collie-home.test.tsx | 36 +- web/src/components/collie-home.tsx | 61 +-- web/src/components/composer.test.tsx | 12 +- web/src/components/composer.tsx | 21 +- web/src/components/connection-banner.test.tsx | 191 -------- web/src/components/connection-banner.tsx | 289 ------------ web/src/components/connection-info.test.tsx | 9 +- web/src/components/connection-info.tsx | 17 +- web/src/components/dog-gallop.tsx | 4 +- web/src/components/freshness-banner.test.tsx | 143 ++++++ web/src/components/freshness-banner.tsx | 209 +++++++++ .../components/prompt-select-block.test.tsx | 12 + web/src/components/status-badge.tsx | 6 +- .../components/update-available-banner.tsx | 2 +- web/src/components/update-banner.test.tsx | 5 +- .../components/update-check-control.test.tsx | 5 +- web/src/components/wizard-block.test.tsx | 7 + web/src/hooks/use-connection-lost.test.ts | 203 --------- web/src/hooks/use-connection-lost.ts | 90 ---- web/src/hooks/use-loading-stalled.ts | 4 +- web/src/hooks/use-online.ts | 19 - web/src/hooks/use-polling.test.ts | 5 +- web/src/hooks/use-voice-input.test.tsx | 154 ++++++- web/src/hooks/use-voice-input.ts | 41 +- web/src/lib/api.test.ts | 74 ++- web/src/lib/api.ts | 70 ++- web/src/lib/connection-health.test.ts | 138 ------ web/src/lib/connection-health.ts | 152 ------- web/src/lib/connection.test.ts | 27 -- web/src/lib/connection.ts | 31 -- web/src/lib/dialog-guard.test.ts | 32 ++ web/src/lib/dialog-guard.ts | 7 +- web/src/lib/loaders.test.ts | 350 +++++---------- web/src/lib/loaders.ts | 203 ++++----- web/src/lib/menu-action.test.ts | 8 +- web/src/lib/menu-action.ts | 2 + web/src/lib/multi-select-action.test.ts | 1 + web/src/lib/multi-select-action.ts | 2 + web/src/lib/preview-action.test.ts | 31 ++ web/src/lib/preview-action.ts | 64 +-- web/src/lib/prompt-action.ts | 2 + web/src/lib/voice-policy.test.ts | 38 ++ web/src/lib/voice-policy.ts | 33 ++ web/src/lib/wizard-action.ts | 2 + web/src/routes/detail.test.tsx | 54 ++- web/src/routes/detail.tsx | 14 +- web/src/routes/history.tsx | 6 +- web/src/routes/home.tsx | 11 +- web/src/routes/root.test.tsx | 41 +- web/src/routes/root.tsx | 72 ++- web/src/routes/settings.tsx | 7 +- web/src/routes/space.tsx | 11 +- web/src/test/setup.ts | 6 - 66 files changed, 2425 insertions(+), 2185 deletions(-) create mode 100644 .adr/0012-synchronous-one-shot-voice.md delete mode 100644 web/src/components/connection-banner.test.tsx delete mode 100644 web/src/components/connection-banner.tsx create mode 100644 web/src/components/freshness-banner.test.tsx create mode 100644 web/src/components/freshness-banner.tsx delete mode 100644 web/src/hooks/use-connection-lost.test.ts delete mode 100644 web/src/hooks/use-connection-lost.ts delete mode 100644 web/src/hooks/use-online.ts delete mode 100644 web/src/lib/connection-health.test.ts delete mode 100644 web/src/lib/connection-health.ts delete mode 100644 web/src/lib/connection.test.ts delete mode 100644 web/src/lib/connection.ts create mode 100644 web/src/lib/voice-policy.test.ts create mode 100644 web/src/lib/voice-policy.ts diff --git a/.adr/0012-synchronous-one-shot-voice.md b/.adr/0012-synchronous-one-shot-voice.md new file mode 100644 index 00000000..755a07af --- /dev/null +++ b/.adr/0012-synchronous-one-shot-voice.md @@ -0,0 +1,52 @@ +# 0012 — Voice remains a synchronous one-shot BFF + +Status: **Accepted** (2026-08-16) + +## Context + +Voice transcription needs a final text result before it can enter Collie's existing editable draft and +explicit Send path. The current requirement is a completed clip, not partial text, playback, or a +background task. A five-minute, 8 MiB clip still needs to tolerate bounded slow but continuously +progressing mobile upload, which makes a small fixed browser timeout dishonest. + +Several larger designs look attractive once a request takes longer than a normal mutation: + +- A **status side-channel** needs operation identity, lifecycle state, polling, retention, and a policy + for what a browser may infer after it loses the original response. +- An **async in-memory job** separates request receipt from result delivery but still loses work on a + bridge restart; adding status/recovery semantics turns it into a tracker rather than a simpler call. +- **Durable or resumable upload** requires audio storage, cleanup, ownership, replay and retry rules, + and a new privacy boundary for the most sensitive payload in this feature. +- A **realtime transport** adds connection lifecycle, framing, codec/backpressure and partial-result + contracts even though the product wants one completed file and final text. + +None solves a current user requirement, and all would expand the bridge's state, failure, and security +surface beyond the existing same-origin, write-gated BFF and one configured provider call. + +## Decision + +**Keep voice transcription synchronous and one-shot.** A pane-local browser operation records a +completed clip, makes one bounded request through the existing same-origin, write-gated bridge, and +receives final text for the ordinary editable draft. Do not add a status endpoint, async job, durable or +resumable upload, or realtime transport for this flow. + +## Consequences + +- There is no audio, operation, retry queue, or recovery record across an interrupted upload, page + cancellation, or bridge restart. The operator records again after failure; a successful transcript + retains only the existing editable browser draft semantics. +- The size-aware total deadline supports a bounded slow-but-progressing uplink. It does not promise + completion through a long interruption or below the accepted uplink floor. MediaRecorder receives a + codec-aware bitrate **hint**, not a guaranteed bitrate or a new quality/acceptance contract. +- Health domains stay split: root snapshot freshness, pane freshness, and fresh-only Herdr state are + independent loader facts; voice phases and the pane write lock are local operation state. A pending + voice request never becomes a global connection or reconnecting signal. +- Network topology and security posture do not change: no listener, front door, browser provider + credential, or realtime channel is added. The same-origin write gate, server-held provider + credential, and metadata-only privacy boundary remain in place. + +## Revisit + +Revisit only when product requirements actually need partial results, status after a lost response, +background completion, or recovery across restart/interruption. Any such requirement should choose its +persistence and security model explicitly rather than silently growing this one-shot path. diff --git a/.adr/README.md b/.adr/README.md index a3c5abb2..7d4aa81c 100644 --- a/.adr/README.md +++ b/.adr/README.md @@ -73,3 +73,4 @@ A superseded ADR is never deleted or edited into agreement with the present. Mar | [0009](./0009-a-generic-menu-is-driven-by-the-keys-it-names.md) | A generic menu is driven by the keys it names, never by digits | Accepted | | [0010](./0010-long-sends-are-verified-via-the-paste-placeholder.md) | Long sends are verified via the paste placeholder, not by chunking them | Accepted | | [0011](./0011-one-openai-compatible-transcription-endpoint.md) | Voice transcription uses one OpenAI-compatible endpoint | Accepted | +| [0012](./0012-synchronous-one-shot-voice.md) | Voice remains a synchronous one-shot BFF | Accepted | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c25a166e..73597828 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -112,8 +112,12 @@ Product details that shaped the loop: configured OpenAI-compatible endpoint for final text. All dedicated transcription settings blank leaves this flow off; any nonblank setting opts in and an omitted model uses Collie's `gpt-4o-transcribe` default. There is no Web Speech API, streaming, playback, codec conversion, - provider registry or fallback. The transcript enters the normal editable/persisted draft; only an - explicit existing Send reaches Herdr. + provider registry or fallback. A pane-local lifecycle reports requesting, recording, finalizing, + and coarse processing. During non-idle voice work, it locks only current-pane terminal/draft + mutations; structural tab/pane controls remain available, while a context-changing navigation + cancels the work or suppresses late results. It never becomes global connection or reconnecting + state. The transcript enters the normal editable/persisted draft; only an explicit existing Send + reaches Herdr. - **Quick replies are heuristics, not guarantees.** Different agents expect different input (a Y/n prompt vs a numbered menu vs an approval phrase), so there is always a **"send exactly what I type"** fallback. @@ -172,23 +176,39 @@ app. Closing this needs the server-side blocking-message capture described above work across turns you haven't scrolled to. Rationale and the measured numbers are commented at the top of `web/src/routes/history.tsx`. - **The browser polls too.** `useRevalidator` → `/api/snapshot` on an adaptive interval. There is no - WebSocket fan-out to the browser and no push of state; pulling is what makes the two recovery loops - below trivial. -- **Two independent recovery loops, designed in from the start** (not retrofitted): - - *bridge ↔ Herdr*: the snapshot poll doubles as resync — a failed tick marks the herd - disconnected (the UI's connection bar shows "Herdr offline") and keeps retrying; the - `events.subscribe` stream reconnects with backoff and re-subscribes, and since it only pokes the - poll, a dropped stream costs latency, never correctness. - - *browser ↔ bridge*: polling makes reconnect trivial — failed polls surface in the connection bar - / offline banner, and the next successful poll heals the UI. No socket lifecycle to manage. + WebSocket fan-out to the browser and no push of state. +- **Freshness is loader-owned, not a global connection inference.** `rootLoader` caches a successful + snapshot per session; `paneLoader` caches successful text per `(session, pane)`. Each cache is + updated only by its own successful response, including an intentionally empty response, and map + presence distinguishes a cold failure from a known-empty last-good result. Every loader run still + attempts its own endpoint: a root result never heals or poisons pane freshness, and vice versa. + A root failure is `snapshotStale`; a pane failure is `paneStale`. +- **Auth and Herdr state have narrower authority.** A 401/403 is classified independently for the + affected root or pane request and its access-refused presentation takes precedence over that + surface's stale notice. A `bridge`/Herdr state is trusted only from a fresh root snapshot: fresh + `disconnected` means Herdr is unavailable, while a cached `bridge` value is never used to make a + current dependency claim. There is no global connection clock, outage latch, probe, or + reconnecting inference. +- **Loading and voice remain local.** Generic navigation/poll loading drives generic progress + treatment, including the header animation; it neither diagnoses freshness nor changes cached data. + During non-idle voice work, the local lifecycle locks only current-pane terminal/draft mutations; + structural tab/pane controls remain available, and context-changing navigation cancels the work or + suppresses late results. Recording or completion cannot alter root/pane freshness. +- **Bridge ↔ Herdr resync remains separate.** The bridge snapshot poll keeps retrying and the + `events.subscribe` stream reconnects with backoff and re-subscribes; because events only poke the + poll, a dropped stream costs latency, not correctness. Browser revalidation likewise retries its + own reads without maintaining a client-side connection state. - **Polling moots per-client backpressure.** A push design would need `bufferedAmount` watching so a slow phone couldn't OOM the bridge. Each client instead fetches a bounded snapshot at its own pace, so there is nothing to buffer or coalesce. - **Render `pane.read` safely** (see §6): strip ANSI **server-side** to plain text and render it as React text nodes; never `innerHTML` raw terminal output. -- **PWA cache-busting.** Service workers serve stale clients after an update, so the build stamp - travels in every response (`X-Collie-Build` header + `/api/config`); on mismatch the footer offers - "new build — tap to update." +- **PWA cache-busting and voice skew.** Service workers serve stale clients after an update, so the + build stamp travels in every response (`X-Collie-Build` header + `/api/config`); on mismatch the + footer offers "new build — tap to update." The completed-file voice multipart format remains stable + across old-web/new-bridge and new-web/old-bridge pairs, but the complete 8 MiB / 256 kb/s behaviour + requires a matched current PWA bundle/service worker and bridge. API traffic remains network-only; + `/api/` receives no service-worker cache or route change. ## 6. Security model @@ -239,10 +259,18 @@ default). These four are genuine RCE vectors and are **load-bearing — do not r Also shipped, as defence in depth: - **Audit log** — every write-level action appends a JSONL line (timestamp, method, truncated params) - to `/audit.log`, mode 0600 since it may echo reply text. Voice transcription records only - MIME, bytes, browser-reported lifecycle duration (`reportedDurationMs`) and outcome (`ok`, `invalid`, - `timeout`, `client-aborted`, or `unavailable`) — never audio, filename, transcript or provider body. - An audit failure never fails the user's action (`bridge/audit.ts`). + to `/audit.log`, mode 0600 since it may echo reply text. Voice has a narrower terminal + boundary: after the write gate, session lookup, and known-pane check, every request that reaches the + transcription handler — including a capacity refusal — records exactly one metadata-only + `transcribe` entry. Its random request id, constructed HTTP status, outcome (`ok`, `busy`, + `invalid`, `timeout`, `client-aborted`, or `unavailable`), optional failed phase, and timing fields + never include audio, filename, transcript, headers, form fields, provider bodies, or provider errors. + Validated MIME, bytes, and browser-reported lifecycle duration (`reportedDurationMs`) appear only + after validation. `bodyFormDataMs` is handler-entry through `formData()` settlement (receive plus + parse, not browser upload time); `providerMs` exists only after a provider invocation; and + `serverTotalMs` ends when the response is constructed, not when the browser receives it. A runtime + body-cap rejection or connection termination before the handler can therefore have no terminal + transcription audit. An audit failure never fails the user's action (`bridge/audit.ts`). - **Destructive-action confirm** — a browser-side prompt when input pattern-matches `rm`, `sudo`, `git push --force`, `dd`, etc. (`web/src/lib/destructive.ts`). Prevents catastrophic mistaps. @@ -254,22 +282,33 @@ Considered, not built: where that friction would have to live: the lock is a pause on an unattended screen and deliberately gates nothing ([ADR 0007](./.adr/0007-the-idle-lock-is-a-pause-not-a-gate.md)). +### Voice transcription boundary + Collie does not intentionally persist or log voice audio or provider bodies: no `Bun.write`, uploads folder, reuse, backup, playback, or request/audit body. The bridge also does not persist or log transcripts; a successful transcript enters the browser's ordinary editable `localStorage` draft, removed on Send or pruned lazily after 48 hours. These are Collie-owned guarantees: browser, Bun, OS, and proxy buffering remain outside them. The configured provider receives the audio, and its retention -or logging is controlled by that provider's policy, not Collie. The client stops at five minutes and -bounds the complete browser-to-bridge request, including its body, to 90 seconds; browser Fetch -refuses a front-door redirect before it can replay the multipart recording. The bridge enforces an 8 -MiB file cap plus the 12 MiB global body cap, validates MIME and browser-reported lifecycle duration -metadata (not parsed media duration) before the outbound call, and applies a 60-second bridge-to-provider -deadline through response-body consumption with zero retries. Its SDK fetch boundary independently -refuses bridge-to-provider redirects and caps decoded **provider** success and error response bodies at -256 KiB; returned text is bounded to 8192 characters. Bun labels `.webm`/`.mp4` multipart parts as -`video/*` even when MediaRecorder supplied `audio/*`, so the bridge accepts those container aliases and -canonicalises them to `audio/webm`/`audio/mp4` for the upstream; no codec inspection or conversion is -added. +or logging is controlled by that provider's policy, not Collie. + +The completed Blob has a known size, so the browser owns one total wall-clock budget: +`ceil((B + 65,536) × 8 × 1000 / 256,000) + 60,000 + 20,000` ms for a valid `B` up to 8 MiB. It starts +before multipart construction and includes upload, bridge work, provider work, and response-body +consumption. The 8 MiB maximum gives 264,192 ms for upload and 344,192 ms total; it supports a +sustained, progressing 256 kb/s effective uplink, not a slower path or a long interruption. This total +browser deadline is distinct from Bun's configured 90-second nominal per-request **idle** allowance, +set before `req.formData()`. Bun's runtime granularity is coarse, so that setting is neither an exact +90-second cutoff nor a whole-request maximum. The provider has its own independent 60-second deadline +through response-body consumption. + +The bridge enforces an 8 MiB file cap plus a 12 MiB global runtime body cap, validates MIME and +browser-reported lifecycle duration metadata (not parsed media duration) before the outbound call, and +admits at most two known-pane voice attempts that pass the write gate per bridge process. A third +receives a sanitized 429 with no browser retry. Browser and provider Fetch both refuse redirects; the +provider SDK has zero retries, caps decoded **provider** success and error response bodies at 256 KiB, and +bounds returned text to 8192 characters. Bun labels `.webm`/`.mp4` multipart parts as `video/*` even +when MediaRecorder supplied `audio/*`, so the bridge accepts those container aliases and canonicalises +them to `audio/webm`/`audio/mp4` for the upstream; no codec inspection or conversion is added. Full passthrough (no command allow-list) is acceptable for a personal tool — an allow-list would defeat the purpose. **Never use `tailscale funnel`** (public exposure). diff --git a/README.md b/README.md index 741007e4..41c30dbb 100644 --- a/README.md +++ b/README.md @@ -356,14 +356,25 @@ For a Herdr-installed candidate, configure transcription in the protected plugin only when the composer is empty. The root [`.env.example`](./.env.example) is a hidden dotfile template: all transcription assignments are intentionally commented, so copying it keeps voice disabled until you uncomment and fill a setting. Any nonblank dedicated setting opts in, and an omitted model resolves to -Collie's default, `gpt-4o-transcribe`. The browser posts a completed WebM/MP4 recording to the -same-origin bridge; after validation, the bridge calls **one** OpenAI-compatible transcription endpoint. -Its narrow provider contract is `POST /audio/transcriptions` with a model/file and `response_format=json`, -returning `{ "text": "…" }`. Collie puts that text into the ordinary editable draft and waits for you to -press the existing **Send** button. It never auto-submits text to Herdr. - -Compatible browsers make a best-effort attempt to keep the screen awake only during active foreground recording; -browser, OS, or manual-lock policy may override it, and hiding or closing still cancels recording with no background +Collie's default, `gpt-4o-transcribe`. + +Voice is a completed-file, synchronous one-shot: the browser keeps a WebM/MP4 recording in memory, +makes one multipart upload to the same-origin bridge, and, after validation, the bridge makes **one** call +to the configured OpenAI-compatible transcription endpoint. Its narrow provider contract is +`POST /audio/transcriptions` with a model/file and `response_format=json`, returning `{ "text": "…" }`. +Neither stage automatically retries; after a failure, make a fresh recording rather than replaying audio. +Collie puts returned text into the ordinary editable draft and waits for the existing **Send** button. It +never auto-submits text to Herdr. + +The composer reports **Requesting microphone…**, **Recording**, **Finishing recording…**, then +**Processing voice…**. Processing deliberately covers multipart creation and upload, bridge parsing, +provider work, and response consumption: Fetch cannot truthfully split those stages further. Compatible +browsers request 24 kb/s for WebM/Opus (and the WebM fallback) and 64 kb/s for MP4/AAC. Those are +best-effort encoder hints, not acceptance or telemetry guarantees, and a browser may ignore them. + +Compatible browsers make a best-effort attempt to keep the screen awake only during active foreground +recording; browser, OS, or manual-lock policy may override it. Hiding the page, page close, a pane/session +change, or **Cancel** cancels the active voice operation, so there is no background recording or completion guarantee. ```dotenv @@ -392,11 +403,27 @@ outbound only, never another Collie listener or front door. Recording stops at about 5 minutes and is rejected above 8 MiB; only WebM/MP4 containers are accepted. `duration_ms` is browser-reported recording lifecycle metadata, not media duration parsed by -the bridge. The browser aborts the complete browser-to-bridge request after 90 seconds and Fetch -refuses a front-door redirect before it can replay the multipart recording. The bridge bounds the -complete bridge-to-provider call, including response-body consumption, to 60 seconds with no retry; its -SDK fetch adapter independently refuses provider redirects and caps **provider** success and error -response bodies at 256 KiB decoded. +the bridge. For a completed Blob of `B` bytes, the browser's total wall-clock deadline is +`ceil((B + 65,536) × 8 × 1000 / 256,000) + 60,000 + 20,000` ms. It assumes a sustained, progressing +effective uplink of at least 256 kb/s; a slower path or a long interruption can still fail. At the 8 MiB +maximum (8,388,608 bytes), the upload allowance is 264,192 ms and the total browser maximum is +**344,192 ms** (5m44.192s). The timer starts before multipart construction and lasts through the final +response body; Fetch also refuses a front-door redirect before it can replay the recording. + +That browser total is separate from Bun's configured 90-second nominal **inactivity** allowance, set +before multipart parsing. Bun applies that setting with coarse runtime granularity, so it is not an exact +90-second maximum or a whole-request deadline. The bridge's provider call has its own independent +60-second deadline, including response-body consumption. At most two known-pane voice attempts that +pass the existing write gate run in one bridge process; a third receives the sanitized +`429 transcription busy` response, and the browser does not retry. The SDK fetch adapter independently +refuses provider redirects and caps +**provider** success and error response bodies at 256 KiB decoded. + +The completed-file wire format (`file` plus browser-reported `duration_ms`) is compatible for an +old-web/new-bridge or new-web/old-bridge pair, so update skew is safe rather than a new protocol. +Only a matched current PWA bundle/service worker and bridge provide the complete 8 MiB / 256 kb/s +behaviour. API traffic remains network-only: the service worker does not cache or route `/api/`, and +this refactor changes no service-worker route. Collie does not intentionally persist or log audio or provider bodies: no audio file is written to `stateDir`, backups, or audit/log bodies. The bridge also does not persist or log transcripts. A diff --git a/bridge/server.integration.test.ts b/bridge/server.integration.test.ts index 0d28776a..141b6066 100644 --- a/bridge/server.integration.test.ts +++ b/bridge/server.integration.test.ts @@ -8,7 +8,12 @@ import type { HerdrClient } from "./herdr-client.ts"; import type { NotificationCoordinator } from "./notifications.ts"; import type { NotifyPrefsStore } from "./notify-prefs.ts"; import type { Push } from "./push.ts"; -import { startServer } from "./server.ts"; +import { + MAX_REQUEST_BODY_BYTES, + MAX_TRANSCRIPTION_BYTES, + VOICE_REQUEST_IDLE_TIMEOUT_SECONDS, + startServer, +} from "./server.ts"; import { SessionRegistry } from "./sessions.ts"; import type { Snooze } from "./snooze.ts"; import type { StateEngine } from "./state-engine.ts"; @@ -68,14 +73,22 @@ function registryWithPanes(paneIds: string[]): SessionRegistry { }); } -function audioForm(): FormData { +function audioForm(bytes = 5): FormData { const form = new FormData(); - form.append("file", new File(["audio"], "recording.webm", { type: "audio/webm" })); + form.append( + "file", + new File([new Uint8Array(bytes)], "recording.webm", { type: "audio/webm" }), + ); form.append("duration_ms", "1000"); return form; } -function startVoiceServer(paneIds: string[], transcriber: Transcriber, seen: string[]) { +function startVoiceServer( + paneIds: string[], + transcriber: Transcriber | null, + seen: string[], + audit: AuditLog = new AuditLog(() => {}), +) { return startServer({ cfg: testConfig(), registry: registryWithPanes(paneIds), @@ -83,15 +96,16 @@ function startVoiceServer(paneIds: string[], transcriber: Transcriber, seen: str snooze: {} as Snooze, notifyPrefs: {} as NotifyPrefsStore, updateMonitor: {} as UpdateMonitor, - audit: new AuditLog(() => {}), + audit, activity: { noteSeen: (_session: string, paneId: string) => seen.push(paneId) } as unknown as ActivityLedger, transcriber, }); } -test("rejects an unknown transcription pane before parsing audio, invoking the provider, or marking activity", async () => { +test("rejects an unknown transcription pane before parsing audio, invoking the provider, marking activity, or auditing", async () => { let transcriberCalls = 0; const seen: string[] = []; + const auditLines: string[] = []; const server = startVoiceServer( ["w1:known"], { @@ -101,6 +115,7 @@ test("rejects an unknown transcription pane before parsing audio, invoking the p }, }, seen, + new AuditLog((line) => void auditLines.push(line)), ); try { @@ -112,12 +127,192 @@ test("rejects an unknown transcription pane before parsing audio, invoking the p await expect(response.json()).resolves.toEqual({ ok: false, error: "pane not found" }); expect(transcriberCalls).toBe(0); expect(seen).toEqual([]); + expect(auditLines).toEqual([]); + } finally { + await server.stop(true); + } +}); + +test("does not audit rejected access or an unknown session", async () => { + let transcriberCalls = 0; + const seen: string[] = []; + const auditLines: string[] = []; + const server = startVoiceServer( + ["w1:known"], + { + transcribe: () => { + transcriberCalls += 1; + return Promise.resolve("must not run"); + }, + }, + seen, + new AuditLog((line) => void auditLines.push(line)), + ); + + try { + const denied = await fetch(`http://127.0.0.1:${server.port}/api/pane/w1%3Aknown/transcribe`, { + method: "POST", + headers: { origin: "https://evil.example" }, + body: audioForm(), + }); + expect(denied.status).toBe(403); + + const unknownSession = await fetch( + `http://127.0.0.1:${server.port}/api/pane/w1%3Aknown/transcribe?session=missing`, + { method: "POST", body: audioForm() }, + ); + expect(unknownSession.status).toBe(404); + expect(transcriberCalls).toBe(0); + expect(seen).toEqual([]); + expect(auditLines).toEqual([]); } finally { await server.stop(true); } }); -test("extends only validated transcription provider work past Bun's default route timeout", async () => { +test("accepts an exact 8 MiB multipart recording within the global body cap", async () => { + let transcriberCalls = 0; + const seen: string[] = []; + const server = startVoiceServer( + ["w1:known"], + { + transcribe: () => { + transcriberCalls += 1; + return Promise.resolve("editable transcript"); + }, + }, + seen, + ); + + try { + const response = await fetch(`http://127.0.0.1:${server.port}/api/pane/w1%3Aknown/transcribe`, { + method: "POST", + body: audioForm(MAX_TRANSCRIPTION_BYTES), + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true, text: "editable transcript" }); + expect(transcriberCalls).toBe(1); + expect(seen).toEqual(["w1:known"]); + } finally { + await server.stop(true); + } +}, 20_000); + +test("Bun runtime body rejection can occur below the handler and therefore has no terminal audit", async () => { + let transcriberCalls = 0; + const seen: string[] = []; + const auditLines: string[] = []; + const server = startVoiceServer( + ["w1:known"], + { + transcribe: () => { + transcriberCalls += 1; + return Promise.resolve("must not run"); + }, + }, + seen, + new AuditLog((line) => void auditLines.push(line)), + ); + + try { + // The multipart framing takes this over Bun's fixed 12 MiB maxRequestBodySize before the route + // can admit or audit it. This intentionally differs from a handler-level declared-size 413. + const response = await fetch(`http://127.0.0.1:${server.port}/api/pane/w1%3Aknown/transcribe`, { + method: "POST", + body: audioForm(MAX_REQUEST_BODY_BYTES), + }); + expect(response.status).toBe(413); + expect(transcriberCalls).toBe(0); + expect(seen).toEqual([]); + expect(auditLines).toEqual([]); + } finally { + await server.stop(true); + } +}, 20_000); + +test("holds two admitted provider calls, returns one busy response, and releases capacity after completion", async () => { + const seen: string[] = []; + const auditLines: string[] = []; + let calls = 0; + let firstReady!: () => void; + const firstStarted = new Promise((resolve) => { + firstReady = () => resolve(); + }); + let secondReady!: () => void; + const secondStarted = new Promise((resolve) => { + secondReady = () => resolve(); + }); + let releaseFirst!: (text: string) => void; + let releaseSecond!: (text: string) => void; + const server = startVoiceServer( + ["w1:known"], + { + transcribe: () => { + calls += 1; + if (calls === 1) { + firstReady(); + return new Promise((resolve) => { + releaseFirst = resolve; + }); + } + if (calls === 2) { + secondReady(); + return new Promise((resolve) => { + releaseSecond = resolve; + }); + } + return Promise.resolve("after release"); + }, + }, + seen, + new AuditLog((line) => void auditLines.push(line)), + ); + + try { + const first = fetch(`http://127.0.0.1:${server.port}/api/pane/w1%3Aknown/transcribe`, { + method: "POST", + body: audioForm(), + }); + await firstStarted; + const second = fetch(`http://127.0.0.1:${server.port}/api/pane/w1%3Aknown/transcribe`, { + method: "POST", + body: audioForm(), + }); + await secondStarted; + + const busy = await fetch(`http://127.0.0.1:${server.port}/api/pane/w1%3Aknown/transcribe`, { + method: "POST", + body: audioForm(), + }); + expect(busy.status).toBe(429); + await expect(busy.json()).resolves.toEqual({ ok: false, error: "transcription busy" }); + expect(calls).toBe(2); + + releaseFirst("first complete"); + const firstResponse = await first; + expect(firstResponse.status).toBe(200); + const afterRelease = await fetch( + `http://127.0.0.1:${server.port}/api/pane/w1%3Aknown/transcribe`, + { method: "POST", body: audioForm() }, + ); + expect(afterRelease.status).toBe(200); + expect(calls).toBe(3); + releaseSecond("second complete"); + expect((await second).status).toBe(200); + + const details = auditLines.map((line) => + (JSON.parse(line) as { action: string; detail: Record }).detail, + ); + expect(details).toHaveLength(4); + expect(details.filter((detail) => detail.outcome === "busy")).toHaveLength(1); + expect(details.filter((detail) => detail.outcome === "ok")).toHaveLength(3); + expect(seen).toEqual(["w1:known", "w1:known", "w1:known"]); + } finally { + await server.stop(true); + } +}); + +test("sets the 90-second idle allowance before body parsing while provider work remains independently bounded", async () => { const delayMs = 15_000; const seen: string[] = []; let transcriberCalls = 0; @@ -148,3 +343,69 @@ test("extends only validated transcription provider work past Bun's default rout await server.stop(true); } }, 25_000); + +// This is deliberately opt-in: Bun 1.3.14's idle timer is coarse rather than an exact wall-clock +// boundary. A 91.007s gap reached the provider and a scaled probe closed about two seconds late, so +// this fixed three-second tolerance conservatively tests a gap beyond the nominal 90-second setting +// without claiming exact enforcement. Run `COLLIE_TEST_BUN_IDLE_BOUNDARY=1 bun test bridge/server.integration.test.ts`. +const BUN_IDLE_SCHEDULING_TOLERANCE_SECONDS = 3; +const idleBoundaryTest = process.env.COLLIE_TEST_BUN_IDLE_BOUNDARY === "1" ? test : test.skip; +idleBoundaryTest("cuts off a multipart body beyond Bun's nominal idle allowance and scheduling tolerance", async () => { + let transcriberCalls = 0; + const seen: string[] = []; + const server = startVoiceServer( + ["w1:known"], + { + transcribe: () => { + transcriberCalls += 1; + return Promise.resolve("must not run"); + }, + }, + seen, + ); + const boundary = "----collie-idle-boundary"; + const encoder = new TextEncoder(); + let sentOpening = false; + let cancelled = false; + const idleMs = (VOICE_REQUEST_IDLE_TIMEOUT_SECONDS + BUN_IDLE_SCHEDULING_TOLERANCE_SECONDS + 1) * 1000; + const body = new ReadableStream({ + pull(controller) { + if (sentOpening) return; + sentOpening = true; + controller.enqueue( + encoder.encode( + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="recording.webm"\r\n` + + "Content-Type: audio/webm\r\n\r\naudio", + ), + ); + void Bun.sleep(idleMs).then(() => { + if (cancelled) return; + try { + controller.enqueue( + encoder.encode( + `\r\n--${boundary}\r\nContent-Disposition: form-data; name="duration_ms"\r\n\r\n1000\r\n--${boundary}--\r\n`, + ), + ); + controller.close(); + } catch { + // The idle timeout can close the client stream before this delayed tail is due. + } + }); + }, + cancel() { + cancelled = true; + }, + }); + + try { + const response = await fetch(`http://127.0.0.1:${server.port}/api/pane/w1%3Aknown/transcribe`, { + method: "POST", + headers: { "content-type": `multipart/form-data; boundary=${boundary}` }, + body, + }).catch(() => null); + expect(response?.status).not.toBe(200); + expect(transcriberCalls).toBe(0); + } finally { + await server.stop(true); + } +}, 110_000); diff --git a/bridge/server.test.ts b/bridge/server.test.ts index 764bd38e..d3d003e8 100644 --- a/bridge/server.test.ts +++ b/bridge/server.test.ts @@ -4,13 +4,17 @@ import { BUILD_HEADER, cacheControlFor, checkAccess, + createTranscriptionAdmission, marksPaneSeen, + MAX_CONCURRENT_TRANSCRIPTION_ATTEMPTS, + MAX_MULTIPART_OVERHEAD, MAX_TRANSCRIPT_CHARS, MAX_TRANSCRIPTION_BYTES, MAX_TRANSCRIPTION_DURATION_MS, SEEN_HEADER, deviceAuth, guard, + handleTranscriptionAttempt, hasKnownPane, historyParams, isHostAllowed, @@ -22,14 +26,18 @@ import { resolveStaticPath, sendReplySteps, startupWarnings, - transcribePane, + VOICE_REQUEST_IDLE_TIMEOUT_SECONDS, withBuildHeader, type ReplySender, } from "./server.ts"; import { AuditLog } from "./audit.ts"; import type { Config } from "./config.ts"; import type { HerdrClient, PaneRead } from "./herdr-client.ts"; -import { TranscriptionProviderError, type Transcriber } from "./transcription.ts"; +import { + TRANSCRIPTION_TIMEOUT_MS, + TranscriptionProviderError, + type Transcriber, +} from "./transcription.ts"; import type { StateEngine } from "./state-engine.ts"; // checkAccess is the API security gate (same-origin/CSRF + optional Tailscale identity). A @@ -108,6 +116,34 @@ describe("voice transcription — guarded, bounded, and body-free", () => { const lines: string[] = []; return { audit: new AuditLog((line) => void lines.push(line)), lines }; }; + const attempt = ( + request: Request, + transcriber: Transcriber | null, + audit: AuditLog, + admission = createTranscriptionAdmission(), + beforeBody: () => void = () => {}, + ) => + handleTranscriptionAttempt( + "w1:p1", + request, + audit, + null, + "default", + transcriber, + admission, + beforeBody, + ); + const detailOf = (line: string): Record => + (JSON.parse(line) as { detail: Record }).detail; + const expectAllSlotsFree = (admission: ReturnType) => { + const first = admission.acquire(); + const second = admission.acquire(); + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + expect(admission.acquire()).toBeNull(); + first!(); + second!(); + }; test("the central write guard rejects before the transcription handler can parse multipart", () => { let parsed = false; @@ -125,10 +161,33 @@ describe("voice transcription — guarded, bounded, and body-free", () => { expect(parsed).toBe(false); }); - test("accepts the exact multipart declaration threshold and rejects one byte over before parsing", async () => { + test("audits an unavailable configured transcriber without parsing the body", async () => { + const { audit, lines } = auditEntries(); + let parsed = false; + const request = { + headers: new Headers({ "content-type": "multipart/form-data" }), + signal: new AbortController().signal, + formData: () => { + parsed = true; + return Promise.resolve(audioForm()); + }, + } as unknown as Request; + + const response = await attempt(request, null, audit); + expect(response.status).toBe(503); + expect(parsed).toBe(false); + expect(lines).toHaveLength(1); + const detail = detailOf(lines[0]!); + expect(detail).toMatchObject({ outcome: "unavailable", status: 503, failedPhase: "request" }); + expect(detail).not.toHaveProperty("bodyFormDataMs"); + expect(detail).not.toHaveProperty("providerMs"); + expect(detail).not.toHaveProperty("mime"); + }); + + test("accepts the exact 8 MiB file plus declaration allowance and rejects one byte over before parsing", async () => { const { transcriber, files } = fakeTranscriber(); - const { audit } = auditEntries(); - const declaration = MAX_TRANSCRIPTION_BYTES + 64 * 1024; + const { audit, lines } = auditEntries(); + const declaration = MAX_TRANSCRIPTION_BYTES + MAX_MULTIPART_OVERHEAD; let parsed = false; const request = (contentLength: number) => ({ @@ -139,35 +198,32 @@ describe("voice transcription — guarded, bounded, and body-free", () => { signal: new AbortController().signal, formData: () => { parsed = true; - return Promise.resolve(audioForm()); + return Promise.resolve(audioForm({ bytes: MAX_TRANSCRIPTION_BYTES })); }, }) as unknown as Request; - await expect( - transcribePane("w1:p1", request(declaration), audit, null, "default", transcriber), - ).resolves.toHaveProperty("status", 200); + const accepted = await attempt(request(declaration), transcriber, audit); + expect(accepted.status).toBe(200); expect(parsed).toBe(true); expect(files).toHaveLength(1); + expect(files[0]?.size).toBe(MAX_TRANSCRIPTION_BYTES); parsed = false; - const rejected = await transcribePane( - "w1:p1", - request(declaration + 1), - audit, - null, - "default", - transcriber, - ); + const rejected = await attempt(request(declaration + 1), transcriber, audit); expect(rejected.status).toBe(413); expect(await rejected.json()).toEqual({ ok: false, error: "audio too large (max 8 MiB)" }); expect(parsed).toBe(false); expect(files).toHaveLength(1); + expect(lines).toHaveLength(2); + const rejectedDetail = detailOf(lines[1]!); + expect(rejectedDetail).toMatchObject({ outcome: "invalid", status: 413, failedPhase: "request" }); + expect(rejectedDetail).not.toHaveProperty("bodyFormDataMs"); + expect(rejectedDetail).not.toHaveProperty("providerMs"); + expect(rejectedDetail).not.toHaveProperty("mime"); }); test("rejects invalid MIME, size, and reported duration before provider invocation", async () => { const { transcriber, files } = fakeTranscriber(); - const { audit } = auditEntries(); - let beforeProviderCalls = 0; for (const form of [ // Bun derives multipart MIME from a filename, so use an Ogg extension for this negative case. audioForm({ type: "audio/ogg", name: "recording.ogg" }), @@ -175,53 +231,58 @@ describe("voice transcription — guarded, bounded, and body-free", () => { audioForm({ duration: String(MAX_TRANSCRIPTION_DURATION_MS + 1) }), audioForm({ duration: "not-a-duration" }), ]) { - const response = await transcribePane( - "w1:p1", - acceptedRequest(form), - audit, - null, - "default", - transcriber, - () => { beforeProviderCalls += 1; }, - ); + const { audit, lines } = auditEntries(); + const response = await attempt(acceptedRequest(form), transcriber, audit); expect(response.status).toBeGreaterThanOrEqual(400); + expect(lines).toHaveLength(1); + const detail = detailOf(lines[0]!); + expect(detail).toMatchObject({ outcome: "invalid", failedPhase: "validation" }); + expect(detail).toHaveProperty("bodyFormDataMs"); + expect(detail).not.toHaveProperty("providerMs"); + expect(detail).not.toHaveProperty("mime"); } expect(files).toEqual([]); - expect(beforeProviderCalls).toBe(0); }); - test("waits for valid multipart parsing before extending the provider wait", async () => { + test("sets the 90-second idle allowance before formData and leaves the provider deadline independent", async () => { const { audit } = auditEntries(); let releaseForm!: (form: FormData) => void; - const pendingForm = new Promise((resolve) => { releaseForm = resolve; }); + const pendingForm = new Promise((resolve) => { + releaseForm = resolve; + }); + const order: string[] = []; const parsingRequest = { headers: new Headers({ "content-type": "multipart/form-data" }), signal: new AbortController().signal, - formData: () => pendingForm, + formData: () => { + order.push("formData"); + return pendingForm; + }, } as unknown as Request; - const order: string[] = []; const orderedTranscriber: Transcriber = { transcribe: () => { order.push("transcriber"); return Promise.resolve("review this transcript"); }, }; - const pending = transcribePane( - "w1:p1", + const pending = attempt( parsingRequest, - audit, - null, - "default", orderedTranscriber, - () => { order.push("before-provider"); }, + audit, + createTranscriptionAdmission(), + () => { + order.push("idle-timeout"); + }, ); await Promise.resolve(); - expect(order).toEqual([]); + expect(order).toEqual(["idle-timeout", "formData"]); releaseForm(audioForm()); await expect(pending).resolves.toHaveProperty("status", 200); - expect(order).toEqual(["before-provider", "transcriber"]); + expect(order).toEqual(["idle-timeout", "formData", "transcriber"]); + expect(VOICE_REQUEST_IDLE_TIMEOUT_SECONDS).toBe(90); + expect(TRANSCRIPTION_TIMEOUT_MS).toBe(60_000); }); test("recognises both agent and shell panes in the last-known state", () => { @@ -239,36 +300,58 @@ describe("voice transcription — guarded, bounded, and body-free", () => { expect(hasKnownPane(engine, "w1:missing")).toBe(false); }); - test("forwards only a generic provider filename and audits no audio or transcript body", async () => { + test("forwards only a generic provider filename and writes one allowlisted metadata-only success audit", async () => { const { transcriber, files } = fakeTranscriber("private transcript text"); const { audit, lines } = auditEntries(); - const response = await transcribePane( + const form = new FormData(); + form.append("file", new File(["private audio bytes"], "personal-note.webm", { type: "audio/webm" })); + form.append("duration_ms", "1000"); + const response = await handleTranscriptionAttempt( "w1:p1", - acceptedRequest(audioForm({ name: "personal-note.webm" })), + acceptedRequest(form), audit, "phone", "default", transcriber, + createTranscriptionAdmission(), + () => {}, ); expect(await response.json()).toEqual({ ok: true, text: "private transcript text" }); expect(files).toHaveLength(1); expect(files[0]?.name).toBe("recording.webm"); - await Promise.resolve(); expect(lines).toHaveLength(1); - expect(lines[0]).toContain('"outcome":"ok"'); expect(lines[0]).not.toContain("personal-note"); + expect(lines[0]).not.toContain("private audio bytes"); expect(lines[0]).not.toContain("private transcript text"); - expect(JSON.parse(lines[0]!).detail).toEqual({ + const detail = detailOf(lines[0]!); + expect(Object.keys(detail).sort()).toEqual( + [ + "requestId", + "outcome", + "status", + "bodyFormDataMs", + "providerMs", + "serverTotalMs", + "mime", + "bytes", + "reportedDurationMs", + ].sort(), + ); + expect(detail).toMatchObject({ + outcome: "ok", + status: 200, mime: "audio/webm", - bytes: 4, + bytes: 19, reportedDurationMs: 1000, - outcome: "ok", }); - expect(lines[0]).not.toContain("durationMs"); + expect(detail.requestId).toMatch(/^[0-9a-f]{8}-[0-9a-f-]{27}$/i); + for (const field of ["bodyFormDataMs", "providerMs", "serverTotalMs"] as const) { + expect(detail[field]).toEqual(expect.any(Number)); + } }); - test("maps deadline, caller-aborted, and unavailable provider failures to distinct outcomes", async () => { + test("maps deadline, caller-aborted, and unavailable provider failures to distinct audited outcomes", async () => { const cases: Array<["timeout" | "client-aborted" | "unavailable", number, string]> = [ ["timeout", 504, "transcription timed out"], ["client-aborted", 499, "transcription cancelled"], @@ -279,29 +362,251 @@ describe("voice transcription — guarded, bounded, and body-free", () => { const failing: Transcriber = { transcribe: () => Promise.reject(new TranscriptionProviderError(kind)), }; - const response = await transcribePane("w1:p1", acceptedRequest(audioForm()), audit, null, "default", failing); + const response = await attempt(acceptedRequest(audioForm()), failing, audit); expect(response.status).toBe(status); expect(await response.json()).toEqual({ ok: false, error: message }); - await Promise.resolve(); - expect(JSON.parse(lines[0]!).detail.outcome).toBe(kind); + expect(lines).toHaveLength(1); + const detail = detailOf(lines[0]!); + expect(detail).toMatchObject({ + outcome: kind, + status, + failedPhase: "provider", + mime: "audio/webm", + bytes: 4, + reportedDurationMs: 1000, + }); + expect(detail).toHaveProperty("bodyFormDataMs"); + expect(detail).toHaveProperty("providerMs"); } }); - test("sanitizes provider failures and rejects an overlong provider result", async () => { - const { audit } = auditEntries(); + test("sanitizes provider failures and audits invalid provider results as their own terminal phase", async () => { + const failedAudit = auditEntries(); const failing: Transcriber = { transcribe: () => Promise.reject(new Error("private provider response body")), }; - const failed = await transcribePane("w1:p1", acceptedRequest(audioForm()), audit, null, "default", failing); + const failed = await attempt(acceptedRequest(audioForm()), failing, failedAudit.audit); expect(failed.status).toBe(502); const failedBody = await failed.text(); expect(failedBody).toContain("transcription unavailable"); expect(failedBody).not.toContain("private provider response body"); + expect(failedAudit.lines).toHaveLength(1); + expect(failedAudit.lines[0]).not.toContain("private provider response body"); + expect(detailOf(failedAudit.lines[0]!)).toMatchObject({ + outcome: "unavailable", + status: 502, + failedPhase: "provider", + }); + const resultAudit = auditEntries(); const { transcriber } = fakeTranscriber("x".repeat(MAX_TRANSCRIPT_CHARS + 1)); - const tooLong = await transcribePane("w1:p1", acceptedRequest(audioForm()), audit, null, "default", transcriber); + const tooLong = await attempt(acceptedRequest(audioForm()), transcriber, resultAudit.audit); expect(tooLong.status).toBe(502); expect(await tooLong.text()).toContain("invalid transcription result"); + expect(resultAudit.lines).toHaveLength(1); + const resultDetail = detailOf(resultAudit.lines[0]!); + expect(resultDetail).toMatchObject({ + outcome: "invalid", + status: 502, + failedPhase: "result", + mime: "audio/webm", + bytes: 4, + reportedDurationMs: 1000, + }); + expect(resultDetail).toHaveProperty("providerMs"); + }); + + test("audits malformed form data once with allowlisted body metadata and releases admission", async () => { + const { audit, lines } = auditEntries(); + const admission = createTranscriptionAdmission(); + let transcriberCalls = 0; + const malformed = { + headers: new Headers({ "content-type": "multipart/form-data" }), + signal: new AbortController().signal, + formData: () => Promise.reject(new Error("private malformed body")), + } as unknown as Request; + + const response = await attempt( + malformed, + { + transcribe: () => { + transcriberCalls += 1; + return Promise.resolve("must not run"); + }, + }, + audit, + admission, + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ ok: false, error: "expected multipart audio" }); + expect(transcriberCalls).toBe(0); + expect(lines).toHaveLength(1); + const detail = detailOf(lines[0]!); + expect(detail).toMatchObject({ outcome: "invalid", status: 400, failedPhase: "body" }); + expect(Object.keys(detail).sort()).toEqual( + ["requestId", "outcome", "status", "failedPhase", "bodyFormDataMs", "serverTotalMs"].sort(), + ); + expect(lines[0]).not.toContain("private malformed body"); + expectAllSlotsFree(admission); + }); + + test("audits cancelled form data as client-aborted and releases admission", async () => { + const aborted = new AbortController(); + aborted.abort(); + const cases = [ + // A disconnected caller can surface through the request signal before formData's rejection. + { signal: aborted.signal, error: new Error("private signal cancellation") }, + // Bun's formData() cancellation shape is AbortError even when a signal has not surfaced yet. + { signal: new AbortController().signal, error: new DOMException("private abort", "AbortError") }, + ]; + + for (const { signal, error } of cases) { + const { audit, lines } = auditEntries(); + const admission = createTranscriptionAdmission(); + let transcriberCalls = 0; + const request = { + headers: new Headers({ "content-type": "multipart/form-data" }), + signal, + formData: () => Promise.reject(error), + } as unknown as Request; + + const response = await attempt( + request, + { + transcribe: () => { + transcriberCalls += 1; + return Promise.resolve("must not run"); + }, + }, + audit, + admission, + ); + // A cancellation changes only audit classification; retain the existing sanitized body response. + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ ok: false, error: "expected multipart audio" }); + expect(transcriberCalls).toBe(0); + expect(lines).toHaveLength(1); + const detail = detailOf(lines[0]!); + expect(detail).toMatchObject({ outcome: "client-aborted", status: 400, failedPhase: "body" }); + expect(Object.keys(detail).sort()).toEqual( + ["requestId", "outcome", "status", "failedPhase", "bodyFormDataMs", "serverTotalMs"].sort(), + ); + expect(lines[0]).not.toContain("private"); + expectAllSlotsFree(admission); + } + }); + + test("holds two attempts, audits a busy third without parsing it, then admits after release", async () => { + expect(MAX_CONCURRENT_TRANSCRIPTION_ATTEMPTS).toBe(2); + const admission = createTranscriptionAdmission(); + const { audit, lines } = auditEntries(); + let calls = 0; + let firstReady!: () => void; + const firstStarted = new Promise((resolve) => { + firstReady = () => resolve(); + }); + let secondReady!: () => void; + const secondStarted = new Promise((resolve) => { + secondReady = () => resolve(); + }); + let releaseFirst!: (text: string) => void; + let releaseSecond!: (text: string) => void; + const transcriber: Transcriber = { + transcribe: () => { + calls += 1; + if (calls === 1) { + firstReady(); + return new Promise((resolve) => { + releaseFirst = resolve; + }); + } + if (calls === 2) { + secondReady(); + return new Promise((resolve) => { + releaseSecond = resolve; + }); + } + return Promise.resolve("after release"); + }, + }; + + const first = attempt(acceptedRequest(audioForm()), transcriber, audit, admission); + await firstStarted; + const second = attempt(acceptedRequest(audioForm()), transcriber, audit, admission); + await secondStarted; + const busy = await attempt(acceptedRequest(audioForm()), transcriber, audit, admission); + + expect(busy.status).toBe(429); + expect(await busy.json()).toEqual({ ok: false, error: "transcription busy" }); + expect(calls).toBe(2); + const busyDetail = detailOf(lines[0]!); + expect(Object.keys(busyDetail).sort()).toEqual( + ["requestId", "outcome", "status", "failedPhase", "serverTotalMs"].sort(), + ); + expect(busyDetail).toMatchObject({ outcome: "busy", status: 429, failedPhase: "request" }); + + releaseFirst("first complete"); + await expect(first).resolves.toHaveProperty("status", 200); + const afterRelease = await attempt(acceptedRequest(audioForm()), transcriber, audit, admission); + expect(afterRelease.status).toBe(200); + expect(calls).toBe(3); + releaseSecond("second complete"); + await expect(second).resolves.toHaveProperty("status", 200); + expect(lines).toHaveLength(4); + }); + + test("releases admission after success, malformed or aborted bodies, provider failure, and thrown setup", async () => { + const cases: Array<{ + name: string; + request: Request; + transcriber: Transcriber | null; + beforeBody?: () => void; + }> = [ + { + name: "success", + request: acceptedRequest(audioForm()), + transcriber: fakeTranscriber().transcriber, + }, + { + name: "malformed body", + request: { + headers: new Headers({ "content-type": "multipart/form-data" }), + signal: new AbortController().signal, + formData: () => Promise.reject(new Error("malformed")), + } as unknown as Request, + transcriber: fakeTranscriber().transcriber, + }, + { + name: "aborted body", + request: { + headers: new Headers({ "content-type": "multipart/form-data" }), + signal: new AbortController().signal, + formData: () => Promise.reject(new DOMException("aborted", "AbortError")), + } as unknown as Request, + transcriber: fakeTranscriber().transcriber, + }, + { + name: "provider failure", + request: acceptedRequest(audioForm()), + transcriber: { transcribe: () => Promise.reject(new Error("provider failed")) }, + }, + { + name: "thrown setup", + request: acceptedRequest(audioForm()), + transcriber: fakeTranscriber().transcriber, + beforeBody: () => { + throw new Error("timeout setup failed"); + }, + }, + ]; + + for (const { request, transcriber, beforeBody } of cases) { + const admission = createTranscriptionAdmission(); + const { audit, lines } = auditEntries(); + await attempt(request, transcriber, audit, admission, beforeBody); + expect(lines).toHaveLength(1); + expectAllSlotsFree(admission); + } }); }); diff --git a/bridge/server.ts b/bridge/server.ts index df6700b1..66d352f7 100644 --- a/bridge/server.ts +++ b/bridge/server.ts @@ -41,16 +41,20 @@ import type { const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10 MB // Multipart wraps a file in a boundary + part headers, so allow a small shared slack for its // Content-Length pre-checks. -const MAX_MULTIPART_OVERHEAD = 64 * 1024; // 64 KB +export const MAX_MULTIPART_OVERHEAD = 64 * 1024; // 64 KB // Hard cap the runtime enforces on ANY request body (Bun.serve maxRequestBodySize). Bigger than the // upload cap + overhead so the handler's own 413 fires first for honest clients; this cuts off a -// chunked or lying client that never sends an accurate Content-Length. -const MAX_REQUEST_BODY_BYTES = 12 * 1024 * 1024; // 12 MB +// chunked or lying client that never sends an accurate Content-Length. Bun may reject at this layer +// before the route handler runs, so those runtime rejections have no terminal transcription audit. +export const MAX_REQUEST_BODY_BYTES = 12 * 1024 * 1024; // 12 MB // Voice recordings stay below the global body limit. They are never written to the uploads directory // or any other filesystem path. export const MAX_TRANSCRIPTION_BYTES = 8 * 1024 * 1024; // 8 MB export const MAX_TRANSCRIPTION_DURATION_MS = 5 * 60 * 1000; // 5 min export const MAX_TRANSCRIPT_CHARS = 8192; +export const MAX_CONCURRENT_TRANSCRIPTION_ATTEMPTS = 2; +/** Bun's per-request nominal idle allowance, not an exact wall-clock boundary or a browser/provider total deadline. */ +export const VOICE_REQUEST_IDLE_TIMEOUT_SECONDS = 90; function declaredMultipartTooLarge(req: Request, fileLimit: number): boolean { const declared = Number(req.headers.get("content-length")); @@ -151,6 +155,27 @@ export function hasKnownPane(engine: StateEngine, paneId: string): boolean { return agents.some((pane) => pane.paneId === paneId) || shellPanes.some((pane) => pane.paneId === paneId); } +/** A process-local, non-queued admission gate for one server instance. */ +export interface TranscriptionAdmission { + acquire(): (() => void) | null; +} + +export function createTranscriptionAdmission(): TranscriptionAdmission { + let active = 0; + return { + acquire() { + if (active >= MAX_CONCURRENT_TRANSCRIPTION_ATTEMPTS) return null; + active += 1; + let released = false; + return () => { + if (released) return; + released = true; + active -= 1; + }; + }, + }; +} + export function startServer(opts: { cfg: Config; registry: SessionRegistry; @@ -170,6 +195,9 @@ export function startServer(opts: { // journal/registry.ts, never here. const journals = cfg.transcript ? buildJournalRegistry(cfg.journalRoots) : null; const transcripts = cfg.transcript ? new TranscriptStore() : null; + // This closure is created once per Bun server, not per request or per session: slow multipart + // bodies and provider calls share the same bounded process-local capacity. + const transcriptionAdmission = createTranscriptionAdmission(); /** Does this agent have a journal at all — the snapshot's History-affordance gate. */ const hasJournal = (agent: string) => adapterFor(journals ?? {}, agent) !== undefined; // Per-session background notifications live in each session's runtime (built by the factory in @@ -290,6 +318,23 @@ export function startServer(opts: { if (isTranscription && !hasKnownPane(rt.engine, paneId)) { return json({ ok: false, error: "pane not found" } satisfies TranscriptionResponse, req.headers.get("accept-encoding"), 404); } + if (isTranscription) { + // Access, session, and known-pane refusals return above without an attempt audit. From this + // point the wrapper owns admission, pre-body idle timeout, terminal audit, and release. + return await handleTranscriptionAttempt( + paneId, + req, + audit, + deviceAuth(req, cfg).device, + session, + transcriber, + transcriptionAdmission, + () => { + bunServer.timeout(req, VOICE_REQUEST_IDLE_TIMEOUT_SECONDS); + activity.noteSeen(session, paneId); + }, + ); + } // You are in this pane: reading it, replying, sending keys, browsing its history. That is // the whole definition of "seen" (.adr/0003), and this is the one place every such request // passes through. It cannot false-positive from background polling — the dashboard loader @@ -311,8 +356,6 @@ export function startServer(opts: { if (action === "reply" && req.method === "POST") return replyPane(herdr, cfg, paneId, req, audit, device, session); if (action === "keys" && req.method === "POST") return keysPane(herdr, cfg, paneId, req, audit, device, session); if (action === "upload" && req.method === "POST") return uploadPane(cfg, paneId, req, audit, device, session); - if (action === "transcribe" && req.method === "POST") - return transcribePane(paneId, req, audit, device, session, transcriber, () => bunServer.timeout(req, 90)); if (action === "close" && req.method === "POST") return closePane(herdr, paneId, req, audit, device, session); if (action === "rename" && req.method === "POST") return renamePane(herdr, paneId, req, audit, device, session); return text("method not allowed", 405); @@ -323,9 +366,8 @@ export function startServer(opts: { // Read-level, like the other non-terminal endpoints. Nothing here is secret — the VAPID // public key is handed to every browser by design — but this was the one route that skipped // checkAccess entirely, so COLLIE_PUBLIC_HOSTS didn't cover it and a rebound DNS name could - // still read the build id. The client only ever calls this same-origin, and a refusal can't - // be mistaken for an outage: ConnectionBanner short-circuits to AuthErrorBanner before its - // red-state probe runs. Noted in #32. + // still read the build id. The client only ever calls this same-origin, and refusals retain + // the normal read-access response. Noted in #32. const denied = guard(req, cfg, "read"); if (denied) return denied; return json({ @@ -1065,55 +1107,207 @@ async function createWorkspace( } } +type TranscriptionOutcome = "ok" | "busy" | "invalid" | "timeout" | "client-aborted" | "unavailable"; +type TranscriptionFailedPhase = "request" | "body" | "validation" | "provider" | "result"; + +interface ValidatedTranscriptionMetadata { + mime: "audio/webm" | "audio/mp4"; + bytes: number; + reportedDurationMs: number; +} + +interface TranscriptionAttemptResult { + response: Response; + outcome: TranscriptionOutcome; + failedPhase?: TranscriptionFailedPhase; + bodyFormDataMs?: number; + providerMs?: number; + metadata?: ValidatedTranscriptionMetadata; +} + +function elapsedMs(startedAt: number): number { + return Math.max(0, Math.round(performance.now() - startedAt)); +} + +function bodyWasAborted(req: Request, error: unknown): boolean { + // Bun 1.3.14 reports a disconnected caller and its own idle expiry alike: an aborted request + // signal and/or a DOM AbortError from formData(). They are body cancellations, but their source + // is not independently distinguishable here; provider deadlines remain the clear timeout outcome. + return req.signal.aborted || (error instanceof DOMException && error.name === "AbortError"); +} + +function recordTranscriptionAttempt( + audit: AuditLog, + paneId: string, + session: string, + device: string | null, + requestId: string, + startedAt: number, + result: TranscriptionAttemptResult, +): void { + // This is the sole terminal transcription audit. It contains only route metadata and validated + // file metadata; audio, filename, transcript, provider data, errors, request headers, and form + // fields never reach it. AuditLog itself deliberately makes a write failure non-fatal. + audit.record({ + action: "transcribe", + paneId, + session, + device, + detail: { + requestId, + outcome: result.outcome, + status: result.response.status, + ...(result.failedPhase !== undefined ? { failedPhase: result.failedPhase } : {}), + ...(result.bodyFormDataMs !== undefined ? { bodyFormDataMs: result.bodyFormDataMs } : {}), + ...(result.providerMs !== undefined ? { providerMs: result.providerMs } : {}), + serverTotalMs: elapsedMs(startedAt), + ...(result.metadata ?? {}), + }, + }); +} + /** - * Validate and forward one completed voice clip without retaining it. This is deliberately separate - * from `uploadPane`: image uploads are durable host files that Herdr reads by path; audio is only a - * short-lived multipart value passed onward to the configured transcription endpoint. + * One authenticated, known-pane route attempt. This owns the complete bounded lifetime: admission, + * Bun's pre-body idle allowance, response construction, one terminal audit, and slot release. */ -export async function transcribePane( +export async function handleTranscriptionAttempt( paneId: string, req: Request, audit: AuditLog, device: string | null, session: string, transcriber: Transcriber | null, - /** Production extends only this validated provider wait past Bun's default request timeout. */ - beforeProviderCall?: () => void, + admission: TranscriptionAdmission, + beforeBody: () => void, ): Promise { + const startedAt = performance.now(); + const requestId = crypto.randomUUID(); const accept = req.headers.get("accept-encoding"); + const release = admission.acquire(); + if (release === null) { + const response = json( + { ok: false, error: "transcription busy" } satisfies TranscriptionResponse, + accept, + 429, + ); + recordTranscriptionAttempt(audit, paneId, session, device, requestId, startedAt, { + response, + outcome: "busy", + failedPhase: "request", + }); + return response; + } + + let enteredHandler = false; + try { + let result: TranscriptionAttemptResult; + try { + beforeBody(); + enteredHandler = true; + // Await before the finally below releases the slot; a bare return of this promise would make + // slow bodies/provider work escape the admission gate. + result = await transcribePane(req, transcriber); + } catch { + // Do not reflect or audit a thrown implementation/provider body. The response is constructed + // before its total duration is stamped, and the terminal audit remains exactly one entry. + result = { + response: json( + { ok: false, error: "transcription unavailable" } satisfies TranscriptionResponse, + accept, + 502, + ), + outcome: "unavailable", + failedPhase: enteredHandler ? "result" : "request", + }; + } + recordTranscriptionAttempt(audit, paneId, session, device, requestId, startedAt, result); + return result.response; + } finally { + release(); + } +} + +/** + * Validate and forward one completed voice clip without retaining it. This is deliberately separate + * from `uploadPane`: image uploads are durable host files that Herdr reads by path; audio is only a + * short-lived multipart value passed onward to the configured transcription endpoint. The route + * wrapper records the sole terminal audit after this handler constructs its response. + */ +export async function transcribePane( + req: Request, + transcriber: Transcriber | null, +): Promise { + const handlerStartedAt = performance.now(); + const accept = req.headers.get("accept-encoding"); + const invalid = ( + error: string, + status: number, + failedPhase: TranscriptionFailedPhase, + fields: Pick = {}, + ): TranscriptionAttemptResult => ({ + response: json({ ok: false, error } satisfies TranscriptionResponse, accept, status), + outcome: "invalid", + failedPhase, + ...fields, + }); + if (transcriber === null) { - return json({ ok: false, error: "transcription unavailable" } satisfies TranscriptionResponse, accept, 503); + return { + response: json( + { ok: false, error: "transcription unavailable" } satisfies TranscriptionResponse, + accept, + 503, + ), + outcome: "unavailable", + failedPhase: "request", + }; } if (!req.headers.get("content-type")?.toLowerCase().startsWith("multipart/form-data")) { - return json({ ok: false, error: "expected multipart audio" } satisfies TranscriptionResponse, accept, 400); + return invalid("expected multipart audio", 400, "request"); } // FormData buffers the completed request, so reject an honest oversized declaration before asking // Bun to parse it. `maxRequestBodySize` remains the hard backstop for chunked or lying requests. if (declaredMultipartTooLarge(req, MAX_TRANSCRIPTION_BYTES)) { - return json({ ok: false, error: "audio too large (max 8 MiB)" } satisfies TranscriptionResponse, accept, 413); + return invalid("audio too large (max 8 MiB)", 413, "request"); } let form: FormData; try { form = await req.formData(); - } catch { - return json({ ok: false, error: "expected multipart audio" } satisfies TranscriptionResponse, accept, 400); + } catch (error) { + const bodyFormDataMs = elapsedMs(handlerStartedAt); + if (bodyWasAborted(req, error)) { + // Preserve the existing sanitized malformed-body response; only the terminal audit distinguishes + // a body cancellation from malformed multipart. + return { + response: json( + { ok: false, error: "expected multipart audio" } satisfies TranscriptionResponse, + accept, + 400, + ), + outcome: "client-aborted", + failedPhase: "body", + bodyFormDataMs, + }; + } + return invalid("expected multipart audio", 400, "body", { bodyFormDataMs }); } + const bodyFormDataMs = elapsedMs(handlerStartedAt); const files = form.getAll("file"); const durations = form.getAll("duration_ms"); if (files.length !== 1 || !(files[0] instanceof File)) { - return json({ ok: false, error: "audio file required" } satisfies TranscriptionResponse, accept, 400); + return invalid("audio file required", 400, "validation", { bodyFormDataMs }); } if (durations.length !== 1 || typeof durations[0] !== "string" || !/^\d+$/.test(durations[0])) { - return json({ ok: false, error: "invalid recording duration" } satisfies TranscriptionResponse, accept, 400); + return invalid("invalid recording duration", 400, "validation", { bodyFormDataMs }); } // This is browser-reported recording lifecycle metadata, not media duration parsed by the bridge. const reportedDurationMs = Number(durations[0]); if (!Number.isSafeInteger(reportedDurationMs) || reportedDurationMs < 1) { - return json({ ok: false, error: "invalid recording duration" } satisfies TranscriptionResponse, accept, 400); + return invalid("invalid recording duration", 400, "validation", { bodyFormDataMs }); } if (reportedDurationMs > MAX_TRANSCRIPTION_DURATION_MS) { - return json({ ok: false, error: "recording too long (max 5 minutes)" } satisfies TranscriptionResponse, accept, 413); + return invalid("recording too long (max 5 minutes)", 413, "validation", { bodyFormDataMs }); } const file = files[0]; @@ -1128,52 +1322,58 @@ export async function transcribePane( ? "audio/mp4" : null; if (mime === null) { - return json({ ok: false, error: "unsupported audio type" } satisfies TranscriptionResponse, accept, 415); + return invalid("unsupported audio type", 415, "validation", { bodyFormDataMs }); } if (file.size < 1) { - return json({ ok: false, error: "audio file required" } satisfies TranscriptionResponse, accept, 400); + return invalid("audio file required", 400, "validation", { bodyFormDataMs }); } if (file.size > MAX_TRANSCRIPTION_BYTES) { - return json({ ok: false, error: "audio too large (max 8 MiB)" } satisfies TranscriptionResponse, accept, 413); + return invalid("audio too large (max 8 MiB)", 413, "validation", { bodyFormDataMs }); } - const detail = { mime, bytes: file.size, reportedDurationMs }; + const metadata: ValidatedTranscriptionMetadata = { mime, bytes: file.size, reportedDurationMs }; // Never forward the caller-supplied filename. It may be sensitive metadata, and the configured // provider only needs a conventional extension to interpret the completed browser recording. const providerFile = new File([file], mime === "audio/mp4" ? "recording.mp4" : "recording.webm", { type: mime, }); + const providerStartedAt = performance.now(); try { - // Multipart validation deliberately retains Bun's normal slow-client timeout. Only the - // subsequent provider wait gets route-specific transport headroom above its own 60s deadline. - beforeProviderCall?.(); const text = (await transcriber.transcribe(providerFile, req.signal)).trim(); + const providerMs = elapsedMs(providerStartedAt); if (!text || text.length > MAX_TRANSCRIPT_CHARS) { - audit.record({ action: "transcribe", paneId, session, device, detail: { ...detail, outcome: "invalid" } }); - return json({ ok: false, error: "invalid transcription result" } satisfies TranscriptionResponse, accept, 502); + return invalid("invalid transcription result", 502, "result", { + bodyFormDataMs, + providerMs, + metadata, + }); } - // Metadata only: no filename, audio bytes, provider body, or returned transcript enters audit.log. - audit.record({ action: "transcribe", paneId, session, device, detail: { ...detail, outcome: "ok" } }); - return json({ ok: true, text } satisfies TranscriptionResponse, accept); + return { + response: json({ ok: true, text } satisfies TranscriptionResponse, accept), + outcome: "ok", + bodyFormDataMs, + providerMs, + metadata, + }; } catch (error) { - const kind = error instanceof TranscriptionProviderError ? error.kind : "unavailable"; - audit.record({ - action: "transcribe", - paneId, - session, - device, - detail: { ...detail, outcome: kind }, - }); + const outcome = error instanceof TranscriptionProviderError ? error.kind : "unavailable"; // Provider messages can include account, model, or upstream error-body details; never reflect - // them to a browser or log them. A fresh recording is required after every failed request. - const status = kind === "timeout" ? 504 : kind === "client-aborted" ? 499 : 502; + // them to a browser or audit them. A fresh recording is required after every failed request. + const status = outcome === "timeout" ? 504 : outcome === "client-aborted" ? 499 : 502; const message = - kind === "timeout" + outcome === "timeout" ? "transcription timed out" - : kind === "client-aborted" + : outcome === "client-aborted" ? "transcription cancelled" : "transcription unavailable"; - return json({ ok: false, error: message } satisfies TranscriptionResponse, accept, status); + return { + response: json({ ok: false, error: message } satisfies TranscriptionResponse, accept, status), + outcome, + failedPhase: "provider", + bodyFormDataMs, + providerMs: elapsedMs(providerStartedAt), + metadata, + }; } } diff --git a/web/src/components/agent-chat.test.tsx b/web/src/components/agent-chat.test.tsx index f226c1bc..8bb213f1 100644 --- a/web/src/components/agent-chat.test.tsx +++ b/web/src/components/agent-chat.test.tsx @@ -6,8 +6,6 @@ import userEvent from "@testing-library/user-event"; import { http, HttpResponse } from "msw"; import { createMemoryRouter, RouterProvider, useParams } from "react-router"; -import { __resetConnectionHealth } from "@/lib/connection-health"; - // Mock the race guard at AgentChat's seam so the frozen-revision tests can observe exactly what // `detectedRevision` the tap handler passes (the guard's own behaviour is covered in // prompt-select-block.test.tsx). The other tests in this file never reach it. @@ -78,6 +76,7 @@ beforeEach(() => { voice = { phase: "idle", elapsedLabel: "0:00", + canWrite: () => voice.phase === "idle", startRecording: vi.fn(), stopRecording: vi.fn(), cancel: vi.fn(), @@ -402,8 +401,56 @@ describe("AgentChat — pane voice write lock", () => { if (control) expect(control()).toBeEnabled(); await invoke(idleOutput); await waitFor(() => expect(submit()).toHaveBeenCalledTimes(1)); + expect(submit()).toHaveBeenLastCalledWith( + expect.objectContaining({ canWrite: expect.any(Function) }), + ); }); + it("passes a live voice-write check to a deferred dialog action", async () => { + let settleGuard!: () => void; + const guardPending = new Promise((resolve) => { + settleGuard = resolve; + }); + let writeAllowed: boolean | undefined; + const submit = vi.mocked(submitPromptOption); + submit.mockClear(); + submit.mockImplementationOnce(async (args) => { + await guardPending; + writeAllowed = args.canWrite(); + return writeAllowed ? { status: "sent" } : { status: "changed" }; + }); + + const { setVoicePhase } = renderVoiceChat(MENU_TEXT); + const action = capturedAnsiOutput(false).onPromptAction!(undefined as never, undefined as never); + await waitFor(() => expect(submit).toHaveBeenCalledTimes(1)); + + // The dialog tap began idle, then recording took the pane before its asynchronous guard settled. + setVoicePhase("recording"); + settleGuard(); + await action; + + expect(writeAllowed).toBe(false); + }); + + it.each(["requesting", "recording", "finalizing", "processing"] as const)( + "keeps current-pane draft and terminal writes locked while %s", + async (phase) => { + voice.phase = phase; + const submit = vi.mocked(submitPromptOption); + submit.mockClear(); + const { setVoicePhase } = renderVoiceChat(MENU_TEXT); + const busyOutput = capturedAnsiOutput(true); + + expect(screen.getByPlaceholderText(/type a reply/i)).toBeDisabled(); + await busyOutput.onPromptAction!(undefined as never, undefined as never); + expect(submit).not.toHaveBeenCalled(); + + setVoicePhase("idle"); + await capturedAnsiOutput(false).onPromptAction!(undefined as never, undefined as never); + await waitFor(() => expect(submit).toHaveBeenCalledTimes(1)); + }, + ); + it("routes a completed transcript to the editable draft without sending", () => { renderChat({ transcriptionEnabled: true }); expect(voiceOptions).toBeDefined(); @@ -606,18 +653,15 @@ describe("AgentChat — mirror tap must not pop the keyboard on option taps", () }); }); -// Connection copy now lives in the single top ConnectionBanner (mounted in RootLayout), not in the -// header — so the pane header has no pill. What it still owns: the agent StatusBadge, which shows the -// LAST snapshot's status and must stop reading as current during an outage (it dims on any not-live). -describe("AgentChat — shared header: stale-status dimming", () => { - beforeEach(() => __resetConnectionHealth()); - - it("dims the agent StatusBadge while the connection is not live and restores it on recovery", () => { +// Root freshness is rendered in RootLayout; the pane header keeps only the agent StatusBadge. A stale +// root snapshot makes that last-known status visually non-current without changing pane freshness. +describe("AgentChat — root/pane freshness", () => { + it("dims the agent StatusBadge for a degraded root and restores it independently", () => { // fixtureAgents[0] is a blocked claude agent → StatusBadge reads "needs you". - let setError: (e: boolean) => void = () => {}; + let setStale: (stale: boolean) => void = () => {}; function Harness() { - const [error, setErr] = useState(true); - setError = setErr; + const [snapshotStale, setSnapshotStale] = useState(true); + setStale = setSnapshotStale; const agent = fixtureAgents[0]!; return ( { tabs={[]} transcriptionEnabled={false} text="out" - error={error} + snapshotStale={snapshotStale} + rootDegraded={snapshotStale} onBack={vi.fn()} onSelect={vi.fn()} /> @@ -638,9 +683,21 @@ describe("AgentChat — shared header: stale-status dimming", () => { render(); const badge = screen.getByText("needs you"); - expect(badge).toHaveClass("opacity-40"); // not live → frozen status dimmed - act(() => setError(false)); // snapshot recovers → live - expect(badge).not.toHaveClass("opacity-40"); // undimmed instantly + expect(badge).toHaveClass("opacity-40"); + act(() => setStale(false)); + expect(badge).not.toHaveClass("opacity-40"); + }); + + it("keeps voice enabled when only the root snapshot is stale", () => { + renderChat({ transcriptionEnabled: true, snapshotStale: true, rootDegraded: true }); + expect(voiceOptions?.enabled).toBe(true); + expect(voice.cancel).not.toHaveBeenCalled(); + }); + + it("renders pane freshness locally without changing the root status treatment", () => { + renderChat({ paneStale: true, paneHasLastGood: true }); + expect(screen.getByText(/Pane output delayed — showing the last update/i)).toBeInTheDocument(); + expect(screen.getByText("needs you")).not.toHaveClass("opacity-40"); }); }); diff --git a/web/src/components/agent-chat.tsx b/web/src/components/agent-chat.tsx index 1138d9f5..76833212 100644 --- a/web/src/components/agent-chat.tsx +++ b/web/src/components/agent-chat.tsx @@ -7,11 +7,11 @@ import { useSpaceActions } from "@/hooks/use-spaces"; import { useDashPrefs, openForCount } from "@/hooks/use-dash-prefs"; import { useDisplayPrefs } from "@/hooks/use-display-prefs"; import { useStableTerminalDraft } from "@/hooks/use-terminal-draft"; -import { isConnecting } from "@/lib/connection"; import { setStatus } from "@/lib/status"; import { ChatMessageList, type ChatMessageListHandle } from "@/components/ui/chat/chat-message-list"; import { BottomSheet } from "@/components/ui/sheet"; import { AppHeader } from "@/components/app-header"; +import { PaneFreshnessNotice } from "@/components/freshness-banner"; import { AnsiOutput } from "@/components/ansi-output"; import { MIRROR_SPACE, MIRROR_INVERT, styleFor } from "@/components/mirror-space"; import { cn } from "@/lib/utils"; @@ -39,7 +39,7 @@ import { canGrowRequestedLines, growRequestedLines } from "@/lib/loaders"; import { shortCwd } from "@/lib/format"; import { historyPath, spacePath } from "@/lib/nav"; import { isReadOnly } from "@/lib/types"; -import type { AgentView, BridgeStatus, DeviceAuth, TabView } from "@/lib/types"; +import type { AgentView, DeviceAuth, TabView } from "@/lib/types"; import type { MenuModel, MultiSelectModel, @@ -69,12 +69,17 @@ interface AgentChatProps { device?: DeviceAuth; /** Snapshot capability for native voice input; no provider configuration reaches this component. */ transcriptionEnabled: boolean; - // Global connection state — fed straight to the shared AppHeader, which drives the header Collie - // mark (gallop/rest, identically to the dashboard), and lets us dim the stale StatusBadge while not - // live. Defaults describe a healthy link so tests that don't care render "live". - bridge?: BridgeStatus | undefined; - error?: boolean; - stalled?: boolean; + /** Root freshness is independent from pane freshness; the root banner owns its auth escape. */ + snapshotStale?: boolean; + snapshotAuthError?: boolean; + /** Includes fresh root evidence that Herdr is unavailable. */ + rootDegraded?: boolean; + /** Pane freshness is rendered directly below this pane's header/status area. */ + paneStale?: boolean; + paneAuthError?: boolean; + paneHasLastGood?: boolean; + /** Generic route loading animation only. */ + loading?: boolean; onBack: () => void; onSelect: (paneId: string) => void; } @@ -105,18 +110,21 @@ export function AgentChat({ revision = 0, device, transcriptionEnabled, - bridge = "connected", - error = false, - stalled = false, + snapshotStale = false, + snapshotAuthError = false, + rootDegraded = false, + paneStale = false, + paneAuthError = false, + paneHasLastGood = false, + loading = false, onBack, onSelect, }: AgentChatProps) { const revalidator = useRevalidator(); const navigate = useNavigate(); - // Poll-truth "is the data on screen not live". The header (AppHeader) reads the same inputs to drive - // the Collie mark + pill; here we use it to dim the StatusBadge, so the badge stops presenting the - // last snapshot's status as current while we're reconnecting/lost, and restores instantly on recovery. - const connecting = isConnecting({ bridge, error, stalled }); + // Current-status presentation is static whenever the root snapshot is stale, refused, or freshly + // reports Herdr unavailable. Generic loading is deliberately not an input here. + const degraded = rootDegraded || snapshotStale || snapshotAuthError; const { newTab } = useSpaceActions(); // Single display-prefs instance: the View controls (in ) write it, the mirror reads it. const { prefs, setWrap, stepFontSize, setRawTerminal } = useDisplayPrefs(); @@ -146,6 +154,9 @@ export function AgentChat({ onError: (message) => setStatus(message, "error"), }); const voiceBusy = voice.phase !== "idle"; + // This is owned by the voice lifecycle's synchronous phase ref, so a recording invalidates a + // deferred dialog write before React has to render the busy controls. + const canWrite = voice.canWrite; // Swipe up (or just tap) the handle above the composer to bring up the pane switcher. A lowish // threshold + a taller hit area (below) make the gesture easy to land with a thumb; tapping is the @@ -345,6 +356,7 @@ export function AgentChat({ requestedLines, detectedRevision: shown.revision, agent: agent?.agent, + canWrite, prompt, option, }); @@ -360,7 +372,7 @@ export function AgentChat({ setStatus(result.error || "Send failed", "error"); } }, - [readOnly, voiceBusy, paneId, session, requestedLines, shown.revision, agent?.agent, revalidator], + [readOnly, voiceBusy, paneId, session, requestedLines, shown.revision, agent?.agent, canWrite, revalidator], ); // Tap a wizard control (an option digit, step navigation, or the review step's submit/cancel). @@ -382,6 +394,7 @@ export function AgentChat({ requestedLines, detectedRevision: shown.revision, agent: agent?.agent, + canWrite, wizard, keys, }); @@ -397,7 +410,7 @@ export function AgentChat({ setStatus(result.error || "Send failed", "error"); } }, - [readOnly, voiceBusy, paneId, session, requestedLines, shown.revision, agent?.agent, revalidator], + [readOnly, voiceBusy, paneId, session, requestedLines, shown.revision, agent?.agent, canWrite, revalidator], ); // Tap a preview-dialog control (an option, the note add/edit/remove, or the wizard step nav). @@ -419,6 +432,7 @@ export function AgentChat({ requestedLines, detectedRevision: shown.revision, agent: agent?.agent, + canWrite, preview, }; const result = @@ -443,7 +457,7 @@ export function AgentChat({ revalidator.revalidate(); } }, - [readOnly, voiceBusy, paneId, session, requestedLines, shown.revision, agent?.agent, revalidator], + [readOnly, voiceBusy, paneId, session, requestedLines, shown.revision, agent?.agent, canWrite, revalidator], ); // Tap a multi-select control (toggle a checkbox, Submit, the "Chat about this" escape, or the @@ -464,6 +478,7 @@ export function AgentChat({ requestedLines, detectedRevision: shown.revision, agent: agent?.agent, + canWrite, multi, intent: action, }); @@ -479,7 +494,7 @@ export function AgentChat({ setStatus(result.error || "Send failed", "error"); } }, - [readOnly, voiceBusy, paneId, session, requestedLines, shown.revision, agent?.agent, revalidator], + [readOnly, voiceBusy, paneId, session, requestedLines, shown.revision, agent?.agent, canWrite, revalidator], ); // Tap a generic-menu control (a footer-named key like Enter/s/Esc, or an arrow). Same guard-first @@ -500,6 +515,7 @@ export function AgentChat({ requestedLines, detectedRevision: shown.revision, agent: agent?.agent, + canWrite, menu, keys: action.keys, nav: action.nav, @@ -516,7 +532,7 @@ export function AgentChat({ setStatus(result.error || "Send failed", "error"); } }, - [readOnly, voiceBusy, paneId, session, requestedLines, shown.revision, agent?.agent, revalidator], + [readOnly, voiceBusy, paneId, session, requestedLines, shown.revision, agent?.agent, canWrite, revalidator], ); // NOTE: the composer is deliberately NOT auto-focused on open/switch — that would pop the Android @@ -568,9 +584,8 @@ export function AgentChat({ slots: the `space › tab` breadcrumb as the center, the agent StatusBadge as the right-cluster lead, and the find bar as the full-row takeover while searching. */} @@ -626,9 +642,9 @@ export function AgentChat({ )} {isShell ? ( - + ) : ( - + )} ) : undefined @@ -681,6 +697,11 @@ export function AgentChat({ (prompt/cursor + up-levelled prompt buttons) it used to cover. Renders nothing — no reserved space — when idle; auto-dismisses. */} + {/* Read-only notice when this device isn't allowlisted (the composer below is disabled too). */} diff --git a/web/src/components/agent-list.test.tsx b/web/src/components/agent-list.test.tsx index 7fb28321..fa6adc54 100644 --- a/web/src/components/agent-list.test.tsx +++ b/web/src/components/agent-list.test.tsx @@ -104,16 +104,47 @@ describe("AgentList — sections", () => { }); it("shows the herd-empty placeholder, and suppresses it when asked", () => { - const { rerender } = render(); + const { rerender } = render( + , + ); expect(screen.getByText(/no agents running/i)).toBeInTheDocument(); rerender(); expect(screen.queryByText(/no agents running/i)).not.toBeInTheDocument(); }); - it("says it's waiting when the bridge is down, rather than 'no agents'", () => { + it("says it's waiting when a fresh root snapshot reports Herdr unavailable", () => { render(); expect(screen.getByText(/waiting for herdr/i)).toBeInTheDocument(); }); + + it("does not claim a prior update after a cold stale root failure", () => { + render( + , + ); + expect(screen.getByText("Agents unavailable while live updates are delayed.")).toBeInTheDocument(); + expect(screen.queryByText(/last update/i)).toBeNull(); + expect(screen.queryByText(/waiting for herdr/i)).toBeNull(); + }); + + it("describes a cached empty snapshot as the last update when its refresh is stale", () => { + render( + , + ); + expect(screen.getByText("No agents in the last update.")).toBeInTheDocument(); + expect(screen.queryByText(/waiting for herdr/i)).toBeNull(); + }); }); describe("AgentList — the attention sections are pinned", () => { diff --git a/web/src/components/agent-list.tsx b/web/src/components/agent-list.tsx index 0a9908bd..69561000 100644 --- a/web/src/components/agent-list.tsx +++ b/web/src/components/agent-list.tsx @@ -8,7 +8,11 @@ import { AgentCard } from "./agent-card"; interface AgentListProps { agents: AgentView[]; + /** Herdr state is meaningful only when the root snapshot itself is fresh. */ bridge?: BridgeStatus | undefined; + snapshotStale?: boolean; + /** Whether the root loader has an authoritative snapshot, even if it is empty. */ + snapshotHasLastGood?: boolean; onOpen: (paneId: string) => void; /** Which way Recent runs, and how to flip it. Omit to render Recent newest-first with no toggle. */ recentDir?: RecentDir; @@ -39,6 +43,8 @@ const ATTENTION: ReadonlySet = new Set(["needs", "ready"]) export function AgentList({ agents, bridge, + snapshotStale = false, + snapshotHasLastGood = false, onOpen, recentDir = "newest", onRecentDirChange, @@ -52,7 +58,13 @@ export function AgentList({
- {bridge === "connected" ? "No agents running." : "Waiting for Herdr…"} + {snapshotStale + ? snapshotHasLastGood + ? "No agents in the last update." + : "Agents unavailable while live updates are delayed." + : bridge === "connected" + ? "No agents running." + : "Waiting for Herdr…"}
); diff --git a/web/src/components/app-header.test.tsx b/web/src/components/app-header.test.tsx index 03053ef7..3a656d1a 100644 --- a/web/src/components/app-header.test.tsx +++ b/web/src/components/app-header.test.tsx @@ -1,14 +1,11 @@ -import { act, render, screen } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter, useLocation } from "react-router"; import type { ReactElement } from "react"; import { AppHeader, SettingsGear } from "./app-header"; import { StatusBadge } from "./status-badge"; -import { CONNECTION_LOST_MS, TROUBLE_MS } from "@/hooks/use-connection-lost"; -import { __resetConnectionHealth } from "@/lib/connection-health"; -// AppHeader mounts CollieHome (a button) and, via SettingsGear, useNavigate — so it needs a router. function renderHeader(ui: ReactElement) { return render(ui, { wrapper: MemoryRouter }); } @@ -18,104 +15,55 @@ function LocationProbe() { return
{loc.pathname + loc.search}
; } -describe("AppHeader — the one shared header shell", () => { - beforeEach(() => __resetConnectionHealth()); - - it("is calm in the PANE variant while live — breadcrumb + status badge, no pill, no wordmark", () => { - // Connection copy lives in the top ConnectionBanner now; the header carries none. A healthy pane - // header shows its own bits and a resting (static) Collie mark. +describe("AppHeader", () => { + it("is calm while fresh", () => { const { container } = renderHeader( - {}} - rightLead={} - > + {}} rightLead={}> webapp › main , ); - expect(screen.queryByRole("status")).toBeNull(); // no connection pill of any kind - expect(container.querySelector(".dog-gallop")).toBeNull(); // mark at rest (static icon) - expect(screen.getByText("webapp › main")).toBeInTheDocument(); // the breadcrumb slot - expect(screen.getByText("working")).toBeInTheDocument(); // the agent status badge - expect(screen.queryByText("Collie")).toBeNull(); // no wordmark in a pane - }); - - it("is calm in the DASHBOARD variant while live — wordmark + settings gear, resting mark", () => { - const { container } = renderHeader( - } />, - ); - expect(screen.getByText("Collie")).toBeInTheDocument(); // wordmark - expect(container.querySelector(".dog-gallop")).toBeNull(); // mark at rest while live - expect(screen.getByRole("button", { name: "Settings" })).toBeInTheDocument(); + expect(container.querySelector(".dog-gallop")).toBeNull(); + expect(container.querySelector("img")).toHaveAttribute("src", "/favicon.svg"); + expect(screen.getByText("webapp › main")).toBeInTheDocument(); + expect(screen.getByText("working")).toBeInTheDocument(); }); - it("returns to the dashboard via onHome when the Collie mark is tapped", async () => { + it("returns home and preserves session-scoped settings navigation", async () => { const onHome = vi.fn(); - renderHeader(); - await userEvent.click(screen.getByRole("button", { name: "Collie home" })); - expect(onHome).toHaveBeenCalledOnce(); - }); - - it("navigates to a session-scoped /settings via the shared gear", async () => { render( - } - /> + } /> , ); + await userEvent.click(screen.getByRole("button", { name: "Collie home" })); + expect(onHome).toHaveBeenCalledOnce(); await userEvent.click(screen.getByRole("button", { name: "Settings" })); - expect(screen.getByTestId("loc").textContent).toBe("/settings?s=collie-demo"); + expect(screen.getByTestId("loc")).toHaveTextContent("/settings?s=collie-demo"); }); - it("the find-bar override takes over the whole row (mark and breadcrumb yield)", () => { - // `error` → not live, so the mark would react — proving the override replaces the row entirely. + it("keeps generic loading animation separate from static degraded treatment", () => { + const { container, rerender } = renderHeader(); + expect(container.querySelector(".dog-gallop")).toHaveClass("dog-gallop--running"); + expect(screen.getByRole("button", { name: "Collie home" })).toBeInTheDocument(); + + rerender(); + expect(container.querySelector(".dog-gallop")).toBeNull(); + expect(container.querySelector("img")?.parentElement).toHaveClass("grayscale"); + expect(screen.getByRole("button", { name: "Collie home" })).toBeInTheDocument(); + + rerender(); + expect(container.querySelector(".dog-gallop")).toHaveClass("dog-gallop--running"); + expect(container.querySelector(".dog-gallop")?.parentElement).toHaveClass("grayscale"); + }); + + it("lets an override take over the whole row", () => { renderHeader( - {}} - rightLead={} - override={
FINDBAR
} - > + {}} override={
FINDBAR
}> webapp › main
, ); - // The override owns the row while searching — the normal content is replaced, not stacked. expect(screen.getByText("FINDBAR")).toBeInTheDocument(); - expect(screen.queryByText("webapp › main")).toBeNull(); expect(screen.queryByRole("button", { name: "Collie home" })).toBeNull(); }); }); - -// The header dog agrees with the ConnectionBanner by construction — it reads the SAME shared-clock -// signals: it gallops only once trouble is sustained (≥4s, the flicker fix), and rests muted once lost -// (≥15s). Fake timers drive the wall-clock hooks (Vitest advances Date.now with them). -describe("AppHeader — the dog keys on trouble/lost, not the first not-live frame", () => { - beforeEach(() => { - vi.useFakeTimers(); - __resetConnectionHealth(); // anchor == frozen clock, so the thresholds land exactly - }); - afterEach(() => vi.useRealTimers()); - - it("stays a static icon during a brief not-live spell, gallops at 4s, rests muted at 15s", () => { - const { container } = renderHeader( {}} />); - // A single not-live frame is NOT trouble yet: the mark stays the static, full-color icon. - expect(container.querySelector(".dog-gallop")).toBeNull(); - expect(container.querySelector("img")).toHaveAttribute("src", "/favicon.svg"); - expect(container.querySelector("img")?.className ?? "").not.toMatch(/grayscale/); - - // Sustained trouble (4s) → the dog gallops (agreeing with the amber bar). - act(() => vi.advanceTimersByTime(TROUBLE_MS)); - expect(container.querySelector(".dog-gallop")).toHaveClass("dog-gallop--running"); - - // Escalated to lost (15s) → the gallop stops and the mark rests on the muted static icon. - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS - TROUBLE_MS)); - expect(container.querySelector(".dog-gallop")).toBeNull(); - expect(container.querySelector("img")?.className ?? "").toMatch(/grayscale/); - }); -}); diff --git a/web/src/components/app-header.tsx b/web/src/components/app-header.tsx index 9bfa4c2e..8200e81c 100644 --- a/web/src/components/app-header.tsx +++ b/web/src/components/app-header.tsx @@ -2,54 +2,39 @@ import type { ReactNode } from "react"; import { Settings } from "lucide-react"; import { useNavigate } from "react-router"; -import { isConnecting } from "@/lib/connection"; -import { useConnectionLost, useConnectionTrouble } from "@/hooks/use-connection-lost"; import { settingsPath } from "@/lib/nav"; import { CollieHome } from "@/components/collie-home"; -import type { BridgeStatus } from "@/lib/types"; interface AppHeaderProps { - // Connection state — the inputs that drive the CollieHome dog. The dog gallops on sustained trouble - // (≥4s not-live) and rests muted once lost (≥15s), both derived here from the SAME shared connection- - // health clock the ConnectionBanner reads, so the header mark and the top connection bar can never - // disagree. There is no longer a per-header pill: the single ConnectionBanner (mounted once in - // RootLayout) owns all connection copy, so a healthy header is just the mark + the caller's own items. - bridge: BridgeStatus | undefined; - error: boolean; - stalled?: boolean; + /** Generic route loading only. It animates the mark without making a freshness claim. */ + loading?: boolean; + /** Static treatment when this route's root snapshot cannot support current-state claims. */ + degraded?: boolean; /** Tapping the Collie mark returns to the dashboard. A callback, not a ``: the - * dashboard and the drilled-in space view share the "/" route, so a same-route link would no-op. */ + * dashboard and the drilled-in space view share the "/" route, so a same-route link would no-op. */ onHome?: () => void; /** Show the "Collie" wordmark beside the mark (dashboard + space). Omit inside a pane — the - * breadcrumb in `children` carries the context there, and the mark stands alone to save width. */ + * breadcrumb in `children` carries the context there, and the mark stands alone to save width. */ wordmark?: boolean; /** Route-specific center content — the pane's `space › tab` breadcrumb. Rendered in a `flex-1 - * min-w-0` region so a long breadcrumb truncates instead of pushing the pill off the row. Empty on - * the dashboard/space, where the region is just the spacer that pushes the right cluster over. */ + * min-w-0` region so a long breadcrumb truncates instead of pushing the right cluster off the row. */ children?: ReactNode; /** Right-cluster lead items (the dashboard's SessionSwitcher; the pane's StatusBadge). */ rightLead?: ReactNode; /** Right-cluster trailing items (the Settings gear). */ rightTrail?: ReactNode; - /** Full-width takeover of the header row (the pane's find bar). When set it replaces the normal - * content while it's up — the find bar owns the row one-handed, exactly as before — but it still - * lives inside this one shell so the sticky/safe-area/zinc bar is never copy-pasted. */ + /** Full-width takeover of the header row (the pane's find bar). */ override?: ReactNode; } -// The single header shell every screen mounts: the sticky, safe-area-aware zinc bar with the Collie -// mark on the left, an optional route breadcrumb in the middle, and the caller's right cluster. The -// mark's connection animation is baked in here (not a slot), so no caller can forget it: it gallops on -// sustained trouble and rests muted once lost, computed from the SAME shared clock as the top -// ConnectionBanner so the two never diverge. A healthy header is calm — just the mark + the caller's -// own items (switcher/badge + gear). +// The shared header shell. Loading animation and degraded treatment are explicit independent inputs: +// neither invents a connection state, and only route loader outcomes decide whether data is degraded. export function AppHeader({ - bridge, - error, - stalled, + loading = false, + degraded = false, onHome, wordmark, children, @@ -57,22 +42,12 @@ export function AppHeader({ rightTrail, override, }: AppHeaderProps) { - // The same two shared-clock signals the ConnectionBanner reads, so the dog and the bar agree by - // construction: gallop while troubled (≥4s not-live), rest muted once lost (≥15s, latched). - const connecting = isConnecting({ bridge, error, stalled }); - const trouble = useConnectionTrouble(connecting); - const lost = useConnectionLost(connecting); return (
{override ?? ( <> - - {/* Center region: the breadcrumb (or, on the dashboard/space, an empty flex-1 spacer that - pushes the right cluster to the edge). min-w-0 so the breadcrumb truncates when tight. */} +
{children}
- {/* gap-1, not gap-3: the icon buttons now carry their own 12px of padding to reach 44px, - so a 12px gap on top of that reads as a gulf. 4px keeps the apparent spacing between - icons close to what it was. */}
{rightLead} {rightTrail} @@ -92,11 +67,6 @@ export function SettingsGear({ session }: { session?: string }) { type="button" onClick={() => navigate(settingsPath(session))} aria-label="Settings" - // A real 44px box, NOT padding pulled back by a negative margin. The negative-margin trick - // keeps icons visually tight but lets adjacent boxes overlap (two -m-3 buttons pull 24px - // against a 12px gap, so a neighbour steals 12px of this one's hit area) and drags the last - // one past the header's padding into document overflow. Costs horizontal room, which the - // breadcrumb absorbs — it already truncates by design. className="grid size-11 place-items-center text-muted-foreground transition-colors hover:text-foreground" > diff --git a/web/src/components/collie-home.test.tsx b/web/src/components/collie-home.test.tsx index 873e332a..3ddbc461 100644 --- a/web/src/components/collie-home.test.tsx +++ b/web/src/components/collie-home.test.tsx @@ -6,44 +6,28 @@ import { CollieHome } from "./collie-home"; describe("CollieHome", () => { it("returns home when tapped", async () => { const onHome = vi.fn(); - render(); + render(); await userEvent.click(screen.getByRole("button", { name: "Collie home" })); expect(onHome).toHaveBeenCalledOnce(); }); - it("shows the static app icon at rest and the galloping sprite once troubled", () => { - const { container, rerender } = render(); - // Rest = the original app icon, no gallop sprite mounted. + it("uses generic loading animation and independent static degraded treatment", () => { + const { container, rerender } = render(); expect(container.querySelector(".dog-gallop")).toBeNull(); - expect(container.querySelector("img")).toHaveAttribute("src", "/favicon.svg"); - rerender(); - // Sustained trouble = the animated sprite replaces the static icon. - expect(container.querySelector("img")).toBeNull(); + + rerender(); expect(container.querySelector(".dog-gallop")).toHaveClass("dog-gallop--running"); - }); + expect(screen.getByRole("button", { name: "Collie home" })).toBeInTheDocument(); - it("rests on the muted static icon (never a frozen sprite) once the outage escalates to lost", () => { - // Galloping = "still trying"; once the reconnect gives up (lost) the sprite is gone entirely. It is - // replaced by the STATIC app icon, muted — not a paused gallop frame, whose full-stretch mid-stride - // pose looked "stuck mid-run" (the exact complaint). - const { container } = render(); + rerender(); expect(container.querySelector(".dog-gallop")).toBeNull(); - const icon = container.querySelector("img"); - expect(icon).toHaveAttribute("src", "/favicon.svg"); - expect(icon?.className).toMatch(/grayscale/); - expect(screen.getByRole("button", { name: "Collie home — not connected" })).toBeInTheDocument(); - }); - - it("gallops while troubled but NOT yet lost", () => { - const { container } = render(); - expect(container.querySelector(".dog-gallop")).toHaveClass("dog-gallop--running"); - expect(screen.getByRole("button", { name: "Collie home — reconnecting" })).toBeInTheDocument(); + expect(container.querySelector("img")?.parentElement).toHaveClass("grayscale"); }); it("shows the wordmark only when asked", () => { - const { rerender } = render(); + const { rerender } = render(); expect(screen.queryByText("Collie")).toBeNull(); - rerender(); + rerender(); expect(screen.getByText("Collie")).toBeInTheDocument(); }); }); diff --git a/web/src/components/collie-home.tsx b/web/src/components/collie-home.tsx index b67ae10c..796d521b 100644 --- a/web/src/components/collie-home.tsx +++ b/web/src/components/collie-home.tsx @@ -1,61 +1,46 @@ -import { cn } from "@/lib/utils"; import { DogGallop } from "@/components/dog-gallop"; +import { cn } from "@/lib/utils"; interface CollieHomeProps { /** Return to the dashboard. */ onHome?: () => void; - /** The connection has been not-live for a sustained beat (useConnectionTrouble, ≥4s) — run the - * gallop sprite. Below that (healthy, or a single slow poll) the static app icon shows: the 4s - * delay is the flicker fix, so a normal polling hiccup never kicks the dog into a run. */ - trouble: boolean; - /** The outage has passed the escalation threshold (useConnectionLost, ≥15s). The mark stops galloping - * and rests on the static app icon, muted — a galloping mark that never stops reads as "still trying" - * when we've in fact given up; the muted icon says "not connected" at a glance, matching the boot - * splash. (Never a gallop rest-frame — that full-stretch pose looks frozen mid-run.) */ - lost?: boolean; - /** Show the "Collie" wordmark beside the mark (dashboard header). Omit inside a pane to save space. */ + /** Generic route loading animation only; it makes no freshness or connection claim. */ + loading?: boolean; + /** Static treatment for a degraded root snapshot. */ + degraded?: boolean; + /** Show the "Collie" wordmark beside the mark (dashboard header). */ wordmark?: boolean; className?: string; } -// The single, shared Collie mark: brand + home button + connection loader in one, so the top-left of -// every screen means the same thing. At rest it's the familiar static app icon (favicon.svg); once the -// connection has been not-live for a sustained beat (`trouble`) it springs into the galloping sprite — -// until the outage escalates (`lost`), when it drops the gallop and rests on the SAME static icon, -// muted, then settles back to the full-color icon once live. The rest state is always the static icon, -// never a paused sprite: a gallop strip's rest frame is a full-stretch mid-stride pose that reads as -// frozen mid-run. Tapping it returns to the dashboard. The dashboard shows the "Collie" wordmark too; -// inside a pane the mark stands alone (the breadcrumb carries the context). Both headers render THIS -// component — the consistency is structural, not a convention two files have to keep agreeing on. -export function CollieHome({ onHome, trouble, lost = false, wordmark = false, className }: CollieHomeProps) { - const gallop = trouble && !lost; +// The shared brand/home mark. Generic loading may animate it; degraded root data is a separate, +// static visual treatment. The accessible name stays stable because neither state diagnoses a network. +export function CollieHome({ + onHome, + loading = false, + degraded = false, + wordmark = false, + className, +}: CollieHomeProps) { return ( - ) : voice.phase === "requesting" || voice.phase === "transcribing" ? ( + ) : voice.phase === "requesting" || voice.phase === "finalizing" || voice.phase === "processing" ? ( diff --git a/web/src/components/connection-banner.test.tsx b/web/src/components/connection-banner.test.tsx deleted file mode 100644 index 05056205..00000000 --- a/web/src/components/connection-banner.test.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { useState } from "react"; -import { act, fireEvent, render, screen } from "@testing-library/react"; -import { createMemoryRouter, RouterProvider } from "react-router"; - -import { ConnectionBanner, EXIT_MS, GREEN_MS } from "./connection-banner"; - -// Drive the two shared-clock thresholds directly so the amber→red→green STATE MACHINE can be tested -// without burning real seconds; the 4s/15s wall-clock lockstep itself is proven in -// use-connection-lost.test.ts. The mocks ignore their arg and return the staged values. -const h = vi.hoisted(() => ({ trouble: false, lost: false })); -vi.mock("@/hooks/use-connection-lost", () => ({ - useConnectionTrouble: () => h.trouble, - useConnectionLost: () => h.lost, -})); -vi.mock("@/hooks/use-loading-stalled", () => ({ useLoadingStalled: () => false })); - -// The /api/config probe (red only) — controllable + counted, so we don't lean on MSW timing under fake -// timers. `reachable` false makes fetchConfig throw (bridge unreachable). -const cfg = vi.hoisted(() => ({ reachable: true, calls: 0 })); -vi.mock("@/lib/api", () => ({ - fetchConfig: vi.fn(async () => { - cfg.calls += 1; - if (!cfg.reachable) throw new Error("unreachable"); - return { push: false, vapidPublicKey: "" }; - }), -})); - -function setOnline(value: boolean) { - Object.defineProperty(navigator, "onLine", { configurable: true, get: () => value }); -} - -// A harness whose own state forces the banner to re-render (creating a fresh element so the mocked -// hooks are re-read) — RouterProvider re-rendered with the same static route element would bail out. -let rerenderBanner: () => void = () => {}; - -function renderBanner( - props: { bridge?: "connected" | "disconnected"; error?: boolean; authError?: boolean } = {}, -) { - function Harness() { - const [, setN] = useState(0); - rerenderBanner = () => setN((n) => n + 1); - return ( - - ); - } - const router = createMemoryRouter([{ path: "/", element: }]); - return render(); -} - -beforeEach(() => { - vi.useFakeTimers(); - h.trouble = false; - h.lost = false; - cfg.reachable = true; - cfg.calls = 0; - setOnline(true); -}); -afterEach(() => { - vi.useRealTimers(); - setOnline(true); -}); - -describe("ConnectionBanner — the single connection surface", () => { - it("shows the auth refusal with Reload and no connection treatment", () => { - h.trouble = true; - h.lost = true; - renderBanner({ authError: true }); - - expect(screen.getByRole("alert")).toHaveTextContent( - "Access refused. This is not a connection problem.", - ); - expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument(); - expect(screen.queryByText("Reconnecting…")).toBeNull(); - expect(screen.queryByRole("button", { name: "Retry" })).toBeNull(); - expect(document.querySelector(".animate-spin")).toBeNull(); - expect(cfg.calls).toBe(0); - }); - - // The escape hatch for an installed PWA, which has no address bar: a real link to the one path the - // service worker always passes to the network. It must stay an with a real href — a button - // with an onClick would be a same-document action the SW never sees as a navigation, which is the - // whole bug (#31). If this assertion is ever "fixed" by swapping in a Button, the PWA is bricked - // again behind a refused session and nothing else will fail. - it("offers a real link to the reserved proxy path, not a click handler", () => { - renderBanner({ authError: true }); - - const signIn = screen.getByRole("link", { name: "Sign in" }); - expect(signIn).toHaveAttribute("href", "/auth/"); - }); - - it("renders nothing while healthy — no bar at all", () => { - renderBanner({ bridge: "connected" }); - expect(screen.queryByRole("status")).toBeNull(); - expect(screen.queryByRole("alert")).toBeNull(); - }); - - it("fades in amber 'Reconnecting…' on sustained trouble — ambient, no Retry button", () => { - h.trouble = true; - renderBanner(); - const row = screen.getByRole("status"); - expect(row).toHaveTextContent("Reconnecting…"); - expect(row.className).toMatch(/bg-status-working/); // amber = checking - expect(screen.queryByRole("button", { name: /retry/i })).toBeNull(); // ambient → no actions - }); - - it("escalates to a red alert with Retry + Reload once lost, naming Herdr when the bridge answers", async () => { - h.trouble = true; - h.lost = true; - cfg.reachable = true; // the config probe succeeds → the bridge is up, so Herdr is the outage - renderBanner(); - await act(async () => {}); // flush the probe microtask - const row = screen.getByRole("alert"); - expect(row.className).toMatch(/bg-status-blocked/); // red = failed - expect(row).toHaveTextContent("Herdr is down on the host"); - expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /reload/i })).toBeInTheDocument(); - }); - - it("says 'Offline' in red when the probe fails AND the browser reports offline", async () => { - h.lost = true; - cfg.reachable = false; - setOnline(false); - renderBanner(); - await act(async () => {}); - expect(screen.getByText("Offline — can't reach Collie")).toBeInTheDocument(); - expect(screen.getByRole("alert").className).toMatch(/bg-status-blocked/); // offline is always red - }); - - it("says 'Can't reach Collie' when the probe fails but the browser still reports online", async () => { - h.lost = true; - cfg.reachable = false; - setOnline(true); - renderBanner(); - await act(async () => {}); - expect(screen.getByText("Can't reach Collie")).toBeInTheDocument(); - }); - - it("Retry re-probes the bridge", async () => { - h.lost = true; - renderBanner(); - await act(async () => {}); - expect(cfg.calls).toBe(1); // probed once when it appeared - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: /retry/i })); - }); - expect(cfg.calls).toBe(2); // Retry ran a fresh probe - }); - - it("is one crisp, non-wrapping row (text-xs, a single truncating flex-1 copy span)", async () => { - h.lost = true; - renderBanner(); - await act(async () => {}); - const row = screen.getByRole("alert"); - expect(row.className).toMatch(/text-xs/); - expect(row.className).not.toMatch(/flex-wrap/); - expect(row.querySelector("span.truncate.flex-1")).not.toBeNull(); - }); - - it("flashes green 'Connected' only after a visible bar recovers, then collapses and unmounts", () => { - h.trouble = true; - renderBanner(); - expect(screen.getByText("Reconnecting…")).toBeInTheDocument(); - - // Recover: the signals go healthy → because a bar WAS visible, a green confirmation appears. - h.trouble = false; - act(() => rerenderBanner()); - const green = screen.getByRole("status"); - expect(green).toHaveTextContent("Connected"); - expect(green.className).toMatch(/bg-status-done/); // green = established - - // It lingers ~1.8s, then the row collapses and the DOM node unmounts (delayed-unmount exit). - act(() => vi.advanceTimersByTime(GREEN_MS)); - expect(screen.getByText("Connected")).toBeInTheDocument(); // still there, collapsing - act(() => vi.advanceTimersByTime(EXIT_MS)); - expect(screen.queryByText("Connected")).toBeNull(); - expect(screen.queryByRole("status")).toBeNull(); - }); - - it("shows nothing on a blip that never reached trouble — green needs a visible bar first", () => { - renderBanner({ bridge: "connected" }); - // Never troubled → never showed a bar → a later 'recovery' re-render must not flash green. - act(() => rerenderBanner()); - act(() => vi.advanceTimersByTime(GREEN_MS + EXIT_MS)); - expect(screen.queryByText("Connected")).toBeNull(); - expect(screen.queryByRole("status")).toBeNull(); - }); -}); diff --git a/web/src/components/connection-banner.tsx b/web/src/components/connection-banner.tsx deleted file mode 100644 index cc8b4b30..00000000 --- a/web/src/components/connection-banner.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useRevalidator } from "react-router"; -import { - CheckCircle2, - Loader2, - LogIn, - Plug, - RefreshCw, - RotateCw, - TriangleAlert, - WifiOff, -} from "lucide-react"; - -import { Button, buttonVariants } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { PROXY_AUTH_PATH } from "@/lib/sw-routes"; -import { useConnectionLost, useConnectionTrouble } from "@/hooks/use-connection-lost"; -import { useLoadingStalled } from "@/hooks/use-loading-stalled"; -import { useOnline } from "@/hooks/use-online"; -import { isConnecting } from "@/lib/connection"; -import * as api from "@/lib/api"; -import type { BridgeStatus } from "@/lib/types"; - -interface ConnectionBannerProps { - /** Herdr link from the last snapshot (undefined before the first successful poll). */ - bridge: BridgeStatus | undefined; - /** The last snapshot fetch failed (stale data on screen). */ - error: boolean; - /** The failed snapshot request was rejected with HTTP 401 or 403. */ - authError: boolean; -} - -// The result of the /api/config probe (which never touches Herdr): "unknown" until it resolves, -// "reachable" = the bridge answered (so the herd link is what's down), "unreachable" = the bridge -// itself couldn't be reached. Only ever run while RED, to name the cause. -type Probe = "unknown" | "reachable" | "unreachable"; - -// The three color-coded states, plus null = nothing. green = established, amber = checking, red = failed. -type Tone = "amber" | "red" | "green"; - -// How long the "Connected" confirmation lingers after a visible bar recovers, then it exits. -export const GREEN_MS = 1_800; -// The collapse/fade before the row unmounts — matches the CSS transition duration below so the DOM -// node lives exactly as long as the exit animation (standard delayed-unmount). -export const EXIT_MS = 200; - -// The ONE connection surface: a single, thin, animated bar mounted once in RootLayout (in-flow above -// the route, a sibling of UpdateAvailableBanner) that is the app's entire connection UI — the header -// pill is gone. It fades in only on SUSTAINED trouble, escalates from amber → red on a real outage, -// flashes green on recovery, and otherwise renders nothing. It reads the SAME two shared-clock signals -// the header dog does (useConnectionTrouble at 4s, useConnectionLost at 15s), so bar and dog can never -// disagree; `connecting` is poll-truth (isConnecting) — navigator.onLine is COPY-only (it picks the -// red cause), never a gate. Threshold lockstep with the shared clock is proven in use-connection-lost; -// here we own the amber→red→green state machine and the smooth mount/unmount. -export function ConnectionBanner({ bridge, error, authError }: ConnectionBannerProps) { - if (authError) return ; - return ; -} - -// A refusal is not an outage, so it gets its own surface ahead of the connection state machine: no -// probe, no reconnect spinner, no escalation clock. The copy stays deliberately non-specific about -// the cause. The flag covers 401 and 403 alike, and a 403 can equally mean "this device is not -// allowlisted", "host not allowed" or "cross-origin rejected", so naming any one of them would be -// wrong more often than right. What the operator needs here is the one fact the old behaviour hid: -// this is not the network. -// -// Reload alone is NOT enough to reach a fronting proxy, which is what this banner used to claim. In -// an installed PWA the service worker answers every navigation it owns — a reload included — from -// the precached app shell, so a reload re-renders the same refused UI and never touches the proxy. -// "Sign in" is the escape: a real navigation to the one path the SW always passes to the network -// (lib/sw-routes). An , not a button, so it is an ordinary navigation the SW sees as such — and -// so it still works if React is wedged. Reload stays alongside it, since a merely stale session on -// an already-signed-in device recovers without leaving the app. -function AuthErrorBanner() { - return ( - - ); -} - -function ConnectionStateBanner({ bridge, error }: Omit) { - const stalled = useLoadingStalled(); - const connecting = isConnecting({ bridge, error, stalled }); - const trouble = useConnectionTrouble(connecting); - const lost = useConnectionLost(connecting); - - // What the live signals want on screen right now — red wins over amber; null = healthy (or a blip - // that never reached trouble). Green is NOT derived here: it's a timed confirmation the state machine - // adds only when a VISIBLE bar recovers, so it can't come from the instantaneous signals. - const activeTone: Exclude | null = lost ? "red" : trouble ? "amber" : null; - - // The rendered tone. Adds the recovery "connected" flash on top of the live signals. - const [tone, setTone] = useState(null); - // Has an amber/red bar actually been shown since the last time we went hidden? Gates the green flash - // so a sub-trouble blip (which never showed a bar) recovers silently. - const shownBar = useRef(false); - - useEffect(() => { - if (activeTone) { - shownBar.current = true; - setTone(activeTone); - return; - } - // activeTone === null → recovered, or never troubled. - if (!shownBar.current) { - setTone(null); // a blip that never showed a bar → show nothing. - return; - } - // Recovery FROM a visible bar → a brief green "connected", then hide. - shownBar.current = false; - setTone("green"); - const id = window.setTimeout(() => setTone(null), GREEN_MS); - return () => clearTimeout(id); - }, [activeTone]); - - // Delayed-unmount + enter/exit animation. `present` = there's a tone to show; we keep the row - // rendered through the collapse so it animates OUT, then unmount. `open` drives the expanded class, - // flipped one tick AFTER mount so the browser transitions from the collapsed initial state in. - const present = tone !== null; - const [rendered, setRendered] = useState(present); - const [open, setOpen] = useState(false); - useEffect(() => { - if (present) { - setRendered(true); - const id = window.setTimeout(() => setOpen(true), 0); - return () => clearTimeout(id); - } - setOpen(false); - const id = window.setTimeout(() => setRendered(false), EXIT_MS); - return () => clearTimeout(id); - }, [present]); - - // The last real tone, held so the row keeps its copy/tint while collapsing after `tone` → null. - const shownToneRef = useRef("amber"); - if (tone) shownToneRef.current = tone; - const shownTone = shownToneRef.current; - - // Probe /api/config only while RED, to tell "bridge unreachable" from "bridge up, Herdr down". Amber - // (ambient) and green (a success flash) never probe. Reset when we leave red so a later outage re-probes. - const online = useOnline(); - const revalidator = useRevalidator(); - const [probe, setProbe] = useState("unknown"); - const [retrying, setRetrying] = useState(false); - - const runProbe = useCallback(async () => { - try { - await api.fetchConfig(); - setProbe("reachable"); - } catch { - setProbe("unreachable"); - } - }, []); - - useEffect(() => { - if (!lost) { - setProbe("unknown"); - return; - } - void runProbe(); - }, [lost, runProbe]); - - if (!rendered) return null; - - // Recovery (a successful poll) flips the signals → tone → hidden on its own, no reload. Retry just - // nudges that along: revalidate the snapshot and re-run the probe. - async function onRetry() { - setRetrying(true); - revalidator.revalidate(); - await runProbe(); - setRetrying(false); - } - - const view = resolveView(shownTone, online, probe); - - return ( - // Outer grid collapses 0fr → 1fr (an in-flow height animation the layout below rides), fading with - // opacity; the inner wrapper clips the content while it's collapsed. Snaps under reduced motion. -
-
-
- - {/* One truncating, flex-1 span — the row can never wrap to a second line, whatever the copy. */} - {view.copy} - {/* Actions only in red — amber is ambient (no buttons), green is a passing confirmation. */} - {shownTone === "red" && ( - <> - - - - )} -
-
-
- ); -} - -// Copy + tint + icon per tone. Green/amber are fixed; red names the cause — the bridge answering means -// Herdr is the outage, otherwise onLine decides between a true offline drop and an unreachable Collie. -function resolveView(tone: Tone, online: boolean, probe: Probe) { - if (tone === "green") { - return { copy: "Connected", Icon: CheckCircle2, row: TINT.done.row, icon: TINT.done.icon } as const; - } - if (tone === "amber") { - // Static Plug (no spinner) — the galloping dog carries the motion, and a spinner would fight - // prefers-reduced-motion. Ambient by design. - return { copy: "Reconnecting…", Icon: Plug, row: TINT.working.row, icon: TINT.working.icon } as const; - } - const cause = - probe === "reachable" - ? { copy: "Herdr is down on the host", Icon: TriangleAlert } - : probe === "unreachable" && !online - ? { copy: "Offline — can't reach Collie", Icon: WifiOff } - : { copy: "Can't reach Collie", Icon: TriangleAlert }; - return { copy: cause.copy, Icon: cause.Icon, row: TINT.blocked.row, icon: TINT.blocked.icon } as const; -} - -const TINT = { - done: { row: "border-status-done/40 bg-status-done/15", icon: "text-status-done" }, - working: { row: "border-status-working/40 bg-status-working/15", icon: "text-status-working" }, - blocked: { row: "border-status-blocked/40 bg-status-blocked/15", icon: "text-status-blocked" }, -} as const; diff --git a/web/src/components/connection-info.test.tsx b/web/src/components/connection-info.test.tsx index 310d8c0b..53cc5a29 100644 --- a/web/src/components/connection-info.test.tsx +++ b/web/src/components/connection-info.test.tsx @@ -10,7 +10,7 @@ describe("ConnectionInfo — device access row", () => { it("reads 'Not enforced' when the feature is off (no device on the snapshot)", () => { render(); expect(screen.getByText("Not enforced")).toBeInTheDocument(); - expect(screen.getByText("Connected")).toBeInTheDocument(); + expect(screen.getByText("Available")).toBeInTheDocument(); }); it("shows full access with the device id for an authorised device", () => { @@ -34,9 +34,10 @@ describe("ConnectionInfo — device access row", () => { expect(screen.getByText(/full access \(local\)/i)).toBeInTheDocument(); }); - it("shows a connecting state and the server build when provided", () => { - render(); - expect(screen.getByText("Connecting…")).toBeInTheDocument(); + it("does not make current bridge claims from a stale snapshot", () => { + render(); + expect(screen.getByText("Last update unavailable")).toBeInTheDocument(); + expect(screen.queryByText("Herdr unavailable")).toBeNull(); expect(screen.getByText("abc1234")).toBeInTheDocument(); }); }); diff --git a/web/src/components/connection-info.tsx b/web/src/components/connection-info.tsx index 0e1f0824..fc0dc800 100644 --- a/web/src/components/connection-info.tsx +++ b/web/src/components/connection-info.tsx @@ -11,15 +11,18 @@ import type { BridgeStatus, DeviceAuth } from "@/lib/types"; // isn't X working" triage. export function ConnectionInfo({ bridge, + snapshotStale = false, device, build, }: { bridge: BridgeStatus | undefined; + /** Cached bridge state must not be presented as current. */ + snapshotStale?: boolean; device: DeviceAuth | undefined; /** Build id the bridge reports it's serving (from /api/config); omitted while loading/offline. */ build?: string; }) { - const b = bridgeLabel(bridge); + const b = bridgeLabel(bridge, snapshotStale); const d = deviceLabel(device); const secure = typeof window !== "undefined" && window.isSecureContext; const host = typeof window !== "undefined" ? window.location.host : "—"; @@ -59,10 +62,14 @@ function Row({ label, children }: { label: string; children: ReactNode }) { ); } -function bridgeLabel(bridge: BridgeStatus | undefined): { text: string; tone: string } { - if (bridge === "connected") return { text: "Connected", tone: "text-status-done" }; - if (bridge === "disconnected") return { text: "Herdr offline", tone: "text-status-working" }; - return { text: "Connecting…", tone: "text-muted-foreground" }; +function bridgeLabel( + bridge: BridgeStatus | undefined, + snapshotStale: boolean, +): { text: string; tone: string } { + if (snapshotStale) return { text: "Last update unavailable", tone: "text-muted-foreground" }; + if (bridge === "connected") return { text: "Available", tone: "text-status-done" }; + if (bridge === "disconnected") return { text: "Herdr unavailable", tone: "text-status-working" }; + return { text: "Not available", tone: "text-muted-foreground" }; } // Mirrors the deviceAuth matrix on the bridge (see bridge/server.ts). "Local" = an authorised request diff --git a/web/src/components/dog-gallop.tsx b/web/src/components/dog-gallop.tsx index e041f7c2..7592fff9 100644 --- a/web/src/components/dog-gallop.tsx +++ b/web/src/components/dog-gallop.tsx @@ -15,8 +15,8 @@ interface DogGallopProps { // The Collie mascot doubling as the app's activity indicator: a 6-frame gallop sprite // (public/dog-gallop.png — a 768×128 strip of six 128px cells, transparent background) stepped // through with a pure-CSS steps(6) animation. No JS timers, no layout thrash, GPU-cheap — the whole -// cycle is one repainting background-position. It gallops while the app is loading/reconnecting -// (`running`); `prefers-reduced-motion` pins it to frame 0 (see index.css). `--dog-size` drives both +// cycle is one repainting background-position. It gallops for generic loading (`running`); +// `prefers-reduced-motion` pins it to frame 0 (see index.css). `--dog-size` drives both // the box and the sprite scale, so one length keeps them in lockstep at any placement. // // NOTE: the `running={false}` rest frame is frame 0 of the gallop strip — a full-stretch mid-stride diff --git a/web/src/components/freshness-banner.test.tsx b/web/src/components/freshness-banner.test.tsx new file mode 100644 index 00000000..d88b5176 --- /dev/null +++ b/web/src/components/freshness-banner.test.tsx @@ -0,0 +1,143 @@ +import { useState } from "react"; +import { act, render, screen } from "@testing-library/react"; +import { createMemoryRouter, RouterProvider } from "react-router"; + +import { EXIT_MS, FreshnessBanner, PaneFreshnessNotice, RESUMED_MS } from "./freshness-banner"; + +type RootFreshness = { + bridge: "connected" | "disconnected" | undefined; + snapshotStale: boolean; + snapshotAuthError: boolean; + snapshotHasLastGood: boolean; +}; + +let setFreshness: (next: RootFreshness) => void = () => {}; + +function renderBanner(initial: Partial = {}) { + function Harness() { + const [freshness, set] = useState({ + bridge: "connected", + snapshotStale: false, + snapshotAuthError: false, + snapshotHasLastGood: true, + ...initial, + }); + setFreshness = set; + return ; + } + const router = createMemoryRouter([{ path: "/", element: }]); + return render(); +} + +describe("FreshnessBanner", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("gives root auth precedence and provides a real sign-in escape", () => { + renderBanner({ snapshotStale: true, snapshotAuthError: true, snapshotHasLastGood: true }); + + expect(screen.getByRole("alert")).toHaveTextContent("Access refused."); + expect(screen.getByRole("link", { name: "Sign in" })).toHaveAttribute("href", "/auth/"); + expect(screen.queryByText(/Live updates delayed/)).toBeNull(); + }); + + it("distinguishes cold and cached stale root snapshots", () => { + const { rerender } = renderBanner({ snapshotStale: true, snapshotHasLastGood: false }); + expect(screen.getByRole("status")).toHaveTextContent("Live updates delayed."); + expect(screen.queryByText(/showing the last update/i)).toBeNull(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + + rerender( + + ), + }, + ])} + />, + ); + expect(screen.getByText(/showing the last update/i)).toBeInTheDocument(); + }); + + it("shows Herdr unavailable only from a fresh root snapshot", () => { + renderBanner({ bridge: "disconnected" }); + expect(screen.getByRole("alert")).toHaveTextContent("Herdr unavailable."); + + act(() => + setFreshness({ + bridge: "disconnected", + snapshotStale: true, + snapshotAuthError: false, + snapshotHasLastGood: true, + }), + ); + expect(screen.getByRole("status")).toHaveTextContent("Live updates delayed"); + expect(screen.queryByText("Herdr unavailable.")).toBeNull(); + }); + + it("announces resumed updates only after a visible cached stale root recovers", () => { + renderBanner({ snapshotStale: true, snapshotHasLastGood: true }); + expect(screen.getByText(/Live updates delayed/)).toBeInTheDocument(); + + act(() => + setFreshness({ + bridge: "connected", + snapshotStale: false, + snapshotAuthError: false, + snapshotHasLastGood: true, + }), + ); + expect(screen.getByText("Live updates resumed")).toBeInTheDocument(); + + act(() => vi.advanceTimersByTime(RESUMED_MS)); + act(() => vi.advanceTimersByTime(EXIT_MS)); + expect(screen.queryByText("Live updates resumed")).toBeNull(); + }); + + it("does not announce a cold failure recovering", () => { + renderBanner({ snapshotStale: true, snapshotHasLastGood: false }); + act(() => + setFreshness({ + bridge: "connected", + snapshotStale: false, + snapshotAuthError: false, + snapshotHasLastGood: true, + }), + ); + expect(screen.queryByText("Live updates resumed")).toBeNull(); + }); +}); + +describe("PaneFreshnessNotice", () => { + it("gives pane auth precedence and retains the sign-in escape", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("Pane access refused."); + expect(screen.getByRole("link", { name: "Sign in" })).toHaveAttribute("href", "/auth/"); + expect(screen.queryByText(/Pane output delayed/)).toBeNull(); + }); + + it("distinguishes cold and cached stale pane output", () => { + const { rerender } = render( + , + ); + expect(screen.getByRole("status")).toHaveTextContent("Pane output delayed."); + expect(screen.queryByText(/showing the last update/i)).toBeNull(); + + rerender(); + expect(screen.getByText(/Pane output delayed — showing the last update/i)).toBeInTheDocument(); + }); + + it("renders no pane row while the pane is fresh", () => { + render(); + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.queryByRole("alert")).toBeNull(); + }); +}); diff --git a/web/src/components/freshness-banner.tsx b/web/src/components/freshness-banner.tsx new file mode 100644 index 00000000..059294cc --- /dev/null +++ b/web/src/components/freshness-banner.tsx @@ -0,0 +1,209 @@ +import { useEffect, useRef, useState } from "react"; +import { CheckCircle2, LogIn, RefreshCw, RotateCw, TriangleAlert } from "lucide-react"; +import { useRevalidator } from "react-router"; + +import { Button, buttonVariants } from "@/components/ui/button"; +import { PROXY_AUTH_PATH } from "@/lib/sw-routes"; +import { cn } from "@/lib/utils"; +import type { BridgeStatus } from "@/lib/types"; + +interface FreshnessBannerProps { + /** Herdr dependency state from this root snapshot only. */ + bridge: BridgeStatus | undefined; + snapshotStale: boolean; + snapshotAuthError: boolean; + snapshotHasLastGood: boolean; +} + +type View = + | { kind: "auth"; copy: string; tone: "blocked" } + | { kind: "stale"; copy: string; tone: "working" } + | { kind: "herdr"; copy: string; tone: "blocked" } + | { kind: "resumed"; copy: string; tone: "done" }; + +/** How long a genuine root-snapshot recovery confirmation remains visible. */ +export const RESUMED_MS = 1_800; +/** Matches the collapse/fade transition so the row unmounts after its exit animation. */ +export const EXIT_MS = 200; + +// Root snapshot freshness has one top-level surface. It is intentionally driven only by the root +// loader's current result: pane reads, generic navigation loading, and voice work never alter it. +export function FreshnessBanner({ + bridge, + snapshotStale, + snapshotAuthError, + snapshotHasLastGood, +}: FreshnessBannerProps) { + const revalidator = useRevalidator(); + const staleCached = snapshotStale && !snapshotAuthError && snapshotHasLastGood; + const wasVisibleStale = useRef(false); + const [resumed, setResumed] = useState(false); + + useEffect(() => { + const recovered = + wasVisibleStale.current && + !snapshotStale && + !snapshotAuthError && + bridge === "connected"; + wasVisibleStale.current = staleCached; + + if (!recovered) { + if (snapshotStale || snapshotAuthError || bridge !== "connected") setResumed(false); + return; + } + + setResumed(true); + const id = window.setTimeout(() => setResumed(false), RESUMED_MS); + return () => clearTimeout(id); + }, [bridge, snapshotAuthError, snapshotStale, staleCached]); + + const view: View | null = snapshotAuthError + ? { kind: "auth", copy: "Access refused.", tone: "blocked" } + : snapshotStale + ? { + kind: "stale", + copy: snapshotHasLastGood + ? "Live updates delayed — showing the last update." + : "Live updates delayed.", + tone: "working", + } + : bridge === "disconnected" + ? { kind: "herdr", copy: "Herdr unavailable.", tone: "blocked" } + : resumed + ? { kind: "resumed", copy: "Live updates resumed", tone: "done" } + : null; + + return revalidator.revalidate()} />; +} + +/** Pane-specific freshness is rendered at the pane, not promoted to a global status store. */ +export function PaneFreshnessNotice({ + paneStale, + paneAuthError, + paneHasLastGood, +}: { + paneStale: boolean; + paneAuthError: boolean; + paneHasLastGood: boolean; +}) { + if (paneAuthError) { + return ( +
+ + Pane access refused. + +
+ ); + } + if (!paneStale) return null; + + return ( +
+ + + {paneHasLastGood + ? "Pane output delayed — showing the last update." + : "Pane output delayed."} + +
+ ); +} + +function AnimatedFreshnessRow({ view, onRetry }: { view: View | null; onRetry: () => void }) { + const present = view !== null; + const [rendered, setRendered] = useState(present); + const [open, setOpen] = useState(false); + const shownView = useRef(view ?? { kind: "stale", copy: "", tone: "working" }); + if (view) shownView.current = view; + + useEffect(() => { + if (present) { + setRendered(true); + const id = window.setTimeout(() => setOpen(true), 0); + return () => clearTimeout(id); + } + setOpen(false); + const id = window.setTimeout(() => setRendered(false), EXIT_MS); + return () => clearTimeout(id); + }, [present]); + + if (!rendered) return null; + const shown = shownView.current; + const Icon = shown.kind === "resumed" ? CheckCircle2 : TriangleAlert; + const tint = TINT[shown.tone]; + const needsRetry = shown.kind === "stale" || shown.kind === "herdr"; + + return ( +
+
+
+ + {shown.copy} + {shown.kind === "auth" ? ( + <> + + + + ) : needsRetry ? ( + + ) : null} +
+
+
+ ); +} + +function SignInLink() { + return ( + + + Sign in + + ); +} + +function ReloadButton() { + return ( + + ); +} + +const TINT = { + done: { row: "border-status-done/40 bg-status-done/15", icon: "text-status-done" }, + working: { row: "border-status-working/40 bg-status-working/15", icon: "text-status-working" }, + blocked: { row: "border-status-blocked/40 bg-status-blocked/15", icon: "text-status-blocked" }, +} as const; diff --git a/web/src/components/prompt-select-block.test.tsx b/web/src/components/prompt-select-block.test.tsx index e8d2fe9c..ad1d774e 100644 --- a/web/src/components/prompt-select-block.test.tsx +++ b/web/src/components/prompt-select-block.test.tsx @@ -96,6 +96,7 @@ describe("submitPromptOption — race guard + per-family keystroke recipe", () = requestedLines: 600, detectedRevision: 7, agent: "claude", + canWrite: () => true, prompt: model, option: model.options[0]!, }); @@ -121,6 +122,7 @@ describe("submitPromptOption — race guard + per-family keystroke recipe", () = requestedLines: 600, detectedRevision: 3, agent: "claude", + canWrite: () => true, prompt: model, option: model.options[0]!, }); @@ -141,6 +143,7 @@ describe("submitPromptOption — race guard + per-family keystroke recipe", () = requestedLines: 600, detectedRevision: 7, agent: "claude", + canWrite: () => true, prompt: model, option: model.options[0]!, }); @@ -161,6 +164,7 @@ describe("submitPromptOption — race guard + per-family keystroke recipe", () = requestedLines: 600, detectedRevision: 7, agent: "claude", + canWrite: () => true, prompt: model, option: model.options[0]!, }); @@ -184,6 +188,7 @@ describe("submitPromptOption — race guard + per-family keystroke recipe", () = requestedLines: 600, detectedRevision: 42, agent: "claude", + canWrite: () => true, prompt: model, option: model.options[1]!, }); @@ -208,6 +213,7 @@ describe("submitPromptOption — race guard + per-family keystroke recipe", () = requestedLines: 600, detectedRevision: 0, agent: "claude", + canWrite: () => true, prompt: model, option: model.options[1]!, }); @@ -231,6 +237,7 @@ describe("submitPromptOption — race guard + per-family keystroke recipe", () = requestedLines: 600, detectedRevision: 42, agent: "claude", + canWrite: () => true, prompt: model, option: model.options[1]!, }); @@ -252,6 +259,7 @@ describe("submitPromptOption — race guard + per-family keystroke recipe", () = requestedLines: 600, detectedRevision: 5, agent: "claude", + canWrite: () => true, prompt: model, option: model.options[0]!, }); @@ -276,6 +284,7 @@ describe("submitPromptOption — race guard + per-family keystroke recipe", () = requestedLines: 600, detectedRevision: 5, agent: "claude", + canWrite: () => true, prompt: model, option: model.options[0]!, }); @@ -297,6 +306,7 @@ function Harness({ prompt, detectedRevision }: { prompt: PromptModel; detectedRe requestedLines: 600, detectedRevision, agent: "claude", + canWrite: () => true, prompt, option, }); @@ -381,6 +391,7 @@ describe("submitPromptOption — same-shaped successor prompt (H1)", () => { requestedLines: 600, detectedRevision: 0, agent: "claude", + canWrite: () => true, prompt: promptA, option: promptA.options[0]!, }); @@ -397,6 +408,7 @@ describe("submitPromptOption — same-shaped successor prompt (H1)", () => { requestedLines: 600, detectedRevision: 0, agent: "claude", + canWrite: () => true, prompt: promptA, option: promptA.options[0]!, }); diff --git a/web/src/components/status-badge.tsx b/web/src/components/status-badge.tsx index ed863cad..83e495cf 100644 --- a/web/src/components/status-badge.tsx +++ b/web/src/components/status-badge.tsx @@ -80,9 +80,9 @@ export function StatusBadge({ className, }: { status: AgentStatus; - /** The badge is showing the LAST snapshot's status while the connection is not live — dim it so - * frozen data doesn't read as current. No animation to remove here (the badge dot never pulses), - * so opacity alone carries it; the transition restores it instantly on recovery. */ + /** Dim a status from stale/refused root data, or a fresh root result that reports Herdr unavailable, + * so it does not read as current. The badge dot never pulses, so opacity alone carries this + * distinction; the transition restores it when current root data is available. */ stale?: boolean; className?: string; }) { diff --git a/web/src/components/update-available-banner.tsx b/web/src/components/update-available-banner.tsx index ccd7b713..642e9613 100644 --- a/web/src/components/update-available-banner.tsx +++ b/web/src/components/update-available-banner.tsx @@ -7,7 +7,7 @@ import { useSelfUpdate } from "@/lib/self-update"; // confirmed-stale but can't auto-update right now — the user has unsent work (an open composer draft, // an in-flight upload, an open action sheet) or we already auto-updated once for this build. An // in-flow row (not an overlay) that stacks above the route in RootLayout's flex column rather than -// covering the sticky header. Shares the top-band idiom with the ConnectionBanner — text-xs, one +// covering the sticky header. Shares the top-band idiom with the freshness row — text-xs, one // truncating row, safe-area top inset — so every top-of-app row reads as one consistent band. // // Mounted unconditionally so useSelfUpdate() runs the controller for its whole lifetime — the diff --git a/web/src/components/update-banner.test.tsx b/web/src/components/update-banner.test.tsx index 26005300..287739cd 100644 --- a/web/src/components/update-banner.test.tsx +++ b/web/src/components/update-banner.test.tsx @@ -62,8 +62,9 @@ function homeData(update: UpdateInfo | undefined): HomeData { snoozedUntil: null, update, transcriptionEnabled: false, - error: false, - authError: false, + snapshotStale: false, + snapshotAuthError: false, + snapshotHasLastGood: true, }; } diff --git a/web/src/components/update-check-control.test.tsx b/web/src/components/update-check-control.test.tsx index d64f9f82..f51bc691 100644 --- a/web/src/components/update-check-control.test.tsx +++ b/web/src/components/update-check-control.test.tsx @@ -35,8 +35,9 @@ function homeData(update: UpdateInfo | undefined): HomeData { snoozedUntil: null, update, transcriptionEnabled: false, - error: false, - authError: false, + snapshotStale: false, + snapshotAuthError: false, + snapshotHasLastGood: true, }; } diff --git a/web/src/components/wizard-block.test.tsx b/web/src/components/wizard-block.test.tsx index 7e6a84a8..38a40930 100644 --- a/web/src/components/wizard-block.test.tsx +++ b/web/src/components/wizard-block.test.tsx @@ -181,6 +181,7 @@ describe("submitWizardKeys — race guard (one keystroke per tap)", () => { requestedLines: 600, detectedRevision: 7, agent: "claude", + canWrite: () => true, wizard: model, keys: ["2"], }); @@ -201,6 +202,7 @@ describe("submitWizardKeys — race guard (one keystroke per tap)", () => { requestedLines: 600, detectedRevision: 7, agent: "claude", + canWrite: () => true, wizard: model, keys: ["2"], }); @@ -222,6 +224,7 @@ describe("submitWizardKeys — race guard (one keystroke per tap)", () => { requestedLines: 600, detectedRevision: 0, agent: "claude", + canWrite: () => true, wizard: model, keys: ["1"], }); @@ -242,6 +245,7 @@ describe("submitWizardKeys — race guard (one keystroke per tap)", () => { requestedLines: 600, detectedRevision: 8, agent: "claude", + canWrite: () => true, wizard: model, keys: ["1"], }); @@ -263,6 +267,7 @@ describe("submitWizardKeys — race guard (one keystroke per tap)", () => { requestedLines: 600, detectedRevision: 5, agent: "claude", + canWrite: () => true, wizard: model, keys: ["3"], }); @@ -287,6 +292,7 @@ describe("submitWizardKeys — race guard (one keystroke per tap)", () => { requestedLines: 600, detectedRevision: 5, agent: "claude", + canWrite: () => true, wizard: model, keys: ["3"], }); @@ -308,6 +314,7 @@ function Harness({ wizard, detectedRevision }: { wizard: WizardModel; detectedRe requestedLines: 600, detectedRevision, agent: "claude", + canWrite: () => true, wizard, keys, }); diff --git a/web/src/hooks/use-connection-lost.test.ts b/web/src/hooks/use-connection-lost.test.ts deleted file mode 100644 index 7e8fbb14..00000000 --- a/web/src/hooks/use-connection-lost.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; - -import { - CONNECTION_LOST_MS, - TROUBLE_MS, - useConnectionLost, - useConnectionTrouble, -} from "./use-connection-lost"; -import { __resetConnectionHealth, isLostLatched, markLive, markWake } from "@/lib/connection-health"; - -// Wall-clock derived, so fake timers (which also advance Date.now in Vitest) drive both the countdown -// and the elapsed-time comparison the hook reads. Escalation now anchors on the SHARED -// lib/connection-health store, so we re-pin its anchor to the frozen clock after useFakeTimers. -describe("useConnectionLost", () => { - beforeEach(() => { - vi.useFakeTimers(); - __resetConnectionHealth(); - }); - afterEach(() => vi.useRealTimers()); - - it("stays false while the connection is healthy", () => { - const { result } = renderHook(({ c }) => useConnectionLost(c), { - initialProps: { c: false }, - }); - expect(result.current).toBe(false); - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS * 2)); - expect(result.current).toBe(false); - }); - - it("flips true only after the threshold of continuous disconnection", () => { - const { result } = renderHook(({ c }) => useConnectionLost(c), { - initialProps: { c: true }, - }); - expect(result.current).toBe(false); // a slow moment isn't yet an outage - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS - 1)); - expect(result.current).toBe(false); - act(() => vi.advanceTimersByTime(1)); - expect(result.current).toBe(true); - }); - - it("does not trip on a brief blip that recovers before the threshold", () => { - const { result, rerender } = renderHook(({ c }) => useConnectionLost(c), { - initialProps: { c: true }, - }); - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS - 3_000)); - rerender({ c: false }); // recovered in time - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS)); - expect(result.current).toBe(false); - }); - - it("resets to false the moment the connection recovers", () => { - const { result, rerender } = renderHook(({ c }) => useConnectionLost(c), { - initialProps: { c: true }, - }); - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS)); - expect(result.current).toBe(true); - rerender({ c: false }); - expect(result.current).toBe(false); - }); - - it("honours a custom threshold", () => { - const { result } = renderHook(({ c }) => useConnectionLost(c, 5_000), { - initialProps: { c: true }, - }); - act(() => vi.advanceTimersByTime(4_999)); - expect(result.current).toBe(false); - act(() => vi.advanceTimersByTime(1)); - expect(result.current).toBe(true); - }); - - // The shared-clock guarantees — the whole point of the module store: independent consumers (the - // header pill, the outage banner, the in-pane header) read the SAME anchor, so they cannot diverge. - it("two independent consumers escalate together (shared clock — cannot diverge)", () => { - const a = renderHook(({ c }) => useConnectionLost(c), { initialProps: { c: true } }); - const b = renderHook(({ c }) => useConnectionLost(c), { initialProps: { c: true } }); - expect(a.result.current).toBe(false); - expect(b.result.current).toBe(false); - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS)); - expect(a.result.current).toBe(true); - expect(b.result.current).toBe(true); - }); - - it("a consumer mounted mid-outage escalates on the SHARED clock, not a fresh one", () => { - // This is the reproduced on-device bug: the pill remounts on a route change and, with the OLD - // per-instance clock, restarted its own 15s — sitting amber while the persistent banner had gone - // red. With the shared anchor, a consumer that appears 10s into an outage escalates WITH the rest. - const a = renderHook(({ c }) => useConnectionLost(c), { initialProps: { c: true } }); - act(() => vi.advanceTimersByTime(10_000)); - expect(a.result.current).toBe(false); - const b = renderHook(({ c }) => useConnectionLost(c), { initialProps: { c: true } }); - expect(b.result.current).toBe(false); - act(() => vi.advanceTimersByTime(5_000)); // t = 15s from outage start - expect(a.result.current).toBe(true); - expect(b.result.current).toBe(true); // did NOT restart its own clock on mount - }); - - it("a live poll (markLive) resets the escalation clock to the moment of success", () => { - const { result } = renderHook(({ c }) => useConnectionLost(c), { initialProps: { c: true } }); - act(() => vi.advanceTimersByTime(10_000)); - expect(result.current).toBe(false); - act(() => markLive()); // a good poll landed 10s in → the anchor moves to now - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS - 1)); - expect(result.current).toBe(false); // the full threshold must elapse FROM the success - act(() => vi.advanceTimersByTime(1)); - expect(result.current).toBe(true); - }); - - it("a wake (markWake) grants a fresh grace window mid-outage", () => { - const { result } = renderHook(({ c }) => useConnectionLost(c), { initialProps: { c: true } }); - act(() => vi.advanceTimersByTime(14_000)); // almost escalated - expect(result.current).toBe(false); - act(() => markWake()); // phone woke → fresh grace from here, not an instant red flash - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS - 1)); - expect(result.current).toBe(false); // the pre-wake timer would have fired; the wake pushed it back - act(() => vi.advanceTimersByTime(1)); - expect(result.current).toBe(true); - }); - - // STICKY escalation — a mid-outage app switch (visibilitychange → markWake) must NOT downgrade an - // already-red "not connected" back to amber "reconnecting…" for another window. - it("(a) once escalated, a wake keeps it lost immediately — no fresh grace on a mid-outage app switch", () => { - const { result } = renderHook(({ c }) => useConnectionLost(c), { initialProps: { c: true } }); - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS)); // escalate → latched - expect(result.current).toBe(true); - act(() => markWake()); // switch away + back mid-outage; old code reset the anchor → downgrade - expect(result.current).toBe(true); // STILL lost, in the very next sample — latch dropped the grace - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS)); // …and stays lost while it keeps failing - expect(result.current).toBe(true); - }); - - it("(b) a wake BEFORE escalation still grants fresh grace (a healthy-network resume never flashes red)", () => { - const { result } = renderHook(({ c }) => useConnectionLost(c), { initialProps: { c: true } }); - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS - 2_000)); // 13s — not yet lost, not yet latched - expect(result.current).toBe(false); - act(() => markWake()); // resume from sleep on a healthy network, before any red UI ever showed - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS - 1)); // grace restarts from the wake - expect(result.current).toBe(false); - act(() => vi.advanceTimersByTime(1)); // a full window AFTER the wake - expect(result.current).toBe(true); - }); - - it("(c) recovery via markLive clears the latch; a later wake does not resurrect the escalation", () => { - const { result } = renderHook(({ c }) => useConnectionLost(c), { initialProps: { c: true } }); - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS)); // escalate → latched - expect(result.current).toBe(true); - act(() => markLive()); // a good poll lands: freshens the anchor AND clears the latch - expect(result.current).toBe(false); // recovered immediately - act(() => markWake()); // a wake AFTER recovery must not bring red back - expect(result.current).toBe(false); - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS - 1)); // full grace still applies from recovery - expect(result.current).toBe(false); - act(() => vi.advanceTimersByTime(1)); // it CAN escalate again if failure genuinely persists - expect(result.current).toBe(true); - }); -}); - -// The 4s ambient TROUBLE threshold — the amber bar + the galloping dog. Same shared anchor as the 15s -// lost escalation, just shorter and NON-latching, so a single slow poll never flashes a bar. -describe("useConnectionTrouble", () => { - beforeEach(() => { - vi.useFakeTimers(); - __resetConnectionHealth(); - }); - afterEach(() => vi.useRealTimers()); - - it("stays false while healthy", () => { - const { result } = renderHook(({ c }) => useConnectionTrouble(c), { initialProps: { c: false } }); - act(() => vi.advanceTimersByTime(TROUBLE_MS * 4)); - expect(result.current).toBe(false); - }); - - it("flips true only after TROUBLE_MS of continuous not-live — a single slow beat isn't yet trouble", () => { - const { result } = renderHook(({ c }) => useConnectionTrouble(c), { initialProps: { c: true } }); - expect(result.current).toBe(false); - act(() => vi.advanceTimersByTime(TROUBLE_MS - 1)); - expect(result.current).toBe(false); - act(() => vi.advanceTimersByTime(1)); - expect(result.current).toBe(true); - }); - - it("never latches — observing trouble does NOT set the sticky escalation latch", () => { - const { result } = renderHook(({ c }) => useConnectionTrouble(c), { initialProps: { c: true } }); - act(() => vi.advanceTimersByTime(TROUBLE_MS)); - expect(result.current).toBe(true); - expect(isLostLatched()).toBe(false); // only the 15s lost threshold latches - // Because it never latched, a pre-lost wake still grants fresh grace: trouble drops on the wake… - act(() => markWake()); - expect(result.current).toBe(false); - act(() => vi.advanceTimersByTime(TROUBLE_MS)); // …and only returns after another full window - expect(result.current).toBe(true); - }); - - it("runs in lockstep with useConnectionLost off the ONE clock: amber at 4s, red at 15s", () => { - const trouble = renderHook(({ c }) => useConnectionTrouble(c), { initialProps: { c: true } }); - const lost = renderHook(({ c }) => useConnectionLost(c), { initialProps: { c: true } }); - act(() => vi.advanceTimersByTime(TROUBLE_MS)); // 4s - expect(trouble.result.current).toBe(true); // amber - expect(lost.result.current).toBe(false); // not red yet - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS - TROUBLE_MS)); // 15s total - expect(trouble.result.current).toBe(true); - expect(lost.result.current).toBe(true); // red — and trouble is still true beneath it - }); -}); diff --git a/web/src/hooks/use-connection-lost.ts b/web/src/hooks/use-connection-lost.ts deleted file mode 100644 index 705a7bde..00000000 --- a/web/src/hooks/use-connection-lost.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffect, useState } from "react"; - -import { - CONNECTION_LOST_MS, - TROUBLE_MS, - latchLost, - useConnectionHealth, -} from "@/lib/connection-health"; - -// Re-exported so the many call sites and tests that import the thresholds from here keep working; the -// constants themselves live with the shared store in lib/connection-health. -export { CONNECTION_LOST_MS, TROUBLE_MS }; - -/** - * Shared implementation for both connection thresholds: true once `connecting` (isConnecting — - * offline / snapshot error / Herdr down / stalled) has stayed true continuously for `thresholdMs`, - * measured from the last PROVABLY LIVE moment. Resets to false the instant `connecting` goes false. - * - * Derives entirely from the module-scoped lib/connection-health store — NOT a per-instance timer. The - * result is a pure function of `connecting` and the shared escalation anchor (`useConnectionHealth`, - * i.e. `effectiveAnchor()`), so every consumer at a given threshold computes the SAME answer and they - * cannot disagree across remounts, route changes, or timer drift. The store subscription re-renders us - * when the anchor moves (a successful poll or a wake); the timeout below forces the re-evaluation at - * the exact threshold moment, and focus/online are cheap re-check nudges after the phone wakes (when - * timers were frozen). Wall-clock throughout: a phone that sleeps mid-outage escalates on true elapsed - * time, not accumulated awake-time. - * - * `latch` is what separates the two thresholds. Only the 15s LOST escalation latches: the moment we - * observe it we call latchLost(), and while latched the store drops the wake grace from the anchor, so - * switching apps and returning MID-OUTAGE can't downgrade a red "not connected" to amber for another - * window — red stays red until a live poll clears the latch (markLive). The 4s TROUBLE threshold NEVER - * latches: it's the ambient amber, and latching it would freeze the sticky state at 4s and break the - * red/green semantics. Both read the SAME latched-or-not anchor, so once latched (red) trouble is - * trivially true too — bar and dog agree by construction. - */ -function useNotLiveFor(connecting: boolean, thresholdMs: number, latch: boolean): boolean { - // The shared escalation anchor. Subscribing re-renders us whenever markLive/markWake/latchLost moves - // it, and feeding it into the effect deps below reschedules the threshold timer against the new - // anchor (e.g. a pre-latch wake pushes escalation back a fresh window; latching drops the wake grace). - const anchor = useConnectionHealth(); - // A bare re-render nudge: `reached` below is the source of truth; this just makes React re-evaluate - // it at the threshold moment (and on focus/online) even when no poll or store change re-renders us. - const [, tick] = useState(0); - - const reached = connecting && Date.now() - anchor >= thresholdMs; - - // Latch the escalation the first time we observe it — but ONLY for the latching (15s lost) threshold. - // Store-owned + idempotent, so all consumers agree and re-running is a no-op; it survives this - // component's remounts because the flag lives in the module store, not here. Cleared only by markLive. - useEffect(() => { - if (latch && reached) latchLost(); - }, [latch, reached]); - - useEffect(() => { - if (!connecting) return; - const recheck = () => tick((n) => n + 1); - const elapsed = Date.now() - anchor; - const id = window.setTimeout(recheck, Math.max(0, thresholdMs - elapsed)); - // Timers freeze while the phone sleeps; focus/online re-measure real elapsed time on wake. - window.addEventListener("focus", recheck); - window.addEventListener("online", recheck); - return () => { - clearTimeout(id); - window.removeEventListener("focus", recheck); - window.removeEventListener("online", recheck); - }; - }, [connecting, thresholdMs, anchor]); - - return reached; -} - -/** - * The STICKY 15s escalation — true once `connecting` has held continuously for `thresholdMs` (default - * CONNECTION_LOST_MS), and it LATCHES: a mid-outage app switch (wake) can't downgrade it back to amber - * for another window; the latch clears only when a live poll proves recovery. Drives the red - * connection bar and the muted "not connected" dog. Also the BootSplash's stuck-cold-start escalation. - */ -export function useConnectionLost(connecting: boolean, thresholdMs = CONNECTION_LOST_MS): boolean { - return useNotLiveFor(connecting, thresholdMs, true); -} - -/** - * The ambient 4s trouble threshold — true once `connecting` has held continuously for `thresholdMs` - * (default TROUBLE_MS). Shares the SAME anchor as useConnectionLost but NEVER latches, so it's a pure - * "have we been not-live for a sustained beat" signal: below it a single slow poll or one failed fetch - * shows nothing (the flicker fix). Drives the amber "reconnecting…" bar and the galloping dog. - */ -export function useConnectionTrouble(connecting: boolean, thresholdMs = TROUBLE_MS): boolean { - return useNotLiveFor(connecting, thresholdMs, false); -} diff --git a/web/src/hooks/use-loading-stalled.ts b/web/src/hooks/use-loading-stalled.ts index 3c68ca74..82c2f3e1 100644 --- a/web/src/hooks/use-loading-stalled.ts +++ b/web/src/hooks/use-loading-stalled.ts @@ -13,8 +13,8 @@ const DEFAULT_THRESHOLD_MS = 2_500; * * Covering navigation as well as revalidation is what makes a black-holed pane-open tap give * feedback: the tap is a router navigation that waits on `paneLoader`, so without this the app looks - * completely dead until the loader's own timeout fires. Feeds `isConnecting`, which gallops the - * Collie mark — instant "we're stuck" signal on both the dashboard and the pane view. + * completely dead until the loader's own timeout fires. It drives only the header's generic loading + * animation, never a freshness or connection diagnosis. */ export function useLoadingStalled(thresholdMs = DEFAULT_THRESHOLD_MS): boolean { const revalidator = useRevalidator(); diff --git a/web/src/hooks/use-online.ts b/web/src/hooks/use-online.ts deleted file mode 100644 index b584b758..00000000 --- a/web/src/hooks/use-online.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useSyncExternalStore } from "react"; - -function subscribe(cb: () => void) { - window.addEventListener("online", cb); - window.addEventListener("offline", cb); - return () => { - window.removeEventListener("online", cb); - window.removeEventListener("offline", cb); - }; -} - -/** Live navigator.onLine — drives the offline banner. */ -export function useOnline(): boolean { - return useSyncExternalStore( - subscribe, - () => navigator.onLine, - () => true, - ); -} diff --git a/web/src/hooks/use-polling.test.ts b/web/src/hooks/use-polling.test.ts index e1b399a2..bc9c4ed6 100644 --- a/web/src/hooks/use-polling.test.ts +++ b/web/src/hooks/use-polling.test.ts @@ -58,8 +58,9 @@ function makeData(agents: AgentView[], shellPanes: AgentView[] = []): HomeData { snoozedUntil: null, update: undefined, transcriptionEnabled: false, - error: false, - authError: false, + snapshotStale: false, + snapshotAuthError: false, + snapshotHasLastGood: true, }; } diff --git a/web/src/hooks/use-voice-input.test.tsx b/web/src/hooks/use-voice-input.test.tsx index 276ca91a..c862272a 100644 --- a/web/src/hooks/use-voice-input.test.tsx +++ b/web/src/hooks/use-voice-input.test.tsx @@ -5,16 +5,18 @@ import userEvent from "@testing-library/user-event"; vi.mock("@/lib/api", () => ({ transcribeAudio: vi.fn() })); import { transcribeAudio } from "@/lib/api"; -import { - MAX_VOICE_BYTES, - MAX_VOICE_DURATION_MS, - recordingMimeType, - useVoiceInput, -} from "./use-voice-input"; +import { MAX_VOICE_BYTES, MAX_VOICE_DURATION_MS } from "@/lib/voice-policy"; + +import { recordingMimeType, useVoiceInput } from "./use-voice-input"; class MockMediaRecorder { static supported = new Set(["audio/webm;codecs=opus"]); static instances: MockMediaRecorder[] = []; + static constructionOptions: MediaRecorderOptions[] = []; + static deferStop = false; + static failWithBitrate = false; + static failAllConstructions = false; + static reportedAudioBitsPerSecond = 0; static isTypeSupported(type: string): boolean { return MockMediaRecorder.supported.has(type); } @@ -26,10 +28,19 @@ class MockMediaRecorder { readonly stream: MediaStream; readonly options?: MediaRecorderOptions; + readonly audioBitsPerSecond: number; constructor(stream: MediaStream, options?: MediaRecorderOptions) { + MockMediaRecorder.constructionOptions.push(options ?? {}); + if ( + MockMediaRecorder.failAllConstructions || + (MockMediaRecorder.failWithBitrate && options?.audioBitsPerSecond !== undefined) + ) { + throw new DOMException("unsupported options", "NotSupportedError"); + } this.stream = stream; this.options = options; + this.audioBitsPerSecond = MockMediaRecorder.reportedAudioBitsPerSecond; MockMediaRecorder.instances.push(this); } @@ -40,6 +51,10 @@ class MockMediaRecorder { stop(): void { if (this.state === "inactive") return; this.state = "inactive"; + if (!MockMediaRecorder.deferStop) this.finishStop(); + } + + finishStop(): void { this.ondataavailable?.({ data: new Blob(["recording"], { type: this.options?.mimeType }) } as BlobEvent); this.onstop?.(new Event("stop")); } @@ -79,7 +94,12 @@ describe("useVoiceInput", () => { beforeEach(() => { MockMediaRecorder.instances = []; + MockMediaRecorder.constructionOptions = []; MockMediaRecorder.supported = new Set(["audio/webm;codecs=opus"]); + MockMediaRecorder.deferStop = false; + MockMediaRecorder.failWithBitrate = false; + MockMediaRecorder.failAllConstructions = false; + MockMediaRecorder.reportedAudioBitsPerSecond = 0; document.body.dataset.transcript = ""; document.body.dataset.error = ""; Object.defineProperty(globalThis, "MediaRecorder", { @@ -105,6 +125,46 @@ describe("useVoiceInput", () => { expect(recordingMimeType()).toBeNull(); }); + it("retries recorder construction once without a rejected bitrate request", async () => { + const user = userEvent.setup(); + const { stream, track } = streamWithTrack(); + MockMediaRecorder.failWithBitrate = true; + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, + }); + render(); + + await user.click(screen.getByRole("button", { name: "start" })); + await screen.findByText("recording"); + + expect(MockMediaRecorder.constructionOptions).toEqual([ + { mimeType: "audio/webm;codecs=opus", audioBitsPerSecond: 24_000 }, + { mimeType: "audio/webm;codecs=opus" }, + ]); + expect(MockMediaRecorder.instances).toHaveLength(1); + await user.click(screen.getByRole("button", { name: "cancel" })); + expect(track.stop).toHaveBeenCalledTimes(1); + }); + + it("uses the AAC request for an MP4 recorder", async () => { + const user = userEvent.setup(); + const { stream } = streamWithTrack(); + MockMediaRecorder.supported = new Set(["audio/mp4"]); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, + }); + render(); + + await user.click(screen.getByRole("button", { name: "start" })); + await screen.findByText("recording"); + expect(MockMediaRecorder.instances[0]?.options).toEqual({ + mimeType: "audio/mp4", + audioBitsPerSecond: 64_000, + }); + }); + it("stops at five minutes and transcribes the bounded recording once", async () => { vi.useFakeTimers(); const { stream, track } = streamWithTrack(); @@ -146,7 +206,69 @@ describe("useVoiceInput", () => { expect(screen.getByText("idle")).toBeInTheDocument(); }); - it("tears down an oversized chunk without transcribing it", async () => { + it("moves from finalizing to coarse processing only after the recorder finalizes", async () => { + const user = userEvent.setup(); + const { stream } = streamWithTrack(); + MockMediaRecorder.deferStop = true; + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, + }); + let resolveTranscription!: (value: { ok: true; text: string }) => void; + vi.mocked(transcribeAudio).mockReturnValue( + new Promise((resolve) => { + resolveTranscription = resolve; + }), + ); + const now = vi.spyOn(Date, "now").mockReturnValue(0); + try { + render(); + await user.click(screen.getByRole("button", { name: "start" })); + await screen.findByText("recording"); + now.mockReturnValue(1); + + await user.click(screen.getByRole("button", { name: "stop" })); + expect(screen.getByText("finalizing")).toBeInTheDocument(); + expect(vi.mocked(transcribeAudio)).not.toHaveBeenCalled(); + + act(() => MockMediaRecorder.instances[0]!.finishStop()); + await screen.findByText("processing"); + expect(vi.mocked(transcribeAudio)).toHaveBeenCalledTimes(1); + + resolveTranscription({ ok: true, text: "done" }); + await screen.findByText("idle"); + } finally { + now.mockRestore(); + } + }); + + it("rejects an onstop delayed beyond five minutes instead of submitting a clamped duration", async () => { + vi.useFakeTimers(); + const { stream } = streamWithTrack(); + MockMediaRecorder.deferStop = true; + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, + }); + render(); + + await act(async () => { + screen.getByRole("button", { name: "start" }).click(); + await Promise.resolve(); + }); + const recorder = MockMediaRecorder.instances[0]!; + act(() => vi.advanceTimersByTime(MAX_VOICE_DURATION_MS - 1)); + act(() => screen.getByRole("button", { name: "stop" }).click()); + expect(screen.getByText("finalizing")).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(2)); + act(() => recorder.finishStop()); + + expect(document.body.dataset.error).toBe("Voice recording exceeded 5 minutes"); + expect(vi.mocked(transcribeAudio)).not.toHaveBeenCalled(); + expect(screen.getByText("idle")).toBeInTheDocument(); + }); + + it("tears down an oversized chunk without submitting it", async () => { const user = userEvent.setup(); const { stream, track } = streamWithTrack(); Object.defineProperty(navigator, "mediaDevices", { @@ -171,7 +293,7 @@ describe("useVoiceInput", () => { expect(screen.getByText("idle")).toBeInTheDocument(); }); - it("releases a late permission stream after cancellation without recording or transcribing", async () => { + it("releases a late permission stream after cancellation without recording or submitting", async () => { const user = userEvent.setup(); const { stream, track } = streamWithTrack(); let resolveStream!: (value: MediaStream) => void; @@ -203,9 +325,10 @@ describe("useVoiceInput", () => { expect(document.body.dataset.transcript).toBe(""); }); - it("records a completed clip, stops tracks, and hands only editable text back", async () => { + it("records when the browser ignores the requested bitrate and hands only editable text back", async () => { const user = userEvent.setup(); const { stream, track } = streamWithTrack(); + MockMediaRecorder.reportedAudioBitsPerSecond = 96_000; Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia: vi.fn().mockResolvedValue(stream) }, @@ -215,10 +338,15 @@ describe("useVoiceInput", () => { await user.click(screen.getByRole("button", { name: "start" })); await screen.findByText("recording"); - expect(MockMediaRecorder.instances[0]?.options?.mimeType).toBe("audio/webm;codecs=opus"); + expect(MockMediaRecorder.instances[0]?.options).toEqual({ + mimeType: "audio/webm;codecs=opus", + audioBitsPerSecond: 24_000, + }); await user.click(screen.getByRole("button", { name: "stop" })); await waitFor(() => expect(document.body.dataset.transcript).toBe("review this first")); + expect(MockMediaRecorder.instances[0]?.audioBitsPerSecond).toBe(96_000); + expect(MockMediaRecorder.constructionOptions).toHaveLength(1); expect(track.stop).toHaveBeenCalledTimes(1); expect(vi.mocked(transcribeAudio)).toHaveBeenCalledTimes(1); expect(screen.getByText("idle")).toBeInTheDocument(); @@ -247,7 +375,7 @@ describe("useVoiceInput", () => { expect(wakeLock.release).not.toHaveBeenCalled(); await user.click(screen.getByRole("button", { name: "stop" })); - await screen.findByText("transcribing"); + await screen.findByText("processing"); expect(wakeLock.release).toHaveBeenCalledTimes(1); }); @@ -334,7 +462,7 @@ describe("useVoiceInput", () => { await user.click(screen.getByRole("button", { name: "start" })); await screen.findByText("recording"); await user.click(screen.getByRole("button", { name: "stop" })); - await screen.findByText("transcribing"); + await screen.findByText("processing"); const signal = vi.mocked(transcribeAudio).mock.calls[0]?.[4]; expect(signal).toBeInstanceOf(AbortSignal); @@ -373,7 +501,7 @@ describe("useVoiceInput", () => { await user.click(screen.getByRole("button", { name: "start" })); await screen.findByText("recording"); await user.click(screen.getByRole("button", { name: "stop" })); - await screen.findByText("transcribing"); + await screen.findByText("processing"); const oldSignal = vi.mocked(transcribeAudio).mock.calls[0]?.[4]; expect(oldSignal).toBeInstanceOf(AbortSignal); expect(first.track.stop).toHaveBeenCalledTimes(1); diff --git a/web/src/hooks/use-voice-input.ts b/web/src/hooks/use-voice-input.ts index b3a6c65c..3d62d36c 100644 --- a/web/src/hooks/use-voice-input.ts +++ b/web/src/hooks/use-voice-input.ts @@ -1,16 +1,21 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import * as api from "@/lib/api"; +import { + MAX_VOICE_BYTES, + MAX_VOICE_DURATION_MS, + RECORDING_MIME_TYPES, + requestedRecordingBitrate, +} from "@/lib/voice-policy"; -export const MAX_VOICE_DURATION_MS = 5 * 60 * 1000; -export const MAX_VOICE_BYTES = 8 * 1024 * 1024; - -export type VoicePhase = "idle" | "requesting" | "recording" | "transcribing"; +export type VoicePhase = "idle" | "requesting" | "recording" | "finalizing" | "processing"; /** One pane's active voice lifecycle, shared by the pane write boundary and its composer controls. */ export interface VoiceInput { phase: VoicePhase; elapsedLabel: string; + /** Synchronous live write permission: false for every active voice phase. */ + canWrite: () => boolean; startRecording: () => Promise; stopRecording: () => void; cancel: () => void; @@ -34,7 +39,7 @@ export function recordingMimeType(): string | null { if (typeof MediaRecorder === "undefined" || typeof MediaRecorder.isTypeSupported !== "function") { return null; } - for (const type of ["audio/webm;codecs=opus", "audio/webm", "audio/mp4"]) { + for (const type of RECORDING_MIME_TYPES) { if (MediaRecorder.isTypeSupported(type)) return type; } return null; @@ -82,6 +87,7 @@ export function useVoiceInput({ phaseRef.current = next; setPhase(next); }; + const canWrite = useCallback(() => phaseRef.current === "idle", []); const clearRecordingTimers = () => { if (elapsedTimerRef.current !== null) clearInterval(elapsedTimerRef.current); @@ -174,9 +180,11 @@ export function useVoiceInput({ reportedDurationMs: number, ) => { if (!isCurrentOperation(controller, scope)) return; - setVoicePhase("transcribing"); const extension = mime.startsWith("audio/mp4") ? "mp4" : "webm"; const file = new File([blob], `recording.${extension}`, { type: mime }); + // Fetch cannot distinguish upload, server parsing, and provider work, so processing remains one + // intentionally coarse phase through the completed one-shot request. + setVoicePhase("processing"); try { const response = await api.transcribeAudio( scope.paneId, @@ -211,7 +219,7 @@ export function useVoiceInput({ } // Switch UI immediately so no draft/edit action can race the completed recording while the // browser delivers its final dataavailable/stop events. - setVoicePhase("transcribing"); + setVoicePhase("finalizing"); releaseWakeLock(); try { recorder.stop(); @@ -245,7 +253,17 @@ export function useVoiceInput({ return; } streamRef.current = stream; - const recorder = new MediaRecorder(stream, { mimeType: mime }); + let recorder: MediaRecorder; + try { + recorder = new MediaRecorder(stream, { + mimeType: mime, + audioBitsPerSecond: requestedRecordingBitrate(mime), + }); + } catch { + // Some browsers reject a valid container when paired with a bitrate request. Retry exactly + // once without that best-effort hint; a second construction error uses the existing failure path. + recorder = new MediaRecorder(stream, { mimeType: mime }); + } recorderRef.current = recorder; chunksRef.current = []; bytesRef.current = 0; @@ -264,7 +282,7 @@ export function useVoiceInput({ recorderRef.current = null; clearRecordingTimers(); stopTracks(); - const reportedDurationMs = Math.min(Date.now() - startedAtRef.current, MAX_VOICE_DURATION_MS); + const reportedDurationMs = Date.now() - startedAtRef.current; const chunks = chunksRef.current; chunksRef.current = []; bytesRef.current = 0; @@ -272,6 +290,10 @@ export function useVoiceInput({ failOperation(controller, scope, "Voice recording was empty"); return; } + if (reportedDurationMs > MAX_VOICE_DURATION_MS) { + failOperation(controller, scope, "Voice recording exceeded 5 minutes"); + return; + } const blob = new Blob(chunks, { type: mime }); if (blob.size > MAX_VOICE_BYTES) { failOperation(controller, scope, "Voice recording exceeded 8 MiB"); @@ -323,6 +345,7 @@ export function useVoiceInput({ return { phase, elapsedLabel: elapsedLabel(elapsedMs), + canWrite, startRecording, stopRecording, cancel, diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index cf341cdb..b155ee5c 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -1,8 +1,7 @@ import { http, HttpResponse } from "msw"; import { server } from "@/test/setup"; -import { fixtureSnapshot } from "@/test/handlers"; -import { __resetConnectionHealth, lastHealthyAt } from "./connection-health"; +import { transcriptionDeadlineMs } from "./voice-policy"; import { checkForUpdates, createTab, @@ -243,13 +242,14 @@ describe("api client — request timeouts", () => { }); }); -// Voice owns a distinct 90-second total deadline because its completed multipart response can stall -// after headers. These cases stay local to the new endpoint; existing request paths retain main's -// native timeout coverage above. +// Voice owns a Blob-size-derived total deadline because its completed multipart response can stall +// after headers. These cases stay local to the endpoint; existing request paths retain main's native +// timeout coverage above. describe("api client — transcription deadline", () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); function stallTranscriptionBody() { @@ -284,17 +284,32 @@ describe("api client — transcription deadline", () => { }; } - it("keeps the 90-second deadline through a stalled transcription body", async () => { + it("starts the Blob-size deadline before FormData and keeps it through a stalled JSON body", async () => { vi.useFakeTimers(); + const NativeFormData = FormData; + const formDataConstructed = vi.fn(); + class TrackingFormData extends NativeFormData { + constructor() { + super(); + formDataConstructed(); + } + } + vi.stubGlobal("FormData", TrackingFormData); + const timeoutSpy = vi.spyOn(globalThis, "setTimeout"); const stalled = stallTranscriptionBody(); - const pending = transcribeAudio( - "w1:p1", - new File(["x"], "recording.webm", { type: "audio/webm" }), - 1_000, - ).catch((error: unknown) => error); + const file = new File(["x"], "recording.webm", { type: "audio/webm" }); + const deadlineMs = transcriptionDeadlineMs(file.size); + const pending = transcribeAudio("w1:p1", file, 1_000).catch((error: unknown) => error); + + expect(formDataConstructed).toHaveBeenCalledTimes(1); + const deadlineTimer = timeoutSpy.mock.calls.findIndex(([, ms]) => ms === deadlineMs); + expect(deadlineTimer).toBeGreaterThanOrEqual(0); + expect(timeoutSpy.mock.invocationCallOrder[deadlineTimer]!).toBeLessThan( + formDataConstructed.mock.invocationCallOrder[0]!, + ); await stalled.bodyReadStarted; - await vi.advanceTimersByTimeAsync(90_000); + await vi.advanceTimersByTimeAsync(deadlineMs); await expect(pending).resolves.toMatchObject({ name: "TimeoutError" }); expect(stalled.signal?.aborted).toBe(true); @@ -353,41 +368,6 @@ describe("api client — session scoping", () => { }); }); -// The fetch layer is where liveness is stamped onto the shared lib/connection-health anchor (the same -// interception point that captures X-Collie-Build). A live snapshot/pane stamps; a 200 that reports -// the herd link down must NOT — otherwise the "Herdr is down" escalation could never fire. -describe("api client — connection-health stamping", () => { - it("stamps a live moment on a healthy snapshot (bridge connected)", async () => { - __resetConnectionHealth(1); // pin the anchor far in the past - await fetchSnapshot(); // default handler → fixtureSnapshot.bridge === "connected" - expect(lastHealthyAt()).toBeGreaterThan(1); - }); - - it("does NOT stamp when the snapshot 200s but reports the herd link disconnected", async () => { - server.use( - http.get("/api/snapshot", () => - HttpResponse.json({ ...fixtureSnapshot, bridge: "disconnected" }), - ), - ); - __resetConnectionHealth(1); - await fetchSnapshot(); - expect(lastHealthyAt()).toBe(1); // a 200 that says "Herdr down" is not a provably-live moment - }); - - it("stamps a live moment on a successful pane read", async () => { - __resetConnectionHealth(1); - await fetchPane("w1:p1"); // default handler → 200 body - expect(lastHealthyAt()).toBeGreaterThan(1); - }); - - it("does NOT stamp when a poll fails (the throw precedes the stamp)", async () => { - server.use(http.get("/api/snapshot", () => new HttpResponse("boom", { status: 502 }))); - __resetConnectionHealth(1); - await expect(fetchSnapshot()).rejects.toThrow(/502/); - expect(lastHealthyAt()).toBe(1); - }); -}); - // A proxy that REDIRECTS an unauthenticated request instead of refusing it strips Collie of the only // signal `isAuthError` (lib/loaders.ts) can act on: `fetch` follows the cross-origin 302, the call // rejects as a TypeError with no status, and the refusal banner — with the Sign-in link that would diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 28046ad3..eec50c99 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -2,8 +2,8 @@ // minimal. Each call throws on a non-2xx so callers (route loaders / action handlers) surface errors. import { trackBusy } from "./busy"; -import { markLive } from "./connection-health"; import { observeServerBuild, SERVER_BUILD_HEADER } from "./server-build"; +import { transcriptionDeadlineMs } from "./voice-policy"; import type { ActionResponse, BridgeConfig, @@ -22,13 +22,12 @@ export type { NotifyPrefs, UpdateInfo }; /** * Marks every API request as XHR so a fronting identity proxy answers it with a status we can read. * - * The refusal banner (components/connection-banner.tsx) is reached only through `isAuthError` + * The refusal banner (components/freshness-banner.tsx) is reached only through `isAuthError` * (lib/loaders.ts), which matches 401/403 on an {@link ApiError}. A proxy that answers an * unauthenticated request with a REDIRECT never produces one: `fetch` follows the 302 to the * identity provider's origin, that response carries no CORS headers, and the call rejects as a - * `TypeError` — a transport failure with no status. The user then gets the connection banner - * ("can't reach Collie") and, worse, loses the Sign-in link that would have fixed it, since a - * missing session is precisely the thing it recovers from. + * `TypeError` — a transport failure with no status. The loaders can then show only a generic stale + * freshness state, losing the Sign-in link that a classified 401/403 would provide. * * Measured against Cloudflare Access with no session: a plain request, `Accept: application/json` * and `Sec-Fetch-Mode: cors` all still redirect; only this header flips the answer to a same-origin @@ -62,16 +61,14 @@ export function isApiErrorStatus(error: unknown, status: number): boolean { // gates on `revalidator.state === "idle"` and never fires again, and route navigations wait on a // loader that never settles. On timeout the fetch aborts with a DOMException named "TimeoutError"; // the loaders rethrow ONLY "AbortError" (a superseded revalidation), so a timeout falls into their -// catch → stale-data-with-error, and the poller/nav can retry. Budgets by request class: +// catch → a stale loader result, and the poller/nav can retry. Budgets by request class: // - GET reads (snapshot/pane polls) are small and frequent — a short leash surfaces a dead link -// fast so the UI can show "reconnecting…" and retry on the next tick. +// fast so the loaders can retain stale data and retry on the next tick. const GET_TIMEOUT_MS = 10_000; // - Mutations drive a real terminal on the host, which can legitimately take a beat — more slack. const MUTATION_TIMEOUT_MS = 20_000; // - Uploads carry a whole file over the phone's uplink — the most generous budget. const UPLOAD_TIMEOUT_MS = 60_000; -// - A completed voice clip has the same uplink cost plus one bounded provider round trip. -const TRANSCRIPTION_TIMEOUT_MS = 90_000; /** * Compose the caller's abort signal (a loader's `request.signal`, used to supersede a stale poll) @@ -96,6 +93,7 @@ export function withTimeout( /** Keep voice's deadline and caller signal alive until its response body is consumed. */ async function transcribeWithDeadline( callerSignal: AbortSignal | undefined, + deadlineMs: number, request: (signal: AbortSignal) => Promise, ): Promise { const controller = new AbortController(); @@ -112,7 +110,7 @@ async function transcribeWithDeadline( if (!controller.signal.aborted) { controller.abort(new DOMException("Request timed out", "TimeoutError")); } - }, TRANSCRIPTION_TIMEOUT_MS); + }, deadlineMs); } try { @@ -225,17 +223,8 @@ function req(path: string, init?: RequestInit, recover?: Recover): Promise return method === "GET" ? op : trackBusy(op); } -export async function fetchSnapshot( - session?: string, - signal?: AbortSignal, -): Promise { - const snap = await req(withSession("/api/snapshot", session), { signal }); - // A snapshot whose herd link is UP is a provably-live moment — stamp the shared connection-health - // anchor so escalation is measured from here. A snapshot that 200s but reports `bridge: - // "disconnected"` is NOT live (the pill/banner still escalate on it), so it must NOT reset the - // clock, or the "Herdr is down" escalation could never surface. - if (snap.bridge !== "disconnected") markLive(); - return snap; +export function fetchSnapshot(session?: string, signal?: AbortSignal): Promise { + return req(withSession("/api/snapshot", session), { signal }); } // Per-pane cache of the last ETag AND the body it belongs to, kept together on purpose. We send @@ -283,9 +272,7 @@ export async function fetchPane( captureBuild(res); // pane polls carry the build header too (incl. 304s) — keep the store fresh if (res.status === 304 && cached) { - // Unchanged — hand back the cached body (text included) so the mirror keeps its content. An - // unchanged poll is still a live poll: stamp the connection-health anchor (a 304 counts as live). - markLive(); + // Unchanged — hand back the cached body (text included) so the mirror keeps its content. return { ...cached.response, notModified: true }; } @@ -305,8 +292,6 @@ export async function fetchPane( } } - // A pane body served from Herdr is provably-live data — stamp the connection-health anchor. - markLive(); return data; } @@ -520,27 +505,28 @@ export function transcribeAudio( session?: string, signal?: AbortSignal, ): Promise { + const deadlineMs = transcriptionDeadlineMs(file.size); return trackBusy( - (async () => { + transcribeWithDeadline(signal, deadlineMs, async (deadlineSignal) => { + // Constructing multipart can synchronously do non-trivial Blob work, so it remains inside the + // total deadline along with the upload, bridge/provider work, and final JSON consumption. const fd = new FormData(); fd.append("file", file); // The wire field is retained for bridge compatibility; its value is browser-reported lifecycle metadata. fd.append("duration_ms", String(reportedDurationMs)); - return transcribeWithDeadline(signal, async (deadlineSignal) => { - const res = await fetch(withSession(`/api/pane/${encodeURIComponent(paneId)}/transcribe`, session), { - method: "POST", - body: fd, - // Do not set content-type: the browser adds the multipart boundary. - headers: { [XHR_HEADER]: XHR_HEADER_VALUE }, - // Do not replay a voice recording if an identity proxy/front door redirects this POST. - redirect: "error", - signal: deadlineSignal, - }); - if (!res.ok) { - throw new ApiError(`transcription → ${res.status} ${await errorDetail(res)}`, res.status); - } - return (await res.json()) as TranscriptionResponse; + const res = await fetch(withSession(`/api/pane/${encodeURIComponent(paneId)}/transcribe`, session), { + method: "POST", + body: fd, + // Do not set content-type: the browser adds the multipart boundary. + headers: { [XHR_HEADER]: XHR_HEADER_VALUE }, + // Do not replay a voice recording if an identity proxy/front door redirects this POST. + redirect: "error", + signal: deadlineSignal, }); - })(), + if (!res.ok) { + throw new ApiError(`transcription → ${res.status} ${await errorDetail(res)}`, res.status); + } + return (await res.json()) as TranscriptionResponse; + }), ); } diff --git a/web/src/lib/connection-health.test.ts b/web/src/lib/connection-health.test.ts deleted file mode 100644 index 53569822..00000000 --- a/web/src/lib/connection-health.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { - __resetConnectionHealth, - effectiveAnchor, - isLostLatched, - lastHealthyAt, - latchLost, - markLive, - markWake, - subscribeHealth, -} from "./connection-health"; - -// Fake timers drive Date.now in Vitest, so the wall-clock anchors advance deterministically. Re-pin -// the anchor to the frozen clock after useFakeTimers so each case starts from a known "now". -describe("connection-health store", () => { - beforeEach(() => { - vi.useFakeTimers(); - __resetConnectionHealth(); - }); - afterEach(() => vi.useRealTimers()); - - it("markLive advances the anchor to now and notifies subscribers", () => { - let hits = 0; - const unsub = subscribeHealth(() => hits++); - const before = lastHealthyAt(); - vi.advanceTimersByTime(5_000); - markLive(); - expect(lastHealthyAt()).toBe(before + 5_000); - expect(hits).toBe(1); - unsub(); - }); - - it("markWake advances the anchor and notifies subscribers", () => { - let hits = 0; - const unsub = subscribeHealth(() => hits++); - const before = lastHealthyAt(); - vi.advanceTimersByTime(3_000); - markWake(); - expect(lastHealthyAt()).toBe(before + 3_000); - expect(hits).toBe(1); - unsub(); - }); - - it("lastHealthyAt returns the LATER of the last live poll and the last wake", () => { - const t0 = lastHealthyAt(); - vi.advanceTimersByTime(5_000); - markLive(); // live at t0+5000 - expect(lastHealthyAt()).toBe(t0 + 5_000); - vi.advanceTimersByTime(3_000); - markWake(); // wake at t0+8000 — the more recent anchor wins - expect(lastHealthyAt()).toBe(t0 + 8_000); - // A subsequent live stamp that is EARLIER than the wake can't pull the anchor backwards. - // (Here time only moves forward, so assert the invariant directly: max(live, wake).) - expect(lastHealthyAt()).toBe(Math.max(t0 + 5_000, t0 + 8_000)); - }); - - it("a subscriber stops receiving notifications after unsubscribe", () => { - let hits = 0; - const unsub = subscribeHealth(() => hits++); - markLive(); - expect(hits).toBe(1); - unsub(); - markLive(); - expect(hits).toBe(1); // no further notifications - }); - - it("a visibilitychange to visible stamps a wake (module-level listener)", () => { - const before = lastHealthyAt(); - vi.advanceTimersByTime(7_000); - // jsdom reports document.visibilityState === "visible" by default, so the listener fires markWake. - document.dispatchEvent(new Event("visibilitychange")); - expect(lastHealthyAt()).toBe(before + 7_000); - }); - - it("__resetConnectionHealth pins both anchors to the given time", () => { - vi.advanceTimersByTime(9_000); - markLive(); - const pinned = 123_456; - __resetConnectionHealth(pinned); - expect(lastHealthyAt()).toBe(pinned); - }); - - // Sticky-escalation latch — the fix for a mid-outage app switch downgrading red → amber. - describe("sticky-escalation latch", () => { - it("latchLost sets the latch and notifies once; repeat calls are no-ops", () => { - let hits = 0; - const unsub = subscribeHealth(() => hits++); - expect(isLostLatched()).toBe(false); - latchLost(); - expect(isLostLatched()).toBe(true); - expect(hits).toBe(1); - latchLost(); // idempotent — already latched, so no state change and no notify - expect(hits).toBe(1); - unsub(); - }); - - it("while latched, effectiveAnchor ignores the wake grace (a wake can't move it)", () => { - const t0 = lastHealthyAt(); - vi.advanceTimersByTime(20_000); // lastLiveAt/lastWakeAt stay at t0 (no markLive/markWake yet) - latchLost(); // escalated: lastLiveAt (t0) is already stale - expect(effectiveAnchor()).toBe(t0); // == lastLiveAt - markWake(); // a mid-outage app switch stamps a wake… - expect(effectiveAnchor()).toBe(t0); // …but effectiveAnchor stays on the stale live anchor - expect(lastHealthyAt()).toBe(t0 + 20_000); // the wake WAS recorded — just excluded while latched - }); - - it("effectiveAnchor DOES include the wake grace when NOT latched", () => { - const t0 = lastHealthyAt(); - vi.advanceTimersByTime(6_000); - markWake(); - expect(isLostLatched()).toBe(false); - expect(effectiveAnchor()).toBe(t0 + 6_000); // max(live, wake) — grace applies - }); - - it("markLive clears the latch (recovery de-escalates everything together)", () => { - latchLost(); - expect(isLostLatched()).toBe(true); - vi.advanceTimersByTime(5_000); - markLive(); - expect(isLostLatched()).toBe(false); - expect(effectiveAnchor()).toBe(lastHealthyAt()); // wake grace back in play - }); - - it("markWake does NOT clear the latch", () => { - latchLost(); - markWake(); - expect(isLostLatched()).toBe(true); - }); - - it("__resetConnectionHealth clears the latch", () => { - latchLost(); - expect(isLostLatched()).toBe(true); - __resetConnectionHealth(); - expect(isLostLatched()).toBe(false); - }); - }); -}); diff --git a/web/src/lib/connection-health.ts b/web/src/lib/connection-health.ts deleted file mode 100644 index d24c808b..00000000 --- a/web/src/lib/connection-health.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { useSyncExternalStore } from "react"; - -// The ONE connection-health clock, shared by every consumer (the header pill, the outage banner, the -// in-pane header, the boot splash). Module-scoped store in the lib/busy.ts + lib/server-build.ts -// idiom — plain module state + a subscribe + a useSyncExternalStore hook — so escalation is derived -// from a SINGLE source of truth that no remount, route change, or per-instance timer can fork. -// -// Why this exists: escalation used to live in a per-COMPONENT ref/timer (useConnectionLost stamped a -// local `since` when it first saw `connecting`). Two independent instances could diverge — most -// visibly, the pill renders inside each route's header, so navigating home→space mid-outage REMOUNTED -// it and restarted its clock, while the banner (in the persistent RootLayout) escalated on time. The -// result on-device: the banner went red "not connected" while the header pill sat amber -// "reconnecting…" for far longer. Anchoring every consumer on this shared store fixes that by -// construction. -// -// Anchor semantics: `lastLiveAt` is the wall-clock of the last PROVABLY LIVE moment — stamped when a -// snapshot/pane fetch returns genuinely live data (see lib/api.ts; a 304 counts as live). `lastWakeAt` -// is stamped when the tab returns to the foreground. Escalation measures a flat CONNECTION_LOST_MS of -// no live data from `max(lastLiveAt, lastWakeAt)`: anchoring on the last SUCCESS means device delays -// (a 10s fetch timeout, a poll gap) can no longer stack BEFORE the clock starts, and the wake anchor -// gives a phone waking from sleep a fresh grace window instead of an instant red flash while its first -// poll is still in flight. -// -// Sticky escalation (`lostLatched`): the wake grace above is honest ONLY before we've escalated. Once -// the app is already showing "not connected" (red pill + banner) and the user switches apps and comes -// back MID-OUTAGE, the wake stamp used to reset the anchor to now and downgrade red → amber -// "reconnecting…" for another full window, even though nothing had changed — a dishonest de-escalation. -// So we LATCH the escalated state: `latchLost()` is called the moment a real connecting consumer -// observes `lost` (see use-connection-lost), and while latched `effectiveAnchor()` DROPS the wake grace -// (measures from `lastLiveAt` alone). Red therefore stays red across backgrounding until the connection -// proves itself — the latch clears ONLY when `markLive()` stamps a genuine live poll, at which point the -// live stamp and the latch clear together and everything recovers as before. The latch is coupled to a -// consumer actually crossing the threshold (not merely the wall-clock going stale) because the anchor -// can go stale for benign reasons too — e.g. the idle-lock pausing polling — where nobody is -// `connecting` and no red UI is showing, so nothing should latch. - -// How long the app must stay continuously not-live before we escalate from the quiet header pill -// ("reconnecting…") to a prominent prompt. Long enough that a normal poll blip, a pane-open hiccup, -// or a brief tunnel drop never trips it — only a genuinely sustained outage does. -export const CONNECTION_LOST_MS = 15_000; - -// How long the app must stay continuously not-live before the connection bar fades IN as an ambient -// amber "reconnecting…" (and the header dog starts to gallop). Short enough to catch a genuine stall, -// long enough that a single slow poll (the stall itself only trips at 2.5s) or one failed fetch never -// flashes a bar — the flicker fix. Measured from the SAME shared anchor as CONNECTION_LOST_MS (via -// useConnectionTrouble), just far shorter and, crucially, NON-latching: only the 15s escalation latches. -export const TROUBLE_MS = 4_000; - -// Both initialise to module-load time (app open), so a dead cold start escalates ~CONNECTION_LOST_MS -// after open (the BootSplash case) — the first successful poll then advances `lastLiveAt` for real. -let lastLiveAt = Date.now(); -let lastWakeAt = Date.now(); -// Sticky-escalation latch — set once a real connecting consumer OBSERVES the lost condition (see -// latchLost + use-connection-lost) and cleared ONLY by a provably-live poll (markLive). While latched, -// effectiveAnchor() drops the wake grace, so backgrounding + returning MID-OUTAGE can no longer -// downgrade red → amber. Module-scoped so every consumer agrees on one escalated/not answer. -let lostLatched = false; -const listeners = new Set<() => void>(); - -function emit() { - for (const fn of listeners) fn(); -} - -/** - * Stamp a provably-live moment: a snapshot/pane fetch that returned live data (a 304 counts). Called - * from lib/api.ts at the same fetch interception point that captures X-Collie-Build, so the anchor - * can't drift from reality. Every stamp advances the wall-clock and notifies subscribers. This is also - * the ONLY thing that clears the sticky-escalation latch: recovery proves itself with a real poll, so - * the live stamp and the latch clear together and every consumer de-escalates at once. - */ -export function markLive(): void { - lastLiveAt = Date.now(); - lostLatched = false; - emit(); -} - -/** - * Stamp a wake: the tab returned to the foreground, granting a fresh grace window before escalation - * (a phone resuming from sleep shouldn't flash red while its first poll is still in flight). Does NOT - * touch the latch: while escalated, effectiveAnchor() ignores this stamp, so a mid-outage app switch - * can't reset the countdown or downgrade red back to amber. - */ -export function markWake(): void { - lastWakeAt = Date.now(); - emit(); -} - -/** - * Latch the sticky-escalation state. Idempotent — only the first call (per outage) flips the flag and - * notifies; repeats are no-ops. Called from use-connection-lost the instant a consumer observes `lost` - * true, so the latch is coupled to a real connecting consumer crossing the threshold rather than the - * bare wall-clock anchor going stale (which happens for benign reasons too, e.g. the idle-lock pausing - * polling, with nobody connecting and no red UI showing — that must NOT latch). - */ -export function latchLost(): void { - if (lostLatched) return; - lostLatched = true; - emit(); -} - -/** Whether the sticky-escalation latch is currently set (exported for tests / diagnostics). */ -export function isLostLatched(): boolean { - return lostLatched; -} - -/** The most recent provably-live anchor — the later of the last live poll and the last wake. */ -export function lastHealthyAt(): number { - return Math.max(lastLiveAt, lastWakeAt); -} - -/** - * The anchor escalation is measured from. NOT latched → `max(lastLiveAt, lastWakeAt)`: a wake grants a - * fresh grace window so a phone resuming from sleep on a HEALTHY network never flashes red while its - * first poll is still in flight. LATCHED → `lastLiveAt` alone (wake grace dropped): once we've - * escalated, a wake can no longer reset the countdown, so an already-red outage that is still failing - * stays red across app switches. Safe because `lostLatched` implies `lastLiveAt` is already at least - * CONNECTION_LOST_MS stale — markLive is the only thing that freshens it, and markLive also clears the - * latch — so dropping the wake grace can never manufacture a false escalation. - */ -export function effectiveAnchor(): number { - return lostLatched ? lastLiveAt : lastHealthyAt(); -} - -export function subscribeHealth(cb: () => void): () => void { - listeners.add(cb); - return () => listeners.delete(cb); -} - -/** - * Reactive read of the shared ESCALATION anchor — re-renders a consumer whenever markLive/markWake/ - * latchLost fires. Returns effectiveAnchor(), so it already honours the sticky latch (drops the wake - * grace once escalated); consumers derive `lost` from this single value and cannot disagree. - */ -export function useConnectionHealth(): number { - return useSyncExternalStore(subscribeHealth, effectiveAnchor, effectiveAnchor); -} - -// A phone backgrounds Collie (screen off, app switch) far more than it truly disconnects; timers -// freeze while it's away. On return, grant a fresh grace window rather than escalating on the stale, -// pre-sleep anchor. Module-level (registered once) so it's independent of any component's lifecycle. -if (typeof document !== "undefined") { - document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "visible") markWake(); - }); -} - -/** Test helper — reset both anchors (defaults to now) AND clear the sticky latch between cases. */ -export function __resetConnectionHealth(now = Date.now()): void { - lastLiveAt = now; - lastWakeAt = now; - lostLatched = false; -} diff --git a/web/src/lib/connection.test.ts b/web/src/lib/connection.test.ts deleted file mode 100644 index f4ad554f..00000000 --- a/web/src/lib/connection.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { isConnecting } from "./connection"; - -describe("isConnecting (poll-truth — navigator.onLine is never an input)", () => { - it("is false when the snapshot path is healthy (data is live)", () => { - expect(isConnecting({ bridge: "connected", error: false })).toBe(false); - }); - - it("has no onLine gate at all — a healthy snapshot stays live no matter what the browser claims", () => { - // Regression guard: onLine used to force isConnecting true. It's gone from ConnState now, so a - // phone whose onLine flag lies (airplane-mode stuck true, OR stuck false after an airplane cycle - // while the network is actually fine) can't manufacture a phantom outage while polls succeed. The - // signature literally has no `online` to pass — a healthy snapshot is the only thing that matters. - expect(isConnecting({ bridge: "connected", error: false })).toBe(false); - }); - - it("is true on a fetch error, before the first snapshot, and when Herdr is disconnected", () => { - expect(isConnecting({ bridge: "connected", error: true })).toBe(true); - expect(isConnecting({ bridge: undefined, error: false })).toBe(true); - expect(isConnecting({ bridge: "disconnected", error: false })).toBe(true); - }); - - it("is true when a load has stalled, even while connected/error-free", () => { - // A stall is an in-flight fetch that hasn't settled — nothing has failed yet, but the data on - // screen isn't live, so the Collie mark should gallop. - expect(isConnecting({ bridge: "connected", error: false, stalled: true })).toBe(true); - }); -}); diff --git a/web/src/lib/connection.ts b/web/src/lib/connection.ts deleted file mode 100644 index efcff8e7..00000000 --- a/web/src/lib/connection.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { BridgeStatus } from "./types"; - -export interface ConnState { - /** Herdr link as last reported by the snapshot; undefined before the first successful poll. */ - bridge: BridgeStatus | undefined; - /** The most recent snapshot fetch failed. */ - error: boolean; - /** - * A load (revalidation OR route navigation) has been in flight long enough to look stalled rather - * than merely slow — see use-loading-stalled. Distinct from `error`: a stall is a fetch that has - * NOT yet settled (so nothing has failed), which is exactly the black-hole case where the app - * would otherwise look dead with no feedback. Optional so callers that don't track it read false. - */ - stalled?: boolean; -} - -// The one predicate for "is the data on screen not yet live" — snapshot error, no first snapshot yet, -// Herdr disconnected, or a load stalled mid-flight. POLL-TRUTH ONLY: liveness is whether the snapshot -// path is healthy, and it deliberately does NOT consult navigator.onLine. A phone's onLine flag lies -// both ways — it stays true in airplane mode, and after an airplane cycle it can STICK false while the -// network is actually fine — so gating liveness on it galloped a phantom outage forever ("the dog is -// running yet the status says idle") while polls quietly succeeded. Polls always attempt; if they -// land, the data is live regardless of what onLine claims. onLine survives only as COPY selection -// (which not-live cause to name) in the ConnectionBanner — never as a liveness gate. The -// Collie mark gallops while this is true and rests when it's false, identically on every screen, so -// the header keeps this out of the per-poll fetch state (it stays put during a normal background -// revalidation, like the status pill, rather than twitching on every tick) — only a genuinely STALLED -// load trips it. Mirrors the not-"live" branches of the ConnectionBanner's tone resolver. -export function isConnecting({ bridge, error, stalled = false }: ConnState): boolean { - return error || bridge === undefined || bridge === "disconnected" || stalled; -} diff --git a/web/src/lib/dialog-guard.test.ts b/web/src/lib/dialog-guard.test.ts index 515b8447..527a66d4 100644 --- a/web/src/lib/dialog-guard.test.ts +++ b/web/src/lib/dialog-guard.test.ts @@ -68,6 +68,7 @@ describe("the guard refuses when the fresh screen isn't the tapped dialog", () = requestedLines: 200, detectedRevision: 0, agent: "claude", + canWrite: () => true, prompt: p, option: p.options[0]!, }); @@ -76,6 +77,35 @@ describe("the guard refuses when the fresh screen isn't the tapped dialog", () = expect(mockSendKeys).toHaveBeenCalledWith("w1:p1", p.options[0]!.keys, undefined, p.signature); }); + it("does not write when recording starts while its fresh read is pending", async () => { + const p = prompt(); + let resolveFresh!: (value: { paneId: string; text: string; truncated: boolean; revision: number }) => void; + mockFetchPane.mockReturnValueOnce( + new Promise((resolve) => { + resolveFresh = resolve; + }), + ); + let voiceBusy = false; + + const action = submitPromptOption({ + paneId: "w1:p1", + requestedLines: 200, + detectedRevision: 0, + agent: "claude", + canWrite: () => !voiceBusy, + prompt: p, + option: p.options[0]!, + }); + expect(mockFetchPane).toHaveBeenCalledTimes(1); + + // The action began idle, but its fresh-read guard has not settled when recording takes ownership. + voiceBusy = true; + resolveFresh({ paneId: "w1:p1", text: permission, truncated: false, revision: 0 }); + + await expect(action).resolves.toEqual({ status: "changed" }); + expect(mockSendKeys).not.toHaveBeenCalled(); + }); + it("refuses (and types nothing) when the pane belongs to an agent with no adapter", async () => { pane(permission); const p = prompt(); @@ -85,6 +115,7 @@ describe("the guard refuses when the fresh screen isn't the tapped dialog", () = requestedLines: 200, detectedRevision: 0, agent: "codex", + canWrite: () => true, prompt: p, option: p.options[0]!, }); @@ -106,6 +137,7 @@ describe("the guard refuses when the fresh screen isn't the tapped dialog", () = requestedLines: 200, detectedRevision: 0, agent: "claude", + canWrite: () => true, kind: "prompt-select", model: p, }, diff --git a/web/src/lib/dialog-guard.ts b/web/src/lib/dialog-guard.ts index aad1b994..705d0574 100644 --- a/web/src/lib/dialog-guard.ts +++ b/web/src/lib/dialog-guard.ts @@ -50,6 +50,8 @@ export interface DialogTarget { detectedRevision: number; /** The session the pane lives in (undefined = primary) — scopes every read + keystroke. */ session?: string; + /** Live permission for this pane write, rechecked immediately before every terminal mutation. */ + canWrite: () => boolean; /** * The pane's Herdr `agent` string — which adapter re-derives the fresh screen. An agent with no * adapter re-derives to nothing, so the guard refuses: fail-CLOSED, the only safe default when we @@ -132,10 +134,13 @@ export async function sendGuardedKeys( * undefined = an unbound write (a later step of a multi-step choreography, which has deliberately * changed the screen since the guard ran). */ export async function sendBoundKeys( - target: { paneId: string; session?: string }, + target: { paneId: string; session?: string; canWrite: () => boolean }, keys: string[], region?: string, ): Promise { + // The dialog guard's fresh read is asynchronous, so its result is only usable while the caller + // still owns this pane's write permission. Check at the write boundary, not just at tap time. + if (!target.canWrite()) return { status: "changed" }; try { const res = region === undefined diff --git a/web/src/lib/loaders.test.ts b/web/src/lib/loaders.test.ts index a5550107..c611035d 100644 --- a/web/src/lib/loaders.test.ts +++ b/web/src/lib/loaders.test.ts @@ -26,69 +26,79 @@ const rejectPane = (status: 401 | 403) => server.use(http.get(/\/api\/pane\/[^/]+$/, () => new HttpResponse(null, { status }))); describe("rootLoader", () => { - it("returns the live snapshot on success", async () => { + it("returns a fresh snapshot with authoritative cache presence", async () => { const { rootLoader } = await import("./loaders"); const data = await rootLoader(); - expect(data.error).toBe(false); - expect(data.authError).toBe(false); + expect(data.snapshotStale).toBe(false); + expect(data.snapshotAuthError).toBe(false); + expect(data.snapshotHasLastGood).toBe(true); expect(data.bridge).toBe("connected"); expect(data.agents).toHaveLength(2); }); - it.each([401, 403] as const)("marks a %i response as an auth error", async (status) => { - rejectSnapshot(status); + it("reports a cold root failure without inventing a cached snapshot", async () => { + failSnapshot(); const { rootLoader } = await import("./loaders"); const data = await rootLoader(); - expect(data.error).toBe(true); - expect(data.authError).toBe(true); + expect(data.snapshotStale).toBe(true); + expect(data.snapshotAuthError).toBe(false); + expect(data.snapshotHasLastGood).toBe(false); + expect(data.agents).toEqual([]); + expect(data.bridge).toBeUndefined(); }); - it("keeps the last-good herd (flagged error) when a refresh fails", async () => { + it.each([401, 403] as const)("gives root auth precedence for a %i response", async (status) => { + rejectSnapshot(status); const { rootLoader } = await import("./loaders"); - await rootLoader(); // prime the cache with a good snapshot + const data = await rootLoader(); + expect(data.snapshotStale).toBe(true); + expect(data.snapshotAuthError).toBe(true); + expect(data.snapshotHasLastGood).toBe(false); + }); + it("keeps the last-good root snapshot on a failed refresh", async () => { + const { rootLoader } = await import("./loaders"); + await rootLoader(); failSnapshot(); - const stale = await rootLoader(); - expect(stale.error).toBe(true); - expect(stale.authError).toBe(false); - expect(stale.bridge).toBe("connected"); // from the cached snapshot - expect(stale.agents).toHaveLength(2); + const stale = await rootLoader(); + expect(stale.snapshotStale).toBe(true); + expect(stale.snapshotAuthError).toBe(false); + expect(stale.snapshotHasLastGood).toBe(true); + expect(stale.bridge).toBe("connected"); expect(stale.agents[0]!.paneId).toBe(fixtureAgents[0]!.paneId); }); - it("does not mark a network error as an auth error", async () => { + it("keeps cache presence for an intentionally empty root snapshot", async () => { + let calls = 0; + server.use( + http.get("/api/snapshot", () => { + calls += 1; + return calls === 1 + ? HttpResponse.json({ ...fixtureSnapshot, agents: [], shellPanes: [] }) + : new HttpResponse(null, { status: 500 }); + }), + ); const { rootLoader } = await import("./loaders"); - vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("network failed")); - const data = await rootLoader(); - expect(data.error).toBe(true); - expect(data.authError).toBe(false); - }); + await rootLoader(); + const stale = await rootLoader(); - it("returns empty + error when there is no last-good snapshot", async () => { - failSnapshot(); - const { rootLoader } = await import("./loaders"); - const data = await rootLoader(); - expect(data.error).toBe(true); - expect(data.agents).toEqual([]); - expect(data.bridge).toBeUndefined(); + expect(stale.snapshotHasLastGood).toBe(true); + expect(stale.agents).toEqual([]); }); - it("treats a cold-start TimeoutError as an error snapshot, NOT a rethrow to the error boundary", async () => { - // The cold-start-against-a-dead-host case: the first snapshot fetch aborts at its timeout with a - // DOMException named "TimeoutError" (distinct from the "AbortError" of a superseded revalidation). - // The loader must fall into the error-snapshot branch so RootLayout + the escalation prompt handle - // it uniformly — it must NOT bubble to RootError's generic "Something went wrong" screen. + it("still fetches each root navigation after a failure", async () => { const { rootLoader } = await import("./loaders"); - vi.spyOn(globalThis, "fetch").mockRejectedValue(new DOMException("timed out", "TimeoutError")); - const data = await rootLoader(); - expect(data.error).toBe(true); - expect(data.authError).toBe(false); - expect(data.bridge).toBeUndefined(); - expect(data.agents).toEqual([]); + failSnapshot(); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + await rootLoader({ request: new Request("http://localhost/") }); + await rootLoader({ request: new Request("http://localhost/space/w1") }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); }); - it("surfaces the snapshot's optional update field onto the loader data", async () => { + it("surfaces the snapshot's optional update and voice capability", async () => { const update = { current: "0.11.0", latest: "0.12.0", @@ -97,90 +107,100 @@ describe("rootLoader", () => { checkedAt: 123, }; server.use( - http.get("/api/snapshot", () => HttpResponse.json({ ...fixtureSnapshot, update })), + http.get("/api/snapshot", () => + HttpResponse.json({ ...fixtureSnapshot, update, transcriptionEnabled: true }), + ), ); const { rootLoader } = await import("./loaders"); const data = await rootLoader(); expect(data.update).toEqual(update); - }); - - it("leaves update undefined when the snapshot omits it (older bridge)", async () => { - const { rootLoader } = await import("./loaders"); - const data = await rootLoader(); - expect(data.update).toBeUndefined(); - }); - - it("threads only the transcription capability and fails closed for an older bridge", async () => { - const { rootLoader } = await import("./loaders"); - expect((await rootLoader()).transcriptionEnabled).toBe(false); - - server.use( - http.get("/api/snapshot", () => - HttpResponse.json({ ...fixtureSnapshot, transcriptionEnabled: true }), - ), - ); - expect((await rootLoader()).transcriptionEnabled).toBe(true); + expect(data.transcriptionEnabled).toBe(true); }); }); describe("paneLoader", () => { - it("returns pane text on success", async () => { + it("returns a fresh pane result with authoritative cache presence", async () => { const { paneLoader } = await import("./loaders"); const data = await paneLoader({ params: { paneId: "w1:p1" } }); - expect(data.error).toBe(false); - expect(data.authError).toBe(false); - expect(data.paneId).toBe("w1:p1"); + expect(data.paneStale).toBe(false); + expect(data.paneAuthError).toBe(false); + expect(data.paneHasLastGood).toBe(true); expect(data.text).toBe(paneTextWithDraft()); }); - it.each([401, 403] as const)("marks a %i response as an auth error", async (status) => { + it("reports a cold pane failure without cached output", async () => { + failPane(); + const { paneLoader } = await import("./loaders"); + const data = await paneLoader({ params: { paneId: "wX:p9" } }); + expect(data.paneStale).toBe(true); + expect(data.paneAuthError).toBe(false); + expect(data.paneHasLastGood).toBe(false); + expect(data.text).toBe(""); + }); + + it.each([401, 403] as const)("gives pane auth precedence for a %i response", async (status) => { rejectPane(status); const { paneLoader } = await import("./loaders"); const data = await paneLoader({ params: { paneId: "w1:p1" } }); - expect(data.error).toBe(true); - expect(data.authError).toBe(true); + expect(data.paneStale).toBe(true); + expect(data.paneAuthError).toBe(true); + expect(data.paneHasLastGood).toBe(false); }); - it("keeps the last-good pane text (flagged error) when a refresh fails", async () => { + it("keeps a stale cached pane, including intentionally empty output", async () => { + let calls = 0; + server.use( + http.get(/\/api\/pane\/[^/]+$/, () => { + calls += 1; + return calls === 1 + ? HttpResponse.json({ paneId: "w1:p1", text: "", truncated: false, revision: 1 }) + : new HttpResponse(null, { status: 500 }); + }), + ); const { paneLoader } = await import("./loaders"); - await paneLoader({ params: { paneId: "w1:p1" } }); // prime per-pane cache - - failPane(); + await paneLoader({ params: { paneId: "w1:p1" } }); const stale = await paneLoader({ params: { paneId: "w1:p1" } }); - expect(stale.error).toBe(true); - expect(stale.authError).toBe(false); - expect(stale.text).toBe(paneTextWithDraft()); - expect(stale.paneId).toBe("w1:p1"); + expect(stale.paneStale).toBe(true); + expect(stale.paneAuthError).toBe(false); + expect(stale.paneHasLastGood).toBe(true); + expect(stale.text).toBe(""); }); - it("returns empty text + error when no last-good exists for that pane", async () => { - failPane(); - const { paneLoader } = await import("./loaders"); - const data = await paneLoader({ params: { paneId: "wX:p9" } }); - expect(data.error).toBe(true); - expect(data.text).toBe(""); - expect(data.paneId).toBe("wX:p9"); + it("keeps root and pane outcomes independent", async () => { + const { rootLoader, paneLoader } = await import("./loaders"); + await rootLoader(); + failSnapshot(); + const staleRoot = await rootLoader(); + const freshPane = await paneLoader({ params: { paneId: "w1:p1" } }); + + expect(staleRoot.snapshotStale).toBe(true); + expect(freshPane.paneStale).toBe(false); }); - it("treats a TimeoutError from fetchPane as degraded (stale text + error), NOT a rethrow", async () => { - // A request that times out aborts with a DOMException named "TimeoutError" — distinct from the - // "AbortError" of a superseded revalidation. The loader rethrows only AbortError, so a timeout - // must fall into the stale-data branch (keep the last-good text on screen, flagged) and not - // bubble up as if the run were superseded. - const { paneLoader } = await import("./loaders"); - await paneLoader({ params: { paneId: "w1:p1" } }); // prime the per-pane stale cache (via MSW) + it("keeps a fresh root result when only the pane refresh fails", async () => { + const { rootLoader, paneLoader } = await import("./loaders"); + await paneLoader({ params: { paneId: "w1:p1" } }); + failPane(); - vi.spyOn(globalThis, "fetch").mockRejectedValue(new DOMException("timed out", "TimeoutError")); - const stale = await paneLoader({ params: { paneId: "w1:p1" } }); + const freshRoot = await rootLoader(); + const stalePane = await paneLoader({ params: { paneId: "w1:p1" } }); - expect(stale.error).toBe(true); - expect(stale.authError).toBe(false); - expect(stale.text).toBe(paneTextWithDraft()); - expect(stale.paneId).toBe("w1:p1"); + expect(freshRoot.snapshotStale).toBe(false); + expect(stalePane.paneStale).toBe(true); }); - it("throws on a missing :paneId param (fail-loud to the error boundary)", async () => { + it("does not share auth outcomes between root and pane loaders", async () => { + rejectPane(401); + const { rootLoader, paneLoader } = await import("./loaders"); + const pane = await paneLoader({ params: { paneId: "w1:p1" } }); + const root = await rootLoader(); + + expect(pane.paneAuthError).toBe(true); + expect(root.snapshotAuthError).toBe(false); + }); + + it("throws on a missing :paneId param", async () => { const { paneLoader } = await import("./loaders"); await expect(paneLoader({ params: {} })).rejects.toThrow(/paneId/); }); @@ -285,7 +305,8 @@ describe("loaders — session scoping", () => { failSnapshot(); // now every snapshot 500s const stale = await rootLoader({ request: new Request("http://localhost/?s=collie-demo") }); - expect(stale.error).toBe(true); + expect(stale.snapshotStale).toBe(true); + expect(stale.snapshotHasLastGood).toBe(false); expect(stale.session).toBe("collie-demo"); expect(stale.agents).toEqual([]); // NOT the primary session's cached herd expect(stale.bridge).toBeUndefined(); @@ -299,144 +320,9 @@ describe("loaders — session scoping", () => { }); }); -// A PWA must navigate INSTANTLY to last-known data while offline. During a KNOWN, escalated outage -// (the shared connection-health store has latched "lost"), a NAVIGATION (loader run at a NEW url) skips -// the doomed fetch and returns cache immediately (flagged error); a REVALIDATION (same url — the poll) -// still really fetches, so recovery is discovered and the stale data swapped out. connection-health is -// imported AFTER vi.resetModules() alongside loaders so both share one fresh module instance (the latch -// the test sets is the one the loader reads). -describe("loaders — offline navigation fast path", () => { - it("a navigation during a known outage returns the cached snapshot INSTANTLY (error, no fetch)", async () => { - const { rootLoader } = await import("./loaders"); - const { latchLost } = await import("./connection-health"); - - await rootLoader({ request: new Request("http://localhost/") }); // prime the last-good snapshot - latchLost(); // escalated outage - - const fetchSpy = vi.spyOn(globalThis, "fetch"); - // Different url ⇒ navigation ⇒ fast path: cache returned without touching the network. - const data = await rootLoader({ request: new Request("http://localhost/space/w1") }); - - expect(fetchSpy).not.toHaveBeenCalled(); - expect(data.error).toBe(true); // flagged stale - expect(data.bridge).toBe("connected"); // last-known herd - expect(data.agents).toHaveLength(2); - }); - - it("keeps the last auth classification on the navigation fast path", async () => { - rejectSnapshot(401); - const { rootLoader } = await import("./loaders"); - const { latchLost } = await import("./connection-health"); - - const rejected = await rootLoader({ request: new Request("http://localhost/") }); - expect(rejected.authError).toBe(true); - latchLost(); - - const fetchSpy = vi.spyOn(globalThis, "fetch"); - const data = await rootLoader({ request: new Request("http://localhost/space/w1") }); - - expect(fetchSpy).not.toHaveBeenCalled(); - expect(data.error).toBe(true); - expect(data.authError).toBe(true); - }); - - it("a revalidation (same url) still really fetches while latched — polls keep probing", async () => { - const { rootLoader } = await import("./loaders"); - const { latchLost } = await import("./connection-health"); - - await rootLoader({ request: new Request("http://localhost/") }); // sets lastRootUrl = "/" - latchLost(); - - const fetchSpy = vi.spyOn(globalThis, "fetch"); - await rootLoader({ request: new Request("http://localhost/") }); // same url ⇒ revalidation - expect(fetchSpy).toHaveBeenCalled(); - }); - - it("recovery: the next successful revalidation clears the latch and returns fresh, live data", async () => { - const { rootLoader } = await import("./loaders"); - const { latchLost, isLostLatched } = await import("./connection-health"); - - await rootLoader({ request: new Request("http://localhost/") }); - latchLost(); - expect(isLostLatched()).toBe(true); - - const data = await rootLoader({ request: new Request("http://localhost/") }); // lands (MSW success) - expect(data.error).toBe(false); - expect(isLostLatched()).toBe(false); // markLive cleared the latch - }); - - it("navigating to an UNVISITED pane during an outage returns a degraded pane INSTANTLY (no fetch)", async () => { - const { paneLoader } = await import("./loaders"); - const { latchLost } = await import("./connection-health"); - latchLost(); - - const fetchSpy = vi.spyOn(globalThis, "fetch"); - const data = await paneLoader({ - params: { paneId: "wX:p9" }, - request: new Request("http://localhost/pane/wX:p9"), - }); - - expect(fetchSpy).not.toHaveBeenCalled(); - expect(data.error).toBe(true); - expect(data.text).toBe(""); // never fetched → empty mirror, but instant (no 10s hang) - expect(data.revision).toBe(0); - }); - - it("returning to a PREVIOUSLY-VISITED pane during an outage shows its stale mirror INSTANTLY", async () => { - const { rootLoader, paneLoader } = await import("./loaders"); - const { latchLost } = await import("./connection-health"); - - // Visit the pane (healthy) so its text is cached, then leave to the dashboard — rootLoader clears - // the pane discriminator so a RETURN reads as a fresh navigation, not a poll. - await paneLoader({ - params: { paneId: "w1:p1" }, - request: new Request("http://localhost/pane/w1:p1"), - }); - await rootLoader({ request: new Request("http://localhost/") }); - - latchLost(); - const fetchSpy = vi.spyOn(globalThis, "fetch"); - const data = await paneLoader({ - params: { paneId: "w1:p1" }, - request: new Request("http://localhost/pane/w1:p1"), - }); - - expect(fetchSpy).not.toHaveBeenCalled(); - expect(data.error).toBe(true); - expect(data.text).toBe(paneTextWithDraft()); // the stale mirror - }); - - it("polling within a pane during an outage keeps fetching (same url ⇒ revalidation)", async () => { - const { paneLoader } = await import("./loaders"); - const { latchLost } = await import("./connection-health"); - - await paneLoader({ - params: { paneId: "w1:p1" }, - request: new Request("http://localhost/pane/w1:p1"), - }); - latchLost(); - - const fetchSpy = vi.spyOn(globalThis, "fetch"); - await paneLoader({ - params: { paneId: "w1:p1" }, - request: new Request("http://localhost/pane/w1:p1"), // same url ⇒ poll ⇒ must fetch - }); - expect(fetchSpy).toHaveBeenCalled(); - }); - - it("does NOT fast-path when the connection is not latched (a brief blip still fetches)", async () => { - const { rootLoader } = await import("./loaders"); - // No latchLost(): a transient blip must keep really fetching on navigation, not serve stale. - await rootLoader({ request: new Request("http://localhost/") }); - const fetchSpy = vi.spyOn(globalThis, "fetch"); - await rootLoader({ request: new Request("http://localhost/space/w1") }); // navigation, but not latched - expect(fetchSpy).toHaveBeenCalled(); - }); -}); - // A superseded revalidation aborts the in-flight fetch via request.signal. The loaders must -// RETHROW that AbortError (so React Router discards the stale run) rather than swallow it into the -// stale-data/error-banner branch — otherwise a fast poll would flash a spurious "reconnecting…". +// RETHROW that AbortError (so React Router discards the stale run) rather than treating a +// superseded poll as a genuine stale-freshness result. describe("loaders — aborted request", () => { function abortedRequest(): Request { const controller = new AbortController(); diff --git a/web/src/lib/loaders.ts b/web/src/lib/loaders.ts index 2455dfa3..35bc2fc3 100644 --- a/web/src/lib/loaders.ts +++ b/web/src/lib/loaders.ts @@ -1,20 +1,11 @@ // React Router data loaders are the data layer — there is intentionally no separate data-fetching // library. The home/detail routes declare these as `loader`s; polling is just -// `useRevalidator().revalidate()` re-running them (see hooks/use-polling.ts). Each loader keeps the -// last good result in a module cache so a transient fetch failure shows stale-but-present data -// (flagged) instead of flashing empty — i.e. keep-previous-data while a refetch is in flight. -// -// Offline fast path (a PWA should navigate instantly to last-known data): during a KNOWN, escalated -// outage (the shared connection-health store has latched "lost"), a NAVIGATION must not block on a -// fetch that will only time out — it returns cached data immediately (flagged error). A REVALIDATION -// (the poll) must keep really fetching so recovery is discovered and the stale data swapped out. React -// Router never tells a loader which kind of run it is, but the request URL does: a revalidation re-runs -// a loader at the SAME url; a navigation runs it at a DIFFERENT one (see isNavigation below). No timer, -// no flag, no race — and because a navigation aborts any in-flight revalidation, the nav is instant -// even while a poll's doomed fetch is still hanging. +// `useRevalidator().revalidate()` re-running them (see hooks/use-polling.ts). Each loader keeps its +// own last-good result in a module cache so a transient fetch failure shows stale data instead of +// flashing empty. Root-snapshot and pane freshness stay independent: every loader run attempts its +// own endpoint, and a successful or failed surface never changes another surface's outcome. import { fetchHistory, fetchPane, fetchSnapshot, isApiErrorStatus } from "@/lib/api"; -import { isLostLatched } from "@/lib/connection-health"; import { SESSION_PARAM, normalizeSession } from "@/lib/session"; import type { AgentView, @@ -31,8 +22,8 @@ import type { } from "@/lib/types"; // A superseded revalidation is aborted via the loader's request.signal; that surfaces as an -// AbortError we must RETHROW so React Router discards the stale run — swallowing it into the -// stale-data/error-banner path would flash a spurious "reconnecting…" on every fast poll. +// AbortError we must RETHROW so React Router discards the stale run rather than treating a +// superseded poll as a genuine stale-freshness result. function isAbortError(e: unknown): boolean { return ( typeof e === "object" && @@ -77,10 +68,12 @@ export interface HomeData { update: UpdateInfo | undefined; /** True only when the bridge explicitly advertises the server-side voice capability. */ transcriptionEnabled: boolean; - /** True when this render is the last-good snapshot after a failed refresh. */ - error: boolean; - /** True when the failed refresh was rejected with HTTP 401 or 403. */ - authError: boolean; + /** True when this render is stale after a failed root-snapshot refresh. */ + snapshotStale: boolean; + /** True when the failed root-snapshot refresh was rejected with HTTP 401 or 403. */ + snapshotAuthError: boolean; + /** True when a last-good root snapshot exists, including an intentionally empty snapshot. */ + snapshotHasLastGood: boolean; } export interface PaneData { @@ -94,54 +87,25 @@ export interface PaneData { * stale in-flight poll (a "Load older" tap raises this; see growRequestedLines). */ requestedLines: number; /** Herdr's monotonic revision for `text` — the prompt-select race guard checks against it. 0 on - * the degraded (stale-text) path, where the guard's fresh fetch will reject a mismatch anyway. */ + * the stale-text path, where the guard's fresh fetch will reject a mismatch anyway. */ revision: number; - error: boolean; - /** True when the failed refresh was rejected with HTTP 401 or 403. */ - authError: boolean; + /** True when this render is stale after a failed pane refresh. */ + paneStale: boolean; + /** True when the failed pane refresh was rejected with HTTP 401 or 403. */ + paneAuthError: boolean; + /** True when a last-good pane result exists, including intentionally empty output. */ + paneHasLastGood: boolean; } // Keep-previous-data cache is now PER-SESSION: switching sessions must not show the other session's // herd flagged as stale. Keyed by session name ("" = primary). const lastSnapshot = new Map(); -// A latched navigation skips the network, so retain whether the last real outcome for each session -// was an auth rejection. Store only rejected sessions; every other real outcome removes the marker. -const authErrorSessions = new Set(); - -function rememberAuthError(session: string | undefined, authError: boolean): void { - const key = session ?? ""; - if (authError) authErrorSessions.add(key); - else authErrorSessions.delete(key); -} - -function hasAuthError(session: string | undefined): boolean { - return authErrorSessions.has(session ?? ""); -} - function isAuthError(error: unknown): boolean { return isApiErrorStatus(error, 401) || isApiErrorStatus(error, 403); } -// The URL each loader last RAN for — the nav-vs-revalidate discriminator for the offline fast path (see -// the header comment). Module-scoped so it survives revalidations (the loader re-runs every poll) and -// resets on a full reload — same lifetime as the caches. `lastRootUrl` is enough for the root loader -// because it runs on EVERY navigation (it's the parent of all routes); the pane loader only runs while -// a pane is mounted, so `lastRootUrl` also CLEARS `lastPaneUrl` whenever we're on a non-pane URL — that -// way re-entering the same pane (pane → home → same pane) reads as a fresh navigation, not a poll. -let lastRootUrl: string | undefined; -let lastPaneUrl: string | undefined; - -function isPaneUrl(url: string | undefined): boolean { - if (!url) return false; - try { - return new URL(url).pathname.startsWith("/pane/"); - } catch { - return url.includes("/pane/"); - } -} - -function toHomeData(snap: SnapshotResponse, session: string | undefined, error: boolean): HomeData { +function toHomeData(snap: SnapshotResponse, session: string | undefined): HomeData { return { bridge: snap.bridge, device: snap.device, @@ -155,62 +119,53 @@ function toHomeData(snap: SnapshotResponse, session: string | undefined, error: update: snap.update, // An older bridge omits the capability; fail closed to the existing text-only composer. transcriptionEnabled: snap.transcriptionEnabled ?? false, - error, - authError: error && hasAuthError(session), + snapshotStale: false, + snapshotAuthError: false, + snapshotHasLastGood: lastSnapshot.has(session ?? ""), }; } -// Last-known home for a session, flagged stale — the cached snapshot if we have one, else an empty -// error snapshot. Shared by BOTH the failed-refresh catch and the offline navigation fast path, so the -// two return byte-identical shapes (the UI can't tell "fetch just failed" from "navigated while known- -// offline" — both are "stale-but-present, flagged"). -function staleHome(session: string | undefined): HomeData { - const cached = lastSnapshot.get(session ?? ""); - return cached - ? toHomeData(cached, session, true) - : { - bridge: undefined, - device: undefined, - agents: [], - shellPanes: [], - workspaces: [], - tabs: [], - sessions: [], - session, - snoozedUntil: null, - update: undefined, - transcriptionEnabled: false, - error: true, - authError: hasAuthError(session), - }; +// Last-known root snapshot for a session, flagged stale. `Map.has()` deliberately distinguishes an +// intentionally empty cached snapshot from a cold failure with nothing to show. +function staleHome(session: string | undefined, snapshotAuthError: boolean): HomeData { + const key = session ?? ""; + const snapshotHasLastGood = lastSnapshot.has(key); + const cached = lastSnapshot.get(key); + if (snapshotHasLastGood && cached) { + return { + ...toHomeData(cached, session), + snapshotStale: true, + snapshotAuthError, + snapshotHasLastGood, + }; + } + return { + bridge: undefined, + device: undefined, + agents: [], + shellPanes: [], + workspaces: [], + tabs: [], + sessions: [], + session, + snoozedUntil: null, + update: undefined, + transcriptionEnabled: false, + snapshotStale: true, + snapshotAuthError, + snapshotHasLastGood, + }; } export async function rootLoader({ request }: { request?: Request } = {}): Promise { const session = sessionFromRequest(request); - // Nav-vs-revalidate: a revalidation (poll) re-runs at the SAME url; a navigation runs at a different - // one. Cold start (lastRootUrl undefined) reads as a navigation too, but the latch gate below is - // never set that early, so the first run always really fetches (BootSplash + escalation, as today). - const url = request?.url; - const isNavigation = lastRootUrl !== url; - lastRootUrl = url; - // Leaving a pane clears the pane loader's discriminator so a later return to it reads as a fresh nav. - if (!isPaneUrl(url)) lastPaneUrl = undefined; - - // Fast path: a navigation during a known, escalated outage returns last-known data INSTANTLY rather - // than hanging on a doomed fetch. Revalidations fall through and really fetch (so recovery lands and - // markLive clears the latch → the next run fetches live and replaces the stale herd). - if (isNavigation && isLostLatched()) return staleHome(session); - try { const snap = await fetchSnapshot(session, request?.signal); lastSnapshot.set(session ?? "", snap); - rememberAuthError(session, false); - return toHomeData(snap, session, false); + return toHomeData(snap, session); } catch (e) { if (isAbortError(e)) throw e; // superseded revalidation — let React Router drop it - rememberAuthError(session, isAuthError(e)); - // Keep the last good herd on screen, flagged so the ConnectionBanner can say "reconnecting…". - return staleHome(session); + return staleHome(session, isAuthError(e)); } } @@ -280,19 +235,26 @@ export function resetRequestedLines(paneId?: string, session?: string): void { else requestedLines.delete(paneKey(paneId, session)); } -// Last-known pane payload, flagged degraded — stale text (empty if this pane was never fetched), -// truncated cleared, revision 0 (the prompt-select guard rejects a 0-revision mismatch anyway). Shared -// by the failed-refresh catch and the offline navigation fast path, so both return the same shape. -function stalePane(paneId: string, session: string | undefined, lines: number): PaneData { +// Last-known pane payload, flagged stale. `Map.has()` deliberately distinguishes an empty cached +// pane from a cold failure with no output. The stale path clears metadata that cannot be current. +function stalePane( + paneId: string, + session: string | undefined, + lines: number, + paneAuthError: boolean, +): PaneData { + const key = paneKey(paneId, session); + const paneHasLastGood = lastPaneText.has(key); return { paneId, session, - text: lastPaneText.get(paneKey(paneId, session)) ?? "", + text: lastPaneText.get(key) ?? "", truncated: false, requestedLines: lines, revision: 0, - error: true, - authError: hasAuthError(session), + paneStale: true, + paneAuthError, + paneHasLastGood, }; } @@ -310,25 +272,13 @@ export async function paneLoader({ const session = sessionFromRequest(request); const key = paneKey(paneId, session); const lines = getRequestedLines(paneId, session); - // Nav-vs-revalidate, as in rootLoader. `lastPaneUrl` also flips to undefined whenever rootLoader sees - // a non-pane URL, so opening a pane (even one just left) reads as a navigation, and polling within it - // (same URL) reads as a revalidation. - const url = request?.url; - const isNavigation = lastPaneUrl !== url; - lastPaneUrl = url; - - // Fast path: navigating to a pane during a known, escalated outage shows its last-known mirror (or an - // empty degraded pane if never visited) INSTANTLY — never a 10s hang on a fetch that can't land. - if (isNavigation && isLostLatched()) return stalePane(paneId, session, lines); try { - // On a 304 fetchPane returns the cached body, so `read.text` is populated either way; the - // `?? lastPaneText` is just belt-and-suspenders. Both paths are a success (not the error - // branch) so the connection bar doesn't flicker on an unchanged poll. + // On a 304 fetchPane returns the cached body, so `read.text` is populated either way. An empty + // successful read is still authoritative and must replace rather than infer from the old text. const read: PaneReadResponse = await fetchPane(paneId, lines, session, request?.signal); - const text = read.text || lastPaneText.get(key) || ""; + const text = read.text; rememberPaneText(key, text); - rememberAuthError(session, false); return { paneId, session, @@ -336,14 +286,13 @@ export async function paneLoader({ truncated: read.truncated, requestedLines: lines, revision: read.revision, - error: false, - authError: false, + paneStale: false, + paneAuthError: false, + paneHasLastGood: lastPaneText.has(key), }; } catch (e) { if (isAbortError(e)) throw e; // superseded revalidation — let React Router drop it - rememberAuthError(session, isAuthError(e)); - // Genuine network / server failure: show stale text flagged as degraded. - return stalePane(paneId, session, lines); + return stalePane(paneId, session, lines, isAuthError(e)); } } diff --git a/web/src/lib/menu-action.test.ts b/web/src/lib/menu-action.test.ts index c1fb054a..b3ba34f0 100644 --- a/web/src/lib/menu-action.test.ts +++ b/web/src/lib/menu-action.test.ts @@ -42,7 +42,13 @@ function menuAt(at = 1) { return detectMenu(splitLines(parseAnsi(pickerBuffer(at))))!; } -const base = { paneId: "w1:p1", requestedLines: 200, detectedRevision: 0, agent: "claude" }; +const base = { + paneId: "w1:p1", + requestedLines: 200, + detectedRevision: 0, + agent: "claude", + canWrite: () => true, +}; beforeEach(() => { vi.clearAllMocks(); diff --git a/web/src/lib/menu-action.ts b/web/src/lib/menu-action.ts index a42ae4c7..967c3b84 100644 --- a/web/src/lib/menu-action.ts +++ b/web/src/lib/menu-action.ts @@ -36,6 +36,8 @@ export async function submitMenuKeys(args: { nav?: boolean; /** The session the pane lives in (undefined = primary) — scopes the read + keystroke. */ session?: string; + /** Live pane-write permission, rechecked after the freshness guard and before the send. */ + canWrite: () => boolean; /** The pane's agent — which adapter re-derives the fresh screen. No adapter = the guard refuses. */ agent?: string; }): Promise { diff --git a/web/src/lib/multi-select-action.test.ts b/web/src/lib/multi-select-action.test.ts index 37d1a623..e3e185dd 100644 --- a/web/src/lib/multi-select-action.test.ts +++ b/web/src/lib/multi-select-action.test.ts @@ -101,6 +101,7 @@ const base = { // The guard re-derives through the pane's ADAPTER (lib/dialog-guard.ts), so every call names the // agent whose grammar produced the fixture — an agent with no adapter fails the guard closed. agent: "claude", + canWrite: () => true, sleep: noSleep, }; const keysSent = () => mockSendKeys.mock.calls.map((c) => c[1]); diff --git a/web/src/lib/multi-select-action.ts b/web/src/lib/multi-select-action.ts index 4e8e9b1a..a5c34ccf 100644 --- a/web/src/lib/multi-select-action.ts +++ b/web/src/lib/multi-select-action.ts @@ -52,6 +52,8 @@ interface GuardArgs { multi: MultiSelectModel; /** The session the pane lives in (undefined = primary) — scopes every read + keystroke below. */ session?: string; + /** Live pane-write permission, rechecked before every macro or single-key write. */ + canWrite: () => boolean; /** The pane's agent — which adapter re-derives the fresh screen. No adapter = the guard refuses. */ agent?: string; /** Test seam for the verification polls' pacing. */ diff --git a/web/src/lib/preview-action.test.ts b/web/src/lib/preview-action.test.ts index ccf72180..cc8518c2 100644 --- a/web/src/lib/preview-action.test.ts +++ b/web/src/lib/preview-action.test.ts @@ -90,6 +90,7 @@ const base = { // The guard re-derives through the pane's ADAPTER (lib/dialog-guard.ts), so every call names the // agent whose grammar produced the fixture — an agent with no adapter fails the guard closed. agent: "claude", + canWrite: () => true, sleep: noSleep, }; @@ -244,6 +245,36 @@ describe("submitPreviewNote — n → verify focus → clear → type → Escape expect(mockSendReply.mock.calls).toEqual([["w1:p1", "focus on mobile", false, undefined]]); }); + it("does not type a note after voice locks the pane during focus verification", async () => { + const m = model({}); + let resolveFocused!: (value: ReturnType) => void; + mockFetchPane + .mockResolvedValueOnce(paneWith(buffer({}))) + .mockImplementationOnce( + () => + new Promise>((resolve) => { + resolveFocused = resolve; + }), + ); + let voiceBusy = false; + + const action = submitPreviewNote({ + ...base, + canWrite: () => !voiceBusy, + preview: m, + text: "focus on mobile", + }); + await vi.waitFor(() => expect(mockSendKeys).toHaveBeenCalledTimes(1)); + + // Opening the note happened while idle; the subsequent reply write must yield to recording. + voiceBusy = true; + resolveFocused(paneWith(buffer({ editing: true }))); + + await expect(action).resolves.toEqual({ status: "changed" }); + expect(mockSendKeys.mock.calls).toEqual([["w1:p1", ["n"], undefined, m.regionSignature]]); + expect(mockSendReply).not.toHaveBeenCalled(); + }); + it("returns changed when the bound note-open write reports prompt_changed", async () => { const m = model({}); mockFetchPane.mockResolvedValueOnce(paneWith(buffer({}))); diff --git a/web/src/lib/preview-action.ts b/web/src/lib/preview-action.ts index 9ce38fb4..3e2491d6 100644 --- a/web/src/lib/preview-action.ts +++ b/web/src/lib/preview-action.ts @@ -18,9 +18,9 @@ // (`previewsEqual` for the entry, `previewCoreEqual` for the mid-flight identity) are the neutral // contract in harness/preview-model.ts, wired to this kind by harness/dialog-contract.ts. -import { sendKeys, sendReply } from "./api"; +import { sendReply } from "./api"; import { type PreviewOption, type PreviewSelectModel } from "./blocks"; -import { guardDialog, pollDialog, type DialogTarget } from "./dialog-guard"; +import { guardDialog, pollDialog, sendBoundKeys, type DialogTarget } from "./dialog-guard"; import { previewCoreEqual, previewStructureEqual } from "./harness/preview-model"; import { sanitizeTypedText, type ActionResult, type Sleep } from "./harness/guard"; @@ -49,6 +49,8 @@ interface GuardArgs { preview: PreviewSelectModel; /** The session the pane lives in (undefined = primary) — scopes every read + keystroke below. */ session?: string; + /** Live pane-write permission, rechecked before every key or reply write. */ + canWrite: () => boolean; /** The pane's agent — which adapter re-derives the fresh screen. No adapter = the guard refuses. */ agent?: string; /** Test seam for the verification polls' pacing. */ @@ -66,19 +68,9 @@ export async function submitPreviewOption( const guarded = await guardDialog(target(args)); if (!guarded.ok) return guarded.result; - try { - // Bind only this first write. It changes the dialog, so later steps must not reuse this region. - const digit = await sendKeys( - args.paneId, - [String(args.option.n)], - args.session, - guarded.region, - ); - if (!digit.ok && digit.code === "prompt_changed") return { status: "changed" }; - if (!digit.ok) return { status: "error", error: digit.error }; - } catch (e) { - return { status: "error", error: e instanceof Error ? e.message : String(e) }; - } + // Bind only this first write. It changes the dialog, so later steps must not reuse this region. + const digit = await sendBoundKeys(target(args), [String(args.option.n)], guarded.region); + if (digit.status !== "sent") return digit; const pointed = await pollDialog( target(args), @@ -88,13 +80,7 @@ export async function submitPreviewOption( ); if (pointed !== "ok") return { status: "changed" }; - try { - const enter = await sendKeys(args.paneId, ["Enter"], args.session); - if (!enter.ok) return { status: "error", error: enter.error }; - return { status: "sent" }; - } catch (e) { - return { status: "error", error: e instanceof Error ? e.message : String(e) }; - } + return sendBoundKeys(target(args), ["Enter"]); } /** @@ -120,14 +106,9 @@ export async function submitPreviewNote( const editing = (m: PreviewSelectModel) => previewCoreEqual(m, args.preview) && m.note.state === "editing"; - try { - // Bind only this first write. It changes the dialog, so later steps must not reuse this region. - const open = await sendKeys(args.paneId, ["n"], args.session, guarded.region); - if (!open.ok && open.code === "prompt_changed") return { status: "changed" }; - if (!open.ok) return { status: "error", error: open.error }; - } catch (e) { - return { status: "error", error: e instanceof Error ? e.message : String(e) }; - } + // Bind only this first write. It changes the dialog, so later steps must not reuse this region. + const open = await sendBoundKeys(target(args), ["n"], guarded.region); + if (open.status !== "sent") return open; // The input must be FOCUSED before anything else is sent — early keys are misrouted (verified). // On timeout we stop dead: a blind Escape could cancel the whole dialog if `n` never landed. @@ -140,12 +121,11 @@ export async function submitPreviewNote( // Deterministic clear: the restored cursor position is unreliable, so kill the tail from // wherever it is, then sweep the head with Backspaces (no-ops once the text is gone). Then // wait until the input verifiably shows empty before typing into it. - const clear = await sendKeys( - args.paneId, + const clear = await sendBoundKeys( + target(args), ["ctrl+k", ...Array.from({ length: CLEAR_SWEEP }, () => "Backspace")], - args.session, ); - if (!clear.ok) return { status: "error", error: clear.error }; + if (clear.status !== "sent") return clear; if ( (await pollDialog(target(args), (m) => editing(m) && m.note.text === "")) !== "ok" ) { @@ -153,6 +133,7 @@ export async function submitPreviewNote( } } if (text.length > 0) { + if (!args.canWrite()) return { status: "changed" }; const typed = await sendReply(args.paneId, text, false, args.session); if (!typed.ok) return { status: "error", error: typed.error }; // Wait for the text to render. The input windows long text around the trailing cursor, so @@ -171,8 +152,8 @@ export async function submitPreviewNote( // (a successor dialog, or a now-running agent — pollUntil returns "drifted"), a second blind // Escape would cancel/interrupt whatever is there now — so abort with "changed" and send nothing. for (let attempt = 0; attempt < 2; attempt++) { - const blur = await sendKeys(args.paneId, ["Escape"], args.session); - if (!blur.ok) return { status: "error", error: blur.error }; + const blur = await sendBoundKeys(target(args), ["Escape"]); + if (blur.status !== "sent") return blur; const blurred = await pollDialog( target(args), (m) => previewCoreEqual(m, args.preview) && m.note.state !== "editing", @@ -196,13 +177,6 @@ export async function submitPreviewKeys( ): Promise { const guarded = await guardDialog(target(args)); if (!guarded.ok) return guarded.result; - try { - // Bind only this first write. It changes the dialog, so later steps must not reuse this region. - const res = await sendKeys(args.paneId, args.keys, args.session, guarded.region); - if (!res.ok && res.code === "prompt_changed") return { status: "changed" }; - if (!res.ok) return { status: "error", error: res.error }; - return { status: "sent" }; - } catch (e) { - return { status: "error", error: e instanceof Error ? e.message : String(e) }; - } + // Bind only this first write. It changes the dialog, so later steps must not reuse this region. + return sendBoundKeys(target(args), args.keys, guarded.region); } diff --git a/web/src/lib/prompt-action.ts b/web/src/lib/prompt-action.ts index 5229b2c9..bf7d172f 100644 --- a/web/src/lib/prompt-action.ts +++ b/web/src/lib/prompt-action.ts @@ -34,6 +34,8 @@ export async function submitPromptOption(args: { option: PromptOption; /** The session the pane lives in (undefined = primary) — scopes the read + keystroke. */ session?: string; + /** Live pane-write permission, rechecked after the freshness guard and before the send. */ + canWrite: () => boolean; /** The pane's agent — which adapter re-derives the fresh screen. No adapter = the guard refuses. */ agent?: string; }): Promise { diff --git a/web/src/lib/voice-policy.test.ts b/web/src/lib/voice-policy.test.ts new file mode 100644 index 00000000..f4a752b6 --- /dev/null +++ b/web/src/lib/voice-policy.test.ts @@ -0,0 +1,38 @@ +import { + MAX_VOICE_BYTES, + MAX_VOICE_DURATION_MS, + MIN_EFFECTIVE_UPLINK_BITS_PER_SECOND, + MULTIPART_ALLOWANCE_BYTES, + PARSE_SCHEDULING_RESPONSE_MARGIN_MS, + PROVIDER_ALLOWANCE_MS, + RECORDING_MIME_TYPES, + requestedRecordingBitrate, + transcriptionDeadlineMs, +} from "./voice-policy"; + +describe("voice recording policy", () => { + it("owns the accepted recording envelope and MIME-aware best-effort bitrates", () => { + expect(MAX_VOICE_DURATION_MS).toBe(5 * 60 * 1000); + expect(MAX_VOICE_BYTES).toBe(8 * 1024 * 1024); + expect(RECORDING_MIME_TYPES).toEqual(["audio/webm;codecs=opus", "audio/webm", "audio/mp4"]); + expect(requestedRecordingBitrate("audio/webm;codecs=opus")).toBe(24_000); + expect(requestedRecordingBitrate("audio/webm")).toBe(24_000); + expect(requestedRecordingBitrate("audio/mp4")).toBe(64_000); + }); + + it("derives the bounded total deadline from known Blob bytes", () => { + expect(MIN_EFFECTIVE_UPLINK_BITS_PER_SECOND).toBe(256_000); + expect(MULTIPART_ALLOWANCE_BYTES).toBe(65_536); + expect(PROVIDER_ALLOWANCE_MS).toBe(60_000); + expect(PARSE_SCHEDULING_RESPONSE_MARGIN_MS).toBe(20_000); + expect(transcriptionDeadlineMs(0)).toBe(82_048); + expect(transcriptionDeadlineMs(1)).toBe(82_049); + expect(transcriptionDeadlineMs(MAX_VOICE_BYTES)).toBe(344_192); + }); + + it("rejects non-integer or out-of-envelope byte counts instead of clamping them", () => { + for (const bytes of [Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5, MAX_VOICE_BYTES + 1]) { + expect(() => transcriptionDeadlineMs(bytes)).toThrow(RangeError); + } + }); +}); diff --git a/web/src/lib/voice-policy.ts b/web/src/lib/voice-policy.ts new file mode 100644 index 00000000..cc4d2762 --- /dev/null +++ b/web/src/lib/voice-policy.ts @@ -0,0 +1,33 @@ +/** Browser-owned recording limits; the bridge enforces the matching submitted-file envelope. */ +export const MAX_VOICE_DURATION_MS = 5 * 60 * 1000; +export const MAX_VOICE_BYTES = 8 * 1024 * 1024; + +/** Ordered by preferred browser recording container. */ +export const RECORDING_MIME_TYPES = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4"] as const; + +/** A best-effort encoder request, not an acceptance or telemetry signal. */ +export function requestedRecordingBitrate(mimeType: string): number { + return mimeType === "audio/mp4" ? 64_000 : 24_000; +} + +// A completed recording has a known byte count, so its one-shot request can use one coherent wall +// clock budget. These deliberately match the bridge's 64 KiB multipart declaration allowance and +// its independently bounded 60-second provider call; neither is a retry allowance. +export const MIN_EFFECTIVE_UPLINK_BITS_PER_SECOND = 256_000; +export const MULTIPART_ALLOWANCE_BYTES = 64 * 1024; +export const PROVIDER_ALLOWANCE_MS = 60_000; +export const PARSE_SCHEDULING_RESPONSE_MARGIN_MS = 20_000; + +/** + * Total browser deadline for a completed audio Blob of known size. Do not clamp: an out-of-envelope + * input must fail before its request starts rather than receive a deadline that under-budgets it. + */ +export function transcriptionDeadlineMs(bytes: number): number { + if (!Number.isFinite(bytes) || !Number.isInteger(bytes) || bytes < 0 || bytes > MAX_VOICE_BYTES) { + throw new RangeError("voice recording bytes must be an integer from 0 through 8 MiB"); + } + const uploadMs = Math.ceil( + ((bytes + MULTIPART_ALLOWANCE_BYTES) * 8 * 1000) / MIN_EFFECTIVE_UPLINK_BITS_PER_SECOND, + ); + return uploadMs + PROVIDER_ALLOWANCE_MS + PARSE_SCHEDULING_RESPONSE_MARGIN_MS; +} diff --git a/web/src/lib/wizard-action.ts b/web/src/lib/wizard-action.ts index 12aa585d..0cf5827d 100644 --- a/web/src/lib/wizard-action.ts +++ b/web/src/lib/wizard-action.ts @@ -30,6 +30,8 @@ export async function submitWizardKeys(args: { keys: string[]; /** The session the pane lives in (undefined = primary) — scopes the read + keystroke. */ session?: string; + /** Live pane-write permission, rechecked after the freshness guard and before the send. */ + canWrite: () => boolean; /** The pane's agent — which adapter re-derives the fresh screen. No adapter = the guard refuses. */ agent?: string; }): Promise { diff --git a/web/src/routes/detail.test.tsx b/web/src/routes/detail.test.tsx index 8716a640..e86f5347 100644 --- a/web/src/routes/detail.test.tsx +++ b/web/src/routes/detail.test.tsx @@ -10,8 +10,24 @@ import { DetailRoute } from "./detail"; // Stub the heavy terminal view: this test is about DetailRoute's routing/freshPane logic, not the // composer. The stub reports which pane it was handed and whether an agent resolved for it. vi.mock("@/components/agent-chat", () => ({ - AgentChat: ({ paneId, agent }: { paneId: string; agent?: AgentView }) => ( -
{`pane:${paneId}:${agent ? "live" : "gone"}`}
+ AgentChat: ({ + paneId, + agent, + snapshotStale, + snapshotAuthError, + paneStale, + paneAuthError, + }: { + paneId: string; + agent?: AgentView; + snapshotStale?: boolean; + snapshotAuthError?: boolean; + paneStale?: boolean; + paneAuthError?: boolean; + }) => ( +
+ {`pane:${paneId}:${agent ? "live" : "gone"}`} +
), })); @@ -46,11 +62,16 @@ const connected = (agents: AgentView[], shellPanes: AgentView[] = []): HomeData snoozedUntil: null, update: undefined, transcriptionEnabled: false, - error: false, - authError: false, + snapshotStale: false, + snapshotAuthError: false, + snapshotHasLastGood: true, }); -function makeRouter(initialPath: string, homeLoader: () => HomeData) { +function makeRouter( + initialPath: string, + homeLoader: () => HomeData, + paneFreshness: Partial = {}, +) { return createMemoryRouter( [ { @@ -69,8 +90,10 @@ function makeRouter(initialPath: string, homeLoader: () => HomeData) { truncated: false, requestedLines: 600, revision: 0, - error: false, - authError: false, + paneStale: false, + paneAuthError: false, + paneHasLastGood: true, + ...paneFreshness, }), element: , }, @@ -82,6 +105,23 @@ function makeRouter(initialPath: string, homeLoader: () => HomeData) { } describe("DetailRoute — freshPane bootstrap", () => { + it("threads independent root and pane stale/auth outcomes to the detail UI", async () => { + const root = { + ...connected([]), + snapshotStale: true, + snapshotAuthError: true, + snapshotHasLastGood: true, + }; + const router = makeRouter(panePath("w1:p1"), () => root, { + paneStale: true, + paneAuthError: true, + paneHasLastGood: true, + }); + render(); + + expect(await screen.findByTestId("chat")).toHaveAttribute("data-freshness", "true:true:true:true"); + }); + it("shows a freshly-created pane opened from the home screen", async () => { const router = makeRouter("/", () => connected([])); render(); diff --git a/web/src/routes/detail.tsx b/web/src/routes/detail.tsx index 8dc603f7..744052d3 100644 --- a/web/src/routes/detail.tsx +++ b/web/src/routes/detail.tsx @@ -52,11 +52,11 @@ export function DetailRoute() { // leaving you on a dead "agent gone" view. Guarded on a connected, non-stale snapshot so a // transient poll failure or reconnect doesn't evict a still-valid pane. useEffect(() => { - if (gone && root.bridge === "connected" && !root.error) { + if (gone && root.bridge === "connected" && !root.snapshotStale) { setStatus("Pane closed", "info"); navigate(homePath(session), { replace: true }); } - }, [gone, root.bridge, root.error, navigate, session]); + }, [gone, root.bridge, root.snapshotStale, navigate, session]); return ( navigate(homePath(session))} onSelect={(id) => navigate(panePath(id, session))} /> diff --git a/web/src/routes/history.tsx b/web/src/routes/history.tsx index 4ba65b67..6361cb36 100644 --- a/web/src/routes/history.tsx +++ b/web/src/routes/history.tsx @@ -3,6 +3,7 @@ import { useLoaderData, useNavigate, useParams, useRouteLoaderData } from "react import { ArrowUpToLine, ChevronDown, ChevronUp, Loader2, ScrollText, Search, X } from "lucide-react"; import { AppHeader } from "@/components/app-header"; +import { useLoadingStalled } from "@/hooks/use-loading-stalled"; import { ChatMessageList, type ChatMessageListHandle } from "@/components/ui/chat/chat-message-list"; import { FindBar } from "@/components/find-bar"; import { TranscriptView } from "@/components/transcript-view"; @@ -48,6 +49,7 @@ export function HistoryRoute() { const { paneId = "" } = useParams(); const navigate = useNavigate(); const session = data.session; + const headerLoading = useLoadingStalled(); const agent = root.agents.find((a) => a.paneId === paneId) ?? @@ -187,8 +189,8 @@ export function HistoryRoute() { return (
navigate(panePath(paneId, session))} override={ findOpen ? ( diff --git a/web/src/routes/home.tsx b/web/src/routes/home.tsx index c0f35eb7..8bb47e84 100644 --- a/web/src/routes/home.tsx +++ b/web/src/routes/home.tsx @@ -39,12 +39,11 @@ export function HomeRoute() { return (
- {/* The dashboard header: wordmark + the session switcher (dashboard-only), then the shared pill - and the Settings gear. The switcher self-hides on a single-session install. */} + {/* The dashboard header: wordmark + the session switcher (dashboard-only), then the Settings + gear. Freshness is owned by RootLayout; the switcher self-hides on a single-session install. */} } rightTrail={} @@ -61,6 +60,8 @@ export function HomeRoute() { { - beforeEach(() => { - vi.useFakeTimers(); - __resetConnectionHealth(); // module-load anchor == frozen clock: a dead cold start escalates ~15s in - }); +describe("BootSplash", () => { + beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); - it("shows the galloping-dog splash before the threshold", () => { + it("uses neutral loading copy before and after a delayed first load", () => { render(); - expect(screen.getByText("Connecting to the herd…")).toBeInTheDocument(); - // still the plain splash a beat before the threshold - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS - 1)); - expect(screen.getByText("Connecting to the herd…")).toBeInTheDocument(); - expect(screen.queryByText("Not connected")).not.toBeInTheDocument(); - }); + expect(screen.getByText("Loading Collie…")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Retry" })).toBeNull(); - it("escalates to 'Not connected' with a Retry once stuck past the threshold", () => { - const { container } = render(); - act(() => vi.advanceTimersByTime(CONNECTION_LOST_MS)); - expect(screen.queryByText("Connecting to the herd…")).not.toBeInTheDocument(); - expect(screen.getByText("Not connected")).toBeInTheDocument(); - expect(screen.getByText(/Can.t reach Collie/)).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(BOOT_LOADING_DELAY_MS)); + expect(screen.getByText("Collie is taking longer than expected")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); - // The galloping mascot is gone — the loading sprite is unmounted and the rest state is the muted - // static app icon (never a frozen gallop frame, which reads as stuck mid-run). - expect(screen.queryByLabelText("Loading")).not.toBeInTheDocument(); - expect(container.querySelector(".dog-gallop")).toBeNull(); - const icon = container.querySelector("img"); - expect(icon).toHaveAttribute("src", "/favicon.svg"); - expect(icon?.className).toMatch(/grayscale/); + expect(screen.queryByText(/not connected|connecting to the herd/i)).toBeNull(); }); }); diff --git a/web/src/routes/root.tsx b/web/src/routes/root.tsx index 7ab2ded9..424c762a 100644 --- a/web/src/routes/root.tsx +++ b/web/src/routes/root.tsx @@ -1,12 +1,12 @@ +import { useEffect, useState } from "react"; import { Outlet, useLoaderData, useParams, useRouteError } from "react-router"; import { usePolling } from "@/hooks/use-polling"; import { usePollBusy } from "@/hooks/use-poll-busy"; import { useAgentTransitions } from "@/hooks/use-transitions"; import { usePushSetup } from "@/hooks/use-push"; -import { useConnectionLost } from "@/hooks/use-connection-lost"; import { UpdateAvailableBanner } from "@/components/update-available-banner"; -import { ConnectionBanner } from "@/components/connection-banner"; +import { FreshnessBanner } from "@/components/freshness-banner"; import { DogGallop } from "@/components/dog-gallop"; import { homePath } from "@/lib/nav"; import { SESSION_PARAM, normalizeSession } from "@/lib/session"; @@ -39,52 +39,42 @@ export function RootLayout() { auto-update) for the app's lifetime; renders the slim "tap to update" row only when a fresh build is confirmed but auto-update is held off (unsent work) or already spent. */} - {/* The app's ONE connection surface: a thin, animated bar that stays hidden while healthy, fades - in amber "reconnecting…" only after ≥4s of sustained trouble (the flicker fix), escalates to a - red "not connected" cause + Retry/Reload at ≥15s, and flashes green on recovery. Reads the - same shared-clock signals as the header dog, so the two always agree. */} - + {/* Root-snapshot freshness has its own surface. Pane reads and generic loading never alter it. */} +
); } -// Shown once, on the very first load, while the snapshot loader resolves (SPA hydration). This is the -// router's HydrateFallback, so it stays mounted until the FIRST loader run settles — and over a dead -// tailnet that initial fetch can hang well past its timeout (or forever on a WebView without -// AbortSignal.timeout). Left as-is, a PWA reopened while the host is unreachable would gallop the dog -// on "Connecting to the herd…" indefinitely, with no way to retry. So once we've been stuck here for -// CONNECTION_LOST_MS (the same wall-clock threshold as the in-app prompt — `connecting` is trivially -// true the whole time we're mounted), the splash escalates to an honest, actionable "Not connected" -// state: the dog rests, the copy says we can't reach Collie, and a Retry re-runs the loaders from -// scratch (a full reload clears most transient failures). Below the threshold it's unchanged. +// Shown once while the first root loader resolves. It intentionally reports only elapsed loading +// time: this route has no loader result yet, so it cannot truthfully diagnose network or Herdr state. +export const BOOT_LOADING_DELAY_MS = 4_000; + export function BootSplash() { - const stuck = useConnectionLost(true); - if (!stuck) { - return ( -
- - Connecting to the herd… -
- ); - } + const [delayed, setDelayed] = useState(false); + useEffect(() => { + const id = window.setTimeout(() => setDelayed(true), BOOT_LOADING_DELAY_MS); + return () => clearTimeout(id); + }, []); + return ( -
- {/* Rest = the static app icon, muted (grayscale + dimmed) to read asleep — NOT a gallop - rest-frame, whose full-stretch mid-stride pose looks frozen mid-run. The "Not connected" - copy below carries the accessible meaning, so the icon is decorative. */} - -

Not connected

-

- Can’t reach Collie — check your connection to the host, then try again. -

- +
+ +

{delayed ? "Collie is taking longer than expected" : "Loading Collie…"}

+ {delayed && ( + + )}
); } diff --git a/web/src/routes/settings.tsx b/web/src/routes/settings.tsx index a553e2eb..1bb4b0d7 100644 --- a/web/src/routes/settings.tsx +++ b/web/src/routes/settings.tsx @@ -134,7 +134,12 @@ export function SettingsRoute() { {/* On-demand upstream update check (independent of push) — drives the footer UpdateBanner. */} - + {/* Update nudge + build stamp, grouped and pinned to the bottom of the page. */}
diff --git a/web/src/routes/space.tsx b/web/src/routes/space.tsx index 4f8ac022..d348f275 100644 --- a/web/src/routes/space.tsx +++ b/web/src/routes/space.tsx @@ -58,20 +58,19 @@ export function SpaceRoute() { const everExisted = useRef(false); if (selectedWs) everExisted.current = true; useEffect(() => { - if (gone && data.bridge === "connected" && !data.error) { + if (gone && data.bridge === "connected" && !data.snapshotStale) { setStatus(everExisted.current ? "Space closed" : "Space not found", "info"); navigate(homePath(data.session), { replace: true }); } - }, [gone, data.bridge, data.error, data.session, navigate]); + }, [gone, data.bridge, data.snapshotStale, data.session, navigate]); return (
{/* The space header: same shell as the dashboard, minus the session switcher (you switch - sessions from home). Wordmark + shared pill + Settings gear. */} + sessions from home). It owns the wordmark and Settings gear; RootLayout owns freshness. */} } diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index 447123ba..a17f8c7f 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -4,17 +4,11 @@ import { cleanup } from "@testing-library/react"; import { setupServer } from "msw/node"; import { handlers, resetTypedDraft } from "./handlers"; -import { __resetConnectionHealth } from "@/lib/connection-health"; // One MSW server for all tests; tests add per-case overrides with `server.use(...)`. export const server = setupServer(...handlers); beforeAll(() => server.listen({ onUnhandledRequest: "warn" })); -// The connection-health store is module-scoped and initialises its anchor to module-load time. Pin it -// to "now" before every test so a component rendered minutes after the file loaded never reads a stale -// anchor as an escalated outage. Fake-timer escalation suites re-pin AFTER vi.useFakeTimers() so the -// anchor equals the frozen clock exactly. -beforeEach(() => __resetConnectionHealth()); // Persisted state (composer drafts, prefs) must not leak between cases — a draft saved by one test // would be restored into the next test's freshly-mounted composer. beforeEach(() => { From 1a20d8fdb1e7da756b11a0e5ca578b8896a447c7 Mon Sep 17 00:00:00 2001 From: en-ver Date: Sun, 16 Aug 2026 06:24:47 +0300 Subject: [PATCH 8/9] test(prompt): preserve merged write guards --- web/src/components/agent-chat.test.tsx | 11 +++++++---- web/src/lib/prompt-action.test.ts | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/web/src/components/agent-chat.test.tsx b/web/src/components/agent-chat.test.tsx index 8bb213f1..e90b9985 100644 --- a/web/src/components/agent-chat.test.tsx +++ b/web/src/components/agent-chat.test.tsx @@ -10,6 +10,7 @@ import { createMemoryRouter, RouterProvider, useParams } from "react-router"; // `detectedRevision` the tap handler passes (the guard's own behaviour is covered in // prompt-select-block.test.tsx). The other tests in this file never reach it. vi.mock("@/lib/prompt-action", () => ({ + submitPromptFeedback: vi.fn(), submitPromptOption: vi.fn(), })); vi.mock("@/lib/wizard-action", () => ({ @@ -304,6 +305,8 @@ const MULTI_SELECT_TEXT = readFileSync( const GENERIC_MENU_TEXT = readFileSync(join(PANES_DIR, "claude--menu-model-picker.txt"), "utf8"); describe("AgentChat — pane voice write lock", () => { + const testPromptAction = { kind: "option" as const, option: { label: "test", keys: [] } }; + function capturedAnsiOutput(busy: boolean): AnsiOutputProps { const props = [...capturedAnsiOutputs].reverse().find((output) => output.promptDisabled === busy); if (!props) throw new Error(`expected ${busy ? "busy" : "idle"} AnsiOutput callbacks`); @@ -346,7 +349,7 @@ describe("AgentChat — pane voice write lock", () => { text: MENU_TEXT, submit: () => vi.mocked(submitPromptOption), invoke: (output: AnsiOutputProps) => - output.onPromptAction!(undefined as never, undefined as never), + output.onPromptAction!(testPromptAction, undefined as never), }, { name: "wizard", @@ -421,7 +424,7 @@ describe("AgentChat — pane voice write lock", () => { }); const { setVoicePhase } = renderVoiceChat(MENU_TEXT); - const action = capturedAnsiOutput(false).onPromptAction!(undefined as never, undefined as never); + const action = capturedAnsiOutput(false).onPromptAction!(testPromptAction, undefined as never); await waitFor(() => expect(submit).toHaveBeenCalledTimes(1)); // The dialog tap began idle, then recording took the pane before its asynchronous guard settled. @@ -442,11 +445,11 @@ describe("AgentChat — pane voice write lock", () => { const busyOutput = capturedAnsiOutput(true); expect(screen.getByPlaceholderText(/type a reply/i)).toBeDisabled(); - await busyOutput.onPromptAction!(undefined as never, undefined as never); + await busyOutput.onPromptAction!(testPromptAction, undefined as never); expect(submit).not.toHaveBeenCalled(); setVoicePhase("idle"); - await capturedAnsiOutput(false).onPromptAction!(undefined as never, undefined as never); + await capturedAnsiOutput(false).onPromptAction!(testPromptAction, undefined as never); await waitFor(() => expect(submit).toHaveBeenCalledTimes(1)); }, ); diff --git a/web/src/lib/prompt-action.test.ts b/web/src/lib/prompt-action.test.ts index 77535f97..d1661dd6 100644 --- a/web/src/lib/prompt-action.test.ts +++ b/web/src/lib/prompt-action.test.ts @@ -97,6 +97,7 @@ const base = { // The guard re-derives through the pane's ADAPTER (lib/dialog-guard.ts), so every call names the // agent whose grammar produced the buffer — an agent with no adapter fails the guard closed. agent: "claude", + canWrite: () => true, sleep: noSleep, }; From 99c3b88ffdfed02985f5d783380d5ab569fec849 Mon Sep 17 00:00:00 2001 From: en-ver Date: Sun, 16 Aug 2026 06:49:13 +0300 Subject: [PATCH 9/9] fix(prompt): guard feedback after focus poll --- web/src/lib/prompt-action.test.ts | 31 +++++++++++++++++++++++++++++++ web/src/lib/prompt-action.ts | 1 + 2 files changed, 32 insertions(+) diff --git a/web/src/lib/prompt-action.test.ts b/web/src/lib/prompt-action.test.ts index d1661dd6..df0aa0dc 100644 --- a/web/src/lib/prompt-action.test.ts +++ b/web/src/lib/prompt-action.test.ts @@ -259,6 +259,37 @@ describe("submitPromptFeedback — the stopping points once it has started writi expect(mockSendKeys.mock.calls).toEqual([["w1:p1", ["3"], undefined, model().signature]]); }); + it("does not type feedback after voice locks the pane during focus verification", async () => { + const m = model(); + let resolveFocused!: (value: ReturnType) => void; + mockFetchPane + .mockResolvedValueOnce(paneWith(buffer())) + .mockImplementationOnce( + () => + new Promise>((resolve) => { + resolveFocused = resolve; + }), + ); + let canWrite = true; + + const action = submitPromptFeedback({ + ...base, + canWrite: () => canWrite, + prompt: m, + text: "use a switch", + }); + await vi.waitFor(() => expect(mockFetchPane).toHaveBeenCalledTimes(2)); + + // The focus digit was sent while idle; once recording owns this pane, its resolved poll must + // not be followed by either the reply paste or the irreversible Enter. + canWrite = false; + resolveFocused(paneWith(buffer({ focused: true }))); + + await expect(action).resolves.toEqual({ status: "changed" }); + expect(mockSendKeys.mock.calls).toEqual([["w1:p1", ["3"], undefined, m.signature]]); + expect(mockSendReply).not.toHaveBeenCalled(); + }); + it("sends NO Enter if the text never arrives — the words wait in the box for a human", async () => { // The single most important assertion in this file. A blind Enter here would submit whatever the // box happens to hold (possibly nothing) as a plan denial. diff --git a/web/src/lib/prompt-action.ts b/web/src/lib/prompt-action.ts index a1d6b4b2..d78d6306 100644 --- a/web/src/lib/prompt-action.ts +++ b/web/src/lib/prompt-action.ts @@ -137,6 +137,7 @@ export async function submitPromptFeedback( } try { + if (!args.canWrite()) return { status: "changed" }; const typed = await sendReply(args.paneId, text, false, args.session); if (!typed.ok) return { status: "error", error: typed.error }; // Wait for our words to render, then match them EXACTLY. The row re-flows rather than windowing,