Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
95 changes: 76 additions & 19 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"]
: []),
Expand Down Expand Up @@ -639,6 +641,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.confirmThreadDelete,
settings.confirmThreadUnpin,
settings.composerCollapseOnScroll,
settings.sendShortcut,
settings.followUpBehavior,
settings.addProjectBaseDirectory,
settings.defaultThreadEnvMode,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -2594,9 +2604,61 @@ export function GeneralSettingsPanel() {
}
/>

<SettingsRow
{...searchableSetting("send-shortcut")}
description="Choose when Enter sends a prompt or inserts a new line"
resetAction={
settings.sendShortcut !== DEFAULT_UNIFIED_SETTINGS.sendShortcut ? (
<SettingResetButton
label="send shortcut"
onClick={() =>
updateSettings({ sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut })
}
/>
) : null
}
control={
<Select
value={settings.sendShortcut}
onValueChange={(value) => {
const option = sendShortcutOptions.find((option) => option.value === value);
if (option) updateSettings({ sendShortcut: option.value });
}}
>
<SelectTrigger
size="sm"
className="w-auto min-w-0 max-w-full"
aria-label="Send shortcut"
>
<SelectValue>
{
sendShortcutOptions.find((option) => option.value === settings.sendShortcut)
?.label
}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
{sendShortcutOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span className="flex items-center justify-between gap-4">
{option.label}
{settings.sendShortcut === option.value && <CheckIcon aria-hidden="true" />}
</span>
</SelectItem>
))}
</SelectPopup>
</Select>
}
/>

<SettingsRow
{...searchableSetting("follow-up-behavior")}
description="Queue follow-ups while the agent runs or steer the current turn."
description={
"Queue follow-ups while the agent runs or steer the current run. " +
(settings.sendShortcut === "mod-enter-multiline"
? `Press ${modifierLabel} + Enter for single-line prompts or ${modifierLabel} + Shift + Enter for multiline prompts to do the opposite for one message.`
: `Press ${modifierLabel}${settings.sendShortcut === "mod-enter" ? " + Shift" : ""} + Enter to do the opposite for one message.`)
}
resetAction={
settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior ? (
<SettingResetButton
Expand All @@ -2610,28 +2672,23 @@ export function GeneralSettingsPanel() {
) : null
}
control={
<Select
value={settings.followUpBehavior}
onValueChange={(value) => {
<ToggleGroup
aria-label="Follow-up behavior"
variant="default"
value={[settings.followUpBehavior]}
onValueChange={(values) => {
const value = values[0];
if (value === "queue" || value === "steer") {
updateSettings({ followUpBehavior: value });
}
}}
>
<SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Follow-up behavior">
<SelectValue>
{settings.followUpBehavior === "queue" ? "Queue" : "Steer"}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem hideIndicator value="queue">
Queue
</SelectItem>
<SelectItem hideIndicator value="steer">
Steer
</SelectItem>
</SelectPopup>
</Select>
{(["queue", "steer"] as const).map((value) => (
<Toggle key={value} value={value} variant="pill">
{value === "queue" ? "Queue" : "Steer"}
Comment thread
Bil0000 marked this conversation as resolved.
</Toggle>
))}
</ToggleGroup>
}
/>

Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/settings/settingsSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ const ITEMS: ReadonlyArray<SettingsSearchItem> = [
];

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"]);
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/ui/toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/composer-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
15 changes: 12 additions & 3 deletions apps/web/src/composer-logic.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ClientSettings } from "@t3tools/contracts/settings";
import type { AssistantCitation } from "@t3tools/contracts";
import {
serializeAssistantCitation,
Expand All @@ -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;
Expand All @@ -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";
}
Expand Down
9 changes: 9 additions & 0 deletions docs/user/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
),
Expand Down Expand Up @@ -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),
Expand Down
Loading