fix(dictation): scope all native stops to the owning session

The unmount cleanup handler (registered for every useLocalDictation
instance) called stop_dictation unconditionally, so a non-recording
composer unmounting (e.g. closing a thread reply composer) while another
composer was actively dictating would tear down the singleton engine and
drop the real recording's final transcript.

Scope every frontend stop_dictation call to a specific session:
- cleanup() passes nativeSessionRef.current (0 for an instance that never
  started; native session IDs start at 1, so a never-recorded composer's
  stop can never match the live session and correctly no-ops).
- startRecording bail paths pass the just-started sessionId, robust against
  a concurrent restart during an await window.
- error path and cancelRecording drop their redundant unconditional stop and
  rely on cleanup()'s scoped stop.
This commit is contained in:
npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
2026-07-11 16:19:21 +01:00
parent 2d1c522789
commit 3fff53a9c2
@@ -149,8 +149,16 @@ export function useLocalDictation({
const cleanup = useCallback(() => {
// Flush any remaining audio before teardown, then stop the native engine.
// Scope the stop to THIS hook instance's session. `cleanup` runs as every
// instance's unmount handler, so an unscoped stop here would let a
// non-recording composer (e.g. a thread reply composer closing) tear down
// the singleton engine owned by another composer that is actively
// recording. `nativeSessionRef.current` is 0 for an instance that never
// started a session (native IDs start at 1), so its stop can never match
// the live session and correctly no-ops.
const stoppingSession = nativeSessionRef.current;
void flushAudioBatch().then(() => {
invoke("stop_dictation").catch(() => {});
invoke("stop_dictation", { session: stoppingSession }).catch(() => {});
});
// Stop batch timer.
if (batchTimerRef.current) {
@@ -212,7 +220,7 @@ export function useLocalDictation({
// Bail if aborted during engine start.
if (startAbortedRef.current) {
invoke("stop_dictation").catch(() => {});
invoke("stop_dictation", { session: sessionId }).catch(() => {});
return;
}
@@ -243,7 +251,7 @@ export function useLocalDictation({
// Bail if stop/cancel was called while we were awaiting.
if (startAbortedRef.current) {
unlistenTranscript();
invoke("stop_dictation").catch(() => {});
invoke("stop_dictation", { session: sessionId }).catch(() => {});
return;
}
unlistenTranscriptRef.current = unlistenTranscript;
@@ -272,7 +280,7 @@ export function useLocalDictation({
if (startAbortedRef.current) {
unlistenTranscript();
unlistenState();
invoke("stop_dictation").catch(() => {});
invoke("stop_dictation", { session: sessionId }).catch(() => {});
return;
}
unlistenStateRef.current = unlistenState;
@@ -291,7 +299,7 @@ export function useLocalDictation({
if (startAbortedRef.current) {
for (const track of stream.getTracks()) track.stop();
streamRef.current = null;
invoke("stop_dictation").catch(() => {});
invoke("stop_dictation", { session: sessionId }).catch(() => {});
return;
}
@@ -332,7 +340,7 @@ export function useLocalDictation({
streamRef.current = null;
void audioContext.close();
audioContextRef.current = null;
invoke("stop_dictation").catch(() => {});
invoke("stop_dictation", { session: sessionId }).catch(() => {});
return;
}
@@ -355,9 +363,10 @@ export function useLocalDictation({
setIsRecording(true);
setIsTranscribing(true);
} catch (error) {
// Stop the native engine if it was started but a later step failed
// (e.g. mic permission denied, AudioWorklet setup error).
invoke("stop_dictation").catch(() => {});
// Tear down and stop the native engine if it was started but a later
// step failed (e.g. mic permission denied, AudioWorklet setup error).
// `cleanup()` performs the session-scoped stop, so it only tears down
// this instance's own session — never another composer's.
cleanup();
setIsRecording(false);
setIsTranscribing(false);
@@ -429,8 +438,10 @@ export function useLocalDictation({
const cancelRecording = useCallback(() => {
// Signal any in-flight startRecording to bail after its next await.
startAbortedRef.current = true;
// `cleanup()` performs the session-scoped native stop, so cancelling a
// composer that isn't the active recorder can't tear down another
// composer's session.
cleanup();
invoke("stop_dictation").catch(() => {});
setIsRecording(false);
setIsTranscribing(false);
}, [cleanup]);