From 426547bfcc5aa884765bfca3fbb276b3c6ce6392 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 12 Jun 2026 13:29:51 +0700 Subject: [PATCH] Fix composer send shortcut conflicts --- packages/app/src/composer/input/input.tsx | 35 ++--------- packages/app/src/composer/input/state.test.ts | 60 ++++++++++++++++++- packages/app/src/composer/input/state.ts | 29 +++++++++ packages/app/src/i18n/resources.test.ts | 2 +- packages/app/src/i18n/resources/ar.ts | 6 +- packages/app/src/i18n/resources/en.ts | 5 +- packages/app/src/i18n/resources/es.ts | 7 ++- packages/app/src/i18n/resources/fr.ts | 8 ++- packages/app/src/i18n/resources/ru.ts | 5 +- packages/app/src/i18n/resources/zh-CN.ts | 5 +- packages/app/src/keyboard/actions.ts | 1 - .../src/keyboard/keyboard-shortcuts.test.ts | 24 ++++++++ .../app/src/keyboard/keyboard-shortcuts.ts | 48 --------------- .../app/src/keyboard/route-shortcut.test.ts | 9 --- packages/app/src/keyboard/route-shortcut.ts | 1 - packages/app/src/screens/settings-screen.tsx | 8 ++- 16 files changed, 152 insertions(+), 101 deletions(-) diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index ac658001c..5e74c4043 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -54,7 +54,7 @@ import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss"; import { useWebElementScrollbar } from "@/components/use-web-scrollbar"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useIosHardwareKeyboardSubmit } from "@/hooks/use-ios-hardware-keyboard-submit"; -import { formatShortcut } from "@/utils/format-shortcut"; +import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; @@ -67,7 +67,9 @@ import { resolveVoiceAccessibilityLabel, resolveVoiceTooltipText, } from "./labels"; -import { computeCanStartDictation } from "./state"; +import { computeCanStartDictation, runAlternateSendAction, runDefaultSendAction } from "./state"; + +const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]]; export interface AttachmentMenuItem { id: string; @@ -1123,32 +1125,6 @@ function computeSendButtonState(input: SendButtonStateInput): SendButtonStateOut return { canPressLoadingButton, isSendButtonDisabled, defaultActionQueues }; } -interface DefaultSendActionContext { - defaultSendBehavior: "interrupt" | "queue"; - isAgentRunning: boolean; - onQueue: ((payload: MessagePayload) => void) | undefined; - handleSendMessage: () => void; - handleQueueMessage: () => void; -} - -function runDefaultSendAction(ctx: DefaultSendActionContext): void { - if (ctx.defaultSendBehavior === "queue" && ctx.isAgentRunning && ctx.onQueue) { - ctx.handleQueueMessage(); - return; - } - ctx.handleSendMessage(); -} - -function runAlternateSendAction(ctx: DefaultSendActionContext): void { - if (ctx.defaultSendBehavior === "queue") { - ctx.handleSendMessage(); - return; - } - if (ctx.onQueue) { - ctx.handleQueueMessage(); - } -} - interface ResolvedMessageInputProps { value: string; onChangeText: (text: string) => void; @@ -1282,7 +1258,6 @@ export const MessageInput = forwardRef( const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg; const toast = useToast(); const voice = useVoiceOptional(); - const sendKeys = useShortcutKeys("message-input-send"); const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle"); const dictationToggleKeys = useShortcutKeys("dictation-toggle"); const focusInputKeys = useShortcutKeys("focus-message-input"); @@ -1898,7 +1873,7 @@ export const MessageInput = forwardRef( isSubmitLoading={isSubmitLoading} submitIcon={submitIcon} buttonIconSize={buttonIconSize} - sendKeys={sendKeys} + sendKeys={DEFAULT_SEND_KEYS} sendTooltipLabel={sendTooltipLabel} /> diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index 99f18ad49..ebea86bbb 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { computeCanStartDictation } from "./state"; +import { computeCanStartDictation, runAlternateSendAction, runDefaultSendAction } from "./state"; const connected = { isConnected: true } as never; const disconnected = { isConnected: false } as never; @@ -91,3 +91,61 @@ describe("computeCanStartDictation", () => { ).toBe(false); }); }); + +describe("composer send behavior", () => { + function actions() { + const calls: string[] = []; + return { + calls, + handleSendMessage: () => calls.push("send"), + handleQueueMessage: () => calls.push("queue"), + onQueue: () => undefined, + }; + } + + it("uses Enter to interrupt and Mod+Enter to queue when interrupt is selected", () => { + const defaultAction = actions(); + runDefaultSendAction({ + defaultSendBehavior: "interrupt", + isAgentRunning: true, + onQueue: defaultAction.onQueue, + handleSendMessage: defaultAction.handleSendMessage, + handleQueueMessage: defaultAction.handleQueueMessage, + }); + + const alternateAction = actions(); + runAlternateSendAction({ + defaultSendBehavior: "interrupt", + isAgentRunning: true, + onQueue: alternateAction.onQueue, + handleSendMessage: alternateAction.handleSendMessage, + handleQueueMessage: alternateAction.handleQueueMessage, + }); + + expect(defaultAction.calls).toEqual(["send"]); + expect(alternateAction.calls).toEqual(["queue"]); + }); + + it("uses Enter to queue and Mod+Enter to submit when queue is selected", () => { + const defaultAction = actions(); + runDefaultSendAction({ + defaultSendBehavior: "queue", + isAgentRunning: true, + onQueue: defaultAction.onQueue, + handleSendMessage: defaultAction.handleSendMessage, + handleQueueMessage: defaultAction.handleQueueMessage, + }); + + const alternateAction = actions(); + runAlternateSendAction({ + defaultSendBehavior: "queue", + isAgentRunning: true, + onQueue: alternateAction.onQueue, + handleSendMessage: alternateAction.handleSendMessage, + handleQueueMessage: alternateAction.handleQueueMessage, + }); + + expect(defaultAction.calls).toEqual(["queue"]); + expect(alternateAction.calls).toEqual(["send"]); + }); +}); diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index c15d01b84..9eaa554fc 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -1,4 +1,15 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { MessagePayload } from "@/composer/types"; + +export type SendBehavior = "interrupt" | "queue"; + +interface SendActionContext { + defaultSendBehavior: SendBehavior; + isAgentRunning: boolean; + onQueue: ((payload: MessagePayload) => void) | undefined; + handleSendMessage: () => void; + handleQueueMessage: () => void; +} export function computeCanStartDictation(input: { client: DaemonClient | null; @@ -12,3 +23,21 @@ export function computeCanStartDictation(input: { socketConnected && readyForDictation && !input.disabled && !input.dictationUnavailableMessage ); } + +export function runDefaultSendAction(ctx: SendActionContext): void { + if (ctx.defaultSendBehavior === "queue" && ctx.isAgentRunning && ctx.onQueue) { + ctx.handleQueueMessage(); + return; + } + ctx.handleSendMessage(); +} + +export function runAlternateSendAction(ctx: SendActionContext): void { + if (ctx.defaultSendBehavior === "queue") { + ctx.handleSendMessage(); + return; + } + if (ctx.isAgentRunning && ctx.onQueue) { + ctx.handleQueueMessage(); + } +} diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 8f6f24526..eb22ae67d 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -236,7 +236,7 @@ describe("translation resources", () => { }); it("includes workspace Git and review keys for the Batch 4B migration", () => { - expect(en.workspace.tabs.actions.newAgent).toBe("New agent tab"); + expect(en.workspace.tabs.actions.newAgent).toBe("New agent"); expect(en.workspace.header.actions.copyPath).toBe("Copy workspace path"); expect(en.workspace.scripts.actions.run).toBe("Run"); expect(en.workspace.git.actions.commit.label).toBe("Commit"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 5ab8f580b..b05a89fbf 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1362,7 +1362,11 @@ export const ar: TranslationResources = { title: "عام", defaultSend: { label: "إرسال افتراضي", - description: "ماذا يحدث عند الضغط على Enter أثناء تشغيل الوكيل", + descriptions: { + interrupt: + "عند تشغيل الوكيل، يوقف Enter التشغيل. Command/Ctrl+Enter يضيف إلى قائمة الانتظار.", + queue: "عند تشغيل الوكيل، يضيف Enter إلى قائمة الانتظار. Command/Ctrl+Enter يرسل.", + }, options: { interrupt: "مقاطعة", queue: "طابور", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index e469f426b..a0cb235c6 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1368,7 +1368,10 @@ export const en = { title: "General", defaultSend: { label: "Default send", - description: "What happens when you press Enter while the agent is running", + descriptions: { + interrupt: "When the agent is running, Enter interrupts. Command/Ctrl+Enter queues.", + queue: "When the agent is running, Enter queues. Command/Ctrl+Enter submits.", + }, options: { interrupt: "Interrupt", queue: "Queue", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index dc2ef7b50..d1ba3d9df 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1397,7 +1397,12 @@ export const es: TranslationResources = { title: "General", defaultSend: { label: "Envío predeterminado", - description: "¿Qué sucede cuando presiona Enter mientras el agente se está ejecutando?", + descriptions: { + interrupt: + "Cuando el agente se está ejecutando, Enter interrumpe. Command/Ctrl+Enter pone en cola.", + queue: + "Cuando el agente se está ejecutando, Enter pone en cola. Command/Ctrl+Enter envía.", + }, options: { interrupt: "Interrumpir", queue: "Cola", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index b92ff5987..b7c013bcb 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1400,8 +1400,12 @@ export const fr: TranslationResources = { title: "Général", defaultSend: { label: "Envoi par défaut", - description: - "Que se passe-t-il lorsque vous appuyez sur Entrée alors que l'agent est en cours d'exécution?", + descriptions: { + interrupt: + "Lorsque l'agent est en cours d'exécution, Entrée interrompt. Commande/Ctrl+Entrée met en file d'attente.", + queue: + "Lorsque l'agent est en cours d'exécution, Entrée met en file d'attente. Commande/Ctrl+Entrée envoie.", + }, options: { interrupt: "Interrompre", queue: "File d'attente", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index d26ac3f35..693d36995 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1389,7 +1389,10 @@ export const ru: TranslationResources = { title: "Общий", defaultSend: { label: "Отправка по умолчанию", - description: "Что произойдет, если вы нажмете Enter во время работы агента", + descriptions: { + interrupt: "Когда агент работает, Enter прерывает. Command/Ctrl+Enter ставит в очередь.", + queue: "Когда агент работает, Enter ставит в очередь. Command/Ctrl+Enter отправляет.", + }, options: { interrupt: "Прерывать", queue: "Очередь", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index b51a433e5..db02a1e92 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1345,7 +1345,10 @@ export const zhCN: TranslationResources = { title: "通用", defaultSend: { label: "默认发送", - description: "Agent 运行时按 Enter 的行为", + descriptions: { + interrupt: "Agent 运行时,Enter 会中断。Command/Ctrl+Enter 会排队。", + queue: "Agent 运行时,Enter 会排队。Command/Ctrl+Enter 会提交。", + }, options: { interrupt: "中断", queue: "排队", diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 09bd73e8f..27024310c 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -8,7 +8,6 @@ export type KeyboardFocusScope = export type MessageInputKeyboardActionKind = | "focus" | "send" - | "queue" | "dictation-toggle" | "dictation-cancel" | "dictation-confirm" diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index c29de8830..de2bef18c 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { buildKeyboardShortcutHelpSections, buildEffectiveBindings, + getBindingIdForAction, resolveKeyboardShortcut, type ChordState, type KeyboardShortcutContext, @@ -413,6 +414,16 @@ describe("keyboard-shortcuts", () => { event: { key: "d", code: "KeyD", metaKey: true }, context: { isMac: true, focusScope: "terminal" }, }, + { + name: "does not bind Cmd+Enter as a rebindable message queue shortcut", + event: { key: "Enter", code: "Enter", metaKey: true }, + context: { isMac: true, focusScope: "message-input" }, + }, + { + name: "does not bind Ctrl+Enter as a rebindable message queue shortcut", + event: { key: "Enter", code: "Enter", ctrlKey: true }, + context: { isMac: false, focusScope: "message-input" }, + }, { name: "does not interrupt agent when terminal is focused", event: { key: "Escape", code: "Escape" }, @@ -600,4 +611,17 @@ describe("keyboard-shortcut help sections", () => { expect(openProject?.label).toBe("Open project"); expect(showShortcuts?.noteKey).toBe("settings.shortcuts.helpNotes.showKeyboardShortcuts"); }); + + it("does not expose Enter send behavior as rebindable shortcut rows", () => { + const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true }); + + expect(findRow(sections, "message-input-send")).toBeNull(); + expect(findRow(sections, "message-input-queue")).toBeNull(); + expect( + getBindingIdForAction("message-input-send", { isMac: true, isDesktop: true }), + ).toBeNull(); + expect( + getBindingIdForAction("message-input-queue", { isMac: true, isDesktop: true }), + ).toBeNull(); + }); }); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index a80dc4138..1ac31e8e9 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -154,8 +154,6 @@ const SHORTCUT_HELP_LABEL_KEYS: Record = { "voice-toggle": "settings.shortcuts.help.toggleVoiceMode", "dictation-toggle": "settings.shortcuts.help.startStopDictation", "agent-interrupt": "settings.shortcuts.help.interruptAgent", - "message-input-send": "settings.shortcuts.help.sendMessage", - "message-input-queue": "settings.shortcuts.help.queueMessage", "voice-mute-toggle": "settings.shortcuts.help.muteUnmuteVoiceMode", }; @@ -953,52 +951,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ keys: ["Esc"], }, }, - { - id: "message-input-send-enter", - action: "message-input.action", - combo: "Enter", - when: { focusScope: "message-input", commandCenter: false }, - payload: { type: "message-input", kind: "send" }, - preventDefault: false, - stopPropagation: false, - help: { - id: "message-input-send", - section: "agent-input", - label: "Send message", - keys: ["Enter"], - }, - }, - { - id: "message-input-queue-cmd-enter-mac", - action: "message-input.action", - combo: "Cmd+Enter", - when: { mac: true, focusScope: "message-input", commandCenter: false }, - payload: { type: "message-input", kind: "queue" }, - preventDefault: false, - stopPropagation: false, - help: { - id: "message-input-queue", - section: "agent-input", - label: "Queue message", - keys: ["mod", "Enter"], - }, - }, - { - id: "message-input-queue-ctrl-enter-non-mac", - action: "message-input.action", - combo: "Ctrl+Enter", - when: { mac: false, focusScope: "message-input", commandCenter: false }, - payload: { type: "message-input", kind: "queue" }, - preventDefault: false, - stopPropagation: false, - help: { - id: "message-input-queue", - section: "agent-input", - label: "Queue message", - keys: ["mod", "Enter"], - }, - }, - { id: "message-input-dictation-confirm-enter", action: "message-input.action", diff --git a/packages/app/src/keyboard/route-shortcut.test.ts b/packages/app/src/keyboard/route-shortcut.test.ts index 7aa63a210..9e40fd52d 100644 --- a/packages/app/src/keyboard/route-shortcut.test.ts +++ b/packages/app/src/keyboard/route-shortcut.test.ts @@ -284,15 +284,6 @@ describe("routeKeyboardShortcut — message-input.action", () => { }); }); - it("returns none for unsupported message-input kinds (queue)", () => { - expect( - routeKeyboardShortcut( - { action: "message-input.action", payload: { kind: "queue" } }, - makeCtx(), - ), - ).toEqual({ kind: "none" }); - }); - it("returns none when kind is missing", () => { expect( routeKeyboardShortcut({ action: "message-input.action", payload: null }, makeCtx()), diff --git a/packages/app/src/keyboard/route-shortcut.ts b/packages/app/src/keyboard/route-shortcut.ts index 410009220..fa1699f0b 100644 --- a/packages/app/src/keyboard/route-shortcut.ts +++ b/packages/app/src/keyboard/route-shortcut.ts @@ -81,7 +81,6 @@ const MESSAGE_INPUT_DISPATCH: Record< "dictation-confirm": { id: "message-input.dictation-confirm", scope: "message-input" }, "voice-toggle": { id: "message-input.voice-toggle", scope: "message-input" }, "voice-mute-toggle": { id: "message-input.voice-mute-toggle", scope: "message-input" }, - queue: null, }; function hasPayloadKey( diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index adf8e3228..50151bb76 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -317,6 +317,10 @@ function GeneralSection({ const activeLocale = getActiveLocale(i18n.language); const iconColor = theme.colors.foregroundMuted; const sendBehaviorOptions = useMemo(() => getSendBehaviorOptions(t), [t]); + const sendBehaviorDescriptionKey = + settings.sendBehavior === "interrupt" + ? "settings.general.defaultSend.descriptions.interrupt" + : "settings.general.defaultSend.descriptions.queue"; const selectedLanguageOption = LANGUAGE_OPTIONS.find( (option) => option.value === settings.language, ); @@ -358,9 +362,7 @@ function GeneralSection({ {t("settings.general.defaultSend.label")} - - {t("settings.general.defaultSend.description")} - + {t(sendBehaviorDescriptionKey)}