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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
7ea24e3c50
commit
2bdd9af90a
@@ -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 });
|
||||
};
|
||||
Reference in New Issue
Block a user