From 07149390d1ce2a97b5a3a40d90caa3393ea16358 Mon Sep 17 00:00:00 2001 From: mrlufepines <269537355+mrlufepines@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:52:58 -0300 Subject: [PATCH 1/2] fix(web): stop held dictation when shortcut modifiers are released --- .../useDictationShortcut.browser.test.tsx | 105 ++++++++++++++---- apps/web/src/speech/useDictationShortcut.ts | 7 +- 2 files changed, 87 insertions(+), 25 deletions(-) diff --git a/apps/web/src/speech/useDictationShortcut.browser.test.tsx b/apps/web/src/speech/useDictationShortcut.browser.test.tsx index 8cd17f4f368dc..15887c421239b 100644 --- a/apps/web/src/speech/useDictationShortcut.browser.test.tsx +++ b/apps/web/src/speech/useDictationShortcut.browser.test.tsx @@ -3,7 +3,8 @@ import { expect, it, vi } from "vite-plus/test"; import { render } from "../../test/render"; import { useDictationShortcut } 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 })); function Probe({ speech }: { speech: Parameters[0]["speech"] }) { useDictationShortcut({ @@ -29,7 +30,8 @@ const press = (repeat = false) => }), ); -function actions(available: boolean) { +function setup(available: boolean, mode: typeof settings.mode = "hold") { + settings.mode = mode; return { available, state: { phase: "idle" as const, error: null, errorAction: null }, @@ -40,14 +42,14 @@ function actions(available: boolean) { } it("leaves the browser shortcut alone when dictation is off", async () => { - const speech = actions(false); + const speech = setup(false); await using _screen = await render(); expect(press()).toBe(true); expect(speech.start).not.toHaveBeenCalled(); }); it("stops on a new press when the OS omitted keyup, but ignores autorepeat", async () => { - const speech = actions(true); + const speech = setup(true); await using screen = await render(); expect(press()).toBe(false); expect(speech.start).toHaveBeenCalledOnce(); @@ -60,23 +62,78 @@ it("stops on a new press when the OS omitted keyup, but ignores autorepeat", asy expect(speech.stop).toHaveBeenCalledOnce(); }); -it("finishes a released hold after startup even if the chord was pressed again while preparing", async () => { - const speech = actions(true); - let finishStart!: () => void; - const startup = new Promise((resolve) => { - finishStart = resolve; - }); - speech.start.mockReturnValue(startup); - await using screen = await render(); - press(); - await screen.rerender( - , - ); - window.dispatchEvent(new KeyboardEvent("keyup", { code: "KeyD" })); - press(); - window.dispatchEvent(new KeyboardEvent("keyup", { code: "KeyD" })); - finishStart(); - await startup; - expect(speech.start).toHaveBeenCalledOnce(); - expect(speech.stop).toHaveBeenCalledOnce(); -}); +it.each(["KeyD", "MetaLeft", "ShiftLeft"])( + "finishes a hold released with %s after startup even if pressed again while preparing", + async (code) => { + const speech = setup(true); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + let finishStart!: () => void; + const startup = new Promise((resolve) => { + finishStart = resolve; + }); + speech.start.mockReturnValue(startup); + await using screen = await render(); + press(); + await screen.rerender( + , + ); + window.dispatchEvent( + new KeyboardEvent("keyup", { + code, + key: code === "MetaLeft" ? "Meta" : code === "ShiftLeft" ? "Shift" : "D", + }), + ); + press(); + window.dispatchEvent( + new KeyboardEvent("keyup", { + code, + key: code === "MetaLeft" ? "Meta" : code === "ShiftLeft" ? "Shift" : "D", + }), + ); + expect(speech.stop).not.toHaveBeenCalled(); + finishStart(); + await startup; + expect(speech.start).toHaveBeenCalledOnce(); + expect(speech.stop).toHaveBeenCalledOnce(); + }, +); + +it.each([ + { mode: "hold", duration: 0, stops: 1 }, + { mode: "auto", duration: 300, stops: 1 }, + { mode: "auto", duration: 299, stops: 0 }, + { mode: "toggle", duration: 300, stops: 0 }, +] as const)( + "handles modifier release without letter keyup in $mode after $duration ms", + async ({ mode, duration, stops }) => { + const speech = setup(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(); + await screen.rerender( + , + ); + clock.mockReturnValue(duration); + window.dispatchEvent( + new KeyboardEvent("keyup", { key: "a", code: "KeyA", metaKey: true, shiftKey: true }), + ); + window.dispatchEvent( + new KeyboardEvent("keyup", { key: "Alt", code: "AltLeft", metaKey: true, shiftKey: true }), + ); + await Promise.resolve(); + expect(speech.stop).not.toHaveBeenCalled(); + window.dispatchEvent( + new KeyboardEvent("keyup", { key: "Meta", code: "MetaLeft", shiftKey: true }), + ); + await Promise.resolve(); + expect(speech.stop).toHaveBeenCalledTimes(stops); + clock.mockReturnValue(1000); + window.dispatchEvent(new KeyboardEvent("keyup", { key: "Shift", code: "ShiftLeft" })); + window.dispatchEvent(new KeyboardEvent("keyup", { key: "d", code: "KeyD" })); + await Promise.resolve(); + expect(speech.stop).toHaveBeenCalledTimes(stops); + if (stops === 0) press(); + expect(speech.stop).toHaveBeenCalledOnce(); + }, +); diff --git a/apps/web/src/speech/useDictationShortcut.ts b/apps/web/src/speech/useDictationShortcut.ts index 6acbe5fac1ee3..e927a2e2ddeb6 100644 --- a/apps/web/src/speech/useDictationShortcut.ts +++ b/apps/web/src/speech/useDictationShortcut.ts @@ -73,6 +73,7 @@ export function useDictationShortcut(input: ShortcutInput) { const pressed = useRef<{ generation: number; code: string; + modifiers: string[]; startedAt: number; mode: typeof mode; starting: Promise; @@ -85,7 +86,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(() => { @@ -140,6 +144,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(), From 3f07448ec368425f1bd294f2ec728d29e9ddf45e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 06:02:45 +0000 Subject: [PATCH 2/2] test(web): trim dictation modifier-release tests to the regression Co-Authored-By: Claude Opus 5.5 --- .../useDictationShortcut.browser.test.tsx | 95 ++++++------------- 1 file changed, 31 insertions(+), 64 deletions(-) diff --git a/apps/web/src/speech/useDictationShortcut.browser.test.tsx b/apps/web/src/speech/useDictationShortcut.browser.test.tsx index c920faf7767a5..8530391cd7f60 100644 --- a/apps/web/src/speech/useDictationShortcut.browser.test.tsx +++ b/apps/web/src/speech/useDictationShortcut.browser.test.tsx @@ -75,79 +75,46 @@ it("stops on a new press when the OS omitted keyup, but ignores autorepeat", asy expect(speech.stop).toHaveBeenCalledOnce(); }); -it.each(["KeyD", "MetaLeft", "ShiftLeft"])( - "finishes a hold released with %s after startup even if pressed again while preparing", - async (code) => { - const speech = actions(true); - using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); - let finishStart!: () => void; - const startup = new Promise((resolve) => { - finishStart = resolve; - }); - speech.start.mockReturnValue(startup); - await using screen = await render(); - press(); - await screen.rerender( - , - ); - window.dispatchEvent( - new KeyboardEvent("keyup", { - code, - key: code === "MetaLeft" ? "Meta" : code === "ShiftLeft" ? "Shift" : "D", - }), - ); - press(); - window.dispatchEvent( - new KeyboardEvent("keyup", { - code, - key: code === "MetaLeft" ? "Meta" : code === "ShiftLeft" ? "Shift" : "D", - }), - ); - expect(speech.stop).not.toHaveBeenCalled(); - finishStart(); - await startup; - expect(speech.start).toHaveBeenCalledOnce(); - expect(speech.stop).toHaveBeenCalledOnce(); - }, -); +it("finishes a released hold after startup even if the chord was pressed again while preparing", async () => { + const speech = actions(true); + let finishStart!: () => void; + const startup = new Promise((resolve) => { + finishStart = resolve; + }); + speech.start.mockReturnValue(startup); + await using screen = await render(); + press(); + await screen.rerender( + , + ); + window.dispatchEvent(new KeyboardEvent("keyup", { code: "KeyD" })); + press(); + window.dispatchEvent(new KeyboardEvent("keyup", { code: "KeyD" })); + finishStart(); + await startup; + expect(speech.start).toHaveBeenCalledOnce(); + expect(speech.stop).toHaveBeenCalledOnce(); +}); it.each([ - { mode: "hold", duration: 0, stops: 1 }, - { mode: "auto", duration: 300, stops: 1 }, - { mode: "auto", duration: 299, stops: 0 }, - { mode: "toggle", duration: 300, stops: 0 }, + { mode: "hold", heldMs: 0, stops: 1 }, + { mode: "auto", heldMs: 300, stops: 1 }, + { mode: "auto", heldMs: 299, stops: 0 }, ] as const)( - "handles modifier release without letter keyup in $mode after $duration ms", - async ({ mode, duration, stops }) => { + "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(); + await using _screen = await render(); using clock = vi.spyOn(performance, "now").mockReturnValue(0); press(); - await screen.rerender( - , - ); - clock.mockReturnValue(duration); - window.dispatchEvent( - new KeyboardEvent("keyup", { key: "a", code: "KeyA", metaKey: true, shiftKey: true }), - ); - window.dispatchEvent( - new KeyboardEvent("keyup", { key: "Alt", code: "AltLeft", metaKey: true, shiftKey: true }), - ); - await Promise.resolve(); + 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", shiftKey: true }), - ); - await Promise.resolve(); - expect(speech.stop).toHaveBeenCalledTimes(stops); - clock.mockReturnValue(1000); - window.dispatchEvent(new KeyboardEvent("keyup", { key: "Shift", code: "ShiftLeft" })); - window.dispatchEvent(new KeyboardEvent("keyup", { key: "d", code: "KeyD" })); - await Promise.resolve(); + window.dispatchEvent(new KeyboardEvent("keyup", { key: "Meta", code: "MetaLeft" })); + await speech.start.mock.results[0]?.value; expect(speech.stop).toHaveBeenCalledTimes(stops); - if (stops === 0) press(); - expect(speech.stop).toHaveBeenCalledOnce(); }, );