perf(ui): lazy-load the lobster pet out of the Control UI startup graph (#110628)

* perf(ui): lazy-load the lobster pet out of the Control UI startup graph

Keep the lightweight lobster contract on the sidebar's static path while idle-loading the heavy pet implementation. Move the logo stand-in custom element into the pet module so both decorative elements upgrade together after the lazy import.

* style(ui): format lobster-pet lazy boundary

* fix(ui): clear the lobster-pet chunk cache on load failure

A rejected import stayed cached forever and surfaced as an unhandled
rejection; clearing it lets a later scheduling call retry.

* fix(ui): retry the lobster-pet chunk once connectivity returns
This commit is contained in:
Peter Steinberger
2026-07-18 12:26:25 +01:00
committed by GitHub
parent efa7e1a85a
commit 60cb6d78de
5 changed files with 240 additions and 162 deletions
+28 -33
View File
@@ -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<unknown> | 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`
<span class=${classes} style=${style} title=${`${visit.name} · filling in for the logo`}
>${renderLobsterSvg(look)}</span
>
`;
}
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()}
<openclaw-lobster-logo-standin .visit=${this.logoVisit}></openclaw-lobster-logo-standin>
</span>
<openclaw-sidebar-build-chip
.basePath=${this.basePath}
+130
View File
@@ -0,0 +1,130 @@
export type LobsterPetMode = "idle" | "busy" | "offline";
export type LobsterRunOutcome = "ok" | "error" | "aborted";
export type LobsterPetPersonalityId = "sleepy" | "zoomy" | "friendly" | "showoff";
export type LobsterPetPaletteId =
| "crimson"
| "coral"
| "teal"
| "violet"
| "ink"
| "blue"
| "gold"
| "calico"
| "abyss"
| "ghost"
| "split"
| "retro";
export type LobsterPetPalette = {
id: LobsterPetPaletteId;
shell: string;
claw: string;
};
export type LobsterPetAccessory =
| "none"
| "crown"
| "sprout"
| "patch"
| "santa"
| "pumpkin"
| "party";
export type LobsterPetAntennae = "perky" | "droopy";
export type LobsterPetBuild = "round" | "squat" | "slender";
export type LobsterPetClawSize = "dainty" | "regular" | "mighty";
export 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;
};
export 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";
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;
}
// 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";
}
+67 -124
View File
@@ -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`
<span class=${classes} style=${style} title=${`${visit.name} · filling in for the logo`}
>${renderLobsterSvg(look)}</span
>
`;
}
}
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);
}
@@ -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 };
}
@@ -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<HTMLElement & { updateComplete: Promise<boolean> }>(
"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);
});
});