From 8754be900afd897fec144e0a2f29ed412b33aade Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 14:57:58 -0700 Subject: [PATCH] feat(ui): add steer-now composer shortcut (#124826) --- ui/src/e2e/chat-flow.follow-ups.e2e.test.ts | 119 ++++++++++++++ .../pages/chat/chat-composer-actions.test.ts | 149 ++++++++++++++++++ ui/src/pages/chat/chat-pane-render.ts | 5 +- ui/src/pages/chat/chat-send-contract.ts | 4 + ui/src/pages/chat/chat-send-submit.ts | 10 +- ui/src/pages/chat/chat-send.test.ts | 27 ++++ ui/src/pages/chat/chat-view.ts | 3 +- .../chat/components/chat-composer-controls.ts | 13 +- .../chat/components/chat-composer-keydown.ts | 17 +- .../chat/components/chat-composer-types.ts | 3 +- ui/src/pages/chat/components/chat-composer.ts | 1 + 11 files changed, 340 insertions(+), 11 deletions(-) diff --git a/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts b/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts index 0d9ad4368a31..2160e5d42a6a 100644 --- a/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts +++ b/ui/src/e2e/chat-flow.follow-ups.e2e.test.ts @@ -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", diff --git a/ui/src/pages/chat/chat-composer-actions.test.ts b/ui/src/pages/chat/chat-composer-actions.test.ts index a3e15c787d8d..58429acc4b6d 100644 --- a/ui/src/pages/chat/chat-composer-actions.test.ts +++ b/ui/src/pages/chat/chat-composer-actions.test.ts @@ -13,6 +13,24 @@ afterEach(async () => { await resetComposerFixture(); }); +function pressComposerEnter( + container: Element, + modifiers: Pick = {}, +) { + const textarea = container.querySelector("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 }); diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index bebd7eb244b1..ece017ff2b56 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -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: () => { diff --git a/ui/src/pages/chat/chat-send-contract.ts b/ui/src/pages/chat/chat-send-contract.ts index cf7f02733267..f6cdb2eed4a7 100644 --- a/ui/src/pages/chat/chat-send-contract.ts +++ b/ui/src/pages/chat/chat-send-contract.ts @@ -17,6 +17,10 @@ type ChatAgentsListSnapshot = Partial> & { agents?: AgentsListResult["agents"]; }; +export type ChatSendOptions = { + followUpMode?: ControlUiFollowUpMode; +}; + export type ChatHost = ChatInputHistoryState & ChatScrollHost & ToolStreamHost & diff --git a/ui/src/pages/chat/chat-send-submit.ts b/ui/src/pages/chat/chat-send-submit.ts index 4e2508b5f6eb..d7d73acc67ad 100644 --- a/ui/src/pages/chat/chat-send-submit.ts +++ b/ui/src/pages/chat/chat-send-submit.ts @@ -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" && diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index 33fee0f36f30..21a26d6a51bf 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -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); diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 708d2e622dcf..18ebeef71959 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -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; - onSend: () => void; + onSend: (options?: ChatSendOptions) => void; onCompact?: () => void | Promise; onOpenSessionCheckpoints?: () => void | Promise; onToggleRealtimeTalk?: () => void; diff --git a/ui/src/pages/chat/components/chat-composer-controls.ts b/ui/src/pages/chat/components/chat-composer-controls.ts index 72569d7f16ba..8bd41bc23932 100644 --- a/ui/src/pages/chat/components/chat-composer-controls.ts +++ b/ui/src/pages/chat/components/chat-composer-controls.ts @@ -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` - +