mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
committed by
GitHub
parent
4b6bbd52e6
commit
9a04fb40ce
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user