Fix composer send shortcut conflicts
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: "طابور",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Очередь",
|
||||
|
||||
@@ -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: "排队",
|
||||
|
||||
@@ -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">(
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user