From c6ebd99a461d1e387ff327105245bbf13b3d709f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 13:09:02 +0100 Subject: [PATCH] fix(control-ui): surface lazy panel load failures --- CHANGELOG.md | 1 + ui/src/i18n/locales/en.ts | 8 +++ ui/src/ui/app-render.ts | 64 ++++++----------- ui/src/ui/lazy-view.browser.test.ts | 70 ++++++++++++++++++ ui/src/ui/lazy-view.ts | 106 ++++++++++++++++++++++++++++ 5 files changed, 207 insertions(+), 42 deletions(-) create mode 100644 ui/src/ui/lazy-view.browser.test.ts create mode 100644 ui/src/ui/lazy-view.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 36aa419d861..057937dabc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/tools: scope tool-loop detection history to the active run when available, so scheduled heartbeat cycles no longer inherit stale repeated-call counts from previous runs. Fixes #40144. Thanks @mattbrown319. +- Control UI: show loading, reload, and retry states when a lazy dashboard panel cannot load after an upgrade, so the Logs tab no longer appears blank on stale browser bundles. Fixes #72450. Thanks @sobergou. - Agents/reasoning: recover fully wrapped unclosed `` replies that would otherwise sanitize to empty text while keeping strict stripping for closed reasoning blocks and unclosed tails after visible text. Fixes #37696; supersedes #51915. Thanks @druide67 and @okuyam2y. - Control UI/Gateway: bind WebChat handshakes to their active socket and reject post-close server registrations, so aborted connects no longer leave zombie clients or misleading duplicate WebSocket connection logs. Fixes #72753. Thanks @LumenFromTheFuture. - Plugins/Windows: normalize Windows absolute paths before handing bundled plugin modules to Jiti, so Feishu/Lark message sending no longer fails with unsupported `c:` ESM loader URLs. Fixes #72783. Thanks @jackychen-png. diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index c2686407661..91b6c666496 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -107,6 +107,14 @@ export const en: TranslationMap = { lightningHelp: "Lightning address for tips (LUD-16)", }, }, + lazyView: { + loadingTitle: "Loading panel", + errorTitle: "Panel failed to load", + errorSubtitle: + "Reload the page to load the latest Control UI bundle, or retry if the network request failed.", + retry: "Retry", + unknownError: "Unknown module load error.", + }, nodes: { binding: { loadConfigHint: "Load config to edit bindings.", diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 55693207d01..fce3d90c700 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -115,6 +115,7 @@ import { } from "./controllers/skills.ts"; import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts"; import { icons } from "./icons.ts"; +import { createLazyView, renderLazyView } from "./lazy-view.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; import "./components/dashboard-header.ts"; import { isPluginEnabledInConfigSnapshot } from "./plugin-activation.ts"; @@ -150,38 +151,21 @@ import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.t import { renderLoginGate } from "./views/login-gate.ts"; import { renderOverview } from "./views/overview.ts"; -// Lazy-loaded view modules – deferred so the initial bundle stays small. -// Each loader resolves once; subsequent calls return the cached module. -type LazyState = { mod: T | null; promise: Promise | null }; - let _pendingUpdate: (() => void) | undefined; -function createLazy(loader: () => Promise): () => T | null { - const s: LazyState = { mod: null, promise: null }; - return () => { - if (s.mod) { - return s.mod; - } - if (!s.promise) { - s.promise = loader().then((m) => { - s.mod = m; - _pendingUpdate?.(); - return m; - }); - } - return null; - }; -} +const notifyLazyViewChanged = () => _pendingUpdate?.(); -const lazyAgents = createLazy(() => import("./views/agents.ts")); -const lazyChannels = createLazy(() => import("./views/channels.ts")); -const lazyCron = createLazy(() => import("./views/cron.ts")); -const lazyDebug = createLazy(() => import("./views/debug.ts")); -const lazyInstances = createLazy(() => import("./views/instances.ts")); -const lazyLogs = createLazy(() => import("./views/logs.ts")); -const lazyNodes = createLazy(() => import("./views/nodes.ts")); -const lazySessions = createLazy(() => import("./views/sessions.ts")); -const lazySkills = createLazy(() => import("./views/skills.ts")); +// Lazy-loaded view modules are deferred so the initial bundle stays small. +// The shared loader renders visible fallback states instead of leaving a tab blank. +const lazyAgents = createLazyView(() => import("./views/agents.ts"), notifyLazyViewChanged); +const lazyChannels = createLazyView(() => import("./views/channels.ts"), notifyLazyViewChanged); +const lazyCron = createLazyView(() => import("./views/cron.ts"), notifyLazyViewChanged); +const lazyDebug = createLazyView(() => import("./views/debug.ts"), notifyLazyViewChanged); +const lazyInstances = createLazyView(() => import("./views/instances.ts"), notifyLazyViewChanged); +const lazyLogs = createLazyView(() => import("./views/logs.ts"), notifyLazyViewChanged); +const lazyNodes = createLazyView(() => import("./views/nodes.ts"), notifyLazyViewChanged); +const lazySessions = createLazyView(() => import("./views/sessions.ts"), notifyLazyViewChanged); +const lazySkills = createLazyView(() => import("./views/skills.ts"), notifyLazyViewChanged); function formatDreamNextCycle(nextRunAtMs: number | undefined): string | null { if (typeof nextRunAtMs !== "number" || !Number.isFinite(nextRunAtMs)) { @@ -212,10 +196,6 @@ function resolveDreamingNextCycle( } let clawhubSearchTimer: ReturnType | null = null; -function lazyRender(getter: () => M | null, render: (mod: M) => unknown) { - const mod = getter(); - return mod ? render(mod) : nothing; -} const UPDATE_BANNER_DISMISS_KEY = "openclaw:control-ui:update-banner-dismissed:v1"; const CRON_THINKING_SUGGESTIONS = ["off", "minimal", "low", "medium", "high"]; @@ -1613,7 +1593,7 @@ export function renderApp(state: AppViewState) { }) : nothing} ${state.tab === "channels" - ? lazyRender(lazyChannels, (m) => + ? renderLazyView(lazyChannels, (m) => m.renderChannels({ connected: state.connected, loading: state.channelsLoading, @@ -1651,7 +1631,7 @@ export function renderApp(state: AppViewState) { ) : nothing} ${state.tab === "instances" - ? lazyRender(lazyInstances, (m) => + ? renderLazyView(lazyInstances, (m) => m.renderInstances({ loading: state.presenceLoading, entries: state.presenceEntries, @@ -1662,7 +1642,7 @@ export function renderApp(state: AppViewState) { ) : nothing} ${state.tab === "sessions" - ? lazyRender(lazySessions, (m) => + ? renderLazyView(lazySessions, (m) => m.renderSessions({ loading: state.sessionsLoading, result: state.sessionsResult, @@ -1770,7 +1750,7 @@ export function renderApp(state: AppViewState) { ${renderUsageTab(state)} ${state.tab === "cron" ? renderCronQuickCreateForTab(state, requestHostUpdate) : nothing} ${state.tab === "cron" - ? lazyRender(lazyCron, (m) => + ? renderLazyView(lazyCron, (m) => m.renderCron({ basePath: state.basePath, loading: state.cronLoading, @@ -1875,7 +1855,7 @@ export function renderApp(state: AppViewState) { ) : nothing} ${state.tab === "agents" - ? lazyRender(lazyAgents, (m) => + ? renderLazyView(lazyAgents, (m) => m.renderAgents({ basePath: state.basePath ?? "", loading: state.agentsLoading, @@ -2190,7 +2170,7 @@ export function renderApp(state: AppViewState) { ) : nothing} ${state.tab === "skills" - ? lazyRender(lazySkills, (m) => + ? renderLazyView(lazySkills, (m) => m.renderSkills({ connected: state.connected, loading: state.skillsLoading, @@ -2236,7 +2216,7 @@ export function renderApp(state: AppViewState) { ) : nothing} ${state.tab === "nodes" - ? lazyRender(lazyNodes, (m) => + ? renderLazyView(lazyNodes, (m) => m.renderNodes({ loading: state.nodesLoading, nodes: state.nodes, @@ -2429,7 +2409,7 @@ export function renderApp(state: AppViewState) { : nothing} ${renderConfigTabForActiveTab()} ${state.tab === "debug" - ? lazyRender(lazyDebug, (m) => + ? renderLazyView(lazyDebug, (m) => m.renderDebug({ loading: state.debugLoading, status: state.debugStatus, @@ -2450,7 +2430,7 @@ export function renderApp(state: AppViewState) { ) : nothing} ${state.tab === "logs" - ? lazyRender(lazyLogs, (m) => + ? renderLazyView(lazyLogs, (m) => m.renderLogs({ loading: state.logsLoading, error: state.logsError, diff --git a/ui/src/ui/lazy-view.browser.test.ts b/ui/src/ui/lazy-view.browser.test.ts new file mode 100644 index 00000000000..f33bf7fb898 --- /dev/null +++ b/ui/src/ui/lazy-view.browser.test.ts @@ -0,0 +1,70 @@ +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { createLazyView, renderLazyView } from "./lazy-view.ts"; + +async function flushPromises() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("lazy view rendering", () => { + it("renders a loading panel until the view module resolves", async () => { + const onChange = vi.fn(); + const view = createLazyView(async () => ({ label: "Logs view" }), onChange); + const container = document.createElement("div"); + + render( + renderLazyView(view, (mod) => mod.label), + container, + ); + + expect(container.textContent).toContain("Loading panel"); + + await flushPromises(); + render( + renderLazyView(view, (mod) => mod.label), + container, + ); + + expect(onChange).toHaveBeenCalled(); + expect(container.textContent).toContain("Logs view"); + }); + + it("renders a recoverable error panel when a lazy module import fails", async () => { + const onChange = vi.fn(); + const loader = vi + .fn<() => Promise<{ label: string }>>() + .mockRejectedValueOnce(new Error("chunk 404")) + .mockResolvedValueOnce({ label: "Recovered" }); + const view = createLazyView(loader, onChange); + const container = document.createElement("div"); + + render( + renderLazyView(view, (mod) => mod.label), + container, + ); + await flushPromises(); + render( + renderLazyView(view, (mod) => mod.label), + container, + ); + + expect(container.textContent).toContain("Panel failed to load"); + expect(container.textContent).toContain("chunk 404"); + + const retry = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Retry", + ); + expect(retry).not.toBeUndefined(); + retry?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + await flushPromises(); + render( + renderLazyView(view, (mod) => mod.label), + container, + ); + + expect(loader).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenCalled(); + expect(container.textContent).toContain("Recovered"); + }); +}); diff --git a/ui/src/ui/lazy-view.ts b/ui/src/ui/lazy-view.ts new file mode 100644 index 00000000000..0e07bf6edf6 --- /dev/null +++ b/ui/src/ui/lazy-view.ts @@ -0,0 +1,106 @@ +import { html } from "lit"; +import { t } from "../i18n/index.ts"; + +type LazyState = { + mod: T | null; + promise: Promise | null; + error: unknown; + hasError: boolean; +}; + +export type LazyView = { + read: () => T | null; + retry: () => void; + error: () => unknown; + hasError: () => boolean; + pending: () => boolean; +}; + +export function createLazyView(loader: () => Promise, onChange?: () => void): LazyView { + const state: LazyState = { mod: null, promise: null, error: undefined, hasError: false }; + + const load = () => { + state.promise = loader() + .then( + (mod) => { + state.mod = mod; + state.error = undefined; + state.hasError = false; + }, + (error: unknown) => { + state.error = error; + state.hasError = true; + state.promise = null; + }, + ) + .finally(() => { + onChange?.(); + }); + }; + + return { + read: () => { + if (state.mod !== null) { + return state.mod; + } + if (!state.promise && !state.hasError) { + load(); + } + return null; + }, + retry: () => { + if (state.mod !== null) { + return; + } + state.error = undefined; + state.hasError = false; + state.promise = null; + load(); + onChange?.(); + }, + error: () => state.error, + hasError: () => state.hasError, + pending: () => state.promise !== null, + }; +} + +function formatLazyViewError(error: unknown): string { + if (error instanceof Error && error.message.trim()) { + return error.message; + } + if (typeof error === "string" && error.trim()) { + return error.trim(); + } + return t("lazyView.unknownError"); +} + +export function renderLazyView(view: LazyView, render: (mod: M) => unknown) { + const mod = view.read(); + if (mod !== null) { + return render(mod); + } + + if (view.hasError()) { + const error = view.error(); + return html` +
+
${t("lazyView.errorTitle")}
+
${t("lazyView.errorSubtitle")}
+
${formatLazyViewError(error)}
+
+ + +
+
+ `; + } + + return html` +
+
${t("lazyView.loadingTitle")}
+
${t("common.loading")}
+
+ `; +}