feat(ui): replace composer run-status pill with a transcript working spark (#104768)

* feat(ui): redesign composer run-status indicator as chrome-free status text

* feat(ui): replace run-status text with transcript working spark

* fix(ui): keep working spark through reloads, dodge running tool rows, show mobile interrupted toast

* fix(ui): satisfy lint on tool-stream marker access
This commit is contained in:
Peter Steinberger
2026-07-11 18:08:43 -07:00
committed by GitHub
parent 4b6bbd52e6
commit 9a04fb40ce
13 changed files with 337 additions and 301 deletions
+21 -44
View File
@@ -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();
+9 -9
View File
@@ -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();
}
@@ -404,11 +404,6 @@ function chatHtml(opts: ChatFixtureOptions = {}) {
</section>
</details>
</div>
<div class="agent-chat__composer-progress">
<span class="agent-chat__run-status agent-chat__run-status--in-progress">
${iconSvg()}<span class="agent-chat__run-status-label">In progress</span>
</span>
</div>
<span class="agent-chat__token-count">8</span>
</div>
</div>
@@ -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);
+49
View File
@@ -53,6 +53,55 @@ function messageRecord(group: MessageGroup, index = 0): Record<string, unknown>
return requireRecord(group.messages[index]?.message);
}
describe("buildChatItems working spark", () => {
const hasReadingIndicator = (props: Partial<BuildChatItemsProps>) =>
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({
+28 -3
View File
@@ -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<ChatItem | Mes
}
}
// Working spark contract: whenever the agent works with nothing visibly
// streaming (pre-first-token, or a queued send in flight), the thread shows
// the reading indicator where the reply will materialize. Streaming text
// and running tool rows take over as the signal once content flows.
// A visible running tool row already signals active work, so the spark is
// suppressed rather than stacked under it; hidden tool calls keep the spark.
const hasVisibleRunningTool =
props.showToolCalls &&
tools.some((message) => {
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
+146 -69
View File
@@ -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`<button class="chat-settings-chip" type="button">Settings</button>`,
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", () => {
+2
View File
@@ -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,
+47 -39
View File
@@ -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<ChatComposerProps, "canAbort" | "onAbort" | "runStatus" | "queue" | "sessionKey">,
): 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<ChatComposerProps, "currentAgentId" | "sessionKey">): 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`
<span
class="agent-chat__run-status agent-chat__run-status--${status.phase}"
role="status"
aria-live="polite"
aria-label=${`Run status: ${label}`}
class="agent-chat__run-status agent-chat__run-status--interrupted"
aria-label="Run status: Interrupted"
>
${icon}<span class="agent-chat__run-status-label">${label}</span>
${icons.stop}<span class="agent-chat__run-status-label">Interrupted</span>
</span>
`;
}
@@ -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,
})}
<div class="agent-chat__composer-shell">
${mobileRunStatusIndicator !== nothing && composerRunStatus
? html`
<div
class="agent-chat__composer-progress agent-chat__composer-progress--mobile agent-chat__composer-progress--${composerRunStatus.phase}"
>
${mobileRunStatusIndicator}
</div>
`
: nothing}
<div
class="agent-chat__input"
@click=${(event: MouseEvent) => focusComposerFromChrome(event, canCompose)}
@@ -2576,6 +2577,13 @@ export function renderChatComposer(props: ChatComposerProps) {
aria-atomic="true"
>${activeSlashMenuOptionLabel}</span
>
<span
class="agent-chat__run-status-announcement agent-chat__sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>${runStatusAnnouncement}</span
>
</div>
<div class="agent-chat__composer-actions">
${renderChatPrimaryActions(runControlsProps)}
@@ -2586,10 +2594,10 @@ export function renderChatComposer(props: ChatComposerProps) {
${composerControls !== nothing
? html`
<div class="agent-chat__composer-controls">
${composerRunStatus
${composerRunStatus?.phase === "interrupted"
? html`
<div class="agent-chat__composer-run-status">
${renderChatRunStatusIndicator(composerRunStatus, inProgressLabel)}
${renderChatRunStatusIndicator(composerRunStatus)}
</div>
`
: nothing}
+3 -3
View File
@@ -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`
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
<span class="chat-reading-indicator__dots"> <span></span><span></span><span></span> </span>
</div>
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">${icons.spark}</div>
`;
}
@@ -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,
+1 -1
View File
@@ -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;
}
+8 -72
View File
@@ -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;
}
+17 -36
View File
@@ -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;
}
}