mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 10:11:15 +00:00
feat(ui): web delights — empty-state mascots, first-reply confetti, composer drag-catch (#109118)
* feat(ui): web delights — empty-state mascots, first-reply confetti, composer drag-catch Three mascot-powered moments in the Control UI: the plugins page and channels hub show a sleepy Clawd when truly empty and a curious one on a search miss; the very first successful assistant reply in a browser earns a single confetti burst in the mascot palette (localStorage once-flag, reduced-motion aware); and dragging files over the new- session composer makes the welcome mascot tease with an open mouth, then snap a catch on drop — upload behavior untouched. * fix(ui): satisfy strict types and knip for the confetti module Tuple index under noUncheckedIndexedAccess gets an anchored fallback, the once-flag helper stays module-internal (knip counts only production consumers), and its coverage moves to the public fireFirstReplyConfetti path in jsdom using the shared storage mock.
This commit is contained in:
committed by
GitHub
parent
9b48ad5977
commit
4733a59f26
69
ui/src/components/confetti.test.ts
Normal file
69
ui/src/components/confetti.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStorageMock } from "../test-helpers/storage.ts";
|
||||
import { fireFirstReplyConfetti } from "./confetti.ts";
|
||||
|
||||
const FLAG_KEY = "openclaw.confetti.firstReply";
|
||||
|
||||
function stubMatchMedia(reducedMotion: boolean): void {
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
(query: string) =>
|
||||
({
|
||||
matches: reducedMotion && query.includes("prefers-reduced-motion"),
|
||||
media: query,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
}) as unknown as MediaQueryList,
|
||||
);
|
||||
}
|
||||
|
||||
describe("fireFirstReplyConfetti", () => {
|
||||
let storage: Storage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createStorageMock();
|
||||
vi.stubGlobal("localStorage", storage);
|
||||
stubMatchMedia(false);
|
||||
// jsdom canvases have no 2d context; stub a minimal drawing surface so the
|
||||
// burst path runs deterministically.
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
|
||||
setTransform: () => undefined,
|
||||
clearRect: () => undefined,
|
||||
save: () => undefined,
|
||||
restore: () => undefined,
|
||||
translate: () => undefined,
|
||||
rotate: () => undefined,
|
||||
fillRect: () => undefined,
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
vi.stubGlobal("requestAnimationFrame", () => 1);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll("canvas").forEach((canvas) => canvas.remove());
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("celebrates the first reply exactly once", () => {
|
||||
fireFirstReplyConfetti();
|
||||
expect(storage.getItem(FLAG_KEY)).toBe("1");
|
||||
expect(document.querySelectorAll("canvas")).toHaveLength(1);
|
||||
|
||||
fireFirstReplyConfetti();
|
||||
expect(document.querySelectorAll("canvas")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips a browser profile that already celebrated", () => {
|
||||
storage.setItem(FLAG_KEY, "1");
|
||||
fireFirstReplyConfetti();
|
||||
expect(document.querySelectorAll("canvas")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips reduced-motion users without burning the once-flag", () => {
|
||||
stubMatchMedia(true);
|
||||
fireFirstReplyConfetti();
|
||||
expect(document.querySelectorAll("canvas")).toHaveLength(0);
|
||||
expect(storage.getItem(FLAG_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
115
ui/src/components/confetti.ts
Normal file
115
ui/src/components/confetti.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
const FIRST_REPLY_CONFETTI_KEY = "openclaw.confetti.firstReply";
|
||||
const CONFETTI_COLORS = ["#ff4d4d", "#ff7079", "#00e5cc", "#f2a833"] as const;
|
||||
const CONFETTI_DURATION_MS = 1_500;
|
||||
const PARTICLE_COUNT = 64;
|
||||
|
||||
type ConfettiStorage = Pick<Storage, "getItem" | "setItem">;
|
||||
|
||||
type ConfettiParticle = {
|
||||
color: (typeof CONFETTI_COLORS)[number];
|
||||
angle: number;
|
||||
speed: number;
|
||||
size: number;
|
||||
spin: number;
|
||||
rotation: number;
|
||||
};
|
||||
|
||||
function shouldFireFirstReplyConfetti(storage: ConfettiStorage): boolean {
|
||||
try {
|
||||
if (storage.getItem(FIRST_REPLY_CONFETTI_KEY) !== null) {
|
||||
return false;
|
||||
}
|
||||
storage.setItem(FIRST_REPLY_CONFETTI_KEY, "1");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createParticles(): ConfettiParticle[] {
|
||||
return Array.from({ length: PARTICLE_COUNT }, (_, index) => ({
|
||||
// noUncheckedIndexedAccess cannot see the modulo bound; index 0 is typed.
|
||||
color: CONFETTI_COLORS[index % CONFETTI_COLORS.length] ?? CONFETTI_COLORS[0],
|
||||
angle: -Math.PI * (0.2 + Math.random() * 0.6),
|
||||
speed: 260 + Math.random() * 360,
|
||||
size: 4 + Math.random() * 5,
|
||||
spin: (Math.random() - 0.5) * 12,
|
||||
rotation: Math.random() * Math.PI,
|
||||
}));
|
||||
}
|
||||
|
||||
export function fireFirstReplyConfetti(): void {
|
||||
if (
|
||||
typeof document === "undefined" ||
|
||||
typeof window === "undefined" ||
|
||||
!document.body ||
|
||||
typeof window.requestAnimationFrame !== "function" ||
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let storage: Storage;
|
||||
try {
|
||||
storage = window.localStorage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!shouldFireFirstReplyConfetti(storage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
Object.assign(canvas.style, {
|
||||
position: "fixed",
|
||||
inset: "0",
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
pointerEvents: "none",
|
||||
zIndex: "2147483647",
|
||||
});
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
document.body.append(canvas);
|
||||
|
||||
const pixelRatio = Math.min(2, Math.max(1, window.devicePixelRatio || 1));
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
canvas.width = Math.round(width * pixelRatio);
|
||||
canvas.height = Math.round(height * pixelRatio);
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
|
||||
const particles = createParticles();
|
||||
const origin = { x: width / 2, y: height * 0.62 };
|
||||
const startedAt = performance.now();
|
||||
const draw = (timestamp: number) => {
|
||||
const elapsedMs = timestamp - startedAt;
|
||||
const elapsed = elapsedMs / 1_000;
|
||||
const progress = elapsedMs / CONFETTI_DURATION_MS;
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.globalAlpha = progress < 0.65 ? 1 : Math.max(0, (1 - progress) / 0.35);
|
||||
|
||||
for (const particle of particles) {
|
||||
const drag = Math.max(0, 1 - elapsed * 0.22);
|
||||
const x = origin.x + Math.cos(particle.angle) * particle.speed * elapsed * drag;
|
||||
const y =
|
||||
origin.y + Math.sin(particle.angle) * particle.speed * elapsed + 420 * elapsed * elapsed;
|
||||
context.save();
|
||||
context.translate(x, y);
|
||||
context.rotate(particle.rotation + particle.spin * elapsed);
|
||||
context.fillStyle = particle.color;
|
||||
context.fillRect(-particle.size / 2, -particle.size / 4, particle.size, particle.size / 2);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
if (progress < 1) {
|
||||
window.requestAnimationFrame(draw);
|
||||
} else {
|
||||
canvas.remove();
|
||||
}
|
||||
};
|
||||
window.requestAnimationFrame(draw);
|
||||
}
|
||||
@@ -127,6 +127,36 @@ describe("MascotAnimator", () => {
|
||||
expect(pose.mouthRound).toBeGreaterThan(0.8);
|
||||
expect(pose.leftEyeOpenness).toBeLessThan(0.1);
|
||||
});
|
||||
|
||||
it("overlays and clears the composer tease expression", () => {
|
||||
const animator = new MascotAnimator(17);
|
||||
animator.setMood("idle", 0);
|
||||
animator.setTease(true, 0);
|
||||
|
||||
expect(animator.poseAt(0)).toMatchObject({ mouthRound: 0.5, gaze: { x: 0, y: 0.6 } });
|
||||
|
||||
animator.setTease(false, 0.1);
|
||||
const cleared = animator.poseAt(0.1);
|
||||
expect(cleared.mouthRound).toBe(0);
|
||||
expect(cleared.gaze).not.toEqual({ x: 0, y: 0.6 });
|
||||
});
|
||||
|
||||
it("plays one bounded catch beat and clears it after 0.8 seconds", () => {
|
||||
const animator = new MascotAnimator(23);
|
||||
animator.setMood("idle", 0);
|
||||
animator.poseAt(0);
|
||||
animator.playCatch(0.1);
|
||||
|
||||
let peakHappyEyes = 0;
|
||||
for (let frame = 0; frame <= 24; frame += 1) {
|
||||
const pose = animator.poseAt(0.1 + frame / 30);
|
||||
expectPoseInBounds(pose);
|
||||
peakHappyEyes = Math.max(peakHappyEyes, pose.happyEyes);
|
||||
}
|
||||
expect(peakHappyEyes).toBeGreaterThan(0.8);
|
||||
expect(animator.poseAt(0.91).happyEyes).toBe(0);
|
||||
expect(animator.poseAt(1.2).happyEyes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("staticMascotPose", () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ const NONZERO_SEED = 0x9e37_79b9_7f4a_7c15n;
|
||||
const XORSHIFT_MULTIPLIER = 2_685_821_657_736_338_717n;
|
||||
const TAU = Math.PI * 2;
|
||||
const BLINK_DURATION = 0.16;
|
||||
const CATCH_DURATION = 0.8;
|
||||
|
||||
function clamp(value: number, min = 0, max = 1): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
@@ -115,6 +116,9 @@ export class MascotAnimator {
|
||||
private currentGaze = { x: 0, y: 0 };
|
||||
private nextClawSnapAt = 0;
|
||||
private nextMoodBeatAt = 0;
|
||||
private teaseActive = false;
|
||||
private teaseChangedAt = 0;
|
||||
private catchStartedAt: number | null = null;
|
||||
|
||||
constructor(seed: bigint | number = BigInt(Date.now())) {
|
||||
this.rng = new SeededGenerator(seed);
|
||||
@@ -136,6 +140,15 @@ export class MascotAnimator {
|
||||
}
|
||||
}
|
||||
|
||||
setTease(active: boolean, time: number): void {
|
||||
this.teaseActive = active;
|
||||
this.teaseChangedAt = time;
|
||||
}
|
||||
|
||||
playCatch(time: number): void {
|
||||
this.catchStartedAt = time;
|
||||
}
|
||||
|
||||
poseAt(time: number): MascotPose {
|
||||
if (this.startTime === null) {
|
||||
this.begin(time);
|
||||
@@ -157,6 +170,23 @@ export class MascotAnimator {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.teaseActive && time >= this.teaseChangedAt) {
|
||||
pose.mouthRound = Math.max(pose.mouthRound, 0.5);
|
||||
pose.gaze = { x: 0, y: 0.6 };
|
||||
}
|
||||
if (this.catchStartedAt !== null) {
|
||||
const progress = (time - this.catchStartedAt) / CATCH_DURATION;
|
||||
if (progress >= 1) {
|
||||
this.catchStartedAt = null;
|
||||
} else if (progress >= 0) {
|
||||
const flash = bell(progress);
|
||||
this.applyGesture("clawSnap", pose, clamp(progress / 0.75));
|
||||
pose.happyEyes = Math.max(pose.happyEyes, 0.9 * flash);
|
||||
pose.mouthCurve = Math.max(pose.mouthCurve, 0.7 * flash);
|
||||
pose.blush = Math.max(pose.blush, 0.65 * flash);
|
||||
}
|
||||
}
|
||||
|
||||
return clampMascotPose(pose);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ class OpenClawMascot extends LitElement {
|
||||
|
||||
@property({ reflect: true }) mood: MascotMood = "idle";
|
||||
@property({ type: Number }) size = DEFAULT_SIZE;
|
||||
@property({ type: Boolean }) tease = false;
|
||||
|
||||
private readonly animator = new MascotAnimator();
|
||||
private animationFrame = 0;
|
||||
@@ -107,6 +108,7 @@ class OpenClawMascot extends LitElement {
|
||||
// Seed the animator's mood before the first pose so `begin()` schedules
|
||||
// for the real mood; `updated()` runs after this and would be too late.
|
||||
this.animator.setMood(this.resolvedMood, currentSeconds());
|
||||
this.animator.setTease(this.tease, currentSeconds());
|
||||
this.drawCurrentFrame(currentSeconds());
|
||||
this.syncPlayback();
|
||||
}
|
||||
@@ -118,12 +120,25 @@ class OpenClawMascot extends LitElement {
|
||||
if (changed.has("mood")) {
|
||||
this.animator.setMood(this.resolvedMood, currentSeconds());
|
||||
}
|
||||
if (changed.has("size") || changed.has("mood")) {
|
||||
if (changed.has("tease")) {
|
||||
this.animator.setTease(this.tease, currentSeconds());
|
||||
}
|
||||
if (changed.has("size") || changed.has("mood") || changed.has("tease")) {
|
||||
this.drawCurrentFrame(currentSeconds());
|
||||
this.syncPlayback();
|
||||
}
|
||||
}
|
||||
|
||||
catchOnce(): void {
|
||||
if (!this.isConnected || this.reducedMotion) {
|
||||
return;
|
||||
}
|
||||
const time = currentSeconds();
|
||||
this.animator.playCatch(time);
|
||||
this.drawCurrentFrame(time);
|
||||
this.syncPlayback();
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<canvas></canvas>`;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
WhatsAppStatus,
|
||||
} from "../../api/types.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import "../../components/openclaw-mascot.ts";
|
||||
import {
|
||||
renderSettingsEmpty,
|
||||
renderSettingsPage,
|
||||
@@ -81,7 +82,13 @@ export function renderChannels(props: ChannelsProps) {
|
||||
`,
|
||||
},
|
||||
connected.length === 0
|
||||
? renderSettingsEmpty(t("channels.hub.noneConnected"))
|
||||
? html`
|
||||
<div class="channels-empty">
|
||||
<!-- No configured transports is a true empty state, so Clawd rests here. -->
|
||||
<openclaw-mascot mood="sleepy" .size=${80}></openclaw-mascot>
|
||||
${renderSettingsEmpty(t("channels.hub.noneConnected"))}
|
||||
</div>
|
||||
`
|
||||
: connected.map((key) => renderConnectedRow(key, props)),
|
||||
)}
|
||||
${renderSettingsSection(
|
||||
|
||||
@@ -18,8 +18,10 @@ import {
|
||||
patchSettings,
|
||||
type UiSettings,
|
||||
} from "../../app/settings.ts";
|
||||
import { fireFirstReplyConfetti } from "../../components/confetti.ts";
|
||||
import { isRenderableControlUiAvatarUrl } from "../../lib/avatar.ts";
|
||||
import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts";
|
||||
import { extractText } from "../../lib/chat/message-extract.ts";
|
||||
import { retirePendingChatSideQuestion, type ChatSideResult } from "../../lib/chat/side-result.ts";
|
||||
import type { EmbedSandboxMode } from "../../lib/chat/tool-display.ts";
|
||||
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
|
||||
@@ -55,7 +57,9 @@ import {
|
||||
} from "./chat-gateway.ts";
|
||||
import {
|
||||
chatScopedEventSessionMatches,
|
||||
isHiddenAssistantStreamText,
|
||||
loadChatHistory,
|
||||
shouldHideAssistantChatMessage,
|
||||
type ChatMetadataResult,
|
||||
type ChatState,
|
||||
} from "./chat-history.ts";
|
||||
@@ -1396,9 +1400,41 @@ export function createPageState(
|
||||
return state;
|
||||
}
|
||||
|
||||
function hasVisibleFinalAssistantReply(
|
||||
state: ChatPageHost,
|
||||
payload: ChatEventPayload | undefined,
|
||||
): boolean {
|
||||
if (payload?.state !== "final") {
|
||||
return false;
|
||||
}
|
||||
const ownsReply =
|
||||
chatScopedEventSessionMatches(state, payload.sessionKey, payload.agentId) ||
|
||||
(typeof payload.runId === "string" && payload.runId === state.chatRunId);
|
||||
if (!ownsReply) {
|
||||
return false;
|
||||
}
|
||||
const finalText = extractText(payload.message);
|
||||
if (
|
||||
typeof finalText === "string" &&
|
||||
finalText.trim().length > 0 &&
|
||||
!isHiddenAssistantStreamText(finalText) &&
|
||||
!shouldHideAssistantChatMessage(payload.message)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
state.chatStream,
|
||||
...(state.chatStreamSegments ?? []).map((segment) => segment.text),
|
||||
].some(
|
||||
(text) =>
|
||||
typeof text === "string" && text.trim().length > 0 && !isHiddenAssistantStreamText(text),
|
||||
);
|
||||
}
|
||||
|
||||
export function handlePageGatewayEvent(state: ChatPageHost, event: GatewayEventFrame) {
|
||||
if (event.event === "chat") {
|
||||
const payload = event.payload as ChatEventPayload | undefined;
|
||||
const shouldCelebrateFirstReply = hasVisibleFinalAssistantReply(state, payload);
|
||||
const terminal =
|
||||
payload?.state === "final" || payload?.state === "aborted" || payload?.state === "error";
|
||||
const delivered = terminal ? rememberDeliveredQueuedUserTurn(state, payload?.runId) : null;
|
||||
@@ -1407,7 +1443,10 @@ export function handlePageGatewayEvent(state: ChatPageHost, event: GatewayEventF
|
||||
// Materialize it before the terminal assistant to preserve transcript order.
|
||||
preserveDeliveredQueuedUserTurn(state, delivered);
|
||||
}
|
||||
handleChatGatewayEvent(state as unknown as ChatState, payload);
|
||||
const result = handleChatGatewayEvent(state as unknown as ChatState, payload);
|
||||
if (shouldCelebrateFirstReply && result === "final") {
|
||||
fireFirstReplyConfetti();
|
||||
}
|
||||
replayPendingSessionMessageReload(state, payload);
|
||||
if (terminal) {
|
||||
removeDeliveredQueuedChatSendForRun(state, payload?.runId);
|
||||
|
||||
@@ -3937,6 +3937,24 @@ describe("chat welcome", () => {
|
||||
expect(container.querySelector(".agent-chat__badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("teases and catches file drags with the welcome mascot", () => {
|
||||
const container = renderWelcome({ assistantAvatar: null, assistantAvatarUrl: null });
|
||||
const welcome = requireElement(container, ".agent-chat__welcome", "welcome screen");
|
||||
const mascot = requireElement(
|
||||
container,
|
||||
".agent-chat__welcome-clawd openclaw-mascot",
|
||||
"welcome mascot",
|
||||
) as HTMLElement & { tease: boolean; catchOnce: () => void };
|
||||
const catchOnce = vi.spyOn(mascot, "catchOnce");
|
||||
|
||||
welcome.dispatchEvent(createDragEvent("dragenter"));
|
||||
expect(mascot.tease).toBe(true);
|
||||
|
||||
welcome.dispatchEvent(createDragEvent("drop"));
|
||||
expect(mascot.tease).toBe(false);
|
||||
expect(catchOnce).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders welcome text from the active locale", async () => {
|
||||
await i18n.setLocale("zh-CN");
|
||||
const container = renderWelcome({ assistantAvatar: "VC", assistantAvatarUrl: null });
|
||||
|
||||
@@ -34,6 +34,8 @@ type ChatWelcomeProps = {
|
||||
onOpenSession?: (sessionKey: string) => void;
|
||||
};
|
||||
|
||||
type WelcomeMascot = HTMLElement & { tease: boolean; catchOnce: () => void };
|
||||
|
||||
const WELCOME_SUGGESTION_KEYS = [
|
||||
"chat.welcome.suggestions.whatCanYouDo",
|
||||
"chat.welcome.suggestions.summarizeRecentSessions",
|
||||
@@ -168,9 +170,47 @@ function renderWelcomeHero(
|
||||
/** The start-screen welcome block, shared by the empty chat and the new-session draft. */
|
||||
export function renderWelcomeState(props: ChatWelcomeProps) {
|
||||
const recentSessions = selectWelcomeRecentSessions(props);
|
||||
let fileDragDepth = 0;
|
||||
const mascotFor = (event: DragEvent): WelcomeMascot | null => {
|
||||
const target = event.currentTarget;
|
||||
return target instanceof HTMLElement
|
||||
? target.querySelector<WelcomeMascot>(".agent-chat__welcome-clawd openclaw-mascot")
|
||||
: null;
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="agent-chat__welcome" style="--agent-color: var(--accent)">
|
||||
<div
|
||||
class="agent-chat__welcome"
|
||||
style="--agent-color: var(--accent)"
|
||||
@dragenter=${(event: DragEvent) => {
|
||||
if (!Array.from(event.dataTransfer?.types ?? []).includes("Files")) {
|
||||
return;
|
||||
}
|
||||
fileDragDepth += 1;
|
||||
const mascot = mascotFor(event);
|
||||
if (mascot) {
|
||||
mascot.tease = true;
|
||||
}
|
||||
}}
|
||||
@dragleave=${(event: DragEvent) => {
|
||||
fileDragDepth = Math.max(0, fileDragDepth - 1);
|
||||
const mascot = mascotFor(event);
|
||||
if (mascot && fileDragDepth === 0) {
|
||||
mascot.tease = false;
|
||||
}
|
||||
}}
|
||||
@drop=${(event: DragEvent) => {
|
||||
if (!Array.from(event.dataTransfer?.types ?? []).includes("Files")) {
|
||||
return;
|
||||
}
|
||||
fileDragDepth = 0;
|
||||
const mascot = mascotFor(event);
|
||||
if (mascot) {
|
||||
mascot.tease = false;
|
||||
mascot.catchOnce();
|
||||
}
|
||||
}}
|
||||
>
|
||||
${renderWelcomeHero({
|
||||
assistantName: props.assistantName,
|
||||
assistantAvatar: props.assistantAvatar,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { live } from "lit/directives/live.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import "../../components/modal-dialog.ts";
|
||||
import "../../components/openclaw-mascot.ts";
|
||||
import {
|
||||
renderSettingsEmpty,
|
||||
renderSettingsPage,
|
||||
@@ -722,16 +723,14 @@ function renderMcpForm(props: PluginsViewProps) {
|
||||
function renderInstalled(props: PluginsViewProps) {
|
||||
const plugins = installedPlugins(props.result?.plugins ?? [], props.query, props.installedFilter);
|
||||
const groups = groupInstalledByCategory(plugins);
|
||||
const filtered = Boolean(props.query || props.installedFilter !== "all");
|
||||
return html`
|
||||
${renderInstalledFilter(props)}
|
||||
${groups.length === 0
|
||||
? renderEmpty(
|
||||
props.query || props.installedFilter !== "all"
|
||||
? t("pluginsPage.noInstalledMatchTitle")
|
||||
: t("pluginsPage.noInstalledTitle"),
|
||||
props.query || props.installedFilter !== "all"
|
||||
? t("pluginsPage.noMatchBody")
|
||||
: t("pluginsPage.noInstalledBody"),
|
||||
filtered ? t("pluginsPage.noInstalledMatchTitle") : t("pluginsPage.noInstalledTitle"),
|
||||
filtered ? t("pluginsPage.noMatchBody") : t("pluginsPage.noInstalledBody"),
|
||||
filtered ? "curious" : "sleepy",
|
||||
)
|
||||
: groups.map((group) =>
|
||||
renderSettingsSection(
|
||||
@@ -989,7 +988,11 @@ function renderDiscover(props: PluginsViewProps) {
|
||||
if (!featuredRows.length && !officialRows.length && !shelves.connectors.length) {
|
||||
return html`
|
||||
${clawHub === nothing
|
||||
? renderEmpty(t("pluginsPage.noDiscoverMatchTitle"), t("pluginsPage.noMatchBody"))
|
||||
? renderEmpty(
|
||||
t("pluginsPage.noDiscoverMatchTitle"),
|
||||
t("pluginsPage.noMatchBody"),
|
||||
"curious",
|
||||
)
|
||||
: nothing}
|
||||
${clawHub}
|
||||
`;
|
||||
@@ -1163,10 +1166,17 @@ function renderDetailCover(slug: string, name: string): TemplateResult {
|
||||
|
||||
/* ---------------------------------- page shell ---------------------------------- */
|
||||
|
||||
function renderEmpty(title: string, body: string) {
|
||||
function renderEmpty(title: string, body: string, mood?: "sleepy" | "curious") {
|
||||
return html`
|
||||
<div class="plugins-empty">
|
||||
<span class="plugins-empty__icon" aria-hidden="true">${icons.puzzle}</span>
|
||||
<!-- Sleepy marks truly empty inventory; curious marks a filter/search miss. -->
|
||||
${mood
|
||||
? html`<openclaw-mascot
|
||||
class="plugins-empty__mascot"
|
||||
.mood=${mood}
|
||||
.size=${84}
|
||||
></openclaw-mascot>`
|
||||
: html`<span class="plugins-empty__icon" aria-hidden="true">${icons.puzzle}</span>`}
|
||||
<h2>${title}</h2>
|
||||
<p>${body}</p>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
shared settings design language. */
|
||||
|
||||
/* Row with a leading 44px art tile (mirrors the plugins hub row anatomy). */
|
||||
.channels-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
|
||||
.channels-empty .settings-empty {
|
||||
padding-top: var(--space-1);
|
||||
}
|
||||
|
||||
.channels-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
@@ -427,10 +427,14 @@ h3.plugins-subheader {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.plugins-empty__icon,
|
||||
.plugins-empty__mascot {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.plugins-empty__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--border-hover);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user