From 5d7665396083d285132d67038813862a93337ca5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 25 Aug 2026 03:12:53 -0700 Subject: [PATCH] fix(web): thread jump hints no longer stick after a dictation paste (#8189) Co-authored-by: Claude Fable 5 --- apps/web/src/shortcutModifierState.test.ts | 25 ++++++++++++++++++++ apps/web/src/shortcutModifierState.ts | 27 ++++++++++++++++------ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/apps/web/src/shortcutModifierState.test.ts b/apps/web/src/shortcutModifierState.test.ts index cb62d45bcc0bfac8ea677a364a1ebc159c6f7066..4ef4b3b6bf2dc0116c39f13f4065d0ffbfceb3b7 100644 --- a/apps/web/src/shortcutModifierState.test.ts +++ b/apps/web/src/shortcutModifierState.test.ts @@ -110,4 +110,29 @@ describe("shortcutModifierState", () => { shiftKey: false, }); }); + + it("ignores poisoned modifier flags on non-modifier keys", () => { + // A dictation paste (synthetic ⌘V) can leave the browser reporting + // metaKey=true on later real key events. Enter to submit must not + // re-mark ⌘ as held. + const state = shortcutModifierStateAfterKeyboardEvent( + emptyState(), + keyboardEventLike("keydown", { key: "Enter", metaKey: true }), + ); + expect(state).toEqual(emptyState()); + }); + + it("clears a held modifier when a non-modifier key reports it released", () => { + const heldMeta: ShortcutModifierState = { + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + }; + const state = shortcutModifierStateAfterKeyboardEvent( + heldMeta, + keyboardEventLike("keydown", { key: "a", metaKey: false }), + ); + expect(state).toEqual(emptyState()); + }); }); diff --git a/apps/web/src/shortcutModifierState.ts b/apps/web/src/shortcutModifierState.ts index a56a1a129d07f67d032b5f1077f02cc8207be15b..3abeeaa3e8aa7b3723ad1e62a8637e78a3b8dd5b 100644 --- a/apps/web/src/shortcutModifierState.ts +++ b/apps/web/src/shortcutModifierState.ts @@ -33,7 +33,12 @@ export function useShortcutModifierState(): ShortcutModifierState { const onKeyboardEvent = (event: KeyboardEvent) => { setState((current) => shortcutModifierStateAfterKeyboardEvent(current, event)); }; - const onWindowBlur = () => { + // Dictation tools (Wispr Flow) paste with a synthetic ⌘V whose Meta keyup + // never reaches the page, so the tracked state stays "⌘ held" forever and + // the thread jump hints stick on screen. A paste is never jump intent, so + // treat it like a blur and reset. A physically held modifier re-registers + // on the next real key event. + const onResetEvent = () => { setState((current) => areShortcutModifierStatesEqual(current, EMPTY_SHORTCUT_MODIFIER_STATE) ? current @@ -43,11 +48,13 @@ export function useShortcutModifierState(): ShortcutModifierState { window.addEventListener("keydown", onKeyboardEvent, true); window.addEventListener("keyup", onKeyboardEvent, true); - window.addEventListener("blur", onWindowBlur); + window.addEventListener("paste", onResetEvent, true); + window.addEventListener("blur", onResetEvent); return () => { window.removeEventListener("keydown", onKeyboardEvent, true); window.removeEventListener("keyup", onKeyboardEvent, true); - window.removeEventListener("blur", onWindowBlur); + window.removeEventListener("paste", onResetEvent, true); + window.removeEventListener("blur", onResetEvent); }; }, []); @@ -84,11 +91,17 @@ export function shortcutModifierStateAfterKeyboardEvent( [normalizedModifierKey]: event.type === "keydown", }; } else { + // Flags on non-modifier keys may only clear a bit, never set one. After a + // dictation tool's synthetic ⌘V (Wispr Flow), the browser can keep + // reporting metaKey=true on real key events (Enter to submit) until the + // user physically taps ⌘. Trusting that flag would mark ⌘ as held and + // stick the thread jump hints. Setting a bit requires a real modifier + // keydown, handled above. nextState = { - metaKey: event.metaKey, - ctrlKey: event.ctrlKey, - altKey: event.altKey, - shiftKey: event.shiftKey, + metaKey: currentState.metaKey && event.metaKey, + ctrlKey: currentState.ctrlKey && event.ctrlKey, + altKey: currentState.altKey && event.altKey, + shiftKey: currentState.shiftKey && event.shiftKey, }; } -- 2.54.0