From 4e1f6493eea10e4cb276f7ec11c1bf221dbed8ea Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 6 Jul 2026 10:32:13 +0100 Subject: [PATCH] fix(dictation): preserve final transcript on user stop, cancel on send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Distinguish user-initiated stop (mic button) from send/navigation cleanup: - stopRecording(): user stop — keeps the run valid during the 3s grace window so the final commit's transcript is delivered to the composer. - cancelRecording(): send/edit-save/navigation — immediately invalidates the run so late transcripts cannot refill the cleared composer. MessageComposer's stopDictationRef now uses cancelRecording (called on send and edit-save). useComposerDictation's draftKey/disabled effects also use cancelRecording. The user-facing toggleRecording/stopRecording path preserves the final transcript for short recordings. --- .../dictation/hooks/useComposerDictation.ts | 10 ++-- .../dictation/hooks/useRealtimeDictation.ts | 54 +++++++++++++++---- .../features/messages/ui/MessageComposer.tsx | 2 +- 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/desktop/src/features/dictation/hooks/useComposerDictation.ts b/desktop/src/features/dictation/hooks/useComposerDictation.ts index fec03c71c..b9d8fd3c2 100644 --- a/desktop/src/features/dictation/hooks/useComposerDictation.ts +++ b/desktop/src/features/dictation/hooks/useComposerDictation.ts @@ -55,21 +55,21 @@ export function useComposerDictation({ isSendBlockedRef, }); - // Stop dictation when the channel/thread changes so that transcript events + // Cancel dictation when the channel/thread changes so that transcript events // from a stale WebRTC session don't leak into the wrong draft. // biome-ignore lint/correctness/useExhaustiveDependencies: draftKey is the sole trigger useEffect(() => { - dictation.stopRecording(); + dictation.cancelRecording(); }, [draftKey]); - // Auto-stop dictation when the composer becomes disabled mid-recording + // Auto-cancel dictation when the composer becomes disabled mid-recording // (e.g. channel becomes read-only, parent send state disables thread composer). // Without this, the WebRTC session keeps running with no way to stop it. useEffect(() => { if (disabled && dictation.isRecording) { - dictation.stopRecording(); + dictation.cancelRecording(); } - }, [disabled, dictation.isRecording, dictation.stopRecording]); + }, [disabled, dictation.isRecording, dictation.cancelRecording]); return dictation; } diff --git a/desktop/src/features/dictation/hooks/useRealtimeDictation.ts b/desktop/src/features/dictation/hooks/useRealtimeDictation.ts index b56c8eb9a..a831f4d6f 100644 --- a/desktop/src/features/dictation/hooks/useRealtimeDictation.ts +++ b/desktop/src/features/dictation/hooks/useRealtimeDictation.ts @@ -85,7 +85,15 @@ export function useRealtimeDictation({ }; }, []); - const cleanupResources = useCallback(() => { + /** + * Tear down recording resources. + * + * @param invalidateRun - When true, immediately marks the run stale so + * late transcript events are rejected (used by send/edit-save/navigation). + * When false (user-initiated stop), the run stays valid during the grace + * window so the final commit's transcript is delivered to the composer. + */ + const cleanupResources = useCallback((invalidateRun = true) => { // Clear periodic commit interval if active. if (commitIntervalRef.current) { clearInterval(commitIntervalRef.current); @@ -102,11 +110,6 @@ export function useRealtimeDictation({ if (needsCommit && dc) { commitAudioBuffer(dc); - // Mark the run stale immediately so late transcript events from the - // data channel are rejected by handleRealtimeEvent. This prevents - // transcripts from writing back into the composer after a send, - // edit-save, or navigation that triggered this cleanup. - activeRunIdRef.current += 1; // Stop the mic immediately so no new audio is sent after commit. for (const track of streamRef.current?.getTracks() ?? []) { track.stop(); @@ -114,12 +117,25 @@ export function useRealtimeDictation({ streamRef.current = null; audioCaptureRef.current?.close(); audioCaptureRef.current = null; - // Delay WebRTC teardown briefly for clean protocol shutdown (the - // commit message needs to reach OpenAI before the channel closes). + // Delay WebRTC teardown briefly so the commit reaches OpenAI and + // the transcript response can arrive. const pc = peerConnectionRef.current; peerConnectionRef.current = null; dataChannelRef.current = null; + const runId = activeRunIdRef.current; + + if (invalidateRun) { + // Send/navigation: reject all late events immediately. + activeRunIdRef.current += 1; + } + // else: user stop — keep the run valid so the final transcript arrives. + setTimeout(() => { + // After the grace window, invalidate if we haven't already (user stop) + // or if no new run started (send/navigation case already bumped). + if (activeRunIdRef.current === runId) { + activeRunIdRef.current += 1; + } dc.close(); pc?.close(); }, 3000); @@ -139,13 +155,22 @@ export function useRealtimeDictation({ streamRef.current = null; }, []); + /** Full cleanup — invalidates the run (used by send/navigation). */ const cleanup = useCallback(() => { - cleanupResources(); + cleanupResources(true); setIsRecording(false); setIsStarting(false); setIsTranscribing(false); }, [cleanupResources]); + /** User-initiated stop — preserves final transcript for manual-commit models. */ + const userStop = useCallback(() => { + cleanupResources(false); + setIsRecording(false); + setIsStarting(false); + // Note: isTranscribing stays true briefly while the final transcript arrives. + }, [cleanupResources]); + useEffect(() => cleanupResources, [cleanupResources]); const handleRealtimeEvent = useCallback( @@ -314,7 +339,15 @@ export function useRealtimeDictation({ } }, [handleRealtimeEvent, isEnabled, isRecording, isStarting]); - const stopRecording = useCallback(() => cleanup(), [cleanup]); + /** User-initiated stop (mic button) — preserves final transcript. */ + const stopRecording = useCallback(() => userStop(), [userStop]); + + /** + * Cancel recording and reject all pending transcripts. Used by send, + * edit-save, and navigation to prevent late events from refilling the + * composer after the content has been dispatched. + */ + const cancelRecording = useCallback(() => cleanup(), [cleanup]); const toggleRecording = useCallback(() => { if (isRecording || isStarting) { @@ -331,6 +364,7 @@ export function useRealtimeDictation({ isTranscribing, startRecording, stopRecording, + cancelRecording, toggleRecording, }; } diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 2e18c13d4..5d9f27ac8 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -278,7 +278,7 @@ function MessageComposerImpl({ submitMessageRef, draftKey: effectiveDraftKey, }); - stopDictationRef.current = dictation.stopRecording; + stopDictationRef.current = dictation.cancelRecording; const composerScrollRef = React.useRef(null); // Set after `useLinkEditor` exists below; the editor's link-click handler // delegates through this ref to break the hook ordering cycle (the editor