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 { 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 { PendingChangesBar } from './PendingChangesBar'; import { PendingChangesBar } from './PendingChangesBar';
import { useChatColumnSession } from './chatColumnSession'; import { useChatColumnSession } from './chatColumnSession';
import { useChatSurfaceMode } from './useChatSurfaceMode'; import { useChatSurfaceMode } from './useChatSurfaceMode';
@@ -490,6 +491,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const agents = getVisibleAgents(); const agents = getVisibleAgents();
const isMobile = useUIStore((state) => state.isMobile); const isMobile = useUIStore((state) => state.isMobile);
const hasHardwareKeyboard = useHardwareKeyboard(); const hasHardwareKeyboard = useHardwareKeyboard();
const enterToSend = useUIStore((state) => state.enterToSend);
const enterToSendConfigured = useUIStore((state) => state.enterToSendConfigured);
const { enabled: isTabletLayout } = useTabletLayout(); const { enabled: isTabletLayout } = useTabletLayout();
const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen); const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
const inputBarOffset = useUIStore((state) => state.inputBarOffset); const inputBarOffset = useUIStore((state) => state.inputBarOffset);
@@ -1924,16 +1927,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
return; return;
} }
// Handle Enter/Ctrl+Enter based on selected follow-up behavior. On // Preserve each surface's existing default until the user changes the
// mobile, and in desktop focus mode, plain Enter writes a newline and // setting. Once configured, the choice applies consistently everywhere.
// only Cmd/Ctrl+Enter sends: both are surfaces for composing long const isCtrlEnter = e.ctrlKey || e.metaKey;
// prompts, where an accidental send costs more than an extra keypress. if (e.key === 'Enter' && shouldSubmitEnter({
const requiresModifierToSend = isMobile || isDesktopExpanded; isMobile,
if (e.key === 'Enter' && !e.shiftKey && (!requiresModifierToSend || e.ctrlKey || e.metaKey)) { isDesktopExpanded,
enterToSend,
enterToSendConfigured,
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
})) {
e.preventDefault(); e.preventDefault();
const isCtrlEnter = e.ctrlKey || e.metaKey;
// Queueing / steering only works when there's an existing busy // Queueing / steering only works when there's an existing busy
// session (or an active auto-review run). // session (or an active auto-review run).
const canQueue = !isBtwActive && inputMode === 'normal' && hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning); const canQueue = !isBtwActive && inputMode === 'normal' && hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning);
@@ -3248,6 +3255,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
editable={Boolean(currentSessionId || newSessionDraftOpen)} editable={Boolean(currentSessionId || newSessionDraftOpen)}
autoCorrect={composerAutoCorrect({ isMobile })} autoCorrect={composerAutoCorrect({ isMobile })}
autoCapitalize={isMobile ? 'sentences' : 'none'} autoCapitalize={isMobile ? 'sentences' : 'none'}
preserveDeferredEnterShift={!enterToSendConfigured || !isMobile}
spellCheck={isMobile || inputSpellcheckEnabled} spellCheck={isMobile || inputSpellcheckEnabled}
fillContainer={isComposerExpanded} fillContainer={isComposerExpanded}
maxLines={isMobile ? MAX_MOBILE_COMPOSER_LINES : MAX_VISIBLE_COMPOSER_LINES} 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 Run tests per file (`bun test <path>`): `mock.module` is process-global, so
suites that install module mocks are order-dependent. 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 type { ComposerEditorViewStore } from './viewStore';
import { composerEditorTheme, composerSelectionExtension } from './theme'; import { composerEditorTheme, composerSelectionExtension } from './theme';
import { handleComposerHostMouseDown } from './hostMouseDown'; import { handleComposerHostMouseDown } from './hostMouseDown';
import { restoreDeferredEnterModifiers } from '../keyboardPolicy';
export interface ComposerSelection { export interface ComposerSelection {
start: number; start: number;
@@ -97,6 +98,8 @@ export interface ComposerEditorProps {
*/ */
autoCorrect?: ComposerAutoCorrect; autoCorrect?: ComposerAutoCorrect;
autoCapitalize?: 'none' | 'sentences'; 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. */ /** Fill the available height instead of growing with the content. */
fillContainer?: boolean; fillContainer?: boolean;
/** Lines of text shown before the editor starts scrolling. */ /** 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 hostRef = React.useRef<HTMLDivElement | null>(null);
const viewRef = React.useRef<EditorView | null>(null); const viewRef = React.useRef<EditorView | null>(null);
// The real keydown's shift state for the LAST Enter that reached the // CodeMirror defers Enter on iOS and Chrome Android, then re-dispatches
// editor. CodeMirror defers Enter on iOS (and Chrome Android) and // a synthetic keydown. Keep modifiers only for that short handoff.
// re-dispatches it as a synthetic keydown built from the key name const lastRealEnterModsRef = React.useRef({ shiftKey: false, ctrlKey: false, metaKey: false });
// alone, dropping every modifier (see `trackRealEnterShift` and the const deferredEnterModsTimeoutRef = React.useRef<number | null>(null);
// `interceptKeys` handler below); this ref is what lets the deferred const clearDeferredEnterModifiers = () => {
// event still tell Shift+Enter from Enter. lastRealEnterModsRef.current = { shiftKey: false, ctrlKey: false, metaKey: false };
const lastRealEnterShiftRef = React.useRef(false); if (deferredEnterModsTimeoutRef.current !== null) {
window.clearTimeout(deferredEnterModsTimeoutRef.current);
deferredEnterModsTimeoutRef.current = null;
}
};
// Callbacks reach the CodeMirror extensions through a ref: the view is // Callbacks reach the CodeMirror extensions through a ref: the view is
// built once and must not be torn down when a handler identity changes, // 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[] = [{ const interceptKeys: KeyBinding[] = [{
any: (_view, event) => { any: (_view, event) => {
// A deferred Enter lost its modifiers in the re-dispatch; // A deferred Enter loses its modifiers in the re-dispatch.
// give the caller's policy (Enter vs Shift+Enter) back the if (event.key === 'Enter' && isDeferredSyntheticEvent(event)) {
// shift state it saw on the real keydown. const preserveShift = handlersRef.current.preserveDeferredEnterShift !== false;
if (event.key === 'Enter' && isDeferredSyntheticEvent(event) && lastRealEnterShiftRef.current) { restoreDeferredEnterModifiers(event, lastRealEnterModsRef.current, { preserveShift });
Object.defineProperty(event, 'shiftKey', { value: true }); clearDeferredEnterModifiers();
} }
return handlersRef.current.onKeyDown?.(event) ?? false; return handlersRef.current.onKeyDown?.(event) ?? false;
}, },
@@ -306,26 +313,26 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
viewRef.current = view; viewRef.current = view;
if (store) store.view = view; if (store) store.view = view;
// CodeMirror defers Enter on iOS (and Chrome Android): the real // Record the real modifier state after CodeMirror decides to defer
// keydown is captured without running the keymaps, the browser's // the event, before its synthetic dispatch. The state expires if
// native newline goes through, and the keymaps then run against a // CodeMirror never dispatches the replacement, so a later Android
// synthetic keydown `dispatchKey` builds from the key name alone — // keyboard Enter cannot inherit an unrelated earlier keydown. The listener lives on
// 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
// the kept-alive view's contentDOM, so it stays across mounts and // the kept-alive view's contentDOM, so it stays across mounts and
// keeps feeding the same ref the `interceptKeys` closure reads. // keeps feeding the same ref the `interceptKeys` closure reads.
const trackRealEnterShift = (event: KeyboardEvent) => { const trackRealEnterMods = (event: KeyboardEvent) => {
if (event.key !== 'Enter' || isDeferredSyntheticEvent(event)) return; 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 () => { return () => {
clearDeferredEnterModifiers();
viewRef.current = null; viewRef.current = null;
// A stored view is detached, not destroyed: the store owns its // A stored view is detached, not destroyed: the store owns its
// lifetime now, and whoever owns the store ends it. // 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', 'persistDraft',
'inputSpellcheck', 'inputSpellcheck',
'largeTextPaste', 'largeTextPaste',
'enterToSend',
]} ]}
/> />
); );
@@ -283,7 +283,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? '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 }> = [ const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [
{ id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' }, { 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 setInputSpellcheckEnabled = useUIStore(state => state.setInputSpellcheckEnabled);
const largeTextPasteBehavior = useUIStore(state => state.largeTextPasteBehavior); const largeTextPasteBehavior = useUIStore(state => state.largeTextPasteBehavior);
const setLargeTextPasteBehavior = useUIStore(state => state.setLargeTextPasteBehavior); 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 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);
@@ -539,6 +544,12 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
void updateDesktopSettings({ inputSpellcheckEnabled: enabled }); void updateDesktopSettings({ inputSpellcheckEnabled: enabled });
}, [setInputSpellcheckEnabled]); }, [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') => { const handleChatRenderModeChange = React.useCallback((mode: 'sorted' | 'live') => {
setChatRenderMode(mode); setChatRenderMode(mode);
void updateDesktopSettings({ chatRenderMode: mode }); void updateDesktopSettings({ chatRenderMode: mode });
@@ -660,7 +671,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| shouldShow('largeTextPaste') || shouldShow('largeTextPaste')
|| shouldShow('showToolFileIcons') || shouldShow('showToolFileIcons')
|| shouldShow('expandedTools') || shouldShow('expandedTools')
|| (!isMobile && shouldShow('inputSpellcheck')); || (!isMobile && shouldShow('inputSpellcheck'))
|| shouldShow('enterToSend');
const showBehaviorDisplaySettings = shouldShow('chatRenderMode') const showBehaviorDisplaySettings = shouldShow('chatRenderMode')
|| (shouldShow('activityRenderMode') && chatRenderMode === 'sorted'); || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted');
const showTransportSection = shouldShow('messageTransport'); const showTransportSection = shouldShow('messageTransport');
@@ -684,6 +696,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| shouldShow('showToolFileIcons') || shouldShow('showToolFileIcons')
|| shouldShow('showTurnChangedFiles') || shouldShow('showTurnChangedFiles')
|| (!isMobile && shouldShow('inputSpellcheck')) || (!isMobile && shouldShow('inputSpellcheck'))
|| shouldShow('enterToSend')
|| shouldShow('reasoning') || shouldShow('reasoning')
|| shouldShow('expandedTools'); || shouldShow('expandedTools');
// First behavior section under the page header should not draw a top border on Chat-only; // 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> </SettingsRadioGroup>
</SettingsControlGroup> </SettingsControlGroup>
)} )}
<div className={SETTINGS_OPTION_STACK_CLASS}> <div className={SETTINGS_OPTION_STACK_CLASS}>
{shouldShow('autoSaveEnabled') && ( {shouldShow('autoSaveEnabled') && (
@@ -2001,7 +2014,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</SettingsSection> </SettingsSection>
)} )}
{(shouldShow('persistDraft') || shouldShow('largeTextPaste') || (!isMobile && shouldShow('inputSpellcheck'))) && ( {(shouldShow('persistDraft') || shouldShow('largeTextPaste') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('enterToSend')) && (
<SettingsSection <SettingsSection
title={t('settings.openchamber.visual.section.composer')} title={t('settings.openchamber.visual.section.composer')}
settingsItem="chat.composer" settingsItem="chat.composer"
@@ -2044,7 +2057,17 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
/> />
))} ))}
</SettingsRadioGroup> </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> </SettingsSection>
)} )}
+2
View File
@@ -724,6 +724,8 @@ export interface SettingsPayload {
queueModeEnabled?: boolean; queueModeEnabled?: boolean;
gitmojiEnabled?: boolean; gitmojiEnabled?: boolean;
inputSpellcheckEnabled?: boolean; inputSpellcheckEnabled?: boolean;
enterToSend?: boolean;
enterToSendConfigured?: boolean;
showOpenCodeUpdateNotifications?: boolean; showOpenCodeUpdateNotifications?: boolean;
openCodeUpdateToastDismissedVersion?: string; openCodeUpdateToastDismissedVersion?: string;
showToolFileIcons?: boolean; showToolFileIcons?: boolean;
+2
View File
@@ -159,6 +159,8 @@ export type DesktopSettings = {
desktopWindowControlsPosition?: DesktopWindowControlsPosition; desktopWindowControlsPosition?: DesktopWindowControlsPosition;
desktopWindowControlsStyle?: DesktopWindowControlsStyle; desktopWindowControlsStyle?: DesktopWindowControlsStyle;
inputSpellcheckEnabled?: boolean; inputSpellcheckEnabled?: boolean;
enterToSend?: boolean;
enterToSendConfigured?: boolean;
showOpenCodeUpdateNotifications?: boolean; showOpenCodeUpdateNotifications?: boolean;
agentControlToolEnabled?: boolean; agentControlToolEnabled?: boolean;
agentWebToolEnabled?: boolean; agentWebToolEnabled?: boolean;
+2
View File
@@ -2105,6 +2105,8 @@ export const dict = {
'chat.chatInput.actions.linkGithubPr': 'GitHub-PR verknüpfen', 'chat.chatInput.actions.linkGithubPr': 'GitHub-PR verknüpfen',
'chat.chatInput.actions.modelAgentSettings': 'Modell- und Agenteneinstellungen', 'chat.chatInput.actions.modelAgentSettings': 'Modell- und Agenteneinstellungen',
'chat.chatInput.actions.sendMessageAria': 'Nachricht senden', 'chat.chatInput.actions.sendMessageAria': 'Nachricht senden',
'chat.chatInput.actions.enterToSend': 'Enter sendet',
'chat.chatInput.actions.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.',
'chat.chatInput.actions.queueMessageAria': 'Nachricht in die Warteschlange stellen', 'chat.chatInput.actions.queueMessageAria': 'Nachricht in die Warteschlange stellen',
'chat.chatInput.actions.stopGeneratingAria': 'Generierung stoppen', 'chat.chatInput.actions.stopGeneratingAria': 'Generierung stoppen',
'chat.chatInput.focusMode.toggleAria': 'Fokusmodus umschalten', 'chat.chatInput.focusMode.toggleAria': 'Fokusmodus umschalten',
+2
View File
@@ -2322,6 +2322,8 @@ export const dict = {
'chat.chatInput.actions.linkGithubPr': 'Link GitHub PR', 'chat.chatInput.actions.linkGithubPr': 'Link GitHub PR',
'chat.chatInput.actions.modelAgentSettings': 'Model and agent settings', 'chat.chatInput.actions.modelAgentSettings': 'Model and agent settings',
'chat.chatInput.actions.sendMessageAria': 'Send message', 'chat.chatInput.actions.sendMessageAria': 'Send message',
'chat.chatInput.actions.enterToSend': 'Enter sends',
'chat.chatInput.actions.enterToSendHint': 'Once changed, this controls Enter and Shift+Enter on every surface. Until then, each surface keeps its existing behavior.',
'chat.chatInput.actions.queueMessageAria': 'Queue message', 'chat.chatInput.actions.queueMessageAria': 'Queue message',
'chat.chatInput.actions.stopGeneratingAria': 'Stop generating', 'chat.chatInput.actions.stopGeneratingAria': 'Stop generating',
'chat.chatInput.focusMode.toggleAria': 'Toggle focus mode', 'chat.chatInput.focusMode.toggleAria': 'Toggle focus mode',
+2
View File
@@ -2300,6 +2300,8 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.actions.linkGithubPr": "Vincular PR de GitHub", "chat.chatInput.actions.linkGithubPr": "Vincular PR de GitHub",
"chat.chatInput.actions.modelAgentSettings": "Configuración del modelo y agente", "chat.chatInput.actions.modelAgentSettings": "Configuración del modelo y agente",
"chat.chatInput.actions.sendMessageAria": "Enviar mensaje", "chat.chatInput.actions.sendMessageAria": "Enviar mensaje",
'chat.chatInput.actions.enterToSend': 'Enter envía',
'chat.chatInput.actions.enterToSendHint': 'Después de cambiarlo, controla Enter y Shift+Enter en todas las superficies. Hasta entonces, cada superficie mantiene su comportamiento actual.',
"chat.chatInput.actions.queueMessageAria": "Poner mensaje en cola", "chat.chatInput.actions.queueMessageAria": "Poner mensaje en cola",
"chat.chatInput.actions.stopGeneratingAria": "Detener generación", "chat.chatInput.actions.stopGeneratingAria": "Detener generación",
"chat.chatInput.focusMode.toggleAria": "Activar o desactivar modo de enfoque", "chat.chatInput.focusMode.toggleAria": "Activar o desactivar modo de enfoque",
+2
View File
@@ -2041,6 +2041,8 @@ export const dict = {
'chat.chatInput.actions.linkGithubPr': 'Lien GitHub PR', 'chat.chatInput.actions.linkGithubPr': 'Lien GitHub PR',
'chat.chatInput.actions.modelAgentSettings': 'Paramètres du modèle et de l\'agent', 'chat.chatInput.actions.modelAgentSettings': 'Paramètres du modèle et de l\'agent',
'chat.chatInput.actions.sendMessageAria': 'Envoyer un message', 'chat.chatInput.actions.sendMessageAria': 'Envoyer un message',
'chat.chatInput.actions.enterToSend': 'Entrée envoie',
'chat.chatInput.actions.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.',
'chat.chatInput.actions.queueMessageAria': 'Message de file d\'attente', 'chat.chatInput.actions.queueMessageAria': 'Message de file d\'attente',
'chat.chatInput.actions.stopGeneratingAria': 'Arrêter de générer', 'chat.chatInput.actions.stopGeneratingAria': 'Arrêter de générer',
'chat.chatInput.focusMode.toggleAria': 'Basculer le mode de mise au point', 'chat.chatInput.focusMode.toggleAria': 'Basculer le mode de mise au point',
+2
View File
@@ -2318,6 +2318,8 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.actions.linkGithubPr': 'GitHub PRをリンク', 'chat.chatInput.actions.linkGithubPr': 'GitHub PRをリンク',
'chat.chatInput.actions.modelAgentSettings': 'モデルとエージェント設定', 'chat.chatInput.actions.modelAgentSettings': 'モデルとエージェント設定',
'chat.chatInput.actions.sendMessageAria': 'メッセージを送信', 'chat.chatInput.actions.sendMessageAria': 'メッセージを送信',
'chat.chatInput.actions.enterToSend': 'Enterで送信',
'chat.chatInput.actions.enterToSendHint': '変更すると、すべての環境でEnterとShift+Enterの動作を制御します。変更するまでは、各環境の既存の動作が維持されます。',
'chat.chatInput.actions.queueMessageAria': 'メッセージをキュー', 'chat.chatInput.actions.queueMessageAria': 'メッセージをキュー',
'chat.chatInput.actions.stopGeneratingAria': '生成を停止', 'chat.chatInput.actions.stopGeneratingAria': '生成を停止',
'chat.chatInput.focusMode.toggleAria': 'フォーカスモードの切り替え', 'chat.chatInput.focusMode.toggleAria': 'フォーカスモードの切り替え',
+2
View File
@@ -2322,6 +2322,8 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.actions.linkGithubPr': 'GitHub PR 연결', 'chat.chatInput.actions.linkGithubPr': 'GitHub PR 연결',
'chat.chatInput.actions.modelAgentSettings': '모델 및 에이전트 설정', 'chat.chatInput.actions.modelAgentSettings': '모델 및 에이전트 설정',
'chat.chatInput.actions.sendMessageAria': '보내기 메시지', 'chat.chatInput.actions.sendMessageAria': '보내기 메시지',
'chat.chatInput.actions.enterToSend': 'Enter로 전송',
'chat.chatInput.actions.enterToSendHint': '변경하면 모든 환경에서 Enter와 Shift+Enter의 동작을 제어합니다. 변경하기 전에는 각 환경의 기존 동작이 유지됩니다.',
'chat.chatInput.actions.queueMessageAria': '메시지 대기열에 추가', 'chat.chatInput.actions.queueMessageAria': '메시지 대기열에 추가',
'chat.chatInput.actions.stopGeneratingAria': '생성 중지', 'chat.chatInput.actions.stopGeneratingAria': '생성 중지',
'chat.chatInput.focusMode.toggleAria': '집중 모드 전환', 'chat.chatInput.focusMode.toggleAria': '집중 모드 전환',
+2
View File
@@ -1255,6 +1255,8 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.actions.modelAgentSettings': 'Model and agent settings', 'chat.chatInput.actions.modelAgentSettings': 'Model and agent settings',
'chat.chatInput.actions.queueMessageAria': 'Queue message', 'chat.chatInput.actions.queueMessageAria': 'Queue message',
'chat.chatInput.actions.sendMessageAria': 'Send message', 'chat.chatInput.actions.sendMessageAria': 'Send message',
'chat.chatInput.actions.enterToSend': 'Enter wysyła',
'chat.chatInput.actions.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.',
'chat.chatInput.actions.stopGeneratingAria': 'Stop generating', 'chat.chatInput.actions.stopGeneratingAria': 'Stop generating',
'chat.chatInput.branch': 'Gałąź', 'chat.chatInput.branch': 'Gałąź',
'chat.chatInput.draftPicker.projectTitle': 'Projekt', 'chat.chatInput.draftPicker.projectTitle': 'Projekt',
@@ -2300,6 +2300,8 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.actions.linkGithubPr": "Vincular PR de GitHub", "chat.chatInput.actions.linkGithubPr": "Vincular PR de GitHub",
"chat.chatInput.actions.modelAgentSettings": "Configurações de modelo e agente", "chat.chatInput.actions.modelAgentSettings": "Configurações de modelo e agente",
"chat.chatInput.actions.sendMessageAria": "Enviar mensagem", "chat.chatInput.actions.sendMessageAria": "Enviar mensagem",
'chat.chatInput.actions.enterToSend': 'Enter envia',
'chat.chatInput.actions.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.',
"chat.chatInput.actions.queueMessageAria": "Colocar mensagem na fila", "chat.chatInput.actions.queueMessageAria": "Colocar mensagem na fila",
"chat.chatInput.actions.stopGeneratingAria": "Parar geração", "chat.chatInput.actions.stopGeneratingAria": "Parar geração",
"chat.chatInput.focusMode.toggleAria": "Ativar ou desativar modo de foco", "chat.chatInput.focusMode.toggleAria": "Ativar ou desativar modo de foco",
+2
View File
@@ -2263,6 +2263,8 @@ export const dict = {
'chat.chatInput.actions.linkGithubPr': 'GitHub PR\'yi bağla', 'chat.chatInput.actions.linkGithubPr': 'GitHub PR\'yi bağla',
'chat.chatInput.actions.modelAgentSettings': 'Model ve agent ayarları', 'chat.chatInput.actions.modelAgentSettings': 'Model ve agent ayarları',
'chat.chatInput.actions.sendMessageAria': 'Mesaj gönder', 'chat.chatInput.actions.sendMessageAria': 'Mesaj gönder',
'chat.chatInput.actions.enterToSend': 'Enter gönderir',
'chat.chatInput.actions.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.',
'chat.chatInput.actions.queueMessageAria': 'Mesajı kuyruğa ekle', 'chat.chatInput.actions.queueMessageAria': 'Mesajı kuyruğa ekle',
'chat.chatInput.actions.stopGeneratingAria': 'Üretmeyi durdur', 'chat.chatInput.actions.stopGeneratingAria': 'Üretmeyi durdur',
'chat.chatInput.focusMode.toggleAria': 'Odak modunu aç/kapat', 'chat.chatInput.focusMode.toggleAria': 'Odak modunu aç/kapat',
+2
View File
@@ -2300,6 +2300,8 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.actions.linkGithubPr": "Пов’язати GitHub PR", "chat.chatInput.actions.linkGithubPr": "Пов’язати GitHub PR",
"chat.chatInput.actions.modelAgentSettings": "Параметри моделі та агента", "chat.chatInput.actions.modelAgentSettings": "Параметри моделі та агента",
"chat.chatInput.actions.sendMessageAria": "Надіслати повідомлення", "chat.chatInput.actions.sendMessageAria": "Надіслати повідомлення",
'chat.chatInput.actions.enterToSend': 'Enter надсилає',
'chat.chatInput.actions.enterToSendHint': 'Після зміни цей параметр керує поведінкою Enter і Shift+Enter на всіх поверхнях. До цього кожна поверхня зберігає свою поточну поведінку.',
"chat.chatInput.actions.queueMessageAria": "Поставити повідомлення в чергу", "chat.chatInput.actions.queueMessageAria": "Поставити повідомлення в чергу",
"chat.chatInput.actions.stopGeneratingAria": "Припинити генерацію", "chat.chatInput.actions.stopGeneratingAria": "Припинити генерацію",
"chat.chatInput.focusMode.toggleAria": "Перемкнути режим фокусування", "chat.chatInput.focusMode.toggleAria": "Перемкнути режим фокусування",
@@ -2288,6 +2288,8 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.actions.linkGithubPr': '关联 GitHub PR', 'chat.chatInput.actions.linkGithubPr': '关联 GitHub PR',
'chat.chatInput.actions.modelAgentSettings': '模型与智能体设置', 'chat.chatInput.actions.modelAgentSettings': '模型与智能体设置',
'chat.chatInput.actions.sendMessageAria': '发送消息', 'chat.chatInput.actions.sendMessageAria': '发送消息',
'chat.chatInput.actions.enterToSend': 'Enter 发送',
'chat.chatInput.actions.enterToSendHint': '更改后,此设置会控制所有界面中的 Enter 和 Shift+Enter。更改前,各界面保持现有行为。',
'chat.chatInput.actions.queueMessageAria': '将消息加入队列', 'chat.chatInput.actions.queueMessageAria': '将消息加入队列',
'chat.chatInput.actions.stopGeneratingAria': '停止生成', 'chat.chatInput.actions.stopGeneratingAria': '停止生成',
'chat.chatInput.focusMode.toggleAria': '切换专注模式', 'chat.chatInput.focusMode.toggleAria': '切换专注模式',
@@ -2292,6 +2292,8 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.actions.linkGithubPr': '關聯 GitHub PR', 'chat.chatInput.actions.linkGithubPr': '關聯 GitHub PR',
'chat.chatInput.actions.modelAgentSettings': '模型與 Agent 設定', 'chat.chatInput.actions.modelAgentSettings': '模型與 Agent 設定',
'chat.chatInput.actions.sendMessageAria': '傳送訊息', 'chat.chatInput.actions.sendMessageAria': '傳送訊息',
'chat.chatInput.actions.enterToSend': 'Enter 傳送',
'chat.chatInput.actions.enterToSendHint': '變更後,此設定會控制所有介面中的 Enter 與 Shift+Enter。變更前,各介面會維持現有行為。',
'chat.chatInput.actions.queueMessageAria': '將訊息加入佇列', 'chat.chatInput.actions.queueMessageAria': '將訊息加入佇列',
'chat.chatInput.actions.stopGeneratingAria': '停止生成', 'chat.chatInput.actions.stopGeneratingAria': '停止生成',
'chat.chatInput.focusMode.toggleAria': '切換專注模式', 'chat.chatInput.focusMode.toggleAria': '切換專注模式',
+10
View File
@@ -261,6 +261,16 @@ describe('updateDesktopSettings', () => {
} }
}); });
test('applies enterToSendConfigured when it arrives without enterToSend', async () => {
useUIStore.setState({ enterToSend: false, enterToSendConfigured: false });
registerSettingsSave(async () => ({ enterToSendConfigured: true }));
await updateDesktopSettings({ enterToSendConfigured: true });
expect(useUIStore.getState().enterToSend).toBe(false);
expect(useUIStore.getState().enterToSendConfigured).toBe(true);
});
test('reports an error without applying a malformed fallback settings response', async () => { test('reports an error without applying a malformed fallback settings response', async () => {
const previousFetch = globalThis.fetch; const previousFetch = globalThis.fetch;
const fallbackFetch: typeof fetch = async () => new Response(JSON.stringify('ok'), { const fallbackFetch: typeof fetch = async () => new Response(JSON.stringify('ok'), {
+18
View File
@@ -574,6 +574,8 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS
summaryLength: defaults.summaryLength, summaryLength: defaults.summaryLength,
maxLastMessageLength: defaults.maxLastMessageLength, maxLastMessageLength: defaults.maxLastMessageLength,
inputSpellcheckEnabled: defaults.inputSpellcheckEnabled, inputSpellcheckEnabled: defaults.inputSpellcheckEnabled,
enterToSend: defaults.enterToSend,
enterToSendConfigured: defaults.enterToSendConfigured,
showOpenCodeUpdateNotifications: defaults.showOpenCodeUpdateNotifications, showOpenCodeUpdateNotifications: defaults.showOpenCodeUpdateNotifications,
agentControlToolEnabled: defaults.agentControlToolEnabled, agentControlToolEnabled: defaults.agentControlToolEnabled,
agentWebToolEnabled: defaults.agentWebToolEnabled, agentWebToolEnabled: defaults.agentWebToolEnabled,
@@ -745,6 +747,16 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
if (typeof settings.inputSpellcheckEnabled === 'boolean' && settings.inputSpellcheckEnabled !== store.inputSpellcheckEnabled) { if (typeof settings.inputSpellcheckEnabled === 'boolean' && settings.inputSpellcheckEnabled !== store.inputSpellcheckEnabled) {
store.setInputSpellcheckEnabled(settings.inputSpellcheckEnabled); store.setInputSpellcheckEnabled(settings.inputSpellcheckEnabled);
} }
if (settings.enterToSend === true || settings.enterToSend === false) {
if (settings.enterToSend !== store.enterToSend) {
store.setEnterToSend(settings.enterToSend);
}
}
if (settings.enterToSendConfigured === true || settings.enterToSendConfigured === false) {
if (settings.enterToSendConfigured !== store.enterToSendConfigured) {
store.setEnterToSendConfigured(settings.enterToSendConfigured);
}
}
if ( if (
typeof settings.showOpenCodeUpdateNotifications === 'boolean' typeof settings.showOpenCodeUpdateNotifications === 'boolean'
&& settings.showOpenCodeUpdateNotifications !== store.showOpenCodeUpdateNotifications && settings.showOpenCodeUpdateNotifications !== store.showOpenCodeUpdateNotifications
@@ -1457,6 +1469,12 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.inputSpellcheckEnabled === 'boolean') { if (typeof candidate.inputSpellcheckEnabled === 'boolean') {
result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled; result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled;
} }
if (candidate.enterToSend === true || candidate.enterToSend === false) {
result.enterToSend = candidate.enterToSend;
}
if (candidate.enterToSendConfigured === true || candidate.enterToSendConfigured === false) {
result.enterToSendConfigured = candidate.enterToSendConfigured;
}
if (typeof candidate.showOpenCodeUpdateNotifications === 'boolean') { if (typeof candidate.showOpenCodeUpdateNotifications === 'boolean') {
result.showOpenCodeUpdateNotifications = candidate.showOpenCodeUpdateNotifications; result.showOpenCodeUpdateNotifications = candidate.showOpenCodeUpdateNotifications;
} }
+6
View File
@@ -367,6 +367,12 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
descriptionKey: 'settings.openchamber.visual.field.largeTextPasteHint', descriptionKey: 'settings.openchamber.visual.field.largeTextPasteHint',
keywords: ['paste', 'clipboard', 'attachment', 'large', 'text', 'file'], keywords: ['paste', 'clipboard', 'attachment', 'large', 'text', 'file'],
}, },
{
id: 'chat.enter-to-send',
page: 'chat',
titleKey: 'chat.chatInput.actions.enterToSend',
keywords: ['enter', 'shift enter', 'send', 'newline'],
},
{ {
id: 'sessions.default-model', id: 'sessions.default-model',
page: 'sessions', page: 'sessions',
+14
View File
@@ -943,6 +943,8 @@ interface UIStore {
projectContextTab: string; projectContextTab: string;
inputSpellcheckEnabled: boolean; inputSpellcheckEnabled: boolean;
largeTextPasteBehavior: LargeTextPasteBehavior; largeTextPasteBehavior: LargeTextPasteBehavior;
enterToSend: boolean;
enterToSendConfigured: boolean;
wideChatLayoutEnabled: boolean; wideChatLayoutEnabled: boolean;
codeBlockLineWrap: boolean; codeBlockLineWrap: boolean;
showToolFileIcons: boolean; showToolFileIcons: boolean;
@@ -1128,6 +1130,8 @@ interface UIStore {
setProjectContextTab: (value: string) => void; setProjectContextTab: (value: string) => void;
setInputSpellcheckEnabled: (value: boolean) => void; setInputSpellcheckEnabled: (value: boolean) => void;
setLargeTextPasteBehavior: (value: LargeTextPasteBehavior) => void; setLargeTextPasteBehavior: (value: LargeTextPasteBehavior) => void;
setEnterToSend: (value: boolean) => void;
setEnterToSendConfigured: (value: boolean) => void;
setWideChatLayoutEnabled: (value: boolean) => void; setWideChatLayoutEnabled: (value: boolean) => void;
setCodeBlockLineWrap: (value: boolean) => void; setCodeBlockLineWrap: (value: boolean) => void;
setShowToolFileIcons: (value: boolean) => void; setShowToolFileIcons: (value: boolean) => void;
@@ -1298,6 +1302,8 @@ export const useUIStore = create<UIStore>()(
projectContextTab: 'notes', projectContextTab: 'notes',
inputSpellcheckEnabled: false, inputSpellcheckEnabled: false,
largeTextPasteBehavior: DEFAULT_LARGE_TEXT_PASTE_BEHAVIOR, largeTextPasteBehavior: DEFAULT_LARGE_TEXT_PASTE_BEHAVIOR,
enterToSend: false,
enterToSendConfigured: false,
wideChatLayoutEnabled: false, wideChatLayoutEnabled: false,
codeBlockLineWrap: true, codeBlockLineWrap: true,
showToolFileIcons: true, showToolFileIcons: true,
@@ -2599,6 +2605,12 @@ export const useUIStore = create<UIStore>()(
setLargeTextPasteBehavior: (value) => { setLargeTextPasteBehavior: (value) => {
set({ largeTextPasteBehavior: normalizeLargeTextPasteBehavior(value) }); set({ largeTextPasteBehavior: normalizeLargeTextPasteBehavior(value) });
}, },
setEnterToSend: (value) => {
set({ enterToSend: value });
},
setEnterToSendConfigured: (value) => {
set({ enterToSendConfigured: value });
},
setWideChatLayoutEnabled: (value) => { setWideChatLayoutEnabled: (value) => {
set({ wideChatLayoutEnabled: value }); set({ wideChatLayoutEnabled: value });
}, },
@@ -3026,6 +3038,8 @@ export const useUIStore = create<UIStore>()(
projectContextSidebarWidth: state.projectContextSidebarWidth, projectContextSidebarWidth: state.projectContextSidebarWidth,
inputSpellcheckEnabled: state.inputSpellcheckEnabled, inputSpellcheckEnabled: state.inputSpellcheckEnabled,
largeTextPasteBehavior: state.largeTextPasteBehavior, largeTextPasteBehavior: state.largeTextPasteBehavior,
enterToSend: state.enterToSend,
enterToSendConfigured: state.enterToSendConfigured,
wideChatLayoutEnabled: state.wideChatLayoutEnabled, wideChatLayoutEnabled: state.wideChatLayoutEnabled,
codeBlockLineWrap: state.codeBlockLineWrap, codeBlockLineWrap: state.codeBlockLineWrap,
showToolFileIcons: state.showToolFileIcons, showToolFileIcons: state.showToolFileIcons,
+1
View File
@@ -66,6 +66,7 @@ The webview build emits each worker as one self-contained file. VS Code webviews
- `bridge-settings-runtime.ts` - `bridge-settings-runtime.ts`
- Settings read/write and OpenCode skills discovery via API for bridge consumers. - Settings read/write and OpenCode skills discovery via API for bridge consumers.
- `settings-changes.ts` validates Enter preferences independently before writes; invalid values are omitted without dropping unrelated changes.
- `bridge-system-runtime.ts` - `bridge-system-runtime.ts`
- System/editor/provider/quota/notification/update-check message handlers. - System/editor/provider/quota/notification/update-check message handlers.
@@ -5,6 +5,7 @@ import * as path from 'path';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { BUILT_IN_SKILL_LOCATION, type DiscoveredSkill, type SkillScope, type SkillSource } from './opencodeConfig'; import { BUILT_IN_SKILL_LOCATION, type DiscoveredSkill, type SkillScope, type SkillSource } from './opencodeConfig';
import type { BridgeContext } from './bridge'; import type { BridgeContext } from './bridge';
import { enterSettingsSchema } from './settings-changes';
const SETTINGS_KEY = 'openchamber.settings'; const SETTINGS_KEY = 'openchamber.settings';
const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
@@ -292,6 +293,14 @@ export const readSettings = (ctx?: BridgeContext): Record<string, unknown> => {
export const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeContext): Promise<Record<string, unknown>> => { export const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeContext): Promise<Record<string, unknown>> => {
const current = readSettings(ctx); const current = readSettings(ctx);
const restChanges = stripDerived({ ...(changes || {}) }); const restChanges = stripDerived({ ...(changes || {}) });
const enterSettings = enterSettingsSchema.parse(restChanges);
for (const key of ['enterToSend', 'enterToSendConfigured'] as const) {
if (enterSettings[key] === undefined) {
delete restChanges[key];
} else {
restChanges[key] = enterSettings[key];
}
}
const keysToClear = new Set<string>(); const keysToClear = new Set<string>();
@@ -0,0 +1,19 @@
import { describe, expect, test } from 'bun:test';
import { enterSettingsSchema } from './settings-changes';
describe('VS Code Enter settings validation', () => {
test('preserves both explicit choices', () => {
expect(enterSettingsSchema.parse({ enterToSend: true, enterToSendConfigured: false }))
.toEqual({ enterToSend: true, enterToSendConfigured: false });
expect(enterSettingsSchema.parse({ enterToSend: false, enterToSendConfigured: true }))
.toEqual({ enterToSend: false, enterToSendConfigured: true });
});
test('omits invalid fields independently', () => {
expect(enterSettingsSchema.parse({ enterToSend: 'true', enterToSendConfigured: true }))
.toEqual({ enterToSend: undefined, enterToSendConfigured: true });
expect(enterSettingsSchema.parse({ enterToSend: false, enterToSendConfigured: 1 }))
.toEqual({ enterToSend: false, enterToSendConfigured: undefined });
expect(enterSettingsSchema.parse({})).toEqual({});
});
});
+8
View File
@@ -0,0 +1,8 @@
import { z } from 'zod';
// Invalid fields are omitted independently so one malformed preference cannot
// discard the other preference or any unrelated settings in the same write.
export const enterSettingsSchema = z.object({
enterToSend: z.boolean().optional().catch(undefined),
enterToSendConfigured: z.boolean().optional().catch(undefined),
});
@@ -519,6 +519,12 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.inputSpellcheckEnabled === 'boolean') { if (typeof candidate.inputSpellcheckEnabled === 'boolean') {
result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled; result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled;
} }
if (candidate.enterToSend === true || candidate.enterToSend === false) {
result.enterToSend = candidate.enterToSend;
}
if (candidate.enterToSendConfigured === true || candidate.enterToSendConfigured === false) {
result.enterToSendConfigured = candidate.enterToSendConfigured;
}
if (typeof candidate.showOpenCodeUpdateNotifications === 'boolean') { if (typeof candidate.showOpenCodeUpdateNotifications === 'boolean') {
result.showOpenCodeUpdateNotifications = candidate.showOpenCodeUpdateNotifications; result.showOpenCodeUpdateNotifications = candidate.showOpenCodeUpdateNotifications;
} }
@@ -66,6 +66,14 @@ describe('settings helpers', () => {
expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: 'false' })).toEqual({}); expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: 'false' })).toEqual({});
}); });
it('sanitizes both Enter settings independently', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ enterToSend: true })).toEqual({ enterToSend: true });
expect(helpers.sanitizeSettingsUpdate({ enterToSendConfigured: false })).toEqual({ enterToSendConfigured: false });
expect(helpers.sanitizeSettingsUpdate({ enterToSend: 'true', enterToSendConfigured: 1 })).toEqual({});
});
it('sanitizes shared sidebar display preferences', () => { it('sanitizes shared sidebar display preferences', () => {
const helpers = createTestHelpers(); const helpers = createTestHelpers();
@@ -325,6 +333,27 @@ describe('settings helpers', () => {
}); });
}); });
it('accepts and rejects invalid Enter-to-send preference values', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ enterToSend: true, enterToSendConfigured: true })).toEqual({
enterToSend: true,
enterToSendConfigured: true,
});
expect(helpers.sanitizeSettingsUpdate({ enterToSend: false, enterToSendConfigured: true })).toEqual({
enterToSend: false,
enterToSendConfigured: true,
});
expect(helpers.sanitizeSettingsUpdate({ enterToSend: false, enterToSendConfigured: false })).toEqual({
enterToSend: false,
enterToSendConfigured: false,
});
expect(helpers.sanitizeSettingsUpdate({ enterToSend: 'true' })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ enterToSend: 1 })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ enterToSendConfigured: 'true' })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ enterToSendConfigured: 1 })).toEqual({});
});
it('accepts dismissed OpenCode update toast version as a persisted shared setting', () => { it('accepts dismissed OpenCode update toast version as a persisted shared setting', () => {
const helpers = createTestHelpers(); const helpers = createTestHelpers();