From f64c4a74af623599d65cf051e66d85cfc5c86d3f Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 13:49:12 +0300 Subject: [PATCH] fix(chat): do not hijack ctrl/cmd+digit while typing in an input The numbered context-surface switcher (mod+digit) fired even while focus was in an editable target, stealing the browser's own tab-switching chord and opening the changes pane mid-typing (issue #2503). Guard the digit branch with an editable-target check (input/textarea/contenteditable, covering the CodeMirror composer) so the chord keeps its normal meaning while the user types; surface switching still works from any non-editable focus, and the shortcut remains rebindable/unassignable in Settings. Fixes #2503 --- .../src/hooks/keyboard-shortcut-dom.test.ts | 26 ++++++++++++++++++- .../ui/src/hooks/keyboard-shortcut-dom.ts | 14 ++++++++++ packages/ui/src/hooks/useKeyboardShortcuts.ts | 9 ++++++- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts b/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts index 608bb203..f68aa331 100644 --- a/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts +++ b/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'bun:test'; -import { hasOpenDropdown } from './keyboard-shortcut-dom'; +import { hasOpenDropdown, isTypingInEditableTarget } from './keyboard-shortcut-dom'; test('does not treat an unrelated visible listbox as an open dropdown', () => { const promptNavigator = {} as Element; @@ -28,3 +28,27 @@ test('detects an open select popup', () => { expect(hasOpenDropdown(root)).toBe(true); }); + +// isTypingInEditableTarget — the mod+digit surface switcher guard (issue +// #2503): while the user is typing in an editable target, ctrl/cmd+digit +// must keep its normal meaning (browser tab switching, in-input chords) +// instead of switching the context panel surface. +const targetWithClosest = (result: Element | null): EventTarget => + ({ closest: (selector: string) => (selector === 'input, textarea, [contenteditable="true"]' ? result : null) }) as unknown as EventTarget; + +test('editable guard is false for a null target', () => { + expect(isTypingInEditableTarget(null)).toBe(false); +}); + +test('editable guard is false for a target without closest', () => { + expect(isTypingInEditableTarget({} as EventTarget)).toBe(false); +}); + +test('editable guard is true inside an input, textarea or contenteditable', () => { + const editable = {} as Element; + expect(isTypingInEditableTarget(targetWithClosest(editable))).toBe(true); +}); + +test('editable guard is false outside editable surfaces', () => { + expect(isTypingInEditableTarget(targetWithClosest(null))).toBe(false); +}); diff --git a/packages/ui/src/hooks/keyboard-shortcut-dom.ts b/packages/ui/src/hooks/keyboard-shortcut-dom.ts index 413b6be2..c53dfe23 100644 --- a/packages/ui/src/hooks/keyboard-shortcut-dom.ts +++ b/packages/ui/src/hooks/keyboard-shortcut-dom.ts @@ -6,3 +6,17 @@ const OPEN_DROPDOWN_SELECTOR = [ export function hasOpenDropdown(root: ParentNode = document): boolean { return Boolean(root.querySelector(OPEN_DROPDOWN_SELECTOR)); } + +// Editable surfaces (the chat composer is a contenteditable CodeMirror view; +// CommitInput, searches and dialogs use textareas/inputs). Global shortcuts +// must not hijack keystrokes while the user is typing in one of these — +// mod+digit in particular is the browser's own tab-switching chord. +const EDITABLE_TARGET_SELECTOR = 'input, textarea, [contenteditable="true"]'; + +export function isTypingInEditableTarget(target: EventTarget | null): boolean { + const element = target as Element | null; + if (!element || typeof element.closest !== 'function') { + return false; + } + return Boolean(element.closest(EDITABLE_TARGET_SELECTOR)); +} diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 8e3ac00a..a3eb5410 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -26,7 +26,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; import { addSelectionToChat } from '@/lib/addSelectionToChat'; -import { hasOpenDropdown } from './keyboard-shortcut-dom'; +import { hasOpenDropdown, isTypingInEditableTarget } from './keyboard-shortcut-dom'; export const useKeyboardShortcuts = () => { const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); @@ -493,6 +493,13 @@ export const useKeyboardShortcuts = () => { if (switchSurfaceDigit !== null && !e.repeat && eventMatchesShortcutPrefix(e, switchSurfacePrefix, heldKeysRef.current)) { + // mod+digit is the browser's own tab-switching chord and a common + // in-input chord; never hijack it while the user is typing (the chat + // composer, CommitInput, searches, dialogs). Surface switching still + // works from anywhere that is not an editable target. + if (isTypingInEditableTarget(e.target)) { + return; + } const state = useUIStore.getState(); if (state.isMobile || !effectiveDirectory) { return;