| author | |
| committer | |
| log | f4ef5155fb8aab7776a41fc8ec73d5367f53ba1f |
| tree | 775471832b0f2f9c17bad372a47f79fb5ffafe42 |
| parent | 0b83045d00d88d0a0b0866fe4942a50a41a08471 |
| signature | Signed by PGP key B5690EEEBB952194 |
11 files changed, 172 insertions(+), 23 deletions(-)
apps/web/src/components/ChatView.tsx+1-1| ... | ... | @@ -7516,7 +7516,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7516 | 7516 | !directAnnotation && |
| 7517 | 7517 | phase === "running" && |
| 7518 | 7518 | activeThreadKey && |
| 7519 | settings.followUpBehavior === "queue" | |
| 7519 | (settings.followUpBehavior === "queue") !== (submissionIntent === "alternate") | |
| 7520 | 7520 | ) { |
| 7521 | 7521 | if (composerRef.current?.validateProviderInput(promptForSend) === false) { |
| 7522 | 7522 | return; |
apps/web/src/components/chat/ChatComposer.tsx+3| ... | ... | @@ -3955,6 +3955,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3955 | 3955 | shiftKey: event.shiftKey, |
| 3956 | 3956 | modifierKey: event.metaKey || event.ctrlKey, |
| 3957 | 3957 | isDraftThread: routeKind === "draft", |
| 3958 | isRunning: phase === "running", | |
| 3959 | sendShortcut: settings.sendShortcut, | |
| 3960 | prompt: promptRef.current, | |
| 3958 | 3961 | }) |
| 3959 | 3962 | : null; |
| 3960 | 3963 | if (submissionIntent) { |
apps/web/src/components/settings/SettingsPanels.tsx+76-19| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { Spinner } from "~/components/ui/spinner"; |
| 2 | 2 | import { NotificationSettings } from "./NotificationSettings"; |
| 3 | import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react"; | |
| 3 | import { ArchiveIcon, ArchiveX, CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react"; | |
| 4 | 4 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 5 | 5 | import type { CSSProperties, ReactNode } from "react"; |
| 6 | 6 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| ... | ... | @@ -105,6 +105,7 @@ import { |
| 105 | 105 | AlertDialogTitle, |
| 106 | 106 | } from "../ui/alert-dialog"; |
| 107 | 107 | import { Button } from "../ui/button"; |
| 108 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; | |
| 108 | 109 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| 109 | 110 | import { |
| 110 | 111 | Dialog, |
| ... | ... | @@ -578,6 +579,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 578 | 579 | ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll |
| 579 | 580 | ? ["Collapse composer on scroll"] |
| 580 | 581 | : []), |
| 582 | ...(settings.sendShortcut !== DEFAULT_UNIFIED_SETTINGS.sendShortcut ? ["Send shortcut"] : []), | |
| 581 | 583 | ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior |
| 582 | 584 | ? ["Follow-up behavior"] |
| 583 | 585 | : []), |
| ... | ... | @@ -639,6 +641,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 639 | 641 | settings.confirmThreadDelete, |
| 640 | 642 | settings.confirmThreadUnpin, |
| 641 | 643 | settings.composerCollapseOnScroll, |
| 644 | settings.sendShortcut, | |
| 642 | 645 | settings.followUpBehavior, |
| 643 | 646 | settings.addProjectBaseDirectory, |
| 644 | 647 | settings.defaultThreadEnvMode, |
| ... | ... | @@ -752,6 +755,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 752 | 755 | proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, |
| 753 | 756 | showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, |
| 754 | 757 | composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, |
| 758 | sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut, | |
| 755 | 759 | followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, |
| 756 | 760 | contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, |
| 757 | 761 | environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, |
| ... | ... | @@ -2086,6 +2090,12 @@ function LegacyFeaturesSection() { |
| 2086 | 2090 | } |
| 2087 | 2091 | |
| 2088 | 2092 | 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; | |
| 2089 | 2099 | const settings = useScopedSettings(); |
| 2090 | 2100 | const updateSettings = useUpdateScopedSettings(); |
| 2091 | 2101 | const navigate = useNavigate(); |
| ... | ... | @@ -2594,9 +2604,61 @@ export function GeneralSettingsPanel() { |
| 2594 | 2604 | } |
| 2595 | 2605 | /> |
| 2596 | 2606 | |
| 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 | ||
| 2597 | 2654 | <SettingsRow |
| 2598 | 2655 | {...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 | } | |
| 2600 | 2662 | resetAction={ |
| 2601 | 2663 | settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior ? ( |
| 2602 | 2664 | <SettingResetButton |
| ... | ... | @@ -2610,28 +2672,23 @@ export function GeneralSettingsPanel() { |
| 2610 | 2672 | ) : null |
| 2611 | 2673 | } |
| 2612 | 2674 | 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]; | |
| 2616 | 2681 | if (value === "queue" || value === "steer") { |
| 2617 | 2682 | updateSettings({ followUpBehavior: value }); |
| 2618 | 2683 | } |
| 2619 | 2684 | }} |
| 2620 | 2685 | > |
| 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> | |
| 2635 | 2692 | } |
| 2636 | 2693 | /> |
| 2637 | 2694 |
apps/web/src/components/settings/settingsSearch.test.ts+4| ... | ... | @@ -45,6 +45,10 @@ const ITEMS: ReadonlyArray<SettingsSearchItem> = [ |
| 45 | 45 | ]; |
| 46 | 46 | |
| 47 | 47 | 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 | ||
| 48 | 52 | it("matches titles, sections, and remembered setting details", () => { |
| 49 | 53 | expect(searchSettings("word", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); |
| 50 | 54 | expect(searchSettings("network", ITEMS).map((item) => item.id)).toEqual(["network-access"]); |
apps/web/src/components/settings/settingsSearch.ts+6| ... | ... | @@ -298,6 +298,12 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 298 | 298 | to: "/settings/general", |
| 299 | 299 | searchTerms: ["composer rest resting scroll wheel conversation timeline shrink minimize"], |
| 300 | 300 | }, |
| 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 | }, | |
| 301 | 307 | { |
| 302 | 308 | id: "follow-up-behavior", |
| 303 | 309 | title: "Follow-up behavior", |
apps/web/src/components/ui/toggle.tsx+1| ... | ... | @@ -25,6 +25,7 @@ const toggleVariants = cva( |
| 25 | 25 | }, |
| 26 | 26 | variant: { |
| 27 | 27 | default: "border-transparent", |
| 28 | pill: "border-transparent rounded-full px-3 font-normal text-muted-foreground data-pressed:text-foreground", | |
| 28 | 29 | ghost: |
| 29 | 30 | "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", |
| 30 | 31 | outline: |
apps/web/src/composer-logic.test.ts+45| ... | ... | @@ -61,6 +61,51 @@ describe("formatAssistantCitationForComposer", () => { |
| 61 | 61 | }); |
| 62 | 62 | |
| 63 | 63 | 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 | ||
| 64 | 109 | it("submits plain Enter on desktop", () => { |
| 65 | 110 | expect( |
| 66 | 111 | composerSubmissionIntentForEnter({ |
apps/web/src/composer-logic.ts+12-3| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { ClientSettings } from "@t3tools/contracts/settings"; | |
| 1 | 2 | import type { AssistantCitation } from "@t3tools/contracts"; |
| 2 | 3 | import { |
| 3 | 4 | serializeAssistantCitation, |
| ... | ... | @@ -10,7 +11,7 @@ import { |
| 10 | 11 | |
| 11 | 12 | export type ComposerTriggerKind = "path" | "pull-request" | "slash-command" | "skill"; |
| 12 | 13 | export type ComposerSlashCommand = "model" | "plan" | "default"; |
| 13 | export type ComposerSubmissionIntent = "foreground" | "background"; | |
| 14 | export type ComposerSubmissionIntent = "foreground" | "background" | "alternate"; | |
| 14 | 15 | |
| 15 | 16 | export interface ComposerTrigger { |
| 16 | 17 | kind: ComposerTriggerKind; |
| ... | ... | @@ -28,9 +29,17 @@ export function composerSubmissionIntentForEnter(input: { |
| 28 | 29 | shiftKey: boolean; |
| 29 | 30 | modifierKey: boolean; |
| 30 | 31 | isDraftThread: boolean; |
| 32 | isRunning?: boolean; | |
| 33 | sendShortcut?: ClientSettings["sendShortcut"]; | |
| 34 | prompt?: string; | |
| 31 | 35 | }): 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"; | |
| 34 | 43 | } |
| 35 | 44 | return input.modifierKey && input.isDraftThread ? "background" : "foreground"; |
| 36 | 45 | } |
docs/user/keybindings.md+9| ... | ... | @@ -5,6 +5,15 @@ also lists the command IDs and defaults available in your version. |
| 5 | 5 | |
| 6 | 6 | ## Composer controls |
| 7 | 7 | |
| 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 | ||
| 8 | 17 | Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. |
| 9 | 18 | Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the |
| 10 | 19 | workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the |
packages/contracts/src/settings.test.ts+11| ... | ... | @@ -529,6 +529,17 @@ describe("ClientSettings context window meter", () => { |
| 529 | 529 | }); |
| 530 | 530 | }); |
| 531 | 531 | |
| 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 | ||
| 532 | 543 | describe("ClientSettings follow-up behavior", () => { |
| 533 | 544 | it("defaults to queue and accepts either behavior", () => { |
| 534 | 545 | expect(decodeClientSettings({}).followUpBehavior).toBe("queue"); |
packages/contracts/src/settings.ts+4| ... | ... | @@ -430,6 +430,9 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 430 | 430 | // Desktop resting composer: scrolling an existing thread's conversation |
| 431 | 431 | // settles the composer into its single-line layout. Losing focus never does. |
| 432 | 432 | composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 433 | sendShortcut: Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"]).pipe( | |
| 434 | Schema.withDecodingDefault(Effect.succeed("enter")), | |
| 435 | ), | |
| 433 | 436 | followUpBehavior: Schema.Literals(["queue", "steer"]).pipe( |
| 434 | 437 | Schema.withDecodingDefault(Effect.succeed("queue")), |
| 435 | 438 | ), |
| ... | ... | @@ -1502,6 +1505,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 1502 | 1505 | planModeEnabled: Schema.optionalKey(Schema.Boolean), |
| 1503 | 1506 | contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), |
| 1504 | 1507 | composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), |
| 1508 | sendShortcut: Schema.optionalKey(Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"])), | |
| 1505 | 1509 | followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), |
| 1506 | 1510 | proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), |
| 1507 | 1511 | showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), |