diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index bff16c88bbcc..331a764b1fbf 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -50,6 +50,7 @@ import { type ComposerSubmissionIntent, type ComposerTrigger, collapseExpandedComposerCursor, + composerEnterCommandAction, composerSubmissionIntentForEnter, detectComposerTrigger, expandCollapsedComposerCursor, @@ -3048,12 +3049,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const submitCitationAndSend = useCallback(() => { const intent = composerSubmissionIntentForEnter({ isMobileViewport, + sendKey: settings.composerSendKey, shiftKey: false, modifierKey: true, isDraftThread: routeKind === "draft", }); submitComposer(undefined, intent ?? "foreground"); - }, [isMobileViewport, routeKind, submitComposer]); + }, [isMobileViewport, routeKind, settings.composerSendKey, submitComposer]); const compactThreadContext = useCallback(() => { if ( compactDisabled || @@ -3220,9 +3222,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } const { trigger } = resolveActiveComposerTrigger(); const menuIsActive = composerMenuOpenRef.current || trigger !== null; + const currentItems = composerMenuItemsRef.current; + const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; if (menuIsActive) { - const currentItems = composerMenuItemsRef.current; - const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; if (key === "ArrowDown" && currentItems.length > 0) { nudgeComposerMenuHighlight("ArrowDown"); return true; @@ -3231,7 +3233,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) nudgeComposerMenuHighlight("ArrowUp"); return true; } - if ((key === "Enter" || key === "Tab") && selectedItem) { + if (key === "Tab" && selectedItem) { onSelectComposerItem(selectedItem); return true; } @@ -3239,18 +3241,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (key === "ArrowUp" || key === "ArrowDown") { return navigatePromptHistory(key === "ArrowUp" ? "backward" : "forward", event); } - const submissionIntent = - key === "Enter" - ? composerSubmissionIntentForEnter({ - isMobileViewport, - shiftKey: event.shiftKey, - modifierKey: event.metaKey || event.ctrlKey, - isDraftThread: routeKind === "draft", - }) - : null; - if (submissionIntent) { - submitComposer(undefined, submissionIntent); - return true; + if (key === "Enter") { + const action = composerEnterCommandAction({ + menuCanSelect: menuIsActive && selectedItem != null, + isMobileViewport, + sendKey: settings.composerSendKey, + shiftKey: event.shiftKey, + modifierKey: event.metaKey || event.ctrlKey, + isDraftThread: routeKind === "draft", + }); + if (action?.kind === "submit") { + submitComposer(undefined, action.intent); + return true; + } + if (action?.kind === "select-menu" && selectedItem) { + onSelectComposerItem(selectedItem); + return true; + } } return false; }; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 4c108b01d0c8..0666d1e17653 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -168,6 +168,13 @@ const ENVIRONMENT_IDENTIFICATION_LABELS: Record void) { ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll ? ["Collapse composer on scroll"] : []), + ...(settings.composerSendKey !== DEFAULT_UNIFIED_SETTINGS.composerSendKey + ? ["Send prompt with"] + : []), ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled ? ["Context window indicator"] : []), @@ -603,6 +613,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadDelete, settings.confirmThreadUnpin, settings.composerCollapseOnScroll, + settings.composerSendKey, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, @@ -708,6 +719,7 @@ export function useSettingsRestore(onRestored?: () => void) { proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, + composerSendKey: DEFAULT_UNIFIED_SETTINGS.composerSendKey, contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, @@ -2370,6 +2382,43 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ composerSendKey: DEFAULT_UNIFIED_SETTINGS.composerSendKey }) + } + /> + ) : null + } + control={ + + } + /> + { }); describe("composerSubmissionIntentForEnter", () => { + const desktop = { isMobileViewport: false, isDraftThread: true } as const; + it("submits plain Enter on desktop", () => { expect( composerSubmissionIntentForEnter({ - isMobileViewport: false, + ...desktop, + sendKey: "enter", shiftKey: false, modifierKey: false, - isDraftThread: true, }), ).toBe("foreground"); }); @@ -67,10 +70,11 @@ describe("composerSubmissionIntentForEnter", () => { it("inserts a newline for plain Enter on mobile", () => { expect( composerSubmissionIntentForEnter({ + ...desktop, isMobileViewport: true, + sendKey: "enter", shiftKey: false, modifierKey: false, - isDraftThread: true, }), ).toBeNull(); }); @@ -78,10 +82,10 @@ describe("composerSubmissionIntentForEnter", () => { it("inserts a newline for Shift+Enter", () => { expect( composerSubmissionIntentForEnter({ - isMobileViewport: false, + ...desktop, + sendKey: "enter", shiftKey: true, modifierKey: false, - isDraftThread: true, }), ).toBeNull(); }); @@ -89,10 +93,10 @@ describe("composerSubmissionIntentForEnter", () => { it("submits a new thread in the background with Mod+Enter", () => { expect( composerSubmissionIntentForEnter({ - isMobileViewport: false, + ...desktop, + sendKey: "enter", shiftKey: false, modifierKey: true, - isDraftThread: true, }), ).toBe("background"); }); @@ -100,13 +104,125 @@ describe("composerSubmissionIntentForEnter", () => { it("keeps Mod+Enter in the foreground for an active thread", () => { expect( composerSubmissionIntentForEnter({ - isMobileViewport: false, + ...desktop, + isDraftThread: false, + sendKey: "enter", shiftKey: false, modifierKey: true, - isDraftThread: false, }), ).toBe("foreground"); }); + + describe("with Mod+Enter as the send key", () => { + it.each([ + ["plain Enter", false], + ["Shift+Enter", true], + ])("inserts a newline for %s", (_label, shiftKey) => { + expect( + composerSubmissionIntentForEnter({ + ...desktop, + sendKey: "mod-enter", + shiftKey, + modifierKey: false, + }), + ).toBeNull(); + }); + + it("submits a draft in the foreground with Mod+Enter", () => { + expect( + composerSubmissionIntentForEnter({ + ...desktop, + sendKey: "mod-enter", + shiftKey: false, + modifierKey: true, + }), + ).toBe("foreground"); + }); + + it("moves the background start to Shift+Mod+Enter", () => { + expect( + composerSubmissionIntentForEnter({ + ...desktop, + sendKey: "mod-enter", + shiftKey: true, + modifierKey: true, + }), + ).toBe("background"); + expect( + composerSubmissionIntentForEnter({ + ...desktop, + isDraftThread: false, + sendKey: "mod-enter", + shiftKey: true, + modifierKey: true, + }), + ).toBe("foreground"); + }); + }); +}); + +describe("composerEnterCommandAction", () => { + const desktop = { isMobileViewport: false, isDraftThread: true } as const; + + it("lets the completion menu consume unmodified Enter", () => { + expect( + composerEnterCommandAction({ + ...desktop, + menuCanSelect: true, + sendKey: "enter", + shiftKey: false, + modifierKey: false, + }), + ).toEqual({ kind: "select-menu" }); + }); + + it("sends with Mod+Enter even when the completion menu is open", () => { + expect( + composerEnterCommandAction({ + ...desktop, + menuCanSelect: true, + sendKey: "mod-enter", + shiftKey: false, + modifierKey: true, + }), + ).toEqual({ kind: "submit", intent: "foreground" }); + }); + + it("starts a background draft with Shift+Mod+Enter while the menu is open", () => { + expect( + composerEnterCommandAction({ + ...desktop, + menuCanSelect: true, + sendKey: "mod-enter", + shiftKey: true, + modifierKey: true, + }), + ).toEqual({ kind: "submit", intent: "background" }); + }); + + it("still lets the menu consume Enter when send is Mod+Enter", () => { + expect( + composerEnterCommandAction({ + ...desktop, + menuCanSelect: true, + sendKey: "mod-enter", + shiftKey: false, + modifierKey: false, + }), + ).toEqual({ kind: "select-menu" }); + }); + + it("submits Mod+Enter as a background draft when send is Enter", () => { + expect( + composerEnterCommandAction({ + ...desktop, + menuCanSelect: true, + sendKey: "enter", + shiftKey: false, + modifierKey: true, + }), + ).toEqual({ kind: "submit", intent: "background" }); + }); }); describe("detectComposerTrigger", () => { diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 4a5b7022adc7..5e701366af22 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,4 +1,4 @@ -import type { AssistantCitation } from "@t3tools/contracts"; +import type { AssistantCitation, ComposerSendKey } from "@t3tools/contracts"; import { serializeAssistantCitation, withAssistantCitationComment, @@ -26,14 +26,49 @@ export function formatAssistantCitationForComposer(citation: AssistantCitation, export function composerSubmissionIntentForEnter(input: { isMobileViewport: boolean; + sendKey: ComposerSendKey; shiftKey: boolean; modifierKey: boolean; isDraftThread: boolean; }): ComposerSubmissionIntent | null { - if (input.isMobileViewport || input.shiftKey) { + if (input.isMobileViewport) { return null; } - return input.modifierKey && input.isDraftThread ? "background" : "foreground"; + const { sendKey, shiftKey, modifierKey } = input; + const sends = sendKey === "mod-enter" ? modifierKey : !shiftKey; + if (!sends) { + return null; + } + // Mod+Enter starts a draft in the background; when it is the send key itself, + // Shift+Mod+Enter takes over that role. + const background = sendKey === "mod-enter" ? modifierKey && shiftKey : modifierKey; + return background && input.isDraftThread ? "background" : "foreground"; +} + +export type ComposerEnterCommandAction = + | { kind: "submit"; intent: ComposerSubmissionIntent } + | { kind: "select-menu" }; + +/** Unmodified Enter confirms a completion. Modified Enter is a send chord. */ +export function composerEnterCommandAction(input: { + menuCanSelect: boolean; + isMobileViewport: boolean; + sendKey: ComposerSendKey; + shiftKey: boolean; + modifierKey: boolean; + isDraftThread: boolean; +}): ComposerEnterCommandAction | null { + const intent = composerSubmissionIntentForEnter(input); + if (intent && input.modifierKey) { + return { kind: "submit", intent }; + } + if (input.menuCanSelect) { + return { kind: "select-menu" }; + } + if (intent) { + return { kind: "submit", intent }; + } + return null; } const isInlineTokenSegment = (segment: ComposerPromptSegment): boolean => segment.type !== "text"; diff --git a/docs/user/composer.md b/docs/user/composer.md index 4a8df5333664..241b0f50904c 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -56,6 +56,14 @@ is unavailable or has changed, the saved quote remains readable. Mobile displays saved quotes and comments, but does not create citations or navigate to their sources. +## Require Cmd/Ctrl+Enter to send + +On web and desktop, Enter sends the prompt and Shift+Enter starts a new line. If +you keep sending drafts early, switch Settings → General → Behavior → +**Send prompt with** to `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and +Linux. Enter then starts a new line, and `Shift+Cmd+Enter` on macOS or +`Shift+Ctrl+Enter` on Windows and Linux starts a new thread in the background. + ## Recall a sent prompt Press `ArrowUp` in an empty composer to bring back the last prompt you sent in this thread. Press diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index b2c244a6123e..076676cfc33c 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -18,7 +18,8 @@ if that project exists there. Otherwise it selects an environment that has it. In a desktop browser or the desktop app, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux to start a new thread and immediately open another draft. The next draft keeps the workspace mode and base branch you selected. With **New -worktree**, each background submission creates its own worktree. +worktree**, each background submission creates its own worktree. If `Cmd+Enter` +is your send key, hold Shift as well. ## Pin and reorder threads diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3491103da94f..394dd54e46e6 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -44,6 +44,11 @@ export const DiffLayout = Schema.Literals(["stacked", "split"]); export type DiffLayout = typeof DiffLayout.Type; const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked"; +// Which Enter chord sends the composer prompt. "mod" is Cmd on macOS and Ctrl elsewhere. +export const ComposerSendKey = Schema.Literals(["enter", "mod-enter"]); +export type ComposerSendKey = typeof ComposerSendKey.Type; +const DEFAULT_COMPOSER_SEND_KEY: ComposerSendKey = "enter"; + export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; @@ -406,6 +411,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))), + composerSendKey: ComposerSendKey.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_COMPOSER_SEND_KEY)), + ), proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Legacy sidebar (the original per-project tree). Deliberately a fresh key @@ -1352,6 +1360,7 @@ export const ClientSettingsPatch = Schema.Struct({ planModeEnabled: Schema.optionalKey(Schema.Boolean), contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), + composerSendKey: Schema.optionalKey(ComposerSendKey), proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),