fix(desktop): scope dictation to composer context

Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-07-31 15:02:32 -04:00
parent c831ebea2d
commit 5e85a241f9
4 changed files with 16 additions and 85 deletions
@@ -1,5 +1,5 @@
import { useCallback, useRef } from "react";
import { replaceTrailingTranscribedText } from "../lib/voiceInput";
import { useCallback } from "react";
import { appendTranscribedText } from "../lib/voiceInput";
import { useLocalDictation } from "./useLocalDictation";
interface UseDictationOptions {
@@ -16,30 +16,15 @@ export function useDictation({
getText,
setText,
}: UseDictationOptions) {
const lastTranscriptRef = useRef("");
const handleTranscript = useCallback(
(transcript: string) => {
const previous = lastTranscriptRef.current;
const latest = getText();
const merged = replaceTrailingTranscribedText(
latest,
previous,
transcript,
);
setText(merged);
// Each native flush is an independent segment, so the next transcript
// appends instead of replacing this one.
lastTranscriptRef.current = "";
setText(appendTranscribedText(getText(), transcript));
},
[getText, setText],
);
const dictation = useLocalDictation({
disabled,
onRecordingStart: () => {
lastTranscriptRef.current = "";
},
onTranscriptText: handleTranscript,
});
@@ -2,8 +2,8 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
appendTranscribedText,
getDictationSendDecision,
replaceTrailingTranscribedText,
shouldAutoSubmitDictation,
} from "./voiceInput.ts";
@@ -71,46 +71,16 @@ test("dictation auto-submit waits until capture and transcription settle", () =>
);
});
// ── replaceTrailingTranscribedText ──────────────────────────────────────────
// ── appendTranscribedText ───────────────────────────────────────────────────
test("replaceTrailingTranscribedText_appendsWhenNoPrevious", () => {
assert.equal(
replaceTrailingTranscribedText("Hello", "", "world"),
"Hello world",
);
test("appendTranscribedText_appendsToExistingText", () => {
assert.equal(appendTranscribedText("Hello", "world"), "Hello world");
});
test("replaceTrailingTranscribedText_appendsToEmptyBase", () => {
assert.equal(replaceTrailingTranscribedText("", "", "hello"), "hello");
test("appendTranscribedText_appendsToEmptyBase", () => {
assert.equal(appendTranscribedText("", "hello"), "hello");
});
test("replaceTrailingTranscribedText_replacesTrailingInterim", () => {
// Interim "hello wor" is refined to "hello world".
assert.equal(
replaceTrailingTranscribedText("hello wor", "hello wor", "hello world"),
"hello world",
);
});
test("replaceTrailingTranscribedText_preservesTextTypedBeforeDictation", () => {
// User typed "Note: " then dictated; the manual prefix must survive.
assert.equal(
replaceTrailingTranscribedText("Note: hi", "hi", "hi there"),
"Note: hi there",
);
});
test("replaceTrailingTranscribedText_appendsWhenPreviousNoLongerMatches", () => {
// If the previous transcript isn't the trailing text anymore, append.
assert.equal(
replaceTrailingTranscribedText("edited text", "old", "new"),
"edited text new",
);
});
test("replaceTrailingTranscribedText_noDoubleSpaceBeforePunctuation", () => {
assert.equal(
replaceTrailingTranscribedText("Hello", "", ", world"),
"Hello, world",
);
test("appendTranscribedText_avoidsSpaceBeforePunctuation", () => {
assert.equal(appendTranscribedText("Hello", ", world"), "Hello, world");
});
@@ -1,4 +1,7 @@
function appendTranscribedText(baseText: string, fragment: string): string {
export function appendTranscribedText(
baseText: string,
fragment: string,
): string {
const normalizedFragment = fragment.replace(/\s+/g, " ").trim();
if (!normalizedFragment) return baseText;
if (!baseText.trim()) return normalizedFragment;
@@ -37,30 +40,3 @@ export function shouldAutoSubmitDictation({
}): boolean {
return requested && !isRecording && !isStarting && !isTranscribing;
}
export function replaceTrailingTranscribedText(
fullText: string,
previousTranscribedText: string,
nextTranscribedText: string,
): string {
if (!previousTranscribedText) {
return appendTranscribedText(fullText, nextTranscribedText);
}
if (fullText.endsWith(previousTranscribedText)) {
return appendTranscribedText(
fullText.slice(0, -previousTranscribedText.length),
nextTranscribedText,
);
}
const trimmedPreviousText = previousTranscribedText.trim();
if (trimmedPreviousText && fullText.endsWith(trimmedPreviousText)) {
return appendTranscribedText(
fullText.slice(0, -trimmedPreviousText.length),
nextTranscribedText,
);
}
return appendTranscribedText(fullText, nextTranscribedText);
}
@@ -274,7 +274,7 @@ function MessageComposerImpl({
isUploadingRef,
setComposerContent,
setEditorContent: richText.setContent,
draftKey: effectiveDraftKey,
draftKey: `${effectiveDraftKey}\0${editTarget?.id ?? ""}\0${replyTarget?.id ?? ""}`,
composerRef: composerScrollRef,
submitMessageRef,
});