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:
Peter Steinberger
2026-07-16 09:02:32 -07:00
committed by GitHub
parent 9b48ad5977
commit 4733a59f26
12 changed files with 401 additions and 14 deletions

View 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();
});
});

View 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);
}

View File

@@ -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", () => {

View File

@@ -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);
}

View File

@@ -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>`;
}

View File

@@ -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(

View File

@@ -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);

View File

@@ -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 });

View File

@@ -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,

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);
}