From 1cce439c9c8b21a1292e51f668533a03db740f8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 02:25:52 +0100 Subject: [PATCH] fix(ui): hide chat skeleton during reload --- CHANGELOG.md | 4 ++ ui/src/ui/views/chat.test.ts | 129 +++++++++++++++++++++++++++++++++++ ui/src/ui/views/chat.ts | 3 +- 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed341ef9ca..67a493b4639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,10 @@ Docs: https://docs.openclaw.ai - ACP: wait for the configured runtime backend to become healthy before startup identity reconciliation, avoiding transient acpx warnings during Gateway boot. Fixes #40566. +- Control UI: hide the chat loading skeleton during background history reloads + when existing messages or active stream content are already visible, avoiding + reload flashes on high-latency local gateways. Fixes #71844. Thanks + @WolvenRA. - CLI/status: label the OpenClaw Serve/Funnel setting as `Tailscale exposure` and show daemon state separately when available, so `gateway.tailscale.mode: "off"` no longer reads like the Tailscale daemon is stopped. Fixes #71790. diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index fe99d05413d..9e5ad9b764b 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -15,6 +15,7 @@ import { renderChatSessionSelect } from "../chat/session-controls.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; import type { ModelCatalogEntry } from "../types.ts"; import type { ChatQueueItem } from "../ui-types.ts"; +import { renderChat } from "./chat.ts"; const refreshVisibleToolsEffectiveForCurrentSessionMock = vi.hoisted(() => vi.fn(async (state: AppViewState) => { @@ -220,12 +221,140 @@ async function flushTasks() { await vi.dynamicImportSettled(); } +function renderChatView(overrides: Partial[0]> = {}) { + const container = document.createElement("div"); + render( + renderChat({ + sessionKey: "main", + onSessionKeyChange: () => undefined, + thinkingLevel: null, + showThinking: false, + showToolCalls: true, + loading: false, + sending: false, + compactionStatus: null, + fallbackStatus: null, + messages: [], + sideResult: null, + toolMessages: [], + streamSegments: [], + stream: null, + streamStartedAt: null, + assistantAvatarUrl: null, + draft: "", + queue: [], + realtimeTalkActive: false, + realtimeTalkStatus: "idle", + realtimeTalkDetail: null, + realtimeTalkTranscript: null, + connected: true, + canSend: true, + disabledReason: null, + error: null, + sessions: null, + focusMode: false, + sidebarOpen: false, + sidebarContent: null, + sidebarError: null, + splitRatio: 0.6, + canvasHostUrl: null, + embedSandboxMode: "scripts", + allowExternalEmbedUrls: false, + assistantName: "Val", + assistantAvatar: null, + userName: null, + userAvatar: null, + localMediaPreviewRoots: [], + assistantAttachmentAuthToken: null, + autoExpandToolCalls: false, + attachments: [], + onAttachmentsChange: () => undefined, + showNewMessages: false, + onScrollToBottom: () => undefined, + onRefresh: () => undefined, + onToggleFocusMode: () => undefined, + getDraft: () => "", + onDraftChange: () => undefined, + onRequestUpdate: () => undefined, + onSend: () => undefined, + onCompact: () => undefined, + onToggleRealtimeTalk: () => undefined, + onAbort: () => undefined, + onQueueRemove: () => undefined, + onQueueSteer: () => undefined, + onDismissSideResult: () => undefined, + onNewSession: () => undefined, + onClearHistory: () => undefined, + agentsList: null, + currentAgentId: "main", + onAgentChange: () => undefined, + onNavigateToAgent: () => undefined, + onSessionSelect: () => undefined, + onOpenSidebar: () => undefined, + onCloseSidebar: () => undefined, + onSplitRatioChange: () => undefined, + onChatScroll: () => undefined, + basePath: "", + ...overrides, + }), + container, + ); + return container; +} + afterEach(() => { loadSessionsMock.mockClear(); refreshVisibleToolsEffectiveForCurrentSessionMock.mockClear(); vi.unstubAllGlobals(); }); +describe("chat loading skeleton", () => { + it("shows the skeleton while the initial history load has no rendered content", () => { + const container = renderChatView({ loading: true }); + + expect(container.querySelector(".chat-loading-skeleton")).not.toBeNull(); + expect(container.querySelector(".agent-chat__welcome")).toBeNull(); + }); + + it("keeps existing messages visible without the skeleton during a background reload", () => { + const container = renderChatView({ + loading: true, + messages: [ + { + role: "assistant", + content: "Already loaded answer", + timestamp: 1, + }, + ], + }); + + expect(container.querySelector(".chat-loading-skeleton")).toBeNull(); + expect(container.textContent).toContain("Already loaded answer"); + }); + + it("keeps active stream content visible without the skeleton during a background reload", () => { + const container = renderChatView({ + loading: true, + stream: "Partial streamed answer", + streamStartedAt: 1, + }); + + expect(container.querySelector(".chat-loading-skeleton")).toBeNull(); + expect(container.textContent).toContain("Partial streamed answer"); + }); + + it("keeps the reading indicator visible without the skeleton before stream text arrives", () => { + const container = renderChatView({ + loading: true, + stream: "", + streamStartedAt: 1, + }); + + expect(container.querySelector(".chat-loading-skeleton")).toBeNull(); + expect(container.querySelector(".chat-reading-indicator")).not.toBeNull(); + }); +}); + describe("chat queue", () => { it("renders Steer only for queued messages during an active run", () => { const onQueueSteer = vi.fn(); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 8b2f69e501f..5580edf509c 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -758,6 +758,7 @@ export function renderChat(props: ChatProps) { requestUpdate(); }; const isEmpty = chatItems.length === 0 && !props.loading; + const showLoadingSkeleton = props.loading && chatItems.length === 0; const thread = html`
- ${props.loading + ${showLoadingSkeleton ? html`