diff --git a/ui/index.html b/ui/index.html
index ae53ce5df699..e349debda8d3 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -209,14 +209,13 @@
OpenClaw Control UI
Control UI did not start
-
- The browser loaded the static page, but the app bundle did not register the
- openclaw-app web component. A browser extension or early content script may
- be blocking module execution.
+
+ The browser loaded the static page, but the app bundle did not start. The gateway may be
+ restarting, or this page may reference assets from a different OpenClaw version.
- - Try again in a clean browser profile or private window.
- - Disable extensions that run on all pages, then reload this dashboard.
+ - OpenClaw will retry the current app bundle automatically.
+ - If this persists, reload or try a clean browser profile.
-
See
0 ? rawDelay : 12000;
+ var maxRecoveryAttempts = 6;
var timer;
+ var recoveryTimer;
+ var recoveryAttempt = 0;
+ var recoveryInFlight = false;
+ var recoveryNavigation = false;
+
+ try {
+ var initialUrl = new URL(window.location.href);
+ recoveryNavigation = initialUrl.searchParams.has("openclaw_mount_recovery");
+ if (recoveryNavigation) {
+ initialUrl.searchParams.delete("openclaw_mount_recovery");
+ window.history.replaceState(null, "", initialUrl.href);
+ }
+ } catch (e) {}
function appMounted() {
try {
@@ -273,8 +287,64 @@
document.body.classList.remove("openclaw-mount-fallback-active");
}
+ function setSummary(text) {
+ if (summary) summary.textContent = text;
+ }
+
+ function scheduleRecovery() {
+ window.clearTimeout(recoveryTimer);
+ if (appMounted() || recoveryAttempt >= maxRecoveryAttempts) return;
+ var retryDelay = Math.min(delay, 1000 * Math.pow(2, recoveryAttempt));
+ recoveryTimer = window.setTimeout(retryCurrentDocument, retryDelay);
+ }
+
+ function finishRecoveryAttempt() {
+ recoveryInFlight = false;
+ if (appMounted()) return;
+ if (recoveryAttempt >= maxRecoveryAttempts) {
+ setSummary(
+ "The gateway is still unavailable. Try again, then check the troubleshooting guide if the problem persists.",
+ );
+ return;
+ }
+ setSummary(
+ "The gateway is not reachable yet. OpenClaw will keep retrying while it restarts.",
+ );
+ scheduleRecovery();
+ }
+
+ function retryCurrentDocument() {
+ if (
+ appMounted() ||
+ recoveryInFlight ||
+ recoveryAttempt >= maxRecoveryAttempts ||
+ typeof window.fetch !== "function"
+ )
+ return;
+ var documentUrl = new URL(window.location.href);
+ if (recoveryNavigation) {
+ setSummary(
+ "A fresh page still could not start the Control UI. Try again, then check the troubleshooting guide if the problem persists.",
+ );
+ return;
+ }
+ recoveryInFlight = true;
+ recoveryAttempt += 1;
+ documentUrl.searchParams.set("openclaw_mount_recovery", String(Date.now()));
+ window
+ .fetch(documentUrl.href, { cache: "no-store", credentials: "same-origin" })
+ .then(function (response) {
+ if (!response.ok) throw new Error("gateway unavailable");
+ window.location.replace(documentUrl.href);
+ })
+ .catch(function () {
+ finishRecoveryAttempt();
+ });
+ }
+
function showFallback() {
if (appMounted()) return;
+ retryCurrentDocument();
fallback.hidden = false;
document.body.classList.add("openclaw-mount-fallback-active");
if (panel && typeof panel.focus === "function") {
@@ -297,6 +367,7 @@
window.customElements.whenDefined(tagName).then(
function () {
window.clearTimeout(timer);
+ window.clearTimeout(recoveryTimer);
hideFallback();
},
function () {},
@@ -311,6 +382,8 @@
if (wait) {
wait.addEventListener("click", function () {
hideFallback();
+ recoveryAttempt = 0;
+ retryCurrentDocument();
armFallbackTimer();
});
}
diff --git a/ui/src/app/mount-fallback.test.ts b/ui/src/app/mount-fallback.test.ts
index 1e82b1fc204f..8c8ad13c6268 100644
--- a/ui/src/app/mount-fallback.test.ts
+++ b/ui/src/app/mount-fallback.test.ts
@@ -1,7 +1,7 @@
// Control UI tests cover mount fallback behavior.
import { readFile } from "node:fs/promises";
import path from "node:path";
-import { afterEach, describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
const indexHtmlPath = path.resolve(
process.cwd(),
@@ -115,4 +115,38 @@ describe("Control UI mount fallback", () => {
expect(fallback.hidden).toBe(true);
expect([...frameWindow.document.body.classList]).toEqual([]);
});
+
+ it("probes a cache-busted current document when the original bundle did not start", async () => {
+ const frameWindow = createIsolatedWindow();
+ const html = await readIndexHtmlWithDelay(1);
+ const fetch = vi.fn().mockResolvedValue({ ok: false });
+ Object.defineProperty(frameWindow, "fetch", { configurable: true, value: fetch });
+ installFallbackShell(frameWindow, html);
+
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
+
+ expect(fetch).toHaveBeenNthCalledWith(1, expect.stringContaining("openclaw_mount_recovery="), {
+ cache: "no-store",
+ credentials: "same-origin",
+ });
+ });
+
+ it("bounds automatic recovery attempts while the gateway is unavailable", async () => {
+ const frameWindow = createIsolatedWindow();
+ const fetch = vi.fn().mockRejectedValue(new Error("gateway unavailable"));
+ Object.defineProperty(frameWindow, "fetch", { configurable: true, value: fetch });
+ installFallbackShell(frameWindow, await readIndexHtmlWithDelay(1));
+
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(6));
+ await waitForWindowTimeout(frameWindow, 10);
+
+ expect(fetch).toHaveBeenCalledTimes(6);
+ expect(
+ requireElementById(
+ frameWindow,
+ "openclaw-mount-fallback-summary",
+ frameWindow.HTMLParagraphElement,
+ ).textContent,
+ ).toContain("gateway is still unavailable");
+ });
});
diff --git a/ui/src/e2e/mount-recovery.e2e.test.ts b/ui/src/e2e/mount-recovery.e2e.test.ts
new file mode 100644
index 000000000000..c82f98bb2aee
--- /dev/null
+++ b/ui/src/e2e/mount-recovery.e2e.test.ts
@@ -0,0 +1,83 @@
+import path from "node:path";
+import { chromium, type Browser } from "playwright";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import {
+ canRunPlaywrightChromium,
+ installMockGateway,
+ resolvePlaywrightChromiumExecutablePath,
+ startControlUiE2eServer,
+ type ControlUiE2eServer,
+} from "../test-helpers/control-ui-e2e.ts";
+
+const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
+const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
+const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
+const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
+
+let browser: Browser;
+let server: ControlUiE2eServer;
+
+describeControlUiE2e("Control UI mount recovery E2E", () => {
+ beforeAll(async () => {
+ if (!chromiumAvailable) {
+ throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`);
+ }
+ server = await startControlUiE2eServer();
+ browser = await chromium.launch({ executablePath: chromiumExecutablePath });
+ });
+
+ afterAll(async () => {
+ await browser?.close();
+ await server?.close();
+ });
+
+ it("reloads a fresh document after the initial app module is unavailable", async () => {
+ const artifactDir = path.resolve(".artifacts/control-ui-e2e/mount-recovery");
+ const context = await browser.newContext({
+ locale: "en-US",
+ recordVideo: { dir: artifactDir, size: { height: 720, width: 1280 } },
+ serviceWorkers: "block",
+ viewport: { height: 720, width: 1280 },
+ });
+ const page = await context.newPage();
+ const baseUrl = new URL(server.baseUrl);
+ let documentRequests = 0;
+ let failedModuleRequests = 0;
+ await page.route(`${baseUrl.origin}/**`, async (route) => {
+ const request = route.request();
+ const url = new URL(request.url());
+ if (request.resourceType() === "document") {
+ documentRequests += 1;
+ const response = await route.fetch();
+ const body = (await response.text()).replace(
+ 'data-openclaw-mount-timeout-ms="12000"',
+ 'data-openclaw-mount-timeout-ms="50"',
+ );
+ await route.fulfill({ response, body });
+ return;
+ }
+ if (url.pathname === "/src/main.ts" && failedModuleRequests === 0) {
+ failedModuleRequests += 1;
+ await route.fulfill({ body: "gateway restarting", status: 503 });
+ return;
+ }
+ await route.continue();
+ });
+ await installMockGateway(page);
+
+ try {
+ expect(
+ (await page.goto(`${server.baseUrl}chat`, { waitUntil: "domcontentloaded" }))?.status(),
+ ).toBe(200);
+ await page.locator("openclaw-app-shell").waitFor();
+ await page.locator(".agent-chat__welcome").waitFor();
+
+ expect(documentRequests).toBe(2);
+ expect(failedModuleRequests).toBe(1);
+ await expect.poll(() => page.url()).not.toContain("openclaw_mount_recovery");
+ await page.screenshot({ path: path.join(artifactDir, "recovered-control-ui.png") });
+ } finally {
+ await context.close();
+ }
+ });
+});