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
This commit is contained in:
Brennan Benson
2026-10-20 23:10:59 -07:00
committed by GitHub
parent fc8439e05f
commit 786a40ef1f
48 changed files with 380 additions and 131 deletions
@@ -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)
}
}, [])
@@ -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 (
@@ -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()
}
@@ -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 {
@@ -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,
@@ -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 }
@@ -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.
@@ -1,4 +1,5 @@
import { useEffect } from 'react'
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
/**
* Why: Electron <webview> 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])
}
@@ -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(() => {
@@ -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)
@@ -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(() => {
@@ -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 <webview>-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)
@@ -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<HTMLImageElement>): void => {
@@ -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
@@ -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)
}
}
@@ -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<DictationState>
@@ -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)
}
@@ -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,
@@ -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])
}
@@ -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,
@@ -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 {
@@ -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])
}
@@ -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
@@ -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()
}
@@ -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 (
@@ -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
@@ -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])
}
@@ -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])
}
@@ -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 (
@@ -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])
}
@@ -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)
@@ -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 })
@@ -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 })
}
@@ -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])
}
@@ -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)
}
}, [])
@@ -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])
@@ -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()
@@ -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])
@@ -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) {
@@ -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' })
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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)
}
}
@@ -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'])
})
})
@@ -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<AppKeyEventType, Registration[]> = { 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
})
}
+2
View File
@@ -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 (
+2
View File
@@ -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 {