Voice-First Agent Chat (SwiftUI)
The complete interaction pattern from savethis: the app's command dock is voice-first —
hold the mic pill to talk, a lit-glass orb inflates and ripples with your voice under
a curved "Listening…" label, your words caption live on the sphere, and releasing sends
the transcript straight into a full-screen agent chat that streams the reply. Typing is
the secondary path (keyboard button on the dock), and both paths land in the same chat
surface.
This skill is the map. The four reference files hold the adaptable implementations with
every hard-won gotcha inline — copy from them rather than re-deriving:
references/speech-engine.md — the two-backend speech facade
(SpeechAnalyzer/SpeechTranscriber on iOS 26, SFSpeechRecognizer fallback), mic
level plumbing, the audio-session actor, and the quick-tap race fix. Read this before
touching any speech code: iOS 26's asset-reservation model fails silently if you
skip a step.
references/listening-orb.md — the voice-reactive overlay: lit glass sphere,
level-driven edge ripple, curved status text, calming scrim, and the press-latency
rules that keep touch-down instant.
references/chat-layer.md — the full-screen chat presentation: card-sheet
backdrop on the pushed-back home, drag-to-dismiss, screen-height transitions, the
dock-mode compose bar that morphs into a type bar, and hold-to-talk inside the chat.
references/agent-wiring.md — routing every entry point into ONE chat surface
(ChatRequest), release-to-send seeding, streaming-agent hookup, and the error
boundary that keeps a denied mic from becoming a dead button.
The interaction grammar
dock (idle) held released
┌──────────────────────────┐ ┌──────────────────────┐ ┌──────────────────────────┐
│ [☰] ( 🎤 pill ) [⌨] │ → │ home recedes │ → │ transcript non-empty: │
│ │ │ orb inflates from mic│ │ full-screen chat opens │
│ tap ⌨ → same chat, │ │ "Listening…" curved │ │ with it already SENT │
│ keyboard rising with it │ │ words caption live │ │ empty: settle back to dock│
└──────────────────────────┘ └──────────────────────┘ └──────────────────────────┘
Non-negotiables that make it feel right (each is explained in the references):
- Touch-down must respond within a frame. Everything slow moves off the press path:
audio session activation and engine start are async off-main, the haptic engine is
prewarmed, the orb inflates by
scaleEffect on a constant frame (never animate its
frame, a material re-rasterises), and the overlay is always mounted, gated by opacity.
- One haptic system. A single CoreHaptics pattern: transient tap decaying into a
low continuous hum for the whole hold. Adding a separate impact haptic beside it
reads as two distinct buzzes.
- Release-to-send, not release-to-review. The transcript goes straight into the
thread as a sent user message. An empty hold settles back silently.
- One chat surface for every entry. Voice release, keyboard button, compose "+",
deep links, thread reopens — all set the same request object and land in the same
full-screen chat. No separate "voice chat" vs "compose sheet".
- The mic is never a silent dead end. Permission denial drops the listening UI and
offers Settings; engine failure mid-hold drops the overlay instead of pretending to
listen. See the error-boundary section of
references/agent-wiring.md.
Sibling skills (compose, don't duplicate)
swiftui-sheet-keyboard-animations — the keyboard prewarm + land-gated focus
handoff used when the chat opens via the keyboard button. The chat layer's
keyboard choreography (one keyboardSpring curve, ignoresSafeArea(.keyboard) on
the sliding container, transform bar lift) builds on it; references/chat-layer.md
covers the integration.
convex-streaming-agents — the backend: start/status endpoints, persisted
partial text, tool traces. references/agent-wiring.md shows the client side
(seeding, hydration, thinking states); use that skill for the Convex functions.
ios-clerk-auth — if the agent runs as the signed-in user.
Red flags (all observed, not hypothetical)
withAnimation { ... .blur(radius:) } over the home hierarchy on press → the press
lags. The "blur" is the overlay's material scrim, never an animated .blur.
- iOS 26 transcription runs but produces empty text, console says "Cannot use modules
with unallocated locales" → you skipped
AssetInventory.reserve. It gates use,
not just download, and returns false when already reserved — check
reservedLocales, don't trust the return value.
- Crash in
installTap (NSException, not a Swift error) → you read the mic format
before activating the audio session. Activation with .measurement can change the
hardware sample rate.
- Mic indicator stays lit after a quick tap with no UI showing → your
stop() guards
on isRecording, which an in-flight async start() hasn't set yet. Use the hold
token from references/speech-engine.md.
- The sheet's top strip flashes/parks at the screen bottom during open/close →
.move(edge:) travels by layout height and your container is safe-area inset.
Use the screen-height offset transition from references/chat-layer.md.
- "Listening…" label appears in stutters →
repeatForever started in onAppear
leaks into the insertion transition. Use phaseAnimator, keep the label mounted.
- Testing note: hold-to-talk is not simulator-drivable (HID events can't sustain a
press) and the SpeechTranscriber model is
.unsupported on the Simulator — only the
legacy engine runs there. Plan a device pass for the hold flow.
Build order that works
- Speech facade + dock pill hold gesture (verify transcript + level on device).
- Listening overlay (orb, scrim, curved status, caption) driven by
speech.level.
- Chat layer presentation (backdrop, transitions, drag dismiss) with typed entry.
- Release-to-send seeding + agent streaming.
- Dock-mode bar inside the chat (hold-to-talk works mid-conversation too).
- Error boundary + permission alert last, but never skip it.
1---2name: swiftui-voice-first-agent-chat3description: Build a voice-first agent chat in SwiftUI: a dock with a hold-to-talk mic pill, a speech-reactive glass orb overlay ('Listening…' + live caption), and release-to-send into a full-screen streaming agent chat. Use when an app wants push-to-talk capture with live transcription (SpeechAnalyzer/SpeechTranscriber + SFSpeechRecognizer fallback), a voice-reactive listening UI, or a custom full-screen chat presentation over the pushed-back home screen. Symptoms it fixes: mic press feels laggy, orb/overlay looks like 'just a blurred screen', transcription silently returns nothing on iOS 26, app crashes in installTap, sheet transitions strand a strip at the screen bottom, quick taps leave a hot mic.4---56# Voice-First Agent Chat (SwiftUI)78The complete interaction pattern from savethis: the app's command dock is voice-first —9**hold the mic pill to talk**, a lit-glass orb inflates and ripples with your voice under10a curved "Listening…" label, your words caption live on the sphere, and **releasing sends11the transcript straight into a full-screen agent chat** that streams the reply. Typing is12the secondary path (keyboard button on the dock), and both paths land in the same chat13surface.1415This skill is the map. The four reference files hold the adaptable implementations with16every hard-won gotcha inline — copy from them rather than re-deriving:1718- **`references/speech-engine.md`** — the two-backend speech facade19 (`SpeechAnalyzer`/`SpeechTranscriber` on iOS 26, `SFSpeechRecognizer` fallback), mic20 level plumbing, the audio-session actor, and the quick-tap race fix. Read this before21 touching any speech code: iOS 26's asset-reservation model fails *silently* if you22 skip a step.23- **`references/listening-orb.md`** — the voice-reactive overlay: lit glass sphere,24 level-driven edge ripple, curved status text, calming scrim, and the press-latency25 rules that keep touch-down instant.26- **`references/chat-layer.md`** — the full-screen chat presentation: card-sheet27 backdrop on the pushed-back home, drag-to-dismiss, screen-height transitions, the28 dock-mode compose bar that morphs into a type bar, and hold-to-talk inside the chat.29- **`references/agent-wiring.md`** — routing every entry point into ONE chat surface30 (`ChatRequest`), release-to-send seeding, streaming-agent hookup, and the error31 boundary that keeps a denied mic from becoming a dead button.3233## The interaction grammar3435```36 dock (idle) held released37┌──────────────────────────┐ ┌──────────────────────┐ ┌──────────────────────────┐38│ [☰] ( 🎤 pill ) [⌨] │ → │ home recedes │ → │ transcript non-empty: │39│ │ │ orb inflates from mic│ │ full-screen chat opens │40│ tap ⌨ → same chat, │ │ "Listening…" curved │ │ with it already SENT │41│ keyboard rising with it │ │ words caption live │ │ empty: settle back to dock│42└──────────────────────────┘ └──────────────────────┘ └──────────────────────────┘43```4445Non-negotiables that make it feel right (each is explained in the references):46471. **Touch-down must respond within a frame.** Everything slow moves off the press path:48 audio session activation and engine start are async off-main, the haptic engine is49 prewarmed, the orb inflates by `scaleEffect` on a constant frame (never animate its50 frame, a material re-rasterises), and the overlay is always mounted, gated by opacity.512. **One haptic system.** A single CoreHaptics pattern: transient tap decaying into a52 low continuous hum for the whole hold. Adding a separate impact haptic beside it53 reads as two distinct buzzes.543. **Release-to-send, not release-to-review.** The transcript goes straight into the55 thread as a sent user message. An empty hold settles back silently.564. **One chat surface for every entry.** Voice release, keyboard button, compose "+",57 deep links, thread reopens — all set the same request object and land in the same58 full-screen chat. No separate "voice chat" vs "compose sheet".595. **The mic is never a silent dead end.** Permission denial drops the listening UI and60 offers Settings; engine failure mid-hold drops the overlay instead of pretending to61 listen. See the error-boundary section of `references/agent-wiring.md`.6263## Sibling skills (compose, don't duplicate)6465- **`swiftui-sheet-keyboard-animations`** — the keyboard prewarm + land-gated focus66 handoff used when the chat opens via the keyboard button. The chat layer's67 keyboard choreography (one `keyboardSpring` curve, `ignoresSafeArea(.keyboard)` on68 the sliding container, transform bar lift) builds on it; `references/chat-layer.md`69 covers the integration.70- **`convex-streaming-agents`** — the backend: start/status endpoints, persisted71 partial text, tool traces. `references/agent-wiring.md` shows the client side72 (seeding, hydration, thinking states); use that skill for the Convex functions.73- **`ios-clerk-auth`** — if the agent runs as the signed-in user.7475## Red flags (all observed, not hypothetical)7677- `withAnimation { ... .blur(radius:) }` over the home hierarchy on press → the press78 lags. The "blur" is the overlay's material scrim, never an animated `.blur`.79- iOS 26 transcription runs but produces empty text, console says "Cannot use modules80 with unallocated locales" → you skipped `AssetInventory.reserve`. It gates *use*,81 not just download, and returns `false` when already reserved — check82 `reservedLocales`, don't trust the return value.83- Crash in `installTap` (NSException, not a Swift error) → you read the mic format84 before activating the audio session. Activation with `.measurement` can change the85 hardware sample rate.86- Mic indicator stays lit after a quick tap with no UI showing → your `stop()` guards87 on `isRecording`, which an in-flight async `start()` hasn't set yet. Use the hold88 token from `references/speech-engine.md`.89- The sheet's top strip flashes/parks at the screen bottom during open/close →90 `.move(edge:)` travels by *layout* height and your container is safe-area inset.91 Use the screen-height offset transition from `references/chat-layer.md`.92- "Listening…" label appears in stutters → `repeatForever` started in `onAppear`93 leaks into the insertion transition. Use `phaseAnimator`, keep the label mounted.94- Testing note: hold-to-talk is **not simulator-drivable** (HID events can't sustain a95 press) and the SpeechTranscriber model is `.unsupported` on the Simulator — only the96 legacy engine runs there. Plan a device pass for the hold flow.9798## Build order that works991001. Speech facade + dock pill hold gesture (verify transcript + level on device).1012. Listening overlay (orb, scrim, curved status, caption) driven by `speech.level`.1023. Chat layer presentation (backdrop, transitions, drag dismiss) with typed entry.1034. Release-to-send seeding + agent streaming.1045. Dock-mode bar inside the chat (hold-to-talk works mid-conversation too).1056. Error boundary + permission alert last, but never skip it.