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
+4 -3
View File
@@ -23,7 +23,7 @@ divs — use the primitives, and extend them (in the shared file) when a new
shape is genuinely missing.
- Flat hierarchy through spacing and typography; no cards, boxed backgrounds, or row chrome.
- Secondary helper text is hidden behind an info icon (`info` prop); the default view stays quiet.
- Secondary helper text is hidden behind an info icon (`info` prop) by default; the default view stays quiet.
- Controls have one standard size (`h-9` / select `size="settings"`) and capped widths — no full-bleed inputs.
- Layouts respond to the settings pane width via container queries (`@xl:` / `@3xl:`), never viewport `sm:`/`lg:` breakpoints (the pane is much narrower than the viewport inside the dialog).
- Checkbox/radio state comes before labels; selected states are subtle and never shift layout.
@@ -57,7 +57,8 @@ Do not introduce raw `<Tooltip>`-based info icons, direct Remixicon components,
## Description Policy (info hints)
- Explanatory prose (what a feature does, when it applies) goes behind the info icon via the `info` prop — never as always-visible `description`.
- Explanatory prose goes behind the info icon via the `info` prop by default.
- When labels alone cannot explain the differences, consequences, or conditions needed to choose a setting, use a title, a visible description, then checkbox or radio controls. Large-text paste modes and send shortcuts with expanded-composer exceptions need this explanation. Having multiple options or a group title alone does not require a description; see `references/controls.md` for composition.
- Stays visible: security/data-loss warnings, destructive consequences, required syntax/placeholder lists the user reads while typing, dynamic status, empty states, validation errors, active-flow wizard instructions.
- Mixed text: keep the warning sentence visible, move the explanation to `info`.
@@ -80,7 +81,7 @@ Dynamic entity rows normally are not indexed. Load `references/search.md` for ex
## Completion Criteria
- Built from shared primitives; no ad-hoc page/section/row markup.
- Explanatory text hidden behind `info`; warnings/syntax/status still visible.
- Description placement follows the policy above; warnings/syntax/status remain visible.
- Container-query (`@xl:`/`@3xl:`) responsiveness — no viewport breakpoints in pane content.
- Controls use the standard size and width caps; no stretched full-width inputs.
- Localized visible and accessibility text everywhere.
@@ -38,6 +38,9 @@ cells or when the control is wide; same `info` / `settingsItem` props.
## Boolean
For a self-explanatory enable/disable setting, use only a checkbox and label;
no separate group title or description is needed.
```tsx
<SettingsCheckboxRow
checked={value}
@@ -54,10 +57,21 @@ for text that must stay visible (warnings, dynamic status).
## Mutually Exclusive Options
Use radios for mutually exclusive modes, not independent checkboxes. Keep
self-explanatory choices compact; a title or description is not mandatory.
When the skill's Description Policy calls for a visible explanation, wrap
the controls in `SettingsControlGroup`: title, description, then options.
Explain the choice once at group level. Use checkbox rows for independent
choices and radio options for mutually exclusive choices. Group spacing is
defined in `layout.md`.
```tsx
<SettingsRadioGroup aria-label={t('...group')}>
<SettingsRadioOption selected={} onSelect={} label={t('...')} ariaLabel={t('...')} />
</SettingsRadioGroup>
<SettingsControlGroup title={t('...group')} description={t('...description')}>
<SettingsRadioGroup aria-label={t('...group')}>
<SettingsRadioOption selected={} onSelect={} label={t('...')} ariaLabel={t('...')} />
</SettingsRadioGroup>
</SettingsControlGroup>
```
Skip per-option descriptions when labels are self-explanatory. For short
@@ -29,7 +29,7 @@ All primitives and class constants below live in
| L2 | `SettingsSection` title (`SETTINGS_SECTION_TITLE_CLASS`) | Section |
| L3 | `SettingsControlGroup` title (`SETTINGS_GROUP_TITLE_CLASS`) | Sub-cluster inside a section |
| L4 | `SETTINGS_FIELD_LABEL_CLASS` | Field / control labels |
| Helper | `SETTINGS_HELPER_CLASS`, `SETTINGS_DESCRIPTION_CLASS` | Rare visible helper text (most goes behind `info`) |
| Helper | `SETTINGS_HELPER_CLASS`, `SETTINGS_DESCRIPTION_CLASS` | Rare visible helper text (most goes behind `info`; see the skill's Description Policy) |
## Navigation Placement
@@ -63,5 +63,6 @@ pattern when touching nav.
- Sections own vertical rhythm: divider + `py-8` come from `SettingsSection`.
- Fields inside a column: `SETTINGS_FIELDS_STACK_CLASS` (`space-y-4`).
- Checkbox/radio lists: `SETTINGS_OPTION_STACK_CLASS` (`space-y-1.5`).
- Groups requiring a title and visible description: separate them from preceding controls with `space-y-6` on the parent. Keep simple checkbox/radio lists compact. The title sits closer to its own description and controls than to the preceding group; use `SettingsControlGroup`'s internal spacing.
- Two-column areas: `SettingsTwoColumn` (`@3xl:grid-cols-2`); use `SettingsStackedField` inside cells (a `SettingsFieldRow` overflows half-width columns).
- No elevated backgrounds, rounded rows, or hover fills without explicit UX value.
+9
View File
@@ -61,6 +61,14 @@ Use `Button` from `packages/ui/src/components/ui/button.tsx`.
Do not hardcode button height/padding when a size variant exists. Do not recreate selection/destructive styling with ad-hoc classes.
## Keyboard Navigation Contract
- Menus, selects, and autocomplete pickers with ArrowDown/ArrowUp navigation must also support Ctrl+N/Ctrl+P, including submenus and searchable lists.
- Keep this behavior in shared components so callers inherit it. Use the keyboard mapping in `packages/ui/src/components/ui/dropdown-navigation.ts`; feature code must not duplicate key detection.
- Lists that own their active option or stop keyboard propagation must call the shared navigation helper at their own event boundary. Wrapping a custom list in a dropdown does not guarantee that its navigation events reach the wrapper.
- Route both key pairs through the same selection logic, preserving disabled-item skipping, boundary or wrap behavior, highlight, and scroll visibility. Consume each navigation event once, only while the menu or picker is active; preserve IME text entry and other modifier chords.
- Verify Ctrl+N/P alongside arrow keys in the real component, including search-input focus, submenus, and closed state. A key-mapping unit test alone does not verify event propagation or focus behavior.
## Icon Contract
```tsx
@@ -82,6 +90,7 @@ For any other technique, load `performance-engineering` and `scripts/perf/DOCUME
- Animations are limited to `transform` and `opacity`, or their cost was measured and accepted.
- No hardcoded/palette colors were introduced.
- Buttons use shared variants and sizes.
- Menus and pickers satisfy the keyboard navigation contract without caller-specific key handling for standard shared components.
- Icons use `Icon`/`IconName`, and generated sprite changes are intentional.
- Hover, selection, primary, and status semantics are distinct.
- Light/dark/high-contrast and long-text states remain legible.
+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';
@@ -2042,8 +2042,10 @@ export const settingsDict = {
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Rechtschreibprüfung in Texteingaben aktivieren',
'settings.openchamber.visual.field.largeTextPaste': 'Großes Texteinfügen',
'settings.openchamber.visual.field.largeTextPasteHint': 'Beim Einfügen von mehr als etwa 2.000 Zeichen oder 25 Zeilen wählen, ob der Text als Datei angehängt, direkt eingefügt oder jedes Mal nachgefragt werden soll.',
'settings.openchamber.visual.field.enterToSend': 'Enter sendet',
'settings.openchamber.visual.field.enterToSendHint': 'Nach der Änderung steuern Enter und Shift+Enter das Verhalten auf jeder Oberfläche. Bis dahin behält jede Oberfläche ihr bestehendes Verhalten bei.',
'settings.openchamber.visual.field.enterToSend': 'Tastenkürzel zum Senden',
'settings.openchamber.visual.field.enterToSendHint': 'Wählen Sie das Tastenkürzel zum Senden im Standard-Composer. Im erweiterten Composer fügt Enter immer eine neue Zeile ein, und Strg/Cmd+Enter sendet.',
'settings.openchamber.visual.option.enterToSend.enter.label': 'Mit Enter senden',
'settings.openchamber.visual.option.enterToSend.modifier.label': 'Mit Strg/Cmd+Enter senden',
'settings.openchamber.visual.field.largeTextPasteAria': 'Verhalten bei großem Texteinfügen',
'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Großes Texteinfügen: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Jedes Mal fragen',
@@ -2125,8 +2125,10 @@ export const settingsDict = {
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Enable Spellcheck in Text Inputs',
'settings.openchamber.visual.field.largeTextPaste': 'Large text paste',
'settings.openchamber.visual.field.largeTextPasteHint': 'When pasting more than about 2,000 characters or 25 lines, choose whether to attach the text as a file, paste it inline, or ask each time.',
'settings.openchamber.visual.field.enterToSend': 'Enter sends',
'settings.openchamber.visual.field.enterToSendHint': 'Once changed, this controls Enter and Shift+Enter on every surface. Until then, each surface keeps its existing behavior.',
'settings.openchamber.visual.field.enterToSend': 'Send shortcut',
'settings.openchamber.visual.field.enterToSendHint': 'Choose the send shortcut for the standard composer. In the expanded composer, Enter always adds a new line and Ctrl/Cmd+Enter sends.',
'settings.openchamber.visual.option.enterToSend.enter.label': 'Send with Enter',
'settings.openchamber.visual.option.enterToSend.modifier.label': 'Send with Ctrl/Cmd+Enter',
'settings.openchamber.visual.field.largeTextPasteAria': 'Large text paste behavior',
'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Large text paste: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Ask each time',
@@ -2274,8 +2274,10 @@ export const settingsDict = {
"settings.openchamber.visual.field.inputHistoryLimitDescription": "Bajar este número elimina de inmediato los prompts más antiguos de tu historial.",
"settings.openchamber.visual.field.inputHistoryLimitAria": "Prompts que recordar",
"settings.openchamber.visual.field.inputHistoryLimitUnit": "prompts",
"settings.openchamber.visual.field.enterToSend": "Enter envía",
"settings.openchamber.visual.field.enterToSendHint": "Después de cambiarlo, controla Enter y Shift+Enter en todas las superficies. Hasta entonces, cada superficie mantiene su comportamiento actual.",
"settings.openchamber.visual.field.enterToSend": "Atajo de envío",
"settings.openchamber.visual.field.enterToSendHint": "Elige el atajo de envío para el compositor estándar. En el compositor expandido, Intro siempre añade una nueva línea y Ctrl/Cmd+Intro envía.",
"settings.openchamber.visual.option.enterToSend.enter.label": "Enviar con Intro",
"settings.openchamber.visual.option.enterToSend.modifier.label": "Enviar con Ctrl/Cmd+Intro",
...linearIntegrationI18n.es,
'settings.page.integrations.title': 'Integraciones',
'settings.page.integrations.description': 'Conecta GitHub y Linear para que OpenChamber pueda trabajar con tus issues y pull requests.',
@@ -2274,8 +2274,10 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': 'Réduire ce nombre supprime aussitôt les prompts les plus anciens de votre historique.',
'settings.openchamber.visual.field.inputHistoryLimitAria': 'Prompts à mémoriser',
'settings.openchamber.visual.field.inputHistoryLimitUnit': 'prompts',
'settings.openchamber.visual.field.enterToSend': 'Entrée envoie',
'settings.openchamber.visual.field.enterToSendHint': 'Après modification, ce réglage contrôle Entrée et Maj+Entrée sur toutes les surfaces. En attendant, chaque surface conserve son comportement actuel.',
'settings.openchamber.visual.field.enterToSend': 'Raccourci d\'envoi',
'settings.openchamber.visual.field.enterToSendHint': 'Choisissez le raccourci d\'envoi pour le composeur standard. Dans le composeur étendu, Entrée ajoute toujours une nouvelle ligne et Ctrl/Cmd+Entrée envoie.',
'settings.openchamber.visual.option.enterToSend.enter.label': 'Envoyer avec Entrée',
'settings.openchamber.visual.option.enterToSend.modifier.label': 'Envoyer avec Ctrl/Cmd+Entrée',
...linearIntegrationI18n.fr,
'settings.page.integrations.title': 'Intégrations',
'settings.page.integrations.description': 'Connectez GitHub et Linear pour quOpenChamber puisse travailler avec vos issues et pull requests.',
@@ -2274,8 +2274,10 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': 'この数を減らすと、履歴内の古いプロンプトはすぐに削除されます。',
'settings.openchamber.visual.field.inputHistoryLimitAria': '記憶するプロンプト数',
'settings.openchamber.visual.field.inputHistoryLimitUnit': '件',
'settings.openchamber.visual.field.enterToSend': 'Enterで送信',
'settings.openchamber.visual.field.enterToSendHint': '変更すると、すべての環境でEnterとShift+Enterの動作を制御します。変更するまでは、各環境の既存の動作が維持されます。',
'settings.openchamber.visual.field.enterToSend': '送信ショートカット',
'settings.openchamber.visual.field.enterToSendHint': '標準コンポーザーの送信ショートカットを選択します。拡張コンポーザーでは、Enter は常に改行し、Ctrl/Cmd+Enter で送信します。',
'settings.openchamber.visual.option.enterToSend.enter.label': 'Enter で送信',
'settings.openchamber.visual.option.enterToSend.modifier.label': 'Ctrl/Cmd+Enter で送信',
...linearIntegrationI18n.ja,
'settings.page.integrations.title': '連携',
'settings.page.integrations.description': 'GitHub と Linear を接続すると、OpenChamber が Issue やプルリクエストを扱えるようになります。',
@@ -2274,8 +2274,10 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': '이 숫자를 낮추면 기록에서 오래된 프롬프트가 바로 삭제됩니다.',
'settings.openchamber.visual.field.inputHistoryLimitAria': '기억할 프롬프트 수',
'settings.openchamber.visual.field.inputHistoryLimitUnit': '개',
'settings.openchamber.visual.field.enterToSend': 'Enter로 전송',
'settings.openchamber.visual.field.enterToSendHint': '변경하면 모든 환경에서 Enter와 Shift+Enter의 동작을 제어합니다. 변경하기 전에는 각 환경의 기존 동작이 유지됩니다.',
'settings.openchamber.visual.field.enterToSend': '전송 단축키',
'settings.openchamber.visual.field.enterToSendHint': '일반 작성기의 전송 단축키를 선택하세요. 확장 작성기에서 Enter가 항상 줄바꿈을 하고 Ctrl/Cmd+Enter로 전송합니다.',
'settings.openchamber.visual.option.enterToSend.enter.label': 'Enter로 전송',
'settings.openchamber.visual.option.enterToSend.modifier.label': 'Ctrl/Cmd+Enter로 전송',
...linearIntegrationI18n.ko,
'settings.page.integrations.title': '통합',
'settings.page.integrations.description': 'GitHub와 Linear를 연결하면 OpenChamber가 이슈와 풀 리퀘스트를 다룰 수 있습니다.',
@@ -2267,8 +2267,10 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': 'Zmniejszenie tej liczby od razu usuwa starsze prompty z historii.',
'settings.openchamber.visual.field.inputHistoryLimitAria': 'Liczba zapamiętywanych promptów',
'settings.openchamber.visual.field.inputHistoryLimitUnit': 'promptów',
'settings.openchamber.visual.field.enterToSend': 'Enter wysyła',
'settings.openchamber.visual.field.enterToSendHint': 'Po zmianie ustawienie steruje działaniem klawiszy Enter i Shift+Enter na każdej powierzchni. Do tego czasu każda powierzchnia zachowuje dotychczasowe działanie.',
'settings.openchamber.visual.field.enterToSend': 'Skrót wysyłania',
'settings.openchamber.visual.field.enterToSendHint': 'Wybierz skrót wysyłania dla standardowego komponentu. W rozszerzonym komponencie Enter zawsze dodaje nowy wiersz, a Ctrl/Cmd+Enter wysyła.',
'settings.openchamber.visual.option.enterToSend.enter.label': 'Wyślij klawiszem Enter',
'settings.openchamber.visual.option.enterToSend.modifier.label': 'Wyślij klawiszami Ctrl/Cmd+Enter',
...linearIntegrationI18n.pl,
'settings.page.integrations.title': 'Integracje',
'settings.page.integrations.description': 'Połącz GitHub i Linear, aby OpenChamber mógł pracować z Twoimi issue i pull requestami.',
@@ -2274,8 +2274,10 @@ export const settingsDict = {
"settings.openchamber.visual.field.inputHistoryLimitDescription": "Reduzir esse número remove na hora os prompts mais antigos do seu histórico.",
"settings.openchamber.visual.field.inputHistoryLimitAria": "Prompts para lembrar",
"settings.openchamber.visual.field.inputHistoryLimitUnit": "prompts",
"settings.openchamber.visual.field.enterToSend": "Enter envia",
"settings.openchamber.visual.field.enterToSendHint": "Depois de alterada, esta opção controla Enter e Shift+Enter em todas as superfícies. Até lá, cada superfície mantém seu comportamento atual.",
"settings.openchamber.visual.field.enterToSend": "Atalho de envio",
"settings.openchamber.visual.field.enterToSendHint": "Escolha o atalho de envio para o compositor padrão. No compositor expandido, Enter sempre cria uma nova linha e Ctrl/Cmd+Enter envia.",
"settings.openchamber.visual.option.enterToSend.enter.label": "Enviar com Enter",
"settings.openchamber.visual.option.enterToSend.modifier.label": "Enviar com Ctrl/Cmd+Enter",
...linearIntegrationI18n['pt-BR'],
'settings.page.integrations.title': 'Integrações',
'settings.page.integrations.description': 'Conecte o GitHub e o Linear para que o OpenChamber possa trabalhar com suas issues e pull requests.',
@@ -1987,8 +1987,10 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': 'Bu sayıyı azaltmak, eski prompt\'ları geçmişinizden hemen siler.',
'settings.openchamber.visual.field.inputHistoryLimitAria': 'Hatırlanacak prompt sayısı',
'settings.openchamber.visual.field.inputHistoryLimitUnit': 'prompt',
'settings.openchamber.visual.field.enterToSend': 'Enter gönderir',
'settings.openchamber.visual.field.enterToSendHint': 'Değiştirildikten sonra Enter ve Shift+Enter davranışını tüm yüzeylerde kontrol eder. O zamana kadar her yüzey mevcut davranışını korur.',
'settings.openchamber.visual.field.enterToSend': 'Gönderme kısayolu',
'settings.openchamber.visual.field.enterToSendHint': 'Standart oluşturucu için gönderme kısayolunu seçin. Genişletilmiş oluşturucuda Enter her zaman yeni satır ekler, Ctrl/Cmd+Enter gönderir.',
'settings.openchamber.visual.option.enterToSend.enter.label': 'Enter ile gönder',
'settings.openchamber.visual.option.enterToSend.modifier.label': 'Ctrl/Cmd+Enter ile gönder',
'settings.openchamber.visual.actions.resetInputBarOffsetAria': 'Giriş çubuğu ofsetini sıfırla',
'settings.openchamber.visual.field.terminalQuickKeysAria': 'Terminal hızlı tuşları',
'settings.openchamber.visual.field.terminalQuickKeys': 'Terminal Hızlı Tuşları',
@@ -2274,8 +2274,10 @@ export const settingsDict = {
"settings.openchamber.visual.field.inputHistoryLimitDescription": "Якщо зменшити це число, старіші промпти одразу буде видалено з історії.",
"settings.openchamber.visual.field.inputHistoryLimitAria": "Скільки промптів пам’ятати",
"settings.openchamber.visual.field.inputHistoryLimitUnit": "промптів",
"settings.openchamber.visual.field.enterToSend": "Enter надсилає",
"settings.openchamber.visual.field.enterToSendHint": "Після зміни цей параметр керує поведінкою Enter і Shift+Enter на всіх поверхнях. До цього кожна поверхня зберігає свою поточну поведінку.",
"settings.openchamber.visual.field.enterToSend": "Комбінація для надсилання",
"settings.openchamber.visual.field.enterToSendHint": "Виберіть комбінацію для надсилання у стандартному композері. У розгорнутому композері Enter завжди додає новий рядок, а Ctrl/Cmd+Enter надсилає.",
"settings.openchamber.visual.option.enterToSend.enter.label": "Надсилати клавішею Enter",
"settings.openchamber.visual.option.enterToSend.modifier.label": "Надсилати за допомогою Ctrl/Cmd+Enter",
...linearIntegrationI18n.uk,
'settings.page.integrations.title': 'Інтеграції',
'settings.page.integrations.description': 'Підключіть GitHub і Linear, щоб OpenChamber міг працювати з вашими задачами та pull request-ами.',
@@ -2274,8 +2274,10 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': '调低这个数字会立即从历史记录中删除较早的提示词。',
'settings.openchamber.visual.field.inputHistoryLimitAria': '要记住的提示词数量',
'settings.openchamber.visual.field.inputHistoryLimitUnit': '条',
'settings.openchamber.visual.field.enterToSend': 'Enter 发送',
'settings.openchamber.visual.field.enterToSendHint': '更改后,此设置会控制所有界面中的 Enter 和 Shift+Enter。更改前,各界面保持现有行为。',
'settings.openchamber.visual.field.enterToSend': '发送快捷键',
'settings.openchamber.visual.field.enterToSendHint': '请选择标准输入框的发送快捷键。在展开的输入框中,Enter 始终换行,Ctrl/Cmd+Enter 发送。',
'settings.openchamber.visual.option.enterToSend.enter.label': '按 Enter 发送',
'settings.openchamber.visual.option.enterToSend.modifier.label': '按 Ctrl/Cmd+Enter 发送',
...linearIntegrationI18n['zh-CN'],
'settings.page.integrations.title': '集成',
'settings.page.integrations.description': '连接 GitHub 和 Linear,让 OpenChamber 可以处理你的 issue 和拉取请求。',
@@ -2274,8 +2274,10 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': '調低這個數字會立刻從歷史記錄移除較早的提示詞。',
'settings.openchamber.visual.field.inputHistoryLimitAria': '要記住的提示詞數量',
'settings.openchamber.visual.field.inputHistoryLimitUnit': '則',
'settings.openchamber.visual.field.enterToSend': 'Enter 傳送',
'settings.openchamber.visual.field.enterToSendHint': '變更後,此設定會控制所有介面中的 Enter 與 Shift+Enter。變更前,各介面會維持現有行為。',
'settings.openchamber.visual.field.enterToSend': '傳送快捷鍵',
'settings.openchamber.visual.field.enterToSendHint': '請選擇標準輸入框的傳送快捷鍵。在展開的輸入框中,Enter 一律換行,Ctrl/Cmd+Enter 傳送。',
'settings.openchamber.visual.option.enterToSend.enter.label': '按 Enter 傳送',
'settings.openchamber.visual.option.enterToSend.modifier.label': '按 Ctrl/Cmd+Enter 傳送',
...linearIntegrationI18n['zh-TW'],
'settings.page.integrations.title': '整合',
'settings.page.integrations.description': '連接 GitHub 和 Linear,讓 OpenChamber 可以處理你的 issue 和 pull request。',
+1 -1
View File
@@ -386,7 +386,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
page: 'chat',
titleKey: 'settings.openchamber.visual.field.enterToSend',
descriptionKey: 'settings.openchamber.visual.field.enterToSendHint',
keywords: ['enter', 'shift enter', 'send', 'newline'],
keywords: ['enter', 'shift enter', 'ctrl enter', 'cmd enter', 'mod enter', 'send', 'newline'],
},
{
id: 'sessions.default-model',