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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-27 23:03:18 +08:00
parent 625fd98368
commit 62f1aa71e8
11 changed files with 198 additions and 6 deletions

View File

@ -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(<Settings />)
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(<Settings />)

View File

@ -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(<ChatInput />)
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: {

View File

@ -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()
}

View File

@ -0,0 +1,11 @@
import type { ChatSendBehavior } from '../../types/settings'
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
export function shouldSubmitOnEnter(
event: Pick<KeyboardEvent | ReactKeyboardEvent, 'key' | 'shiftKey' | 'ctrlKey' | 'metaKey'>,
behavior: ChatSendBehavior,
): boolean {
if (event.key !== 'Enter' || event.shiftKey) return false
if (behavior === 'modifierEnter') return event.ctrlKey || event.metaKey
return !event.ctrlKey && !event.metaKey
}

View File

@ -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',

View File

@ -864,6 +864,12 @@ export const zh: Record<TranslationKey, string> = {
'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 访问',

View File

@ -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()
}

View File

@ -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<DesktopNotificationPermission, string> = {
granted: t('settings.general.notificationsStatusGranted'),
denied: t('settings.general.notificationsStatusDenied'),
@ -2088,6 +2103,31 @@ function GeneralSettings() {
</div>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.chatSendBehaviorTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.chatSendBehaviorDescription')}</p>
<div className="grid grid-cols-2 gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-2">
{CHAT_SEND_BEHAVIORS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => void setChatSendBehavior(option.value)}
aria-pressed={chatSendBehavior === option.value}
className={`rounded-lg border px-3 py-2 text-left transition-colors ${
chatSendBehavior === option.value
? 'border-[var(--color-brand)] bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<div className="text-xs font-semibold">{option.label}</div>
<div className="mt-1 text-[11px] leading-4 text-[var(--color-text-tertiary)]">
{option.description}
</div>
</button>
))}
</div>
</div>
{uiZoomSection}
<div className="mt-8">

View File

@ -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', () => {

View File

@ -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<void>
setLocale: (locale: Locale) => void
setTheme: (theme: ThemeMode) => Promise<void>
setChatSendBehavior: (behavior: ChatSendBehavior) => Promise<void>
setSkipWebFetchPreflight: (enabled: boolean) => Promise<void>
setDesktopNotificationsEnabled: (enabled: boolean) => Promise<void>
setDesktopTerminal: (settings: DesktopTerminalSettings) => Promise<void>
@ -139,6 +142,7 @@ export const useSettingsStore = create<SettingsStore>((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<SettingsStore>((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<SettingsStore>((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'
}

View File

@ -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