feat(ui): add composer enter-to-send toggle (#3178)

* feat(ui): add composer enter-to-send toggle and native hardware-keyboard detection

Replaces the settings-page "Enter sends with a keyboard attached" checkbox with
an EnterKeyToggle in the composer footer: plain Enter submits / Shift+Enter
inserts a newline when enabled, Shift+Enter submits / Enter inserts a newline
when disabled. Ctrl/Cmd+Enter always submits as the soft-keyboard fallback.
Persisted as enterToSend.

Adds the Android HardwareKeyboardPlugin: scans input devices for an alphabetic
physical keyboard (ignoring phantom key/sensor devices), re-answers on config
changes/foreground, and confirms attachment from real hardware key events.
MainActivity surfaces key events to it before the WebView consumes them. The
composer and draft layout start keyboard-aware instead of inferring one focus
late; ComposerEditor preserves Enter modifiers through CodeMirror's deferred
re-dispatch so the toggle can tell Shift/Ctrl+Enter from plain Enter.

Removes the settings search entry and i18n keys for the old checkbox.

* refactor(ui): keep enter-to-send branch focused

* fix(ui): preserve enter-toggle taps on touch

* fix(ui): preserve enter key defaults and move setting

* fix(ui): keep enter setting lint-clean

* fix(i18n): preserve current Turkish message parity

* fix(settings): persist enter-to-send preference

* fix(ui): clarify enter-to-send setting

* fix(ui): apply enter preference on desktop

* fix(ui): match enter setting focus mode default

* test(ui): cover enter key policy matrix

* fix(ui): harden deferred enter handling

* fix(chat): preserve untouched Enter policy and validate settings

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Muhammad Zaim
2026-09-05 19:07:28 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 7ea24e3c50
commit 2bdd9af90a
31 changed files with 368 additions and 39 deletions
+16 -8
View File
@@ -51,6 +51,7 @@ import { ModelControls } from './ModelControls';
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 { PendingChangesBar } from './PendingChangesBar';
import { useChatColumnSession } from './chatColumnSession';
import { useChatSurfaceMode } from './useChatSurfaceMode';
@@ -490,6 +491,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const agents = getVisibleAgents();
const isMobile = useUIStore((state) => state.isMobile);
const hasHardwareKeyboard = useHardwareKeyboard();
const enterToSend = useUIStore((state) => state.enterToSend);
const enterToSendConfigured = useUIStore((state) => state.enterToSendConfigured);
const { enabled: isTabletLayout } = useTabletLayout();
const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
const inputBarOffset = useUIStore((state) => state.inputBarOffset);
@@ -1924,16 +1927,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
return;
}
// Handle Enter/Ctrl+Enter based on selected follow-up behavior. On
// mobile, and in desktop focus mode, plain Enter writes a newline and
// only Cmd/Ctrl+Enter sends: both are surfaces for composing long
// prompts, where an accidental send costs more than an extra keypress.
const requiresModifierToSend = isMobile || isDesktopExpanded;
if (e.key === 'Enter' && !e.shiftKey && (!requiresModifierToSend || e.ctrlKey || e.metaKey)) {
// Preserve each surface's existing default until the user changes the
// setting. Once configured, the choice applies consistently everywhere.
const isCtrlEnter = e.ctrlKey || e.metaKey;
if (e.key === 'Enter' && shouldSubmitEnter({
isMobile,
isDesktopExpanded,
enterToSend,
enterToSendConfigured,
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
})) {
e.preventDefault();
const isCtrlEnter = e.ctrlKey || e.metaKey;
// Queueing / steering only works when there's an existing busy
// session (or an active auto-review run).
const canQueue = !isBtwActive && inputMode === 'normal' && hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning);
@@ -3248,6 +3255,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
editable={Boolean(currentSessionId || newSessionDraftOpen)}
autoCorrect={composerAutoCorrect({ isMobile })}
autoCapitalize={isMobile ? 'sentences' : 'none'}
preserveDeferredEnterShift={!enterToSendConfigured || !isMobile}
spellCheck={isMobile || inputSpellcheckEnabled}
fillContainer={isComposerExpanded}
maxLines={isMobile ? MAX_MOBILE_COMPOSER_LINES : MAX_VISIBLE_COMPOSER_LINES}
@@ -218,3 +218,18 @@ on the strength of type-check and unit tests.
Run tests per file (`bun test <path>`): `mock.module` is process-global, so
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.
CodeMirror's deferred mobile Enter loses modifier information. Untouched
settings restore Shift to keep the original policy. Once configured, with mobile
autocapitalization enabled, the editor cannot distinguish its Shift flag from
an intentional Shift press and does not restore Shift. Consequently, deferred
Shift+Enter can send when Enter-to-send is enabled and cannot serve as the send
shortcut when it is disabled. Ctrl/Cmd+Enter remains the supported modified
send shortcut on this path.
@@ -40,6 +40,7 @@ import { replaceWithCaret } from './documentEdits';
import type { ComposerEditorViewStore } from './viewStore';
import { composerEditorTheme, composerSelectionExtension } from './theme';
import { handleComposerHostMouseDown } from './hostMouseDown';
import { restoreDeferredEnterModifiers } from '../keyboardPolicy';
export interface ComposerSelection {
start: number;
@@ -97,6 +98,8 @@ export interface ComposerEditorProps {
*/
autoCorrect?: ComposerAutoCorrect;
autoCapitalize?: 'none' | 'sentences';
/** Retain the untouched key policy before a mobile user opts in. */
preserveDeferredEnterShift?: boolean;
/** Fill the available height instead of growing with the content. */
fillContainer?: boolean;
/** Lines of text shown before the editor starts scrolling. */
@@ -175,13 +178,17 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
const hostRef = React.useRef<HTMLDivElement | null>(null);
const viewRef = React.useRef<EditorView | null>(null);
// The real keydown's shift state for the LAST Enter that reached the
// editor. CodeMirror defers Enter on iOS (and Chrome Android) and
// re-dispatches it as a synthetic keydown built from the key name
// alone, dropping every modifier (see `trackRealEnterShift` and the
// `interceptKeys` handler below); this ref is what lets the deferred
// event still tell Shift+Enter from Enter.
const lastRealEnterShiftRef = React.useRef(false);
// CodeMirror defers Enter on iOS and Chrome Android, then re-dispatches
// a synthetic keydown. Keep modifiers only for that short handoff.
const lastRealEnterModsRef = React.useRef({ shiftKey: false, ctrlKey: false, metaKey: false });
const deferredEnterModsTimeoutRef = React.useRef<number | null>(null);
const clearDeferredEnterModifiers = () => {
lastRealEnterModsRef.current = { shiftKey: false, ctrlKey: false, metaKey: false };
if (deferredEnterModsTimeoutRef.current !== null) {
window.clearTimeout(deferredEnterModsTimeoutRef.current);
deferredEnterModsTimeoutRef.current = null;
}
};
// Callbacks reach the CodeMirror extensions through a ref: the view is
// built once and must not be torn down when a handler identity changes,
@@ -224,11 +231,11 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
const interceptKeys: KeyBinding[] = [{
any: (_view, event) => {
// A deferred Enter lost its modifiers in the re-dispatch;
// give the caller's policy (Enter vs Shift+Enter) back the
// shift state it saw on the real keydown.
if (event.key === 'Enter' && isDeferredSyntheticEvent(event) && lastRealEnterShiftRef.current) {
Object.defineProperty(event, 'shiftKey', { value: true });
// A deferred Enter loses its modifiers in the re-dispatch.
if (event.key === 'Enter' && isDeferredSyntheticEvent(event)) {
const preserveShift = handlersRef.current.preserveDeferredEnterShift !== false;
restoreDeferredEnterModifiers(event, lastRealEnterModsRef.current, { preserveShift });
clearDeferredEnterModifiers();
}
return handlersRef.current.onKeyDown?.(event) ?? false;
},
@@ -306,26 +313,26 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
viewRef.current = view;
if (store) store.view = view;
// CodeMirror defers Enter on iOS (and Chrome Android): the real
// keydown is captured without running the keymaps, the browser's
// native newline goes through, and the keymaps then run against a
// synthetic keydown `dispatchKey` builds from the key name alone —
// which has NO modifiers. Recording the real shift state here (a
// plain listener, registered after CodeMirror's own, so it runs
// after the deferral decision but before the deferred dispatch)
// lets the deferred Enter be re-presented with Shift+Enter intact
// instead of arriving as a plain Enter that "sends" where Enter
// sends. Without it, Shift+Enter on iOS/Android submits the
// message instead of inserting a newline. The listener lives on
// Record the real modifier state after CodeMirror decides to defer
// the event, before its synthetic dispatch. The state expires if
// CodeMirror never dispatches the replacement, so a later Android
// keyboard Enter cannot inherit an unrelated earlier keydown. The listener lives on
// the kept-alive view's contentDOM, so it stays across mounts and
// keeps feeding the same ref the `interceptKeys` closure reads.
const trackRealEnterShift = (event: KeyboardEvent) => {
const trackRealEnterMods = (event: KeyboardEvent) => {
if (event.key !== 'Enter' || isDeferredSyntheticEvent(event)) return;
lastRealEnterShiftRef.current = event.shiftKey;
clearDeferredEnterModifiers();
lastRealEnterModsRef.current = {
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
};
deferredEnterModsTimeoutRef.current = window.setTimeout(clearDeferredEnterModifiers, 500);
};
view.contentDOM.addEventListener('keydown', trackRealEnterShift);
view.contentDOM.addEventListener('keydown', trackRealEnterMods);
return () => {
clearDeferredEnterModifiers();
viewRef.current = null;
// A stored view is detached, not destroyed: the store owns its
// lifetime now, and whoever owns the store ends it.
@@ -0,0 +1,82 @@
import { describe, expect, test } from 'bun:test';
import {
restoreDeferredEnterModifiers,
shouldSubmitEnter,
type EnterKeyPolicyInput,
type EnterModifierState,
} from './keyboardPolicy';
const policy = (overrides: Partial<EnterKeyPolicyInput>): EnterKeyPolicyInput => ({
isMobile: false,
isDesktopExpanded: false,
enterToSend: false,
enterToSendConfigured: false,
shiftKey: false,
ctrlKey: false,
metaKey: false,
...overrides,
});
const enterPolicyCases: Array<[string, Partial<EnterKeyPolicyInput>, boolean]> = [
['mobile default Enter inserts a newline', { isMobile: true }, false],
['desktop default Enter sends', {}, true],
['desktop focus mode default Enter inserts a newline', { isDesktopExpanded: true }, false],
['configured enabled Enter sends on mobile', { isMobile: true, enterToSendConfigured: true, enterToSend: true }, true],
['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],
['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],
];
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);
});
}
}
for (const [name, overrides, expected] of enterPolicyCases) {
test(name, () => {
expect(shouldSubmitEnter(policy(overrides))).toBe(expected);
});
}
});
const deferredModifierCases: Array<[string, EnterModifierState]> = [
['Shift', { shiftKey: true, ctrlKey: false, metaKey: false }],
['Ctrl', { shiftKey: false, ctrlKey: true, metaKey: false }],
['Meta', { shiftKey: false, ctrlKey: false, metaKey: true }],
['Shift+Ctrl+Meta', { shiftKey: true, ctrlKey: true, metaKey: true }],
];
describe('deferred Enter modifiers', () => {
for (const modifiers of [{ shiftKey: true, ctrlKey: true, metaKey: false }, { shiftKey: true, ctrlKey: false, metaKey: true }]) {
test(`untouched mobile deferred Shift does not submit: ${JSON.stringify(modifiers)}`, () => {
const event = { shiftKey: false, ctrlKey: false, metaKey: false };
restoreDeferredEnterModifiers(event, modifiers, { preserveShift: true });
expect(shouldSubmitEnter(policy({ isMobile: true, ...event }))).toBe(false);
});
}
for (const [name, modifiers] of deferredModifierCases) {
test(`preserves ${name}`, () => {
const event = { shiftKey: false, ctrlKey: false, metaKey: false };
restoreDeferredEnterModifiers(event, modifiers);
expect(event).toEqual(modifiers);
});
}
test('does not restore iOS auto-capitalization as Shift', () => {
const event = { shiftKey: false, ctrlKey: false, metaKey: false };
restoreDeferredEnterModifiers(event, { shiftKey: true, ctrlKey: false, metaKey: false }, { preserveShift: false });
expect(event).toEqual({ shiftKey: false, ctrlKey: false, metaKey: false });
});
});
@@ -0,0 +1,45 @@
export interface EnterKeyPolicyInput {
isMobile: boolean;
isDesktopExpanded: boolean;
enterToSend: boolean;
enterToSendConfigured: boolean;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
}
export const shouldSubmitEnter = (input: EnterKeyPolicyInput): boolean => {
const enterSendsByDefault = !input.isMobile && !input.isDesktopExpanded;
const isCtrlEnter = input.ctrlKey || input.metaKey;
if (!input.enterToSendConfigured) {
return !input.shiftKey && (enterSendsByDefault || isCtrlEnter);
}
const enterSends = input.enterToSend;
const sendsWithEnter = enterSends
? !input.shiftKey
: input.shiftKey;
return isCtrlEnter || sendsWithEnter;
};
export interface EnterModifierState {
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
}
interface DeferredEnterModifierOptions {
preserveShift?: boolean;
}
export const restoreDeferredEnterModifiers = (
event: EnterModifierState,
modifiers: EnterModifierState,
options: DeferredEnterModifierOptions = {},
): void => {
if (options.preserveShift !== false && modifiers.shiftKey) {
Object.defineProperty(event, 'shiftKey', { value: true });
}
if (modifiers.ctrlKey) Object.defineProperty(event, 'ctrlKey', { value: true });
if (modifiers.metaKey) Object.defineProperty(event, 'metaKey', { value: true });
};
@@ -210,6 +210,7 @@ const ChatSectionContent: React.FC = () => {
'persistDraft',
'inputSpellcheck',
'largeTextPaste',
'enterToSend',
]}
/>
);
@@ -283,7 +283,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? 'markdown' : 'plain';
};
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs';
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'enterToSend' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs';
const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [
{ id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' },
@@ -380,6 +380,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setInputSpellcheckEnabled = useUIStore(state => state.setInputSpellcheckEnabled);
const largeTextPasteBehavior = useUIStore(state => state.largeTextPasteBehavior);
const setLargeTextPasteBehavior = useUIStore(state => state.setLargeTextPasteBehavior);
const enterToSend = useUIStore(state => state.enterToSend);
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 showToolFileIcons = useUIStore(state => state.showToolFileIcons);
const setShowToolFileIcons = useUIStore(state => state.setShowToolFileIcons);
const showTurnChangedFiles = useUIStore(state => state.showTurnChangedFiles);
@@ -539,6 +544,12 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
void updateDesktopSettings({ inputSpellcheckEnabled: enabled });
}, [setInputSpellcheckEnabled]);
const handleEnterToSendChange = React.useCallback((enabled: boolean) => {
setEnterToSend(enabled);
setEnterToSendConfigured(true);
void updateDesktopSettings({ enterToSend: enabled, enterToSendConfigured: true });
}, [setEnterToSend, setEnterToSendConfigured]);
const handleChatRenderModeChange = React.useCallback((mode: 'sorted' | 'live') => {
setChatRenderMode(mode);
void updateDesktopSettings({ chatRenderMode: mode });
@@ -660,7 +671,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| shouldShow('largeTextPaste')
|| shouldShow('showToolFileIcons')
|| shouldShow('expandedTools')
|| (!isMobile && shouldShow('inputSpellcheck'));
|| (!isMobile && shouldShow('inputSpellcheck'))
|| shouldShow('enterToSend');
const showBehaviorDisplaySettings = shouldShow('chatRenderMode')
|| (shouldShow('activityRenderMode') && chatRenderMode === 'sorted');
const showTransportSection = shouldShow('messageTransport');
@@ -684,6 +696,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| shouldShow('showToolFileIcons')
|| shouldShow('showTurnChangedFiles')
|| (!isMobile && shouldShow('inputSpellcheck'))
|| shouldShow('enterToSend')
|| shouldShow('reasoning')
|| shouldShow('expandedTools');
// First behavior section under the page header should not draw a top border on Chat-only;
@@ -1457,7 +1470,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
/>
))}
</SettingsRadioGroup>
</SettingsControlGroup>
</SettingsControlGroup>
)}
<div className={SETTINGS_OPTION_STACK_CLASS}>
{shouldShow('autoSaveEnabled') && (
@@ -2001,7 +2014,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</SettingsSection>
)}
{(shouldShow('persistDraft') || shouldShow('largeTextPaste') || (!isMobile && shouldShow('inputSpellcheck'))) && (
{(shouldShow('persistDraft') || shouldShow('largeTextPaste') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('enterToSend')) && (
<SettingsSection
title={t('settings.openchamber.visual.section.composer')}
settingsItem="chat.composer"
@@ -2044,7 +2057,17 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
/>
))}
</SettingsRadioGroup>
</SettingsControlGroup>
</SettingsControlGroup>
)}
{shouldShow('enterToSend') && (
<SettingsCheckboxRow
checked={enterToSendConfigured ? enterToSend : !isMobile && !isExpandedInput}
onChange={handleEnterToSendChange}
label={t('chat.chatInput.actions.enterToSend')}
info={t('chat.chatInput.actions.enterToSendHint')}
ariaLabel={t('chat.chatInput.actions.enterToSend')}
settingsItem="chat.enter-to-send"
/>
)}
</SettingsSection>
)}