fix(control-ui): add static mount fallback

This commit is contained in:
Val Alexander
2026-05-11 07:01:02 -05:00
parent 8f1e6ab13c
commit 6f12cb27d9
4 changed files with 308 additions and 0 deletions

View File

@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
- CI: add a non-blocking `plugin-inspector-advisory` artifact to Plugin Prerelease so release runs capture bundled plugin compatibility triage without changing the blocking gate.
- Providers/fal: route GPT Image 2 and Nano Banana 2 reference-image edit requests to `/edit` with `image_urls` array, enforce NB2 edit geometry using `aspect_ratio` and `resolution` params, lift Fal edit mode input-image caps to 10 for GPT Image 2 and 14 for Nano Banana 2, and allow aspect-ratio hints in edit mode. (#77295) Thanks @leoge007.
- Control UI: show a plain HTML recovery panel when the app module never registers, giving blank dashboard pages a retry path and browser-extension troubleshooting link. Fixes #44107. Thanks @BunsDev.
- Build: enable additional low-churn oxlint rules for promise, TypeScript, and runtime footgun checks.
- Build: enable stricter Vitest lint rules for focused, disabled, conditional, hook, matcher, and expectation hazards.

View File

@@ -425,6 +425,16 @@ pnpm ui:dev
Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`).
## Blank Control UI page
If the browser loads a blank dashboard and DevTools shows no useful error, an extension or early content script may have prevented the JavaScript module app from evaluating. The static page includes a plain HTML recovery panel that appears when `<openclaw-app>` is not registered after startup.
Use the panel's **Try again** action after changing the browser environment, or reload manually after these checks:
- Disable extensions that inject into all pages, especially extensions with `<all_urls>` content scripts.
- Try a private window, a clean browser profile, or another browser.
- Keep the Gateway running and verify the same dashboard URL after the browser change.
## Debugging/testing: dev server + remote Gateway
The Control UI is static files; the WebSocket target is configurable and can be different from the HTTP origin. This is handy when you want the Vite dev server locally but the Gateway runs elsewhere.

View File

@@ -62,9 +62,244 @@
} catch (e) {}
})();
</script>
<style>
body.openclaw-mount-fallback-active {
margin: 0;
min-width: 320px;
color: #eef4f8;
background: #101418;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
body.openclaw-mount-fallback-active openclaw-app {
display: none;
}
.mount-fallback {
box-sizing: border-box;
min-height: 100vh;
padding: 24px;
place-items: center;
}
.mount-fallback:not([hidden]) {
display: grid;
}
.mount-fallback__panel {
box-sizing: border-box;
width: min(100%, 640px);
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
background: #151b21;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.36);
padding: 28px;
}
.mount-fallback__eyebrow {
margin: 0 0 10px;
color: #9fb0bd;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.mount-fallback__panel h1 {
margin: 0;
color: inherit;
font-size: clamp(1.5rem, 3vw, 2rem);
line-height: 1.15;
}
.mount-fallback__panel p {
margin: 16px 0 0;
color: #c8d3da;
font-size: 1rem;
line-height: 1.6;
}
.mount-fallback__panel ul {
margin: 18px 0 0;
padding-left: 1.2rem;
color: #c8d3da;
line-height: 1.6;
}
.mount-fallback__panel a {
color: #8bd3ff;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.18em;
}
.mount-fallback__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 24px;
}
.mount-fallback__button {
min-height: 42px;
border: 1px solid rgba(148, 163, 184, 0.36);
border-radius: 6px;
padding: 0 16px;
color: inherit;
background: transparent;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.mount-fallback__button--primary {
border-color: #66c2ff;
color: #061019;
background: #8bd3ff;
}
.mount-fallback__button:focus-visible,
.mount-fallback__panel a:focus-visible {
outline: 3px solid #f9c74f;
outline-offset: 3px;
}
html[data-theme-mode="light"] body.openclaw-mount-fallback-active {
color: #151b21;
background: #f5f7fa;
}
html[data-theme-mode="light"] .mount-fallback__panel {
border-color: rgba(71, 85, 105, 0.22);
background: #ffffff;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.14);
}
html[data-theme-mode="light"] .mount-fallback__eyebrow,
html[data-theme-mode="light"] .mount-fallback__panel p,
html[data-theme-mode="light"] .mount-fallback__panel ul {
color: #4b5963;
}
html[data-theme-mode="light"] .mount-fallback__panel a {
color: #0369a1;
}
</style>
</head>
<body>
<openclaw-app></openclaw-app>
<section
id="openclaw-mount-fallback"
class="mount-fallback"
data-openclaw-mount-timeout-ms="12000"
role="alert"
aria-labelledby="openclaw-mount-fallback-title"
hidden
>
<div class="mount-fallback__panel">
<p class="mount-fallback__eyebrow">OpenClaw Control UI</p>
<h1 id="openclaw-mount-fallback-title">Control UI did not start</h1>
<p>
The browser loaded the static page, but the app bundle did not register the
<code>openclaw-app</code> web component. A browser extension or early content script may
be blocking module execution.
</p>
<ul>
<li>Try again in a clean browser profile or private window.</li>
<li>Disable extensions that run on all pages, then reload this dashboard.</li>
<li>
See
<a
href="https://docs.openclaw.ai/web/control-ui#blank-control-ui-page"
target="_blank"
rel="noreferrer"
>Control UI troubleshooting</a
>.
</li>
</ul>
<div class="mount-fallback__actions">
<button
type="button"
id="openclaw-mount-retry"
class="mount-fallback__button mount-fallback__button--primary"
>
Try again
</button>
<button type="button" id="openclaw-mount-dismiss" class="mount-fallback__button">
Hide message
</button>
</div>
</div>
</section>
<script>
(function () {
var tagName = "openclaw-app";
var app = document.querySelector(tagName);
var fallback = document.getElementById("openclaw-mount-fallback");
if (!app || !fallback) return;
var dismissed = false;
var retry = document.getElementById("openclaw-mount-retry");
var dismiss = document.getElementById("openclaw-mount-dismiss");
var rawDelay = Number(fallback.getAttribute("data-openclaw-mount-timeout-ms"));
var delay = Number.isFinite(rawDelay) && rawDelay > 0 ? rawDelay : 12000;
function appMounted() {
try {
return Boolean(
app.isConnected &&
window.customElements &&
typeof window.customElements.get === "function" &&
window.customElements.get(tagName),
);
} catch (e) {
return false;
}
}
function hideFallback() {
fallback.hidden = true;
document.body.classList.remove("openclaw-mount-fallback-active");
}
function showFallback() {
if (dismissed || appMounted()) return;
fallback.hidden = false;
document.body.classList.add("openclaw-mount-fallback-active");
}
var timer = window.setTimeout(showFallback, delay);
if (window.customElements && typeof window.customElements.whenDefined === "function") {
window.customElements.whenDefined(tagName).then(
function () {
window.clearTimeout(timer);
hideFallback();
},
function () {},
);
}
if (retry) {
retry.addEventListener("click", function () {
window.location.reload();
});
}
if (dismiss) {
dismiss.addEventListener("click", function () {
dismissed = true;
window.clearTimeout(timer);
hideFallback();
});
}
})();
</script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,62 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const indexHtmlPath = path.resolve(process.cwd(), "ui/index.html");
async function readIndexHtmlWithDelay(delayMs: number): Promise<string> {
const html = await readFile(indexHtmlPath, "utf8");
return html.replace(
'data-openclaw-mount-timeout-ms="12000"',
`data-openclaw-mount-timeout-ms="${delayMs}"`,
);
}
function waitForWindowTimeout(window: Window, delayMs: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, delayMs);
});
}
function installFallbackShell(html: string): void {
const parsed = new DOMParser().parseFromString(html, "text/html");
document.head.innerHTML = parsed.head.innerHTML;
document.body.innerHTML = parsed.body.innerHTML;
const sentinel = Array.from(parsed.querySelectorAll("script:not([src])")).find((script) =>
script.textContent?.includes("openclaw-mount-fallback"),
);
expect(sentinel).toBeTruthy();
window.eval(sentinel?.textContent ?? "");
}
describe("Control UI mount fallback", () => {
afterEach(() => {
document.head.innerHTML = "";
document.body.innerHTML = "";
});
it("shows the static troubleshooting panel when the app element is never registered", async () => {
installFallbackShell(await readIndexHtmlWithDelay(1));
await waitForWindowTimeout(window, 10);
const fallback = document.getElementById("openclaw-mount-fallback");
expect(fallback?.hidden).toBe(false);
expect(document.body.classList.contains("openclaw-mount-fallback-active")).toBe(true);
expect(fallback?.textContent).toContain("Control UI did not start");
expect(fallback?.textContent).toContain("Control UI troubleshooting");
});
it("keeps the fallback hidden when the app element registers before the timeout", async () => {
installFallbackShell(await readIndexHtmlWithDelay(25));
if (!window.customElements.get("openclaw-app")) {
window.customElements.define("openclaw-app", class extends HTMLElement {});
}
await window.customElements.whenDefined("openclaw-app");
await waitForWindowTimeout(window, 35);
const fallback = document.getElementById("openclaw-mount-fallback");
expect(fallback?.hidden).toBe(true);
expect(document.body.classList.contains("openclaw-mount-fallback-active")).toBe(false);
});
});