feat(ui): add steer-now composer shortcut (#124826)

This commit is contained in:
Peter Steinberger
2026-08-16 14:57:58 -07:00
committed by GitHub
parent eab2b8fdca
commit 8754be900a
11 changed files with 340 additions and 11 deletions
+119
View File
@@ -3,6 +3,7 @@ import { GATEWAY_SERVER_CAPS } from "../../../packages/gateway-protocol/src/inde
import {
chatSessionListResponse,
createChatFlowE2eSuite,
expectRequestCountStable,
installMockGateway,
requireRecord,
requireString,
@@ -696,6 +697,124 @@ suite.define(() => {
}
});
it("steers a queued follow-up with modified Enter in Enter shortcut mode", async () => {
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
...(artifactDir
? { recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } } }
: {}),
});
const page = await context.newPage();
const gateway = await installMockGateway(page);
try {
await page.goto(`${suite.server.baseUrl}settings/appearance`);
await page.locator("[data-settings-follow-up-mode]").selectOption("queue");
await page.locator("[data-settings-send-shortcut]").selectOption("enter");
await page.goto(`${suite.server.baseUrl}chat`);
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.fill("keep the first shortcut run active");
await page.getByRole("button", { name: "Send message" }).click();
const firstSend = requireRecord((await gateway.waitForRequest("chat.send")).params);
const firstRunId = requireString(firstSend.idempotencyKey, "first active run id");
await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 });
const steerText = "steer this keyboard follow-up now";
await composer.fill(steerText);
const enterQueueButton = page.getByRole("button", { name: "Queue message" });
const enterTooltip = await enterQueueButton
.locator("..")
.evaluate((element) => (element as HTMLElement & { content?: string }).content);
expect(enterTooltip).toBe("Queue ⏎ · Steer ⌘/Ctrl+Enter");
if (artifactDir) {
await enterQueueButton.hover();
await expect
.poll(() =>
enterQueueButton.evaluate((button) => {
const tooltip = button
.closest("openclaw-tooltip")
?.shadowRoot?.querySelector("wa-tooltip");
return (tooltip as (HTMLElement & { open?: boolean }) | null)?.open === true;
}),
)
.toBe(true);
await page.screenshot({
path: `${artifactDir}/queue-steer-shortcut.png`,
fullPage: true,
});
}
await composer.press("Control+Enter");
const firstRunSends = await waitForRequests(gateway, "chat.send", 2);
const steerParams = requireRecord(firstRunSends[1]?.params);
expect(steerParams).toMatchObject({
deliver: false,
expectedRunId: firstRunId,
message: steerText,
queueMode: "steer",
sessionKey: "main",
});
const steeredRow = page.locator(".chat-queue__item--steered", { hasText: steerText });
await steeredRow.waitFor({ timeout: 10_000 });
await gateway.emitGatewayEvent("chat", {
runId: requireString(steerParams.idempotencyKey, "steer send id"),
sessionKey: "main",
state: "final",
});
await steeredRow.waitFor({ state: "detached", timeout: 10_000 });
await gateway.emitChatFinal({ runId: firstRunId, text: "First shortcut run finished." });
await page
.getByRole("button", { name: "Stop generating" })
.waitFor({ state: "detached", timeout: 10_000 });
} finally {
await suite.closeBrowserContext(context);
}
});
it("keeps modified Enter queued in modifier-enter shortcut mode", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page);
try {
await page.goto(`${suite.server.baseUrl}settings/appearance`);
await page.locator("[data-settings-follow-up-mode]").selectOption("queue");
await page.locator("[data-settings-send-shortcut]").selectOption("modifier-enter");
await page.goto(`${suite.server.baseUrl}chat`);
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.fill("keep the modifier shortcut run active");
await page.getByRole("button", { name: "Send message" }).click();
await gateway.waitForRequest("chat.send");
await page.getByRole("button", { name: "Stop generating" }).waitFor({ timeout: 10_000 });
const queuedText = "leave this modifier follow-up queued";
await composer.fill(queuedText);
const queueButton = page.getByRole("button", { name: "Queue message" });
const tooltip = await queueButton
.locator("..")
.evaluate((element) => (element as HTMLElement & { content?: string }).content);
expect(tooltip).toBe("Queue");
await composer.press("Control+Enter");
const queuedRow = page.locator(".chat-queue__item", { hasText: queuedText });
await queuedRow.waitFor({ timeout: 10_000 });
await queuedRow.getByText("Waiting for current run").waitFor({ timeout: 10_000 });
await expectRequestCountStable(gateway, "chat.send", 1);
} finally {
await suite.closeBrowserContext(context);
}
});
it("honors a session interrupt override ahead of the webchat config default", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",
@@ -13,6 +13,24 @@ afterEach(async () => {
await resetComposerFixture();
});
function pressComposerEnter(
container: Element,
modifiers: Pick<KeyboardEventInit, "altKey" | "ctrlKey" | "metaKey" | "shiftKey"> = {},
) {
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
if (!textarea) {
throw new Error("expected composer textarea");
}
const event = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: "Enter",
...modifiers,
});
textarea.dispatchEvent(event);
return event;
}
describe("renderChatComposer controls", () => {
it.each([
{
@@ -235,6 +253,137 @@ describe("renderChatComposer controls", () => {
expect(onSend).not.toHaveBeenCalled();
});
it.each([
["Meta", { metaKey: true }],
["Control", { ctrlKey: true }],
] as const)("uses %s+Enter to steer an active queued follow-up", (_name, modifiers) => {
const onSend = vi.fn();
const { container } = renderComposer({
canAbort: true,
draft: "Steer this now",
followUpMode: "queue",
onAbort: vi.fn(),
onSend,
sendShortcut: "enter",
});
pressComposerEnter(container, modifiers);
expect(onSend).toHaveBeenCalledOnce();
expect(onSend).toHaveBeenCalledWith({ followUpMode: "steer" });
});
it.each([
["modifier-enter", true, "queue", false],
["enter", false, "queue", false],
["enter", true, "steer", false],
["enter", true, "queue", true],
] as const)(
"keeps ordinary send semantics for shortcut=%s active=%s mode=%s alt=%s",
(sendShortcut, active, followUpMode, altKey) => {
const onSend = vi.fn();
const { container } = renderComposer({
canAbort: active,
draft: "Keep the ordinary send path",
followUpMode,
onAbort: active ? vi.fn() : undefined,
onSend,
sendShortcut,
});
pressComposerEnter(container, { altKey, ctrlKey: true });
expect(onSend.mock.calls).toEqual([[]]);
},
);
it("keeps empty modified Enter on the existing empty-draft path", () => {
const onSend = vi.fn();
const { container } = renderComposer({
canAbort: true,
draft: "",
followUpMode: "queue",
onAbort: vi.fn(),
onSend,
sendShortcut: "enter",
});
pressComposerEnter(container, { ctrlKey: true });
expect(onSend).not.toHaveBeenCalled();
});
it("keeps Shift+modified Enter as a newline", () => {
const onSend = vi.fn();
const { container } = renderComposer({
canAbort: true,
draft: "Keep editing",
followUpMode: "queue",
onAbort: vi.fn(),
onSend,
sendShortcut: "enter",
});
const event = pressComposerEnter(container, { ctrlKey: true, shiftKey: true });
expect(event.defaultPrevented).toBe(false);
expect(onSend).not.toHaveBeenCalled();
});
it("teaches the steer shortcut only when the force-steer action is available", () => {
const available = renderComposer({
canAbort: true,
draft: "Follow up now",
followUpMode: "queue",
onAbort: vi.fn(),
sendShortcut: "enter",
});
const availablePrimary = primaryButton(available.container);
const availableTooltip = availablePrimary.closest("openclaw-tooltip") as
| (HTMLElement & { content?: string })
| null;
expect(availablePrimary.getAttribute("aria-label")).toBe(t("chat.runControls.queueMessage"));
expect(availableTooltip?.content).toBe("Queue ⏎ · Steer ⌘/Ctrl+Enter");
const unavailable = [
{
overrides: {
canAbort: true,
draft: "Follow up later",
followUpMode: "queue" as const,
onAbort: vi.fn(),
sendShortcut: "modifier-enter" as const,
},
tooltip: t("chat.runControls.queue"),
},
{
overrides: {
draft: "Send without an active run",
followUpMode: "queue" as const,
sendShortcut: "enter" as const,
},
tooltip: t("chat.runControls.send"),
},
{
overrides: {
canAbort: true,
draft: "Already steering",
followUpMode: "steer" as const,
onAbort: vi.fn(),
sendShortcut: "enter" as const,
},
tooltip: t("chat.queue.steer"),
},
];
for (const testCase of unavailable) {
const view = renderComposer(testCase.overrides);
const tooltip = primaryButton(view.container).closest("openclaw-tooltip") as
| (HTMLElement & { content?: string })
| null;
expect(tooltip?.content).toBe(testCase.tooltip);
}
});
it("stops an abortable run with Escape unless reply or menu precedence owns it", () => {
const onAbort = vi.fn();
let view = renderComposer({ canAbort: true, onAbort });
+3 -2
View File
@@ -50,6 +50,7 @@ import {
} from "./chat-pane-state.ts";
import { dismissRealtimeTalkError } from "./chat-realtime.ts";
import { activeChatRunStartupStatus } from "./chat-run-startup.ts";
import type { ChatSendOptions } from "./chat-send-contract.ts";
import { refreshChatCommands, refreshPageChat } from "./chat-state-refresh.ts";
import {
resolveChatAgentId,
@@ -520,12 +521,12 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
state.requestUpdate?.();
},
onRemoveAttachment: this.removeBrowserAnnotation,
onSend: () =>
onSend: (options?: ChatSendOptions) =>
catalogKey
? void this.continueCatalogSession(catalogKey)
: suggestionViewer
? void this.addCurrentSessionSuggestion()
: void state.handleSendChat(),
: void state.handleSendChat(undefined, options),
onCompact: sessionActionCallbacks.onCompact,
// Checkpoint deep-link carries the archived filter so the row stays findable.
onOpenSessionCheckpoints: () => {
+4
View File
@@ -17,6 +17,10 @@ type ChatAgentsListSnapshot = Partial<Omit<AgentsListResult, "agents">> & {
agents?: AgentsListResult["agents"];
};
export type ChatSendOptions = {
followUpMode?: ControlUiFollowUpMode;
};
export type ChatHost = ChatInputHistoryState &
ChatScrollHost &
ToolStreamHost &
+6 -4
View File
@@ -36,7 +36,7 @@ import {
submittedCommandScopeIsVisible,
type ChatCommandComposerRecovery,
} from "./chat-send-composer.ts";
import type { ChatHost } from "./chat-send-contract.ts";
import type { ChatHost, ChatSendOptions } from "./chat-send-contract.ts";
import { chatOutboxDrainDependencies, deliverChatQueueItem } from "./chat-send-delivery.ts";
import {
canSendVolatileQueueItem,
@@ -69,7 +69,7 @@ import {
sendQueuedChatMessageWithQueueMode as sendQueuedChatMessageWithQueueModeLifecycle,
} from "./steer-lifecycle.ts";
type ChatSendOptions = {
type ChatSendSubmitOptions = ChatSendOptions & {
restoreDraft?: boolean;
skillWorkshopRevision?: ChatQueueSkillWorkshopRevision;
/** Lets request-scoped UI actions recover from rejected local commands. */
@@ -194,7 +194,7 @@ async function sendDetachedCommandMessage(
export async function handleSendChat(
host: ChatHost,
messageOverride?: string,
opts?: ChatSendOptions,
opts?: ChatSendSubmitOptions,
) {
const previousDraft = host.chatMessage;
const userMessage = (messageOverride ?? host.chatMessage).trim();
@@ -538,7 +538,9 @@ export async function handleSendChat(
recordChatSendTiming(host, pending, "queued-busy", submittedAtMs);
// Only an explicit browser override replaces inherited Gateway policy.
const followUpMode =
host.chatFollowUpMode ?? normalizeChatFollowUpModeOverride(host.settings?.chatFollowUpMode);
opts?.followUpMode ??
host.chatFollowUpMode ??
normalizeChatFollowUpModeOverride(host.settings?.chatFollowUpMode);
if (
!skillWorkshopRevision &&
followUpMode !== "queue" &&
+27
View File
@@ -3632,6 +3632,33 @@ describe("handleSendChat", () => {
expect(host.chatMessage).toBe("queued while busy");
});
it("lets a per-send steer override beat the effective queue setting", async () => {
const host = makeChatHost({
requestHandlers: {
"chat.send": { status: "started", runId: "steer-run" },
},
chatMessage: "steer this queued follow-up now",
chatRunId: "active-run",
chatStream: "Working...",
sessionKey: "agent:main:main",
settings: { chatFollowUpMode: "queue" },
});
await handleSendChat(host, undefined, { followUpMode: "steer" });
await waitForFast(() =>
expect(host.request).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
expectedRunId: "active-run",
message: "steer this queued follow-up now",
queueMode: "steer",
sessionKey: "agent:main:main",
}),
),
);
});
it("fails visibly when a busy send cannot be parked after durable admission", async () => {
const storage = createStorageMock();
const setItem = storage.setItem.bind(storage);
+2 -1
View File
@@ -33,6 +33,7 @@ import type { ProviderUsageDisplayProps } from "../../lib/provider-quota-summary
import type { SessionToolOverrides } from "../../lib/sessions/patch.ts";
import type { UiSessionDefaultsHost } from "../../lib/sessions/session-key.ts";
import type { ChatRunStartupStatus } from "./chat-run-startup.ts";
import type { ChatSendOptions } from "./chat-send-contract.ts";
import { type ChatCloudStartupNoticeProps, renderChatViewNotices } from "./chat-view-notices.ts";
import { createChatAttachmentDropHandlers } from "./components/chat-attachments.ts";
import type { BackgroundTasksProps } from "./components/chat-background-tasks.types.ts";
@@ -192,7 +193,7 @@ export type ChatProps = ChatTaskSuggestionTrayProps &
onRequestUpdate?: () => void;
onHistoryKeydown?: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult;
onSlashIntent?: () => void | Promise<void>;
onSend: () => void;
onSend: (options?: ChatSendOptions) => void;
onCompact?: () => void | Promise<void>;
onOpenSessionCheckpoints?: () => void | Promise<void>;
onToggleRealtimeTalk?: () => void;
@@ -1,5 +1,6 @@
import { html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import type { ChatSendShortcut } from "../../../app/settings.ts";
import { icons } from "../../../components/icons.ts";
import { syncDropdownItemRadio } from "../../../components/web-awesome.ts";
import { t } from "../../../i18n/index.ts";
@@ -22,6 +23,7 @@ export type ChatRunControlsProps = {
hasAttachments?: boolean;
isBusy: boolean;
followUpMode?: ControlUiFollowUpMode;
sendShortcut: ChatSendShortcut;
suggestionComposer?: boolean;
sending: boolean;
voiceActive?: boolean;
@@ -218,6 +220,15 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) {
: interruptsActiveRun
? t("chat.runControls.sendMessage")
: t("chat.runControls.queueMessage");
const queueSteerShortcutAvailable =
props.canAbort &&
props.canSend &&
hasComposedContent &&
props.followUpMode === "queue" &&
props.sendShortcut === "enter";
const activeRunActionTooltip = queueSteerShortcutAvailable
? `${activeRunActionLabel} ⏎ · ${t("chat.queue.steer")} ${t("chat.sendShortcutModifierEnter")}`
: activeRunActionLabel;
const storeDraftAndSend = () => {
if (props.draft.trim()) {
props.onStoreDraft(props.draft);
@@ -247,7 +258,7 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) {
const voiceErrored = props.voiceStatus === "error";
const voiceButton = renderComposerVoiceButton(props);
const sendAction = html`
<openclaw-tooltip .content=${activeRunActionLabel}>
<openclaw-tooltip .content=${activeRunActionTooltip}>
<button
class="chat-send-btn"
@pointerdown=${props.onPrimaryActionPointerDown}
@@ -209,7 +209,8 @@ export function createComposerKeyDownHandler({
const sendShortcutMatches = sendShortcut === "enter" || event.metaKey || event.ctrlKey;
if (event.key === "Enter" && !event.shiftKey && sendShortcutMatches) {
const attachments = props.getAttachments?.() ?? props.attachments ?? [];
if (!target.value.trim() && attachments.length === 0) {
const hasComposedContent = Boolean(target.value.trim() || attachments.length);
if (!hasComposedContent) {
// Mirror the queue chip's Steer availability exactly (visible surface,
// connected + composable gate), or offline Enter would swallow the key
// and invoke a lifecycle that returns with no visible outcome.
@@ -232,7 +233,19 @@ export function createComposerKeyDownHandler({
}
event.preventDefault();
commitDraft(target.value);
props.onSend();
const steerImmediately =
sendShortcut === "enter" &&
showAbortableUi &&
props.followUpMode === "queue" &&
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
hasComposedContent;
if (steerImmediately) {
props.onSend({ followUpMode: "steer" });
} else {
props.onSend();
}
syncDraftAfterSend(target);
}
};
@@ -8,6 +8,7 @@ import type { SlashCommandDef } from "../../../lib/chat/commands.ts";
import type { ControlUiFollowUpMode } from "../../../lib/chat/follow-up-mode.ts";
import type { ProviderUsageDisplayProps } from "../../../lib/provider-quota-summary.ts";
import type { SessionToolOverrides } from "../../../lib/sessions/patch.ts";
import type { ChatSendOptions } from "../chat-send-contract.ts";
import type { ComposerDictationController } from "../composer-dictation.ts";
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "../input-history.ts";
import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts";
@@ -119,7 +120,7 @@ export type ChatComposerProps = ChatAttachmentControlsProps & {
onDraftChange: (next: string) => void;
onHistoryKeydown?: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult;
onSlashIntent?: () => void | Promise<void>;
onSend: () => void;
onSend: (options?: ChatSendOptions) => void;
onCompact?: () => void | Promise<void>;
onToggleRealtimeTalk?: () => void;
onToggleRealtimeCamera?: () => void;
@@ -492,6 +492,7 @@ export function renderChatComposer(props: ChatComposerProps) {
hasAttachments: !props.suggestionComposer && Boolean(props.attachments?.length),
isBusy,
followUpMode: props.followUpMode,
sendShortcut,
suggestionComposer: props.suggestionComposer,
sending: props.sending,
voiceActive: props.realtimeTalkActive,