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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
1306b1124c
commit
5ae1a949c8
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user