mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
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 <contact@lukelabonte.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2495,6 +2495,7 @@ function ComposerContentImpl({
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={activeSendBehavior}
|
||||
sendShortcut={appSettings.sendShortcut}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
|
||||
@@ -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<MessageInputRef, MessageInputProps>(
|
||||
voiceAgentId,
|
||||
isAgentRunning,
|
||||
defaultSendBehavior,
|
||||
sendShortcut,
|
||||
onQueue,
|
||||
onSubmitLoadingPress,
|
||||
onKeyPressCallback,
|
||||
@@ -1755,6 +1778,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
selectionRef.current,
|
||||
),
|
||||
submitOnEnter: shouldSubmitOnEnter,
|
||||
sendShortcut,
|
||||
isAgentRunning,
|
||||
onQueue,
|
||||
isSubmitDisabled,
|
||||
isSubmitLoading,
|
||||
disabled,
|
||||
@@ -1766,6 +1792,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
shouldHandleWebKeyPress,
|
||||
onKeyPressCallback,
|
||||
shouldSubmitOnEnter,
|
||||
sendShortcut,
|
||||
isAgentRunning,
|
||||
onQueue,
|
||||
isSubmitDisabled,
|
||||
isSubmitLoading,
|
||||
disabled,
|
||||
@@ -1980,7 +2009,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
submitLabel={submitLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
buttonIconSize={buttonIconSize}
|
||||
sendKeys={DEFAULT_SEND_KEYS}
|
||||
sendKeys={resolveSendKeys(sendShortcut)}
|
||||
sendTooltipLabel={sendTooltipLabel}
|
||||
/>
|
||||
),
|
||||
@@ -2001,6 +2030,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
submitLabel,
|
||||
submitButtonTestID,
|
||||
buttonIconSize,
|
||||
sendShortcut,
|
||||
sendTooltipLabel,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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> = {},
|
||||
): 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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<string, string>;
|
||||
/** 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({
|
||||
|
||||
@@ -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: "بسأل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "確認する",
|
||||
|
||||
@@ -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: "물어보기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Спрашивать",
|
||||
|
||||
@@ -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: "询问",
|
||||
|
||||
@@ -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 (
|
||||
<SettingsSection title={t("settings.general.sending")}>
|
||||
<SettingsCard>
|
||||
{shortcutsAvailable ? (
|
||||
<SettingsSelect
|
||||
label={t("settings.general.sendShortcut.label")}
|
||||
value={settings.sendShortcut}
|
||||
options={shortcutOptions}
|
||||
onValueChange={changeSendShortcut}
|
||||
/>
|
||||
) : null}
|
||||
<SettingsSelect
|
||||
label={t("settings.general.defaultSend.label")}
|
||||
hint={t(`settings.general.defaultSend.descriptions.${settings.sendBehavior}`)}
|
||||
hint={t(
|
||||
shortcutsAvailable && settings.sendShortcut === "mod-enter"
|
||||
? `settings.general.defaultSend.commandEnterDescriptions.${settings.sendBehavior}`
|
||||
: `settings.general.defaultSend.descriptions.${settings.sendBehavior}`,
|
||||
{ modifier },
|
||||
)}
|
||||
value={settings.sendBehavior}
|
||||
options={options}
|
||||
onValueChange={change}
|
||||
|
||||
Reference in New Issue
Block a user