diff --git a/desktop/src/features/dictation/hooks/useMessageComposerDictation.tsx b/desktop/src/features/dictation/hooks/useMessageComposerDictation.tsx index a6d5d4277..960c390e6 100644 --- a/desktop/src/features/dictation/hooks/useMessageComposerDictation.tsx +++ b/desktop/src/features/dictation/hooks/useMessageComposerDictation.tsx @@ -1,7 +1,10 @@ import type * as React from "react"; -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useFeatureEnabled } from "@/shared/features"; -import { getDictationSendDecision } from "../lib/voiceInput"; +import { + getDictationSendDecision, + shouldAutoSubmitDictation, +} from "../lib/voiceInput"; import { DictationButton } from "../ui/DictationButton"; import { useComposerDictation } from "./useComposerDictation"; @@ -15,10 +18,14 @@ interface UseMessageComposerDictationOptions { setEditorContent: (text: string) => void; draftKey: string | null; composerRef: React.RefObject; + submitMessageRef: React.MutableRefObject<() => void>; } export function useMessageComposerDictation({ + disabled, + draftKey, setEditorContent, + submitMessageRef, ...options }: UseMessageComposerDictationOptions) { const enabled = useFeatureEnabled("voiceDictation"); @@ -26,10 +33,34 @@ export function useMessageComposerDictation({ setEditorContentRef.current = setEditorContent; const dictation = useComposerDictation({ ...options, + disabled, + draftKey, enabled, setEditorContentRef, }); const { isRecording, isStarting, isTranscribing, stopRecording } = dictation; + const sendAfterTranscriptionRef = useRef(false); + + // biome-ignore lint/correctness/useExhaustiveDependencies: composer scope and availability are the reset triggers for a queued send + useEffect(() => { + sendAfterTranscriptionRef.current = false; + }, [disabled, draftKey]); + + useEffect(() => { + if ( + !shouldAutoSubmitDictation({ + requested: sendAfterTranscriptionRef.current, + isRecording, + isStarting, + isTranscribing, + }) + ) { + return; + } + sendAfterTranscriptionRef.current = false; + submitMessageRef.current(); + }, [isRecording, isStarting, isTranscribing, submitMessageRef]); + const prepareToSubmit = useCallback(() => { const decision = getDictationSendDecision({ isRecording, @@ -37,6 +68,7 @@ export function useMessageComposerDictation({ isTranscribing, }); if (decision === "stop-recording") { + sendAfterTranscriptionRef.current = true; stopRecording(); } return decision === "send"; diff --git a/desktop/src/features/dictation/lib/voiceInput.test.mjs b/desktop/src/features/dictation/lib/voiceInput.test.mjs index 908a7bd7f..3360d9ab0 100644 --- a/desktop/src/features/dictation/lib/voiceInput.test.mjs +++ b/desktop/src/features/dictation/lib/voiceInput.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { getDictationSendDecision, replaceTrailingTranscribedText, + shouldAutoSubmitDictation, } from "./voiceInput.ts"; test("dictation send stops capture before submitting", () => { @@ -44,6 +45,32 @@ test("dictation send waits for the final transcript", () => { ); }); +test("dictation auto-submit waits until capture and transcription settle", () => { + const state = { + requested: true, + isRecording: false, + isStarting: false, + isTranscribing: false, + }; + assert.equal(shouldAutoSubmitDictation(state), true); + assert.equal( + shouldAutoSubmitDictation({ ...state, requested: false }), + false, + ); + assert.equal( + shouldAutoSubmitDictation({ ...state, isRecording: true }), + false, + ); + assert.equal( + shouldAutoSubmitDictation({ ...state, isStarting: true }), + false, + ); + assert.equal( + shouldAutoSubmitDictation({ ...state, isTranscribing: true }), + false, + ); +}); + // ── replaceTrailingTranscribedText ────────────────────────────────────────── test("replaceTrailingTranscribedText_appendsWhenNoPrevious", () => { diff --git a/desktop/src/features/dictation/lib/voiceInput.ts b/desktop/src/features/dictation/lib/voiceInput.ts index c8d66a3ca..da92bdf7f 100644 --- a/desktop/src/features/dictation/lib/voiceInput.ts +++ b/desktop/src/features/dictation/lib/voiceInput.ts @@ -24,6 +24,20 @@ export function getDictationSendDecision({ return "send"; } +export function shouldAutoSubmitDictation({ + requested, + isRecording, + isStarting, + isTranscribing, +}: { + requested: boolean; + isRecording: boolean; + isStarting: boolean; + isTranscribing: boolean; +}): boolean { + return requested && !isRecording && !isStarting && !isTranscribing; +} + export function replaceTrailingTranscribedText( fullText: string, previousTranscribedText: string, diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 1fbf8440d..5d92b3c25 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -232,7 +232,6 @@ function MessageComposerImpl({ (replyTarget ? `Reply to ${replyTarget.author} in #${channelName}` : `Message #${channelName}`)); - const richText = useRichTextEditor({ placeholder: computedPlaceholder, editable: !disabled, @@ -277,6 +276,7 @@ function MessageComposerImpl({ setEditorContent: richText.setContent, draftKey: effectiveDraftKey, composerRef: composerScrollRef, + submitMessageRef, }); prepareDictationSubmitRef.current = dictation.prepareToSubmit; const linkEditor = useLinkEditor(richText);