From 786a40ef1f61718d5819ff071fc8a60ced1c3c14 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:10:59 -0700 Subject: [PATCH] Custom tab shortcuts work right after a keyboard worktree switch (#30941) * fix(shortcuts): app shortcuts run before the terminal, whatever order they mounted in App shortcut owners and terminal panes both listened on window capture, so React's effect order decided who saw a chord first. After a worktree switch the new pane registered before the tab shortcut listener and turned a user-bound Cmd+Right into Ctrl+E; clicking a tab flipped the order back. A keyboard router installed before React renders now runs every app owner, in registration order, ahead of any pane listener; a key no owner claims still reaches the terminal's own handling. Held tab chords are claimed on every repeat but switch once. Tracked in STA-11205 (same bug as STA-4602). * test(shortcuts): type the router mock with key event names * fix(shortcuts): install the keyboard router in the web client before it renders * test(shortcuts): floating panel specs read the routed key listeners from the window stub --- .../src/app-shell/use-global-keybindings.ts | 11 ++- .../components/NewWorkspaceComposerModal.tsx | 5 +- .../src/components/SelectedTextCopyMenu.tsx | 5 +- .../activity/ActivityPrototypePage.tsx | 5 +- .../use-automations-page-escape.ts | 5 +- .../browser-pane/annotate/useGrabMode.ts | 5 +- .../browser-page-context-menu.tsx | 5 +- .../use-browser-address-bar-dismissal.ts | 5 +- .../use-browser-page-chrome-focus.ts | 5 +- .../use-browser-page-find-shortcuts.ts | 5 +- .../use-browser-page-keyboard-shortcuts.ts | 5 +- .../use-browser-page-webview-shortcuts.ts | 9 +- .../remote-browser-page-context-menu.tsx | 5 +- .../use-remote-browser-page-chrome-chords.ts | 5 +- .../ContextualTourOverlaySurface.tsx | 5 +- .../dictation/use-hold-dictation-gesture.ts | 9 +- .../editor/use-markdown-preview-viewport.ts | 5 +- .../editor/use-pdf-viewer-find-shortcut.ts | 5 +- .../editor/useMarkdownPreviewShortcut.ts | 5 +- .../editor/useRichMarkdownSearch.ts | 5 +- ...use-feature-wall-tour-keyboard-shortcut.ts | 5 +- .../FloatingTerminalPanel.behavior.test.tsx | 8 ++ ...ting-terminal-global-shortcut-listeners.ts | 28 ++++-- .../github-project/ProjectViewToolbar.tsx | 5 +- .../native-chat/use-native-chat-find.ts | 5 +- .../native-chat/use-native-chat-font-size.ts | 5 +- .../use-native-chat-toggle-shortcut.ts | 5 +- .../components/onboarding/OnboardingFlow.tsx | 9 +- .../right-sidebar/useFileExplorerKeys.ts | 5 +- .../sidebar/workspace-delete-quick-action.ts | 9 +- .../drag/use-pointer-window-events.ts | 5 +- .../worktree-list/navigation/use-keyboard.ts | 5 +- .../use-skills-page-keyboard-navigation.ts | 5 +- .../StarNagAgentValueMomentObserver.tsx | 5 +- .../components/tab-bar/RecentTabSwitcher.tsx | 9 +- .../TabBarQuickCommandsMenu.keyboard.test.ts | 10 +- .../tab-bar-quick-commands-shortcut.ts | 9 +- .../terminal-keyboard-event-handlers.ts | 2 +- .../terminal-workspace-keydown.test.ts | 28 +++--- .../components/terminal-workspace-keydown.ts | 24 +++-- .../use-task-page-global-effects.ts | 10 +- .../use-task-page-search-actions.ts | 10 +- .../use-terminal-keyboard-shortcuts.ts | 5 +- .../src/hooks/agent-auto-ack-presence.ts | 5 +- .../src/lib/app-keyboard-router.test.ts | 91 +++++++++++++++++++ src/renderer/src/lib/app-keyboard-router.ts | 86 ++++++++++++++++++ src/renderer/src/main.tsx | 2 + src/renderer/src/web/main.tsx | 2 + 48 files changed, 380 insertions(+), 131 deletions(-) create mode 100644 src/renderer/src/lib/app-keyboard-router.test.ts create mode 100644 src/renderer/src/lib/app-keyboard-router.ts diff --git a/src/renderer/src/app-shell/use-global-keybindings.ts b/src/renderer/src/app-shell/use-global-keybindings.ts index 2d23a1b740f..a11290419e3 100644 --- a/src/renderer/src/app-shell/use-global-keybindings.ts +++ b/src/renderer/src/app-shell/use-global-keybindings.ts @@ -40,6 +40,7 @@ import { } from './app-command-handlers' import type { AppChromeLayout } from './use-app-chrome-layout' import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' /** * Registers the window-level shortcut listeners and the app command dispatcher. @@ -104,7 +105,7 @@ export function useGlobalKeybindings(args: { terminalShortcutPolicy } = state - // Child handlers (e.g. terminal search) share this window capture phase and fire first; bail if they already preventDefault'd so both don't act. + // Other app owners share this routed stage and may fire first; bail if they already preventDefault'd so both don't act. if (input.defaultPrevented || input.isComposing || input.key === 'Process') { return } @@ -322,13 +323,13 @@ export function useGlobalKeybindings(args: { // Why: a window blur mid-gesture must not leave the detector armed. const onBlur = (): void => doubleTapDetector.reset() - window.addEventListener('keydown', onKeyDown, { capture: true }) - window.addEventListener('keyup', onKeyUp, { capture: true }) + addAppKeyListener('keydown', onKeyDown) + addAppKeyListener('keyup', onKeyUp) window.addEventListener('blur', onBlur) return () => { unregisterAppCommandDispatcher() - window.removeEventListener('keydown', onKeyDown, { capture: true }) - window.removeEventListener('keyup', onKeyUp, { capture: true }) + removeAppKeyListener('keydown', onKeyDown) + removeAppKeyListener('keyup', onKeyUp) window.removeEventListener('blur', onBlur) } }, []) diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index ca3c4603d73..604a9deff70 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -28,6 +28,7 @@ import { translate } from '@/i18n/i18n' import { getWorkspaceComposerInitialFocusTarget } from '@/lib/workspace-composer-initial-focus' import { getFolderWorkspacePrimaryActionLabel } from '@/components/sidebar/folder-workspace-composer-helpers' import type { WorkspaceCreationTargetHint } from '@/lib/project-host-workspace-target' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' // Why: match App-level AddRepoDialog loading — the add flow is off the hot // path for the composer, so keep its clone/SSH machinery out of the entry render. @@ -284,8 +285,8 @@ function QuickTabBody({ event.preventDefault() void handleCreate() } - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + addAppKeyListener('keydown', onKeyDown) + return () => removeAppKeyListener('keydown', onKeyDown) }, [active, composerRef, createDisabled, handleCreate, nestedDialogOpen]) return ( diff --git a/src/renderer/src/components/SelectedTextCopyMenu.tsx b/src/renderer/src/components/SelectedTextCopyMenu.tsx index 5198ce2002f..97b3013fcb2 100644 --- a/src/renderer/src/components/SelectedTextCopyMenu.tsx +++ b/src/renderer/src/components/SelectedTextCopyMenu.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom' import { Copy } from 'lucide-react' import { translate } from '@/i18n/i18n' import { addViewportSizeChangeListener } from '@/hooks/viewport-size-change-listener' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' type SelectedTextCopyMenuProps = { children: React.ReactNode @@ -57,14 +58,14 @@ export function SelectedTextCopyMenu({ } window.addEventListener('pointerdown', close) - window.addEventListener('keydown', handleKeyDown, true) + addAppKeyListener('keydown', handleKeyDown) window.addEventListener('scroll', close, true) // Why: a bare resize listener also fires on the main process's reveal reflow, which changes // no dimensions — the menu would close on every window restore. const removeViewportListener = addViewportSizeChangeListener(close) return () => { window.removeEventListener('pointerdown', close) - window.removeEventListener('keydown', handleKeyDown, true) + removeAppKeyListener('keydown', handleKeyDown) window.removeEventListener('scroll', close, true) removeViewportListener() } diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 4db73d25275..3621235aa2b 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -21,6 +21,7 @@ import { useActivityTerminalPortalStatus } from './activity-terminal-portal-status' import type { ActivityTerminalPortalSlotId } from './activity-thread-types' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' export * from './activity-prototype-page-exports' @@ -236,8 +237,8 @@ export default function ActivityPrototypePage(): React.JSX.Element { }) } - window.addEventListener('keydown', focusActivityFilter, { capture: true }) - return () => window.removeEventListener('keydown', focusActivityFilter, { capture: true }) + addAppKeyListener('keydown', focusActivityFilter) + return () => removeAppKeyListener('keydown', focusActivityFilter) }, [activePortalTargetEl, inactivePortalTargetEl]) const { diff --git a/src/renderer/src/components/automations/use-automations-page-escape.ts b/src/renderer/src/components/automations/use-automations-page-escape.ts index 9f65c3649c6..12efee2f09a 100644 --- a/src/renderer/src/components/automations/use-automations-page-escape.ts +++ b/src/renderer/src/components/automations/use-automations-page-escape.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react' import { hasVisibleOverlay } from '@/lib/visible-overlay' import type { AutomationsPageLocalState } from './use-automations-page-local-state' import type { AutomationsPageStoreState } from './use-automations-page-store-state' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' export function useAutomationsPageEscape({ store, @@ -92,8 +93,8 @@ export function useAutomationsPageEscape({ closeAutomationsPage() } - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + addAppKeyListener('keydown', onKeyDown) + return () => removeAppKeyListener('keydown', onKeyDown) }, [ activeModal, closeAutomationsPage, diff --git a/src/renderer/src/components/browser-pane/annotate/useGrabMode.ts b/src/renderer/src/components/browser-pane/annotate/useGrabMode.ts index 4dba0fc5815..c93e2ff6109 100644 --- a/src/renderer/src/components/browser-pane/annotate/useGrabMode.ts +++ b/src/renderer/src/components/browser-pane/annotate/useGrabMode.ts @@ -7,6 +7,7 @@ import type { import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' import { isEditableKeyboardTarget } from '../host-guest/browser-keyboard' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' // --------------------------------------------------------------------------- // Grab mode state machine @@ -266,8 +267,8 @@ export function useGrabMode(browserPageId: string): GrabModeHook { cancel() } } - window.addEventListener('keydown', handleKeyDown, true) - return () => window.removeEventListener('keydown', handleKeyDown, true) + addAppKeyListener('keydown', handleKeyDown) + return () => removeAppKeyListener('keydown', handleKeyDown) }, [state, cancel]) return { state, payload, error, contextMenu, toggle, cancel, rearm, exit } diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-context-menu.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-context-menu.tsx index 22f12bfea4e..24fe7b18629 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-context-menu.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-context-menu.tsx @@ -13,6 +13,7 @@ import { translate } from '@/i18n/i18n' import { normalizeExternalBrowserUrl } from '../../../../../shared/browser-url' import { resolveBrowserSourceUnifiedTab } from '@/lib/browser-workspace-source-resolution' import type { BrowserPageContextMenuState } from '../describe-page/browser-page-types' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' // `focus:` rather than `focus-visible:` — items are only ever focused programmatically // while the menu is open, so every focus here is keyboard navigation. @@ -94,8 +95,8 @@ export function BrowserPageContextMenu({ closeMenu() } } - window.addEventListener('keydown', handleKeyDown, true) - return () => window.removeEventListener('keydown', handleKeyDown, true) + addAppKeyListener('keydown', handleKeyDown) + return () => removeAppKeyListener('keydown', handleKeyDown) }, [closeMenu, contextMenu]) // Why: role="menu" is unreachable by keyboard unless focus moves in on open. diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-dismissal.ts b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-dismissal.ts index ec3aae5c12e..59cad9b1009 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-dismissal.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-address-bar-dismissal.ts @@ -1,4 +1,5 @@ import { useEffect } from 'react' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' /** * Why: Electron guests run in a separate process, so clicking the page never dispatches @@ -35,11 +36,11 @@ export function useBrowserAddressBarDismissal(open: boolean, dismissSuggestions: window.addEventListener('blur', handleWindowBlur) document.addEventListener('focusin', handleFocusIn, true) - window.addEventListener('keydown', handleEscape, true) + addAppKeyListener('keydown', handleEscape) return () => { window.removeEventListener('blur', handleWindowBlur) document.removeEventListener('focusin', handleFocusIn, true) - window.removeEventListener('keydown', handleEscape, true) + removeAppKeyListener('keydown', handleEscape) } }, [dismissSuggestions, open]) } diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.ts b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.ts index cf97db907f4..6188ab7ae9f 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-chrome-focus.ts @@ -11,6 +11,7 @@ import { browserChromeShortcutOwnsEvent } from '../describe-page/browser-overlay import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-types' import type { BrowserAddressBarSelection } from './browser-address-bar-edit-session' import type { BrowserPageGuestFocus } from './browser-page-guest-focus' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' /** Frames an address-bar grab keeps retrying before it lets the guest have focus back. */ const ADDRESS_BAR_FOCUS_FRAMES = 6 @@ -182,8 +183,8 @@ export function useBrowserPageChromeFocus({ event.stopImmediatePropagation() focusAddressBarNow() } - window.addEventListener('keydown', handleKeyDown, true) - return () => window.removeEventListener('keydown', handleKeyDown, true) + addAppKeyListener('keydown', handleKeyDown) + return () => removeAppKeyListener('keydown', handleKeyDown) }, [chromeShortcutScope, focusAddressBarNow, keybindings, workspaceId]) useEffect(() => { diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-find-shortcuts.ts b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-find-shortcuts.ts index 390a8fe77d5..e2826ec6537 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-find-shortcuts.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/use-browser-page-find-shortcuts.ts @@ -4,6 +4,7 @@ import { useAppStore } from '@/store' import { keybindingMatchesAction } from '../../../../../shared/keybindings' import { browserChromeShortcutOwnsEvent } from '../describe-page/browser-overlay-shortcut-target' import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-types' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' export function useBrowserPageFindShortcuts({ browserTabId, @@ -38,8 +39,8 @@ export function useBrowserPageFindShortcuts({ e.stopPropagation() setFindOpen(true) } - window.addEventListener('keydown', handleKeyDown, true) - return () => window.removeEventListener('keydown', handleKeyDown, true) + addAppKeyListener('keydown', handleKeyDown) + return () => removeAppKeyListener('keydown', handleKeyDown) }, [chromeShortcutScope, keybindings, setFindOpen, workspaceId]) // Cmd/Ctrl+F — find in page (IPC path: focus inside webview guest) diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.ts index 5794b493b6b..cf882307986 100644 --- a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.ts +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-keyboard-shortcuts.ts @@ -6,6 +6,7 @@ import { browserChromeShortcutOwnsEvent } from '../describe-page/browser-overlay import type { BrowserChromeShortcutScope, GrabIntent } from '../describe-page/browser-page-types' import { isEditableKeyboardTarget } from './browser-keyboard' import { useBrowserPageWebviewShortcuts } from './use-browser-page-webview-shortcuts' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' export function useBrowserPageKeyboardShortcuts({ browserTabId, @@ -123,8 +124,8 @@ export function useBrowserPageKeyboardShortcuts({ e.stopPropagation() handleGrabActionShortcut(key as 'c' | 's') } - window.addEventListener('keydown', handleKeyDown, true) - return () => window.removeEventListener('keydown', handleKeyDown, true) + addAppKeyListener('keydown', handleKeyDown) + return () => removeAppKeyListener('keydown', handleKeyDown) }, [grabIsInteractive, handleGrabActionShortcut]) useEffect(() => { diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-shortcuts.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-shortcuts.ts index 7935e00d8d1..c622f107eb5 100644 --- a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-shortcuts.ts +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-shortcuts.ts @@ -12,6 +12,7 @@ import { type BrowserPageZoomCommand, type BrowserPageZoomDirection } from './browser-page-zoom' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' /** * History, reload and zoom chords for a -backed pane — local and client-hosted alike. @@ -72,8 +73,8 @@ export function useBrowserPageWebviewShortcuts({ webviewRef.current?.goForward() } } - window.addEventListener('keydown', handleKeyDown, true) - return () => window.removeEventListener('keydown', handleKeyDown, true) + addAppKeyListener('keydown', handleKeyDown) + return () => removeAppKeyListener('keydown', handleKeyDown) }, [chromeShortcutScope, keybindings, webviewRef, workspaceId]) // Browser history shortcuts (IPC path: focus inside webview guest) @@ -123,8 +124,8 @@ export function useBrowserPageWebviewShortcuts({ e.stopImmediatePropagation() reloadWebviewOrRecoverGuest(isHardReload) } - window.addEventListener('keydown', handleKeyDown, true) - return () => window.removeEventListener('keydown', handleKeyDown, true) + addAppKeyListener('keydown', handleKeyDown) + return () => removeAppKeyListener('keydown', handleKeyDown) }, [chromeShortcutScope, keybindings, reloadWebviewOrRecoverGuest, workspaceId]) // Cmd/Ctrl+R — reload (IPC path: focus inside webview guest) diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-context-menu.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-context-menu.tsx index 23ce8fb413b..fa068f7fee2 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-context-menu.tsx +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-context-menu.tsx @@ -16,6 +16,7 @@ import { type RemoteBrowserPaneNotice, type RemoteBrowserRuntimeTarget } from './remote-browser-page-input-model' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' export function useRemoteBrowserPageContextMenu({ busy, @@ -65,8 +66,8 @@ export function useRemoteBrowserPageContextMenu({ setContextMenu(null) } } - window.addEventListener('keydown', handleKeyDown, true) - return () => window.removeEventListener('keydown', handleKeyDown, true) + addAppKeyListener('keydown', handleKeyDown) + return () => removeAppKeyListener('keydown', handleKeyDown) }, [contextMenu]) const handleRemoteContextMenu = (event: React.MouseEvent): void => { diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-chrome-chords.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-chrome-chords.ts index df4044a3e58..04346420264 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-chrome-chords.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-chrome-chords.ts @@ -10,6 +10,7 @@ import { REMOTE_BROWSER_FIND_UNAVAILABLE_NOTICE_ID, type RemoteBrowserPaneNotice } from './remote-browser-page-input-model' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' const FIND_UNAVAILABLE_NOTICE_MS = 4000 @@ -121,9 +122,9 @@ export function useRemoteBrowserPageChromeChords({ // chords land on it rather than one of them silently doing nothing. void runRemoteNavigation('browser.reload') } - window.addEventListener('keydown', handleKeyDown, true) + addAppKeyListener('keydown', handleKeyDown) return () => { - window.removeEventListener('keydown', handleKeyDown, true) + removeAppKeyListener('keydown', handleKeyDown) if (findNoticeTimerRef.current !== null) { clearTimeout(findNoticeTimerRef.current) findNoticeTimerRef.current = null diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx index 1c176502613..05e15ed43cd 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx @@ -26,6 +26,7 @@ import { type ContextualTourFloatingPosition } from './contextual-tour-floating-position' import { translate } from '@/i18n/i18n' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' const FOCUSABLE_SELECTOR = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' @@ -70,7 +71,7 @@ function disposeContextualTourGlobalKeyGuard(): void { if (!guardedWindow.__orcaContextualTourGlobalKeyGuardInstalled) { return } - window.removeEventListener('keydown', handleContextualTourGlobalKeyDown, true) + removeAppKeyListener('keydown', handleContextualTourGlobalKeyDown) delete guardedWindow.__orcaContextualTourGlobalKeyGuardInstalled } @@ -80,7 +81,7 @@ if (typeof window !== 'undefined') { } if (!guardedWindow.__orcaContextualTourGlobalKeyGuardInstalled) { guardedWindow.__orcaContextualTourGlobalKeyGuardInstalled = true - window.addEventListener('keydown', handleContextualTourGlobalKeyDown, true) + addAppKeyListener('keydown', handleContextualTourGlobalKeyDown) } } diff --git a/src/renderer/src/components/dictation/use-hold-dictation-gesture.ts b/src/renderer/src/components/dictation/use-hold-dictation-gesture.ts index 554af340c9c..ad92b834831 100644 --- a/src/renderer/src/components/dictation/use-hold-dictation-gesture.ts +++ b/src/renderer/src/components/dictation/use-hold-dictation-gesture.ts @@ -4,6 +4,7 @@ import { keybindingMatchesAction, type KeybindingOverrides } from '../../../../s import type { DictationState } from '../../../../shared/speech-types' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { DictationInsertionTarget } from './dictation-insertion-target' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' type HoldDictationGestureOptions = { dictationStateRef: MutableRefObject @@ -176,14 +177,14 @@ export function useHoldDictationGesture({ } } - window.addEventListener('keydown', handleKeyDown, true) - window.addEventListener('keyup', handleKeyUp, true) + addAppKeyListener('keydown', handleKeyDown) + addAppKeyListener('keyup', handleKeyUp) window.addEventListener('blur', handleBlur) document.addEventListener('visibilitychange', handleVisibilityChange) return () => { handleBlur() - window.removeEventListener('keydown', handleKeyDown, true) - window.removeEventListener('keyup', handleKeyUp, true) + removeAppKeyListener('keydown', handleKeyDown) + removeAppKeyListener('keyup', handleKeyUp) window.removeEventListener('blur', handleBlur) document.removeEventListener('visibilitychange', handleVisibilityChange) } diff --git a/src/renderer/src/components/editor/use-markdown-preview-viewport.ts b/src/renderer/src/components/editor/use-markdown-preview-viewport.ts index f7f95a3d9ab..60afbf6335c 100644 --- a/src/renderer/src/components/editor/use-markdown-preview-viewport.ts +++ b/src/renderer/src/components/editor/use-markdown-preview-viewport.ts @@ -13,6 +13,7 @@ import { isMarkdownPreviewFindShortcut } from './markdown-preview-search' import { useMarkdownPreviewDomSearch } from './use-markdown-preview-dom-search' import type { MarkdownPreviewFoundation } from './use-markdown-preview-foundation' import { useMarkdownPreviewScrollViewport } from './use-markdown-preview-scroll-viewport' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' export function useMarkdownPreviewViewport({ foundation, @@ -270,8 +271,8 @@ export function useMarkdownPreviewViewport({ } } - window.addEventListener('keydown', handleKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) + addAppKeyListener('keydown', handleKeyDown) + return () => removeAppKeyListener('keydown', handleKeyDown) }, [ activeAnnotationBlockKeyRef, closeSearch, diff --git a/src/renderer/src/components/editor/use-pdf-viewer-find-shortcut.ts b/src/renderer/src/components/editor/use-pdf-viewer-find-shortcut.ts index 04677b9d29f..66c1bf478dc 100644 --- a/src/renderer/src/components/editor/use-pdf-viewer-find-shortcut.ts +++ b/src/renderer/src/components/editor/use-pdf-viewer-find-shortcut.ts @@ -3,6 +3,7 @@ import { getShortcutPlatform } from '@/lib/shortcut-platform' import { isEditableTarget } from '@/lib/editable-target' import { isEventTargetInsideFloatingWorkspacePanel } from '@/lib/floating-workspace-terminal-actions' import { keybindingMatchesAction, type KeybindingOverrides } from '../../../../shared/keybindings' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' /** * Find is this PDF's when the key comes from inside it, or when it owns the panel's commands (the @@ -55,7 +56,7 @@ export function usePdfViewerFindShortcut({ e.stopPropagation() openFind() } - window.addEventListener('keydown', handleKeyDown, true) - return () => window.removeEventListener('keydown', handleKeyDown, true) + addAppKeyListener('keydown', handleKeyDown) + return () => removeAppKeyListener('keydown', handleKeyDown) }, [keybindings, openFind, ownsCommands, rootRef]) } diff --git a/src/renderer/src/components/editor/useMarkdownPreviewShortcut.ts b/src/renderer/src/components/editor/useMarkdownPreviewShortcut.ts index 7f4cc9309ee..35cded7f81e 100644 --- a/src/renderer/src/components/editor/useMarkdownPreviewShortcut.ts +++ b/src/renderer/src/components/editor/useMarkdownPreviewShortcut.ts @@ -4,6 +4,7 @@ import { getShortcutPlatform } from '@/lib/shortcut-platform' import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { canOpenMarkdownPreview, isMarkdownPreviewShortcut } from './markdown-preview-controls' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' type UseMarkdownPreviewShortcutParams = { activeFile: OpenFile | null @@ -75,8 +76,8 @@ export function useMarkdownPreviewShortcut({ { sourceFileId: activeFileId ?? undefined } ) } - window.addEventListener('keydown', handleKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) + addAppKeyListener('keydown', handleKeyDown) + return () => removeAppKeyListener('keydown', handleKeyDown) }, [ activeFileDiffSource, activeFileMode, diff --git a/src/renderer/src/components/editor/useRichMarkdownSearch.ts b/src/renderer/src/components/editor/useRichMarkdownSearch.ts index bcc304c3463..a087c3ed6ef 100644 --- a/src/renderer/src/components/editor/useRichMarkdownSearch.ts +++ b/src/renderer/src/components/editor/useRichMarkdownSearch.ts @@ -14,6 +14,7 @@ import { } from './rich-markdown-search' import { createRichMarkdownSearchMatchesCache } from './rich-markdown-search-matches-cache' import { useRichMarkdownSearchHighlights } from './useRichMarkdownSearchHighlights' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' export function useRichMarkdownSearch({ editor, @@ -318,8 +319,8 @@ export function useRichMarkdownSearch({ } } - window.addEventListener('keydown', handleKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) + addAppKeyListener('keydown', handleKeyDown) + return () => removeAppKeyListener('keydown', handleKeyDown) }, [closeSearch, isSearchOpen, keybindings, openReplace, openSearch, rootRef]) return { diff --git a/src/renderer/src/components/feature-wall/use-feature-wall-tour-keyboard-shortcut.ts b/src/renderer/src/components/feature-wall/use-feature-wall-tour-keyboard-shortcut.ts index 9e0f3c5e8ea..0bbcb7481b8 100644 --- a/src/renderer/src/components/feature-wall/use-feature-wall-tour-keyboard-shortcut.ts +++ b/src/renderer/src/components/feature-wall/use-feature-wall-tour-keyboard-shortcut.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react' import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' export function useFeatureWallTourKeyboardShortcut({ isOpen, @@ -21,7 +22,7 @@ export function useFeatureWallTourKeyboardShortcut({ event.preventDefault() onContinue() } - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + addAppKeyListener('keydown', onKeyDown) + return () => removeAppKeyListener('keydown', onKeyDown) }, [enabled, isOpen, onContinue]) } diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.behavior.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.behavior.test.tsx index 23b1400aa64..c73f830cc71 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.behavior.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.behavior.test.tsx @@ -4,6 +4,14 @@ import './FloatingTerminalPanel.focus.spec' import './FloatingTerminalPanel.shortcuts.spec' import './FloatingTerminalPanel.tab-lifecycle.spec' +// Why: the specs read the stubbed window's listeners in place of the app keyboard router's stage. +vi.mock('@/lib/app-keyboard-router', () => ({ + addAppKeyListener: (type: 'keydown' | 'keyup', handler: (e: KeyboardEvent) => void) => + window.addEventListener(type, handler, { capture: true }), + removeAppKeyListener: (type: 'keydown' | 'keyup', handler: (e: KeyboardEvent) => void) => + window.removeEventListener(type, handler, { capture: true }) +})) + vi.mock('zustand/react/shallow', () => ({ // Why: zustand resolves the real react (unmocked in node_modules); the memo wrapper is inert here. useShallow: (selector: unknown) => selector diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-global-shortcut-listeners.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-global-shortcut-listeners.ts index e5e23b7ca71..e81b152eaee 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-global-shortcut-listeners.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-global-shortcut-listeners.ts @@ -14,6 +14,16 @@ import { import { toModifierDoubleTapEvent } from '../../../../shared/modifier-double-tap-detector' import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' import type { FloatingTerminalPanelShortcuts } from './use-floating-terminal-panel-shortcuts' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' + +const FLOATING_TAB_SWITCH_ACTIONS: readonly KeybindingActionId[] = [ + 'tab.nextSameType', + 'tab.previousSameType', + 'tab.nextAllTypes', + 'tab.previousAllTypes', + 'tab.nextTerminal', + 'tab.previousTerminal' +] type FloatingTerminalGlobalShortcutListenersInput = Pick< FloatingTerminalPanelLocalState, @@ -58,9 +68,6 @@ export function useFloatingTerminalGlobalShortcutListeners({ }), Date.now() ) - if (event.repeat) { - return - } const state = useAppStore.getState() const context: KeybindingContext = isFloatingWorkspaceTerminalInputTarget(event.target) ? 'terminal' @@ -75,6 +82,13 @@ export function useFloatingTerminalGlobalShortcutListeners({ event.stopPropagation() event.stopImmediatePropagation() } + if (event.repeat) { + // Why: a held tab chord switches once; its repeats must not reach the next terminal. + if (FLOATING_TAB_SWITCH_ACTIONS.some(matches)) { + consume() + } + return + } const dispatchShortcut = floatingShortcutListenersRef.current.handleFloatingPanelShortcutAction if ( @@ -137,12 +151,12 @@ export function useFloatingTerminalGlobalShortcutListeners({ ) } const handleFloatingPanelBlur = (): void => doubleTapDetector?.reset() - window.addEventListener('keydown', handleFloatingPanelKeyDown, { capture: true }) - window.addEventListener('keyup', handleFloatingPanelKeyUp, { capture: true }) + addAppKeyListener('keydown', handleFloatingPanelKeyDown) + addAppKeyListener('keyup', handleFloatingPanelKeyUp) window.addEventListener('blur', handleFloatingPanelBlur) return () => { - window.removeEventListener('keydown', handleFloatingPanelKeyDown, { capture: true }) - window.removeEventListener('keyup', handleFloatingPanelKeyUp, { capture: true }) + removeAppKeyListener('keydown', handleFloatingPanelKeyDown) + removeAppKeyListener('keyup', handleFloatingPanelKeyUp) window.removeEventListener('blur', handleFloatingPanelBlur) doubleTapDetector?.reset() } diff --git a/src/renderer/src/components/github-project/ProjectViewToolbar.tsx b/src/renderer/src/components/github-project/ProjectViewToolbar.tsx index 81427ebbc80..a7af8c7099c 100644 --- a/src/renderer/src/components/github-project/ProjectViewToolbar.tsx +++ b/src/renderer/src/components/github-project/ProjectViewToolbar.tsx @@ -7,6 +7,7 @@ import { translate } from '@/i18n/i18n' import ProjectPicker from './ProjectPicker' import type { ProjectViewTableState } from './useProjectViewTable' import { githubProjectHost } from '../../../../shared/github/project-identity' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' export function ProjectViewToolbar({ tableState }: { tableState: ProjectViewTableState }) { const { @@ -173,8 +174,8 @@ function ProjectSearchInput({ input.focus() input.select() } - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + addAppKeyListener('keydown', onKeyDown) + return () => removeAppKeyListener('keydown', onKeyDown) }, []) return ( diff --git a/src/renderer/src/components/native-chat/use-native-chat-find.ts b/src/renderer/src/components/native-chat/use-native-chat-find.ts index c40abe1215c..07bcc2a2e7c 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-find.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-find.ts @@ -14,6 +14,7 @@ import { useAppStore } from '../../store' import type { NativeChatComposerHandle } from './native-chat-composer-types' import type { NativeChatMessageListHandle } from './use-native-chat-reveal-latest' import { focusNativeChatPromptCard } from './use-native-chat-prompt-card-focus' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' /** The chat's find: open state and query live with the chat, so a reopen keeps the last query. */ export type NativeChatFind = { @@ -122,8 +123,8 @@ export function useNativeChatFind( open() } } - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + addAppKeyListener('keydown', onKeyDown) + return () => removeAppKeyListener('keydown', onKeyDown) }, [enabled, open, rootRef]) // Why React capture on the root: it runs before the composer's own onKeyDownCapture (outer diff --git a/src/renderer/src/components/native-chat/use-native-chat-font-size.ts b/src/renderer/src/components/native-chat/use-native-chat-font-size.ts index 3efe9d0b2b3..f435223fc0a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-font-size.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-font-size.ts @@ -4,6 +4,7 @@ import { writeNativeChatFontSize } from './native-chat-font-size-write' import { isWebClientLocation } from '@/lib/web-client-location' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { useAppStore } from '../../store' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' export function useNativeChatFontSize( enabled: boolean, @@ -32,7 +33,7 @@ export function useNativeChatFontSize( e.stopPropagation() void writeNativeChatFontSize(action) } - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + addAppKeyListener('keydown', onKeyDown) + return () => removeAppKeyListener('keydown', onKeyDown) }, [enabled, rootRef]) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts index bb1cff3ddcf..494d8be2e2c 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts @@ -12,6 +12,7 @@ import { isNativeChatTabWideFallbackSafe, resolveNativeChatActiveLayoutLeafId } from './native-chat-leaf-routing' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' export function resolveNativeChatToggleShortcutDetectedAgent({ terminalTabId, @@ -104,9 +105,9 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive e.stopPropagation() state.toggleTabViewMode(tab.id) } - window.addEventListener('keydown', onKeyDown, { capture: true }) + addAppKeyListener('keydown', onKeyDown) return () => { - window.removeEventListener('keydown', onKeyDown, { capture: true }) + removeAppKeyListener('keydown', onKeyDown) } }, [worktreeId, isWorktreeActive]) } diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx index c57e2556014..85318e51355 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx @@ -15,6 +15,7 @@ import { OnboardingFooter } from './OnboardingFooter' import { shouldRequestOnboardingSkipConfirmation } from './onboarding-dismiss-target' import logo from '../../../../../resources/logo.svg' import { translate } from '@/i18n/i18n' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' const stepCopy = { agent: { @@ -177,8 +178,8 @@ export default function OnboardingFlow({ event.preventDefault() void flowNext('keyboard') } - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + addAppKeyListener('keydown', onKeyDown) + return () => removeAppKeyListener('keydown', onKeyDown) }, [flowNext]) useEffect(() => { @@ -189,8 +190,8 @@ export default function OnboardingFlow({ event.preventDefault() requestSkipConfirmation('keyboard') } - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + addAppKeyListener('keydown', onKeyDown) + return () => removeAppKeyListener('keydown', onKeyDown) }, [requestSkipConfirmation, skipConfirmOpen]) return ( diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts b/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts index e1d6783dc3a..7dc3993e615 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerKeys.ts @@ -20,6 +20,7 @@ import { import { keybindingMatchesAction } from '../../../../shared/keybindings' import { translate } from '@/i18n/i18n' import { isEditableTarget } from '@/lib/editable-target' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' export function shouldIgnoreFileExplorerKeyTarget(target: EventTarget | null): boolean { return ( @@ -288,7 +289,7 @@ export function useFileExplorerKeys(opts: { } } - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + addAppKeyListener('keydown', onKeyDown) + return () => removeAppKeyListener('keydown', onKeyDown) }, [keybindings, rightSidebarExplorerView, rightSidebarOpen, rightSidebarTab, opts.containerRef]) } diff --git a/src/renderer/src/components/sidebar/workspace-delete-quick-action.ts b/src/renderer/src/components/sidebar/workspace-delete-quick-action.ts index c702f97dc8d..1f3613de3d2 100644 --- a/src/renderer/src/components/sidebar/workspace-delete-quick-action.ts +++ b/src/renderer/src/components/sidebar/workspace-delete-quick-action.ts @@ -1,4 +1,5 @@ import { useSyncExternalStore } from 'react' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' let deleteModifierPressed = false let listenersInstalled = false @@ -41,8 +42,8 @@ function installListeners(): void { listenersInstalled = true // Why: Option/Alt is a transient reveal modifier, so stale key state after // focus loss would leave destructive affordances visible. - window.addEventListener('keydown', onKeyDown, { capture: true }) - window.addEventListener('keyup', onKeyUp, { capture: true }) + addAppKeyListener('keydown', onKeyDown) + addAppKeyListener('keyup', onKeyUp) window.addEventListener('blur', clearDeleteModifierPressed) if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', clearDeleteModifierPressed) @@ -54,8 +55,8 @@ function uninstallListeners(): void { return } listenersInstalled = false - window.removeEventListener('keydown', onKeyDown, { capture: true }) - window.removeEventListener('keyup', onKeyUp, { capture: true }) + removeAppKeyListener('keydown', onKeyDown) + removeAppKeyListener('keyup', onKeyUp) window.removeEventListener('blur', clearDeleteModifierPressed) if (typeof document !== 'undefined') { document.removeEventListener('visibilitychange', clearDeleteModifierPressed) diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-window-events.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-window-events.ts index fa8be3a21b6..93e1d621cf1 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-window-events.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-window-events.ts @@ -6,6 +6,7 @@ import type { } from './drop-commit-context' import { commitWorktreePointerDrop } from './pointer-commit' import type { WorktreePointerDrag } from './row-state' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4 @@ -87,12 +88,12 @@ export function useWorktreePointerDragWindowEvents(args: { clearWorktreeDrag() } - window.addEventListener('keydown', handleKeyDown, { capture: true }) + addAppKeyListener('keydown', handleKeyDown) window.addEventListener('pointermove', handlePointerMove, { capture: true }) window.addEventListener('pointerup', handlePointerUp, { capture: true }) window.addEventListener('pointercancel', handlePointerCancel, { capture: true }) return () => { - window.removeEventListener('keydown', handleKeyDown, { capture: true }) + removeAppKeyListener('keydown', handleKeyDown) window.removeEventListener('pointermove', handlePointerMove, { capture: true }) window.removeEventListener('pointerup', handlePointerUp, { capture: true }) window.removeEventListener('pointercancel', handlePointerCancel, { capture: true }) diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.ts index 89806299d5c..c210677c09a 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.ts @@ -19,6 +19,7 @@ import { resolveCycledWorktreeId } from '../../worktree-keyboard-cycle' import { findPreferredRenderRowIndexForWorktreeIdentity } from './render-row-lookup' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' export function useWorktreeListKeyboardNavigation(args: { rows: HostSectionRow[] @@ -126,11 +127,11 @@ export function useWorktreeListKeyboardNavigation(args: { } } - window.addEventListener('keydown', handleKeyDown, { capture: true }) + addAppKeyListener('keydown', handleKeyDown) window.addEventListener('pointerdown', endListNavigation, { capture: true }) window.addEventListener('focusout', endListNavigation, { capture: true }) return () => { - window.removeEventListener('keydown', handleKeyDown, { capture: true }) + removeAppKeyListener('keydown', handleKeyDown) window.removeEventListener('pointerdown', endListNavigation, { capture: true }) window.removeEventListener('focusout', endListNavigation, { capture: true }) } diff --git a/src/renderer/src/components/skills/use-skills-page-keyboard-navigation.ts b/src/renderer/src/components/skills/use-skills-page-keyboard-navigation.ts index 9f67c5313d8..9ec089be593 100644 --- a/src/renderer/src/components/skills/use-skills-page-keyboard-navigation.ts +++ b/src/renderer/src/components/skills/use-skills-page-keyboard-navigation.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react' import type { SkillsPageView } from './skills-page-view' import { hasVisibleOverlay } from '@/lib/visible-overlay' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' type UseSkillsPageKeyboardNavigationOptions = { closeSkillsPage: () => void @@ -47,7 +48,7 @@ export function useSkillsPageKeyboardNavigation({ } // Why: tooltips can consume Escape before bubble listeners see it. - window.addEventListener('keydown', handleKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) + addAppKeyListener('keydown', handleKeyDown) + return () => removeAppKeyListener('keydown', handleKeyDown) }, [closeSkillsPage, exitSelection, exitSharedLinks, selectionMode, view]) } diff --git a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx index ace4491a13c..ef76a60ce93 100644 --- a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx +++ b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { agentTurnEndedUncleanly } from '../../../../shared/agent-main-agent-verdict' import { useAppStore } from '@/store' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' // Why: leave a short quiet window after agents finish so the prompt does not // interrupt follow-up typing or status churn from the completed run. @@ -117,10 +118,10 @@ export function StarNagAgentValueMomentObserver(): null { } lastTypingAtRef.current = Date.now() } - window.addEventListener('keydown', markTyping, true) + addAppKeyListener('keydown', markTyping) window.addEventListener('input', markTyping, true) return () => { - window.removeEventListener('keydown', markTyping, true) + removeAppKeyListener('keydown', markTyping) window.removeEventListener('input', markTyping, true) } }, []) diff --git a/src/renderer/src/components/tab-bar/RecentTabSwitcher.tsx b/src/renderer/src/components/tab-bar/RecentTabSwitcher.tsx index 27edecf0359..118ad2f5f22 100644 --- a/src/renderer/src/components/tab-bar/RecentTabSwitcher.tsx +++ b/src/renderer/src/components/tab-bar/RecentTabSwitcher.tsx @@ -15,6 +15,7 @@ import { type RecentTabSwitcherItem } from './recent-tab-switching' import { translate } from '@/i18n/i18n' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' type SwitcherState = { items: RecentTabSwitcherItem[] @@ -136,12 +137,12 @@ export default function RecentTabSwitcher(): React.JSX.Element | null { consumeKeyboardEvent(event) commit() } - window.addEventListener('keydown', onKeyDown, { capture: true }) - window.addEventListener('keyup', onKeyUp, { capture: true }) + addAppKeyListener('keydown', onKeyDown) + addAppKeyListener('keyup', onKeyUp) window.addEventListener('blur', cancel) return () => { - window.removeEventListener('keydown', onKeyDown, { capture: true }) - window.removeEventListener('keyup', onKeyUp, { capture: true }) + removeAppKeyListener('keydown', onKeyDown) + removeAppKeyListener('keyup', onKeyUp) window.removeEventListener('blur', cancel) } }, [cancel, commit, openOrAdvance]) diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts b/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts index 295f0d5f281..12011007847 100644 --- a/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.keyboard.test.ts @@ -58,6 +58,14 @@ vi.mock('../../../../shared/keybindings', () => ({ keybindingMatchesAction: keybindingsMock.matchAction })) +// Why: the stubbed window stands in for the app keyboard router's capture stage. +vi.mock('@/lib/app-keyboard-router', () => ({ + addAppKeyListener: (type: 'keydown' | 'keyup', handler: (e: KeyboardEvent) => void) => + window.addEventListener(type, handler, { capture: true }), + removeAppKeyListener: (type: 'keydown' | 'keyup', handler: (e: KeyboardEvent) => void) => + window.removeEventListener(type, handler, { capture: true }) +})) + vi.mock('@/lib/shortcut-platform', () => ({ getShortcutPlatform: () => 'darwin' as const })) @@ -165,7 +173,7 @@ beforeEach(() => { appStoreMock.state.keybindings = {} appStoreMock.state.settings.terminalShortcutPolicy = 'orca-first' vi.stubGlobal('window', { - addEventListener: vi.fn((type: string, handler: (e: KeyboardEvent) => void) => { + addEventListener: vi.fn((type: 'keydown' | 'keyup', handler: (e: KeyboardEvent) => void) => { windowListeners.set(type, handler) }), removeEventListener: vi.fn() diff --git a/src/renderer/src/components/tab-bar/tab-bar-quick-commands-shortcut.ts b/src/renderer/src/components/tab-bar/tab-bar-quick-commands-shortcut.ts index e9fa3973955..786b499ed43 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-quick-commands-shortcut.ts +++ b/src/renderer/src/components/tab-bar/tab-bar-quick-commands-shortcut.ts @@ -11,6 +11,7 @@ import { import { getShortcutPlatform } from '@/lib/shortcut-platform' import { TOGGLE_QUICK_COMMANDS_MENU_EVENT } from '@/lib/quick-commands-menu-events' import { useAppStore } from '@/store' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' type UseTabBarQuickCommandsShortcutParams = { isFocused: boolean @@ -126,12 +127,12 @@ export function useTabBarQuickCommandsShortcut({ ) } const onBlur = (): void => doubleTapDetector.reset() - window.addEventListener('keydown', onKeyDown, { capture: true }) - window.addEventListener('keyup', onKeyUp, { capture: true }) + addAppKeyListener('keydown', onKeyDown) + addAppKeyListener('keyup', onKeyUp) window.addEventListener('blur', onBlur) return () => { - window.removeEventListener('keydown', onKeyDown, { capture: true }) - window.removeEventListener('keyup', onKeyUp, { capture: true }) + removeAppKeyListener('keydown', onKeyDown) + removeAppKeyListener('keyup', onKeyUp) window.removeEventListener('blur', onBlur) } }, [activeView, isFocused, keybindings, menuOpen, onOpenChange, terminalShortcutPolicy]) diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts index 7051fa05223..2c43e92160d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts @@ -163,7 +163,7 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) { // Cmd+G / Cmd+Shift+G navigates terminal search matches even when focus // is inside the search input itself, so this check must run before the // editable-target guard would otherwise bypass all terminal shortcuts. - // stopImmediatePropagation prevents App.tsx's Cmd+Shift+G (source-control sidebar) from also firing. + // App owners run before this listener; source control's Cmd+Shift+G stands down while terminal search is open. const direction = matchSearchNavigate(e, isMac, searchOpenRef.current, searchStateRef.current) if (direction !== null) { if (e.repeat) { diff --git a/src/renderer/src/components/terminal-workspace-keydown.test.ts b/src/renderer/src/components/terminal-workspace-keydown.test.ts index a6c182e693b..cddddd71bb0 100644 --- a/src/renderer/src/components/terminal-workspace-keydown.test.ts +++ b/src/renderer/src/components/terminal-workspace-keydown.test.ts @@ -306,19 +306,25 @@ describe('shared tab navigation routing', () => { mocks.state = { activeWorktreeId: controller.activeWorktreeId } }) - function nextTab(): void { - handleTerminalWorkspaceKeyDown( - new KeyboardEvent('keydown', { - key: ']', - metaKey: true, - shiftKey: true, - cancelable: true - }), - controller, - 'darwin' - ) + function nextTab(repeat = false): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key: ']', + metaKey: true, + shiftKey: true, + cancelable: true, + repeat + }) + handleTerminalWorkspaceKeyDown(event, controller, 'darwin') + return event } + it('claims a held tab chord on every repeat but switches once', () => { + nextTab() + const repeat = nextTab(true) + expect(repeat.defaultPrevented).toBe(true) + expect(handleSwitchTabAcrossAllTypes).toHaveBeenCalledTimes(1) + }) + it('sends the keyboard and IPC navigation intent to the same workspace operation', () => { nextTab() dispatchWorkspaceTabCommand({ type: 'switch', direction: 1, scope: 'all-types' }) diff --git a/src/renderer/src/components/terminal-workspace-keydown.ts b/src/renderer/src/components/terminal-workspace-keydown.ts index b7f6e6fe751..32b51d76be5 100644 --- a/src/renderer/src/components/terminal-workspace-keydown.ts +++ b/src/renderer/src/components/terminal-workspace-keydown.ts @@ -227,11 +227,18 @@ export function handleTerminalWorkspaceKeyDown( ) { return } - if (!event.repeat && matchShortcut('tab.previousRecent')) { + // Why: a held tab chord is claimed on every repeat but switches once; an unclaimed repeat + // would reach the newly active terminal as its own key (Cmd+Right as Ctrl+E). + const claimTabSwitch = (): boolean => { event.preventDefault() event.stopPropagation() event.stopImmediatePropagation() - dispatchWorkspaceTabCommand({ type: 'previous-recent' }) + return !event.repeat + } + if (matchShortcut('tab.previousRecent')) { + if (claimTabSwitch()) { + dispatchWorkspaceTabCommand({ type: 'previous-recent' }) + } return } const switchSameTypeDirection = matchShortcut('tab.nextSameType') @@ -244,10 +251,10 @@ export function handleTerminalWorkspaceKeyDown( : matchShortcut('tab.previousAllTypes') ? -1 : null - if (!event.repeat && (switchSameTypeDirection !== null || switchAllTypesDirection !== null)) { - event.preventDefault() - event.stopPropagation() - event.stopImmediatePropagation() + if (switchSameTypeDirection !== null || switchAllTypesDirection !== null) { + if (!claimTabSwitch()) { + return + } notifyTerminalCapture( switchAllTypesDirection !== null ? switchAllTypesDirection === 1 @@ -268,10 +275,7 @@ export function handleTerminalWorkspaceKeyDown( : matchShortcut('tab.previousTerminal') ? -1 : null - if (!event.repeat && terminalTabDirection !== null) { - event.preventDefault() - event.stopPropagation() - event.stopImmediatePropagation() + if (terminalTabDirection !== null && claimTabSwitch()) { dispatchWorkspaceTabCommand({ type: 'switch', direction: terminalTabDirection, diff --git a/src/renderer/src/components/use-task-page-global-effects.ts b/src/renderer/src/components/use-task-page-global-effects.ts index 9209e1f9b67..6678b09d9eb 100644 --- a/src/renderer/src/components/use-task-page-global-effects.ts +++ b/src/renderer/src/components/use-task-page-global-effects.ts @@ -1,5 +1,6 @@ import type { TaskPageJiraSummaryGenerationModel } from './use-task-page-jira-summary-generation' import { useEffect } from 'react' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' export function useTaskPageGlobalEffects(model: TaskPageJiraSummaryGenerationModel) { const { closeTaskPage, @@ -72,13 +73,8 @@ export function useTaskPageGlobalEffects(model: TaskPageJiraSummaryGenerationMod event.preventDefault() closeTaskPage() } - window.addEventListener('keydown', onKeyDown, { - capture: true - }) - return () => - window.removeEventListener('keydown', onKeyDown, { - capture: true - }) + addAppKeyListener('keydown', onKeyDown) + return () => removeAppKeyListener('keydown', onKeyDown) }, [ activeModal, closeTaskPage, diff --git a/src/renderer/src/components/use-task-page-search-actions.ts b/src/renderer/src/components/use-task-page-search-actions.ts index f9a07140003..63549591c15 100644 --- a/src/renderer/src/components/use-task-page-search-actions.ts +++ b/src/renderer/src/components/use-task-page-search-actions.ts @@ -12,6 +12,7 @@ import { translate } from '@/i18n/i18n' import type { GitHubTaskKind } from '@/components/task-page-localized-options' import { getTaskPresetQuery } from '../../../shared/task-preset-query' import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' export function useTaskPageSearchActions(model: TaskPageGitHubQuietRefreshModel) { const { setTaskResumeState, @@ -218,13 +219,8 @@ export function useTaskPageSearchActions(model: TaskPageGitHubQuietRefreshModel) input.focus() input.select() } - window.addEventListener('keydown', onKeyDown, { - capture: true - }) - return () => - window.removeEventListener('keydown', onKeyDown, { - capture: true - }) + addAppKeyListener('keydown', onKeyDown) + return () => removeAppKeyListener('keydown', onKeyDown) }, [ activeModal, dialogWorkItem, diff --git a/src/renderer/src/components/use-terminal-keyboard-shortcuts.ts b/src/renderer/src/components/use-terminal-keyboard-shortcuts.ts index d7cbff68b13..c868dc9bab0 100644 --- a/src/renderer/src/components/use-terminal-keyboard-shortcuts.ts +++ b/src/renderer/src/components/use-terminal-keyboard-shortcuts.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react' import { handleTerminalWorkspaceKeyDown } from './terminal-workspace-keydown' import type { TerminalActivationController } from './use-terminal-activation-actions' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' export function useTerminalKeyboardShortcuts(controller: TerminalActivationController): void { const { @@ -28,8 +29,8 @@ export function useTerminalKeyboardShortcuts(controller: TerminalActivationContr const onKeyDown = (event: KeyboardEvent): void => { handleTerminalWorkspaceKeyDown(event, controller, shortcutPlatform) } - window.addEventListener('keydown', onKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) + addAppKeyListener('keydown', onKeyDown) + return () => removeAppKeyListener('keydown', onKeyDown) // oxlint-disable-next-line react-hooks/exhaustive-deps -- preserve the original listener refresh contract across extraction. }, [ activeWorktreeId, diff --git a/src/renderer/src/hooks/agent-auto-ack-presence.ts b/src/renderer/src/hooks/agent-auto-ack-presence.ts index a47ed4dcf27..ed1806e7df6 100644 --- a/src/renderer/src/hooks/agent-auto-ack-presence.ts +++ b/src/renderer/src/hooks/agent-auto-ack-presence.ts @@ -1,4 +1,5 @@ import { DESKTOP_AWAY_SECONDS } from '../../../shared/desktop-away-threshold' +import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' // The one record of user presence, from trusted renderer input only: the on-screen scan's gate // and the marking guard both read it. Main's "not away" only answers the scan that asked — it @@ -74,13 +75,13 @@ export function subscribeAutoAckPresenceSignals( window.addEventListener('pointermove', input) window.addEventListener('wheel', input, { passive: true }) // Why capture: xterm stops the keydowns it handles before they bubble to the window. - window.addEventListener('keydown', input, { capture: true }) + addAppKeyListener('keydown', input) return () => { document.removeEventListener('visibilitychange', onRescan) window.removeEventListener('focus', onRescan) window.removeEventListener('pointerdown', input) window.removeEventListener('pointermove', input) window.removeEventListener('wheel', input) - window.removeEventListener('keydown', input, { capture: true }) + removeAppKeyListener('keydown', input) } } diff --git a/src/renderer/src/lib/app-keyboard-router.test.ts b/src/renderer/src/lib/app-keyboard-router.test.ts new file mode 100644 index 00000000000..f8887db1767 --- /dev/null +++ b/src/renderer/src/lib/app-keyboard-router.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it } from 'vitest' +import { + addAppKeyListener, + installAppKeyboardRouter, + removeAppKeyListener +} from './app-keyboard-router' + +installAppKeyboardRouter(window) + +const cleanups: (() => void)[] = [] +afterEach(() => { + cleanups.splice(0).forEach((cleanup) => cleanup()) +}) + +function onWindow(listener: (event: KeyboardEvent) => void): void { + window.addEventListener('keydown', listener, { capture: true }) + cleanups.push(() => window.removeEventListener('keydown', listener, { capture: true })) +} + +function onApp(listener: (event: KeyboardEvent) => void): void { + addAppKeyListener('keydown', listener) + cleanups.push(() => removeAppKeyListener('keydown', listener)) +} + +function press(): string[] { + const seen: string[] = [] + const record = (name: string) => () => seen.push(name) + const target = document.createElement('textarea') + document.body.append(target) + document.addEventListener('keydown', record('document'), { capture: true, once: true }) + target.addEventListener('keydown', record('terminal input'), { once: true }) + cleanups.push(() => target.remove()) + target.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', metaKey: true, bubbles: true }) + ) + return seen +} + +describe('app keyboard router', () => { + // STA-11205: a tab shortcut owner re-registered after the terminal pane still runs first. + it.each(['pane first', 'app owner first'])( + 'runs app owners ahead of the terminal pane whatever the registration order (%s)', + (order) => { + const seen: string[] = [] + const pane = (event: KeyboardEvent): void => { + seen.push('pane translation') + event.stopImmediatePropagation() + } + const tabSwitch = (event: KeyboardEvent): void => { + seen.push('tab switch') + event.stopImmediatePropagation() + } + if (order === 'pane first') { + onWindow(pane) + onApp(tabSwitch) + } else { + onApp(tabSwitch) + onWindow(pane) + } + const tail = press() + expect([...seen, ...tail]).toEqual(['tab switch']) + } + ) + + it('leaves the terminal fallback when no app owner claims the key', () => { + const seen: string[] = [] + onWindow(() => seen.push('pane translation')) + onApp(() => seen.push('owner declined')) + const tail = press() + expect([...seen, ...tail]).toEqual([ + 'owner declined', + 'pane translation', + 'document', + 'terminal input' + ]) + }) + + it('keeps native stop semantics between app owners', () => { + const seen: string[] = [] + onApp((event) => { + seen.push('stops propagation') + event.stopPropagation() + }) + onApp(() => seen.push('later owner')) + onWindow(() => seen.push('pane')) + const tail = press() + expect([...seen, ...tail]).toEqual(['stops propagation', 'later owner', 'pane']) + }) +}) diff --git a/src/renderer/src/lib/app-keyboard-router.ts b/src/renderer/src/lib/app-keyboard-router.ts new file mode 100644 index 00000000000..77e2845b484 --- /dev/null +++ b/src/renderer/src/lib/app-keyboard-router.ts @@ -0,0 +1,86 @@ +// Why: app shortcut owners and terminal panes both listen on window capture, so React's +// effect order decided who saw a chord first (STA-11205). The router is installed before +// React renders and runs every app owner, in registration order, ahead of any pane listener. + +type AppKeyEventType = 'keydown' | 'keyup' +type AppKeyListener = (event: KeyboardEvent) => void +type Registration = { listener: AppKeyListener; active: boolean } + +const registrations: Record = { keydown: [], keyup: [] } +let installedOn: Window | null = null + +function dispatch(type: AppKeyEventType, event: KeyboardEvent): void { + // Why: like native listeners, owners added during a dispatch first see the next event. + const snapshot = registrations[type].slice() + let stoppedImmediately = false + const originalStop = event.stopImmediatePropagation + // Why: only an immediate stop ends the app stage; plain stopPropagation still lets the + // remaining window owners run, as it did when each owner was its own window listener. + event.stopImmediatePropagation = () => { + stoppedImmediately = true + originalStop.call(event) + } + try { + for (const registration of snapshot) { + if (stoppedImmediately) { + break + } + if (!registration.active) { + continue + } + try { + registration.listener(event) + } catch (error) { + // Why: a throwing owner must not cost the rest their shortcuts, as with native listeners. + reportError(error) + } + } + } finally { + event.stopImmediatePropagation = originalStop + } +} + +const onKeyDown = (event: KeyboardEvent): void => dispatch('keydown', event) +const onKeyUp = (event: KeyboardEvent): void => dispatch('keyup', event) + +/** Call before React renders so the router's window listeners precede every pane's. */ +export function installAppKeyboardRouter(target: Window = window): void { + if (installedOn === target) { + return + } + installedOn?.removeEventListener('keydown', onKeyDown, { capture: true }) + installedOn?.removeEventListener('keyup', onKeyUp, { capture: true }) + target.addEventListener('keydown', onKeyDown, { capture: true }) + target.addEventListener('keyup', onKeyUp, { capture: true }) + installedOn = target +} + +/** Window-capture key listener for app shortcut owners; terminal panes keep their own. */ +export function addAppKeyListener(type: AppKeyEventType, listener: AppKeyListener): void { + installAppKeyboardRouter(installedOn ?? window) + const list = registrations[type] + if (list.some((registration) => registration.active && registration.listener === listener)) { + return + } + list.push({ listener, active: true }) +} + +export function removeAppKeyListener(type: AppKeyEventType, listener: AppKeyListener): void { + const list = registrations[type] + const index = list.findIndex( + (registration) => registration.active && registration.listener === listener + ) + if (index === -1) { + return + } + list[index].active = false + list.splice(index, 1) +} + +if (import.meta.hot) { + import.meta.hot.dispose(() => { + installedOn?.removeEventListener('keydown', onKeyDown, { capture: true }) + installedOn?.removeEventListener('keyup', onKeyUp, { capture: true }) + installedOn = null + }) +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 4b0b316ce1d..b7c92fbf7ee 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -17,6 +17,7 @@ import { import { installAutomationHostDiagnostic } from './components/automations/automation-host-diagnostics' import { applyDocumentTheme } from './lib/document-theme' import { installTypingLatencyDiagnostic } from './lib/typing-latency/diagnostic' +import { installAppKeyboardRouter } from './lib/app-keyboard-router' import { shouldEnableReactGrab } from './lib/react-grab-dev-gate' import { I18nProvider } from './i18n/I18nProvider' import { translate } from './i18n/i18n' @@ -31,6 +32,7 @@ import.meta.hot?.dispose(disposeOsFileDropGuard) recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV }) installRendererCrashDiagnostics() installTypingLatencyDiagnostic() +installAppKeyboardRouter() installAutomationHostDiagnostic() if ( diff --git a/src/renderer/src/web/main.tsx b/src/renderer/src/web/main.tsx index 1d321e0bf1a..aa49175a6e1 100644 --- a/src/renderer/src/web/main.tsx +++ b/src/renderer/src/web/main.tsx @@ -22,9 +22,11 @@ import { WebPairingSecurityNotice } from './WebPairingSecurityNotice' import { I18nProvider } from '../i18n/I18nProvider' import { translate } from '../i18n/i18n' import { installOsFileDropCancellationGuard } from '../lib/os-file-drop-cancellation-guard' +import { installAppKeyboardRouter } from '../lib/app-keyboard-router' const disposeOsFileDropGuard = installOsFileDropCancellationGuard() import.meta.hot?.dispose(disposeOsFileDropGuard) +installAppKeyboardRouter() const App = lazy(() => import('../App')) function WebRoot(): React.JSX.Element {