diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts
index c6da3dd02676..7e20b2915a51 100644
--- a/ui/src/app/app-host.ts
+++ b/ui/src/app/app-host.ts
@@ -14,6 +14,7 @@ import "../components/github-link-hovercard-registration.ts";
import "../components/login-gate.ts";
import "../components/macos-titlebar-controls.ts";
import "../components/onboarding-memory-import.ts";
+import "../components/openclaw-mascot.ts";
import "../components/resizable-divider.ts";
import "../components/sidebar-update-card.ts";
import "../components/tooltip.ts";
@@ -215,16 +216,10 @@ function resolveTerminalThemeMode(): "dark" | "light" {
return document.documentElement.dataset.themeMode === "light" ? "light" : "dark";
}
-// The mascot SVG animates via SMIL, so it must load through
—
-// inlining the markup would freeze it (see ui/public/favicon.svg).
-function renderConnectingSplash(basePath: string) {
+function renderConnectingSplash() {
return html`
-
+
`;
}
@@ -416,7 +411,7 @@ class OpenClawApp extends OpenClawLightDomElement {
fullscreen
>
${!isOptionalElementDefined(TERMINAL_PANEL_ELEMENT) && terminalAvailable
- ? renderConnectingSplash(context.basePath)
+ ? renderConnectingSplash()
: nothing}
${!terminalAvailable && (gatewayConnected || gatewaySnapshot.lastError)
? html`
${t("terminal.unavailable")}
`
@@ -439,7 +434,7 @@ class OpenClawApp extends OpenClawLightDomElement {
if (initialConnectPending) {
return html`
- ${renderConnectingSplash(context.basePath)} ${gatewayUrlConfirmation}
+ ${renderConnectingSplash()} ${gatewayUrlConfirmation}
`;
}
diff --git a/ui/src/app/router-outlet.test.ts b/ui/src/app/router-outlet.test.ts
index 4b50865697e6..5e3ce9d83b1f 100644
--- a/ui/src/app/router-outlet.test.ts
+++ b/ui/src/app/router-outlet.test.ts
@@ -53,6 +53,48 @@ async function settleOutlet(outlet: RouterOutletElement): Promise {
}
describe("openclaw-router-outlet", () => {
+ it("replaces the centered loading mascot with the resolved route", async () => {
+ vi.useFakeTimers();
+ const routeModule = deferred();
+ const context = { label: "loaded" };
+ const router = createRouter({
+ routes: [
+ definePage({
+ id: "page",
+ path: "/page",
+ component: () => routeModule.promise,
+ loader: (loadContext) => ({ label: loadContext.label }),
+ }),
+ ],
+ });
+ const outlet = createOutlet(router, context);
+ const navigation = router.navigate("page", context);
+
+ await settleOutlet(outlet);
+ expect(outlet.querySelector('[role="status"]')).toBeNull();
+
+ await vi.advanceTimersByTimeAsync(1_000);
+ await settleOutlet(outlet);
+
+ const loadingState = outlet.querySelector('[role="status"]');
+ expect(loadingState?.getAttribute("aria-label")).toBe("Loading…");
+ expect(loadingState?.querySelector("openclaw-mascot")?.getAttribute("mood")).toBe("thinking");
+ expect(loadingState?.textContent?.trim()).toBe("");
+ expect(outlet.textContent).not.toContain("Loading panel");
+
+ routeModule.resolve({
+ render: (data) => html`${data?.label}
`,
+ });
+ await navigation;
+ await settleOutlet(outlet);
+
+ expect(outlet.querySelector('[data-testid="route-page"]')?.textContent).toBe("loaded");
+ expect(outlet.querySelector('[role="status"]')).toBeNull();
+ expect(outlet.querySelector("openclaw-mascot")).toBeNull();
+ outlet.remove();
+ router.stop();
+ });
+
it("keeps the current route mounted until nested MCP Apps finish teardown", async () => {
const teardown = deferred();
const teardownView = vi.fn(() => teardown.promise);
diff --git a/ui/src/app/router-outlet.ts b/ui/src/app/router-outlet.ts
index 257edf7cc7e1..522c2128493a 100644
--- a/ui/src/app/router-outlet.ts
+++ b/ui/src/app/router-outlet.ts
@@ -3,6 +3,7 @@ import { html, nothing } from "lit";
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { property } from "lit/decorators.js";
import { icon } from "../components/icons.ts";
+import { renderLoadingState } from "../components/loading-state.ts";
import { McpAppUnmountGate } from "../components/mcp-app-unmount.ts";
import { t } from "../i18n/index.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
@@ -46,15 +47,6 @@ function measureRoutedRender(routeId: string, render: () => T): T {
return result;
}
-function renderPending() {
- return html`
-
- ${t("lazyView.loadingTitle")}
- ${t("common.loading")}
-
- `;
-}
-
/**
* Shows progress while waiting for the restarting gateway. The state lives on
* the element rather than in render state because the reload replaces the
@@ -171,7 +163,7 @@ function renderRouterOutlet
+
+
+ `;
+}
diff --git a/ui/src/e2e/initial-connect-splash.e2e.test.ts b/ui/src/e2e/initial-connect-splash.e2e.test.ts
index 4c58d9f31ba9..322922d07eb8 100644
--- a/ui/src/e2e/initial-connect-splash.e2e.test.ts
+++ b/ui/src/e2e/initial-connect-splash.e2e.test.ts
@@ -1,5 +1,7 @@
// Control UI tests cover the initial-connect splash shown instead of the
// login gate while a first connect backed by stored credentials is in flight.
+import { mkdir } from "node:fs/promises";
+import path from "node:path";
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js";
@@ -15,15 +17,32 @@ const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
+const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
+const viewport = { height: 900, width: 1280 };
let browser: Browser;
let server: ControlUiE2eServer;
const openContexts = new Set();
async function createPage(): Promise {
- const context = await browser.newContext({ viewport: { height: 900, width: 1280 } });
+ if (artifactDir) {
+ await mkdir(artifactDir, { recursive: true });
+ }
+ const context = await browser.newContext({
+ viewport,
+ ...(artifactDir ? { recordVideo: { dir: artifactDir, size: viewport } } : {}),
+ });
openContexts.add(context);
- return await context.newPage();
+ const page = await context.newPage();
+ page.setDefaultTimeout(10_000);
+ return page;
+}
+
+async function captureProof(page: Page, name: string): Promise {
+ if (!artifactDir) {
+ return;
+ }
+ await page.screenshot({ fullPage: true, path: path.join(artifactDir, `${name}.png`) });
}
describeControlUiE2e("Control UI initial connect splash E2E", () => {
@@ -54,12 +73,90 @@ describeControlUiE2e("Control UI initial connect splash E2E", () => {
await page.goto(`${server.baseUrl}#token=e2e-shared-token`);
await gateway.waitForRequest("connect");
- await page.locator(".connect-splash").waitFor();
+ const splash = page.locator(".connect-splash");
+ await splash.waitFor();
+ const mascot = splash.locator('openclaw-mascot[mood="thinking"]');
+ await mascot.waitFor();
+ const mascotBounds = await mascot.boundingBox();
+ expect(mascotBounds).not.toBeNull();
+ expect(
+ Math.abs((mascotBounds?.x ?? 0) + (mascotBounds?.width ?? 0) / 2 - viewport.width / 2),
+ ).toBeLessThanOrEqual(1);
+ expect(
+ Math.abs((mascotBounds?.y ?? 0) + (mascotBounds?.height ?? 0) / 2 - viewport.height / 2),
+ ).toBeLessThanOrEqual(1);
+ expect(await page.getByText("Loading panel", { exact: true }).count()).toBe(0);
+ expect(await page.locator("openclaw-app-sidebar").count()).toBe(0);
expect(await page.locator("openclaw-login-gate").count()).toBe(0);
+ await captureProof(page, "01-centered-connecting-mascot");
await gateway.resolveDeferred("connect");
await page.locator("openclaw-app-shell").waitFor();
expect(await page.locator(".connect-splash").count()).toBe(0);
+ await captureProof(page, "02-connected-content");
+ });
+
+ it("centers the animated mascot until the chat route finishes loading", async () => {
+ const page = await createPage();
+ let chatModuleRequested = false;
+ let releaseChatModule!: () => void;
+ const chatModuleReady = new Promise((resolve) => {
+ releaseChatModule = resolve;
+ });
+ await page.route(`${new URL(server.baseUrl).origin}/**`, async (route) => {
+ if (new URL(route.request().url()).pathname.endsWith("/chat-page.ts")) {
+ chatModuleRequested = true;
+ await chatModuleReady;
+ }
+ await route.continue();
+ });
+ await installMockGateway(page);
+
+ try {
+ await page.goto(`${server.baseUrl}chat?session=main`, {
+ waitUntil: "domcontentloaded",
+ });
+ await page.locator("openclaw-app-shell").waitFor();
+ await expect.poll(() => chatModuleRequested).toBe(true);
+
+ const loadingState = page.locator(".lazy-view-state--loading");
+ await loadingState.waitFor();
+ expect(await loadingState.getAttribute("role")).toBe("status");
+ expect(await loadingState.getAttribute("aria-label")).toBe("Loading…");
+ expect((await loadingState.textContent())?.trim()).toBe("");
+ expect(await page.getByText("Loading panel", { exact: true }).count()).toBe(0);
+
+ const mascot = loadingState.locator('openclaw-mascot[mood="thinking"]');
+ await mascot.waitFor();
+ const [loadingBounds, mascotBounds] = await Promise.all([
+ loadingState.boundingBox(),
+ mascot.boundingBox(),
+ ]);
+ expect(loadingBounds).not.toBeNull();
+ expect(mascotBounds).not.toBeNull();
+ expect(
+ Math.abs(
+ (mascotBounds?.x ?? 0) +
+ (mascotBounds?.width ?? 0) / 2 -
+ ((loadingBounds?.x ?? 0) + (loadingBounds?.width ?? 0) / 2),
+ ),
+ ).toBeLessThanOrEqual(1);
+ expect(
+ Math.abs(
+ (mascotBounds?.y ?? 0) +
+ (mascotBounds?.height ?? 0) / 2 -
+ ((loadingBounds?.y ?? 0) + (loadingBounds?.height ?? 0) / 2),
+ ),
+ ).toBeLessThanOrEqual(1);
+ await captureProof(page, "03-centered-pending-chat-mascot");
+
+ releaseChatModule();
+ await page.locator("openclaw-chat-page").waitFor();
+ expect(await loadingState.count()).toBe(0);
+ await captureProof(page, "04-loaded-chat-content");
+ } finally {
+ releaseChatModule();
+ }
});
it("keeps the login gate for first connects without stored credentials", async () => {
diff --git a/ui/src/pages/workboard/view.test.ts b/ui/src/pages/workboard/view.test.ts
index 350274af0190..4fdc2f2995b5 100644
--- a/ui/src/pages/workboard/view.test.ts
+++ b/ui/src/pages/workboard/view.test.ts
@@ -2779,7 +2779,7 @@ describe("renderWorkboard", () => {
expect(container.querySelector(".workboard-column")).toBeNull();
});
- it("keeps the panel in a neutral loading state while config enablement is unknown", () => {
+ it("shows the animated mascot while config enablement is unknown", () => {
const container = document.createElement("div");
render(
@@ -2795,7 +2795,11 @@ describe("renderWorkboard", () => {
container,
);
- expect(container.textContent).toContain("Loading panel");
+ const loadingState = container.querySelector('[role="status"]');
+ expect(loadingState?.getAttribute("aria-label")).toBe("Loading…");
+ expect(loadingState?.querySelector("openclaw-mascot")?.getAttribute("mood")).toBe("thinking");
+ expect(loadingState?.textContent?.trim()).toBe("");
+ expect(container.textContent).not.toContain("Loading panel");
expect(container.textContent).not.toContain("Workboard is disabled");
expect(container.querySelector(".workboard-column")).toBeNull();
});
diff --git a/ui/src/pages/workboard/view.ts b/ui/src/pages/workboard/view.ts
index 7ec06aa9f4f3..aa463a871700 100644
--- a/ui/src/pages/workboard/view.ts
+++ b/ui/src/pages/workboard/view.ts
@@ -1,6 +1,7 @@
import { html, nothing } from "lit";
import "../../components/agent-select-registration.ts";
import { icons } from "../../components/icons.ts";
+import { renderLoadingState } from "../../components/loading-state.ts";
import "../../components/modal-dialog.ts";
import "../../components/tooltip.ts";
import { t } from "../../i18n/index.ts";
@@ -152,12 +153,7 @@ export function renderWorkboard(props: WorkboardProps) {
`;
}
- return html`
-
- ${t("lazyView.loadingTitle")}
- ${t("common.loading")}
-
- `;
+ return renderLoadingState();
}
if (!props.pluginEnabled) {
diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css
index ca2234d2e6ab..9a3ab4e92e9c 100644
--- a/ui/src/styles/components.css
+++ b/ui/src/styles/components.css
@@ -1431,6 +1431,17 @@ openclaw-session-owner-chip {
color: var(--ok);
}
+/* Pending routes share the native mascot without exposing transient panel text. */
+.lazy-view-state--loading {
+ display: grid;
+ flex: 1 1 auto;
+ place-items: center;
+ min-width: 0;
+ min-height: min(50dvh, 360px);
+ width: 100%;
+ margin: auto;
+}
+
/* Lazy route load failure — a centered panel state, not a stretched callout. */
.lazy-view-error {
margin: auto;