mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user