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", () => {
110110 shiftKey: false,
111111 });
112112 });
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 });
113138});
apps/web/src/shortcutModifierState.ts+20-7
......@@ -33,7 +33,12 @@ export function useShortcutModifierState(): ShortcutModifierState {
3333 const onKeyboardEvent = (event: KeyboardEvent) => {
3434 setState((current) => shortcutModifierStateAfterKeyboardEvent(current, event));
3535 };
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 = () => {
3742 setState((current) =>
3843 areShortcutModifierStatesEqual(current, EMPTY_SHORTCUT_MODIFIER_STATE)
3944 ? current
......@@ -43,11 +48,13 @@ export function useShortcutModifierState(): ShortcutModifierState {
4348
4449 window.addEventListener("keydown", onKeyboardEvent, true);
4550 window.addEventListener("keyup", onKeyboardEvent, true);
46 window.addEventListener("blur", onWindowBlur);
51 window.addEventListener("paste", onResetEvent, true);
52 window.addEventListener("blur", onResetEvent);
4753 return () => {
4854 window.removeEventListener("keydown", onKeyboardEvent, true);
4955 window.removeEventListener("keyup", onKeyboardEvent, true);
50 window.removeEventListener("blur", onWindowBlur);
56 window.removeEventListener("paste", onResetEvent, true);
57 window.removeEventListener("blur", onResetEvent);
5158 };
5259 }, []);
5360
......@@ -84,11 +91,17 @@ export function shortcutModifierStateAfterKeyboardEvent(
8491 [normalizedModifierKey]: event.type === "keydown",
8592 };
8693 } 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.
87100 nextState = {
88 metaKey: event.metaKey,
89 ctrlKey: event.ctrlKey,
90 altKey: event.altKey,
91 shiftKey: event.shiftKey,
101 metaKey: currentState.metaKey && event.metaKey,
102 ctrlKey: currentState.ctrlKey && event.ctrlKey,
103 altKey: currentState.altKey && event.altKey,
104 shiftKey: currentState.shiftKey && event.shiftKey,
92105 };
93106 }
94107