Skip to content

Commit f4ef515

Browse files
authored
feat(web): add send shortcut and follow-up controls (#12075)
1 parent 0b83045 commit f4ef515

11 files changed

Lines changed: 172 additions & 23 deletions

File tree

‎apps/web/src/components/ChatView.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7516,7 +7516,7 @@ export default function ChatView(props: ChatViewProps) {
75167516
!directAnnotation &&
75177517
phase === "running" &&
75187518
activeThreadKey &&
7519-
settings.followUpBehavior === "queue"
7519+
(settings.followUpBehavior === "queue") !== (submissionIntent === "alternate")
75207520
) {
75217521
if (composerRef.current?.validateProviderInput(promptForSend) === false) {
75227522
return;

‎apps/web/src/components/chat/ChatComposer.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3955,6 +3955,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
39553955
shiftKey: event.shiftKey,
39563956
modifierKey: event.metaKey || event.ctrlKey,
39573957
isDraftThread: routeKind === "draft",
3958+
isRunning: phase === "running",
3959+
sendShortcut: settings.sendShortcut,
3960+
prompt: promptRef.current,
39583961
})
39593962
: null;
39603963
if (submissionIntent) {

‎apps/web/src/components/settings/SettingsPanels.tsx‎

Lines changed: 76 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Spinner } from "~/components/ui/spinner";
22
import { NotificationSettings } from "./NotificationSettings";
3-
import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react";
3+
import { ArchiveIcon, ArchiveX, CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react";
44
import { Link, useNavigate } from "@tanstack/react-router";
55
import type { CSSProperties, ReactNode } from "react";
66
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -105,6 +105,7 @@ import {
105105
AlertDialogTitle,
106106
} from "../ui/alert-dialog";
107107
import { Button } from "../ui/button";
108+
import { Toggle, ToggleGroup } from "../ui/toggle-group";
108109
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
109110
import {
110111
Dialog,
@@ -578,6 +579,7 @@ export function useSettingsRestore(onRestored?: () => void) {
578579
...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll
579580
? ["Collapse composer on scroll"]
580581
: []),
582+
...(settings.sendShortcut !== DEFAULT_UNIFIED_SETTINGS.sendShortcut ? ["Send shortcut"] : []),
581583
...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior
582584
? ["Follow-up behavior"]
583585
: []),
@@ -639,6 +641,7 @@ export function useSettingsRestore(onRestored?: () => void) {
639641
settings.confirmThreadDelete,
640642
settings.confirmThreadUnpin,
641643
settings.composerCollapseOnScroll,
644+
settings.sendShortcut,
642645
settings.followUpBehavior,
643646
settings.addProjectBaseDirectory,
644647
settings.defaultThreadEnvMode,
@@ -752,6 +755,7 @@ export function useSettingsRestore(onRestored?: () => void) {
752755
proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,
753756
showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,
754757
composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,
758+
sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut,
755759
followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior,
756760
contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,
757761
environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
@@ -2086,6 +2090,12 @@ function LegacyFeaturesSection() {
20862090
}
20872091

20882092
export function GeneralSettingsPanel() {
2093+
const modifierLabel = isMacPlatform(navigator.platform) ? "⌘" : "Ctrl";
2094+
const sendShortcutOptions = [
2095+
{ value: "enter", label: "Enter" },
2096+
{ value: "mod-enter-multiline", label: `${modifierLabel} + Enter for multiline prompts` },
2097+
{ value: "mod-enter", label: `${modifierLabel} + Enter always` },
2098+
] as const;
20892099
const settings = useScopedSettings();
20902100
const updateSettings = useUpdateScopedSettings();
20912101
const navigate = useNavigate();
@@ -2594,9 +2604,61 @@ export function GeneralSettingsPanel() {
25942604
}
25952605
/>
25962606

2607+
<SettingsRow
2608+
{...searchableSetting("send-shortcut")}
2609+
description="Choose when Enter sends a prompt or inserts a new line"
2610+
resetAction={
2611+
settings.sendShortcut !== DEFAULT_UNIFIED_SETTINGS.sendShortcut ? (
2612+
<SettingResetButton
2613+
label="send shortcut"
2614+
onClick={() =>
2615+
updateSettings({ sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut })
2616+
}
2617+
/>
2618+
) : null
2619+
}
2620+
control={
2621+
<Select
2622+
value={settings.sendShortcut}
2623+
onValueChange={(value) => {
2624+
const option = sendShortcutOptions.find((option) => option.value === value);
2625+
if (option) updateSettings({ sendShortcut: option.value });
2626+
}}
2627+
>
2628+
<SelectTrigger
2629+
size="sm"
2630+
className="w-auto min-w-0 max-w-full"
2631+
aria-label="Send shortcut"
2632+
>
2633+
<SelectValue>
2634+
{
2635+
sendShortcutOptions.find((option) => option.value === settings.sendShortcut)
2636+
?.label
2637+
}
2638+
</SelectValue>
2639+
</SelectTrigger>
2640+
<SelectPopup align="end" alignItemWithTrigger={false}>
2641+
{sendShortcutOptions.map((option) => (
2642+
<SelectItem key={option.value} value={option.value}>
2643+
<span className="flex items-center justify-between gap-4">
2644+
{option.label}
2645+
{settings.sendShortcut === option.value && <CheckIcon aria-hidden="true" />}
2646+
</span>
2647+
</SelectItem>
2648+
))}
2649+
</SelectPopup>
2650+
</Select>
2651+
}
2652+
/>
2653+
25972654
<SettingsRow
25982655
{...searchableSetting("follow-up-behavior")}
2599-
description="Queue follow-ups while the agent runs or steer the current turn."
2656+
description={
2657+
"Queue follow-ups while the agent runs or steer the current run. " +
2658+
(settings.sendShortcut === "mod-enter-multiline"
2659+
? `Press ${modifierLabel} + Enter for single-line prompts or ${modifierLabel} + Shift + Enter for multiline prompts to do the opposite for one message.`
2660+
: `Press ${modifierLabel}${settings.sendShortcut === "mod-enter" ? " + Shift" : ""} + Enter to do the opposite for one message.`)
2661+
}
26002662
resetAction={
26012663
settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior ? (
26022664
<SettingResetButton
@@ -2610,28 +2672,23 @@ export function GeneralSettingsPanel() {
26102672
) : null
26112673
}
26122674
control={
2613-
<Select
2614-
value={settings.followUpBehavior}
2615-
onValueChange={(value) => {
2675+
<ToggleGroup
2676+
aria-label="Follow-up behavior"
2677+
variant="default"
2678+
value={[settings.followUpBehavior]}
2679+
onValueChange={(values) => {
2680+
const value = values[0];
26162681
if (value === "queue" || value === "steer") {
26172682
updateSettings({ followUpBehavior: value });
26182683
}
26192684
}}
26202685
>
2621-
<SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Follow-up behavior">
2622-
<SelectValue>
2623-
{settings.followUpBehavior === "queue" ? "Queue" : "Steer"}
2624-
</SelectValue>
2625-
</SelectTrigger>
2626-
<SelectPopup align="end" alignItemWithTrigger={false}>
2627-
<SelectItem hideIndicator value="queue">
2628-
Queue
2629-
</SelectItem>
2630-
<SelectItem hideIndicator value="steer">
2631-
Steer
2632-
</SelectItem>
2633-
</SelectPopup>
2634-
</Select>
2686+
{(["queue", "steer"] as const).map((value) => (
2687+
<Toggle key={value} value={value} variant="pill">
2688+
{value === "queue" ? "Queue" : "Steer"}
2689+
</Toggle>
2690+
))}
2691+
</ToggleGroup>
26352692
}
26362693
/>
26372694

‎apps/web/src/components/settings/settingsSearch.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ const ITEMS: ReadonlyArray<SettingsSearchItem> = [
4545
];
4646

4747
describe("searchSettings", () => {
48+
it.each(["send shortcut", "multiline", "new line"])("finds Send shortcut for %s", (query) => {
49+
expect(searchSettings(query).map((item) => item.id)).toContain("send-shortcut");
50+
});
51+
4852
it("matches titles, sections, and remembered setting details", () => {
4953
expect(searchSettings("word", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
5054
expect(searchSettings("network", ITEMS).map((item) => item.id)).toEqual(["network-access"]);

‎apps/web/src/components/settings/settingsSearch.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,12 @@ export const SETTINGS_SEARCH_ITEMS = [
298298
to: "/settings/general",
299299
searchTerms: ["composer rest resting scroll wheel conversation timeline shrink minimize"],
300300
},
301+
{
302+
id: "send-shortcut",
303+
title: "Send shortcut",
304+
to: "/settings/general",
305+
searchTerms: ["enter return command ctrl multiline prompt new line composer"],
306+
},
301307
{
302308
id: "follow-up-behavior",
303309
title: "Follow-up behavior",

‎apps/web/src/components/ui/toggle.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const toggleVariants = cva(
2525
},
2626
variant: {
2727
default: "border-transparent",
28+
pill: "border-transparent rounded-full px-3 font-normal text-muted-foreground data-pressed:text-foreground",
2829
ghost:
2930
"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",
3031
outline:

‎apps/web/src/composer-logic.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,51 @@ describe("formatAssistantCitationForComposer", () => {
6161
});
6262

6363
describe("composerSubmissionIntentForEnter", () => {
64+
it.each([
65+
["enter", "one line", false, "foreground"],
66+
["enter", "two\nlines", false, "foreground"],
67+
["mod-enter-multiline", "one line", false, "foreground"],
68+
["mod-enter-multiline", "two\nlines", false, null],
69+
["mod-enter-multiline", "two\nlines", true, "foreground"],
70+
["mod-enter", "one line", false, null],
71+
["mod-enter", "one line", true, "foreground"],
72+
] as const)("uses %s for %j with modifier=%s", (sendShortcut, prompt, modifierKey, expected) => {
73+
expect(
74+
composerSubmissionIntentForEnter({
75+
isMobileViewport: false,
76+
shiftKey: false,
77+
modifierKey,
78+
isDraftThread: false,
79+
sendShortcut,
80+
prompt,
81+
}),
82+
).toBe(expected);
83+
});
84+
85+
it.each([
86+
["enter", false, "alternate"],
87+
["enter", true, null],
88+
["mod-enter-multiline", false, "foreground"],
89+
["mod-enter-multiline", true, "alternate"],
90+
["mod-enter", false, "foreground"],
91+
["mod-enter", true, "alternate"],
92+
] as const)(
93+
"resolves running follow-ups with %s and shift=%s",
94+
(sendShortcut, shiftKey, expected) => {
95+
expect(
96+
composerSubmissionIntentForEnter({
97+
isMobileViewport: false,
98+
shiftKey,
99+
modifierKey: true,
100+
isDraftThread: false,
101+
isRunning: true,
102+
sendShortcut,
103+
prompt: "two\nlines",
104+
}),
105+
).toBe(expected);
106+
},
107+
);
108+
64109
it("submits plain Enter on desktop", () => {
65110
expect(
66111
composerSubmissionIntentForEnter({

‎apps/web/src/composer-logic.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ClientSettings } from "@t3tools/contracts/settings";
12
import type { AssistantCitation } from "@t3tools/contracts";
23
import {
34
serializeAssistantCitation,
@@ -10,7 +11,7 @@ import {
1011

1112
export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill";
1213
export type ComposerSlashCommand = "model" | "plan" | "default";
13-
export type ComposerSubmissionIntent = "foreground" | "background";
14+
export type ComposerSubmissionIntent = "foreground" | "background" | "alternate";
1415

1516
export interface ComposerTrigger {
1617
kind: ComposerTriggerKind;
@@ -28,9 +29,17 @@ export function composerSubmissionIntentForEnter(input: {
2829
shiftKey: boolean;
2930
modifierKey: boolean;
3031
isDraftThread: boolean;
32+
isRunning?: boolean;
33+
sendShortcut?: ClientSettings["sendShortcut"];
34+
prompt?: string;
3135
}): ComposerSubmissionIntent | null {
32-
if (input.isMobileViewport || input.shiftKey) {
33-
return null;
36+
const requiresModifier =
37+
input.sendShortcut === "mod-enter" ||
38+
(input.sendShortcut === "mod-enter-multiline" && /[\r\n]/.test(input.prompt ?? ""));
39+
if (input.isMobileViewport || (requiresModifier && !input.modifierKey)) return null;
40+
if (input.shiftKey && !(requiresModifier && input.modifierKey && input.isRunning)) return null;
41+
if (input.isRunning && input.modifierKey && (!requiresModifier || input.shiftKey)) {
42+
return "alternate";
3443
}
3544
return input.modifierKey && input.isDraftThread ? "background" : "foreground";
3645
}

‎docs/user/keybindings.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ also lists the command IDs and defaults available in your version.
55

66
## Composer controls
77

8+
In **Settings → General → Send shortcut**, choose whether Enter sends, requires
9+
`mod+Enter` for multiline prompts, or always requires `mod+Enter`. `Shift+Enter`
10+
inserts a new line. This applies to the web and desktop composer at desktop widths.
11+
12+
**Follow-up behavior** chooses Queue or Steer while the agent runs. Use
13+
`mod+Enter` to do the opposite for one message. When sending requires `mod+Enter`,
14+
use `mod+Shift+Enter` for the opposite action. In a new thread, `mod+Enter` keeps
15+
starting the thread in the background.
16+
817
Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host.
918
Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the
1019
workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the

‎packages/contracts/src/settings.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,17 @@ describe("ClientSettings context window meter", () => {
529529
});
530530
});
531531

532+
describe("ClientSettings send shortcut", () => {
533+
it("defaults to Enter and validates the supported choices", () => {
534+
expect(decodeClientSettings({}).sendShortcut).toBe("enter");
535+
for (const sendShortcut of ["enter", "mod-enter-multiline", "mod-enter"]) {
536+
expect(decodeClientSettings({ sendShortcut }).sendShortcut).toBe(sendShortcut);
537+
expect(decodeClientSettingsPatch({ sendShortcut }).sendShortcut).toBe(sendShortcut);
538+
}
539+
expect(() => decodeClientSettingsPatch({ sendShortcut: "invalid" })).toThrow();
540+
});
541+
});
542+
532543
describe("ClientSettings follow-up behavior", () => {
533544
it("defaults to queue and accepts either behavior", () => {
534545
expect(decodeClientSettings({}).followUpBehavior).toBe("queue");

0 commit comments

Comments
 (0)