diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index 0a9c3391..0d62e91f 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -135,6 +135,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) { open={openPicker === 'project'} onOpenChange={(open) => setOpenPicker(open ? 'project' : null)} onValueChange={handleProjectChange} + disableGlobalShortcuts > setOpenPicker(open ? 'worktree' : null)} onValueChange={handleDirectoryChange} + disableGlobalShortcuts > > = {}) { return { @@ -13,11 +13,11 @@ function keyEvent(key: string, modifiers: Partial { - expect(getDropdownMenuNavigationKey(keyEvent('n', { ctrlKey: true }))).toBe('ArrowDown'); - expect(getDropdownMenuNavigationKey(keyEvent('p', { ctrlKey: true }))).toBe('ArrowUp'); - expect(getDropdownMenuNavigationKey(keyEvent('N', { ctrlKey: true }))).toBe('ArrowDown'); - expect(getDropdownMenuNavigationKey(keyEvent('n'))).toBe(null); - expect(getDropdownMenuNavigationKey(keyEvent('n', { ctrlKey: true, shiftKey: true }))).toBe(null); - expect(getDropdownMenuNavigationKey(keyEvent('p', { ctrlKey: true, altKey: true }))).toBe(null); - expect(getDropdownMenuNavigationKey(keyEvent('p', { ctrlKey: true, metaKey: true }))).toBe(null); + expect(getDropdownNavigationKey(keyEvent('n', { ctrlKey: true }))).toBe('ArrowDown'); + expect(getDropdownNavigationKey(keyEvent('p', { ctrlKey: true }))).toBe('ArrowUp'); + expect(getDropdownNavigationKey(keyEvent('N', { ctrlKey: true }))).toBe('ArrowDown'); + expect(getDropdownNavigationKey(keyEvent('n'))).toBe(null); + expect(getDropdownNavigationKey(keyEvent('n', { ctrlKey: true, shiftKey: true }))).toBe(null); + expect(getDropdownNavigationKey(keyEvent('p', { ctrlKey: true, altKey: true }))).toBe(null); + expect(getDropdownNavigationKey(keyEvent('p', { ctrlKey: true, metaKey: true }))).toBe(null); }); diff --git a/packages/ui/src/components/ui/dropdown-menu.tsx b/packages/ui/src/components/ui/dropdown-menu.tsx index 81e91380..c9adb503 100644 --- a/packages/ui/src/components/ui/dropdown-menu.tsx +++ b/packages/ui/src/components/ui/dropdown-menu.tsx @@ -4,7 +4,8 @@ import { Menu as BaseMenu } from "@base-ui/react/menu" import { cn } from "@/lib/utils" import { Icon } from "@/components/icon/Icon"; import { shortcutRegistry } from "@/lib/shortcuts"; -import { getDropdownMenuNavigationKey } from "./dropdown-menu-keyboard"; +import { isIMECompositionEvent } from "@/lib/ime"; +import { getDropdownNavigationKey } from "./dropdown-navigation"; import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from "./dropdown-menu.styles"; type AsChildProps = { asChild?: boolean }; @@ -141,8 +142,8 @@ function DropdownMenuContent({ const handleKeyDown: NonNullable['onKeyDown']> = (event) => { onKeyDown?.(event); - if (event.defaultPrevented || event.isPropagationStopped() || event.nativeEvent.isComposing) return; - const navigationKey = getDropdownMenuNavigationKey(event); + if (event.defaultPrevented || event.isPropagationStopped() || isIMECompositionEvent(event)) return; + const navigationKey = getDropdownNavigationKey(event); if (!navigationKey) return; event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', { diff --git a/packages/ui/src/components/ui/dropdown-menu-keyboard.ts b/packages/ui/src/components/ui/dropdown-navigation.ts similarity index 57% rename from packages/ui/src/components/ui/dropdown-menu-keyboard.ts rename to packages/ui/src/components/ui/dropdown-navigation.ts index 66f32699..5e3c1a4c 100644 --- a/packages/ui/src/components/ui/dropdown-menu-keyboard.ts +++ b/packages/ui/src/components/ui/dropdown-navigation.ts @@ -1,4 +1,4 @@ -export function getDropdownMenuNavigationKey(event: Pick): 'ArrowDown' | 'ArrowUp' | null { +export function getDropdownNavigationKey(event: Pick): 'ArrowDown' | 'ArrowUp' | null { if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null; if (event.key.toLowerCase() === 'n') return 'ArrowDown'; if (event.key.toLowerCase() === 'p') return 'ArrowUp'; diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx index e3ea6220..0d4c19bb 100644 --- a/packages/ui/src/components/ui/select.tsx +++ b/packages/ui/src/components/ui/select.tsx @@ -8,6 +8,9 @@ import { cn } from "@/lib/utils" import { dropdownTriggerVariants } from "@/components/ui/dropdown-trigger" import { ScrollableOverlay } from "@/components/ui/ScrollableOverlay"; import { Icon } from "@/components/icon/Icon"; +import { shortcutRegistry } from "@/lib/shortcuts"; +import { isIMECompositionEvent } from "@/lib/ime"; +import { getDropdownNavigationKey } from "./dropdown-navigation"; type AsChildProps = { asChild?: boolean }; type AsChildRenderProps = { @@ -36,14 +39,21 @@ type SelectRootProps = Omit< value?: Value; defaultValue?: Value; onValueChange?: (value: Value, eventDetails: SelectRootChangeEventDetails) => void; + disableGlobalShortcuts?: boolean; }; function Select({ onValueChange, modal = false, + disableGlobalShortcuts = false, + open, + defaultOpen, + onOpenChange, ...props }: SelectRootProps) { const [portalContainer, setPortalContainer] = React.useState(null); + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false); + const isOpen = open ?? uncontrolledOpen; const portalContextValue = React.useMemo(() => ({ portalContainer, setPortalContainer, @@ -58,9 +68,26 @@ function Select({ [onValueChange] ); + React.useLayoutEffect(() => { + if (!disableGlobalShortcuts || !isOpen) return; + return shortcutRegistry.suspend(); + }, [disableGlobalShortcuts, isOpen]); + + const handleOpenChange: NonNullable['onOpenChange']> = (nextOpen, eventDetails) => { + if (open === undefined) setUncontrolledOpen(nextOpen); + onOpenChange?.(nextOpen, eventDetails); + }; + return ( - + ) } @@ -174,12 +201,28 @@ function SelectContent({ sideOffset, side, align, + onKeyDown, ...props }: React.ComponentProps & SelectContentExtra) { const portalContext = React.useContext(SelectPortalContext); const alignItemWithTrigger = position === "item-aligned"; const portalContainer = portalContext?.portalContainer ?? null; + const handleKeyDown: NonNullable['onKeyDown']> = (event) => { + onKeyDown?.(event); + if (event.defaultPrevented || event.isPropagationStopped() || isIMECompositionEvent(event)) return; + const navigationKey = getDropdownNavigationKey(event); + if (!navigationKey) return; + + event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', { + key: navigationKey, + bubbles: true, + cancelable: true, + })); + event.preventDefault(); + event.stopPropagation(); + }; + return ( { expect(calls).toEqual(['sequence']); }); + test('consumes a matching captured prefix key during IME composition', () => { + for (const compositionState of [{ isComposing: true }, { keyCode: 229 }]) { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('open_session_list', () => { calls.push('sequence'); }); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'mod+s l' }); + const secondKey = key('l', compositionState); + + expect(dispatcher.dispatch(key('s', { ctrlKey: true }))).toBe(true); + expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(true); + expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(true); + expect(calls).toEqual(['sequence']); + } + }); + + test('clears an active prefix but preserves an unmatched IME key', () => { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('open_session_list', () => { calls.push('sequence'); }); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'mod+s l' }); + const secondKey = key('x', { isComposing: true }); + + dispatcher.dispatch(key('s', { ctrlKey: true })); + expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(false); + expect(dispatcher.hasActivePrefix()).toBe(false); + expect(calls).toEqual([]); + }); + test('stops after the first handler that accepts a conflicting binding', () => { const registry = new ShortcutRegistry(); const calls: string[] = []; diff --git a/packages/ui/src/lib/shortcuts/dispatcher.ts b/packages/ui/src/lib/shortcuts/dispatcher.ts index deb9f8ab..a2348506 100644 --- a/packages/ui/src/lib/shortcuts/dispatcher.ts +++ b/packages/ui/src/lib/shortcuts/dispatcher.ts @@ -7,6 +7,7 @@ import { } from './bindings'; import { type ShortcutHandler, ShortcutRegistry } from './registry'; import type { ShortcutActionId } from './schema'; +import { isIMECompositionEvent } from '../ime'; const SEQUENCE_TIMEOUT_MS = 1500; const MODIFIER_KEYS = new Set(['alt', 'control', 'meta', 'shift']); @@ -38,7 +39,7 @@ export class ShortcutDispatcher { } dispatch(event: KeyboardEvent): boolean { - if (event.repeat || event.isComposing || MODIFIER_KEYS.has(event.key.toLowerCase())) { + if (event.repeat || isIMECompositionEvent(event) || MODIFIER_KEYS.has(event.key.toLowerCase())) { return false; } if (event.key === 'Escape' && this.hasActivePrefix()) { @@ -48,11 +49,7 @@ export class ShortcutDispatcher { const matches = this.getMatches(); if (this.prefix) { - const pending = matches.filter((match) => ( - match.chords.length === 2 - && match.chords[0] === this.prefix - && eventMatchesShortcut(event, match.chords[1]) - )); + const pending = this.getPrefixMatches(matches, event); if (pending.length > 0) { this.clear(); return this.invoke(pending, event); @@ -109,6 +106,14 @@ export class ShortcutDispatcher { dispatchActivePrefix(event: KeyboardEvent): boolean { this.capturedPrefixEvents.add(event); + if (isIMECompositionEvent(event)) { + if (event.repeat || MODIFIER_KEYS.has(event.key.toLowerCase()) || !this.hasActivePrefix()) { + return false; + } + const pending = this.getPrefixMatches(this.getMatches(), event); + this.clear(); + return pending.length > 0 ? this.invoke(pending, event) : false; + } return this.dispatch(event); } @@ -127,6 +132,14 @@ export class ShortcutDispatcher { return false; } + private getPrefixMatches(matches: BindingMatch[], event: KeyboardEvent): BindingMatch[] { + return matches.filter((match) => ( + match.chords.length === 2 + && match.chords[0] === this.prefix + && eventMatchesShortcut(event, match.chords[1]) + )); + } + private getMatches(): BindingMatch[] { const matches: BindingMatch[] = []; for (const actionId of this.options.registry.actionIds()) {