diff --git a/apps/web/src/speech/useDictationShortcut.browser.test.tsx b/apps/web/src/speech/useDictationShortcut.browser.test.tsx index 5bbc17b1ecc3d..8530391cd7f60 100644 --- a/apps/web/src/speech/useDictationShortcut.browser.test.tsx +++ b/apps/web/src/speech/useDictationShortcut.browser.test.tsx @@ -4,7 +4,8 @@ import { expect, it, vi } from "vite-plus/test"; import { render } from "../../test/render"; import { useDictationShortcut, type DictationKeyHandlers } from "./useDictationShortcut"; -vi.mock("../hooks/useSettings", () => ({ useClientSettings: () => "hold" })); +const settings = vi.hoisted(() => ({ mode: "hold" as "hold" | "auto" | "toggle" })); +vi.mock("../hooks/useSettings", () => ({ useClientSettings: () => settings.mode })); type ShortcutInput = Parameters[0]; @@ -42,7 +43,8 @@ const press = (repeat = false) => }), ); -function actions(available: boolean) { +function actions(available: boolean, mode: typeof settings.mode = "hold") { + settings.mode = mode; return { available, state: { phase: "idle" as const, error: null, errorAction: null }, @@ -94,6 +96,28 @@ it("finishes a released hold after startup even if the chord was pressed again w expect(speech.stop).toHaveBeenCalledOnce(); }); +it.each([ + { mode: "hold", heldMs: 0, stops: 1 }, + { mode: "auto", heldMs: 300, stops: 1 }, + { mode: "auto", heldMs: 299, stops: 0 }, +] as const)( + "ends a $mode press held $heldMs ms when a chord modifier is released without the letter keyup", + async ({ mode, heldMs, stops }) => { + const speech = actions(true, mode); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + await using _screen = await render(); + using clock = vi.spyOn(performance, "now").mockReturnValue(0); + press(); + clock.mockReturnValue(heldMs); + window.dispatchEvent(new KeyboardEvent("keyup", { key: "Alt", code: "AltLeft" })); + await speech.start.mock.results[0]?.value; + expect(speech.stop).not.toHaveBeenCalled(); + window.dispatchEvent(new KeyboardEvent("keyup", { key: "Meta", code: "MetaLeft" })); + await speech.start.mock.results[0]?.value; + expect(speech.stop).toHaveBeenCalledTimes(stops); + }, +); + const escape = () => new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true, cancelable: true }); diff --git a/apps/web/src/speech/useDictationShortcut.ts b/apps/web/src/speech/useDictationShortcut.ts index 278a4baa60026..155a6efd68585 100644 --- a/apps/web/src/speech/useDictationShortcut.ts +++ b/apps/web/src/speech/useDictationShortcut.ts @@ -98,6 +98,7 @@ export function useDictationShortcut(input: ShortcutInput) { const pressed = useRef<{ generation: number; code: string; + modifiers: string[]; startedAt: number; mode: typeof mode; starting: Promise; @@ -110,7 +111,10 @@ export function useDictationShortcut(input: ShortcutInput) { shortcutTargets.set(id.current, () => current.current); const finish = (event: KeyboardEvent | null) => { const press = pressed.current; - if (!press || (event && (event.code || event.key) !== press.code)) return; + if (!press) return; + // macOS can omit the letter keyup while Command is held; releasing a chord modifier ends it too. + if (event && (event.code || event.key) !== press.code && !press.modifiers.includes(event.key)) + return; pressed.current = null; if (shouldFinishDictationOnRelease(press.mode, performance.now() - press.startedAt)) { void press.starting.then(() => { @@ -166,6 +170,7 @@ export function useDictationShortcut(input: ShortcutInput) { pressed.current = { generation: generation.current, code: event.code || event.key, + modifiers: ["Alt", "Control", "Meta", "Shift"].filter((key) => event.getModifierState(key)), startedAt: performance.now(), mode, starting: speech.start(),