feat(avatar): let Oos react to what their bots do (#141)

A bot's Oo now cheers for a moment when its run finishes, the stage Oo
follows the pointer and squishes when tapped, and chat headers show the
speaking bot's own Oo instead of the generic mascot. Small avatars in
lists only animate while working or waiting so long lists stay calm.


Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-05 01:51:37 +02:00 committed by GitHub
parent 5f3aa8f3fe
commit 55df4a6439
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 167 additions and 12 deletions

View File

@ -3,16 +3,33 @@ import {
ACTIVE_RUN_STATUSES,
DEFAULT_OO_AVATAR,
decodeOoAvatar,
OO_SUCCESS_SECONDS,
ooIdentityShapeIndex,
ooMomentAfterStatus,
ooMomentForStatus,
resolveOoColor,
} from "@rakazo/core";
import { memo, useMemo } from "react";
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { Image, View } from "react-native";
import { useI18n } from "../lib/i18n";
import { NativeSymbol } from "./native-symbol";
import { OoFigure } from "./oo-figure";
/** A short moment after a status change, such as joy when a run finishes. */
function useOoStatusMoment(status?: string): OoMoment | null {
const previous = useRef(status);
const [moment, setMoment] = useState<OoMoment | null>(null);
useEffect(() => {
const next = ooMomentAfterStatus(previous.current, status);
previous.current = status;
setMoment(next);
if (!next) return;
const timer = setTimeout(() => setMoment(null), OO_SUCCESS_SECONDS * 1000);
return () => clearTimeout(timer);
}, [status]);
return moment;
}
export const BotAvatar = memo(function BotAvatar({
color,
size = 54,
@ -37,6 +54,7 @@ export const BotAvatar = memo(function BotAvatar({
const look = useMemo(() => decodeOoAvatar(color) ?? DEFAULT_OO_AVATAR, [color]);
const seed = identity || (picture ? "agent" : color) || "agent";
const body = resolveOoColor(seed, look.color).hex;
const statusMoment = useOoStatusMoment(status);
return (
<View style={{ width: size, height: size }}>
{picture ? (
@ -49,7 +67,7 @@ export const BotAvatar = memo(function BotAvatar({
avatar={look}
color={body}
fallbackShape={look.shape ?? ooIdentityShapeIndex(seed)}
moment={moment ?? ooMomentForStatus(status)}
moment={moment ?? statusMoment ?? ooMomentForStatus(status)}
size={size}
still={still}
/>

View File

@ -70,6 +70,7 @@ export function StageHero({
status={botStatus}
pose={pose}
size={avatarSize}
interactive
className="drop-shadow-[0_6px_16px_rgba(0,0,0,0.12)] dark:drop-shadow-[0_6px_24px_rgba(0,0,0,0.4)]"
/>
</div>

View File

@ -19,7 +19,6 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EvooMascot,
Popover,
PopoverContent,
PopoverTrigger,
@ -795,7 +794,12 @@ const MessageView = memo(function MessageView({
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="relative flex items-center justify-center size-7 rounded-xl bg-foreground/[0.08] border border-border shadow-xs">
<EvooMascot size={22} interactive={false} pose={isLive ? "working" : "idle"} />
<BotAvatar
color={speakerBot?.color || speakerColorDef.hex}
identity={message.botId ?? "bot"}
size={22}
moment={isLive ? "working" : "idle"}
/>
</div>
<div className="flex items-center gap-2">
<span

View File

@ -80,6 +80,7 @@ export function ooMomentForStatus(status?: string, streaming = false): OoMoment
case "leased":
return "working";
case "waiting_input":
case "waiting_takeover":
return "waiting";
case "error":
case "failed":
@ -557,3 +558,18 @@ export function ooFrame({ rig, avatar, moment, t, life, still = false }: OoFrame
export function ooBoop(life: OoLife, t: number): void {
life.boopAt = t;
}
/** How long an Oo celebrates a finished run before it settles back to idle. */
export const OO_SUCCESS_SECONDS = 2.6;
/**
* The moment to show right after a status change: a run that just finished
* successfully earns a short celebration, everything else follows the status.
*/
export function ooMomentAfterStatus(
previous: string | undefined,
status: string | undefined,
): OoMoment | null {
const wasWorking = ooMomentForStatus(previous) === "working";
return wasWorking && (status === "idle" || status === "completed") ? "success" : null;
}

View File

@ -7,7 +7,13 @@ import {
OO_RIG_EYES,
OO_SPECIES,
} from "./oo-avatar.js";
import { createOoLife, ooFrame, ooMomentForStatus, ooSeededRandom } from "./oo-motion.js";
import {
createOoLife,
ooFrame,
ooMomentAfterStatus,
ooMomentForStatus,
ooSeededRandom,
} from "./oo-motion.js";
import {
OO_DEFAULT_COLOR,
OO_PALETTE_HEXES,
@ -202,3 +208,13 @@ describe("oo palette", () => {
expect(ooIdentityShapeIndex("maya")).toBeLessThan(8);
});
});
describe("oo moments after a status change", () => {
it("celebrates a finished run only", () => {
expect(ooMomentAfterStatus("running", "completed")).toBe("success");
expect(ooMomentAfterStatus("running", "idle")).toBe("success");
expect(ooMomentAfterStatus("running", "failed")).toBeNull();
expect(ooMomentAfterStatus("waiting_input", "idle")).toBeNull();
expect(ooMomentAfterStatus(undefined, "idle")).toBeNull();
});
});

View File

@ -0,0 +1,42 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { BotAvatar } from "./bot-avatar.js";
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.useFakeTimers();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
function moment() {
return container.querySelector("svg[data-oo-moment]")?.getAttribute("data-oo-moment");
}
it("celebrates when a run finishes, then settles", () => {
act(() => root.render(<BotAvatar color="#8B5CF6" status="running" size={72} />));
expect(moment()).toBe("working");
act(() => root.render(<BotAvatar color="#8B5CF6" status="completed" size={72} />));
expect(moment()).toBe("success");
act(() => vi.advanceTimersByTime(3000));
expect(moment()).toBe("idle");
});
it("does not celebrate a failed run", () => {
act(() => root.render(<BotAvatar color="#8B5CF6" status="running" size={72} />));
act(() => root.render(<BotAvatar color="#8B5CF6" status="failed" size={72} />));
expect(moment()).toBe("concerned");
});

View File

@ -4,11 +4,13 @@ import {
DEFAULT_OO_AVATAR,
decodeOoAvatar,
OO_CLASSIC_SHAPE_COUNT,
OO_SUCCESS_SECONDS,
ooIdentityShapeIndex,
ooMomentAfterStatus,
ooMomentForStatus,
resolveOoColor,
} from "@rakazo/core";
import { memo, useId, useMemo } from "react";
import { memo, useEffect, useId, useMemo, useRef, useState } from "react";
import { normalizeEvooPose } from "./evoo-character/pose-map.js";
import type { EvooPose } from "./evoo-character/types.js";
import { cn } from "./lib/utils.js";
@ -55,6 +57,21 @@ export function ooMomentForPose(pose: EvooPose): OoMoment {
return POSE_MOMENT[canonical] ?? (canonical as OoMoment);
}
/** A short moment after a status change, such as joy when a run finishes. */
export function useOoStatusMoment(status?: string): OoMoment | null {
const previous = useRef(status);
const [moment, setMoment] = useState<OoMoment | null>(null);
useEffect(() => {
const next = ooMomentAfterStatus(previous.current, status);
previous.current = status;
setMoment(next);
if (!next) return;
const timer = setTimeout(() => setMoment(null), OO_SUCCESS_SECONDS * 1000);
return () => clearTimeout(timer);
}, [status]);
return moment;
}
export interface BotAvatarProps {
color: string;
size?: number;
@ -66,6 +83,8 @@ export interface BotAvatarProps {
moment?: OoMoment;
/** Draw one still frame. */
still?: boolean;
/** Eyes follow the pointer and a tap boops the Oo. */
interactive?: boolean;
className?: string;
}
@ -106,6 +125,7 @@ export const BotAvatar = memo(function BotAvatar({
pose,
moment,
still,
interactive,
className,
}: BotAvatarProps) {
const isWorking = ACTIVE_RUN_STATUSES.some((s) => s === status);
@ -119,7 +139,9 @@ export const BotAvatar = memo(function BotAvatar({
() => resolveOoColor(effectiveId, parsed.color),
[effectiveId, parsed.color],
);
const activeMoment = moment ?? (pose ? ooMomentForPose(pose) : ooMomentForStatus(status));
const statusMoment = useOoStatusMoment(status);
const activeMoment =
moment ?? statusMoment ?? (pose ? ooMomentForPose(pose) : ooMomentForStatus(status));
if (parsed.isImage && parsed.imageUrl) {
return (
@ -154,6 +176,7 @@ export const BotAvatar = memo(function BotAvatar({
moment={activeMoment}
size={size}
still={still}
interactive={interactive}
/>
</div>
);

View File

@ -2,14 +2,17 @@ import type { OoAvatar, OoFrame, OoLife, OoMoment, OoPaint, OoShape } from "@rak
import {
buildOoRig,
createOoLife,
OO_LIST_MOMENTS,
OO_RIG_INSET,
OO_RIG_SIZE,
OO_STILL_TIME,
ooBoop,
ooFrame,
ooLodForSize,
ooSeededRandom,
} from "@rakazo/core";
import { type ReactNode, useEffect, useId, useMemo, useRef } from "react";
import type { PointerEvent, ReactNode } from "react";
import { useEffect, useId, useMemo, useRef } from "react";
export interface OoFigureProps {
avatar: OoAvatar;
@ -21,12 +24,17 @@ export interface OoFigureProps {
size: number;
/** Draw one still frame (thumbnails, pickers). Reduced motion always draws still. */
still?: boolean;
/** Eyes follow the pointer and a tap boops the Oo. */
interactive?: boolean;
className?: string;
}
/** The body fills the avatar box; ears, hats and effects may reach past it. */
const OO_VIEWBOX = `${OO_RIG_INSET} ${OO_RIG_INSET + 4} ${OO_RIG_SIZE - 2 * OO_RIG_INSET} ${OO_RIG_SIZE - 2 * OO_RIG_INSET}`;
/** Above this size an Oo always moves; smaller ones only for list moments. */
const LIVELY_SIZE = 56;
/* ---------- shared ticker: one animation frame loop for every visible Oo ---------- */
type Tick = (now: number) => void;
@ -199,6 +207,7 @@ export function OoFigure({
moment,
size,
still = false,
interactive = false,
className,
}: OoFigureProps) {
const gradient = `oo${useId().replace(/[^a-zA-Z0-9-_]/g, "")}`;
@ -222,14 +231,20 @@ export function OoFigure({
);
const svgRef = useRef<SVGSVGElement>(null);
const lifeRef = useRef<OoLife | null>(null);
const startRef = useRef(0);
// Small Oos in lists only move for the moments people watch for.
const lively = !still && (size > LIVELY_SIZE || OO_LIST_MOMENTS.includes(moment));
useEffect(() => {
const svg = svgRef.current;
if (!svg || still || prefersReducedMotion() || typeof requestAnimationFrame !== "function")
if (!svg || !lively || prefersReducedMotion() || typeof requestAnimationFrame !== "function")
return;
if (!lifeRef.current) lifeRef.current = createOoLife();
if (!lifeRef.current) {
lifeRef.current = createOoLife();
startRef.current = performance.now() / 1000 - Math.random() * 10;
}
const life = lifeRef.current;
const start = performance.now() / 1000 - Math.random() * 10;
const start = startRef.current;
const tick = (now: number) =>
applyFrame(svg, ooFrame({ rig, avatar, moment, t: now - start, life }), rig.mouthColor);
let unsubscribe: (() => void) | null = null;
@ -249,7 +264,26 @@ export function OoFigure({
observer.disconnect();
unsubscribe?.();
};
}, [rig, avatar, moment, still]);
}, [rig, avatar, moment, lively]);
const pointerHandlers = interactive
? {
onPointerMove: (event: PointerEvent<SVGSVGElement>) => {
const life = lifeRef.current;
if (!life || lod !== "full") return;
const box = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - box.left) / box.width) * 2 - 1;
const y = ((event.clientY - box.top) / box.height) * 2 - 1;
life.pointer = { x: Math.max(-1, Math.min(1, x)), y: Math.max(-1, Math.min(1, y)) };
},
onPointerLeave: () => {
if (lifeRef.current) lifeRef.current.pointer = null;
},
onPointerDown: () => {
if (lifeRef.current) ooBoop(lifeRef.current, performance.now() / 1000 - startRef.current);
},
}
: {};
const { colors } = rig;
return (
@ -263,6 +297,7 @@ export function OoFigure({
data-oo-species={avatar.species}
data-oo-moment={moment}
overflow="visible"
{...pointerHandlers}
>
<defs>
<linearGradient id={`${gradient}-body`} x1="0" y1="0" x2="1" y2="1">