From 7f6a805e0e011c44b0596054bc9bbf600a1ca30c Mon Sep 17 00:00:00 2001 From: Luke LaBonte Date: Mon, 26 Oct 2026 22:05:16 -0500 Subject: [PATCH] feat(app): choose the composer send shortcut (#5503) Add a client-local Send shortcut selector with Enter and Command/Ctrl+Enter choices. Preserve Enter as the default and route the selected shortcut through the existing send behavior. Cover selection, persistence, editing, and running-agent send behavior with browser tests. Co-authored-by: Luke LaBonte --- .../settings-toggle-tab-regression.spec.ts | 121 ++++++++++ packages/app/src/composer/index.tsx | 1 + packages/app/src/composer/input/input.tsx | 38 +++- packages/app/src/composer/input/state.test.ts | 208 ++++++++++++++++++ packages/app/src/composer/input/state.ts | 24 ++ packages/app/src/hooks/use-settings/index.ts | 2 + .../src/hooks/use-settings/storage.test.ts | 33 +++ .../app/src/hooks/use-settings/storage.ts | 5 + packages/app/src/i18n/resources/ar.ts | 13 ++ packages/app/src/i18n/resources/en.ts | 14 ++ packages/app/src/i18n/resources/es.ts | 15 ++ packages/app/src/i18n/resources/fr.ts | 15 ++ packages/app/src/i18n/resources/ja.ts | 13 ++ packages/app/src/i18n/resources/ko.ts | 15 ++ packages/app/src/i18n/resources/pt-BR.ts | 15 ++ packages/app/src/i18n/resources/ru.ts | 15 ++ packages/app/src/i18n/resources/zh-CN.ts | 12 + .../settings/general/sending-section.tsx | 33 ++- 18 files changed, 586 insertions(+), 6 deletions(-) diff --git a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts index 41da8ac3fa..35ec6f78bb 100644 --- a/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts +++ b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts @@ -1,3 +1,4 @@ +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; import { expect, test } from "../support/fixtures"; import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab"; @@ -274,3 +275,123 @@ test.describe("Settings toggle tab regression", () => { } }); }); + +async function selectSendingOption( + page: import("@playwright/test").Page, + setting: string, + option: string, +) { + await page.getByRole("button", { name: new RegExp(`^${setting}:`) }).click(); + await page.getByRole("menuitem", { name: option, exact: true }).click(); + await expect( + page.getByRole("button", { name: `${setting}: ${option}`, exact: true }), + ).toBeVisible(); +} + +async function sendDraftWithShortcut( + page: import("@playwright/test").Page, + draft: string, + shortcut: string, +) { + const composer = page.getByRole("textbox", { name: "Message agent..." }); + await composer.fill(draft); + await composer.press(shortcut); + await expect(composer).toHaveValue(""); + await expect(page.getByText(draft, { exact: true })).toBeVisible(); +} + +test("send shortcut selection persists and controls the composer", async ({ page }, testInfo) => { + test.setTimeout(180_000); + const workspace = await seedWorkspace({ repoPrefix: "send-shortcut-" }); + const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl"; + try { + const agent = await createMockIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Send shortcut", + }); + await openAgentRouteAndExpectFocused({ + page, + serverId: getServerId(), + workspaceId: workspace.workspaceId, + agentId: agent.id, + }); + await openComposerTab(page, agent.id); + const composer = page.getByRole("textbox", { name: "Message agent..." }); + + await test.step("Enter sends by default", async () => { + await sendDraftWithShortcut(page, "Default Enter submission", "Enter"); + }); + await test.step("choose the explicit modifier shortcut and retain it after reload", async () => { + await pressSettingsToggleShortcut(page); + await expect( + page.getByRole("button", { name: "Send shortcut: Enter", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Send shortcut: Enter", exact: true }).click(); + await expect(page.getByRole("menuitem")).toHaveText(["Enter", `${modifierLabel}+Enter`]); + // Let the menu opening animation settle for visual QA. + await page.waitForTimeout(300); + await page.screenshot({ path: testInfo.outputPath("send-shortcut-selector.png") }); + await page.getByRole("menuitem", { name: `${modifierLabel}+Enter`, exact: true }).click(); + await page.reload(); + await expect( + page.getByRole("button", { name: `Send shortcut: ${modifierLabel}+Enter`, exact: true }), + ).toBeVisible(); + await pressSettingsToggleShortcut(page); + await expect(composer).toBeVisible(); + }); + await test.step("Enter and Shift+Enter keep editing; modifier Enter sends", async () => { + await composer.fill("First line"); + await composer.press("Enter"); + await expect(composer).toHaveValue("First line\n"); + await composer.press("Shift+Enter"); + await expect(composer).toHaveValue("First line\n\n"); + await sendDraftWithShortcut(page, "Sent with Control", "Control+Enter"); + await sendDraftWithShortcut(page, "Sent with Command", "Meta+Enter"); + }); + await test.step("switch back to Enter without changing the default send behavior", async () => { + await pressSettingsToggleShortcut(page); + await selectSendingOption(page, "Send shortcut", "Enter"); + await pressSettingsToggleShortcut(page); + await composer.fill("Shift still edits"); + await composer.press("Shift+Enter"); + await expect(composer).toHaveValue("Shift still edits\n"); + await sendDraftWithShortcut(page, "Restored Enter submission", "Enter"); + }); + } finally { + await workspace.cleanup(); + } +}); + +for (const behavior of ["Steer", "Interrupt", "Queue"] as const) { + test(`modifier send shortcut follows ${behavior} while an agent runs`, async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "running-send-shortcut-", + title: "Running send shortcut", + model: "one-minute-stream", + }); + const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl"; + try { + await openAgentRoute(page, agent); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); + await pressSettingsToggleShortcut(page); + await selectSendingOption(page, "Send shortcut", `${modifierLabel}+Enter`); + await selectSendingOption(page, "Default send", behavior); + await pressSettingsToggleShortcut(page); + await sendDraftWithShortcut(page, "Stay running while I write a follow-up", "Control+Enter"); + await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible(); + const composer = page.getByRole("textbox", { name: "Message agent..." }); + await composer.fill("Follow-up"); + await composer.press("Enter"); + await expect(composer).toHaveValue("Follow-up\n"); + await composer.press("Control+Enter"); + await expect(composer).toHaveValue(""); + await expect(page.getByText("Follow-up", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Send queued message" })).toHaveCount( + behavior === "Queue" ? 1 : 0, + ); + } finally { + await agent.cleanup(); + } + }); +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index d000c269af..b7088ddeef 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -2495,6 +2495,7 @@ function ComposerContentImpl({ voiceAgentId={agentId} isAgentRunning={isAgentRunning} defaultSendBehavior={activeSendBehavior} + sendShortcut={appSettings.sendShortcut} onQueue={handleQueue} onSubmitLoadingPress={submitLoadingPressHandler} onKeyPress={handleCommandKeyPress} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 73c54331d1..16acede7e5 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1,3 +1,4 @@ +import type { SendShortcut } from "@/hooks/use-settings"; import { useComposerEditingSession, useComposerFullscreen, @@ -88,6 +89,7 @@ import { resolveAlternateSendAction, resolveComposerSurfacePresentation, resolveComposerSurfaceText, + resolveMessageInputEnterAction, resolveRetainedSelection, runAlternateSendAction, runDefaultSendAction, @@ -96,6 +98,11 @@ import { } from "./state"; const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]]; +const MOD_ENTER_SEND_KEYS: ShortcutKey[][] = [["mod", "Enter"]]; + +function resolveSendKeys(sendShortcut: SendShortcut): ShortcutKey[][] { + return sendShortcut === "mod-enter" ? MOD_ENTER_SEND_KEYS : DEFAULT_SEND_KEYS; +} const COMPOSER_INPUT_DATASET = { composerInput: "" } as const; export interface AttachmentMenuItem { @@ -167,6 +174,7 @@ export interface MessageInputProps { * running. "interrupt" and "steer" send immediately, "queue" queues. Required so the default * lives only in DEFAULT_CLIENT_SETTINGS. */ defaultSendBehavior: "interrupt" | "steer" | "queue"; + sendShortcut: SendShortcut; /** Callback for queue button when agent is running */ onQueue?: (payload: MessagePayload) => void; /** Optional handler used when submit button is in loading state. */ @@ -402,6 +410,9 @@ interface DesktopKeyPressContext { onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined; input: ComposerKeyPressEvent["input"]; submitOnEnter: boolean; + sendShortcut: SendShortcut; + isAgentRunning: boolean; + onQueue: ((payload: MessagePayload) => void) | undefined; isSubmitDisabled: boolean; isSubmitLoading: boolean; disabled: boolean; @@ -428,12 +439,21 @@ function handleDesktopKeyPressImpl( } if (event.nativeEvent.key !== "Enter") return; - if (!ctx.submitOnEnter) return; - if (shiftKey) return; + + const action = resolveMessageInputEnterAction({ + submitOnEnter: ctx.submitOnEnter, + sendShortcut: ctx.sendShortcut, + shiftKey: shiftKey ?? false, + metaKey: metaKey ?? false, + ctrlKey: ctrlKey ?? false, + isAgentRunning: ctx.isAgentRunning, + onQueue: ctx.onQueue, + }); + if (action === null) return; if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return; event.preventDefault(); - if (metaKey || ctrlKey) { + if (action === "alternate-send") { ctx.handleAlternateSendAction(); return; } @@ -1112,6 +1132,7 @@ interface ResolvedMessageInputProps { voiceAgentId: string | undefined; isAgentRunning: boolean; defaultSendBehavior: "interrupt" | "steer" | "queue"; + sendShortcut: SendShortcut; onQueue: ((payload: MessagePayload) => void) | undefined; onSubmitLoadingPress: (() => void) | undefined; onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined; @@ -1159,6 +1180,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu voiceAgentId: props.voiceAgentId, isAgentRunning: props.isAgentRunning ?? false, defaultSendBehavior: props.defaultSendBehavior, + sendShortcut: props.sendShortcut, onQueue: props.onQueue, onSubmitLoadingPress: props.onSubmitLoadingPress, onKeyPressCallback: props.onKeyPress, @@ -1236,6 +1258,7 @@ export const MessageInput = forwardRef( voiceAgentId, isAgentRunning, defaultSendBehavior, + sendShortcut, onQueue, onSubmitLoadingPress, onKeyPressCallback, @@ -1755,6 +1778,9 @@ export const MessageInput = forwardRef( selectionRef.current, ), submitOnEnter: shouldSubmitOnEnter, + sendShortcut, + isAgentRunning, + onQueue, isSubmitDisabled, isSubmitLoading, disabled, @@ -1766,6 +1792,9 @@ export const MessageInput = forwardRef( shouldHandleWebKeyPress, onKeyPressCallback, shouldSubmitOnEnter, + sendShortcut, + isAgentRunning, + onQueue, isSubmitDisabled, isSubmitLoading, disabled, @@ -1980,7 +2009,7 @@ export const MessageInput = forwardRef( submitLabel={submitLabel} submitButtonTestID={submitButtonTestID} buttonIconSize={buttonIconSize} - sendKeys={DEFAULT_SEND_KEYS} + sendKeys={resolveSendKeys(sendShortcut)} sendTooltipLabel={sendTooltipLabel} /> ), @@ -2001,6 +2030,7 @@ export const MessageInput = forwardRef( submitLabel, submitButtonTestID, buttonIconSize, + sendShortcut, sendTooltipLabel, ], ); diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index 82f30da364..ad492976b3 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it, vi } from "vitest"; import { applyDictationTranscript, computeCanStartDictation, + type MessageInputEnterContext, resolveActiveSendBehavior, resolveComposerSurfacePresentation, resolveComposerSurfaceText, resolveGlyphRailOffset, + resolveMessageInputEnterAction, resolveRetainedSelection, resolveAlternateSendAction, runAlternateSendAction, @@ -320,6 +322,212 @@ describe("composer send behavior", () => { }); }); +describe("resolveMessageInputEnterAction", () => { + function enterContext( + overrides: Partial = {}, + ): MessageInputEnterContext { + return { + submitOnEnter: true, + sendShortcut: "enter", + shiftKey: false, + metaKey: false, + ctrlKey: false, + isAgentRunning: false, + onQueue: () => undefined, + ...overrides, + }; + } + + it("returns null for plain, Shift, and modifier Enter when submit on enter is disabled", () => { + expect(resolveMessageInputEnterAction(enterContext({ submitOnEnter: false }))).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, shiftKey: true })), + ).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, metaKey: true })), + ).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, ctrlKey: true })), + ).toBeNull(); + }); + + it("still returns null when modifier Enter sends and submit on enter is disabled", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", submitOnEnter: false, metaKey: true }), + ), + ).toBeNull(); + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", submitOnEnter: false }), + ), + ).toBeNull(); + }); + + it("sends on plain Enter when Enter sends", () => { + expect(resolveMessageInputEnterAction(enterContext())).toBe("default-send"); + }); + + it("returns null for Shift+Enter when Enter sends", () => { + expect(resolveMessageInputEnterAction(enterContext({ shiftKey: true }))).toBeNull(); + }); + + it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter so Shift keeps its newline", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ shiftKey: true, metaKey: true, isAgentRunning: true }), + ), + ).toBeNull(); + expect( + resolveMessageInputEnterAction( + enterContext({ shiftKey: true, ctrlKey: true, isAgentRunning: true }), + ), + ).toBeNull(); + }); + + it("sends on Cmd+Enter and Ctrl+Enter while idle even with a queue handler", () => { + expect(resolveMessageInputEnterAction(enterContext({ metaKey: true }))).toBe("default-send"); + expect(resolveMessageInputEnterAction(enterContext({ ctrlKey: true }))).toBe("default-send"); + }); + + it("queues on Cmd+Enter and Ctrl+Enter while the agent is running with a queue handler", () => { + expect( + resolveMessageInputEnterAction(enterContext({ metaKey: true, isAgentRunning: true })), + ).toBe("alternate-send"); + expect( + resolveMessageInputEnterAction(enterContext({ ctrlKey: true, isAgentRunning: true })), + ).toBe("alternate-send"); + }); + + it("sends on Cmd+Enter and Ctrl+Enter while running when no queue handler exists", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ metaKey: true, isAgentRunning: true, onQueue: undefined }), + ), + ).toBe("default-send"); + expect( + resolveMessageInputEnterAction( + enterContext({ ctrlKey: true, isAgentRunning: true, onQueue: undefined }), + ), + ).toBe("default-send"); + }); + + it("returns null for plain and Shift+Enter when modifier Enter sends", () => { + expect(resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter" }))).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", shiftKey: true })), + ).toBeNull(); + }); + + it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter when modifier Enter sends", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", shiftKey: true, metaKey: true }), + ), + ).toBeNull(); + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", shiftKey: true, ctrlKey: true }), + ), + ).toBeNull(); + }); + + it("sends on Cmd+Enter and Ctrl+Enter while idle when modifier Enter sends", () => { + expect( + resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", metaKey: true })), + ).toBe("default-send"); + expect( + resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", ctrlKey: true })), + ).toBe("default-send"); + }); + + it("uses the default action on modifier Enter while running when modifier Enter sends", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }), + ), + ).toBe("default-send"); + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", ctrlKey: true, isAgentRunning: true }), + ), + ).toBe("default-send"); + }); + + describe("composed with the send actions", () => { + function actions() { + const calls: string[] = []; + return { + calls, + handleSendMessage: () => calls.push("send"), + handleQueueMessage: () => calls.push("queue"), + onQueue: () => undefined, + }; + } + + function resolveCommandEnterToSendModifierEnter() { + return resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }), + ); + } + + it("queues through the default send action when queue behavior is selected and the agent is running", () => { + const sendAction = actions(); + + expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send"); + + runDefaultSendAction({ + defaultSendBehavior: "queue", + isAgentRunning: true, + onQueue: sendAction.onQueue, + handleSendMessage: sendAction.handleSendMessage, + handleQueueMessage: sendAction.handleQueueMessage, + }); + + expect(sendAction.calls).toEqual(["queue"]); + }); + + it("sends through the default send action when queue behavior is selected and the agent is idle", () => { + const sendAction = actions(); + + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: false }), + ), + ).toBe("default-send"); + + runDefaultSendAction({ + defaultSendBehavior: "queue", + isAgentRunning: false, + onQueue: sendAction.onQueue, + handleSendMessage: sendAction.handleSendMessage, + handleQueueMessage: sendAction.handleQueueMessage, + }); + + expect(sendAction.calls).toEqual(["send"]); + }); + + it.each([{ defaultSendBehavior: "steer" }, { defaultSendBehavior: "interrupt" }] as const)( + "sends through the default send action when $defaultSendBehavior behavior is selected and the agent is running", + ({ defaultSendBehavior }) => { + const sendAction = actions(); + + expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send"); + + runDefaultSendAction({ + defaultSendBehavior, + isAgentRunning: true, + onQueue: sendAction.onQueue, + handleSendMessage: sendAction.handleSendMessage, + handleQueueMessage: sendAction.handleQueueMessage, + }); + + expect(sendAction.calls).toEqual(["send"]); + }, + ); + }); +}); + describe("stopRealtimeVoice", () => { it("keeps voice mode active when the running agent refuses cancellation", async () => { const cancellationError = new Error("active run cancellation was not acknowledged"); diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index 9cf6279482..67c8030745 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -1,3 +1,4 @@ +import type { SendShortcut } from "@/hooks/use-settings"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; import type { MessagePayload, TextReplacement } from "@/composer/types"; @@ -151,6 +152,29 @@ export function runAlternateSendAction(ctx: SendActionContext): void { runDefaultSendAction(ctx); } +export type MessageInputEnterAction = "alternate-send" | "default-send"; + +export interface MessageInputEnterContext { + submitOnEnter: boolean; + sendShortcut: SendShortcut; + shiftKey: boolean; + metaKey: boolean; + ctrlKey: boolean; + isAgentRunning: boolean; + onQueue: ((payload: MessagePayload) => void) | undefined; +} + +export function resolveMessageInputEnterAction( + ctx: MessageInputEnterContext, +): MessageInputEnterAction | null { + if (!ctx.submitOnEnter) return null; + if (ctx.shiftKey) return null; + const hasModifier = ctx.metaKey || ctx.ctrlKey; + if (ctx.sendShortcut === "mod-enter") return hasModifier ? "default-send" : null; + if (hasModifier && ctx.isAgentRunning && ctx.onQueue) return "alternate-send"; + return "default-send"; +} + export function runMessageInputKeyboardAction( action: MessageInputKeyboardActionKind, actions: MessageInputKeyboardActions, diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts index 9f6f22fb23..382ab350fc 100644 --- a/packages/app/src/hooks/use-settings/index.ts +++ b/packages/app/src/hooks/use-settings/index.ts @@ -49,6 +49,7 @@ import { type KeyValueStorage, type ReleaseChannel, type SendBehavior, + type SendShortcut, type ServiceUrlBehavior, type Settings, type SidebarWorkspaceTrailing, @@ -93,6 +94,7 @@ export type { KeyValueStorage, ReleaseChannel, SendBehavior, + SendShortcut, ServiceUrlBehavior, Settings, SettingsDeps, diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index fe2ffdd2a0..0b169b5214 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -227,6 +227,39 @@ describe("loadAppSettingsFromStorage", () => { expect(result.chatOutlineEnabled).toBe(false); }); + it("uses Enter to send by default", async () => { + const deps = makeDeps(); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.sendShortcut).toBe("enter"); + expect(DEFAULT_CLIENT_SETTINGS.sendShortcut).toBe("enter"); + }); + + it("loads the modifier Enter send preference", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "mod-enter" }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.sendShortcut).toBe("mod-enter"); + }); + + it("falls back to Enter when the stored send shortcut is invalid", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "yes" }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.sendShortcut).toBe("enter"); + }); + it("defaults sidebar navigation items to an empty preference list", async () => { const deps = makeDeps(); diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 3a5be63441..424536ac69 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -33,6 +33,8 @@ import { migrateAppSettings } from "./migrations"; export { APP_SETTINGS_KEY } from "./keys"; export const APP_SETTINGS_QUERY_KEY = ["app-settings"]; +export type SendShortcut = "enter" | "mod-enter"; + export type SendBehavior = ActiveTurnBehavior | "queue"; export type ReleaseChannel = "stable" | "beta"; export type ServiceUrlBehavior = "ask" | "in-app" | "external"; @@ -104,6 +106,7 @@ export interface AppSettings { chatDisplay: ChatDisplay; chatOutlineEnabled: boolean; vimKeybindings: boolean; + sendShortcut: SendShortcut; filePanelAssociations: Record; /** Desktop-only preferences for implicit opens into the ordinary side pane. */ openInSidePane: OpenInSidePanePreferences; @@ -160,6 +163,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { chatDisplay: "collapseTurns", chatOutlineEnabled: true, vimKeybindings: false, + sendShortcut: "enter", filePanelAssociations: {}, openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES, pullRequestOpenLocation: "explorer", @@ -264,6 +268,7 @@ const StoredAppSettingsSchema = z compactToolCalls: z.boolean().optional().catch(undefined), chatOutlineEnabled: z.boolean().catch(true), vimKeybindings: z.boolean().catch(false), + sendShortcut: z.enum(["enter", "mod-enter"]).catch("enter"), filePanelAssociations: z.record(z.string(), z.string()).catch({}), openInSidePane: z .object({ diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 108fad41d4..6bfc30095c 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2369,12 +2369,25 @@ export const ar: TranslationResources = { "عند تشغيل الوكيل، يوجّه Enter الجولة النشطة. يضيف Command/Ctrl+Enter إلى قائمة الانتظار.", queue: "عند تشغيل الوكيل، يضيف Enter إلى قائمة الانتظار. Command/Ctrl+Enter يرسل.", }, + commandEnterDescriptions: { + interrupt: "عند تشغيل الوكيل، يوقف {{modifier}}+Enter التشغيل. يُدرج Enter سطرًا جديدًا.", + steer: "عند تشغيل الوكيل، يوجّه {{modifier}}+Enter الجولة النشطة. يُدرج Enter سطرًا جديدًا.", + queue: + "عند تشغيل الوكيل، يضيف {{modifier}}+Enter إلى قائمة الانتظار. يُدرج Enter سطرًا جديدًا.", + }, options: { interrupt: "مقاطعة", steer: "توجيه", queue: "طابور", }, }, + sendShortcut: { + label: "اختصار الإرسال", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "بسأل", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 16bddd9cf9..ff1e80d88b 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2457,12 +2457,26 @@ export const en = { "When the agent is running, Enter steers the active turn. Command/Ctrl+Enter queues.", queue: "When the agent is running, Enter queues. Command/Ctrl+Enter submits.", }, + commandEnterDescriptions: { + interrupt: + "When the agent is running, {{modifier}}+Enter interrupts. Enter inserts a new line.", + steer: + "When the agent is running, {{modifier}}+Enter steers the active turn. Enter inserts a new line.", + queue: "When the agent is running, {{modifier}}+Enter queues. Enter inserts a new line.", + }, options: { interrupt: "Interrupt", steer: "Steer", queue: "Queue", }, }, + sendShortcut: { + label: "Send shortcut", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Ask", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 2226583781..8784adefb5 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2384,12 +2384,27 @@ export const es: TranslationResources = { queue: "Cuando el agente se está ejecutando, Enter pone en cola. Command/Ctrl+Enter envía.", }, + commandEnterDescriptions: { + interrupt: + "Cuando el agente se está ejecutando, {{modifier}}+Enter interrumpe. Enter inserta una línea nueva.", + steer: + "Cuando el agente se está ejecutando, {{modifier}}+Enter dirige el turno activo. Enter inserta una línea nueva.", + queue: + "Cuando el agente se está ejecutando, {{modifier}}+Enter pone en cola. Enter inserta una línea nueva.", + }, options: { interrupt: "Interrumpir", steer: "Dirigir", queue: "Cola", }, }, + sendShortcut: { + label: "Atajo de envío", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Preguntar", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 65d72d405b..bbafc03dda 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2383,12 +2383,27 @@ export const fr: TranslationResources = { queue: "Quand l’agent est en cours d’exécution, Entrée met en file d’attente. Cmd/Ctrl+Entrée envoie.", }, + commandEnterDescriptions: { + interrupt: + "Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée interrompt. Entrée insère une nouvelle ligne.", + steer: + "Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée oriente le tour actif. Entrée insère une nouvelle ligne.", + queue: + "Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée met en file d'attente. Entrée insère une nouvelle ligne.", + }, options: { interrupt: "Interrompre", steer: "Réorienter", queue: "File d’attente", }, }, + sendShortcut: { + label: "Raccourci d’envoi", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Demander", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 5829bf4fa3..e7dcc5b5e7 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2353,12 +2353,25 @@ export const ja: TranslationResources = { "エージェント実行中、Enterで現在のターンに指示を送ります。Command/Ctrl+Enterでキューに追加。", queue: "エージェント実行中、Enterでキューに追加します。Command/Ctrl+Enterで送信。", }, + commandEnterDescriptions: { + interrupt: "エージェント実行中、{{modifier}}+Enterで中断します。Enterで改行します。", + steer: + "エージェント実行中、{{modifier}}+Enterで現在のターンに指示を送ります。Enterで改行します。", + queue: "エージェント実行中、{{modifier}}+Enterでキューに追加します。Enterで改行します。", + }, options: { interrupt: "中断", steer: "指示", queue: "キュー", }, }, + sendShortcut: { + label: "送信ショートカット", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "確認する", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 36a27fc84e..d8d1fbc787 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2344,12 +2344,27 @@ export const ko: TranslationResources = { queue: "에이전트가 실행 중일 때 Enter는 대기열에 추가합니다. Command/Ctrl+Enter는 제출합니다.", }, + commandEnterDescriptions: { + interrupt: + "에이전트가 실행 중일 때 {{modifier}}+Enter는 중단합니다. Enter는 줄바꿈합니다.", + steer: + "에이전트가 실행 중일 때 {{modifier}}+Enter는 현재 턴에 지시합니다. Enter는 줄바꿈합니다.", + queue: + "에이전트가 실행 중일 때 {{modifier}}+Enter는 대기열에 추가합니다. Enter는 줄바꿈합니다.", + }, options: { interrupt: "중단", steer: "지시", queue: "대기열", }, }, + sendShortcut: { + label: "전송 단축키", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "물어보기", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 5a83b4345c..1c024dc64a 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2367,12 +2367,27 @@ export const ptBR: TranslationResources = { "Quando o agente está em execução, Enter orienta o turno ativo. Command/Ctrl+Enter enfileira.", queue: "Quando o agente está em execução, Enter enfileira. Command/Ctrl+Enter envia.", }, + commandEnterDescriptions: { + interrupt: + "Quando o agente está em execução, {{modifier}}+Enter interrompe. Enter insere uma nova linha.", + steer: + "Quando o agente está em execução, {{modifier}}+Enter orienta o turno ativo. Enter insere uma nova linha.", + queue: + "Quando o agente está em execução, {{modifier}}+Enter enfileira. Enter insere uma nova linha.", + }, options: { interrupt: "Interromper", steer: "Orientar", queue: "Fila", }, }, + sendShortcut: { + label: "Atalho de envio", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Perguntar", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 135d5fe2f9..093ac68c36 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2370,12 +2370,27 @@ export const ru: TranslationResources = { queue: "Когда агент работает, Enter ставит сообщение в очередь, а Command/Ctrl+Enter отправляет его.", }, + commandEnterDescriptions: { + interrupt: + "Когда агент работает, {{modifier}}+Enter прерывает его работу, а Enter вставляет новую строку.", + steer: + "Когда агент работает, {{modifier}}+Enter дополняет текущий ответ новым указанием, а Enter вставляет новую строку.", + queue: + "Когда агент работает, {{modifier}}+Enter ставит сообщение в очередь, а Enter вставляет новую строку.", + }, options: { interrupt: "Прерывать", steer: "Направить", queue: "Поставить в очередь", }, }, + sendShortcut: { + label: "Сочетание клавиш для отправки", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Спрашивать", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 78b0f06442..8ca178f206 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2308,12 +2308,24 @@ export const zhCN: TranslationResources = { steer: "Agent 运行时,Enter 会引导当前回合。Command/Ctrl+Enter 会排队。", queue: "Agent 运行时,Enter 会排队。Command/Ctrl+Enter 会提交。", }, + commandEnterDescriptions: { + interrupt: "Agent 运行时,{{modifier}}+Enter 会中断。Enter 会插入换行。", + steer: "Agent 运行时,{{modifier}}+Enter 会引导当前回合。Enter 会插入换行。", + queue: "Agent 运行时,{{modifier}}+Enter 会排队。Enter 会插入换行。", + }, options: { interrupt: "中断", steer: "引导", queue: "排队", }, }, + sendShortcut: { + label: "发送快捷键", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "询问", diff --git a/packages/app/src/screens/settings/general/sending-section.tsx b/packages/app/src/screens/settings/general/sending-section.tsx index 85da0fdf4e..c5d5f01760 100644 --- a/packages/app/src/screens/settings/general/sending-section.tsx +++ b/packages/app/src/screens/settings/general/sending-section.tsx @@ -1,13 +1,17 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { SettingsCard, SettingsSection, SettingsSelect } from "@/components/settings"; -import { useAppSettings, type SendBehavior } from "@/hooks/use-settings"; +import { useAppSettings, type SendShortcut, type SendBehavior } from "@/hooks/use-settings"; +import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; +import { getShortcutOs } from "@/utils/shortcut-platform"; const SEND_BEHAVIORS: readonly SendBehavior[] = ["interrupt", "steer", "queue"]; export function SendingSection() { const { t } = useTranslation(); const { settings, updateSettings } = useAppSettings(); + const shortcutsAvailable = useKeyboardShortcutsAvailable(); + const modifier = getShortcutOs() === "mac" ? "⌘" : "Ctrl"; const options = useMemo( () => SEND_BEHAVIORS.map((value) => ({ @@ -20,12 +24,37 @@ export function SendingSection() { (sendBehavior: SendBehavior) => void updateSettings({ sendBehavior }), [updateSettings], ); + const shortcutOptions = useMemo( + () => + (["enter", "mod-enter"] as const).map((value) => ({ + value, + label: t(`settings.general.sendShortcut.options.${value}`, { modifier }), + })), + [t, modifier], + ); + const changeSendShortcut = useCallback( + (sendShortcut: SendShortcut) => void updateSettings({ sendShortcut }), + [updateSettings], + ); return ( + {shortcutsAvailable ? ( + + ) : null}