diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 688ae69aa..6a53a6af3 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -643,6 +643,13 @@ export function AppShell() { window.addEventListener("blur", releaseDictationKey); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { + // Release a held ⌘D before unregistering. If `settingsOpen` (or another + // dep) flips while the key is down, this cleanup runs and the next effect + // pass returns early (Settings) or rebinds fresh — either way the `keyup` + // listener is gone and the effect-local `dictationKeyHeld` is lost, so the + // release would never fire and the mic would keep recording. Dispatch it + // here first. + releaseDictationKey(); window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("blur", releaseDictationKey); diff --git a/desktop/src/features/dictation/hooks/useLocalDictation.ts b/desktop/src/features/dictation/hooks/useLocalDictation.ts index f25a4e05d..979862a53 100644 --- a/desktop/src/features/dictation/hooks/useLocalDictation.ts +++ b/desktop/src/features/dictation/hooks/useLocalDictation.ts @@ -351,6 +351,35 @@ export function useLocalDictation({ if (state === "stopped") { setIsRecording(false); setIsTranscribing(false); + // Tear down this instance's local capture pipeline. The native + // engine is a singleton: when another mounted composer calls + // `start_dictation`, it stops this session's engine, so we can + // receive `stopped` without our own `stopRecording()`/`cleanup()` + // having run. Without tearing down here, `streamRef`/`workletRef`/ + // `batchTimerRef` stay alive and this composer keeps the mic open, + // pushing stale audio, until it unmounts. Don't re-invoke + // `stop_dictation` — the native side already stopped (that's why + // this event fired). + if (batchTimerRef.current) { + clearInterval(batchTimerRef.current); + batchTimerRef.current = null; + } + if (streamRef.current) { + for (const track of streamRef.current.getTracks()) { + track.stop(); + } + streamRef.current = null; + } + if (workletRef.current) { + workletRef.current.port.onmessage = null; + workletRef.current.disconnect(); + workletRef.current = null; + } + audioBatchRef.current = []; + if (audioContextRef.current) { + void audioContextRef.current.close(); + audioContextRef.current = null; + } // Clean up event listeners now that the session is fully done. if (unlistenTranscriptRef.current) { unlistenTranscriptRef.current();