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} +
    + +
    + + + +
    +
    +
  • + ); +} + +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 ? ( +
    + + +
    + ) : ( + + )} +
    + + ); +} + +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" },