feat(ui): steer queued chat messages

This commit is contained in:
Peter Steinberger
2026-04-24 02:35:27 +01:00
parent 7dc1aeebbf
commit 1a8a6f8fba
16 changed files with 401 additions and 16 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -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%;

View File

@@ -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");
});
});

View File

@@ -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<void> {
({ 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",
}),
]);

View File

@@ -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<typeof setLastActiveSessionKey>[0],
host.sessionKey,
);
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
}
async function flushChatQueue(host: ChatHost) {
if (!host.connected || isChatBusy(host)) {
return;

View File

@@ -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;
},

View File

@@ -425,6 +425,7 @@ export type AppViewState = {
setPassword: (next: string) => void;
setChatMessage: (next: string) => void;
handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise<void>;
steerQueuedChatMessage: (id: string) => Promise<void>;
handleAbortChat: () => Promise<void>;
removeQueuedMessage: (id: string) => void;
handleChatScroll: (event: Event) => void;

View File

@@ -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<typeof steerQueuedChatMessageInternal>[0],
id,
);
}
async handleWhatsAppStart(force: boolean) {
await handleWhatsAppStartInternal(this, force);
}

View File

@@ -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<HTMLButtonElement>('button[title="Queue"]');
const stopButton = container.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('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();

View File

@@ -42,6 +42,20 @@ export function renderChatRunControls(props: ChatRunControlsProps) {
${props.canAbort
? html`
<button
class="chat-send-btn"
@click=${() => {
if (props.draft.trim()) {
props.onStoreDraft(props.draft);
}
props.onSend();
}}
?disabled=${!props.connected || props.sending}
title="Queue"
aria-label="Queue message"
>
${icons.send}
</button>
<button
class="chat-send-btn chat-send-btn--stop"
@click=${props.onAbort}

View File

@@ -385,6 +385,30 @@ export async function sendDetachedChatMessage(
}
}
export async function sendSteerChatMessage(
state: ChatState,
message: string,
attachments?: ChatAttachment[],
): Promise<string | null> {
if (!state.client || !state.connected) {
return null;
}
const msg = message.trim();
const hasAttachments = attachments && attachments.length > 0;
if (!msg && !hasAttachments) {
return null;
}
state.lastError = null;
const runId = generateUUID();
try {
await requestChatSend(state, { message: msg, attachments, runId });
return runId;
} catch (err) {
state.lastError = formatConnectError(err);
return null;
}
}
export async function abortChatRun(state: ChatState): Promise<boolean> {
if (!state.client || !state.connected) {
return false;

View File

@@ -128,6 +128,12 @@ export const icons = {
<path d="m19 12-7 7-7-7" />
</svg>
`,
cornerDownRight: html`
<svg viewBox="0 0 24 24">
<polyline points="15 10 20 15 15 20" />
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
</svg>
`,
copy: html`
<svg viewBox="0 0 24 24">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />

View File

@@ -8,6 +8,7 @@ export type ChatQueueItem = {
id: string;
text: string;
createdAt: number;
kind?: "queued" | "steered";
attachments?: ChatAttachment[];
refreshSessions?: boolean;
localCommandArgs?: string;

View File

@@ -0,0 +1,107 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ChatQueueItem } from "../ui-types.ts";
import { cleanupChatModuleState, renderChat, type ChatProps } from "./chat.ts";
function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
return {
sessionKey: "agent:main:main",
onSessionKeyChange: () => undefined,
thinkingLevel: null,
showThinking: true,
showToolCalls: true,
loading: false,
sending: false,
canAbort: true,
messages: [],
toolMessages: [],
streamSegments: [],
stream: "Working...",
streamStartedAt: 1,
draft: "",
queue: [],
connected: true,
canSend: true,
disabledReason: null,
error: null,
sessions: {
ts: 0,
path: "",
count: 1,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [{ key: "agent:main:main", kind: "direct", status: "running", updatedAt: null }],
},
focusMode: false,
assistantName: "Test Agent",
assistantAvatar: null,
onRefresh: () => undefined,
onToggleFocusMode: () => undefined,
onDraftChange: () => undefined,
onSend: () => undefined,
onAbort: () => undefined,
onQueueRemove: () => undefined,
onNewSession: () => undefined,
agentsList: { agents: [{ id: "main", name: "Main" }], defaultId: "main" },
currentAgentId: "main",
onAgentChange: () => undefined,
...overrides,
};
}
function renderQueue(queue: ChatQueueItem[], onQueueSteer = vi.fn()) {
const container = document.createElement("div");
render(
renderChat(
createProps({
queue,
onQueueSteer,
}),
),
container,
);
return { container, onQueueSteer };
}
describe("chat view queue steering", () => {
afterEach(() => {
cleanupChatModuleState();
});
it("renders Steer only for queued messages during an active run", () => {
const { container, onQueueSteer } = renderQueue([
{ id: "queued-1", text: "tighten the plan", createdAt: 1 },
{ id: "steered-1", text: "already sent", createdAt: 2, kind: "steered" },
{ id: "local-1", text: "/status", createdAt: 3, localCommandName: "status" },
]);
const steerButtons = container.querySelectorAll<HTMLButtonElement>(".chat-queue__steer");
expect(steerButtons).toHaveLength(1);
expect(steerButtons[0].textContent?.trim()).toBe("Steer");
expect(container.querySelector(".chat-queue__badge")?.textContent?.trim()).toBe("Steered");
steerButtons[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onQueueSteer).toHaveBeenCalledWith("queued-1");
});
it("hides queued-message Steer when no run is active", () => {
const { container } = renderQueue(
[{ id: "queued-1", text: "tighten the plan", createdAt: 1 }],
vi.fn(),
);
render(
renderChat(
createProps({
canAbort: false,
stream: null,
queue: [{ id: "queued-1", text: "tighten the plan", createdAt: 1 }],
}),
),
container,
);
expect(container.querySelector(".chat-queue__steer")).toBeNull();
});
});

View File

@@ -97,6 +97,7 @@ export type ChatProps = {
onSend: () => void;
onAbort?: () => void;
onQueueRemove: (id: string) => void;
onQueueSteer?: (id: string) => void;
onDismissSideResult?: () => void;
onNewSession: () => void;
onClearHistory?: () => void;
@@ -1131,19 +1132,47 @@ export function renderChat(props: ChatProps) {
<div class="chat-queue__list">
${props.queue.map(
(item) => html`
<div class="chat-queue__item">
<div class="chat-queue__text">
${item.text ||
(item.attachments?.length ? `Image (${item.attachments.length})` : "")}
<div
class="chat-queue__item ${item.kind === "steered"
? "chat-queue__item--steered"
: ""}"
>
<div class="chat-queue__main">
${item.kind === "steered"
? html`<span class="chat-queue__badge">Steered</span>`
: nothing}
<div class="chat-queue__text">
${item.text ||
(item.attachments?.length ? `Image (${item.attachments.length})` : "")}
</div>
</div>
<div class="chat-queue__actions">
${props.canAbort &&
props.onQueueSteer &&
item.kind !== "steered" &&
!item.localCommandName
? html`
<button
class="btn chat-queue__steer"
type="button"
title="Steer now"
aria-label="Steer queued message"
@click=${() => props.onQueueSteer?.(item.id)}
>
${icons.cornerDownRight}
<span>Steer</span>
</button>
`
: nothing}
<button
class="btn chat-queue__remove"
type="button"
aria-label="Remove queued message"
@click=${() => props.onQueueRemove(item.id)}
>
${icons.x}
</button>
</div>
<button
class="btn chat-queue__remove"
type="button"
aria-label="Remove queued message"
@click=${() => props.onQueueRemove(item.id)}
>
${icons.x}
</button>
</div>
`,
)}