fix(dictation): preserve final transcript on user stop, cancel on send

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.
This commit is contained in:
klopez4212
2026-07-11 16:18:38 +01:00
parent cd6a707fc0
commit 4e1f6493ee
3 changed files with 50 additions and 16 deletions
@@ -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;
}
@@ -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,
};
}
@@ -278,7 +278,7 @@ function MessageComposerImpl({
submitMessageRef,
draftKey: effectiveDraftKey,
});
stopDictationRef.current = dictation.stopRecording;
stopDictationRef.current = dictation.cancelRecording;
const composerScrollRef = React.useRef<HTMLDivElement>(null);
// Set after `useLinkEditor` exists below; the editor's link-click handler
// delegates through this ref to break the hook ordering cycle (the editor