Skip to content

Commit 5d76653

Browse files
t3dotggclaude
andauthored
fix(web): thread jump hints no longer stick after a dictation paste (pingdotgg#8189)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent c6b8bb8 commit 5d76653

2 files changed

Lines changed: 45 additions & 7 deletions

File tree

‎apps/web/src/shortcutModifierState.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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

0 commit comments

Comments
 (0)