From 55f403d2e2bd671428723fd67037993346481da7 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:54:27 +0200 Subject: [PATCH 1/3] feat(web): add send shortcut and follow-up controls --- apps/web/src/components/ChatView.tsx | 2 +- apps/web/src/components/chat/ChatComposer.tsx | 3 + .../components/settings/SettingsPanels.tsx | 99 +++++++++++++++---- .../settings/settingsSearch.test.ts | 4 + .../src/components/settings/settingsSearch.ts | 6 ++ apps/web/src/composer-logic.test.ts | 45 +++++++++ apps/web/src/composer-logic.ts | 15 ++- docs/user/keybindings.md | 9 ++ packages/contracts/src/settings.test.ts | 11 +++ packages/contracts/src/settings.ts | 4 + 10 files changed, 175 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 708809712917..60b72c6bbc0b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7516,7 +7516,7 @@ export default function ChatView(props: ChatViewProps) { !directAnnotation && phase === "running" && activeThreadKey && - settings.followUpBehavior === "queue" + (settings.followUpBehavior === "queue") !== (submissionIntent === "alternate") ) { if (composerRef.current?.validateProviderInput(promptForSend) === false) { return; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f7d3ac540ccc..d32f847bcaaa 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3955,6 +3955,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) shiftKey: event.shiftKey, modifierKey: event.metaKey || event.ctrlKey, isDraftThread: routeKind === "draft", + isRunning: phase === "running", + sendShortcut: settings.sendShortcut, + prompt: promptRef.current, }) : null; if (submissionIntent) { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5238cf19e190..7e24cd364860 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,6 +1,6 @@ import { Spinner } from "~/components/ui/spinner"; import { NotificationSettings } from "./NotificationSettings"; -import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react"; +import { ArchiveIcon, ArchiveX, CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react"; import { Link, useNavigate } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -105,6 +105,7 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; +import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Dialog, @@ -578,6 +579,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll ? ["Collapse composer on scroll"] : []), + ...(settings.sendShortcut !== DEFAULT_UNIFIED_SETTINGS.sendShortcut ? ["Send shortcut"] : []), ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior ? ["Follow-up behavior"] : []), @@ -639,6 +641,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadDelete, settings.confirmThreadUnpin, settings.composerCollapseOnScroll, + settings.sendShortcut, settings.followUpBehavior, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, @@ -752,6 +755,7 @@ export function useSettingsRestore(onRestored?: () => void) { proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, + sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut, followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, @@ -2086,6 +2090,12 @@ function LegacyFeaturesSection() { } export function GeneralSettingsPanel() { + const modifierLabel = isMacPlatform(navigator.platform) ? "⌘" : "Ctrl"; + const sendShortcutOptions = [ + { value: "enter", label: "Enter" }, + { value: "mod-enter-multiline", label: `${modifierLabel} + Enter for multiline prompts` }, + { value: "mod-enter", label: `${modifierLabel} + Enter always` }, + ] as const; const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); const navigate = useNavigate(); @@ -2594,9 +2604,61 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut }) + } + /> + ) : null + } + control={ + + } + /> + { + { + const value = values[0]; if (value === "queue" || value === "steer") { updateSettings({ followUpBehavior: value }); } }} > - - - {settings.followUpBehavior === "queue" ? "Queue" : "Steer"} - - - - - Queue - - - Steer - - - + {(["queue", "steer"] as const).map((value) => ( + + {value === "queue" ? "Queue" : "Steer"} + + ))} + } /> diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index ea9e1a88ec30..a45b20150138 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -45,6 +45,10 @@ const ITEMS: ReadonlyArray = [ ]; describe("searchSettings", () => { + it.each(["send shortcut", "multiline", "new line"])("finds Send shortcut for %s", (query) => { + expect(searchSettings(query).map((item) => item.id)).toContain("send-shortcut"); + }); + it("matches titles, sections, and remembered setting details", () => { expect(searchSettings("word", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); expect(searchSettings("network", ITEMS).map((item) => item.id)).toEqual(["network-access"]); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 7e999417f2a8..55d3a32050c0 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -298,6 +298,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["composer rest resting scroll wheel conversation timeline shrink minimize"], }, + { + id: "send-shortcut", + title: "Send shortcut", + to: "/settings/general", + searchTerms: ["enter return command ctrl multiline prompt new line composer"], + }, { id: "follow-up-behavior", title: "Follow-up behavior", diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 3f319331bb73..64ff906d41d1 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -61,6 +61,51 @@ describe("formatAssistantCitationForComposer", () => { }); describe("composerSubmissionIntentForEnter", () => { + it.each([ + ["enter", "one line", false, "foreground"], + ["enter", "two\nlines", false, "foreground"], + ["mod-enter-multiline", "one line", false, "foreground"], + ["mod-enter-multiline", "two\nlines", false, null], + ["mod-enter-multiline", "two\nlines", true, "foreground"], + ["mod-enter", "one line", false, null], + ["mod-enter", "one line", true, "foreground"], + ] as const)("uses %s for %j with modifier=%s", (sendShortcut, prompt, modifierKey, expected) => { + expect( + composerSubmissionIntentForEnter({ + isMobileViewport: false, + shiftKey: false, + modifierKey, + isDraftThread: false, + sendShortcut, + prompt, + }), + ).toBe(expected); + }); + + it.each([ + ["enter", false, "alternate"], + ["enter", true, null], + ["mod-enter-multiline", false, "foreground"], + ["mod-enter-multiline", true, "alternate"], + ["mod-enter", false, "foreground"], + ["mod-enter", true, "alternate"], + ] as const)( + "resolves running follow-ups with %s and shift=%s", + (sendShortcut, shiftKey, expected) => { + expect( + composerSubmissionIntentForEnter({ + isMobileViewport: false, + shiftKey, + modifierKey: true, + isDraftThread: false, + isRunning: true, + sendShortcut, + prompt: "two\nlines", + }), + ).toBe(expected); + }, + ); + it("submits plain Enter on desktop", () => { expect( composerSubmissionIntentForEnter({ diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 9a9354a84d8b..ed74adf51db1 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,3 +1,4 @@ +import type { ClientSettings } from "@t3tools/contracts/settings"; import type { AssistantCitation } from "@t3tools/contracts"; import { serializeAssistantCitation, @@ -10,7 +11,7 @@ import { export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill"; export type ComposerSlashCommand = "model" | "plan" | "default"; -export type ComposerSubmissionIntent = "foreground" | "background"; +export type ComposerSubmissionIntent = "foreground" | "background" | "alternate"; export interface ComposerTrigger { kind: ComposerTriggerKind; @@ -28,9 +29,17 @@ export function composerSubmissionIntentForEnter(input: { shiftKey: boolean; modifierKey: boolean; isDraftThread: boolean; + isRunning?: boolean; + sendShortcut?: ClientSettings["sendShortcut"]; + prompt?: string; }): ComposerSubmissionIntent | null { - if (input.isMobileViewport || input.shiftKey) { - return null; + const requiresModifier = + input.sendShortcut === "mod-enter" || + (input.sendShortcut === "mod-enter-multiline" && /[\r\n]/.test(input.prompt ?? "")); + if (input.isMobileViewport || (requiresModifier && !input.modifierKey)) return null; + if (input.shiftKey && !(requiresModifier && input.modifierKey && input.isRunning)) return null; + if (input.isRunning && input.modifierKey && (!requiresModifier || input.shiftKey)) { + return "alternate"; } return input.modifierKey && input.isDraftThread ? "background" : "foreground"; } diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index acf6bd2aa1ac..f9284811de3a 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -5,6 +5,15 @@ also lists the command IDs and defaults available in your version. ## Composer controls +In **Settings → General → Send shortcut**, choose whether Enter sends, requires +`mod+Enter` for multiline prompts, or always requires `mod+Enter`. `Shift+Enter` +inserts a new line. This applies to the web and desktop composer at desktop widths. + +**Follow-up behavior** chooses Queue or Steer while the agent runs. Use +`mod+Enter` to do the opposite for one message. When sending requires `mod+Enter`, +use `mod+Shift+Enter` for the opposite action. In a new thread, `mod+Enter` keeps +starting the thread in the background. + Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ba612edaf26b..4a0309fe7185 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -529,6 +529,17 @@ describe("ClientSettings context window meter", () => { }); }); +describe("ClientSettings send shortcut", () => { + it("defaults to Enter and validates the supported choices", () => { + expect(decodeClientSettings({}).sendShortcut).toBe("enter"); + for (const sendShortcut of ["enter", "mod-enter-multiline", "mod-enter"]) { + expect(decodeClientSettings({ sendShortcut }).sendShortcut).toBe(sendShortcut); + expect(decodeClientSettingsPatch({ sendShortcut }).sendShortcut).toBe(sendShortcut); + } + expect(() => decodeClientSettingsPatch({ sendShortcut: "invalid" })).toThrow(); + }); +}); + describe("ClientSettings follow-up behavior", () => { it("defaults to queue and accepts either behavior", () => { expect(decodeClientSettings({}).followUpBehavior).toBe("queue"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1c53b36037ba..236b100d7e53 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -430,6 +430,9 @@ export const ClientSettingsSchema = Schema.Struct({ // Desktop resting composer: scrolling an existing thread's conversation // settles the composer into its single-line layout. Losing focus never does. composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + sendShortcut: Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"]).pipe( + Schema.withDecodingDefault(Effect.succeed("enter")), + ), followUpBehavior: Schema.Literals(["queue", "steer"]).pipe( Schema.withDecodingDefault(Effect.succeed("queue")), ), @@ -1502,6 +1505,7 @@ export const ClientSettingsPatch = Schema.Struct({ planModeEnabled: Schema.optionalKey(Schema.Boolean), contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), + sendShortcut: Schema.optionalKey(Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"])), followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), From 0146c18369c8c6b24565f268fb38d711e9046231 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:13:20 +0200 Subject: [PATCH 2/3] refactor(web): use a shared pill toggle variant --- apps/web/src/components/settings/SettingsPanels.tsx | 6 +----- apps/web/src/components/ui/toggle.tsx | 1 + 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 7e24cd364860..78bafd4dffed 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2684,11 +2684,7 @@ export function GeneralSettingsPanel() { }} > {(["queue", "steer"] as const).map((value) => ( - + {value === "queue" ? "Queue" : "Steer"} ))} diff --git a/apps/web/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx index 9f74d4546cc7..17efb492ebeb 100644 --- a/apps/web/src/components/ui/toggle.tsx +++ b/apps/web/src/components/ui/toggle.tsx @@ -25,6 +25,7 @@ const toggleVariants = cva( }, variant: { default: "border-transparent", + pill: "border-transparent rounded-full px-3 font-normal text-muted-foreground data-pressed:text-foreground", ghost: "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", outline: From 7897d2a2653883fccad7352a62544a5d45803173 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:32:27 +0200 Subject: [PATCH 3/3] docs(web): clarify multiline follow-up shortcuts --- apps/web/src/components/settings/SettingsPanels.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 78bafd4dffed..16ea81cd76e9 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2654,10 +2654,10 @@ export function GeneralSettingsPanel() {