From 9a04fb40cefe440e43dd77bf16af2a4f9f8fd757 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 18:08:43 -0700 Subject: [PATCH] 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 --- ui/src/e2e/chat-composer-redesign.e2e.test.ts | 65 ++---- ui/src/pages/chat/chat-composer.test.ts | 18 +- .../chat/chat-responsive.browser.test.ts | 27 +-- ui/src/pages/chat/chat-thread.test.ts | 49 ++++ ui/src/pages/chat/chat-thread.ts | 31 ++- ui/src/pages/chat/chat-view.test.ts | 215 ++++++++++++------ ui/src/pages/chat/chat-view.ts | 2 + ui/src/pages/chat/components/chat-composer.ts | 86 +++---- ui/src/pages/chat/components/chat-message.ts | 6 +- ui/src/pages/chat/components/chat-thread.ts | 4 + ui/src/styles/chat/grouped.css | 2 +- ui/src/styles/chat/layout.css | 80 +------ ui/src/styles/chat/tool-cards.css | 53 ++--- 13 files changed, 337 insertions(+), 301 deletions(-) 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