Skip to content
Open
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
39 changes: 23 additions & 16 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
type ComposerSubmissionIntent,
type ComposerTrigger,
collapseExpandedComposerCursor,
composerEnterCommandAction,
composerSubmissionIntentForEnter,
detectComposerTrigger,
expandCollapsedComposerCursor,
Expand Down Expand Up @@ -3078,12 +3079,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 ||
Expand Down Expand Up @@ -3250,9 +3252,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;
Expand All @@ -3261,26 +3263,31 @@ 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;
}
}
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;
};
Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@ const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, s
none: "None",
};

const MOD_KEY_LABEL = isMacPlatform(navigator.platform) ? "Cmd" : "Ctrl";

const COMPOSER_SEND_KEY_LABELS = {
enter: "Enter",
"mod-enter": `${MOD_KEY_LABEL}+Enter`,
} as const;

const TIMESTAMP_FORMAT_LABELS = {
locale: "System default",
"12-hour": "12-hour",
Expand Down Expand Up @@ -545,6 +552,9 @@ export function useSettingsRestore(onRestored?: () => 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"]
: []),
Expand Down Expand Up @@ -603,6 +613,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.confirmThreadDelete,
settings.confirmThreadUnpin,
settings.composerCollapseOnScroll,
settings.composerSendKey,
settings.addProjectBaseDirectory,
settings.defaultThreadEnvMode,
settings.newWorktreesStartFromOrigin,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2370,6 +2382,43 @@ export function GeneralSettingsPanel() {
}
/>

<SettingsRow
{...searchableSetting("composer-send-key")}
description={`Set the prompt sending key to ${MOD_KEY_LABEL}+Enter to keep a stray Enter from sending an unfinished prompt.`}
resetAction={
settings.composerSendKey !== DEFAULT_UNIFIED_SETTINGS.composerSendKey ? (
<SettingResetButton
label="send prompt with"
onClick={() =>
updateSettings({ composerSendKey: DEFAULT_UNIFIED_SETTINGS.composerSendKey })
}
/>
) : null
}
control={
<Select
value={settings.composerSendKey}
onValueChange={(value) => {
if (value === "enter" || value === "mod-enter") {
updateSettings({ composerSendKey: value });
}
}}
>
<SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Send prompt with">
<SelectValue>{COMPOSER_SEND_KEY_LABELS[settings.composerSendKey]}</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem hideIndicator value="enter">
{COMPOSER_SEND_KEY_LABELS.enter}
</SelectItem>
<SelectItem hideIndicator value="mod-enter">
{COMPOSER_SEND_KEY_LABELS["mod-enter"]}
</SelectItem>
</SelectPopup>
</Select>
}
/>

<SettingsRow
serverScoped
{...searchableSetting("provider-update-checks")}
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
searchTerms: ["composer rest resting scroll wheel conversation timeline shrink minimize"],
},
{
id: "composer-send-key",
title: "Send prompt with",
to: "/settings/general",
searchTerms: [
"enter cmd command ctrl control return submit accidental keyboard shortcut composer",
],
},
{
id: "provider-update-checks",
title: "Provider update checks",
Expand Down
134 changes: 125 additions & 9 deletions apps/web/src/composer-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
clampCollapsedComposerCursor,
collapseExpandedComposerCursor,
composerEnterCommandAction,
composerSubmissionIntentForEnter,
detectComposerTrigger,
expandCollapsedComposerCursor,
Expand Down Expand Up @@ -53,60 +54,175 @@ describe("formatAssistantCitationForComposer", () => {
});

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");
});

it("inserts a newline for plain Enter on mobile", () => {
expect(
composerSubmissionIntentForEnter({
...desktop,
isMobileViewport: true,
sendKey: "enter",
shiftKey: false,
modifierKey: false,
isDraftThread: true,
}),
).toBeNull();
});

it("inserts a newline for Shift+Enter", () => {
expect(
composerSubmissionIntentForEnter({
isMobileViewport: false,
...desktop,
sendKey: "enter",
shiftKey: true,
modifierKey: false,
isDraftThread: true,
}),
).toBeNull();
});

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");
});

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", () => {
Expand Down
Loading
Loading