fix(ui): improve composer focus, keyboard navigation, and settings (#3376)

* fix(ui): make composer keyboard interactions consistent

* docs(settings): refine description visibility guidance

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
ChangeHow
2026-09-07 20:30:19 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 1306b1124c
commit 5ae1a949c8
28 changed files with 252 additions and 97 deletions
+12 -34
View File
@@ -51,6 +51,7 @@ import { parseAgentMentions } from '@/lib/messages/agentMentions';
import { CONTEXT_METADATA_KEY, draftFromContextPayload } from '@/lib/messages/contextParts';
import { ComposerStatusBar } from './ComposerStatusBar';
import { shouldSubmitEnter } from './composer/keyboardPolicy';
import { getDropdownNavigationKey } from '@/components/ui/dropdown-navigation';
import { PendingChangesBar } from './PendingChangesBar';
import { useChatColumnSession } from './chatColumnSession';
import { useChatSurfaceMode } from './useChatSurfaceMode';
@@ -1873,40 +1874,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
return;
}
if (openAutocomplete === 'command' && commandRef.current) {
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
commandRef.current.handleKeyDown(e.key);
return;
}
}
if (openAutocomplete === 'skill' && skillRef.current) {
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
skillRef.current.handleKeyDown(e.key);
return;
}
}
if (openAutocomplete === 'snippet' && snippetRef.current) {
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
snippetRef.current.handleKeyDown(e.key);
return;
}
}
if (openAutocomplete === 'mention' && mentionRef.current) {
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault();
e.stopPropagation();
mentionRef.current.handleKeyDown(e.key);
return;
}
const autocomplete = openAutocomplete === 'command' ? commandRef.current
: openAutocomplete === 'skill' ? skillRef.current
: openAutocomplete === 'snippet' ? snippetRef.current
: openAutocomplete === 'mention' ? mentionRef.current
: null;
const autocompleteKey = getDropdownNavigationKey(e) ?? e.key;
if (autocomplete && (autocompleteKey === 'Enter' || autocompleteKey === 'ArrowUp' || autocompleteKey === 'ArrowDown' || autocompleteKey === 'Escape' || autocompleteKey === 'Tab')) {
e.preventDefault();
e.stopPropagation();
autocomplete.handleKeyDown(autocompleteKey);
return;
}
if (isDesktopExpanded && e.key === 'Escape') {
@@ -200,9 +200,13 @@ and the send path reading the same grammar.
result as transient local state that resets on every close, and commits
through the existing project-change flow only on explicit activation.
Filtering changes the result area below the anchored input without moving
the search field. The
worktree Select and the mobile bottom sheets are unchanged. The selectors only
consume their shared prefix while the draft target UI is mounted.
the search field. The worktree picker remains a Select; mobile keeps its
bottom sheets. The selectors only consume their shared prefix while the
draft target UI is mounted.
Keyboard selection returns focus to the current form's composer, including
when the selected value is unchanged.
- `ChatInput.tsx` maps Ctrl+N/P to the active command, skill, snippet, or
mention picker after its IME guard.
## Input recall ownership
@@ -259,10 +263,13 @@ suites that install module mocks are order-dependent.
## Enter preference
`keyboardPolicy.ts` owns the submission decision. Until the Chat setting is
changed, desktop Enter sends, mobile and focus mode require Ctrl/Cmd+Enter,
and Shift-modified Enter does not send. An explicit choice applies across
shared composers; Ctrl/Cmd+Enter sends in either configured mode.
`keyboardPolicy.ts` owns the submission decision. The expanded desktop composer
always inserts a newline with Enter, including Shift+Enter, and sends with
Ctrl/Cmd+Enter; it ignores the Enter-to-send preference. Outside expanded mode,
until the Chat setting is changed, desktop Enter sends, mobile requires
Ctrl/Cmd+Enter, and Shift-modified Enter does not send. An explicit choice
applies across the other shared composers; Ctrl/Cmd+Enter sends in either
configured mode.
CodeMirror's deferred mobile Enter loses modifier information. Untouched
settings restore Shift to keep the original policy. Once configured, with mobile
@@ -26,6 +26,10 @@ const enterPolicyCases: Array<[string, Partial<EnterKeyPolicyInput>, boolean]> =
['configured enabled Shift+Enter inserts a newline', { enterToSendConfigured: true, enterToSend: true, shiftKey: true }, false],
['configured disabled Enter inserts a newline', { enterToSendConfigured: true, enterToSend: false }, false],
['configured disabled Shift+Enter sends', { enterToSendConfigured: true, enterToSend: false, shiftKey: true }, true],
['expanded composer Enter inserts a newline when Enter-to-send is enabled', { isDesktopExpanded: true, enterToSendConfigured: true, enterToSend: true }, false],
['expanded composer Shift+Enter inserts a newline when Enter-to-send is disabled', { isDesktopExpanded: true, enterToSendConfigured: true, enterToSend: false, shiftKey: true }, false],
['expanded composer Ctrl+Enter sends despite Enter-to-send being disabled', { isDesktopExpanded: true, enterToSendConfigured: true, ctrlKey: true }, true],
['expanded composer Cmd+Enter sends despite Enter-to-send being enabled', { isDesktopExpanded: true, enterToSendConfigured: true, enterToSend: true, metaKey: true }, true],
['configured Ctrl+Enter always sends', { enterToSendConfigured: true, isMobile: true, isDesktopExpanded: true, shiftKey: true, ctrlKey: true }, true],
['configured Meta+Enter always sends', { enterToSendConfigured: true, isMobile: true, isDesktopExpanded: true, shiftKey: true, metaKey: true }, true],
];
@@ -33,8 +37,9 @@ const enterPolicyCases: Array<[string, Partial<EnterKeyPolicyInput>, boolean]> =
describe('Enter key policy', () => {
for (const surface of [{}, { isMobile: true }, { isDesktopExpanded: true }]) {
for (const modifiers of [{}, { ctrlKey: true }, { metaKey: true }, { ctrlKey: true, metaKey: true }]) {
test(`untouched Shift+Enter does not submit: ${JSON.stringify({ ...surface, ...modifiers })}`, () => {
expect(shouldSubmitEnter(policy({ ...surface, ...modifiers, shiftKey: true }))).toBe(false);
test(`untouched Shift+Enter only submits with a modifier in expanded mode: ${JSON.stringify({ ...surface, ...modifiers })}`, () => {
expect(shouldSubmitEnter(policy({ ...surface, ...modifiers, shiftKey: true })))
.toBe(Boolean(surface.isDesktopExpanded && (modifiers.ctrlKey || modifiers.metaKey)));
});
}
}
@@ -9,8 +9,10 @@ export interface EnterKeyPolicyInput {
}
export const shouldSubmitEnter = (input: EnterKeyPolicyInput): boolean => {
const enterSendsByDefault = !input.isMobile && !input.isDesktopExpanded;
const isCtrlEnter = input.ctrlKey || input.metaKey;
if (input.isDesktopExpanded) return isCtrlEnter;
const enterSendsByDefault = !input.isMobile;
if (!input.enterToSendConfigured) {
return !input.shiftKey && (enterSendsByDefault || isCtrlEnter);
}
@@ -163,6 +163,10 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
const [projectFocusReturn, setProjectFocusReturn] = React.useState(false);
const projectTriggerRef = React.useRef<HTMLButtonElement>(null);
const worktreeTriggerRef = React.useRef<HTMLButtonElement>(null);
// Controlled Select closes can omit finalFocus's interaction type.
const keyboardCloseRef = React.useRef(false);
const getComposerInput = () => projectTriggerRef.current?.closest('form')?.querySelector<HTMLElement>('[data-chat-input="true"] .cm-content');
const getFinalFocus = () => keyboardCloseRef.current ? getComposerInput() : true;
const projectSearchRef = React.useRef<HTMLInputElement>(null);
// Preserve Select's dialog portal and main-area containment.
const [projectPortalContainer, setProjectPortalContainer] = React.useState<HTMLElement | null>(null);
@@ -190,6 +194,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
if (openPicker === null || !shouldDismissDropdown(event)) return;
event.preventDefault();
event.stopPropagation();
keyboardCloseRef.current = true;
setOpenPicker(null);
};
@@ -279,10 +284,10 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
setOpenPicker(null);
}}
onOpenChangeComplete={(open) => {
// Return focus after Base UI finishes closing so the
// trigger itself, not document body, keeps keyboard flow.
// Return focus after Base UI finishes closing so typing
// continues in this form's composer, including reselection.
if (!open && projectFocusReturn) {
projectTriggerRef.current?.focus();
(getComposerInput() ?? projectTriggerRef.current)?.focus();
setProjectFocusReturn(false);
}
// Focus the search once the popup mounts; the opening
@@ -403,7 +408,10 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
<Select
value={selectedDirectory ?? branchItems[0]?.value ?? normalizePath(selectedProject.path) ?? ''}
open={openPicker === 'worktree'}
onOpenChange={(open) => setOpenPicker(open ? 'worktree' : null)}
onOpenChange={(open, details) => {
keyboardCloseRef.current = !open && details.event.type === 'keydown';
setOpenPicker(open ? 'worktree' : null);
}}
onValueChange={handleDirectoryChange}
disableGlobalShortcuts
>
@@ -433,7 +441,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
</TooltipContent>
) : null}
</Tooltip>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown} finalFocus={getFinalFocus}>
{projectRootBranchOption ? (
<SelectGroup>
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
@@ -16,6 +16,7 @@ import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { handleDropdownNavigationKey } from '@/components/ui/dropdown-navigation';
import { getCurrentIntlLocale } from '@/lib/i18n';
import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata';
import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDisplay';
@@ -585,9 +586,17 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
filteredFavorites.map((entry) => [`${entry.providerID}:${entry.modelID}`, entry] as const),
), [filteredFavorites]);
React.useEffect(() => {
selectionStore.set(0);
}, [searchQuery, selectionStore]);
const initialSelectionIndex = searchQuery.trim() || !selectedModel ? 0 : Math.max(0,
flatModelList.findIndex((entry) => entry.providerID === selectedModel.providerID && entry.modelID === selectedModel.modelID),
);
React.useLayoutEffect(() => {
selectionStore.set(initialSelectionIndex);
// Opening or scrolling the list must not let a stationary pointer replace the current model.
keyboardOwnsSelectionRef.current = true;
lastMousePositionRef.current = null;
scrollIntoView(scrollRef.current, itemRefs.current[initialSelectionIndex]);
}, [initialSelectionIndex, searchQuery, selectedModel?.providerID, selectedModel?.modelID, selectionStore]);
const selectIndex = React.useCallback((index: number) => {
selectionStore.set(index);
@@ -608,10 +617,13 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
React.useEffect(() => {
onActiveEntryChange?.(flatModelList[selectionStore.getSnapshot()]);
}, [flatModelList, onActiveEntryChange, selectionStore]);
}, [flatModelList, initialSelectionIndex, onActiveEntryChange, selectionStore]);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
if (event.defaultPrevented) return;
if (handleDropdownNavigationKey(event, (navigationKey) => {
moveSelection(navigationKey === 'ArrowDown' ? 1 : -1);
})) return;
event.stopPropagation();
if ((event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
const selected = flatModelList[selectionStore.getSnapshot()];
@@ -405,7 +405,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setEnterToSend = useUIStore(state => state.setEnterToSend);
const enterToSendConfigured = useUIStore(state => state.enterToSendConfigured);
const setEnterToSendConfigured = useUIStore(state => state.setEnterToSendConfigured);
const isExpandedInput = useUIStore(state => state.isExpandedInput);
const enterSendSelected = enterToSendConfigured ? enterToSend : !isMobile;
const showToolFileIcons = useUIStore(state => state.showToolFileIcons);
const setShowToolFileIcons = useUIStore(state => state.setShowToolFileIcons);
const showTurnChangedFiles = useUIStore(state => state.showTurnChangedFiles);
@@ -2097,8 +2097,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
<SettingsSection
title={t('settings.openchamber.visual.section.composer')}
settingsItem="chat.composer"
contentClassName={SETTINGS_OPTION_STACK_CLASS}
contentClassName="space-y-6"
>
{(shouldShow('persistDraft') || (!isMobile && shouldShow('inputSpellcheck'))) && (
<div className={SETTINGS_OPTION_STACK_CLASS}>
{shouldShow('persistDraft') && (
<SettingsCheckboxRow
checked={persistChatDraft}
@@ -2118,11 +2120,13 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
settingsItem="chat.spellcheck"
/>
)}
</div>
)}
{shouldShow('largeTextPaste') && (
<SettingsControlGroup
title={t('settings.openchamber.visual.field.largeTextPaste')}
info={t('settings.openchamber.visual.field.largeTextPasteHint')}
description={t('settings.openchamber.visual.field.largeTextPasteHint')}
settingsItem="chat.large-text-paste"
>
<SettingsRadioGroup aria-label={t('settings.openchamber.visual.field.largeTextPasteAria')}>
@@ -2139,14 +2143,26 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</SettingsControlGroup>
)}
{shouldShow('enterToSend') && (
<SettingsCheckboxRow
checked={enterToSendConfigured ? enterToSend : !isMobile && !isExpandedInput}
onChange={handleEnterToSendChange}
label={t('settings.openchamber.visual.field.enterToSend')}
info={t('settings.openchamber.visual.field.enterToSendHint')}
ariaLabel={t('settings.openchamber.visual.field.enterToSend')}
<SettingsControlGroup
title={t('settings.openchamber.visual.field.enterToSend')}
description={t('settings.openchamber.visual.field.enterToSendHint')}
settingsItem="chat.enter-to-send"
/>
>
<SettingsRadioGroup aria-label={t('settings.openchamber.visual.field.enterToSend')}>
<SettingsRadioOption
selected={enterSendSelected}
onSelect={() => handleEnterToSendChange(true)}
label={t('settings.openchamber.visual.option.enterToSend.enter.label')}
ariaLabel={t('settings.openchamber.visual.option.enterToSend.enter.label')}
/>
<SettingsRadioOption
selected={!enterSendSelected}
onSelect={() => handleEnterToSendChange(false)}
label={t('settings.openchamber.visual.option.enterToSend.modifier.label')}
ariaLabel={t('settings.openchamber.visual.option.enterToSend.modifier.label')}
/>
</SettingsRadioGroup>
</SettingsControlGroup>
)}
</SettingsSection>
)}
+14 -1
View File
@@ -7,6 +7,7 @@ import {
dropdownMenuPopupClass,
dropdownMenuSeparatorClass,
} from "./dropdown-menu.styles";
import { handleDropdownNavigationKey } from "./dropdown-navigation";
function ContextMenu({ ...props }: React.ComponentProps<typeof BaseContextMenu.Root>) {
return <BaseContextMenu.Root {...props} />;
@@ -22,7 +23,18 @@ type ContentProps = {
children?: React.ReactNode;
} & React.ComponentProps<typeof BaseContextMenu.Popup>;
function ContextMenuContent({ className, positionerClassName, children, style, ...props }: ContentProps) {
function ContextMenuContent({ className, positionerClassName, children, style, onKeyDown, ...props }: ContentProps) {
const handleKeyDown: NonNullable<React.ComponentProps<typeof BaseContextMenu.Popup>["onKeyDown"]> = (event) => {
onKeyDown?.(event);
handleDropdownNavigationKey(event, (navigationKey) => {
event.currentTarget.dispatchEvent(new KeyboardEvent("keydown", {
key: navigationKey,
bubbles: true,
cancelable: true,
}));
});
};
return (
<BaseContextMenu.Portal>
<BaseContextMenu.Positioner className={cn("app-region-no-drag z-50", positionerClassName)}>
@@ -34,6 +46,7 @@ function ContextMenuContent({ className, positionerClassName, children, style, .
}}
className={cn(dropdownMenuPopupClass, className)}
{...props}
onKeyDown={handleKeyDown}
>
{children}
</BaseContextMenu.Popup>
@@ -322,9 +322,21 @@ function DropdownMenuSubTrigger({
function DropdownMenuSubContent({
className,
children,
onKeyDown,
...props
}: React.ComponentProps<typeof BaseMenu.Popup>) {
const portalContext = React.useContext(DropdownPortalContext);
const handleKeyDown: NonNullable<React.ComponentProps<typeof BaseMenu.Popup>['onKeyDown']> = (event) => {
onKeyDown?.(event);
handleDropdownNavigationKey(event, (navigationKey) => {
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
key: navigationKey,
bubbles: true,
cancelable: true,
}));
});
};
return (
<BaseMenu.Portal container={portalContext?.portalContainer || undefined}>
<BaseMenu.Positioner className="z-50">
@@ -338,6 +350,7 @@ function DropdownMenuSubContent({
className
)}
{...props}
onKeyDown={handleKeyDown}
>
{children}
</BaseMenu.Popup>
@@ -0,0 +1,52 @@
import { describe, expect, test } from 'bun:test';
import { getDropdownNavigationKey, handleDropdownNavigationKey } from './dropdown-navigation';
function createEvent(overrides: Partial<KeyboardEvent> = {}) {
let defaultPrevented = false;
let propagationStopped = false;
return {
key: 'n',
code: 'KeyN',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
get defaultPrevented() { return defaultPrevented; },
isPropagationStopped: () => propagationStopped,
preventDefault: () => { defaultPrevented = true; },
stopPropagation: () => { propagationStopped = true; },
...overrides,
};
}
describe('dropdown Ctrl+N/P navigation', () => {
test('requires an exact Ctrl modifier chord', () => {
expect(getDropdownNavigationKey(createEvent())).toBe('ArrowDown');
expect(getDropdownNavigationKey(createEvent({ key: 'p', code: 'KeyP' }))).toBe('ArrowUp');
expect(getDropdownNavigationKey(createEvent({ ctrlKey: false }))).toBeNull();
expect(getDropdownNavigationKey(createEvent({ metaKey: true }))).toBeNull();
expect(getDropdownNavigationKey(createEvent({ altKey: true }))).toBeNull();
expect(getDropdownNavigationKey(createEvent({ shiftKey: true }))).toBeNull();
});
test('uses the physical key for non-Latin layouts', () => {
expect(getDropdownNavigationKey(createEvent({ key: 'т', code: 'KeyN' }))).toBe('ArrowDown');
});
test('respects cancellation and otherwise navigates exactly once', () => {
const cancelled = createEvent({ defaultPrevented: true });
expect(handleDropdownNavigationKey(cancelled, () => { throw new Error('should not navigate'); })).toBe(false);
const stopped = createEvent();
stopped.stopPropagation();
expect(handleDropdownNavigationKey(stopped, () => { throw new Error('should not navigate'); })).toBe(false);
const event = createEvent();
const steps: string[] = [];
expect(handleDropdownNavigationKey(event, (key) => steps.push(key))).toBe(true);
expect(steps).toEqual(['ArrowDown']);
expect(event.defaultPrevented).toBe(true);
expect(event.isPropagationStopped()).toBe(true);
});
});
@@ -2,7 +2,7 @@ import type React from 'react';
import { isIMECompositionEvent } from '@/lib/ime';
function getDropdownNavigationKey(event: Pick<KeyboardEvent, 'key' | 'code' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>): 'ArrowDown' | 'ArrowUp' | null {
export function getDropdownNavigationKey(event: Pick<KeyboardEvent, 'key' | 'code' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>): 'ArrowDown' | 'ArrowUp' | null {
if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null;
// `code` covers non-Latin layouts, where `key` is the layout's own letter.
if (event.key.toLowerCase() === 'n' || event.code === 'KeyN') return 'ArrowDown';