From 62f1aa71e88177b7c91f48ea6ff7163ebc1074fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 27 May 2026 23:03:18 +0800 Subject: [PATCH] fix: prevent accidental desktop chat sends (#631) Desktop users have different multiline habits, and issue #631 calls out that Enter-only submission is too easy to trigger. This adds a persisted General setting that keeps Enter-send as the default while allowing Ctrl/Cmd+Enter submission for users who want plain Enter to insert a newline. Constraint: Preserve existing Enter-to-send behavior as the default Rejected: Change the global default to Ctrl/Cmd+Enter | would disrupt existing users Confidence: high Scope-risk: moderate Directive: Keep active and empty session composers using the shared send shortcut helper Tested: cd desktop && bun run test -- --run src/components/chat/ChatInput.test.tsx src/__tests__/generalSettings.test.tsx src/stores/settingsStore.test.ts Tested: bun run check:desktop Tested: cd desktop && bun run lint Tested: Browser smoke on http://127.0.0.1:5174/?serverUrl=http%3A%2F%2F127.0.0.1%3A3456 Not-tested: Packaged Tauri app runtime Related: #631 --- .../src/__tests__/generalSettings.test.tsx | 18 ++++++- .../src/components/chat/ChatInput.test.tsx | 30 +++++++++++ desktop/src/components/chat/ChatInput.tsx | 10 +++- desktop/src/components/chat/sendShortcut.ts | 11 ++++ desktop/src/i18n/locales/en.ts | 6 +++ desktop/src/i18n/locales/zh.ts | 6 +++ desktop/src/pages/EmptySession.tsx | 7 ++- desktop/src/pages/Settings.tsx | 42 +++++++++++++++- desktop/src/stores/settingsStore.test.ts | 50 +++++++++++++++++++ desktop/src/stores/settingsStore.ts | 21 ++++++++ desktop/src/types/settings.ts | 3 ++ 11 files changed, 198 insertions(+), 6 deletions(-) create mode 100644 desktop/src/components/chat/sendShortcut.ts diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 4c513d85..119e89c7 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -8,7 +8,7 @@ import { useUIStore } from '../stores/uiStore' import { useUpdateStore } from '../stores/updateStore' import type { SavedProvider } from '../types/provider' import type { ProviderPreset } from '../types/providerPreset' -import type { AppMode, ThemeMode, UpdateProxySettings } from '../types/settings' +import type { AppMode, ChatSendBehavior, ThemeMode, UpdateProxySettings } from '../types/settings' const MOCK_DELETE_PROVIDER = vi.fn() const MOCK_GET_SETTINGS = vi.fn() @@ -166,6 +166,7 @@ describe('Settings > General tab', () => { thinkingEnabled: true, skipWebFetchPreflight: true, desktopNotificationsEnabled: true, + chatSendBehavior: 'enter', responseLanguage: '', uiZoom: 1, webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, @@ -193,6 +194,9 @@ describe('Settings > General tab', () => { setDesktopNotificationsEnabled: vi.fn().mockImplementation(async (enabled: boolean) => { useSettingsStore.setState({ desktopNotificationsEnabled: enabled }) }), + setChatSendBehavior: vi.fn().mockImplementation(async (chatSendBehavior: ChatSendBehavior) => { + useSettingsStore.setState({ chatSendBehavior }) + }), setResponseLanguage: vi.fn().mockImplementation(async (language: string) => { useSettingsStore.setState({ responseLanguage: language }) }), @@ -328,6 +332,18 @@ describe('Settings > General tab', () => { expect((networkHeading.compareDocumentPosition(webFetchHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) }) + it('lets users choose Ctrl or Command Enter as the chat send shortcut', async () => { + render() + + fireEvent.click(screen.getByText('General')) + fireEvent.click(screen.getByRole('button', { name: /Ctrl\/Cmd\+Enter sends/i })) + + await waitFor(() => { + expect(useSettingsStore.getState().setChatSendBehavior).toHaveBeenCalledWith('modifierEnter') + }) + expect(screen.getByRole('button', { name: /Ctrl\/Cmd\+Enter sends/i })).toHaveAttribute('aria-pressed', 'true') + }) + it('saves provider network timeout and manual proxy from General settings', async () => { render() diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index 8a58957e..c0236c6e 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -920,6 +920,36 @@ describe('ChatInput file mentions', () => { expect(input).not.toHaveClass('pb-14') }) + it('uses Ctrl or Command Enter to send when that composer preference is selected', async () => { + useSettingsStore.setState({ + chatSendBehavior: 'modifierEnter', + }) + + render() + + await waitFor(() => { + expect(mocks.getGitInfo).toHaveBeenCalledWith(sessionId) + }) + + const input = screen.getByRole('textbox') as HTMLTextAreaElement + fireEvent.change(input, { + target: { + value: 'avoid accidental sends', + selectionStart: 'avoid accidental sends'.length, + }, + }) + + fireEvent.keyDown(input, { key: 'Enter' }) + expect(mocks.wsSend).not.toHaveBeenCalled() + + fireEvent.keyDown(input, { key: 'Enter', ctrlKey: true }) + expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, { + type: 'user_message', + content: 'avoid accidental sends', + attachments: [], + }) + }) + it('prioritizes active-session slash commands by command name when filtering', async () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index d3996d38..2653419e 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -39,6 +39,7 @@ import { type ComposerAttachment, } from '../../lib/composerAttachments' import { useComposerFileDrop } from './useComposerFileDrop' +import { shouldSubmitOnEnter } from './sendShortcut' type GitInfo = SessionGitInfo @@ -131,6 +132,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro activeTabId ? state.selections[activeTabId] : undefined, ) const currentModel = useSettingsStore((state) => state.currentModel) + const chatSendBehavior = useSettingsStore((state) => state.chatSendBehavior) const runtimeSelectionKey = runtimeSelection ? `${runtimeSelection.providerId ?? 'official'}:${runtimeSelection.modelId}` : undefined @@ -694,7 +696,11 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro return } if (event.key === 'Enter') { - if (exactSlashCommand && slashFilter.trim().toLowerCase() === exactSlashCommand.name.toLowerCase()) { + if ( + exactSlashCommand && + slashFilter.trim().toLowerCase() === exactSlashCommand.name.toLowerCase() && + shouldSubmitOnEnter(event, chatSendBehavior) + ) { event.preventDefault() handleSubmit() return @@ -717,7 +723,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro } } - if (event.key === 'Enter' && !event.shiftKey) { + if (shouldSubmitOnEnter(event, chatSendBehavior)) { event.preventDefault() handleSubmit() } diff --git a/desktop/src/components/chat/sendShortcut.ts b/desktop/src/components/chat/sendShortcut.ts new file mode 100644 index 00000000..cf7463a3 --- /dev/null +++ b/desktop/src/components/chat/sendShortcut.ts @@ -0,0 +1,11 @@ +import type { ChatSendBehavior } from '../../types/settings' +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' + +export function shouldSubmitOnEnter( + event: Pick, + behavior: ChatSendBehavior, +): boolean { + if (event.key !== 'Enter' || event.shiftKey) return false + if (behavior === 'modifierEnter') return event.ctrlKey || event.metaKey + return !event.ctrlKey && !event.metaKey +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 2e97a4dc..bbda80d4 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -862,6 +862,12 @@ export const en = { 'settings.general.notificationsOpenSettings': 'Open Settings', 'settings.general.notificationsTestTitle': 'Claude Code Haha notifications are enabled', 'settings.general.notificationsTestBody': 'Permission prompts and completed agent replies will now use system notifications.', + 'settings.general.chatSendBehaviorTitle': 'Message Sending', + 'settings.general.chatSendBehaviorDescription': 'Choose how the desktop chat composer submits messages.', + 'settings.general.chatSendBehaviorEnter': 'Enter sends', + 'settings.general.chatSendBehaviorEnterDescription': 'Shift+Enter inserts a new line.', + 'settings.general.chatSendBehaviorModifier': 'Ctrl/Cmd+Enter sends', + 'settings.general.chatSendBehaviorModifierDescription': 'Enter and Shift+Enter insert new lines.', 'settings.general.h5AccessTitle': 'H5 Access', 'settings.general.h5AccessDescription': 'Expose the desktop H5 app on your local network. Phones connect with a QR link that includes a short-lived H5 token.', 'settings.general.h5AccessEnabled': 'Enable H5 access', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 05a4ee33..4d589fa9 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -864,6 +864,12 @@ export const zh: Record = { 'settings.general.notificationsOpenSettings': '打开系统设置', 'settings.general.notificationsTestTitle': 'Claude Code Haha 通知已启用', 'settings.general.notificationsTestBody': '后续授权确认和 Agent 回复完成都会通过系统通知提醒。', + 'settings.general.chatSendBehaviorTitle': '消息发送方式', + 'settings.general.chatSendBehaviorDescription': '选择桌面端对话输入框如何发送消息。', + 'settings.general.chatSendBehaviorEnter': 'Enter 发送', + 'settings.general.chatSendBehaviorEnterDescription': 'Shift+Enter 换行。', + 'settings.general.chatSendBehaviorModifier': 'Ctrl/Cmd+Enter 发送', + 'settings.general.chatSendBehaviorModifierDescription': 'Enter 和 Shift+Enter 都会换行。', 'settings.general.h5AccessTitle': 'H5 访问', 'settings.general.h5AccessDescription': '在局域网内暴露桌面端 H5 应用。手机通过带 H5 token 的二维码快速连接。', 'settings.general.h5AccessEnabled': '启用 H5 访问', diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index ee57aa14..410cb43b 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -25,6 +25,7 @@ import { type ComposerAttachment, } from '../lib/composerAttachments' import { useComposerFileDrop } from '../components/chat/useComposerFileDrop' +import { shouldSubmitOnEnter } from '../components/chat/sendShortcut' import { getLocalizedFallbackCommands, filterSlashCommands, @@ -108,6 +109,7 @@ export function EmptySession() { const setActiveView = useUIStore((state) => state.setActiveView) const addToast = useUIStore((state) => state.addToast) const currentModel = useSettingsStore((state) => state.currentModel) + const chatSendBehavior = useSettingsStore((state) => state.chatSendBehavior) const lastPluginReloadSummary = usePluginStore((state) => state.lastReloadSummary) const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY]) const draftRuntimeSelectionKey = draftRuntimeSelection @@ -380,7 +382,8 @@ export function EmptySession() { if ( event.key === 'Enter' && exactSlashCommand && - slashFilter.trim().toLowerCase() === exactSlashCommand.name.toLowerCase() + slashFilter.trim().toLowerCase() === exactSlashCommand.name.toLowerCase() && + shouldSubmitOnEnter(event, chatSendBehavior) ) { event.preventDefault() void handleSubmit() @@ -398,7 +401,7 @@ export function EmptySession() { } } - if (event.key === 'Enter' && !event.shiftKey) { + if (shouldSubmitOnEnter(event, chatSendBehavior)) { event.preventDefault() handleSubmit() } diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index a5ac0cb0..2ef29416 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -9,7 +9,7 @@ import { ConfirmDialog } from '../components/shared/ConfirmDialog' import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' import { Dropdown } from '../components/shared/Dropdown' -import type { PermissionMode, EffortLevel, ThemeMode, UpdateProxyMode, NetworkProxyMode, WebSearchMode, AppMode } from '../types/settings' +import type { PermissionMode, EffortLevel, ThemeMode, UpdateProxyMode, NetworkProxyMode, WebSearchMode, AppMode, ChatSendBehavior } from '../types/settings' import type { Locale } from '../i18n' import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider' import type { ProviderPreset } from '../types/providerPreset' @@ -1489,6 +1489,8 @@ function GeneralSettings() { setLocale, theme, setTheme, + chatSendBehavior, + setChatSendBehavior, skipWebFetchPreflight, setSkipWebFetchPreflight, desktopNotificationsEnabled, @@ -1632,6 +1634,19 @@ function GeneralSettings() { }, ] + const CHAT_SEND_BEHAVIORS: Array<{ value: ChatSendBehavior; label: string; description: string }> = [ + { + value: 'enter', + label: t('settings.general.chatSendBehaviorEnter'), + description: t('settings.general.chatSendBehaviorEnterDescription'), + }, + { + value: 'modifierEnter', + label: t('settings.general.chatSendBehaviorModifier'), + description: t('settings.general.chatSendBehaviorModifierDescription'), + }, + ] + const notificationStatusLabel: Record = { granted: t('settings.general.notificationsStatusGranted'), denied: t('settings.general.notificationsStatusDenied'), @@ -2088,6 +2103,31 @@ function GeneralSettings() { +
+

{t('settings.general.chatSendBehaviorTitle')}

+

{t('settings.general.chatSendBehaviorDescription')}

+
+ {CHAT_SEND_BEHAVIORS.map((option) => ( + + ))} +
+
+ {uiZoomSection}
diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts index 3dc5931b..598b769d 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -279,6 +279,56 @@ describe('settingsStore network persistence', () => { }, }) }) + + it('persists the chat send behavior preference and normalizes invalid values', async () => { + const updateUser = vi.fn().mockResolvedValue({}) + vi.doMock('../api/settings', () => ({ + settingsApi: { + getUser: vi.fn().mockResolvedValue({ + chatSendBehavior: 'unexpected', + }), + updateUser, + getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }), + setPermissionMode: vi.fn(), + getCliLauncherStatus: vi.fn(), + }, + })) + vi.doMock('../api/models', () => ({ + modelsApi: { + list: vi.fn().mockResolvedValue({ models: [] }), + getCurrent: vi.fn().mockResolvedValue({ model: null }), + setCurrent: vi.fn(), + getEffort: vi.fn().mockResolvedValue({ level: 'medium' }), + setEffort: vi.fn(), + }, + })) + vi.doMock('../api/h5Access', () => ({ + h5AccessApi: { + get: vi.fn().mockResolvedValue({ + settings: { + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: null, + }, + }), + enable: vi.fn(), + disable: vi.fn(), + regenerate: vi.fn(), + update: vi.fn(), + }, + })) + + const { useSettingsStore } = await import('./settingsStore') + + await useSettingsStore.getState().fetchAll() + expect(useSettingsStore.getState().chatSendBehavior).toBe('enter') + + await useSettingsStore.getState().setChatSendBehavior('modifierEnter') + + expect(useSettingsStore.getState().chatSendBehavior).toBe('modifierEnter') + expect(updateUser).toHaveBeenCalledWith({ chatSendBehavior: 'modifierEnter' }) + }) }) describe('settingsStore app mode', () => { diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index 687c701b..e7edf80c 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -7,6 +7,7 @@ import { isThemeMode, type AppMode, type AppModeConfig, + type ChatSendBehavior, type DesktopTerminalSettings, type DesktopTerminalStartupShell, type H5AccessDiagnostics, @@ -57,6 +58,7 @@ type SettingsStore = { activeProviderName: string | null locale: Locale theme: ThemeMode + chatSendBehavior: ChatSendBehavior skipWebFetchPreflight: boolean desktopNotificationsEnabled: boolean desktopTerminal: DesktopTerminalSettings @@ -82,6 +84,7 @@ type SettingsStore = { setThinkingEnabled: (enabled: boolean) => Promise setLocale: (locale: Locale) => void setTheme: (theme: ThemeMode) => Promise + setChatSendBehavior: (behavior: ChatSendBehavior) => Promise setSkipWebFetchPreflight: (enabled: boolean) => Promise setDesktopNotificationsEnabled: (enabled: boolean) => Promise setDesktopTerminal: (settings: DesktopTerminalSettings) => Promise @@ -139,6 +142,7 @@ export const useSettingsStore = create((set, get) => ({ activeProviderName: null, locale: getStoredLocale(), theme: useUIStore.getState().theme, + chatSendBehavior: 'enter', skipWebFetchPreflight: true, desktopNotificationsEnabled: false, desktopTerminal: DEFAULT_DESKTOP_TERMINAL_SETTINGS, @@ -189,6 +193,7 @@ export const useSettingsStore = create((set, get) => ({ effortLevel: level, thinkingEnabled: userSettings.alwaysThinkingEnabled !== false, theme, + chatSendBehavior: normalizeChatSendBehavior(userSettings.chatSendBehavior), skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false, desktopNotificationsEnabled: userSettings.desktopNotificationsEnabled === true, desktopTerminal: normalizeDesktopTerminalSettings(userSettings.desktopTerminal), @@ -272,6 +277,18 @@ export const useSettingsStore = create((set, get) => ({ } }, + setChatSendBehavior: async (behavior) => { + const prev = get().chatSendBehavior + const next = normalizeChatSendBehavior(behavior) + set({ chatSendBehavior: next }) + try { + await settingsApi.updateUser({ chatSendBehavior: next }) + } catch (error) { + set({ chatSendBehavior: prev }) + throw error + } + }, + setSkipWebFetchPreflight: async (enabled) => { const prev = get().skipWebFetchPreflight set({ skipWebFetchPreflight: enabled }) @@ -466,6 +483,10 @@ function normalizeWebSearchSettings(settings: WebSearchSettings | undefined): We } } +function normalizeChatSendBehavior(value: unknown): ChatSendBehavior { + return value === 'modifierEnter' ? 'modifierEnter' : 'enter' +} + function isUpdateProxyMode(value: unknown): value is UpdateProxyMode { return value === 'system' || value === 'manual' } diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index 33b9175d..57061d4c 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -12,6 +12,8 @@ export function isThemeMode(value: unknown): value is ThemeMode { export type WebSearchMode = 'auto' | 'anthropic' | 'tavily' | 'brave' | 'disabled' +export type ChatSendBehavior = 'enter' | 'modifierEnter' + export type WebSearchSettings = { mode?: WebSearchMode tavilyApiKey?: string @@ -80,6 +82,7 @@ export type UserSettings = { alwaysThinkingEnabled?: boolean permissionMode?: PermissionMode theme?: ThemeMode + chatSendBehavior?: ChatSendBehavior skipWebFetchPreflight?: boolean desktopNotificationsEnabled?: boolean webSearch?: WebSearchSettings