From 68ff1eab7494f1cd80a51161ed7dfb018912f64a Mon Sep 17 00:00:00 2001 From: brendandebeasi Date: Tue, 12 May 2026 19:21:20 -0700 Subject: [PATCH 1/2] feat(interface): configurable Enter behavior in chat composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a user-level preference for how the Enter key behaves in the chat composer: - Enter to send (default, current behavior) — Shift+Enter inserts a newline - Enter for new line — Cmd/Ctrl/Alt+Enter sends Persists via localStorage in a new useChatInputPrefs hook, surfaced through a new Settings → Composition section. Wired through PortalComposer (main chat) via the new `enterToSubmit` prop on @spacedrive/ai's ChatComposer, and also applied to CortexChatPanel (the new-agent dialog composer). Closes #599 --- interface/src/components/CortexChatPanel.tsx | 17 +++++- .../src/components/portal/PortalComposer.tsx | 11 +++- .../settings/CompositionSection.tsx | 60 +++++++++++++++++++ .../src/components/settings/constants.ts | 6 ++ interface/src/components/settings/index.ts | 1 + interface/src/components/settings/types.ts | 1 + interface/src/hooks/useChatInputPrefs.ts | 47 +++++++++++++++ interface/src/routes/Settings.tsx | 3 + 8 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 interface/src/components/settings/CompositionSection.tsx create mode 100644 interface/src/hooks/useChatInputPrefs.ts diff --git a/interface/src/components/CortexChatPanel.tsx b/interface/src/components/CortexChatPanel.tsx index 49b6bf56d..ccd6bc762 100644 --- a/interface/src/components/CortexChatPanel.tsx +++ b/interface/src/components/CortexChatPanel.tsx @@ -1,5 +1,6 @@ import {useCallback, useEffect, useRef, useState} from "react"; import {useCortexChat, type ToolActivity} from "@/hooks/useCortexChat"; +import {useChatInputPrefs} from "@/hooks/useChatInputPrefs"; import {Markdown} from "@/components/Markdown"; import {ToolCall, type ToolCallPair} from "@/components/ToolCall"; import { @@ -184,6 +185,7 @@ function CortexChatInput({ isStreaming: boolean; }) { const textareaRef = useRef(null); + const {prefs} = useChatInputPrefs(); useEffect(() => { textareaRef.current?.focus(); @@ -207,7 +209,14 @@ function CortexChatInput({ }, [value]); const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === "Enter" && !event.shiftKey) { + if (event.key !== "Enter" || event.nativeEvent.isComposing) return; + const hasSubmitModifier = + event.metaKey || event.ctrlKey || event.altKey; + if (prefs.enterToSubmit) { + if (event.shiftKey) return; + event.preventDefault(); + onSubmit(); + } else if (hasSubmitModifier) { event.preventDefault(); onSubmit(); } @@ -222,7 +231,11 @@ function CortexChatInput({ onChange={(event) => onChange(event.target.value)} onKeyDown={handleKeyDown} placeholder={ - isStreaming ? "Waiting for response..." : "Message the cortex..." + isStreaming + ? "Waiting for response..." + : prefs.enterToSubmit + ? "Message the cortex..." + : "Message the cortex... (⌘/Ctrl+Enter to send)" } disabled={isStreaming} rows={1} diff --git a/interface/src/components/portal/PortalComposer.tsx b/interface/src/components/portal/PortalComposer.tsx index a9396c750..7db12c9c8 100644 --- a/interface/src/components/portal/PortalComposer.tsx +++ b/interface/src/components/portal/PortalComposer.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from "react"; import { ChatComposer, type ModelOption } from "@spacedrive/ai"; import { usePopover } from "@spacedrive/primitives"; import { Paperclip, X } from "@phosphor-icons/react"; +import { useChatInputPrefs } from "@/hooks/useChatInputPrefs"; interface PortalComposerProps { agentName: string; @@ -44,6 +45,7 @@ export function PortalComposer({ const fileInputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const dragCounter = useRef(0); + const {prefs} = useChatInputPrefs(); const handleFileChange = (e: React.ChangeEvent) => { const files = Array.from(e.target.files ?? []); @@ -133,8 +135,15 @@ export function PortalComposer({ draft={draft} onDraftChange={onDraftChange} onSend={onSend} - placeholder={disabled ? "Waiting for response..." : `Message ${agentName}...`} + placeholder={ + disabled + ? "Waiting for response..." + : prefs.enterToSubmit + ? `Message ${agentName}...` + : `Message ${agentName}... (⌘/Ctrl+Enter to send)` + } isSending={disabled} + enterToSubmit={prefs.enterToSubmit} toolbarExtra={paperclipButton} projectSelector={ projectOptions.length > 0 diff --git a/interface/src/components/settings/CompositionSection.tsx b/interface/src/components/settings/CompositionSection.tsx new file mode 100644 index 000000000..c8ccd3785 --- /dev/null +++ b/interface/src/components/settings/CompositionSection.tsx @@ -0,0 +1,60 @@ +import {useChatInputPrefs} from "@/hooks/useChatInputPrefs"; + +export function CompositionSection() { + const {prefs, setEnterToSubmit} = useChatInputPrefs(); + + const options: { + value: boolean; + title: string; + description: string; + }[] = [ + { + value: true, + title: "Enter to send", + description: "Enter sends the message. Shift+Enter inserts a new line.", + }, + { + value: false, + title: "Enter for new line", + description: + "Enter inserts a new line. ⌘/Ctrl+Enter (or Option+Enter) sends.", + }, + ]; + + return ( +
+
+

