From 1a8a6f8fba042476701fa9ac91eb87f38d3be3f9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Apr 2026 02:35:27 +0100 Subject: [PATCH] feat(ui): steer queued chat messages --- CHANGELOG.md | 1 + docs/web/control-ui.md | 1 + ui/src/styles/chat/layout.css | 53 ++++++++++++++ ui/src/styles/chat/layout.test.ts | 19 +++++ ui/src/ui/app-chat.test.ts | 46 +++++++++++- ui/src/ui/app-chat.ts | 50 ++++++++++++- ui/src/ui/app-render.ts | 1 + ui/src/ui/app-view-state.ts | 1 + ui/src/ui/app.ts | 8 +++ ui/src/ui/chat/run-controls.test.ts | 32 +++++++++ ui/src/ui/chat/run-controls.ts | 14 ++++ ui/src/ui/controllers/chat.ts | 24 +++++++ ui/src/ui/icons.ts | 6 ++ ui/src/ui/ui-types.ts | 1 + ui/src/ui/views/chat.test.ts | 107 ++++++++++++++++++++++++++++ ui/src/ui/views/chat.ts | 53 ++++++++++---- 16 files changed, 401 insertions(+), 16 deletions(-) create mode 100644 ui/src/styles/chat/layout.test.ts create mode 100644 ui/src/ui/views/chat.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5335daae0c4..503555c6162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Control UI/chat: add a Steer action on queued messages so a browser follow-up can be injected into the active run without retyping it. - Agents/tools: add optional per-call `timeoutMs` support for image, video, music, and TTS generation tools so agents can extend provider request timeouts only when a specific generation needs it. - Agents/subagents: add optional forked context for native `sessions_spawn` runs so agents can let a child inherit the requester transcript when needed, while keeping clean isolated sessions as the default; includes prompt guidance, context-engine hook metadata, docs, and QA coverage. - Codex harness: add structured debug logging for embedded harness selection decisions so `/status` stays simple while gateway logs explain auto-selection and Pi fallback reasons. (#70760) Thanks @100yenadmin. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 99f80692ffa..b0a3da3c4a3 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -153,6 +153,7 @@ Cron jobs panel notes: - The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options. - Stop: - Click **Stop** (calls `chat.abort`) + - While a run is active, normal follow-ups queue. Click **Steer** on a queued message to inject that follow-up into the running turn. - Type `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`, `please stop`) to abort out-of-band - `chat.abort` supports `{ sessionKey }` (no `runId`) to abort all active runs for that session - Abort partial retention: diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index b841361be33..d4bade8e9db 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -646,6 +646,59 @@ background: color-mix(in srgb, var(--danger) 85%, #fff); } +.chat-queue__item--steered { + border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); +} + +.chat-queue__main { + min-width: 0; +} + +.chat-queue__actions { + display: flex; + align-items: flex-start; + gap: 6px; +} + +.chat-queue__steer { + align-self: start; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + color: var(--accent); + font-size: 12px; + line-height: 1; +} + +.chat-queue__steer svg { + width: 13px; + height: 13px; + fill: none; + stroke: currentColor; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.chat-queue__steer:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +.chat-queue__badge { + display: inline-flex; + width: fit-content; + margin-bottom: 6px; + flex-shrink: 0; + padding: 2px 6px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); + font-size: 0.68rem; + font-weight: 600; + line-height: 1.2; +} + .slash-menu { position: absolute; bottom: 100%; diff --git a/ui/src/styles/chat/layout.test.ts b/ui/src/styles/chat/layout.test.ts new file mode 100644 index 00000000000..bfbeb7b9c93 --- /dev/null +++ b/ui/src/styles/chat/layout.test.ts @@ -0,0 +1,19 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("chat steer styles", () => { + it("styles queued-message steering controls and pending indicators", () => { + const cssPath = [ + resolve(process.cwd(), "src/styles/chat/layout.css"), + resolve(process.cwd(), "ui/src/styles/chat/layout.css"), + ].find((candidate) => existsSync(candidate)); + expect(cssPath).toBeTruthy(); + const css = readFileSync(cssPath!, "utf8"); + + expect(css).toContain(".chat-queue__steer"); + expect(css).toContain(".chat-queue__actions"); + expect(css).toContain(".chat-queue__item--steered"); + expect(css).toContain(".chat-queue__badge"); + }); +}); diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 8940cd54605..a9516ae44fe 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -13,13 +13,19 @@ vi.mock("./app-last-active-session.ts", () => ({ })); let handleSendChat: typeof import("./app-chat.ts").handleSendChat; +let steerQueuedChatMessage: typeof import("./app-chat.ts").steerQueuedChatMessage; let handleAbortChat: typeof import("./app-chat.ts").handleAbortChat; let refreshChatAvatar: typeof import("./app-chat.ts").refreshChatAvatar; let clearPendingQueueItemsForRun: typeof import("./app-chat.ts").clearPendingQueueItemsForRun; async function loadChatHelpers(): Promise { - ({ handleSendChat, handleAbortChat, refreshChatAvatar, clearPendingQueueItemsForRun } = - await import("./app-chat.ts")); + ({ + handleSendChat, + steerQueuedChatMessage, + handleAbortChat, + refreshChatAvatar, + clearPendingQueueItemsForRun, + } = await import("./app-chat.ts")); } function requestUrl(input: string | URL | Request): string { @@ -514,6 +520,42 @@ describe("handleSendChat", () => { expect(host.chatQueue).toEqual([ expect.objectContaining({ text: "/steer tighten the plan", + kind: "steered", + pendingRunId: "run-1", + }), + ]); + }); + + it("steers a queued message into the active run without replacing run tracking", async () => { + const request = vi.fn(async (method: string) => { + if (method === "chat.send") { + return { status: "started", runId: "steer-run" }; + } + throw new Error(`Unexpected request: ${method}`); + }); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + chatRunId: "run-1", + chatStream: "Working...", + chatQueue: [{ id: "queued-1", text: "tighten the plan", createdAt: 1 }], + sessionKey: "agent:main:main", + }); + + await steerQueuedChatMessage(host, "queued-1"); + + expect(request).toHaveBeenCalledWith("chat.send", { + sessionKey: "agent:main:main", + message: "tighten the plan", + deliver: false, + idempotencyKey: expect.any(String), + attachments: undefined, + }); + expect(host.chatRunId).toBe("run-1"); + expect(host.chatStream).toBe("Working..."); + expect(host.chatQueue).toEqual([ + expect.objectContaining({ + text: "tighten the plan", + kind: "steered", pendingRunId: "run-1", }), ]); diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 702bc51cdc8..c40872e2518 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -10,6 +10,7 @@ import { loadChatHistory, sendChatMessage, sendDetachedChatMessage, + sendSteerChatMessage, type ChatState, } from "./controllers/chat.ts"; import { loadModels } from "./controllers/models.ts"; @@ -134,9 +135,15 @@ function enqueueChatMessage( ]; } -function enqueuePendingRunMessage(host: ChatHost, text: string, pendingRunId: string) { +function enqueuePendingRunMessage( + host: ChatHost, + text: string, + pendingRunId: string, + attachments?: ChatAttachment[], +) { const trimmed = text.trim(); - if (!trimmed) { + const hasAttachments = Boolean(attachments && attachments.length > 0); + if (!trimmed && !hasAttachments) { return; } host.chatQueue = [ @@ -145,6 +152,8 @@ function enqueuePendingRunMessage(host: ChatHost, text: string, pendingRunId: st id: generateUUID(), text: trimmed, createdAt: Date.now(), + kind: "steered", + attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined, pendingRunId, }, ]; @@ -226,6 +235,43 @@ async function sendDetachedBtwMessage( return ok; } +export async function steerQueuedChatMessage(host: ChatHost, id: string) { + if (!host.connected || !host.chatRunId) { + return; + } + const activeRunId = host.chatRunId; + const item = host.chatQueue.find( + (entry) => entry.id === id && !entry.pendingRunId && !entry.localCommandName, + ); + if (!item) { + return; + } + const message = item.text.trim(); + const attachments = item.attachments ?? []; + const hasAttachments = attachments.length > 0; + if (!message && !hasAttachments) { + return; + } + + host.chatQueue = host.chatQueue.map((entry) => + entry.id === id ? { ...entry, kind: "steered", pendingRunId: activeRunId } : entry, + ); + const runId = await sendSteerChatMessage( + host as unknown as ChatState, + message, + hasAttachments ? attachments : undefined, + ); + if (!runId) { + host.chatQueue = host.chatQueue.map((entry) => (entry.id === id ? item : entry)); + return; + } + setLastActiveSessionKey( + host as unknown as Parameters[0], + host.sessionKey, + ); + scheduleChatScroll(host as unknown as Parameters[0]); +} + async function flushChatQueue(host: ChatHost) { if (!host.connected || isChatBusy(host)) { return; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 7a00ebd53c1..2cf5e69bc1f 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -2259,6 +2259,7 @@ export function renderApp(state: AppViewState) { canAbort: Boolean(state.chatRunId), onAbort: () => void state.handleAbortChat(), onQueueRemove: (id) => state.removeQueuedMessage(id), + onQueueSteer: (id) => void state.steerQueuedChatMessage(id), onDismissSideResult: () => { state.chatSideResult = null; }, diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 0a2f5d5479a..528b9363ee4 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -425,6 +425,7 @@ export type AppViewState = { setPassword: (next: string) => void; setChatMessage: (next: string) => void; handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise; + steerQueuedChatMessage: (id: string) => Promise; handleAbortChat: () => Promise; removeQueuedMessage: (id: string) => void; handleChatScroll: (event: Event) => void; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index ca05a641be2..d955a73423d 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -19,6 +19,7 @@ import { handleAbortChat as handleAbortChatInternal, handleSendChat as handleSendChatInternal, removeQueuedMessage as removeQueuedMessageInternal, + steerQueuedChatMessage as steerQueuedChatMessageInternal, } from "./app-chat.ts"; import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults.ts"; import type { EventLogEntry } from "./app-events.ts"; @@ -709,6 +710,13 @@ export class OpenClawApp extends LitElement { ); } + async steerQueuedChatMessage(id: string) { + await steerQueuedChatMessageInternal( + this as unknown as Parameters[0], + id, + ); + } + async handleWhatsAppStart(force: boolean) { await handleWhatsAppStartInternal(this, force); } diff --git a/ui/src/ui/chat/run-controls.test.ts b/ui/src/ui/chat/run-controls.test.ts index 1cab7989338..260891167b0 100644 --- a/ui/src/ui/chat/run-controls.test.ts +++ b/ui/src/ui/chat/run-controls.test.ts @@ -25,18 +25,26 @@ describe("chat run controls", () => { it("switches between idle and abort actions", () => { const container = document.createElement("div"); const onAbort = vi.fn(); + const onQueueSend = vi.fn(); + const onQueueStoreDraft = vi.fn(); render( renderChatRunControls( createProps({ canAbort: true, + draft: " queue this ", sending: true, onAbort, + onSend: onQueueSend, + onStoreDraft: onQueueStoreDraft, }), ), container, ); + const queueButton = container.querySelector('button[title="Queue"]'); const stopButton = container.querySelector('button[title="Stop"]'); + expect(queueButton).not.toBeNull(); + expect(queueButton?.disabled).toBe(true); expect(stopButton).not.toBeNull(); stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onAbort).toHaveBeenCalledTimes(1); @@ -73,6 +81,30 @@ describe("chat run controls", () => { expect(container.textContent).not.toContain("Stop"); }); + it("queues draft text while an active run is abortable", () => { + const container = document.createElement("div"); + const onSend = vi.fn(); + const onStoreDraft = vi.fn(); + render( + renderChatRunControls( + createProps({ + canAbort: true, + draft: " follow up ", + onSend, + onStoreDraft, + }), + ), + container, + ); + + const queueButton = container.querySelector('button[title="Queue"]'); + expect(queueButton).not.toBeNull(); + expect(queueButton?.disabled).toBe(false); + queueButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onStoreDraft).toHaveBeenCalledWith(" follow up "); + expect(onSend).toHaveBeenCalledTimes(1); + }); + it("keeps Stop clickable while disconnected when a run is abortable", () => { const container = document.createElement("div"); const onAbort = vi.fn(); diff --git a/ui/src/ui/chat/run-controls.ts b/ui/src/ui/chat/run-controls.ts index a598594f598..75cfb89ba42 100644 --- a/ui/src/ui/chat/run-controls.ts +++ b/ui/src/ui/chat/run-controls.ts @@ -42,6 +42,20 @@ export function renderChatRunControls(props: ChatRunControlsProps) { ${props.canAbort ? html` + + ` + : nothing} + - `, )}