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
3 changes: 3 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 29 additions & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand All @@ -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 (
Expand All @@ -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,
Expand Down Expand Up @@ -3481,6 +3498,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
keybindings,
"composer.dictateStructured",
)}
historyShortcutLabel={shortcutLabelForCommand(
keybindings,
"composer.dictationHistory",
)}
onOpenHistory={openDictationHistoryStable}
/>
<DictationHistoryDialog
open={dictationHistoryOpen}
onOpenChange={setDictationHistoryOpen}
dictateShortcutLabel={shortcutLabelForCommand(keybindings, "composer.dictate")}
onInsert={insertDictationFromHistoryStable}
/>
</div>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const renderButton = (onLanguageChange: (language: string) => void) =>
onToggle={() => {}}
dictateShortcutLabel={null}
structuredShortcutLabel={null}
historyShortcutLabel={null}
onOpenHistory={() => {}}
language="de"
onLanguageChange={onLanguageChange}
/>,
Expand Down
29 changes: 27 additions & 2 deletions apps/web/src/components/chat/ComposerDictationButton.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
}) {
Expand Down Expand Up @@ -153,6 +170,14 @@ export function ComposerDictationButton(props: {
</MenuRadioItem>
))}
</MenuRadioGroup>
<MenuSeparator />
<MenuItem onClick={props.onOpenHistory}>
<HistoryIcon />
History
{props.historyShortcutLabel ? (
<MenuShortcut>{props.historyShortcutLabel}</MenuShortcut>
) : null}
</MenuItem>
</MenuPopup>
</Menu>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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) => (
<DictationHistoryDialog
open={open}
onOpenChange={onOpenChange}
dictateShortcutLabel="F9"
onInsert={onInsert}
/>
);
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(
<DictationHistoryDialog
open
onOpenChange={() => {}}
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<HTMLElement>('[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");
});
});
});
Loading
Loading