fix(desktop): send finalized dictation in one click

Co-authored-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-07-31 14:39:30 -04:00
co-authored by Kenny Lopez
parent 3057890dd6
commit 0da28eff4e
4 changed files with 76 additions and 3 deletions
@@ -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<HTMLElement | null>;
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";
@@ -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", () => {
@@ -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,
@@ -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);