fix(control-ui): surface lazy panel load failures

This commit is contained in:
Peter Steinberger
2026-04-27 13:09:02 +01:00
parent 0141471dd5
commit c6ebd99a46
5 changed files with 207 additions and 42 deletions

View File

@@ -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 `<think>` 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.

View File

@@ -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.",

View File

@@ -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<T> = { mod: T | null; promise: Promise<T> | null };
let _pendingUpdate: (() => void) | undefined;
function createLazy<T>(loader: () => Promise<T>): () => T | null {
const s: LazyState<T> = { 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<typeof setTimeout> | null = null;
function lazyRender<M>(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,

View File

@@ -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");
});
});

106
ui/src/ui/lazy-view.ts Normal file
View File

@@ -0,0 +1,106 @@
import { html } from "lit";
import { t } from "../i18n/index.ts";
type LazyState<T> = {
mod: T | null;
promise: Promise<void> | null;
error: unknown;
hasError: boolean;
};
export type LazyView<T> = {
read: () => T | null;
retry: () => void;
error: () => unknown;
hasError: () => boolean;
pending: () => boolean;
};
export function createLazyView<T>(loader: () => Promise<T>, onChange?: () => void): LazyView<T> {
const state: LazyState<T> = { 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<M>(view: LazyView<M>, render: (mod: M) => unknown) {
const mod = view.read();
if (mod !== null) {
return render(mod);
}
if (view.hasError()) {
const error = view.error();
return html`
<section class="card lazy-view-state lazy-view-state--error">
<div class="card-title">${t("lazyView.errorTitle")}</div>
<div class="card-sub">${t("lazyView.errorSubtitle")}</div>
<div class="callout danger" style="margin-top: 12px;">${formatLazyViewError(error)}</div>
<div style="display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap;">
<button class="btn primary" @click=${() => globalThis.location.reload()}>
${t("common.reload")}
</button>
<button class="btn" @click=${() => view.retry()}>${t("lazyView.retry")}</button>
</div>
</section>
`;
}
return html`
<section class="card lazy-view-state lazy-view-state--loading">
<div class="card-title">${t("lazyView.loadingTitle")}</div>
<div class="card-sub">${t("common.loading")}</div>
</section>
`;
}