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>
222 lines
6.5 KiB
TypeScript
222 lines
6.5 KiB
TypeScript
import type { OoMoment } from "@rakazo/core";
|
|
import {
|
|
ACTIVE_RUN_STATUSES,
|
|
DEFAULT_OO_AVATAR,
|
|
decodeOoAvatar,
|
|
OO_CLASSIC_SHAPE_COUNT,
|
|
OO_SUCCESS_SECONDS,
|
|
ooIdentityShapeIndex,
|
|
ooMomentAfterStatus,
|
|
ooMomentForStatus,
|
|
resolveOoColor,
|
|
} from "@rakazo/core";
|
|
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";
|
|
import { OoFigure } from "./oo-figure.js";
|
|
import "./styles.css";
|
|
|
|
export function parseBotAvatar(
|
|
rawColor: string,
|
|
_identity?: string,
|
|
): {
|
|
color: string;
|
|
shapeIndex?: number;
|
|
isImage: boolean;
|
|
imageUrl?: string;
|
|
} {
|
|
if (!rawColor) return { color: "#F97316", isImage: false };
|
|
// Only data: image URLs are rendered. Arbitrary http(s)/blob values in `color`
|
|
// must not become <img src> (SSRF / tracking when other members view the bot).
|
|
if (rawColor.startsWith("data:image/")) {
|
|
return { color: "#F97316", isImage: true, imageUrl: rawColor };
|
|
}
|
|
const look = decodeOoAvatar(rawColor);
|
|
if (!look) return { color: "#F97316", isImage: false };
|
|
return {
|
|
color: look.color || "#F97316",
|
|
shapeIndex: look.shape ?? undefined,
|
|
isImage: false,
|
|
};
|
|
}
|
|
|
|
/** Mascot poses that have their own Oo moment under another name. */
|
|
const POSE_MOMENT: Partial<Record<EvooPose, OoMoment>> = {
|
|
analyzing: "reading",
|
|
skeptical: "thinking",
|
|
curious: "idle",
|
|
delighted: "success",
|
|
error: "concerned",
|
|
collaborating: "working",
|
|
};
|
|
|
|
/** Mascot pose → Oo moment; every canonical pose has one. */
|
|
export function ooMomentForPose(pose: EvooPose): OoMoment {
|
|
const canonical = normalizeEvooPose(pose);
|
|
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;
|
|
status?: string;
|
|
identity?: string;
|
|
/** Explicit mascot pose; when omitted, derived from `status`. */
|
|
pose?: EvooPose;
|
|
/** Explicit moment; wins over `pose` and `status`. */
|
|
moment?: OoMoment;
|
|
/** Draw one still frame. */
|
|
still?: boolean;
|
|
/** Eyes follow the pointer and a tap boops the Oo. */
|
|
interactive?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
function WorkingRing({ size, color }: { size: number; color: string }) {
|
|
const id = useId().replace(/[^a-zA-Z0-9-_]/g, "");
|
|
return (
|
|
<svg
|
|
className="rakazo-bot-avatar-ring absolute pointer-events-none"
|
|
style={{ inset: -4, width: size + 8, height: size + 8 }}
|
|
viewBox="0 0 48 48"
|
|
fill="none"
|
|
aria-hidden="true"
|
|
>
|
|
<circle
|
|
cx="24"
|
|
cy="24"
|
|
r="22"
|
|
stroke={`url(#${id}-ring)`}
|
|
strokeWidth="3.2"
|
|
strokeLinecap="round"
|
|
strokeDasharray="45 80"
|
|
/>
|
|
<defs>
|
|
<linearGradient id={`${id}-ring`} x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" stopColor={color} stopOpacity="1" />
|
|
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
|
</linearGradient>
|
|
</defs>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export const BotAvatar = memo(function BotAvatar({
|
|
color,
|
|
size = 36,
|
|
status,
|
|
identity = "",
|
|
pose,
|
|
moment,
|
|
still,
|
|
interactive,
|
|
className,
|
|
}: BotAvatarProps) {
|
|
const isWorking = ACTIVE_RUN_STATUSES.some((s) => s === status);
|
|
const parsed = useMemo(() => parseBotAvatar(color, identity), [color, identity]);
|
|
const look = useMemo(
|
|
() => (parsed.isImage ? null : (decodeOoAvatar(color) ?? DEFAULT_OO_AVATAR)),
|
|
[color, parsed.isImage],
|
|
);
|
|
const effectiveId = identity || parsed.color || "agent";
|
|
const colorDef = useMemo(
|
|
() => resolveOoColor(effectiveId, parsed.color),
|
|
[effectiveId, parsed.color],
|
|
);
|
|
const statusMoment = useOoStatusMoment(status);
|
|
const activeMoment =
|
|
moment ?? statusMoment ?? (pose ? ooMomentForPose(pose) : ooMomentForStatus(status));
|
|
|
|
if (parsed.isImage && parsed.imageUrl) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"rakazo-bot-avatar relative overflow-hidden rounded-full flex items-center justify-center select-none bg-secondary shrink-0 border border-border",
|
|
className,
|
|
)}
|
|
data-working={isWorking}
|
|
style={{ width: size, height: size }}
|
|
>
|
|
{isWorking ? <WorkingRing size={size} color={colorDef.light} /> : null}
|
|
<img src={parsed.imageUrl} alt="" className="h-full w-full object-cover" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"rakazo-bot-avatar relative inline-flex items-center justify-center shrink-0 select-none",
|
|
className,
|
|
)}
|
|
style={{ width: size, height: size }}
|
|
data-working={isWorking}
|
|
>
|
|
{isWorking ? <WorkingRing size={size} color={colorDef.light} /> : null}
|
|
<OoFigure
|
|
avatar={look ?? DEFAULT_OO_AVATAR}
|
|
color={colorDef.hex}
|
|
fallbackShape={look?.shape ?? ooIdentityShapeIndex(effectiveId)}
|
|
moment={activeMoment}
|
|
size={size}
|
|
still={still}
|
|
interactive={interactive}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
/** A classic silhouette in the picker, drawn as the Oo it would become. */
|
|
export function OoShapePreview({
|
|
shapeIndex,
|
|
color,
|
|
selected,
|
|
onClick,
|
|
}: {
|
|
shapeIndex: number;
|
|
color: string;
|
|
selected?: boolean;
|
|
onClick?: () => void;
|
|
}) {
|
|
const colorDef = resolveOoColor("preview", color);
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-label="EvOo"
|
|
aria-pressed={selected ?? false}
|
|
className={cn(
|
|
"relative flex size-11 items-center justify-center rounded-xl transition-transform hover:scale-105 active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover",
|
|
selected
|
|
? "ring-2 ring-primary ring-offset-2 ring-offset-popover bg-foreground/10"
|
|
: "hover:bg-foreground/5",
|
|
)}
|
|
>
|
|
<OoFigure
|
|
avatar={DEFAULT_OO_AVATAR}
|
|
color={colorDef.hex}
|
|
fallbackShape={shapeIndex % OO_CLASSIC_SHAPE_COUNT}
|
|
moment="idle"
|
|
size={40}
|
|
still
|
|
/>
|
|
</button>
|
|
);
|
|
}
|