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 });
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user