fix(ui): show centered mascot while pages load (#114481)

This commit is contained in:
Peter Steinberger
2026-07-27 05:40:38 -04:00
committed by GitHub
parent 2e9621018a
commit 4f0dcd4d30
8 changed files with 184 additions and 31 deletions

View File

@@ -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 <img src> —
// inlining the markup would freeze it (see ui/public/favicon.svg).
function renderConnectingSplash(basePath: string) {
function renderConnectingSplash() {
return html`
<main class="connect-splash" role="status" aria-live="polite" aria-label=${t("common.loading")}>
<img
class="connect-splash__logo"
src=${controlUiPublicAssetPath("favicon.svg", basePath)}
alt=""
/>
<openclaw-mascot mood="thinking" .size=${120}></openclaw-mascot>
</main>
`;
}
@@ -416,7 +411,7 @@ class OpenClawApp extends OpenClawLightDomElement {
fullscreen
></openclaw-terminal-panel>
${!isOptionalElementDefined(TERMINAL_PANEL_ELEMENT) && terminalAvailable
? renderConnectingSplash(context.basePath)
? renderConnectingSplash()
: nothing}
${!terminalAvailable && (gatewayConnected || gatewaySnapshot.lastError)
? html`<div class="terminal-view-unavailable">${t("terminal.unavailable")}</div>`
@@ -439,7 +434,7 @@ class OpenClawApp extends OpenClawLightDomElement {
if (initialConnectPending) {
return html`
<openclaw-tooltip-provider>
${renderConnectingSplash(context.basePath)} ${gatewayUrlConfirmation}
${renderConnectingSplash()} ${gatewayUrlConfirmation}
</openclaw-tooltip-provider>
`;
}

View File

@@ -53,6 +53,48 @@ async function settleOutlet(outlet: RouterOutletElement): Promise<void> {
}
describe("openclaw-router-outlet", () => {
it("replaces the centered loading mascot with the resolved route", async () => {
vi.useFakeTimers();
const routeModule = deferred<TestModule>();
const context = { label: "loaded" };
const router = createRouter<RouteId, TestContext, TestModule, TestData>({
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`<div data-testid="route-page">${data?.label}</div>`,
});
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<void>();
const teardownView = vi.fn(() => teardown.promise);

View File

@@ -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<T>(routeId: string, render: () => T): T {
return result;
}
function renderPending() {
return html`
<section class="card lazy-view-state lazy-view-state--loading" role="status">
<div class="card-title">${t("lazyView.loadingTitle")}</div>
<div class="card-sub">${t("common.loading")}</div>
</section>
`;
}
/**
* 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<TRouteId extends string, TLoadContext, TModule, TDat
routeId,
)
: selection.showPending
? renderPending()
? renderLoadingState()
: nothing;
}
const routeModule = renderedMatch.module;

View File

@@ -0,0 +1,16 @@
import { html } from "lit";
import { t } from "../i18n/index.ts";
import "./openclaw-mascot.ts";
export function renderLoadingState() {
return html`
<section
class="lazy-view-state lazy-view-state--loading"
role="status"
aria-live="polite"
aria-label=${t("common.loading")}
>
<openclaw-mascot mood="thinking" .size=${120}></openclaw-mascot>
</section>
`;
}

View File

@@ -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<BrowserContext>();
async function createPage(): Promise<Page> {
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<void> {
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<void>((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 () => {

View File

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

View File

@@ -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) {
</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>
`;
return renderLoadingState();
}
if (!props.pluginEnabled) {

View File

@@ -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;