+ Message input +

+

+ Choose how the Enter key behaves in the chat composer. +

+
+
+ {options.map((opt) => { + const active = prefs.enterToSubmit === opt.value; + return ( + + ); + })} +
+
+ ); +} diff --git a/interface/src/components/settings/constants.ts b/interface/src/components/settings/constants.ts index 018bce707..e3709bb73 100644 --- a/interface/src/components/settings/constants.ts +++ b/interface/src/components/settings/constants.ts @@ -61,6 +61,12 @@ export const SECTIONS = [ group: "general" as const, description: "Theme and display settings", }, + { + id: "composition" as const, + label: "Composition", + group: "general" as const, + description: "Chat composer and message input behavior", + }, { id: "config-file" as const, label: "Config File", diff --git a/interface/src/components/settings/index.ts b/interface/src/components/settings/index.ts index ec98e9a78..47f16d28d 100644 --- a/interface/src/components/settings/index.ts +++ b/interface/src/components/settings/index.ts @@ -1,5 +1,6 @@ export {InstanceSection} from "./InstanceSection"; export {AppearanceSection} from "./AppearanceSection"; +export {CompositionSection} from "./CompositionSection"; export {ChannelsSection} from "./ChannelsSection"; export {SecretsSection} from "./SecretsSection"; export {ApiKeysSection} from "./ApiKeysSection"; diff --git a/interface/src/components/settings/types.ts b/interface/src/components/settings/types.ts index 0f4d29ba0..9e3ae3b11 100644 --- a/interface/src/components/settings/types.ts +++ b/interface/src/components/settings/types.ts @@ -3,6 +3,7 @@ import type {GlobalSettingsResponse} from "@/api/client"; export type SectionId = | "instance" | "appearance" + | "composition" | "providers" | "channels" | "api-keys" diff --git a/interface/src/hooks/useChatInputPrefs.ts b/interface/src/hooks/useChatInputPrefs.ts new file mode 100644 index 000000000..bfc954636 --- /dev/null +++ b/interface/src/hooks/useChatInputPrefs.ts @@ -0,0 +1,47 @@ +import {useCallback, useEffect, useState} from "react"; + +export interface ChatInputPrefs { + enterToSubmit: boolean; +} + +const STORAGE_KEY = "spacebot-chat-input-prefs"; +const DEFAULTS: ChatInputPrefs = {enterToSubmit: true}; + +function readStored(): ChatInputPrefs { + if (typeof window === "undefined") return DEFAULTS; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return DEFAULTS; + const parsed = JSON.parse(raw); + return { + enterToSubmit: + typeof parsed?.enterToSubmit === "boolean" + ? parsed.enterToSubmit + : DEFAULTS.enterToSubmit, + }; + } catch { + return DEFAULTS; + } +} + +export function useChatInputPrefs() { + const [prefs, setPrefs] = useState(readStored); + + useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEY) setPrefs(readStored()); + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, []); + + const setEnterToSubmit = useCallback((value: boolean) => { + setPrefs((prev) => { + const next = {...prev, enterToSubmit: value}; + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + return next; + }); + }, []); + + return {prefs, setEnterToSubmit}; +} diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 446299266..9f1b6074c 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -17,6 +17,7 @@ import {ModelSelect} from "@/components/ModelSelect"; import { InstanceSection, AppearanceSection, + CompositionSection, ChannelsSection, SecretsSection, ApiKeysSection, @@ -556,6 +557,8 @@ export function Settings() { /> ) : activeSection === "appearance" ? ( + ) : activeSection === "composition" ? ( + ) : activeSection === "providers" ? (
{/* Section header */} From be5f071dfa5ed331ee018d8e0c676b5325c467c8 Mon Sep 17 00:00:00 2001 From: brendandebeasi Date: Tue, 12 May 2026 19:34:49 -0700 Subject: [PATCH 2/2] fix(interface): a11y + canSend guard from PR review - CompositionSection: add type="button" and aria-pressed to the Enter-behavior toggle so assistive tech can announce the active mode. - CortexChatPanel: gate keyboard-triggered submit on canSend (matches the send button's enabled state and mirrors the upstream ChatComposer fix in spaceui#4). Addresses CodeRabbit review on #600. --- interface/src/components/CortexChatPanel.tsx | 4 +++- interface/src/components/settings/CompositionSection.tsx | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/interface/src/components/CortexChatPanel.tsx b/interface/src/components/CortexChatPanel.tsx index ccd6bc762..1b5406ce8 100644 --- a/interface/src/components/CortexChatPanel.tsx +++ b/interface/src/components/CortexChatPanel.tsx @@ -208,6 +208,7 @@ function CortexChatInput({ return () => textarea.removeEventListener("input", adjustHeight); }, [value]); + const canSend = !isStreaming && value.trim().length > 0; const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key !== "Enter" || event.nativeEvent.isComposing) return; const hasSubmitModifier = @@ -215,8 +216,9 @@ function CortexChatInput({ if (prefs.enterToSubmit) { if (event.shiftKey) return; event.preventDefault(); - onSubmit(); + if (canSend) onSubmit(); } else if (hasSubmitModifier) { + if (!canSend) return; event.preventDefault(); onSubmit(); } diff --git a/interface/src/components/settings/CompositionSection.tsx b/interface/src/components/settings/CompositionSection.tsx index c8ccd3785..689731644 100644 --- a/interface/src/components/settings/CompositionSection.tsx +++ b/interface/src/components/settings/CompositionSection.tsx @@ -37,6 +37,8 @@ export function CompositionSection() { return (