diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index d0c387b19113..2d7694e8b0dc 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -22,20 +22,40 @@ import { AppSidebarSessionListElement } from "./app-sidebar-session-list.ts"; import { icons } from "./icons.ts"; import { LOBSTER_LOGO_VISIT_EVENT, - LOBSTER_PET_BUILD_MULS, - LOBSTER_PET_CLAW_MULS, lobsterPetSeed, - renderLobsterSvg, resolveLobsterPetMode, resolveLobsterRunOutcome, type LobsterLogoVisitDetail, -} from "./lobster-pet.ts"; +} from "./lobster-pet-contract.ts"; const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platform ?? "") ? "⌘K" : "Ctrl K"; const OFFLINE_INDICATOR_DELAY_MS = 2_000; +let lobsterPetModuleLoad: Promise | null = null; + +function scheduleLobsterPetLoad() { + if (lobsterPetModuleLoad || customElements.get("openclaw-lobster-pet")) { + return; + } + const start = () => { + // A failed chunk fetch must not pin a rejected promise forever: clear the + // cache and retry when connectivity returns. The sidebar mounts once per + // page, so without this a transient failure would disable the pet for the + // whole session; a deploy-pruned chunk stays off until reload, by design. + lobsterPetModuleLoad ??= import("./lobster-pet.ts").catch(() => { + lobsterPetModuleLoad = null; + window.addEventListener("online", () => start(), { once: true }); + }); + }; + if ("requestIdleCallback" in window) { + requestIdleCallback(() => start(), { timeout: 3000 }); + } else { + setTimeout(start, 1500); + } +} + class AppSidebar extends AppSidebarSessionListElement { @state() private logoVisit: LobsterLogoVisitDetail | null = null; @state() private debouncedDisconnected = false; @@ -51,6 +71,9 @@ class AppSidebar extends AppSidebarSessionListElement { override connectedCallback() { super.connectedCallback(); this.syncOfflineIndicator(); + // The decorative pet's large module stays out of startup and upgrades in place. + // Its first visit is at least 15 seconds after load, so idle loading cannot miss one. + scheduleLobsterPetLoad(); } override disconnectedCallback() { @@ -87,34 +110,6 @@ class AppSidebar extends AppSidebarSessionListElement { this.logoVisit = detail.phase === "out" ? null : detail; }; - private renderLogoStandIn() { - const visit = this.logoVisit; - if (!visit?.look) { - return nothing; - } - const look = visit.look; - const classes = [ - "sidebar-brand__pet", - `lobster-pet--palette-${look.palette.id}`, - visit.phase === "leaving" ? "sidebar-brand__pet--leaving" : "", - ] - .filter(Boolean) - .join(" "); - const style = [ - `--lob-shell:${look.palette.shell}`, - `--lob-claw:${look.palette.claw}`, - `--lob-blink-delay:${look.blinkDelayS}s`, - `--lob-w:${LOBSTER_PET_BUILD_MULS[look.build].w}`, - `--lob-h:${LOBSTER_PET_BUILD_MULS[look.build].h}`, - `--lob-claw-scale:${LOBSTER_PET_CLAW_MULS[look.clawSize]}`, - ].join(";"); - return html` - ${renderLobsterSvg(look)} - `; - } - private renderBrand() { const collapseLabel = t("nav.collapse"); const gatewayStatus = t("chat.gatewayStatus", { @@ -238,7 +233,7 @@ class AppSidebar extends AppSidebarSessionListElement { alt="" aria-hidden="true" /> - ${this.renderLogoStandIn()} + >> 0; +} + +// One salt per page load: revisiting the UI re-rolls every session's lobster, +// while re-renders within a load stay stable for a given session key. +const LOAD_SALT = Math.trunc(Math.random() * 0xffffffff); + +export function lobsterPetSeed(sessionKey: string): number { + return (fnv1a(sessionKey) ^ LOAD_SALT) >>> 0; +} + +// The most recently active session with a terminal status decides how the +// pet reacts when the busy state clears: failures earn sympathy, not cheers. +export function resolveLobsterRunOutcome( + sessions: + | ReadonlyArray<{ + status?: "running" | "done" | "failed" | "killed" | "timeout"; + endedAt?: number | null; + lastActivityAt?: number | null; + updatedAt?: number | null; + }> + | null + | undefined, +): LobsterRunOutcome { + let latest: { at: number; outcome: LobsterRunOutcome } | null = null; + for (const row of sessions ?? []) { + if (!row.status || row.status === "running") { + continue; + } + // endedAt is the run-completion timestamp; activity/updated stamps also + // move on unrelated events (reads, renames) and only serve as fallbacks. + const at = row.endedAt ?? row.lastActivityAt ?? row.updatedAt ?? 0; + if (!latest || at > latest.at) { + const outcome: LobsterRunOutcome = + row.status === "failed" || row.status === "timeout" + ? "error" + : row.status === "killed" + ? "aborted" + : "ok"; + latest = { at, outcome }; + } + } + return latest?.outcome ?? "ok"; +} + +export function resolveLobsterPetMode( + connected: boolean, + sessions: ReadonlyArray<{ hasActiveRun?: boolean | null }> | null | undefined, +): LobsterPetMode { + if (!connected) { + return "offline"; + } + return sessions?.some((row) => row.hasActiveRun === true) ? "busy" : "idle"; +} diff --git a/ui/src/components/lobster-pet.ts b/ui/src/components/lobster-pet.ts index 0cabe7a82b3c..9795a58c3279 100644 --- a/ui/src/components/lobster-pet.ts +++ b/ui/src/components/lobster-pet.ts @@ -20,6 +20,33 @@ import { recordLobsterVisit, type LobsterFamiliarity, } from "./lobster-dex.ts"; +import { + LOBSTER_LOGO_VISIT_EVENT, + type LobsterLogoVisitDetail, + type LobsterLogoVisitPhase, + type LobsterPetAccessory, + type LobsterPetAntennae, + type LobsterPetBuild, + type LobsterPetClawSize, + type LobsterPetLook, + type LobsterPetMode, + type LobsterPetPalette, + type LobsterPetPaletteId, + type LobsterPetPersonalityId, + type LobsterRunOutcome, +} from "./lobster-pet-contract.ts"; + +export { + LOBSTER_LOGO_VISIT_EVENT, + lobsterPetSeed, + resolveLobsterPetMode, + resolveLobsterRunOutcome, + type LobsterLogoVisitDetail, + type LobsterLogoVisitPhase, + type LobsterPetLook, + type LobsterPetMode, + type LobsterRunOutcome, +} from "./lobster-pet-contract.ts"; type LobsterPetAct = | "wave" @@ -37,53 +64,6 @@ type LobsterPetAct = | "droop" | "sweep"; -type LobsterPetMode = "idle" | "busy" | "offline"; - -type LobsterPetPersonalityId = "sleepy" | "zoomy" | "friendly" | "showoff"; - -type LobsterPetPaletteId = - | "crimson" - | "coral" - | "teal" - | "violet" - | "ink" - | "blue" - | "gold" - | "calico" - | "abyss" - | "ghost" - | "split" - | "retro"; - -type LobsterPetPalette = { - id: LobsterPetPaletteId; - shell: string; - claw: string; -}; - -type LobsterPetAccessory = "none" | "crown" | "sprout" | "patch" | "santa" | "pumpkin" | "party"; - -type LobsterPetAntennae = "perky" | "droopy"; - -type LobsterPetBuild = "round" | "squat" | "slender"; - -type LobsterPetClawSize = "dainty" | "regular" | "mighty"; - -type LobsterPetLook = { - palette: LobsterPetPalette; - scale: number; - accessory: LobsterPetAccessory; - antennae: LobsterPetAntennae; - side: "left" | "right"; - spotPct: number; - facing: 1 | -1; - personality: LobsterPetPersonalityId; - blinkDelayS: number; - build: LobsterPetBuild; - clawSize: LobsterPetClawSize; - tailFan: boolean; -}; - type ActProfile = { // [min, max] delay before the next act. delayMs: [number, number]; @@ -385,20 +365,6 @@ function isLobsterLogoLoad(seed: number): boolean { return mulberry32((seed ^ 0x1063) >>> 0)() < 0.12; } -type LobsterLogoVisitPhase = "in" | "leaving" | "out"; - -export type LobsterLogoVisitDetail = { - phase: LobsterLogoVisitPhase; - // A null look on a non-"out" phase means "hide the logo, render no - // stand-in": a ledge visit scared the brand mark away. - look: LobsterPetLook | null; - name: string | null; -}; - -// Fired on the pet host whenever the logo stand-in phase changes; the -// sidebar owns the brand slot, so the swap renders there, not here. -export const LOBSTER_LOGO_VISIT_EVENT = "openclaw-lobster-logo-visit"; - type LobsterPasserKind = "stranger" | "crab"; type LobsterPasserPlan = { @@ -461,15 +427,6 @@ function isLobsterNightTime(now: Date = new Date()): boolean { return hour >= 22 || hour < 6; } -function fnv1a(value: string): number { - let hash = 0x811c9dc5; - for (let i = 0; i < value.length; i++) { - hash ^= value.charCodeAt(i); - hash = Math.imul(hash, 0x01000193); - } - return hash >>> 0; -} - function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => { @@ -496,14 +453,6 @@ function randomBetween(rng: () => number, min: number, max: number): number { return min + rng() * (max - min); } -// One salt per page load: revisiting the UI re-rolls every session's lobster, -// while re-renders within a load stay stable for a given session key. -const LOAD_SALT = Math.trunc(Math.random() * 0xffffffff); - -export function lobsterPetSeed(sessionKey: string): number { - return (fnv1a(sessionKey) ^ LOAD_SALT) >>> 0; -} - export function createLobsterPetLook(seed: number, now: Date = new Date()): LobsterPetLook { const rng = mulberry32(seed); const palette = pickWeighted(rng, PALETTES); @@ -555,52 +504,6 @@ export function createLobsterPetLook(seed: number, now: Date = new Date()): Lobs }; } -type LobsterRunOutcome = "ok" | "error" | "aborted"; - -// The most recently active session with a terminal status decides how the -// pet reacts when the busy state clears: failures earn sympathy, not cheers. -export function resolveLobsterRunOutcome( - sessions: - | ReadonlyArray<{ - status?: "running" | "done" | "failed" | "killed" | "timeout"; - endedAt?: number | null; - lastActivityAt?: number | null; - updatedAt?: number | null; - }> - | null - | undefined, -): LobsterRunOutcome { - let latest: { at: number; outcome: LobsterRunOutcome } | null = null; - for (const row of sessions ?? []) { - if (!row.status || row.status === "running") { - continue; - } - // endedAt is the run-completion timestamp; activity/updated stamps also - // move on unrelated events (reads, renames) and only serve as fallbacks. - const at = row.endedAt ?? row.lastActivityAt ?? row.updatedAt ?? 0; - if (!latest || at > latest.at) { - const outcome: LobsterRunOutcome = - row.status === "failed" || row.status === "timeout" - ? "error" - : row.status === "killed" - ? "aborted" - : "ok"; - latest = { at, outcome }; - } - } - return latest?.outcome ?? "ok"; -} - -export function resolveLobsterPetMode( - connected: boolean, - sessions: ReadonlyArray<{ hasActiveRun?: boolean | null }> | null | undefined, -): LobsterPetMode { - if (!connected) { - return "offline"; - } - return sessions?.some((row) => row.hasActiveRun === true) ? "busy" : "idle"; -} - function prefersReducedMotion(): boolean { return ( typeof window !== "undefined" && @@ -904,6 +807,42 @@ export function renderLobsterSvg( `; } +class LobsterLogoStandIn extends LitElement { + override createRenderRoot() { + return this; + } + + @property({ attribute: false }) visit: LobsterLogoVisitDetail | null = null; + + override render() { + const visit = this.visit; + if (!visit?.look) { + return nothing; + } + const look = visit.look; + const classes = [ + "sidebar-brand__pet", + `lobster-pet--palette-${look.palette.id}`, + visit.phase === "leaving" ? "sidebar-brand__pet--leaving" : "", + ] + .filter(Boolean) + .join(" "); + const style = [ + `--lob-shell:${look.palette.shell}`, + `--lob-claw:${look.palette.claw}`, + `--lob-blink-delay:${look.blinkDelayS}s`, + `--lob-w:${LOBSTER_PET_BUILD_MULS[look.build].w}`, + `--lob-h:${LOBSTER_PET_BUILD_MULS[look.build].h}`, + `--lob-claw-scale:${LOBSTER_PET_CLAW_MULS[look.clawSize]}`, + ].join(";"); + return html` + ${renderLobsterSvg(look)} + `; + } +} + class LobsterPet extends LitElement { override createRenderRoot() { return this; @@ -1835,6 +1774,10 @@ class LobsterPet extends LitElement { } } +if (!customElements.get("openclaw-lobster-logo-standin")) { + customElements.define("openclaw-lobster-logo-standin", LobsterLogoStandIn); +} + if (!customElements.get("openclaw-lobster-pet")) { customElements.define("openclaw-lobster-pet", LobsterPet); } diff --git a/ui/src/e2e/sidebar-customization.e2e.test.ts b/ui/src/e2e/sidebar-customization.e2e.test.ts index 536c9b7d8263..2a9a901e60b8 100644 --- a/ui/src/e2e/sidebar-customization.e2e.test.ts +++ b/ui/src/e2e/sidebar-customization.e2e.test.ts @@ -101,6 +101,7 @@ async function openSidebarTestPage() { const page = await context.newPage(); await installMockGateway(page); await page.goto(`${server.baseUrl}chat`); + await page.waitForFunction(() => Boolean(customElements.get("openclaw-lobster-pet"))); return { context, page }; } diff --git a/ui/src/test-helpers/app-sidebar-cases/sessions.ts b/ui/src/test-helpers/app-sidebar-cases/sessions.ts index 3d3ac3c375aa..bb24395f2e64 100644 --- a/ui/src/test-helpers/app-sidebar-cases/sessions.ts +++ b/ui/src/test-helpers/app-sidebar-cases/sessions.ts @@ -232,13 +232,22 @@ describe("AppSidebar logo stand-in wiring", () => { ); const logo = () => sidebar.querySelector(".sidebar-brand__logo"); const standIn = () => sidebar.querySelector(".sidebar-brand__pet"); + const standInHost = sidebar.querySelector }>( + "openclaw-lobster-logo-standin", + ); + const settleStandIn = async () => { + await sidebar.updateComplete; + await standInHost?.updateComplete; + }; + expect(standInHost).not.toBeNull(); + await standInHost?.updateComplete; expect(logo()?.classList.contains("sidebar-brand__logo--vacated")).toBe(false); expect(standIn()).toBeNull(); const look = createLobsterPetLook(70); dispatch({ phase: "in", look, name: "Pinchy" }); - await sidebar.updateComplete; + await settleStandIn(); expect(logo()?.classList.contains("sidebar-brand__logo--vacated")).toBe(true); const sprite = standIn(); expect(sprite).not.toBeNull(); @@ -247,23 +256,23 @@ describe("AppSidebar logo stand-in wiring", () => { expect(sprite?.querySelector(".lobster-pet__svg")).not.toBeNull(); dispatch({ phase: "leaving", look, name: "Pinchy" }); - await sidebar.updateComplete; + await settleStandIn(); expect(standIn()?.classList.contains("sidebar-brand__pet--leaving")).toBe(true); dispatch({ phase: "out", look: null, name: null }); - await sidebar.updateComplete; + await settleStandIn(); expect(standIn()).toBeNull(); expect(logo()?.classList.contains("sidebar-brand__logo--vacated")).toBe(false); // A lookless scare phase hides the logo with no stand-in crab, and the // "out" edge restores it. dispatch({ phase: "in", look: null, name: null }); - await sidebar.updateComplete; + await settleStandIn(); expect(logo()?.classList.contains("sidebar-brand__logo--vacated")).toBe(true); expect(standIn()).toBeNull(); dispatch({ phase: "out", look: null, name: null }); - await sidebar.updateComplete; + await settleStandIn(); expect(logo()?.classList.contains("sidebar-brand__logo--vacated")).toBe(false); }); });