authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-25 03:12:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-25 03:12:53-07:00
log5d7665396083d285132d67038813862a93337ca5
tree95e664616df55ef0114ee7205070b9723b347936
parentc6b8bb82575c972ce858ddf3bdbf5386f2fb0f5e
signature Signed by PGP key B5690EEEBB952194

fix(web): thread jump hints no longer stick after a dictation paste (#8189)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 45 insertions(+), 7 deletions(-)

apps/web/src/shortcutModifierState.test.ts+25
...@@ -110,4 +110,29 @@ describe("shortcutModifierState", () => {...@@ -110,4 +110,29 @@ describe("shortcutModifierState", () => {
110 shiftKey: false,110 shiftKey: false,
111 });111 });
112 });112 });
113
114 it("ignores poisoned modifier flags on non-modifier keys", () => {
115 // A dictation paste (synthetic ⌘V) can leave the browser reporting
116 // metaKey=true on later real key events. Enter to submit must not
117 // re-mark ⌘ as held.
118 const state = shortcutModifierStateAfterKeyboardEvent(
119 emptyState(),
120 keyboardEventLike("keydown", { key: "Enter", metaKey: true }),
121 );
122 expect(state).toEqual(emptyState());
123 });
124
125 it("clears a held modifier when a non-modifier key reports it released", () => {
126 const heldMeta: ShortcutModifierState = {
127 metaKey: true,
128 ctrlKey: false,
129 altKey: false,
130 shiftKey: false,
131 };
132 const state = shortcutModifierStateAfterKeyboardEvent(
133 heldMeta,
134 keyboardEventLike("keydown", { key: "a", metaKey: false }),
135 );
136 expect(state).toEqual(emptyState());
137 });
113});138});
apps/web/src/shortcutModifierState.ts+20-7
...@@ -33,7 +33,12 @@ export function useShortcutModifierState(): ShortcutModifierState {...@@ -33,7 +33,12 @@ export function useShortcutModifierState(): ShortcutModifierState {
33 const onKeyboardEvent = (event: KeyboardEvent) => {33 const onKeyboardEvent = (event: KeyboardEvent) => {
34 setState((current) => shortcutModifierStateAfterKeyboardEvent(current, event));34 setState((current) => shortcutModifierStateAfterKeyboardEvent(current, event));
35 };35 };
36 const onWindowBlur = () => {36 // Dictation tools (Wispr Flow) paste with a synthetic ⌘V whose Meta keyup
37 // never reaches the page, so the tracked state stays "⌘ held" forever and
38 // the thread jump hints stick on screen. A paste is never jump intent, so
39 // treat it like a blur and reset. A physically held modifier re-registers
40 // on the next real key event.
41 const onResetEvent = () => {
37 setState((current) =>42 setState((current) =>
38 areShortcutModifierStatesEqual(current, EMPTY_SHORTCUT_MODIFIER_STATE)43 areShortcutModifierStatesEqual(current, EMPTY_SHORTCUT_MODIFIER_STATE)
39 ? current44 ? current
...@@ -43,11 +48,13 @@ export function useShortcutModifierState(): ShortcutModifierState {...@@ -43,11 +48,13 @@ export function useShortcutModifierState(): ShortcutModifierState {
4348
44 window.addEventListener("keydown", onKeyboardEvent, true);49 window.addEventListener("keydown", onKeyboardEvent, true);
45 window.addEventListener("keyup", onKeyboardEvent, true);50 window.addEventListener("keyup", onKeyboardEvent, true);
46 window.addEventListener("blur", onWindowBlur);51 window.addEventListener("paste", onResetEvent, true);
52 window.addEventListener("blur", onResetEvent);
47 return () => {53 return () => {
48 window.removeEventListener("keydown", onKeyboardEvent, true);54 window.removeEventListener("keydown", onKeyboardEvent, true);
49 window.removeEventListener("keyup", onKeyboardEvent, true);55 window.removeEventListener("keyup", onKeyboardEvent, true);
50 window.removeEventListener("blur", onWindowBlur);56 window.removeEventListener("paste", onResetEvent, true);
57 window.removeEventListener("blur", onResetEvent);
51 };58 };
52 }, []);59 }, []);
5360
...@@ -84,11 +91,17 @@ export function shortcutModifierStateAfterKeyboardEvent(...@@ -84,11 +91,17 @@ export function shortcutModifierStateAfterKeyboardEvent(
84 [normalizedModifierKey]: event.type === "keydown",91 [normalizedModifierKey]: event.type === "keydown",
85 };92 };
86 } else {93 } else {
94 // Flags on non-modifier keys may only clear a bit, never set one. After a
95 // dictation tool's synthetic ⌘V (Wispr Flow), the browser can keep
96 // reporting metaKey=true on real key events (Enter to submit) until the
97 // user physically taps ⌘. Trusting that flag would mark ⌘ as held and
98 // stick the thread jump hints. Setting a bit requires a real modifier
99 // keydown, handled above.
87 nextState = {100 nextState = {
88 metaKey: event.metaKey,101 metaKey: currentState.metaKey && event.metaKey,
89 ctrlKey: event.ctrlKey,102 ctrlKey: currentState.ctrlKey && event.ctrlKey,
90 altKey: event.altKey,103 altKey: currentState.altKey && event.altKey,
91 shiftKey: event.shiftKey,104 shiftKey: currentState.shiftKey && event.shiftKey,
92 };105 };
93 }106 }
94107