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;