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. shape is genuinely missing.
- Flat hierarchy through spacing and typography; no cards, boxed backgrounds, or row chrome. - 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. - 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). - 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. - 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) ## 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. - 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`. - 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 ## Completion Criteria
- Built from shared primitives; no ad-hoc page/section/row markup. - 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. - Container-query (`@xl:`/`@3xl:`) responsiveness — no viewport breakpoints in pane content.
- Controls use the standard size and width caps; no stretched full-width inputs. - Controls use the standard size and width caps; no stretched full-width inputs.
- Localized visible and accessibility text everywhere. - Localized visible and accessibility text everywhere.
@@ -38,6 +38,9 @@ cells or when the control is wide; same `info` / `settingsItem` props.
## Boolean ## Boolean
For a self-explanatory enable/disable setting, use only a checkbox and label;
no separate group title or description is needed.
```tsx ```tsx
<SettingsCheckboxRow <SettingsCheckboxRow
checked={value} checked={value}
@@ -54,10 +57,21 @@ for text that must stay visible (warnings, dynamic status).
## Mutually Exclusive Options ## 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 ```tsx
<SettingsRadioGroup aria-label={t('...group')}> <SettingsControlGroup title={t('...group')} description={t('...description')}>
<SettingsRadioOption selected={} onSelect={} label={t('...')} ariaLabel={t('...')} /> <SettingsRadioGroup aria-label={t('...group')}>
</SettingsRadioGroup> <SettingsRadioOption selected={} onSelect={} label={t('...')} ariaLabel={t('...')} />
</SettingsRadioGroup>
</SettingsControlGroup>
``` ```
Skip per-option descriptions when labels are self-explanatory. For short 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 | | L2 | `SettingsSection` title (`SETTINGS_SECTION_TITLE_CLASS`) | Section |
| L3 | `SettingsControlGroup` title (`SETTINGS_GROUP_TITLE_CLASS`) | Sub-cluster inside a section | | L3 | `SettingsControlGroup` title (`SETTINGS_GROUP_TITLE_CLASS`) | Sub-cluster inside a section |
| L4 | `SETTINGS_FIELD_LABEL_CLASS` | Field / control labels | | 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 ## Navigation Placement
@@ -63,5 +63,6 @@ pattern when touching nav.
- Sections own vertical rhythm: divider + `py-8` come from `SettingsSection`. - Sections own vertical rhythm: divider + `py-8` come from `SettingsSection`.
- Fields inside a column: `SETTINGS_FIELDS_STACK_CLASS` (`space-y-4`). - Fields inside a column: `SETTINGS_FIELDS_STACK_CLASS` (`space-y-4`).
- Checkbox/radio lists: `SETTINGS_OPTION_STACK_CLASS` (`space-y-1.5`). - 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). - 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. - 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. 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 ## Icon Contract
```tsx ```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. - Animations are limited to `transform` and `opacity`, or their cost was measured and accepted.
- No hardcoded/palette colors were introduced. - No hardcoded/palette colors were introduced.
- Buttons use shared variants and sizes. - 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. - Icons use `Icon`/`IconName`, and generated sprite changes are intentional.
- Hover, selection, primary, and status semantics are distinct. - Hover, selection, primary, and status semantics are distinct.
- Light/dark/high-contrast and long-text states remain legible. - 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 { CONTEXT_METADATA_KEY, draftFromContextPayload } from '@/lib/messages/contextParts';
import { ComposerStatusBar } from './ComposerStatusBar'; import { ComposerStatusBar } from './ComposerStatusBar';
import { shouldSubmitEnter } from './composer/keyboardPolicy'; import { shouldSubmitEnter } from './composer/keyboardPolicy';
import { getDropdownNavigationKey } from '@/components/ui/dropdown-navigation';
import { PendingChangesBar } from './PendingChangesBar'; import { PendingChangesBar } from './PendingChangesBar';
import { useChatColumnSession } from './chatColumnSession'; import { useChatColumnSession } from './chatColumnSession';
import { useChatSurfaceMode } from './useChatSurfaceMode'; import { useChatSurfaceMode } from './useChatSurfaceMode';
@@ -1873,40 +1874,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
return; return;
} }
if (openAutocomplete === 'command' && commandRef.current) { const autocomplete = openAutocomplete === 'command' ? commandRef.current
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { : openAutocomplete === 'skill' ? skillRef.current
e.preventDefault(); : openAutocomplete === 'snippet' ? snippetRef.current
e.stopPropagation(); : openAutocomplete === 'mention' ? mentionRef.current
commandRef.current.handleKeyDown(e.key); : null;
return; const autocompleteKey = getDropdownNavigationKey(e) ?? e.key;
} if (autocomplete && (autocompleteKey === 'Enter' || autocompleteKey === 'ArrowUp' || autocompleteKey === 'ArrowDown' || autocompleteKey === 'Escape' || autocompleteKey === 'Tab')) {
} e.preventDefault();
e.stopPropagation();
if (openAutocomplete === 'skill' && skillRef.current) { autocomplete.handleKeyDown(autocompleteKey);
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { return;
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;
}
} }
if (isDesktopExpanded && e.key === 'Escape') { 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 result as transient local state that resets on every close, and commits
through the existing project-change flow only on explicit activation. through the existing project-change flow only on explicit activation.
Filtering changes the result area below the anchored input without moving Filtering changes the result area below the anchored input without moving
the search field. The the search field. The worktree picker remains a Select; mobile keeps its
worktree Select and the mobile bottom sheets are unchanged. The selectors only bottom sheets. The selectors only consume their shared prefix while the
consume their shared prefix while the draft target UI is mounted. 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 ## Input recall ownership
@@ -259,10 +263,13 @@ suites that install module mocks are order-dependent.
## Enter preference ## Enter preference
`keyboardPolicy.ts` owns the submission decision. Until the Chat setting is `keyboardPolicy.ts` owns the submission decision. The expanded desktop composer
changed, desktop Enter sends, mobile and focus mode require Ctrl/Cmd+Enter, always inserts a newline with Enter, including Shift+Enter, and sends with
and Shift-modified Enter does not send. An explicit choice applies across Ctrl/Cmd+Enter; it ignores the Enter-to-send preference. Outside expanded mode,
shared composers; Ctrl/Cmd+Enter sends in either configured 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 CodeMirror's deferred mobile Enter loses modifier information. Untouched
settings restore Shift to keep the original policy. Once configured, with mobile 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 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 Enter inserts a newline', { enterToSendConfigured: true, enterToSend: false }, false],
['configured disabled Shift+Enter sends', { enterToSendConfigured: true, enterToSend: false, shiftKey: true }, true], ['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 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], ['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', () => { describe('Enter key policy', () => {
for (const surface of [{}, { isMobile: true }, { isDesktopExpanded: true }]) { for (const surface of [{}, { isMobile: true }, { isDesktopExpanded: true }]) {
for (const modifiers of [{}, { ctrlKey: true }, { metaKey: true }, { ctrlKey: true, metaKey: true }]) { for (const modifiers of [{}, { ctrlKey: true }, { metaKey: true }, { ctrlKey: true, metaKey: true }]) {
test(`untouched Shift+Enter does not submit: ${JSON.stringify({ ...surface, ...modifiers })}`, () => { test(`untouched Shift+Enter only submits with a modifier in expanded mode: ${JSON.stringify({ ...surface, ...modifiers })}`, () => {
expect(shouldSubmitEnter(policy({ ...surface, ...modifiers, shiftKey: true }))).toBe(false); 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 => { export const shouldSubmitEnter = (input: EnterKeyPolicyInput): boolean => {
const enterSendsByDefault = !input.isMobile && !input.isDesktopExpanded;
const isCtrlEnter = input.ctrlKey || input.metaKey; const isCtrlEnter = input.ctrlKey || input.metaKey;
if (input.isDesktopExpanded) return isCtrlEnter;
const enterSendsByDefault = !input.isMobile;
if (!input.enterToSendConfigured) { if (!input.enterToSendConfigured) {
return !input.shiftKey && (enterSendsByDefault || isCtrlEnter); return !input.shiftKey && (enterSendsByDefault || isCtrlEnter);
} }
@@ -163,6 +163,10 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
const [projectFocusReturn, setProjectFocusReturn] = React.useState(false); const [projectFocusReturn, setProjectFocusReturn] = React.useState(false);
const projectTriggerRef = React.useRef<HTMLButtonElement>(null); const projectTriggerRef = React.useRef<HTMLButtonElement>(null);
const worktreeTriggerRef = 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); const projectSearchRef = React.useRef<HTMLInputElement>(null);
// Preserve Select's dialog portal and main-area containment. // Preserve Select's dialog portal and main-area containment.
const [projectPortalContainer, setProjectPortalContainer] = React.useState<HTMLElement | null>(null); const [projectPortalContainer, setProjectPortalContainer] = React.useState<HTMLElement | null>(null);
@@ -190,6 +194,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
if (openPicker === null || !shouldDismissDropdown(event)) return; if (openPicker === null || !shouldDismissDropdown(event)) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
keyboardCloseRef.current = true;
setOpenPicker(null); setOpenPicker(null);
}; };
@@ -279,10 +284,10 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
setOpenPicker(null); setOpenPicker(null);
}} }}
onOpenChangeComplete={(open) => { onOpenChangeComplete={(open) => {
// Return focus after Base UI finishes closing so the // Return focus after Base UI finishes closing so typing
// trigger itself, not document body, keeps keyboard flow. // continues in this form's composer, including reselection.
if (!open && projectFocusReturn) { if (!open && projectFocusReturn) {
projectTriggerRef.current?.focus(); (getComposerInput() ?? projectTriggerRef.current)?.focus();
setProjectFocusReturn(false); setProjectFocusReturn(false);
} }
// Focus the search once the popup mounts; the opening // Focus the search once the popup mounts; the opening
@@ -403,7 +408,10 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
<Select <Select
value={selectedDirectory ?? branchItems[0]?.value ?? normalizePath(selectedProject.path) ?? ''} value={selectedDirectory ?? branchItems[0]?.value ?? normalizePath(selectedProject.path) ?? ''}
open={openPicker === 'worktree'} 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} onValueChange={handleDirectoryChange}
disableGlobalShortcuts disableGlobalShortcuts
> >
@@ -433,7 +441,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
</TooltipContent> </TooltipContent>
) : null} ) : null}
</Tooltip> </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 ? ( {projectRootBranchOption ? (
<SelectGroup> <SelectGroup>
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel> <SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
@@ -16,6 +16,7 @@ import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { handleDropdownNavigationKey } from '@/components/ui/dropdown-navigation';
import { getCurrentIntlLocale } from '@/lib/i18n'; import { getCurrentIntlLocale } from '@/lib/i18n';
import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata'; import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata';
import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDisplay'; 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.map((entry) => [`${entry.providerID}:${entry.modelID}`, entry] as const),
), [filteredFavorites]); ), [filteredFavorites]);
React.useEffect(() => { const initialSelectionIndex = searchQuery.trim() || !selectedModel ? 0 : Math.max(0,
selectionStore.set(0); flatModelList.findIndex((entry) => entry.providerID === selectedModel.providerID && entry.modelID === selectedModel.modelID),
}, [searchQuery, selectionStore]); );
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) => { const selectIndex = React.useCallback((index: number) => {
selectionStore.set(index); selectionStore.set(index);
@@ -608,10 +617,13 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
React.useEffect(() => { React.useEffect(() => {
onActiveEntryChange?.(flatModelList[selectionStore.getSnapshot()]); onActiveEntryChange?.(flatModelList[selectionStore.getSnapshot()]);
}, [flatModelList, onActiveEntryChange, selectionStore]); }, [flatModelList, initialSelectionIndex, onActiveEntryChange, selectionStore]);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
if (event.defaultPrevented) return; if (event.defaultPrevented) return;
if (handleDropdownNavigationKey(event, (navigationKey) => {
moveSelection(navigationKey === 'ArrowDown' ? 1 : -1);
})) return;
event.stopPropagation(); event.stopPropagation();
if ((event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { if ((event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
const selected = flatModelList[selectionStore.getSnapshot()]; const selected = flatModelList[selectionStore.getSnapshot()];
@@ -405,7 +405,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setEnterToSend = useUIStore(state => state.setEnterToSend); const setEnterToSend = useUIStore(state => state.setEnterToSend);
const enterToSendConfigured = useUIStore(state => state.enterToSendConfigured); const enterToSendConfigured = useUIStore(state => state.enterToSendConfigured);
const setEnterToSendConfigured = useUIStore(state => state.setEnterToSendConfigured); const setEnterToSendConfigured = useUIStore(state => state.setEnterToSendConfigured);
const isExpandedInput = useUIStore(state => state.isExpandedInput); const enterSendSelected = enterToSendConfigured ? enterToSend : !isMobile;
const showToolFileIcons = useUIStore(state => state.showToolFileIcons); const showToolFileIcons = useUIStore(state => state.showToolFileIcons);
const setShowToolFileIcons = useUIStore(state => state.setShowToolFileIcons); const setShowToolFileIcons = useUIStore(state => state.setShowToolFileIcons);
const showTurnChangedFiles = useUIStore(state => state.showTurnChangedFiles); const showTurnChangedFiles = useUIStore(state => state.showTurnChangedFiles);
@@ -2097,8 +2097,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
<SettingsSection <SettingsSection
title={t('settings.openchamber.visual.section.composer')} title={t('settings.openchamber.visual.section.composer')}
settingsItem="chat.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') && ( {shouldShow('persistDraft') && (
<SettingsCheckboxRow <SettingsCheckboxRow
checked={persistChatDraft} checked={persistChatDraft}
@@ -2118,11 +2120,13 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
settingsItem="chat.spellcheck" settingsItem="chat.spellcheck"
/> />
)} )}
</div>
)}
{shouldShow('largeTextPaste') && ( {shouldShow('largeTextPaste') && (
<SettingsControlGroup <SettingsControlGroup
title={t('settings.openchamber.visual.field.largeTextPaste')} 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" settingsItem="chat.large-text-paste"
> >
<SettingsRadioGroup aria-label={t('settings.openchamber.visual.field.largeTextPasteAria')}> <SettingsRadioGroup aria-label={t('settings.openchamber.visual.field.largeTextPasteAria')}>
@@ -2139,14 +2143,26 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</SettingsControlGroup> </SettingsControlGroup>
)} )}
{shouldShow('enterToSend') && ( {shouldShow('enterToSend') && (
<SettingsCheckboxRow <SettingsControlGroup
checked={enterToSendConfigured ? enterToSend : !isMobile && !isExpandedInput} title={t('settings.openchamber.visual.field.enterToSend')}
onChange={handleEnterToSendChange} description={t('settings.openchamber.visual.field.enterToSendHint')}
label={t('settings.openchamber.visual.field.enterToSend')}
info={t('settings.openchamber.visual.field.enterToSendHint')}
ariaLabel={t('settings.openchamber.visual.field.enterToSend')}
settingsItem="chat.enter-to-send" 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> </SettingsSection>
)} )}
+14 -1
View File
@@ -7,6 +7,7 @@ import {
dropdownMenuPopupClass, dropdownMenuPopupClass,
dropdownMenuSeparatorClass, dropdownMenuSeparatorClass,
} from "./dropdown-menu.styles"; } from "./dropdown-menu.styles";
import { handleDropdownNavigationKey } from "./dropdown-navigation";
function ContextMenu({ ...props }: React.ComponentProps<typeof BaseContextMenu.Root>) { function ContextMenu({ ...props }: React.ComponentProps<typeof BaseContextMenu.Root>) {
return <BaseContextMenu.Root {...props} />; return <BaseContextMenu.Root {...props} />;
@@ -22,7 +23,18 @@ type ContentProps = {
children?: React.ReactNode; children?: React.ReactNode;
} & React.ComponentProps<typeof BaseContextMenu.Popup>; } & 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 ( return (
<BaseContextMenu.Portal> <BaseContextMenu.Portal>
<BaseContextMenu.Positioner className={cn("app-region-no-drag z-50", positionerClassName)}> <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)} className={cn(dropdownMenuPopupClass, className)}
{...props} {...props}
onKeyDown={handleKeyDown}
> >
{children} {children}
</BaseContextMenu.Popup> </BaseContextMenu.Popup>
@@ -322,9 +322,21 @@ function DropdownMenuSubTrigger({
function DropdownMenuSubContent({ function DropdownMenuSubContent({
className, className,
children, children,
onKeyDown,
...props ...props
}: React.ComponentProps<typeof BaseMenu.Popup>) { }: React.ComponentProps<typeof BaseMenu.Popup>) {
const portalContext = React.useContext(DropdownPortalContext); 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 ( return (
<BaseMenu.Portal container={portalContext?.portalContainer || undefined}> <BaseMenu.Portal container={portalContext?.portalContainer || undefined}>
<BaseMenu.Positioner className="z-50"> <BaseMenu.Positioner className="z-50">
@@ -338,6 +350,7 @@ function DropdownMenuSubContent({
className className
)} )}
{...props} {...props}
onKeyDown={handleKeyDown}
> >
{children} {children}
</BaseMenu.Popup> </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'; 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; if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null;
// `code` covers non-Latin layouts, where `key` is the layout's own letter. // `code` covers non-Latin layouts, where `key` is the layout's own letter.
if (event.key.toLowerCase() === 'n' || event.code === 'KeyN') return 'ArrowDown'; 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.enableSpellcheckInTextInputs': 'Rechtschreibprüfung in Texteingaben aktivieren',
'settings.openchamber.visual.field.largeTextPaste': 'Großes Texteinfügen', '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.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.enterToSend': 'Tastenkürzel zum Senden',
'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.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.largeTextPasteAria': 'Verhalten bei großem Texteinfügen',
'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Großes Texteinfügen: {option}', 'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Großes Texteinfügen: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Jedes Mal fragen', '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.enableSpellcheckInTextInputs': 'Enable Spellcheck in Text Inputs',
'settings.openchamber.visual.field.largeTextPaste': 'Large text paste', '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.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.enterToSend': 'Send shortcut',
'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.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.largeTextPasteAria': 'Large text paste behavior',
'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Large text paste: {option}', 'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Large text paste: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Ask each time', '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.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.inputHistoryLimitAria": "Prompts que recordar",
"settings.openchamber.visual.field.inputHistoryLimitUnit": "prompts", "settings.openchamber.visual.field.inputHistoryLimitUnit": "prompts",
"settings.openchamber.visual.field.enterToSend": "Enter envía", "settings.openchamber.visual.field.enterToSend": "Atajo de envío",
"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.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, ...linearIntegrationI18n.es,
'settings.page.integrations.title': 'Integraciones', 'settings.page.integrations.title': 'Integraciones',
'settings.page.integrations.description': 'Conecta GitHub y Linear para que OpenChamber pueda trabajar con tus issues y pull requests.', '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.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.inputHistoryLimitAria': 'Prompts à mémoriser',
'settings.openchamber.visual.field.inputHistoryLimitUnit': 'prompts', 'settings.openchamber.visual.field.inputHistoryLimitUnit': 'prompts',
'settings.openchamber.visual.field.enterToSend': 'Entrée envoie', 'settings.openchamber.visual.field.enterToSend': 'Raccourci d\'envoi',
'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.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, ...linearIntegrationI18n.fr,
'settings.page.integrations.title': 'Intégrations', 'settings.page.integrations.title': 'Intégrations',
'settings.page.integrations.description': 'Connectez GitHub et Linear pour quOpenChamber puisse travailler avec vos issues et pull requests.', '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.inputHistoryLimitDescription': 'この数を減らすと、履歴内の古いプロンプトはすぐに削除されます。',
'settings.openchamber.visual.field.inputHistoryLimitAria': '記憶するプロンプト数', 'settings.openchamber.visual.field.inputHistoryLimitAria': '記憶するプロンプト数',
'settings.openchamber.visual.field.inputHistoryLimitUnit': '件', 'settings.openchamber.visual.field.inputHistoryLimitUnit': '件',
'settings.openchamber.visual.field.enterToSend': 'Enterで送信', 'settings.openchamber.visual.field.enterToSend': '送信ショートカット',
'settings.openchamber.visual.field.enterToSendHint': '変更すると、すべての環境でEnterとShift+Enterの動作を制御します。変更するまでは、各環境の既存の動作が維持されます。', '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, ...linearIntegrationI18n.ja,
'settings.page.integrations.title': '連携', 'settings.page.integrations.title': '連携',
'settings.page.integrations.description': 'GitHub と Linear を接続すると、OpenChamber が Issue やプルリクエストを扱えるようになります。', 'settings.page.integrations.description': 'GitHub と Linear を接続すると、OpenChamber が Issue やプルリクエストを扱えるようになります。',
@@ -2274,8 +2274,10 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': '이 숫자를 낮추면 기록에서 오래된 프롬프트가 바로 삭제됩니다.', 'settings.openchamber.visual.field.inputHistoryLimitDescription': '이 숫자를 낮추면 기록에서 오래된 프롬프트가 바로 삭제됩니다.',
'settings.openchamber.visual.field.inputHistoryLimitAria': '기억할 프롬프트 수', 'settings.openchamber.visual.field.inputHistoryLimitAria': '기억할 프롬프트 수',
'settings.openchamber.visual.field.inputHistoryLimitUnit': '개', 'settings.openchamber.visual.field.inputHistoryLimitUnit': '개',
'settings.openchamber.visual.field.enterToSend': 'Enter로 전송', 'settings.openchamber.visual.field.enterToSend': '전송 단축키',
'settings.openchamber.visual.field.enterToSendHint': '변경하면 모든 환경에서 Enter와 Shift+Enter의 동작을 제어합니다. 변경하기 전에는 각 환경의 기존 동작이 유지됩니다.', '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, ...linearIntegrationI18n.ko,
'settings.page.integrations.title': '통합', 'settings.page.integrations.title': '통합',
'settings.page.integrations.description': 'GitHub와 Linear를 연결하면 OpenChamber가 이슈와 풀 리퀘스트를 다룰 수 있습니다.', '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.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.inputHistoryLimitAria': 'Liczba zapamiętywanych promptów',
'settings.openchamber.visual.field.inputHistoryLimitUnit': 'promptów', 'settings.openchamber.visual.field.inputHistoryLimitUnit': 'promptów',
'settings.openchamber.visual.field.enterToSend': 'Enter wysyła', 'settings.openchamber.visual.field.enterToSend': 'Skrót wysyłania',
'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.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, ...linearIntegrationI18n.pl,
'settings.page.integrations.title': 'Integracje', '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.', '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.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.inputHistoryLimitAria": "Prompts para lembrar",
"settings.openchamber.visual.field.inputHistoryLimitUnit": "prompts", "settings.openchamber.visual.field.inputHistoryLimitUnit": "prompts",
"settings.openchamber.visual.field.enterToSend": "Enter envia", "settings.openchamber.visual.field.enterToSend": "Atalho de envio",
"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.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'], ...linearIntegrationI18n['pt-BR'],
'settings.page.integrations.title': 'Integrações', '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.', '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.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.inputHistoryLimitAria': 'Hatırlanacak prompt sayısı',
'settings.openchamber.visual.field.inputHistoryLimitUnit': 'prompt', 'settings.openchamber.visual.field.inputHistoryLimitUnit': 'prompt',
'settings.openchamber.visual.field.enterToSend': 'Enter gönderir', 'settings.openchamber.visual.field.enterToSend': 'Gönderme kısayolu',
'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.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.actions.resetInputBarOffsetAria': 'Giriş çubuğu ofsetini sıfırla',
'settings.openchamber.visual.field.terminalQuickKeysAria': 'Terminal hızlı tuşları', 'settings.openchamber.visual.field.terminalQuickKeysAria': 'Terminal hızlı tuşları',
'settings.openchamber.visual.field.terminalQuickKeys': '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.inputHistoryLimitDescription": "Якщо зменшити це число, старіші промпти одразу буде видалено з історії.",
"settings.openchamber.visual.field.inputHistoryLimitAria": "Скільки промптів пам’ятати", "settings.openchamber.visual.field.inputHistoryLimitAria": "Скільки промптів пам’ятати",
"settings.openchamber.visual.field.inputHistoryLimitUnit": "промптів", "settings.openchamber.visual.field.inputHistoryLimitUnit": "промптів",
"settings.openchamber.visual.field.enterToSend": "Enter надсилає", "settings.openchamber.visual.field.enterToSend": "Комбінація для надсилання",
"settings.openchamber.visual.field.enterToSendHint": "Після зміни цей параметр керує поведінкою Enter і Shift+Enter на всіх поверхнях. До цього кожна поверхня зберігає свою поточну поведінку.", "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, ...linearIntegrationI18n.uk,
'settings.page.integrations.title': 'Інтеграції', 'settings.page.integrations.title': 'Інтеграції',
'settings.page.integrations.description': 'Підключіть GitHub і Linear, щоб OpenChamber міг працювати з вашими задачами та pull request-ами.', '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.inputHistoryLimitDescription': '调低这个数字会立即从历史记录中删除较早的提示词。',
'settings.openchamber.visual.field.inputHistoryLimitAria': '要记住的提示词数量', 'settings.openchamber.visual.field.inputHistoryLimitAria': '要记住的提示词数量',
'settings.openchamber.visual.field.inputHistoryLimitUnit': '条', 'settings.openchamber.visual.field.inputHistoryLimitUnit': '条',
'settings.openchamber.visual.field.enterToSend': 'Enter 发送', 'settings.openchamber.visual.field.enterToSend': '发送快捷键',
'settings.openchamber.visual.field.enterToSendHint': '更改后,此设置会控制所有界面中的 Enter 和 Shift+Enter。更改前,各界面保持现有行为。', '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'], ...linearIntegrationI18n['zh-CN'],
'settings.page.integrations.title': '集成', 'settings.page.integrations.title': '集成',
'settings.page.integrations.description': '连接 GitHub 和 Linear,让 OpenChamber 可以处理你的 issue 和拉取请求。', 'settings.page.integrations.description': '连接 GitHub 和 Linear,让 OpenChamber 可以处理你的 issue 和拉取请求。',
@@ -2274,8 +2274,10 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': '調低這個數字會立刻從歷史記錄移除較早的提示詞。', 'settings.openchamber.visual.field.inputHistoryLimitDescription': '調低這個數字會立刻從歷史記錄移除較早的提示詞。',
'settings.openchamber.visual.field.inputHistoryLimitAria': '要記住的提示詞數量', 'settings.openchamber.visual.field.inputHistoryLimitAria': '要記住的提示詞數量',
'settings.openchamber.visual.field.inputHistoryLimitUnit': '則', 'settings.openchamber.visual.field.inputHistoryLimitUnit': '則',
'settings.openchamber.visual.field.enterToSend': 'Enter 傳送', 'settings.openchamber.visual.field.enterToSend': '傳送快捷鍵',
'settings.openchamber.visual.field.enterToSendHint': '變更後,此設定會控制所有介面中的 Enter 與 Shift+Enter。變更前,各介面會維持現有行為。', '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'], ...linearIntegrationI18n['zh-TW'],
'settings.page.integrations.title': '整合', 'settings.page.integrations.title': '整合',
'settings.page.integrations.description': '連接 GitHub 和 Linear,讓 OpenChamber 可以處理你的 issue 和 pull request。', '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', page: 'chat',
titleKey: 'settings.openchamber.visual.field.enterToSend', titleKey: 'settings.openchamber.visual.field.enterToSend',
descriptionKey: 'settings.openchamber.visual.field.enterToSendHint', 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', id: 'sessions.default-model',