Fix composer send shortcut conflicts

This commit is contained in:
Mohamed Boudra
2026-06-12 13:29:51 +07:00
parent 9e9d0ee305
commit 426547bfcc
16 changed files with 152 additions and 101 deletions
+5 -30
View File
@@ -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<MessageInputRef, MessageInputProps>(
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<MessageInputRef, MessageInputProps>(
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
buttonIconSize={buttonIconSize}
sendKeys={sendKeys}
sendKeys={DEFAULT_SEND_KEYS}
sendTooltipLabel={sendTooltipLabel}
/>
</View>
+59 -1
View File
@@ -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"]);
});
});
+29
View File
@@ -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();
}
}
+1 -1
View File
@@ -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");
+5 -1
View File
@@ -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: "طابور",
+4 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -2
View File
@@ -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",
+4 -1
View File
@@ -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: "Очередь",
+4 -1
View File
@@ -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: "排队",
-1
View File
@@ -8,7 +8,6 @@ export type KeyboardFocusScope =
export type MessageInputKeyboardActionKind =
| "focus"
| "send"
| "queue"
| "dictation-toggle"
| "dictation-cancel"
| "dictation-confirm"
@@ -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();
});
});
@@ -154,8 +154,6 @@ const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"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",
@@ -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<ShortcutAction>({ kind: "none" });
});
it("returns none when kind is missing", () => {
expect(
routeKeyboardShortcut({ action: "message-input.action", payload: null }, makeCtx()),
@@ -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<K extends "index" | "delta" | "kind">(
+5 -3
View File
@@ -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({
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.general.defaultSend.label")}</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.general.defaultSend.description")}
</Text>
<Text style={settingsStyles.rowHint}>{t(sendBehaviorDescriptionKey)}</Text>
</View>
<SegmentedControl
size="sm"