fix(ui): prevent reading indicator from sticking after assistant response (#83711)

Summary:
- The PR removes the Control UI chat fallback that converts a null stream into an empty stream for abortable runs, adds null-vs-empty stream regression tests, and updates the changelog.
- Reproducibility: yes. source-level reproduction is high confidence: current main converts null stream plus c ... ading indicator. The linked source PR also reports live Control UI verification after the equivalent patch.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(ui): prevent reading indicator from sticking after assistant resp…

Validation:
- ClawSweeper review passed for head 44bea55110.
- Required merge gates passed before the squash merge.

Prepared head SHA: 44bea55110
Review: https://github.com/openclaw/openclaw/pull/83711#issuecomment-4480128171

Co-authored-by: 二狗子 <njuboy11@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
clawsweeper[bot]
2026-05-18 17:28:26 +00:00
committed by GitHub
parent 5702858553
commit a4f80f905d
3 changed files with 28 additions and 4 deletions
+1
View File
@@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/image generation: allow distinct `image_generate` prompts to start separate session-backed background tasks while same-prompt retries still return the active task status. (#83614) Thanks @Elarwei001.
- Control UI: stop the chat reading indicator from sticking after an assistant response finishes. (#83515) Thanks @njuboy11.
- Skills: reject empty or whitespace-only skill names and descriptions during quick validation. (#27061)
- Sessions: skip trailing custom transcript entries when checking tail assistant replies so embedded CLI gap-fill does not duplicate canonical assistant output. (#83635) Thanks @yaoyi1222.
- Memory Wiki: keep `wiki_lint` tool output path-safe by reporting vault-internal lint reports as relative paths in tool text and details while preserving absolute report paths for CLI/file callers. (#83439) Thanks @LLagoon3.
+26 -3
View File
@@ -489,14 +489,37 @@ describe("chat loading skeleton", () => {
expect(container.querySelector(".agent-chat__welcome")).toBeNull();
});
it("shows the reading indicator instead of the skeleton while an active run has no stream yet", () => {
it("shows the loading skeleton for an active run with no stream", () => {
const container = renderChatView({ canAbort: true, loading: true });
expect(container.querySelector(".chat-loading-skeleton")).toBeNull();
expect(container.querySelectorAll(".chat-reading-indicator")).toHaveLength(1);
expect(container.querySelector(".chat-loading-skeleton")).not.toBeNull();
expect(container.querySelectorAll(".chat-reading-indicator")).toHaveLength(0);
expect(container.querySelector(".agent-chat__welcome")).toBeNull();
});
it("shows the reading indicator when an active run has an empty stream", () => {
const container = renderChatView({ canAbort: true, stream: "" });
expect(container.querySelector(".chat-reading-indicator")).not.toBeNull();
});
it("does not keep the reading indicator after an assistant response has rendered", () => {
const container = renderChatView({
canAbort: true,
messages: [
{
role: "assistant",
content: "Finished answer",
timestamp: 1,
},
],
stream: null,
});
expect(container.querySelector(".chat-reading-indicator")).toBeNull();
expect(container.querySelector(".chat-group")?.textContent?.trim()).toBe("Finished answer");
});
it("keeps existing messages visible without the skeleton during a background reload", () => {
const container = renderChatView({
loading: true,
+1 -1
View File
@@ -1003,7 +1003,7 @@ export function renderChat(props: ChatProps) {
const requestUpdate = props.onRequestUpdate ?? (() => {});
const splitRatio = props.splitRatio ?? 0.6;
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
const displayStream = props.stream ?? (canAbort ? "" : null);
const displayStream = props.stream ?? null;
const handleCodeBlockCopy = (e: Event) => {
const btn = (e.target as HTMLElement).closest(".code-block-copy");