mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-05 03:51:39 +00:00
fix(ui): show centered mascot while pages load (#114481)
This commit is contained in:
committed by
GitHub
parent
2e9621018a
commit
4f0dcd4d30
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
16
ui/src/components/loading-state.ts
Normal file
16
ui/src/components/loading-state.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user