From 55df4a64391c6c6065aa639e71e011c643e6d581 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:51:37 +0200 Subject: [PATCH] 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 --- apps/mobile/components/bot-avatar.tsx | 22 ++++++++- apps/web/src/components/stage/StageHero.tsx | 1 + apps/web/src/pages/shell/transcript.tsx | 8 +++- packages/core/src/oo-motion.ts | 16 +++++++ packages/core/src/oo-rig.test.ts | 18 +++++++- .../ui-web/src/bot-avatar-moment.test.tsx | 42 +++++++++++++++++ packages/ui-web/src/bot-avatar.tsx | 27 ++++++++++- packages/ui-web/src/oo-figure.tsx | 45 ++++++++++++++++--- 8 files changed, 167 insertions(+), 12 deletions(-) create mode 100644 packages/ui-web/src/bot-avatar-moment.test.tsx diff --git a/apps/mobile/components/bot-avatar.tsx b/apps/mobile/components/bot-avatar.tsx index 4e3eb43..7d1fa44 100644 --- a/apps/mobile/components/bot-avatar.tsx +++ b/apps/mobile/components/bot-avatar.tsx @@ -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(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 ( {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} /> diff --git a/apps/web/src/components/stage/StageHero.tsx b/apps/web/src/components/stage/StageHero.tsx index 6eaf225..daaf0b7 100644 --- a/apps/web/src/components/stage/StageHero.tsx +++ b/apps/web/src/components/stage/StageHero.tsx @@ -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)]" /> diff --git a/apps/web/src/pages/shell/transcript.tsx b/apps/web/src/pages/shell/transcript.tsx index 57fe3a6..100a10e 100644 --- a/apps/web/src/pages/shell/transcript.tsx +++ b/apps/web/src/pages/shell/transcript.tsx @@ -19,7 +19,6 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - EvooMascot, Popover, PopoverContent, PopoverTrigger, @@ -795,7 +794,12 @@ const MessageView = memo(function MessageView({
- +
{ 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(); + }); +}); diff --git a/packages/ui-web/src/bot-avatar-moment.test.tsx b/packages/ui-web/src/bot-avatar-moment.test.tsx new file mode 100644 index 0000000..14b2590 --- /dev/null +++ b/packages/ui-web/src/bot-avatar-moment.test.tsx @@ -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()); + expect(moment()).toBe("working"); + act(() => root.render()); + expect(moment()).toBe("success"); + act(() => vi.advanceTimersByTime(3000)); + expect(moment()).toBe("idle"); +}); + +it("does not celebrate a failed run", () => { + act(() => root.render()); + act(() => root.render()); + expect(moment()).toBe("concerned"); +}); diff --git a/packages/ui-web/src/bot-avatar.tsx b/packages/ui-web/src/bot-avatar.tsx index 48ca7c9..ecda3b8 100644 --- a/packages/ui-web/src/bot-avatar.tsx +++ b/packages/ui-web/src/bot-avatar.tsx @@ -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(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} />
); diff --git a/packages/ui-web/src/oo-figure.tsx b/packages/ui-web/src/oo-figure.tsx index 9cd7095..0cd5e5b 100644 --- a/packages/ui-web/src/oo-figure.tsx +++ b/packages/ui-web/src/oo-figure.tsx @@ -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(null); const lifeRef = useRef(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) => { + 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} >