mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
625fd98368
commit
62f1aa71e8
@ -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 />)
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
11
desktop/src/components/chat/sendShortcut.ts
Normal file
11
desktop/src/components/chat/sendShortcut.ts
Normal 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
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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 访问',
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user