diff --git a/FORK.md b/FORK.md
index 785aa78340bdd..54c3930ec268c 100644
--- a/FORK.md
+++ b/FORK.md
@@ -113,6 +113,9 @@ 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").
+- **Shift+F9** (or the arrow beside the mic) opens the history of everything dictated: Enter or
+ the arrow button inserts an entry, a click shows it in full. A structured prompt keeps its plain
+ transcript as the entry before it. The newest 10,000 stay in this client's IndexedDB.
- 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.
- Dictation expects German. The arrow beside the mic button switches the language
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index e29c0128bac46..2f6b73d1dd379 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -230,6 +230,7 @@ const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidErro
import { markDictatedText } from "@t3tools/client-runtime/voice-input/dictationMarker";
import { useDictation } from "../../dictation/useDictation";
import { ComposerDictationButton } from "./ComposerDictationButton";
+import { DictationHistoryDialog } from "./DictationHistoryDialog";
import { toastManager } from "../ui/toast";
import { PlayIcon } from "lucide-react";
import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText";
@@ -2461,11 +2462,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
});
const toggleDictationStable = useStableCallback(dictation.toggle);
const cancelDictationStable = useStableCallback(dictation.cancel);
+ const insertDictationFromHistoryStable = useStableCallback(dictation.insertFromHistory);
+ const [dictationHistoryOpen, setDictationHistoryOpen] = useState(false);
+ const openDictationHistoryStable = useStableCallback(() => setDictationHistoryOpen(true));
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window keydown listener for the dictation shortcuts and Esc
useEffect(() => {
const handler = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
+ // The open history takes Esc for itself, so closing it cannot drop a running recording.
+ if (dictationHistoryOpen) return;
// Before anything else claims Esc (it can also interrupt a running turn).
if (cancelDictationStable()) {
event.preventDefault();
@@ -2480,7 +2486,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
modelPickerOpen: isComposerModelPickerOpen,
},
});
- if (command !== "composer.dictate" && command !== "composer.dictateStructured") return;
+ if (
+ command !== "composer.dictate" &&
+ command !== "composer.dictateStructured" &&
+ command !== "composer.dictationHistory"
+ ) {
+ return;
+ }
// With several composers on screen, the one holding focus dictates.
const focusedForm = document.activeElement?.closest("form");
if (
@@ -2493,12 +2505,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
event.preventDefault();
event.stopPropagation();
if (event.repeat) return;
+ if (command === "composer.dictationHistory") {
+ setDictationHistoryOpen((open) => !open);
+ return;
+ }
toggleDictationStable(command === "composer.dictateStructured");
};
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [
cancelDictationStable,
+ dictationHistoryOpen,
isComposerModelPickerOpen,
keybindings,
terminalOpen,
@@ -3481,6 +3498,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
keybindings,
"composer.dictateStructured",
)}
+ historyShortcutLabel={shortcutLabelForCommand(
+ keybindings,
+ "composer.dictationHistory",
+ )}
+ onOpenHistory={openDictationHistoryStable}
+ />
+
void) =>
onToggle={() => {}}
dictateShortcutLabel={null}
structuredShortcutLabel={null}
+ historyShortcutLabel={null}
+ onOpenHistory={() => {}}
language="de"
onLanguageChange={onLanguageChange}
/>,
diff --git a/apps/web/src/components/chat/ComposerDictationButton.tsx b/apps/web/src/components/chat/ComposerDictationButton.tsx
index c2b86386ac36a..b4815a90458ce 100644
--- a/apps/web/src/components/chat/ComposerDictationButton.tsx
+++ b/apps/web/src/components/chat/ComposerDictationButton.tsx
@@ -1,10 +1,25 @@
-import { ChevronDownIcon, LoaderCircleIcon, MicIcon, SparklesIcon } from "lucide-react";
+import {
+ ChevronDownIcon,
+ HistoryIcon,
+ LoaderCircleIcon,
+ MicIcon,
+ SparklesIcon,
+} from "lucide-react";
import { DICTATION_LANGUAGES } from "../../dictation/dictationLanguages";
import type { DictationState } from "../../dictation/useDictation";
import { cn } from "~/lib/utils";
import { Button } from "../ui/button";
-import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ui/menu";
+import {
+ Menu,
+ MenuItem,
+ MenuPopup,
+ MenuRadioGroup,
+ MenuRadioItem,
+ MenuSeparator,
+ MenuShortcut,
+ MenuTrigger,
+} from "../ui/menu";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { useComposerMenuProps } from "./composerEventScope";
@@ -24,6 +39,8 @@ export function ComposerDictationButton(props: {
readonly onToggle: (structured: boolean) => void;
readonly dictateShortcutLabel: string | null;
readonly structuredShortcutLabel: string | null;
+ readonly historyShortcutLabel: string | null;
+ readonly onOpenHistory: () => void;
readonly language: string;
readonly onLanguageChange: (language: string) => void;
}) {
@@ -153,6 +170,14 @@ export function ComposerDictationButton(props: {
))}
+
+
+
+ History
+ {props.historyShortcutLabel ? (
+ {props.historyShortcutLabel}
+ ) : null}
+
diff --git a/apps/web/src/components/chat/DictationHistoryDialog.browser.test.tsx b/apps/web/src/components/chat/DictationHistoryDialog.browser.test.tsx
new file mode 100644
index 0000000000000..ce6429d64fca7
--- /dev/null
+++ b/apps/web/src/components/chat/DictationHistoryDialog.browser.test.tsx
@@ -0,0 +1,71 @@
+import "~/index.css";
+
+import { describe, expect, it, vi } from "vite-plus/test";
+import { page, userEvent } from "vite-plus/test/browser";
+
+import { render } from "../../../test/render";
+import {
+ addDictationHistoryEntry,
+ clearDictationHistory,
+ listDictationHistory,
+} from "../../dictation/dictationHistory";
+import { DictationHistoryDialog } from "./DictationHistoryDialog";
+
+const seed = async (count: number) => {
+ await clearDictationHistory();
+ for (let index = 0; index < count; index++) {
+ await addDictationHistoryEntry({
+ text: `Prompt ${index}`,
+ createdAt: 1_790_000_000_000,
+ structured: false,
+ });
+ }
+};
+
+describe("DictationHistoryDialog", () => {
+ it("moves through the entries with the arrows and inserts on Enter, once closed", async () => {
+ await seed(2);
+ const onInsert = vi.fn();
+ const onOpenChange = vi.fn();
+ const dialog = (open: boolean) => (
+
+ );
+ await using screen = await render(dialog(true));
+ await expect.element(page.getByText("Prompt 0")).toBeVisible();
+ await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}");
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ expect(onInsert).not.toHaveBeenCalled();
+ await screen.rerender(dialog(false));
+ await vi.waitFor(() => expect(onInsert).toHaveBeenCalledWith("Prompt 0"));
+ });
+
+ it("loads older entries when scrolled to the end, and deletes for good", async () => {
+ await seed(120);
+ await using _screen = await render(
+ {}}
+ dictateShortcutLabel="F9"
+ onInsert={() => {}}
+ />,
+ );
+ await expect.element(page.getByText("Prompt 119")).toBeVisible();
+ await expect.element(page.getByText("Prompt 0", { exact: true })).not.toBeInTheDocument();
+ const list = page.getByRole("list", { name: "Dictated prompts" }).element();
+ const viewport = list.closest('[data-slot="scroll-area-viewport"]')!;
+ viewport.scrollTop = viewport.scrollHeight;
+ await expect.element(page.getByText("Prompt 0", { exact: true })).toBeInTheDocument();
+
+ await page.getByRole("button", { name: "Delete" }).first().click();
+ await expect.element(page.getByText("Prompt 119")).not.toBeInTheDocument();
+ await vi.waitFor(async () => {
+ const [newest] = await listDictationHistory({ before: null, limit: 1 });
+ expect(newest?.text).toBe("Prompt 118");
+ });
+ });
+});
diff --git a/apps/web/src/components/chat/DictationHistoryDialog.tsx b/apps/web/src/components/chat/DictationHistoryDialog.tsx
new file mode 100644
index 0000000000000..8a2169d0a7df4
--- /dev/null
+++ b/apps/web/src/components/chat/DictationHistoryDialog.tsx
@@ -0,0 +1,295 @@
+import { CheckIcon, CopyIcon, CornerDownLeftIcon, Trash2Icon } from "lucide-react";
+import { type KeyboardEvent, useEffect, useRef, useState } from "react";
+
+import { markDictatedText } from "@t3tools/client-runtime/voice-input/dictationMarker";
+
+import {
+ clearDictationHistory,
+ deleteDictationHistoryEntry,
+ listDictationHistory,
+ type DictationHistoryEntry,
+} from "../../dictation/dictationHistory";
+import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
+import { useStableCallback } from "../../hooks/useStableCallback";
+import { cn } from "~/lib/utils";
+import { Button } from "../ui/button";
+import { Dialog, DialogFooter, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog";
+import { ScrollArea } from "../ui/scroll-area";
+
+const PAGE_SIZE = 100;
+const LOAD_MORE_WITHIN_PX = 600;
+
+function dayLabel(timestamp: number, now: Date): string {
+ const date = new Date(timestamp);
+ const yesterday = new Date(now);
+ yesterday.setDate(now.getDate() - 1);
+ if (date.toDateString() === now.toDateString()) return "Today";
+ if (date.toDateString() === yesterday.toDateString()) return "Yesterday";
+ return date.toLocaleDateString(undefined, {
+ weekday: "long",
+ day: "numeric",
+ month: "long",
+ ...(date.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }),
+ });
+}
+
+function HistoryRow(props: {
+ readonly entry: DictationHistoryEntry;
+ readonly heading: string | null;
+ readonly onInsert: (text: string) => void;
+ readonly onDelete: (id: number) => void;
+}) {
+ const { entry } = props;
+ const [expanded, setExpanded] = useState(false);
+ const { copyToClipboard, isCopied } = useCopyToClipboard();
+ return (
+
+ {props.heading ? (
+
+ {props.heading}
+
+ ) : null}
+
+
setExpanded((previous) => !previous)}
+ onKeyDown={(event) => {
+ if (event.key !== "Enter") return;
+ event.preventDefault();
+ props.onInsert(entry.text);
+ }}
+ >
+
+ {entry.text}
+
+
+
+ {new Date(entry.createdAt).toLocaleTimeString(undefined, {
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+ {entry.structured ? Structured : null}
+
+
+
+ props.onInsert(entry.text)}
+ >
+
+
+ copyToClipboard(markDictatedText("", entry.text))}
+ >
+ {isCopied ? : }
+
+ props.onDelete(entry.id)}
+ >
+
+
+
+
+
+ );
+}
+
+function HistoryContent(props: {
+ readonly dictateShortcutLabel: string | null;
+ readonly onInsert: (text: string) => void;
+}) {
+ const [entries, setEntries] = useState>([]);
+ const [status, setStatus] = useState<"loading" | "more" | "complete" | "failed">("loading");
+ const [confirmingClear, setConfirmingClear] = useState(false);
+ const loadingRef = useRef(false);
+
+ const [now] = useState(() => new Date());
+
+ const loadPage = useStableCallback(async (before: number | null) => {
+ if (loadingRef.current) return;
+ loadingRef.current = true;
+ try {
+ const page = await listDictationHistory({ before, limit: PAGE_SIZE });
+ setEntries((previous) => [...previous, ...page]);
+ setStatus(page.length < PAGE_SIZE ? "complete" : "more");
+ } catch (error) {
+ console.warn("dictation: history could not be read", error);
+ setStatus("failed");
+ } finally {
+ loadingRef.current = false;
+ }
+ });
+
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reads the first page from IndexedDB when the dialog opens
+ useEffect(() => {
+ void loadPage(null);
+ }, [loadPage]);
+
+ const moveFocus = (event: KeyboardEvent) => {
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
+ const rows = Array.from(
+ event.currentTarget.querySelectorAll("[data-dictation-history-row]"),
+ );
+ const current = rows.indexOf(document.activeElement as HTMLElement);
+ const next = rows[current + (event.key === "ArrowDown" ? 1 : -1)];
+ if (next === undefined) return;
+ event.preventDefault();
+ next.focus();
+ next.scrollIntoView({ block: "nearest" });
+ };
+
+ const remove = (id: number) => {
+ setEntries((previous) => previous.filter((entry) => entry.id !== id));
+ void deleteDictationHistoryEntry(id).catch((error: unknown) => {
+ console.warn("dictation: history entry could not be deleted", error);
+ });
+ };
+
+ const clear = () => {
+ setConfirmingClear(false);
+ setEntries([]);
+ setStatus("complete");
+ void clearDictationHistory().catch((error: unknown) => {
+ console.warn("dictation: history could not be cleared", error);
+ });
+ };
+
+ return (
+ <>
+
+
{
+ const viewport = event.target as HTMLElement;
+ const remaining = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
+ const last = entries.at(-1);
+ if (status === "more" && last && remaining < LOAD_MORE_WITHIN_PX) {
+ void loadPage(last.id);
+ }
+ }}
+ >
+ {entries.length === 0 && status !== "loading" ? (
+
+ {status === "failed"
+ ? "History could not be loaded. Reopen it to try again"
+ : props.dictateShortcutLabel
+ ? `Nothing dictated yet. Press ${props.dictateShortcutLabel} to start`
+ : "Nothing dictated yet"}
+
+ ) : (
+
+ {entries.map((entry, index) => {
+ const day = dayLabel(entry.createdAt, now);
+ const previous = entries[index - 1];
+ const heading = previous && dayLabel(previous.createdAt, now) === day ? null : day;
+ return (
+
+ );
+ })}
+
+ )}
+
+
+
+ Saved on this device
+ {confirmingClear ? (
+
+ setConfirmingClear(false)}
+ >
+ Keep history
+
+
+ Delete all
+
+
+ ) : (
+ setConfirmingClear(true)}
+ >
+ Clear history
+
+ )}
+
+ >
+ );
+}
+
+export function DictationHistoryDialog(props: {
+ readonly open: boolean;
+ readonly onOpenChange: (open: boolean) => void;
+ readonly dictateShortcutLabel: string | null;
+ readonly onInsert: (text: string) => void;
+}) {
+ // Inserted once the dialog has closed and let go of focus, so the caret lands in the composer.
+ const pendingInsertRef = useRef(null);
+ return (
+ {
+ const text = pendingInsertRef.current;
+ if (open || text === null) return;
+ pendingInsertRef.current = null;
+ props.onInsert(text);
+ }}
+ >
+ document.querySelector("[data-dictation-history-list]")}
+ >
+
+ Dictation history
+
+ {
+ pendingInsertRef.current = text;
+ props.onOpenChange(false);
+ }}
+ />
+
+
+ );
+}
diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts
index df1500a4d3da2..231f6789940ba 100644
--- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts
+++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts
@@ -375,6 +375,7 @@ export function commandLabel(command: KeybindingCommand): string {
if (command === "composer.sendAndNewThread") return "Composer: Send and Start New Thread";
if (command === "composer.dictate") return "Composer: Dictate";
if (command === "composer.dictateStructured") return "Composer: Dictate as Structured Prompt";
+ if (command === "composer.dictationHistory") return "Composer: Dictation History";
if (command === "thread.steerQueuedMessage") return "Queue: Send First Queued Message";
if (command === "thread.editQueuedMessage") return "Queue: Edit Last Queued Message";
if (command === "thread.copyReference") return "Pull Request: Copy Link or Thread";
diff --git a/apps/web/src/dictation/dictationHistory.browser.test.ts b/apps/web/src/dictation/dictationHistory.browser.test.ts
new file mode 100644
index 0000000000000..aec5b15271a81
--- /dev/null
+++ b/apps/web/src/dictation/dictationHistory.browser.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ addDictationHistoryEntry,
+ clearDictationHistory,
+ deleteDictationHistoryEntry,
+ listDictationHistory,
+} from "./dictationHistory";
+
+const add = (text: string, maxEntries?: number) =>
+ addDictationHistoryEntry({ text, createdAt: 0, structured: false }, maxEntries);
+
+const texts = async (input: { before: number | null; limit: number }) =>
+ (await listDictationHistory(input)).map((entry) => entry.text);
+
+describe("dictationHistory", () => {
+ it("pages from newest to oldest and survives a deleted entry", async () => {
+ await clearDictationHistory();
+ for (const text of ["a", "b", "c", "d"]) await add(text);
+
+ const firstPage = await listDictationHistory({ before: null, limit: 2 });
+ expect(firstPage.map((entry) => entry.text)).toEqual(["d", "c"]);
+
+ await deleteDictationHistoryEntry(firstPage[1]!.id);
+ expect(await texts({ before: firstPage[1]!.id, limit: 2 })).toEqual(["b", "a"]);
+ expect(await texts({ before: null, limit: 10 })).toEqual(["d", "b", "a"]);
+ });
+
+ it("drops the oldest entries beyond the limit", async () => {
+ await clearDictationHistory();
+ for (const text of ["a", "b", "c", "d"]) await add(text, 2);
+ expect(await texts({ before: null, limit: 10 })).toEqual(["d", "c"]);
+ });
+});
diff --git a/apps/web/src/dictation/dictationHistory.ts b/apps/web/src/dictation/dictationHistory.ts
new file mode 100644
index 0000000000000..1224c69b636ba
--- /dev/null
+++ b/apps/web/src/dictation/dictationHistory.ts
@@ -0,0 +1,91 @@
+// Dictated text kept on this client. IndexedDB rather than localStorage: reads are paged and
+// asynchronous, so a long history costs nothing until it is opened.
+
+const DATABASE_NAME = "t3code:dictation-history";
+const STORE_NAME = "entries";
+// About 10 to 20 MB at this size: a typical dictation is one or two KB of text.
+const MAX_ENTRIES = 10_000;
+
+export interface DictationHistoryEntry {
+ readonly id: number;
+ readonly text: string;
+ readonly createdAt: number;
+ /** The rewritten prompt; its plain transcript is the entry before it. */
+ readonly structured: boolean;
+}
+
+let database: Promise | undefined;
+
+function openDatabase() {
+ return (database ??= new Promise((resolve, reject) => {
+ const request = indexedDB.open(DATABASE_NAME, 1);
+ request.addEventListener("upgradeneeded", () => {
+ request.result.createObjectStore(STORE_NAME, { keyPath: "id", autoIncrement: true });
+ });
+ request.addEventListener("success", () => resolve(request.result));
+ request.addEventListener("error", () => reject(request.error));
+ request.addEventListener("blocked", () => reject(new Error("Dictation history is blocked.")));
+ }));
+}
+
+async function runTransaction(mode: IDBTransactionMode, use: (store: IDBObjectStore) => void) {
+ const transaction = (await openDatabase()).transaction(STORE_NAME, mode);
+ use(transaction.objectStore(STORE_NAME));
+ await new Promise((resolve, reject) => {
+ transaction.addEventListener("complete", () => resolve());
+ transaction.addEventListener("abort", () => reject(transaction.error));
+ transaction.addEventListener("error", () => reject(transaction.error));
+ });
+}
+
+/** Adds an entry and drops the oldest ones beyond `maxEntries`. */
+export function addDictationHistoryEntry(
+ entry: Omit,
+ maxEntries = MAX_ENTRIES,
+): Promise {
+ return runTransaction("readwrite", (store) => {
+ store.add(entry);
+ const count = store.count();
+ count.addEventListener("success", () => {
+ let excess = count.result - maxEntries;
+ if (excess <= 0) return;
+ const cursor = store.openCursor();
+ cursor.addEventListener("success", () => {
+ if (cursor.result === null || excess <= 0) return;
+ cursor.result.delete();
+ excess--;
+ cursor.result.continue();
+ });
+ });
+ });
+}
+
+/** Up to `limit` entries older than the entry `before`, newest first. */
+export async function listDictationHistory(input: {
+ readonly before: number | null;
+ readonly limit: number;
+}): Promise> {
+ const entries: Array = [];
+ await runTransaction("readonly", (store) => {
+ const range = input.before === null ? null : IDBKeyRange.upperBound(input.before, true);
+ const cursor = store.openCursor(range, "prev");
+ cursor.addEventListener("success", () => {
+ if (cursor.result === null) return;
+ entries.push(cursor.result.value as DictationHistoryEntry);
+ if (entries.length < input.limit) cursor.result.continue();
+ });
+ });
+ return entries;
+}
+
+export function deleteDictationHistoryEntry(id: number): Promise {
+ return runTransaction("readwrite", (store) => {
+ store.delete(id);
+ });
+}
+
+export function clearDictationHistory(): Promise {
+ return runTransaction("readwrite", (store) => {
+ store.clear();
+ });
+}
diff --git a/apps/web/src/dictation/useDictation.ts b/apps/web/src/dictation/useDictation.ts
index 6205187b5155f..de9e12fe90548 100644
--- a/apps/web/src/dictation/useDictation.ts
+++ b/apps/web/src/dictation/useDictation.ts
@@ -18,6 +18,7 @@ import {
startMicrophoneCapture,
type MicrophoneCapture,
} from "./microphoneCapture";
+import { addDictationHistoryEntry } from "./dictationHistory";
import { DEFAULT_DICTATION_LANGUAGE } from "./dictationLanguages";
// Dictation into the composer: the microphone is recorded here and streamed to a local speech
@@ -55,6 +56,15 @@ interface Recording {
const IDLE: DictationState = { phase: "idle", preview: "", structured: false, notice: null };
+// Dictating must keep working when the history cannot be written.
+const recordInHistory = (text: string, structured: boolean) => {
+ void addDictationHistoryEntry({ text, createdAt: Date.now(), structured }).catch(
+ (error: unknown) => {
+ console.warn("dictation: history entry could not be saved", error);
+ },
+ );
+};
+
const failureText = (result: { readonly _tag: "Failure" }) => {
const error = squashAtomCommandFailure(result as never) as unknown;
return error instanceof Error ? error.message : String(error);
@@ -238,6 +248,8 @@ export function useDictation(input: {
return;
}
const result = await transcribe.finish(recording.sessionId);
+ // Before the generation check: leaving the thread drops the insert, not what was said.
+ if (result.ok && result.value.text !== "") recordInHistory(result.value.text, false);
if (generation !== generationRef.current) return;
if (!result.ok) {
setState(IDLE);
@@ -270,11 +282,16 @@ export function useDictation(input: {
deliver(text, "Structuring failed · inserted unchanged");
return;
}
+ recordInHistory(rewritten.value.text, true);
deliver(rewritten.value.text, null);
};
const deliver = (text: string, warning: string | null) => {
setState(IDLE);
+ place(text, warning);
+ };
+
+ const place = (text: string, warning: string | null) => {
if (inputRef.current.insert(text)) {
notify(
warning ? { text: warning, tone: "warning" } : { text: "Inserted", tone: "success" },
@@ -342,5 +359,8 @@ export function useDictation(input: {
[],
);
- return { state, toggle, cancel, language, setLanguage };
+ /** Puts an earlier dictation into the composer; a running recording carries on. */
+ const insertFromHistory = (text: string) => place(text, null);
+
+ return { state, toggle, cancel, insertFromHistory, language, setLanguage };
}
diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts
index 1f0a096fccaec..ff7ad47b4a1c7 100644
--- a/apps/web/src/keybindings.test.ts
+++ b/apps/web/src/keybindings.test.ts
@@ -425,6 +425,17 @@ describe("model picker navigation helpers", () => {
});
});
+describe("dictation shortcuts", () => {
+ it("keeps the history on Shift+F9 apart from dictating on F9", () => {
+ const resolve = (shiftKey: boolean) =>
+ resolveShortcutCommand(event({ key: "F9", shiftKey }), DEFAULT_RESOLVED_KEYBINDINGS, {
+ context: { terminalFocus: false },
+ });
+ assert.strictEqual(resolve(false), "composer.dictate");
+ assert.strictEqual(resolve(true), "composer.dictationHistory");
+ });
+});
+
describe("chat/editor shortcuts", () => {
it("toggles the thread panel by default without taking terminal keystrokes", () => {
for (const platform of ["MacIntel", "Win32", "Linux"]) {
diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts
index 98ff44a1a4119..97fe73037062d 100644
--- a/packages/contracts/src/keybindings.ts
+++ b/packages/contracts/src/keybindings.ts
@@ -101,6 +101,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
"composer.branch",
"composer.dictate",
"composer.dictateStructured",
+ "composer.dictationHistory",
"chat.new",
"chat.newLocal",
"chat.newWithoutProject",
diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts
index b4044539a9347..298829813c5f5 100644
--- a/packages/shared/src/keybindings.ts
+++ b/packages/shared/src/keybindings.ts
@@ -210,6 +210,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [
{ key: "mod+shift+g", command: "composer.branch", when: "!terminalFocus" },
{ key: "f9", command: "composer.dictate", when: "!terminalFocus" },
{ key: "f8", command: "composer.dictateStructured", when: "!terminalFocus" },
+ { key: "shift+f9", command: "composer.dictationHistory", when: "!terminalFocus" },
{ key: "mod+shift+l", command: "composer.previousWorktree", when: "!terminalFocus" },
{ key: "mod+shift+k", command: "pullRequest.copyNumber", when: "!terminalFocus" },
{ key: "mod+shift+arrowup", command: "modelPicker.previousProvider", when: "modelPickerOpen" },