diff --git a/packages/app/e2e/browser/keyboard-shortcut-sequence.spec.ts b/packages/app/e2e/browser/keyboard-shortcut-sequence.spec.ts new file mode 100644 index 0000000000..fd24e1d63c --- /dev/null +++ b/packages/app/e2e/browser/keyboard-shortcut-sequence.spec.ts @@ -0,0 +1,65 @@ +import { expect, test, type Page } from "../support/fixtures"; + +// "Toggle command center" recorded as a two-key sequence. Both platform +// bindings are overridden so the spec does not depend on the host OS. +const COMMAND_CENTER_SEQUENCE = { + "command-center-toggle-cmd-k-mac": "Cmd+K J", + "command-center-toggle-ctrl-k-non-mac": "Ctrl+K J", +}; + +const SEQUENCE_PREFIX = process.platform === "darwin" ? "Meta+K" : "Control+K"; +const SEQUENCE_FINAL_KEY = "j"; + +/** Record the sequence in Settings → Keyboard shortcuts, through its storage. */ +async function recordCommandCenterSequence(page: Page): Promise { + await page.addInitScript((overrides) => { + localStorage.setItem("@paseo:keyboard-shortcut-overrides", JSON.stringify(overrides)); + }, COMMAND_CENTER_SEQUENCE); +} + +/** + * Resize the window and let the app render again, so the resize lands between + * the two keys of the sequence rather than after them. + */ +async function resizeWindowAndSettle(page: Page, width: number): Promise { + await page.setViewportSize({ width, height: 800 }); + await page.waitForFunction((expected) => window.innerWidth === expected, width); + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); +} + +async function expectCommandCenterOpen(page: Page): Promise { + await expect(page.getByTestId("command-center-panel")).toBeVisible({ timeout: 30_000 }); +} + +test.describe("Multi-key shortcut sequences", () => { + test("a recorded sequence opens the command center", async ({ page, withWorkspace }) => { + await recordCommandCenterSequence(page); + const workspace = await withWorkspace({ prefix: "shortcut-sequence-" }); + await workspace.navigateTo(); + + await page.keyboard.press(SEQUENCE_PREFIX); + await page.keyboard.press(SEQUENCE_FINAL_KEY); + + await expectCommandCenterOpen(page); + }); + + test("a sequence completes when the window resizes between its keys", async ({ + page, + withWorkspace, + }) => { + await recordCommandCenterSequence(page); + const workspace = await withWorkspace({ prefix: "shortcut-sequence-resize-" }); + await workspace.navigateTo(); + + await page.keyboard.press(SEQUENCE_PREFIX); + await resizeWindowAndSettle(page, 1100); + await page.keyboard.press(SEQUENCE_FINAL_KEY); + + await expectCommandCenterOpen(page); + }); +}); diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 057efef369..d723b5be04 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -27,6 +27,7 @@ import { } from "@/keyboard/route-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; +import { useStableEvent } from "@/hooks/use-stable-event"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { isNative } from "@/constants/platform"; import { keyboardShortcutsAvailable } from "@/keyboard/availability"; @@ -75,7 +76,6 @@ export function useKeyboardShortcuts({ const openProjectPickerAction = useOpenAddProject(); const activeWorkspaceSelection = useActiveWorkspaceSelection(); const keyboardWorkspaceSelectionRef = useRef(null); - const badgeModifierKeyRef = useRef(undefined); const publishBrowserShortcutPolicy = useCallback( (chordState?: ChordState) => { @@ -107,271 +107,285 @@ export function useKeyboardShortcuts({ publishBrowserShortcutPolicy(); }, [isDesktopApp, publishBrowserShortcutPolicy]); + // Only the modifier that actually performs the workspace-index jump on this + // runtime should reveal the sidebar number badges (Alt on web, Cmd on + // desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown + // to drive badge visibility, so we set the flag matching this runtime. + // Derived from the effective bindings: `null` when the user unassigned or + // rebound the jump shortcut, and no `event.key` ever equals null, so the + // badges simply never appear. + const badgeModifierKey = getWorkspaceIndexJumpModifierKey( + { isMac, isDesktop: isDesktopApp }, + bindings, + ); + + // The keyup listener matches the released key against the current modifier, + // so a modifier held while the jump binding changes could never be released + // and the badges would stay up until a blur. Clear them when it changes. + useEffect(() => { + resetModifiers(); + }, [badgeModifierKey, resetModifiers]); + + const setBadgeModifierDown = (down: boolean) => { + const state = useKeyboardShortcutsStore.getState(); + if (isDesktopApp) { + state.setCmdOrCtrlDown(down); + } else { + state.setAltDown(down); + } + }; + + const shouldHandle = () => { + if (typeof document === "undefined") return false; + if (document.visibilityState !== "visible") return false; + return true; + }; + + const captureCommandCenterFocusRestore = (event: KeyboardEvent) => { + const target = event.target instanceof Element ? event.target : null; + const targetEl = + target?.closest?.("textarea, input, [contenteditable='true']") ?? + (target instanceof HTMLElement ? target : null); + const active = document.activeElement; + const activeEl = active instanceof HTMLElement ? active : null; + setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null); + }; + + const callbacksByName: Record void) | undefined> = { + "toggle-agent-list": toggleAgentList, + "toggle-both-sidebars": toggleBothSidebars, + "cycle-theme": cycleTheme, + }; + + const performShortcutAction = ( + action: ShortcutAction, + event: KeyboardEvent | null, + browserFocusRestoreElement: HTMLElement | null = null, + ): boolean => { + switch (action.kind) { + case "none": + return false; + case "dispatch": + return keyboardActionDispatcher.dispatch(action.action); + case "navigate-workspace": + keyboardWorkspaceSelectionRef.current = { + serverId: action.serverId, + workspaceId: action.workspaceId, + }; + navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId }); + return true; + case "navigate-last-workspace": + if (navigateToLastWorkspace()) { + return true; + } + router.replace(buildOpenProjectRoute()); + return true; + case "router-replace": + router.replace(action.route as Parameters[0]); + return true; + case "router-back": + router.back(); + return true; + case "router-push": + router.push(action.route as Parameters[0]); + return true; + case "open-project-picker": + void openProjectPickerAction(); + return true; + case "callback": + callbacksByName[action.name]?.(); + return true; + case "command-center-toggle": { + if (action.nextOpen) { + if (event) { + captureCommandCenterFocusRestore(event); + } else { + setCommandCenterFocusRestoreElement(browserFocusRestoreElement); + } + } + useKeyboardShortcutsStore + .getState() + .setCommandCenterOpen(action.nextOpen, action.scope ?? null); + return true; + } + case "shortcuts-dialog-toggle": + useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen); + return true; + } + }; + + const routeAndPerformShortcut = (input: { + action: string; + payload: KeyboardShortcutPayload; + domEvent: KeyboardEvent | null; + browserFocusRestoreElement?: HTMLElement | null; + }): boolean => { + const store = useKeyboardShortcutsStore.getState(); + const shortcutAction = routeKeyboardShortcut( + { action: input.action, payload: input.payload }, + { + pathname, + isMobile, + sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets, + navigationActiveWorkspace: + keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection, + commandCenterOpen: store.commandCenterOpen, + shortcutsDialogOpen: store.shortcutsDialogOpen, + }, + ); + const handled = performShortcutAction( + shortcutAction, + input.domEvent, + input.browserFocusRestoreElement, + ); + if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) { + exitFocusMode(); + } + return handled; + }; + + const resolveAndPerformShortcut = (input: { + event: KeyboardShortcutInput; + focusScope: KeyboardFocusScope; + domEvent: KeyboardEvent | null; + browserFocusRestoreElement?: HTMLElement | null; + }) => { + const store = useKeyboardShortcutsStore.getState(); + const previousChordState = chordStateRef.current; + const result = resolveKeyboardShortcut({ + event: input.event, + context: { + isMac, + isDesktop: isDesktopApp, + focusScope: input.focusScope, + commandCenterOpen: store.commandCenterOpen, + }, + chordState: chordStateRef.current, + onChordReset: () => { + chordStateRef.current = { + candidateIndices: [], + step: 0, + timeoutId: null, + }; + publishBrowserShortcutPolicy(); + }, + bindings, + }); + chordStateRef.current = result.nextChordState; + if ( + shouldPublishBrowserShortcutPolicy({ + isBrowserInput: "browserId" in input.event, + previousChordState, + nextChordState: result.nextChordState, + }) + ) { + publishBrowserShortcutPolicy(result.nextChordState); + } + + if (result.preventDefault && input.domEvent) { + input.domEvent.preventDefault(); + input.domEvent.stopPropagation(); + } + + if (!result.match) { + return; + } + + const handled = routeAndPerformShortcut({ + action: result.match.action, + payload: result.match.payload, + domEvent: input.domEvent, + browserFocusRestoreElement: input.browserFocusRestoreElement, + }); + if (!handled || !input.domEvent) { + return; + } + + if (result.match.preventDefault) { + input.domEvent.preventDefault(); + } + if (result.match.stopPropagation) { + input.domEvent.stopPropagation(); + } + }; + + // The window listeners must outlive ordinary re-renders: removing them resets + // any in-progress chord. Stable events let them read the latest render instead + // of being re-registered whenever a route, callback, or selection changes. + const handleKeyDown = useStableEvent((event: KeyboardEvent) => { + if (!shouldHandle()) { + return; + } + + if (dispatchTopWebOverlayKeyDown(event)) { + return; + } + + // During IME composition, Enter confirms the candidate selection and must + // not route through global shortcuts like message send. + if (isImeComposingKeyboardEvent(event)) { + return; + } + + const store = useKeyboardShortcutsStore.getState(); + if (store.capturingShortcut) { + return; + } + + const key = event.key ?? ""; + if ( + key === "Escape" && + pathname.startsWith("/settings") && + !isMobile && + hasActiveWebOverlay() + ) { + return; + } + if (key === badgeModifierKey && !event.shiftKey) { + setBadgeModifierDown(true); + } + if (key === "Shift") { + const state = useKeyboardShortcutsStore.getState(); + if (state.altDown || state.cmdOrCtrlDown) { + state.resetModifiers(); + } + } + + const focusScope = resolveKeyboardFocusScope({ + target: event.target, + commandCenterOpen: store.commandCenterOpen, + }); + resolveAndPerformShortcut({ + event, + focusScope, + domEvent: event, + }); + }); + + const handleKeyUp = useStableEvent((event: KeyboardEvent) => { + const key = event.key ?? ""; + if (key === badgeModifierKey) { + setBadgeModifierDown(false); + } + }); + + const handleBrowserShortcutInput = useStableEvent((payload: unknown) => { + const input = parseBrowserShortcutInput(payload); + if (!input) { + return; + } + resolveAndPerformShortcut({ + event: input, + focusScope: "browser", + domEvent: null, + browserFocusRestoreElement: getResidentBrowserWebview(input.browserId), + }); + }); + useEffect(() => { if (!enabled) return; if (!shortcutsAvailable) return; - // Only the modifier that actually performs the workspace-index jump on this - // runtime should reveal the sidebar number badges (Alt on web, Cmd on - // desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown - // to drive badge visibility, so we set the flag matching this runtime. - // Derived from the effective bindings: `null` when the user unassigned or - // rebound the jump shortcut, and no `event.key` ever equals null, so the - // badges simply never appear. - const badgeModifierKey = getWorkspaceIndexJumpModifierKey( - { isMac, isDesktop: isDesktopApp }, - bindings, - ); - const setBadgeModifierDown = (down: boolean) => { - const state = useKeyboardShortcutsStore.getState(); - if (isDesktopApp) { - state.setCmdOrCtrlDown(down); - } else { - state.setAltDown(down); - } - }; - - // The keyup listener matches the released key against the modifier derived - // when the effect last ran, so a modifier held while the jump binding - // changes can never be released -- the badges would stay up until a blur. - // Clear on change only: this effect also re-runs on every navigation, and - // clearing unconditionally would drop the badges mid Cmd+1, Cmd+2. - if (badgeModifierKeyRef.current !== badgeModifierKey) { - badgeModifierKeyRef.current = badgeModifierKey; - resetModifiers(); - } - - const shouldHandle = () => { - if (typeof document === "undefined") return false; - if (document.visibilityState !== "visible") return false; - return true; - }; - - const captureCommandCenterFocusRestore = (event: KeyboardEvent) => { - const target = event.target instanceof Element ? event.target : null; - const targetEl = - target?.closest?.("textarea, input, [contenteditable='true']") ?? - (target instanceof HTMLElement ? target : null); - const active = document.activeElement; - const activeEl = active instanceof HTMLElement ? active : null; - setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null); - }; - - const callbacksByName: Record void) | undefined> = { - "toggle-agent-list": toggleAgentList, - "toggle-both-sidebars": toggleBothSidebars, - "cycle-theme": cycleTheme, - }; - - const performShortcutAction = ( - action: ShortcutAction, - event: KeyboardEvent | null, - browserFocusRestoreElement: HTMLElement | null = null, - ): boolean => { - switch (action.kind) { - case "none": - return false; - case "dispatch": - return keyboardActionDispatcher.dispatch(action.action); - case "navigate-workspace": - keyboardWorkspaceSelectionRef.current = { - serverId: action.serverId, - workspaceId: action.workspaceId, - }; - navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId }); - return true; - case "navigate-last-workspace": - if (navigateToLastWorkspace()) { - return true; - } - router.replace(buildOpenProjectRoute()); - return true; - case "router-replace": - router.replace(action.route as Parameters[0]); - return true; - case "router-back": - router.back(); - return true; - case "router-push": - router.push(action.route as Parameters[0]); - return true; - case "open-project-picker": - void openProjectPickerAction(); - return true; - case "callback": - callbacksByName[action.name]?.(); - return true; - case "command-center-toggle": { - if (action.nextOpen) { - if (event) { - captureCommandCenterFocusRestore(event); - } else { - setCommandCenterFocusRestoreElement(browserFocusRestoreElement); - } - } - useKeyboardShortcutsStore - .getState() - .setCommandCenterOpen(action.nextOpen, action.scope ?? null); - return true; - } - case "shortcuts-dialog-toggle": - useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen); - return true; - } - }; - - const routeAndPerformShortcut = (input: { - action: string; - payload: KeyboardShortcutPayload; - domEvent: KeyboardEvent | null; - browserFocusRestoreElement?: HTMLElement | null; - }): boolean => { - const store = useKeyboardShortcutsStore.getState(); - const shortcutAction = routeKeyboardShortcut( - { action: input.action, payload: input.payload }, - { - pathname, - isMobile, - sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets, - navigationActiveWorkspace: - keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection, - commandCenterOpen: store.commandCenterOpen, - shortcutsDialogOpen: store.shortcutsDialogOpen, - }, - ); - const handled = performShortcutAction( - shortcutAction, - input.domEvent, - input.browserFocusRestoreElement, - ); - if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) { - exitFocusMode(); - } - return handled; - }; - - const resolveAndPerformShortcut = (input: { - event: KeyboardShortcutInput; - focusScope: KeyboardFocusScope; - domEvent: KeyboardEvent | null; - browserFocusRestoreElement?: HTMLElement | null; - }) => { - const store = useKeyboardShortcutsStore.getState(); - const previousChordState = chordStateRef.current; - const result = resolveKeyboardShortcut({ - event: input.event, - context: { - isMac, - isDesktop: isDesktopApp, - focusScope: input.focusScope, - commandCenterOpen: store.commandCenterOpen, - }, - chordState: chordStateRef.current, - onChordReset: () => { - chordStateRef.current = { - candidateIndices: [], - step: 0, - timeoutId: null, - }; - publishBrowserShortcutPolicy(); - }, - bindings, - }); - chordStateRef.current = result.nextChordState; - if ( - shouldPublishBrowserShortcutPolicy({ - isBrowserInput: "browserId" in input.event, - previousChordState, - nextChordState: result.nextChordState, - }) - ) { - publishBrowserShortcutPolicy(result.nextChordState); - } - - if (result.preventDefault && input.domEvent) { - input.domEvent.preventDefault(); - input.domEvent.stopPropagation(); - } - - if (!result.match) { - return; - } - - const handled = routeAndPerformShortcut({ - action: result.match.action, - payload: result.match.payload, - domEvent: input.domEvent, - browserFocusRestoreElement: input.browserFocusRestoreElement, - }); - if (!handled || !input.domEvent) { - return; - } - - if (result.match.preventDefault) { - input.domEvent.preventDefault(); - } - if (result.match.stopPropagation) { - input.domEvent.stopPropagation(); - } - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if (!shouldHandle()) { - return; - } - - if (dispatchTopWebOverlayKeyDown(event)) { - return; - } - - // During IME composition, Enter confirms the candidate selection and must - // not route through global shortcuts like message send. - if (isImeComposingKeyboardEvent(event)) { - return; - } - - const store = useKeyboardShortcutsStore.getState(); - if (store.capturingShortcut) { - return; - } - - const key = event.key ?? ""; - if ( - key === "Escape" && - pathname.startsWith("/settings") && - !isMobile && - hasActiveWebOverlay() - ) { - return; - } - if (key === badgeModifierKey && !event.shiftKey) { - setBadgeModifierDown(true); - } - if (key === "Shift") { - const state = useKeyboardShortcutsStore.getState(); - if (state.altDown || state.cmdOrCtrlDown) { - state.resetModifiers(); - } - } - - const focusScope = resolveKeyboardFocusScope({ - target: event.target, - commandCenterOpen: store.commandCenterOpen, - }); - resolveAndPerformShortcut({ - event, - focusScope, - domEvent: event, - }); - }; - - const handleKeyUp = (event: KeyboardEvent) => { - const key = event.key ?? ""; - if (key === badgeModifierKey) { - setBadgeModifierDown(false); - } - }; - const handleBlurOrHide = () => { resetModifiers(); }; @@ -382,18 +396,7 @@ export function useKeyboardShortcuts({ document.addEventListener("visibilitychange", handleBlurOrHide); const browserShortcutSubscription = isElectronRuntime() - ? getDesktopHost()?.events?.on?.("browser-shortcut-input", (payload) => { - const input = parseBrowserShortcutInput(payload); - if (!input) { - return; - } - resolveAndPerformShortcut({ - event: input, - focusScope: "browser", - domEvent: null, - browserFocusRestoreElement: getResidentBrowserWebview(input.browserId), - }); - }) + ? getDesktopHost()?.events?.on?.("browser-shortcut-input", handleBrowserShortcutInput) : null; return () => { if (chordStateRef.current.timeoutId !== null) { @@ -415,23 +418,11 @@ export function useKeyboardShortcuts({ } }; }, [ - bindings, - cycleTheme, enabled, - exitFocusMode, - activeWorkspaceSelection, - isDesktopApp, - isMac, - isMobile, - isWorkspaceFocusModeEnabled, - keyboardActionDispatcher, - openProjectPickerAction, - pathname, - publishBrowserShortcutPolicy, + handleBrowserShortcutInput, + handleKeyDown, + handleKeyUp, resetModifiers, - router, shortcutsAvailable, - toggleAgentList, - toggleBothSidebars, ]); }