Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ shift-click, or ending with the word "Reinschrift") to get a structured prompt,
cancel a recording or to insert the plain transcript while it is being rewritten. Both keys
can be rebound in Settings → Keybindings ("Composer: Dictate").

- Dictated text starts with `[STT]`, once per prompt, so you and agents can tell it was spoken.
Thread titles leave it out. The iPhone dictation does the same; the t3-voice app does not.
- The flag under the mic button is the spoken language; the arrows beside it switch it
(German, English, French, Spanish, Italian, Dutch, Polish). The choice is kept per client.
- The desktop app transcribes on its own computer, also for threads on a remote environment
Expand Down
14 changes: 7 additions & 7 deletions apps/mobile/src/features/voice-input/voiceInputSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,8 @@ describe("global voice input", () => {
);

it.each([
{ selection: { start: 6, end: 11 }, expected: "hello spoken text", cursor: 17 },
{ selection: { start: 6, end: 6 }, expected: "hello spoken text world", cursor: 18 },
{ selection: { start: 6, end: 11 }, expected: "hello [STT] spoken text", cursor: 23 },
{ selection: { start: 6, end: 6 }, expected: "hello [STT] spoken text world", cursor: 24 },
])(
"keeps the starting selection $selection after navigation",
async ({ selection, expected, cursor }) => {
Expand Down Expand Up @@ -187,7 +187,7 @@ describe("global voice input", () => {
expect(recorder.stop).not.toHaveBeenCalled();
await session.controller.stop();

expect(drafts.get("first")).toBe("original prompt spoken text");
expect(drafts.get("first")).toBe("original prompt [STT] spoken text");
expect(drafts.get(visibleDraft)).toBe("other prompt");
expect(session.controller.currentState.phase).toBe("idle");
});
Expand Down Expand Up @@ -236,7 +236,7 @@ describe("global voice input", () => {
transcription.resolve("spoken text");
await (stopping ?? session.controller.stop());
expect(recorder.record).toHaveBeenCalledTimes(1);
expect(firstCommit).toHaveBeenCalledWith("first spoken text", { start: 17, end: 17 });
expect(firstCommit).toHaveBeenCalledWith("first [STT] spoken text", { start: 23, end: 23 });
expect(secondCommit).not.toHaveBeenCalled();
},
);
Expand Down Expand Up @@ -265,7 +265,7 @@ describe("global voice input", () => {
error: null,
url: recorder.uri,
});
expect(commit).toHaveBeenCalledWith("first spoken text", { start: 17, end: 17 });
expect(commit).toHaveBeenCalledWith("first [STT] spoken text", { start: 23, end: 23 });
expect(session.controller.currentState.phase).toBe("idle");
});

Expand Down Expand Up @@ -324,7 +324,7 @@ describe("global voice input", () => {
expect(recorder.stop).not.toHaveBeenCalled();
expect(session.controller.currentState.phase).toBe("recording");
await session.controller.stop();
expect(commit).toHaveBeenCalledWith("hello spoken text", { start: 17, end: 17 });
expect(commit).toHaveBeenCalledWith("hello [STT] spoken text", { start: 23, end: 23 });
});

it("waits for canceled native work before starting a recording for another draft", async () => {
Expand Down Expand Up @@ -353,7 +353,7 @@ describe("global voice input", () => {
await session.controller.stop();
expect(recorder.record).toHaveBeenCalledTimes(1);
expect(oldCommit).not.toHaveBeenCalled();
expect(nextCommit).toHaveBeenCalledWith("second spoken text", { start: 18, end: 18 });
expect(nextCommit).toHaveBeenCalledWith("second [STT] spoken text", { start: 24, end: 24 });
});

it("holds the screen awake and meters only while recording", async () => {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ import { importComposerThreadAttachment } from "./composerThreadImport";
import { observeResize } from "~/lib/observeResize";

const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError);
import { markDictatedText } from "@t3tools/client-runtime/voice-input/dictationMarker";
import { useDictation } from "../../dictation/useDictation";
import { ComposerDictationButton } from "./ComposerDictationButton";
import { toastManager } from "../ui/toast";
Expand Down Expand Up @@ -2474,7 +2475,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
// thread may belong to. The desktop app transcribes on this computer either way.
transcriptionEnvironmentId: primaryEnvironmentId ?? environmentId,
insert: (text) =>
insertComposerTextStable(`${text} `, "cursor", { ensureLeadingBoundary: true }),
insertComposerTextStable(`${markDictatedText(promptRef.current, text)} `, "cursor", {
ensureLeadingBoundary: true,
}),
});
const toggleDictationStable = useStableCallback(dictation.toggle);
const cancelDictationStable = useStableCallback(dictation.cancel);
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/dictation/useDictation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import { markDictatedText } from "@t3tools/client-runtime/voice-input/dictationMarker";
import type {
DesktopDictationOutcome,
DictationAppendInput,
Expand Down Expand Up @@ -102,6 +103,7 @@ export function useDictation(input: {
const unstructuredRef = useRef<string | null>(null);
const inputRef = useRef(input);
const languageRef = useRef(language);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- gives the send timer and async steps the latest props
useEffect(() => {
inputRef.current = input;
languageRef.current = language;
Expand Down Expand Up @@ -279,7 +281,7 @@ export function useDictation(input: {
);
return;
}
void navigator.clipboard?.writeText(text);
void navigator.clipboard?.writeText(markDictatedText("", text));
notify(
{
text: "The composer cannot take text right now · copied to the clipboard",
Expand Down Expand Up @@ -323,6 +325,7 @@ export function useDictation(input: {
return false;
};

// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- holds the microphone and timers until unmount
useEffect(
() => () => {
// Leaving the composer (another thread, unmount) ends a recording without inserting it.
Expand Down
4 changes: 4 additions & 0 deletions packages/client-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@
"types": "./src/voice-input/index.ts",
"default": "./src/voice-input/index.ts"
},
"./voice-input/dictationMarker": {
"types": "./src/voice-input/dictationMarker.ts",
"default": "./src/voice-input/dictationMarker.ts"
},
"./t3ToolSummary": {
"types": "./src/t3ToolSummary.ts",
"default": "./src/t3ToolSummary.ts"
Expand Down
11 changes: 11 additions & 0 deletions packages/client-runtime/src/operations/threadTitle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vite-plus/test";

import { deriveThreadTitleSeed } from "./threadTitle.ts";

describe("deriveThreadTitleSeed", () => {
it("leaves the dictation marker out of the title", () => {
expect(deriveThreadTitleSeed({ text: "[STT] Fix the login", attachments: [] })).toBe(
"Fix the login",
);
});
});
5 changes: 4 additions & 1 deletion packages/client-runtime/src/operations/threadTitle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitation
import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
import { truncate } from "@t3tools/shared/String";

import { DICTATION_MARKER } from "../voice-input/dictationMarker.ts";

export interface ThreadTitleSeedInput {
readonly text: string;
readonly attachments: ReadonlyArray<Pick<ChatAttachment, "type" | "name">>;
Expand All @@ -15,7 +17,8 @@ function normalizeTitleSeed(value: string): string {

export function threadTitleTextFromPrompt(value: string): string {
const imageIds = new Set<string>();
const prose = replaceComposerContextReferences(value, (reference) => {
const unmarked = value.replace(`${DICTATION_MARKER} `, "");
const prose = replaceComposerContextReferences(unmarked, (reference) => {
if (reference.kind === "image") {
imageIds.add(reference.contextId);
return "";
Expand Down
14 changes: 7 additions & 7 deletions packages/client-runtime/src/voice-input/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ describe("resolveTranscriptCommit", () => {

expect(resolveTranscriptCommit(captured, captured, "use the mobile skill", "en-US")).toEqual({
kind: "commit",
text: "Fix 🧪 then use the mobile skill please",
selection: { start: tokenStart + "use the mobile skill".length, end: tokenStart + 20 },
text: "Fix 🧪 then [STT] use the mobile skill please",
selection: { start: tokenStart + 26, end: tokenStart + 26 },
});
});

Expand Down Expand Up @@ -201,14 +201,14 @@ describe("VoiceInputController", () => {
if (changeDuring === "recording") selected = second;
await harness.controller.stop();

expect(harness.commits.map((commit) => commit.text)).toEqual(["hello first choice"]);
expect(harness.commits.map((commit) => commit.text)).toEqual(["hello [STT] first choice"]);

await harness.controller.start();
await harness.controller.stop();

expect(harness.commits.map((commit) => commit.text)).toEqual([
"hello first choice",
"hello second choice",
"hello [STT] first choice",
"hello [STT] second choice",
]);
expect(preparationSignals).toHaveLength(2);
expect(transcriptionSignals).toHaveLength(2);
Expand All @@ -235,7 +235,7 @@ describe("VoiceInputController", () => {
await stopping;

expect(harness.commits).toEqual([
{ text: "hello new text", selection: { start: 14, end: 14 } },
{ text: "hello [STT] new text", selection: { start: 20, end: 20 } },
]);
expect(harness.deleted).toEqual(["file:///voice.m4a"]);
});
Expand Down Expand Up @@ -456,7 +456,7 @@ describe("VoiceInputController", () => {
await harness.controller.stop();

expect(harness.commits).toEqual([
{ text: "hello new text", selection: { start: 14, end: 14 } },
{ text: "hello [STT] new text", selection: { start: 20, end: 20 } },
]);
expect(harness.controller.currentState.phase).toBe("idle");
});
Expand Down
6 changes: 4 additions & 2 deletions packages/client-runtime/src/voice-input/controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { replaceTextRange } from "@t3tools/shared/composerTrigger";

import { markDictatedText } from "./dictationMarker.ts";
import type { PreparedVoiceTranscription, VoiceTranscriber } from "./transcription.ts";

export const VOICE_RECORDING_LIMIT_SECONDS = 5 * 60;
Expand Down Expand Up @@ -81,10 +82,11 @@ export function resolveTranscriptCommit(
return { kind: "stale" };
}

const replacement = transcript.trim();
if (replacement.length === 0) {
const trimmedTranscript = transcript.trim();
if (trimmedTranscript.length === 0) {
return { kind: "empty" };
}
const replacement = markDictatedText(captured.text, trimmedTranscript);

const isEmptySelection = captured.selection.start === captured.selection.end;
const normalizedLocale = locale.replaceAll("_", "-").toLowerCase();
Expand Down
14 changes: 14 additions & 0 deletions packages/client-runtime/src/voice-input/dictationMarker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vite-plus/test";

import { markDictatedText } from "./dictationMarker.ts";

describe("markDictatedText", () => {
it("marks the first dictation of a draft", () => {
expect(markDictatedText("", "hello")).toBe("[STT] hello");
expect(markDictatedText("/review ", "hello")).toBe("[STT] hello");
});

it("leaves later dictations in a marked draft alone", () => {
expect(markDictatedText("[STT] hello ", "again")).toBe("again");
});
});
10 changes: 10 additions & 0 deletions packages/client-runtime/src/voice-input/dictationMarker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** Tells whoever reads a prompt, agents included, that it was spoken and may hold transcription errors. */
export const DICTATION_MARKER = "[STT]";

/**
* Marks only the first dictation of a draft. It stays in front of the spoken text rather than at
* the start of the draft, because a typed `/command` only works as the draft's first word.
*/
export function markDictatedText(draft: string, text: string): string {
return draft.includes(DICTATION_MARKER) ? text : `${DICTATION_MARKER} ${text}`;
}
Loading