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
This commit is contained in:
Serhii Dziupin
2026-08-05 13:49:12 +03:00
parent 34c221b07f
commit f64c4a74af
3 changed files with 47 additions and 2 deletions
@@ -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);
});
@@ -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));
}
@@ -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;