diff --git a/apps/web/src/components/ChatComposer.tsx b/apps/web/src/components/ChatComposer.tsx index d63300c89..bee551775 100644 --- a/apps/web/src/components/ChatComposer.tsx +++ b/apps/web/src/components/ChatComposer.tsx @@ -161,6 +161,10 @@ interface Props { // ChatPane). Pass `null` (or omit) to render the full rail. pinnedPluginId?: string | null; footerAccessory?: ReactNode; + // Composer send-key preference (Settings → General). When true, a bare + // Enter sends and ⌘/Ctrl + Enter inserts a newline; when false/undefined + // the original ⌘/Ctrl + Enter-sends behavior applies. + enterToSend?: boolean; } // Imperative handle so ancestors (e.g. example chips in ChatPane) can @@ -224,6 +228,7 @@ export const ChatComposer = forwardRef( onProjectSkillChange, pinnedPluginId = null, footerAccessory, + enterToSend = true, }, ref ) { @@ -1880,6 +1885,8 @@ export const ChatComposer = forwardRef( composingRef.current = false; }} onKeyDown={(e) => { + // IME guard (macOS Korean/Japanese/… composition): Enter that + // commits an in-progress syllable must not send or pick. if (isImeComposing(e, composingRef.current)) return; if (slash && filteredSlash.length > 0) { if (e.key === 'ArrowDown') { @@ -1910,14 +1917,51 @@ export const ChatComposer = forwardRef( setMention(null); return; } - if ( - e.key === 'Enter' && - !e.shiftKey && - !e.altKey && - (e.metaKey || e.ctrlKey || !mention) - ) { + // Escape stops an in-flight run, mirroring the Stop button. + // The slash / mention popovers consume Escape above, so this + // only fires when neither is open. + if (e.key === "Escape" && streaming) { e.preventDefault(); - void submit(); + onStop(); + return; + } + if (e.key === "Enter") { + // Send-key resolution honors the Settings → General + // "Enter to send" preference. On (default): bare Enter sends + // and ⌘/Ctrl+Enter inserts a newline. Off (legacy): + // ⌘/Ctrl+Enter sends and bare Enter inserts a newline. + // Shift+Enter always newlines. The slash palette already + // consumed bare Enter above when it was open. + const sends = enterToSend + ? !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey + : (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey; + // Don't fire a send out from under an open @-mention + // popover the user is mid-selecting. + if (sends && !(enterToSend && mention)) { + e.preventDefault(); + void submit(); + return; + } + // Enter-to-send mode: the former send combo now inserts a + // newline at the caret (browsers don't newline on the + // modifier combo by themselves). Skip while sending is + // disabled so the keystroke stays inert, matching the + // blocked-submit behavior. + if (enterToSend && (e.metaKey || e.ctrlKey) && !sendDisabled) { + e.preventDefault(); + const ta = e.currentTarget; + const value = ta.value; + const start = ta.selectionStart ?? value.length; + const end = ta.selectionEnd ?? value.length; + setDraft(value.slice(0, start) + "\n" + value.slice(end)); + requestAnimationFrame(() => { + try { + ta.selectionStart = ta.selectionEnd = start + 1; + } catch { + /* textarea detached before caret restore */ + } + }); + } } }} /> diff --git a/apps/web/src/components/ChatPane.tsx b/apps/web/src/components/ChatPane.tsx index ebb643e82..0461da593 100644 --- a/apps/web/src/components/ChatPane.tsx +++ b/apps/web/src/components/ChatPane.tsx @@ -304,6 +304,9 @@ interface Props { // Bumped by the parent to push a draft into the composer (used by the // "Import repo" CTA). The nonce lets the same text fire more than once. composerDraftSignal?: { text: string; nonce: number }; + // Composer send-key preference (Settings → General), forwarded to + // ChatComposer. Omitted → defaults to the ⌘/Ctrl+Enter-sends behavior. + enterToSend?: boolean; // Optional pet wiring forwarded straight through to ChatComposer's // /pet button. When omitted the composer hides the button entirely. petConfig?: AppConfig['pet']; @@ -389,6 +392,7 @@ export function ChatPane({ githubConnected, onConnectRepo, composerDraftSignal, + enterToSend, petConfig, onAdoptPet, onTogglePet, @@ -1306,6 +1310,7 @@ export function ChatPane({ onStop={onStop} onOpenSettings={onOpenSettings} onOpenMcpSettings={onOpenMcpSettings} + enterToSend={enterToSend} petConfig={petConfig} onAdoptPet={onAdoptPet} onTogglePet={onTogglePet} diff --git a/apps/web/src/components/DesignSystemFlow.tsx b/apps/web/src/components/DesignSystemFlow.tsx index ee9a4e299..1c169740f 100644 --- a/apps/web/src/components/DesignSystemFlow.tsx +++ b/apps/web/src/components/DesignSystemFlow.tsx @@ -1774,6 +1774,7 @@ export function DesignSystemDetailView({ void sendProjectChatMessage(prompt, attachments, commentAttachments); }} onStop={stopProjectChat} + enterToSend={config?.enterToSend ?? true} initialDraft={chatSeed?.text} conversations={conversations} activeConversationId={activeConversationId} diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index 9a52d7ff2..1fb2344fc 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -4499,6 +4499,7 @@ export function ProjectView({ githubConnected={githubConnected} onConnectRepo={handleConnectRepo} composerDraftSignal={composerDraftSignal} + enterToSend={config.enterToSend ?? true} petConfig={config.pet} onAdoptPet={onAdoptPetInline} onTogglePet={onTogglePet} diff --git a/apps/web/src/components/SettingsDialog.tsx b/apps/web/src/components/SettingsDialog.tsx index 3045b433b..9e0ede7d2 100644 --- a/apps/web/src/components/SettingsDialog.tsx +++ b/apps/web/src/components/SettingsDialog.tsx @@ -122,6 +122,7 @@ import { export type SettingsSection = | 'execution' + | 'general' | 'instructions' | 'media' | 'composio' @@ -2024,6 +2025,7 @@ export function SettingsDialog({ }, integrations: { title: t('settings.mcpServerTitle'), subtitle: t('settings.mcpServerHint') }, mcpClient: { title: t('settings.externalMcpTitle'), subtitle: t('settings.externalMcpHint') }, + general: { title: t('settings.general'), subtitle: t('settings.generalHint') }, language: { title: t('settings.language'), subtitle: t('settings.languageHint') }, appearance: { title: t('settings.appearance'), subtitle: t('settings.appearanceHint') }, critiqueTheater: { @@ -2407,6 +2409,17 @@ export function SettingsDialog({ {t('settings.mcpServerHint')} +