diff --git a/ui/src/e2e/chat-composer-redesign.e2e.test.ts b/ui/src/e2e/chat-composer-redesign.e2e.test.ts
index 6361faf74894..e6bd4e87bfb2 100644
--- a/ui/src/e2e/chat-composer-redesign.e2e.test.ts
+++ b/ui/src/e2e/chat-composer-redesign.e2e.test.ts
@@ -361,7 +361,15 @@ describeControlUiE2e("Control UI chat composer redesign", () => {
"idempotencyKey" in sendRequest.params
? String(sendRequest.params.idempotencyKey)
: "";
+ // Pre-first-token: the thread shows the working spark; the composer
+ // renders no visible run status (sr-only announcement only).
+ const spark = page.locator(".chat-reading-indicator");
+ await expect.poll(() => spark.isVisible()).toBe(true);
await gateway.resolveDeferred("chat.send", { runId, status: "started" });
+ await expect.poll(() => spark.isVisible()).toBe(true);
+ const announcement = composer.locator(".agent-chat__run-status-announcement");
+ await expect.poll(() => announcement.textContent()).toContain("Rosita is");
+ await expect.poll(() => composer.locator(".agent-chat__composer-run-status").count()).toBe(0);
await gateway.emitGatewayEvent("chat", {
deltaText: "Working on it.",
message: {
@@ -373,49 +381,26 @@ describeControlUiE2e("Control UI chat composer redesign", () => {
sessionKey: "main",
state: "delta",
});
- const progress = composer.locator(".agent-chat__composer-run-status .agent-chat__run-status");
- await expect.poll(() => progress.isVisible()).toBe(true);
- await expect.poll(() => progress.textContent()).toContain("Rosita is responding");
- await expect
- .poll(() =>
- progress.evaluate((node) => node.closest(".agent-chat__composer-controls") != null),
- )
- .toBe(true);
- const [
- activeSettingsBox,
- activeSplitViewBox,
- activeProgressBox,
- activeModelBox,
- activeChatContentBox,
- ] = await Promise.all([
- settings.boundingBox(),
- splitView.boundingBox(),
- progress.boundingBox(),
- model.boundingBox(),
- chatContent.boundingBox(),
- ]);
+ // Streaming content replaces the spark as the working signal.
+ await expect.poll(() => page.getByText("Working on it.").first().isVisible()).toBe(true);
+ await expect.poll(() => spark.count()).toBe(0);
+ await expect.poll(() => announcement.textContent()).toContain("Rosita is responding");
+ const [activeSettingsBox, activeSplitViewBox, activeModelBox, activeChatContentBox] =
+ await Promise.all([
+ settings.boundingBox(),
+ splitView.boundingBox(),
+ model.boundingBox(),
+ chatContent.boundingBox(),
+ ]);
expect(activeSettingsBox).not.toBeNull();
expect(activeSplitViewBox).not.toBeNull();
- expect(activeProgressBox).not.toBeNull();
expect(activeModelBox).not.toBeNull();
expect(activeChatContentBox).not.toBeNull();
- if (
- !activeSettingsBox ||
- !activeSplitViewBox ||
- !activeProgressBox ||
- !activeModelBox ||
- !activeChatContentBox
- ) {
+ if (!activeSettingsBox || !activeSplitViewBox || !activeModelBox || !activeChatContentBox) {
throw new Error("expected chat content and composer controls to have layout boxes");
}
- expect(activeProgressBox.x).toBeGreaterThanOrEqual(
- activeSettingsBox.x + activeSettingsBox.width - 1,
- );
- expect(
- activeProgressBox.x - (activeSettingsBox.x + activeSettingsBox.width),
- ).toBeLessThanOrEqual(8);
expect(activeModelBox.x).toBeGreaterThanOrEqual(
- activeProgressBox.x + activeProgressBox.width - 1,
+ activeSettingsBox.x + activeSettingsBox.width - 1,
);
// The opener lives in the floating toggle cluster pinned to the
// top-right corner of the chat area. The cluster's right edge hugs the
@@ -433,14 +418,6 @@ describeControlUiE2e("Control UI chat composer redesign", () => {
),
).toBeLessThanOrEqual(24);
expect(Math.abs(activeSplitViewBox.y - activeChatContentBox.y)).toBeLessThanOrEqual(24);
- expect(
- Math.abs(
- activeProgressBox.y +
- activeProgressBox.height / 2 -
- (activeSettingsBox.y + activeSettingsBox.height / 2),
- ),
- ).toBeLessThanOrEqual(2);
- await expect.poll(() => progress.textContent()).toContain("Rosita is responding");
const stop = page.getByRole("button", { name: "Stop generating" });
await expect.poll(() => stop.isVisible()).toBe(true);
await stop.click();
diff --git a/ui/src/pages/chat/chat-composer.test.ts b/ui/src/pages/chat/chat-composer.test.ts
index d6e66245be54..d359e92b4705 100644
--- a/ui/src/pages/chat/chat-composer.test.ts
+++ b/ui/src/pages/chat/chat-composer.test.ts
@@ -321,15 +321,15 @@ describe("chat run controls", () => {
});
describe("chat status indicators", () => {
- it("renders compact composer run statuses", () => {
+ it("renders only interrupted as a visible composer run status", () => {
const container = document.createElement("div");
const nowSpy = vi.spyOn(Date, "now");
try {
nowSpy.mockReturnValue(1_000);
+ // Working and Done have no composer chrome: the thread spark and content
+ // arriving cover them (the sr-only region announces them separately).
render(renderChatRunStatusIndicator({ phase: "in-progress" }), container);
- let indicator = container.querySelector(".agent-chat__run-status--in-progress");
- expect(indicator?.textContent).toContain("In progress");
- expect(indicator?.getAttribute("aria-label")).toBe("Run status: In progress");
+ expect(container.querySelector(".agent-chat__run-status")).toBeNull();
render(
renderChatRunStatusIndicator({
@@ -340,8 +340,7 @@ describe("chat status indicators", () => {
}),
container,
);
- indicator = container.querySelector(".agent-chat__run-status--done");
- expect(indicator?.textContent).toContain("Done");
+ expect(container.querySelector(".agent-chat__run-status")).toBeNull();
render(
renderChatRunStatusIndicator({
@@ -352,20 +351,21 @@ describe("chat status indicators", () => {
}),
container,
);
- indicator = container.querySelector(".agent-chat__run-status--interrupted");
+ const indicator = container.querySelector(".agent-chat__run-status--interrupted");
expect(indicator?.textContent).toContain("Interrupted");
+ expect(indicator?.getAttribute("aria-label")).toBe("Run status: Interrupted");
nowSpy.mockReturnValue(7_000);
render(
renderChatRunStatusIndicator({
- phase: "done",
+ phase: "interrupted",
runId: "run-1",
sessionKey: "main",
occurredAt: 1_000,
}),
container,
);
- expect(container.querySelector(".agent-chat__run-status--done")).toBeNull();
+ expect(container.querySelector(".agent-chat__run-status--interrupted")).toBeNull();
} finally {
nowSpy.mockRestore();
}
diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts
index f89f7467ac6a..769edbdfc3bd 100644
--- a/ui/src/pages/chat/chat-responsive.browser.test.ts
+++ b/ui/src/pages/chat/chat-responsive.browser.test.ts
@@ -404,11 +404,6 @@ function chatHtml(opts: ChatFixtureOptions = {}) {
-
-
- ${iconSvg()}In progress
-
-
8
@@ -1307,7 +1302,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
input: rectFor(".agent-chat__input"),
thread: rectFor(".chat-thread"),
footer: rectFor(".agent-chat__composer-footer"),
- progress: rectFor(".agent-chat__composer-progress"),
textarea: rectFor(".agent-chat__composer-combobox > textarea"),
meta: rectFor(".agent-chat__composer-meta"),
model: rectFor(".chat-composer-model-control"),
@@ -1323,7 +1317,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
const input = expectControlRect(controls.input, "composer");
const thread = expectControlRect(controls.thread, "chat thread");
const footer = expectControlRect(controls.footer, "composer footer");
- const progress = expectControlRect(controls.progress, "composer progress");
const textarea = expectControlRect(controls.textarea, "composer textarea");
const meta = expectControlRect(controls.meta, "composer metadata");
const model = expectControlRect(controls.model, "composer model control");
@@ -1332,17 +1325,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
const attach = expectControlRect(controls.attach, "composer attach control");
const send = expectControlRect(controls.send, "composer send control");
- for (const control of [
- footer,
- progress,
- textarea,
- meta,
- model,
- context,
- settings,
- attach,
- send,
- ]) {
+ for (const control of [footer, textarea, meta, model, context, settings, attach, send]) {
expect(control.x).toBeGreaterThanOrEqual(input.x - 1);
expect(control.x + control.width).toBeLessThanOrEqual(input.x + input.width + 1);
}
@@ -1356,7 +1339,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
expect(settings.y + settings.height).toBeLessThanOrEqual(footer.y + footer.height + 1);
expect(model.y).toBeGreaterThanOrEqual(textarea.y);
expect(context.y).toBeGreaterThanOrEqual(textarea.y);
- expect(progress.y).toBeGreaterThanOrEqual(textarea.y);
expect(
Math.abs(attach.y + attach.height / 2 - (send.y + send.height / 2)),
).toBeLessThanOrEqual(2);
@@ -1364,11 +1346,6 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
expect(model.x).toBeGreaterThanOrEqual(settings.x + settings.width - 1);
expect(send.x).toBeGreaterThanOrEqual(textarea.x + textarea.width - 1);
expect(send.x + send.width).toBeLessThanOrEqual(input.x + input.width + 1);
- expect(progress.x).toBeGreaterThanOrEqual(context.x + context.width - 1);
- expect(
- Math.abs(progress.y + progress.height / 2 - (context.y + context.height / 2)),
- ).toBeLessThanOrEqual(2);
- expect(rectsOverlap(progress, context)).toBe(false);
expect(rectsOverlap(model, settings)).toBe(false);
expect(rectsOverlap(model, send)).toBe(false);
expect(rectsOverlap(settings, send)).toBe(false);
@@ -1381,7 +1358,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
expect(model.width).toBeLessThanOrEqual(footer.width);
expect(send.width).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
expect(send.height).toBeGreaterThanOrEqual(TOUCH_TARGET_MIN_PX);
- for (const control of [model, settings, context, progress]) {
+ for (const control of [model, settings, context]) {
expect(
Math.abs(control.y + control.height / 2 - (settings.y + settings.height / 2)),
).toBeLessThanOrEqual(2);
diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts
index a414fde308da..6e3b7e70eebc 100644
--- a/ui/src/pages/chat/chat-thread.test.ts
+++ b/ui/src/pages/chat/chat-thread.test.ts
@@ -53,6 +53,55 @@ function messageRecord(group: MessageGroup, index = 0): Record
return requireRecord(group.messages[index]?.message);
}
+describe("buildChatItems working spark", () => {
+ const hasReadingIndicator = (props: Partial) =>
+ buildChatItems(createProps(props)).some((item) => item.kind === "reading-indicator");
+ const liveTool = (resultReceived: boolean) => ({
+ role: "assistant",
+ toolCallId: "tool-1",
+ content: [{ type: "toolcall", name: "exec", arguments: {} }],
+ timestamp: 1_000,
+ __openclawToolStreamLive: true,
+ __openclawToolStreamResultReceived: resultReceived,
+ });
+
+ it("shows the spark while a run works with nothing streaming", () => {
+ expect(hasReadingIndicator({ runWorking: true })).toBe(true);
+ });
+
+ it("keeps the spark during a background reload with visible content", () => {
+ expect(
+ hasReadingIndicator({
+ runWorking: true,
+ loading: true,
+ messages: [{ role: "assistant", content: "answer", timestamp: 1 }],
+ }),
+ ).toBe(true);
+ });
+
+ it("yields to the initial-load skeleton on an empty thread", () => {
+ expect(hasReadingIndicator({ runWorking: true, loading: true })).toBe(false);
+ });
+
+ it("does not stack the spark under a visible running tool row", () => {
+ expect(hasReadingIndicator({ runWorking: true, toolMessages: [liveTool(false)] })).toBe(false);
+ });
+
+ it("returns the spark once the running tool resolves", () => {
+ expect(hasReadingIndicator({ runWorking: true, toolMessages: [liveTool(true)] })).toBe(true);
+ });
+
+ it("keeps the spark when tool calls are hidden", () => {
+ expect(
+ hasReadingIndicator({
+ runWorking: true,
+ showToolCalls: false,
+ toolMessages: [liveTool(false)],
+ }),
+ ).toBe(true);
+ });
+});
+
describe("buildChatItems", () => {
it("keeps consecutive user messages from different senders in separate groups", () => {
const groups = messageGroups({
diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts
index 55387ed9ca7e..0a4e9859bd22 100644
--- a/ui/src/pages/chat/chat-thread.ts
+++ b/ui/src/pages/chat/chat-thread.ts
@@ -46,6 +46,10 @@ export type BuildChatItemsProps = {
streamStartedAt: number | null;
queue?: ChatQueueItem[];
showToolCalls: boolean;
+ /** True while the agent is visibly working (isChatRunWorking). */
+ runWorking?: boolean;
+ /** True while chat history is loading (initial load or background reload). */
+ loading?: boolean;
searchOpen?: boolean;
searchQuery?: string;
historyRenderLimit?: number;
@@ -1300,11 +1304,30 @@ export function buildChatItems(props: BuildChatItemsProps): Array {
+ const record = asRecord(message);
+ return (
+ record?.["__openclawToolStreamLive"] === true &&
+ record["__openclawToolStreamResultReceived"] !== true
+ );
+ });
+ // The initial-load skeleton owns the empty thread; a background reload with
+ // content still visible keeps the spark (it is the only working signal).
+ const initialHistoryLoad = props.loading === true && items.length === 0;
const hasPendingResponse =
props.stream === null &&
- queuedSends.some(
- (item) => item.sendState === "sending" && shouldRenderQueuedSendInThread(item),
- );
+ ((props.runWorking === true && !hasVisibleRunningTool && !initialHistoryLoad) ||
+ queuedSends.some(
+ (item) => item.sendState === "sending" && shouldRenderQueuedSendInThread(item),
+ ));
if (hasPendingResponse) {
items.push({
kind: "reading-indicator",
@@ -1349,6 +1372,8 @@ function sameChatItemsInput(previous: BuildChatItemsProps, next: BuildChatItemsP
previous.streamStartedAt === next.streamStartedAt &&
previous.queue === next.queue &&
previous.showToolCalls === next.showToolCalls &&
+ previous.runWorking === next.runWorking &&
+ previous.loading === next.loading &&
previous.searchOpen === next.searchOpen &&
previous.searchQuery === next.searchQuery &&
previous.historyRenderLimit === next.historyRenderLimit
diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts
index a9d0a21436fa..72e8c2e77fc9 100644
--- a/ui/src/pages/chat/chat-view.test.ts
+++ b/ui/src/pages/chat/chat-view.test.ts
@@ -58,33 +58,40 @@ const refreshVisibleToolsEffectiveForCurrentSessionMock = vi.hoisted(() =>
}),
);
const buildChatItemsMock = vi.hoisted(() =>
- vi.fn((props: { messages: unknown[]; stream: string | null; streamStartedAt: number | null }) => {
- if (
- props.messages.some(
- (message) =>
- typeof message === "object" &&
- message !== null &&
- (message as { __testDivider?: unknown })["__testDivider"] === true,
- )
- ) {
- return [
- {
- kind: "divider",
- key: "divider:compaction:test",
- label: "Compacted history",
- description:
- "The compacted transcript is preserved as a checkpoint. Open session checkpoints to branch or restore from that compacted view.",
- action: {
- kind: "session-checkpoints",
- label: "Open checkpoints",
+ vi.fn(
+ (props: {
+ messages: unknown[];
+ stream: string | null;
+ streamStartedAt: number | null;
+ runWorking?: boolean;
+ loading?: boolean;
+ }) => {
+ if (
+ props.messages.some(
+ (message) =>
+ typeof message === "object" &&
+ message !== null &&
+ (message as { __testDivider?: unknown })["__testDivider"] === true,
+ )
+ ) {
+ return [
+ {
+ kind: "divider",
+ key: "divider:compaction:test",
+ label: "Compacted history",
+ description:
+ "The compacted transcript is preserved as a checkpoint. Open session checkpoints to branch or restore from that compacted view.",
+ action: {
+ kind: "session-checkpoints",
+ label: "Open checkpoints",
+ },
+ timestamp: 1,
},
- timestamp: 1,
- },
- ];
- }
- if (props.messages.length > 0) {
- return [
- {
+ ];
+ }
+ const items: unknown[] = [];
+ if (props.messages.length > 0) {
+ items.push({
kind: "group",
key: "group:assistant:test",
role: "assistant",
@@ -94,24 +101,33 @@ const buildChatItemsMock = vi.hoisted(() =>
})),
timestamp: 1,
isStreaming: false,
- },
- ];
- }
- if (props.stream !== null) {
- return props.stream
- ? [
- {
- kind: "stream",
- key: "stream:test",
- text: props.stream,
- startedAt: props.streamStartedAt ?? 1,
- isStreaming: true,
- },
- ]
- : [{ kind: "reading-indicator", key: "reading:test" }];
- }
- return [];
- }),
+ });
+ }
+ // Mirrors buildChatItems: streamed text renders as a stream item; an
+ // empty stream or a working run with no stream shows the reading
+ // indicator (working spark), except on the initial empty load where
+ // the skeleton owns the thread.
+ if (props.stream !== null) {
+ items.push(
+ props.stream
+ ? {
+ kind: "stream",
+ key: "stream:test",
+ text: props.stream,
+ startedAt: props.streamStartedAt ?? 1,
+ isStreaming: true,
+ }
+ : { kind: "reading-indicator", key: "reading:test" },
+ );
+ } else if (
+ props.runWorking === true &&
+ !(props.loading === true && props.messages.length === 0)
+ ) {
+ items.push({ kind: "reading-indicator", key: "reading:test" });
+ }
+ return items;
+ },
+ ),
);
const renderMessageGroupMock = vi.hoisted(() =>
vi.fn(
@@ -1856,7 +1872,7 @@ describe("chat loading skeleton", () => {
expect(container.querySelector(".chat-reading-indicator")).not.toBeNull();
});
- it("does not keep the reading indicator after an assistant response has rendered", () => {
+ it("keeps the working spark below a rendered response while the run continues", () => {
const container = renderChatView({
canAbort: true,
messages: [
@@ -1869,10 +1885,34 @@ describe("chat loading skeleton", () => {
stream: null,
});
- expect(container.querySelector(".chat-reading-indicator")).toBeNull();
+ // canAbort with no terminal status means the run is still working (e.g.
+ // between tool steps); the spark stays as the "still working" signal.
+ expect(container.querySelector(".chat-reading-indicator")).not.toBeNull();
expect(container.querySelector(".chat-group")?.textContent?.trim()).toBe("Finished answer");
});
+ it("drops the working spark once the run reaches a terminal status", () => {
+ const container = renderChatView({
+ canAbort: true,
+ runStatus: {
+ phase: "done",
+ runId: "run-1",
+ sessionKey: "main",
+ occurredAt: Date.now(),
+ },
+ messages: [
+ {
+ role: "assistant",
+ content: "Finished answer",
+ timestamp: 1,
+ },
+ ],
+ stream: null,
+ });
+
+ expect(container.querySelector(".chat-reading-indicator")).toBeNull();
+ });
+
it("keeps existing messages visible without the skeleton during a background reload", () => {
const container = renderChatView({
loading: true,
@@ -1948,15 +1988,15 @@ describe("chat loading skeleton", () => {
},
});
- const status = container.querySelector(
- ".agent-chat__composer-run-status .agent-chat__run-status--in-progress",
- );
+ // The composer shows no working chrome; the thread spark is the visible
+ // signal and the sr-only region carries the phase announcement.
const context = container.querySelector(".context-ring");
const contextUsage = context?.closest(".context-usage");
- expect(status).toBeInstanceOf(HTMLElement);
- expect(status?.textContent).toContain("Sending message");
- expect(status?.closest(".agent-chat__composer-controls")).not.toBeNull();
- expect(status?.closest(".agent-chat__composer-footer")).not.toBeNull();
+ expect(container.querySelector(".agent-chat__run-status")).toBeNull();
+ expect(container.querySelector(".agent-chat__run-status-announcement")?.textContent).toContain(
+ "Sending message",
+ );
+ expect(container.querySelector(".chat-reading-indicator")).not.toBeNull();
expect(contextUsage?.closest(".agent-chat__composer-meta")).not.toBeNull();
});
@@ -2023,7 +2063,7 @@ describe("chat loading skeleton", () => {
expect(usageLink?.getAttribute("href")).toBe("/rosita/usage");
});
- it("does not show prompt-bar progress for another session send", () => {
+ it("does not announce progress for another session send", () => {
const container = renderChatView({
sessionKey: "session-b",
sending: true,
@@ -2039,10 +2079,13 @@ describe("chat loading skeleton", () => {
],
});
- expect(container.querySelector(".agent-chat__run-status--in-progress")).toBeNull();
+ expect(
+ container.querySelector(".agent-chat__run-status-announcement")?.textContent?.trim(),
+ ).toBe("");
+ expect(container.querySelector(".chat-reading-indicator")).toBeNull();
});
- it("shows prompt-bar progress while the current session send waits for model switching", () => {
+ it("shows the working spark while the current session send waits for model switching", () => {
const container = renderChatView({
queue: [
{
@@ -2056,9 +2099,10 @@ describe("chat loading skeleton", () => {
],
});
- const status = container.querySelector(".agent-chat__run-status--in-progress");
- expect(status).toBeInstanceOf(HTMLElement);
- expect(status?.textContent).toContain("Preparing model");
+ expect(container.querySelector(".agent-chat__run-status-announcement")?.textContent).toContain(
+ "Preparing model",
+ );
+ expect(container.querySelector(".chat-reading-indicator")).not.toBeNull();
});
it("shows active model-switch progress over the previous run's terminal status", () => {
@@ -2081,8 +2125,10 @@ describe("chat loading skeleton", () => {
],
});
- expect(container.querySelector(".agent-chat__run-status--in-progress")).not.toBeNull();
- expect(container.querySelector(".agent-chat__run-status--done")).toBeNull();
+ expect(container.querySelector(".agent-chat__run-status-announcement")?.textContent).toContain(
+ "Preparing model",
+ );
+ expect(container.querySelector(".chat-reading-indicator")).not.toBeNull();
});
it("keeps terminal status for the submitted run while its acknowledgement is pending", () => {
@@ -2106,11 +2152,13 @@ describe("chat loading skeleton", () => {
],
});
- expect(container.querySelector(".agent-chat__run-status--done")).not.toBeNull();
- expect(container.querySelector(".agent-chat__run-status--in-progress")).toBeNull();
+ expect(
+ container.querySelector(".agent-chat__run-status-announcement")?.textContent?.trim(),
+ ).toBe("Done");
+ expect(container.querySelector(".chat-reading-indicator")).toBeNull();
});
- it("does not show prompt-bar progress for reconnect-waiting sends", () => {
+ it("does not announce progress for reconnect-waiting sends", () => {
const container = renderChatView({
queue: [
{
@@ -2124,7 +2172,10 @@ describe("chat loading skeleton", () => {
],
});
- expect(container.querySelector(".agent-chat__run-status--in-progress")).toBeNull();
+ expect(
+ container.querySelector(".agent-chat__run-status-announcement")?.textContent?.trim(),
+ ).toBe("");
+ expect(container.querySelector(".chat-reading-indicator")).toBeNull();
});
it("lets terminal run status win over stale abortable session UI", () => {
@@ -2158,16 +2209,42 @@ describe("chat loading skeleton", () => {
onCompact: () => undefined,
});
- expect(container.querySelector(".agent-chat__run-status--done")?.textContent).toContain(
- "Done",
- );
- expect(container.querySelector(".agent-chat__run-status--in-progress")).toBeNull();
+ expect(
+ container.querySelector(".agent-chat__run-status-announcement")?.textContent?.trim(),
+ ).toBe("Done");
+ expect(container.querySelector(".agent-chat__run-status")).toBeNull();
expect(container.querySelector(".chat-reading-indicator")).toBeNull();
expect(container.querySelector(".chat-send-btn--stop")).toBeNull();
} finally {
nowSpy.mockRestore();
}
});
+
+ it("shows the interrupted toast in the composer footer", () => {
+ const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
+ try {
+ const container = renderChatView({
+ composerControls: html``,
+ runStatus: {
+ phase: "interrupted",
+ runId: "run-1",
+ sessionKey: "main",
+ occurredAt: 1_000,
+ },
+ });
+
+ const toast = container.querySelector(
+ ".agent-chat__composer-run-status .agent-chat__run-status--interrupted",
+ );
+ expect(toast?.textContent).toContain("Interrupted");
+ expect(
+ container.querySelector(".agent-chat__run-status-announcement")?.textContent?.trim(),
+ ).toBe("Interrupted");
+ expect(container.querySelector(".chat-reading-indicator")).toBeNull();
+ } finally {
+ nowSpy.mockRestore();
+ }
+ });
});
describe("chat voice controls", () => {
diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts
index 94cec14da66c..30bf51413fa0 100644
--- a/ui/src/pages/chat/chat-view.ts
+++ b/ui/src/pages/chat/chat-view.ts
@@ -25,6 +25,7 @@ import {
} from "./components/chat-background-tasks.ts";
import {
handleChatAttachmentDrop,
+ isChatRunWorking,
renderChatComposer,
resetChatComposerState,
} from "./components/chat-composer.ts";
@@ -232,6 +233,7 @@ export function renderChat(props: ChatProps) {
showThinking: props.showThinking,
showToolCalls: props.showToolCalls,
runActive: Boolean(props.canAbort),
+ runWorking: isChatRunWorking(props),
sessions: props.sessions,
sessionHost: props.sessionHost,
assistantName: props.assistantName,
diff --git a/ui/src/pages/chat/components/chat-composer.ts b/ui/src/pages/chat/components/chat-composer.ts
index 724dd3d4e587..8034f801994b 100644
--- a/ui/src/pages/chat/components/chat-composer.ts
+++ b/ui/src/pages/chat/components/chat-composer.ts
@@ -194,6 +194,21 @@ function isCurrentSessionSubmittedProgress(
);
}
+// Single source for "the agent is visibly working": drives both the thread's
+// working spark and the composer's sr-only announcement. A fresh terminal
+// toast masks stale abortable rows so neither surface flashes back to working.
+export function isChatRunWorking(
+ props: Pick,
+): boolean {
+ const canAbort = Boolean(props.canAbort && props.onAbort);
+ return (
+ (canAbort && !hasTerminalRunStatus(props.runStatus)) ||
+ props.queue.some((item) =>
+ isCurrentSessionSubmittedProgress(item, props.sessionKey, props.runStatus),
+ )
+ );
+}
+
function composerDraftKey(props: Pick): string {
return `${props.currentAgentId}\u0000${props.sessionKey}`;
}
@@ -1257,39 +1272,25 @@ type ComposerRunStatus =
occurredAt?: number | null;
};
-export function renderChatRunStatusIndicator(
- status: ComposerRunStatus | null | undefined,
- inProgressLabel = "In progress",
-) {
- if (!status) {
+// Working and Done need no composer chrome: the thread's working spark,
+// content arriving, and Stop reverting to Send already show them (screen
+// readers get the composer's persistent sr-only run-status region).
+// Interrupted keeps a visible toast: the transcript shows nothing when a run
+// is killed, so silence would read as "finished".
+export function renderChatRunStatusIndicator(status: ComposerRunStatus | null | undefined) {
+ if (status?.phase !== "interrupted") {
return nothing;
}
- if (status.phase !== "in-progress") {
- const elapsed = Date.now() - status.occurredAt;
- if (elapsed >= CHAT_RUN_STATUS_TOAST_DURATION_MS) {
- return nothing;
- }
+ const elapsed = Date.now() - status.occurredAt;
+ if (elapsed >= CHAT_RUN_STATUS_TOAST_DURATION_MS) {
+ return nothing;
}
- const label =
- status.phase === "in-progress"
- ? inProgressLabel
- : status.phase === "done"
- ? "Done"
- : "Interrupted";
- const icon =
- status.phase === "in-progress"
- ? icons.loader
- : status.phase === "done"
- ? icons.check
- : icons.stop;
return html`
- ${icon}${label}
+ ${icons.stop}Interrupted
`;
}
@@ -2107,7 +2108,16 @@ export function renderChatComposer(props: ChatComposerProps) {
: props.sending || submittedProgress
? "Sending message..."
: `${assistantName} is working...`;
- const mobileRunStatusIndicator = renderChatRunStatusIndicator(composerRunStatus, inProgressLabel);
+ // Persistent sr-only live region: run phases are otherwise conveyed only
+ // visually (thread spark, content arriving, interrupted toast).
+ const runStatusAnnouncement =
+ composerRunStatus == null
+ ? ""
+ : composerRunStatus.phase === "in-progress"
+ ? inProgressLabel
+ : composerRunStatus.phase === "done"
+ ? "Done"
+ : "Interrupted";
const requestUpdate = props.onRequestUpdate ?? (() => {});
const sendShortcut = normalizeChatSendShortcut(props.sendShortcut);
@@ -2368,15 +2378,6 @@ export function renderChatComposer(props: ChatComposerProps) {
onQueueRemove: props.onQueueRemove,
})}
- ${mobileRunStatusIndicator !== nothing && composerRunStatus
- ? html`
-
- ${mobileRunStatusIndicator}
-
- `
- : nothing}
focusComposerFromChrome(event, canCompose)}
@@ -2576,6 +2577,13 @@ export function renderChatComposer(props: ChatComposerProps) {
aria-atomic="true"
>${activeSlashMenuOptionLabel}
+ ${runStatusAnnouncement}
${renderChatPrimaryActions(runControlsProps)}
@@ -2586,10 +2594,10 @@ export function renderChatComposer(props: ChatComposerProps) {
${composerControls !== nothing
? html`
- ${composerRunStatus
+ ${composerRunStatus?.phase === "interrupted"
? html`
- ${renderChatRunStatusIndicator(composerRunStatus, inProgressLabel)}
+ ${renderChatRunStatusIndicator(composerRunStatus)}
`
: nothing}
diff --git a/ui/src/pages/chat/components/chat-message.ts b/ui/src/pages/chat/components/chat-message.ts
index b6c4eca4c332..ea098b6acb20 100644
--- a/ui/src/pages/chat/components/chat-message.ts
+++ b/ui/src/pages/chat/components/chat-message.ts
@@ -580,10 +580,10 @@ type StreamGroupOptions = {
};
function renderReadingIndicatorBubble() {
+ // Working spark: pulsing brand mark where the reply will materialize.
+ // aria-hidden; the composer's sr-only run-status region announces phases.
return html`
-
-
-
+
${icons.spark}
`;
}
diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts
index f06bedc86dab..3f837c27bb6d 100644
--- a/ui/src/pages/chat/components/chat-thread.ts
+++ b/ui/src/pages/chat/components/chat-thread.ts
@@ -99,6 +99,8 @@ type ChatThreadProps = {
showToolCalls: boolean;
/** True while the session has an abortable live run (marks running tool rows). */
runActive?: boolean;
+ /** True while the agent is visibly working (isChatRunWorking); shows the working spark. */
+ runWorking?: boolean;
sessions: SessionsListResult | null;
/** Host context resolving global-alias session keys (scope=global fleets). */
/** Includes assistantAgentId so bare-global welcome recents scope to the selected agent. */
@@ -756,6 +758,8 @@ export function renderChatThread(props: ChatThreadProps) {
streamStartedAt: props.streamStartedAt,
queue: props.queue,
showToolCalls: props.showToolCalls,
+ runWorking: Boolean(props.runWorking),
+ loading: props.loading,
searchOpen: state.searchOpen,
searchQuery: state.searchQuery,
historyRenderLimit,
diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css
index 0615c7661434..6dbf2cc58131 100644
--- a/ui/src/styles/chat/grouped.css
+++ b/ui/src/styles/chat/grouped.css
@@ -312,7 +312,7 @@ img.chat-avatar {
box-shadow: none;
}
-/* Keep the typing dots on the same left edge as the flat text column. */
+/* Keep the working spark on the same left edge as the flat text column. */
.chat-group.assistant .chat-bubble.chat-reading-indicator {
padding: 10px 0;
}
diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css
index 478d9631a000..8e803c24a3e9 100644
--- a/ui/src/styles/chat/layout.css
+++ b/ui/src/styles/chat/layout.css
@@ -1462,48 +1462,6 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread {
}
}
-.agent-chat__composer-progress {
- display: flex;
- align-items: center;
- box-sizing: border-box;
- width: 100%;
- min-height: 32px;
- padding: 6px 10px;
- border: 1px solid color-mix(in srgb, currentColor 32%, transparent);
- border-radius: var(--radius-md);
- background: color-mix(in srgb, currentColor 8%, var(--card));
- color: var(--muted);
- pointer-events: none;
-}
-
-.agent-chat__composer-progress--mobile {
- display: none;
-}
-
-.agent-chat__composer-progress .agent-chat__run-status {
- width: 100%;
- max-width: none;
- height: auto;
- padding: 0;
- border: 0;
- border-radius: 0;
- justify-content: flex-start;
- color: inherit;
- font-size: var(--control-ui-text-sm);
-}
-
-.agent-chat__composer-progress--in-progress {
- color: var(--info);
-}
-
-.agent-chat__composer-progress--done {
- color: var(--ok);
-}
-
-.agent-chat__composer-progress--interrupted {
- color: var(--warn);
-}
-
.agent-chat__composer-combobox {
position: relative;
display: flex;
@@ -2507,12 +2465,10 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread {
.agent-chat__run-status {
display: inline-flex;
align-items: center;
- gap: 5px;
+ gap: 6px;
height: 24px;
max-width: 132px;
- padding: 0 8px;
- border-radius: var(--radius-full);
- border: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
+ padding: 0 2px;
color: var(--muted);
font-size: 0.72rem;
line-height: 1;
@@ -2538,25 +2494,11 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread {
text-overflow: ellipsis;
}
-/* Status chips keep their semantic hue in the text/icon only; borders stay
- near-neutral so the row reads as quiet metadata, not alerts. */
-.agent-chat__run-status--in-progress {
- color: var(--info);
- border-color: color-mix(in srgb, var(--info) 18%, transparent);
-}
-
-.agent-chat__run-status--in-progress svg {
- animation: chat-run-status-spin 1s linear infinite;
-}
-
-.agent-chat__run-status--done {
- color: var(--ok);
- border-color: color-mix(in srgb, var(--ok) 18%, transparent);
-}
-
+/* Interrupted is the only visible composer run status: killed runs leave no
+ transcript marker, so silence would read as "finished". Working/Done are
+ covered by the thread spark, content arriving, and Stop reverting. */
.agent-chat__run-status--interrupted {
color: var(--warn);
- border-color: color-mix(in srgb, var(--warn) 22%, transparent);
}
@keyframes chat-run-status-spin {
@@ -2803,21 +2745,15 @@ openclaw-chat-pane:has(> .chat-pane__header) .chat-thread {
.agent-chat__composer-controls {
order: 0;
display: grid;
- grid-template-columns: 40px minmax(0, max-content);
+ /* Three columns: settings, the transient interrupted toast (absent in
+ the normal state, so its column collapses), and the model picker. */
+ grid-template-columns: 40px minmax(0, max-content) minmax(0, max-content);
flex: 1 1 0;
width: auto;
margin-left: 0;
gap: 0;
}
- .agent-chat__composer-progress--mobile {
- display: flex;
- }
-
- .agent-chat__composer-run-status {
- display: none;
- }
-
.agent-chat__composer-meta {
margin-left: 0;
}
diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css
index 7c7bc105144b..ddb4524722e9 100644
--- a/ui/src/styles/chat/tool-cards.css
+++ b/ui/src/styles/chat/tool-cards.css
@@ -1002,61 +1002,42 @@
padding: 0 12px 10px 12px;
}
-/* Reading Indicator: bubble chrome and sizing come from .chat-bubble and
- .chat-bubble.chat-reading-indicator (components.css). */
+/* Reading indicator = working spark: pulsing brand mark where the reply will
+ materialize while the agent works with nothing visibly streaming. Bubble
+ chrome and sizing come from .chat-bubble.chat-reading-indicator
+ (components.css). Filled, not stroked: it reads as a solid star at 16px. */
.chat-reading-indicator {
display: inline-flex;
-}
-
-.chat-reading-indicator__dots {
- display: inline-flex;
align-items: center;
- gap: 4px;
- height: 12px;
+ color: var(--accent);
}
-.chat-reading-indicator__dots span {
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: var(--radius-full);
- background: var(--muted);
- opacity: 0.6;
- transform: translateY(0);
- animation: chatReadingDot 1.2s ease-in-out infinite;
+.chat-reading-indicator svg {
+ width: 16px;
+ height: 16px;
+ fill: currentColor;
+ stroke: none;
+ animation: chatWorkingSparkPulse 1.6s ease-in-out infinite;
will-change: transform, opacity;
}
-.chat-reading-indicator__dots span:nth-child(1) {
- animation-delay: 0s;
-}
-
-.chat-reading-indicator__dots span:nth-child(2) {
- animation-delay: 0.15s;
-}
-
-.chat-reading-indicator__dots span:nth-child(3) {
- animation-delay: 0.3s;
-}
-
-@keyframes chatReadingDot {
+@keyframes chatWorkingSparkPulse {
0%,
- 80%,
100% {
opacity: 0.4;
- transform: translateY(0);
+ transform: scale(0.86);
}
- 40% {
+ 50% {
opacity: 1;
- transform: translateY(-3px);
+ transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
- .chat-reading-indicator__dots span {
+ .chat-reading-indicator svg {
animation: none;
- opacity: 0.6;
+ opacity: 0.85;
}
}