Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions apps/web/src/shortcutModifierState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";

import {
areShortcutModifierStatesEqual,
shortcutModifierStateAfterPaste,
shortcutModifierStateAfterKeyboardEvent,
type ShortcutModifierState,
} from "./shortcutModifierState";
Expand Down Expand Up @@ -110,4 +111,65 @@ describe("shortcutModifierState", () => {
shiftKey: false,
});
});

it("ignores poisoned modifier flags on non-modifier keys", () => {
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());
});

it("preserves a held modifier during a keyboard paste", () => {
const heldMeta: ShortcutModifierState = {
metaKey: true,
ctrlKey: false,
altKey: false,
shiftKey: false,
};
expect(shortcutModifierStateAfterPaste(heldMeta, true)).toBe(heldMeta);
});

it("clears stale modifiers from an unmodified synthetic paste", () => {
const heldMeta: ShortcutModifierState = {
metaKey: true,
ctrlKey: false,
altKey: false,
shiftKey: false,
};
expect(shortcutModifierStateAfterPaste(heldMeta, false)).toEqual(emptyState());
});

it("tracks AltGraph as a combined Control and Alt modifier", () => {
const state = shortcutModifierStateAfterKeyboardEvent(
emptyState(),
keyboardEventLike("keydown", { key: "AltGraph" }),
);
expect(state).toEqual({
metaKey: false,
ctrlKey: true,
altKey: true,
shiftKey: false,
});
expect(
shortcutModifierStateAfterKeyboardEvent(
state,
keyboardEventLike("keyup", { key: "AltGraph" }),
),
).toEqual(emptyState());
});
});
52 changes: 45 additions & 7 deletions apps/web/src/shortcutModifierState.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";

export interface ShortcutModifierState {
metaKey: boolean;
Expand Down Expand Up @@ -28,12 +28,25 @@ export function areShortcutModifierStatesEqual(

export function useShortcutModifierState(): ShortcutModifierState {
const [state, setState] = useState(EMPTY_SHORTCUT_MODIFIER_STATE);
const keyboardPastePendingRef = useRef(false);

useEffect(() => {
const onKeyboardEvent = (event: KeyboardEvent) => {
if (event.type === "keydown") {
keyboardPastePendingRef.current =
event.key.toLowerCase() === "v" && (event.metaKey || event.ctrlKey);
Comment on lines +35 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset modifiers after injected keyboard pastes

In the web/desktop shortcut-state hook, a dictation tool that injects Control/Meta keydown, V keydown, and paste but omits the matching keyup is classified as keyboard-initiated here. The paste handler then preserves the held modifier, leaving the thread-jump hints stuck—the failure this change is intended to fix. The paste reset must distinguish ordinary keyboard input from an injected sequence without relying only on the preceding V keydown.

AGENTS.md reference: AGENTS.md:L142-L142

Useful? React with 👍 / 👎.

} else if (event.key.toLowerCase() === "v") {
keyboardPastePendingRef.current = false;
}
setState((current) => shortcutModifierStateAfterKeyboardEvent(current, event));
};
const onPaste = () => {
const wasKeyboardInitiated = keyboardPastePendingRef.current;
keyboardPastePendingRef.current = false;
setState((current) => shortcutModifierStateAfterPaste(current, wasKeyboardInitiated));
};
const onWindowBlur = () => {
keyboardPastePendingRef.current = false;
setState((current) =>
areShortcutModifierStatesEqual(current, EMPTY_SHORTCUT_MODIFIER_STATE)
? current
Expand All @@ -43,18 +56,20 @@ export function useShortcutModifierState(): ShortcutModifierState {

window.addEventListener("keydown", onKeyboardEvent, true);
window.addEventListener("keyup", onKeyboardEvent, true);
window.addEventListener("paste", onPaste, true);
window.addEventListener("blur", onWindowBlur);
return () => {
window.removeEventListener("keydown", onKeyboardEvent, true);
window.removeEventListener("keyup", onKeyboardEvent, true);
window.removeEventListener("paste", onPaste, true);
window.removeEventListener("blur", onWindowBlur);
};
}, []);

return state;
}

function normalizeModifierKey(key: string): keyof ShortcutModifierState | null {
function normalizeModifierKey(key: string): keyof ShortcutModifierState | "altGraph" | null {
switch (key) {
case "Meta":
case "OS":
Expand All @@ -65,6 +80,8 @@ function normalizeModifierKey(key: string): keyof ShortcutModifierState | null {
case "Alt":
case "Option":
return "altKey";
case "AltGraph":
return "altGraph";
case "Shift":
return "shiftKey";
default:
Expand All @@ -78,19 +95,40 @@ export function shortcutModifierStateAfterKeyboardEvent(
): ShortcutModifierState {
const normalizedModifierKey = normalizeModifierKey(event.key);
let nextState: ShortcutModifierState;
if (normalizedModifierKey) {
if (normalizedModifierKey === "altGraph") {
nextState = {
...currentState,
ctrlKey: event.type === "keydown",
altKey: event.type === "keydown",
Comment on lines +101 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Preserve independently held modifiers on AltGraph release

When a user keeps a separate Control or Alt key held while releasing AltGraph, these assignments unconditionally clear both flags. Because the new non-modifier path can only clear state, a subsequent number keydown cannot restore the still-held modifier, so jump hints remain hidden until that modifier is released and pressed again. Preserve modifier flags that remain active independently of AltGraph.

AGENTS.md reference: AGENTS.md:L142-L142

Useful? React with 👍 / 👎.

};
} else if (normalizedModifierKey) {
nextState = {
...currentState,
[normalizedModifierKey]: event.type === "keydown",
};
} else {
// Non-modifier events can clear stale browser flags, but only a real
// modifier keydown may mark a modifier as held.
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,
Comment on lines +114 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve AltGraph's reported Alt state

On Windows/Linux layouts where AltGraph is emitted as a Control keydown followed by an AltGraph event carrying both ctrlKey and altKey, AltGraph is not normalized as a modifier here. This conjunction therefore retains Ctrl but refuses to set Alt, making the state look like an exact Ctrl-only chord; downstream shouldShowThreadJumpHintsForModifiers can consequently display the default mod+1..9 jump hints while the user is typing AltGr characters.

Useful? React with 👍 / 👎.

shiftKey: currentState.shiftKey && event.shiftKey,
};
}

return areShortcutModifierStatesEqual(currentState, nextState) ? currentState : nextState;
}

export function shortcutModifierStateAfterPaste(
currentState: ShortcutModifierState,
wasKeyboardInitiated: boolean,
): ShortcutModifierState {
if (
wasKeyboardInitiated ||
areShortcutModifierStatesEqual(currentState, EMPTY_SHORTCUT_MODIFIER_STATE)
) {
return currentState;
}
return EMPTY_SHORTCUT_MODIFIER_STATE;
}
Loading