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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user