mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:00:43 +00:00
fix(control-ui): surface lazy panel load failures
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
70
ui/src/ui/lazy-view.browser.test.ts
Normal file
70
ui/src/ui/lazy-view.browser.test.ts
Normal 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
106
ui/src/ui/lazy-view.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user