4aae61297f
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files
Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
141 lines
4.2 KiB
TypeScript
141 lines
4.2 KiB
TypeScript
/**
|
||
* @file PetSprite.tsx
|
||
* @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is
|
||
* a 1536×1872 spritesheet — 8 columns × 9 rows of 192×208 frames, one row per
|
||
* animation state (idle, run right, run left, waving, jumping, failed,
|
||
* waiting, running, review) — played one row at a time by Sagi's mood; an
|
||
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
||
* Sagi. An `action` (dragged left/right, hovered) plays the run-left/run-right
|
||
* or waving row over the mood's, like Codex's own pet. Each row steps at its
|
||
* own pace and holds its last frame longer, so a loop breathes instead of
|
||
* spinning. Reduced motion holds the row's first frame. A sprite that fails to
|
||
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
||
* back to Sagi instead of an empty box.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { api } from "../../lib/api";
|
||
import type { Mood } from "./brain";
|
||
|
||
const CELL = { w: 192, h: 208 };
|
||
const SHEET = { cols: 8, rows: 9 };
|
||
/** Frames in each row of the Codex atlas. */
|
||
const FRAMES = [6, 8, 8, 4, 5, 8, 6, 6, 6];
|
||
/** Which row plays for each mood. */
|
||
const ROW: Record<Mood, number> = {
|
||
idle: 0,
|
||
sleeping: 0,
|
||
thinking: 7, // running — an agent is working
|
||
watching: 8, // review
|
||
happy: 4, // jumping
|
||
stuck: 6, // waiting — for the user
|
||
worried: 5, // failed
|
||
disconnected: 5,
|
||
};
|
||
/** What the user is doing to the pet, which outranks its mood. */
|
||
export type PetAction = "run-left" | "run-right" | "wave";
|
||
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
|
||
/** Per row: ms per frame, and ms the last frame is held before the loop restarts. */
|
||
const PACE: [number, number][] = [
|
||
[220, 1400], // idle: a breath, then a pause
|
||
[120, 120], // run right — keeps up with the drag
|
||
[120, 120], // run left
|
||
[180, 400], // waving
|
||
[160, 700], // jumping
|
||
[200, 1200], // failed
|
||
[220, 900], // waiting
|
||
[170, 400], // running (working)
|
||
[220, 900], // review
|
||
];
|
||
const SLEEP_PACE: [number, number] = [450, 2000];
|
||
|
||
export function PetSprite({
|
||
id,
|
||
kind,
|
||
mood,
|
||
action,
|
||
size,
|
||
reducedMotion,
|
||
onBroken,
|
||
}: {
|
||
id: string;
|
||
kind: "codex" | "image";
|
||
mood: Mood;
|
||
action?: PetAction | null;
|
||
size: number;
|
||
reducedMotion: boolean;
|
||
onBroken: () => void;
|
||
}) {
|
||
const url = api.pets.spriteUrl(id);
|
||
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
|
||
const sleeping = !action && mood === "sleeping";
|
||
const [frame, setFrame] = useState(0);
|
||
|
||
useEffect(() => {
|
||
setFrame(0);
|
||
if (kind !== "codex" || reducedMotion) return;
|
||
const count = FRAMES[row] ?? 1;
|
||
const [step, hold] = sleeping ? SLEEP_PACE : (PACE[row] ?? [200, 200]);
|
||
let f = 0;
|
||
let timer = 0;
|
||
const tick = () => {
|
||
timer = window.setTimeout(
|
||
() => {
|
||
f = (f + 1) % count;
|
||
setFrame(f);
|
||
tick();
|
||
},
|
||
f === count - 1 ? hold : step
|
||
);
|
||
};
|
||
tick();
|
||
return () => window.clearTimeout(timer);
|
||
}, [kind, row, sleeping, reducedMotion]);
|
||
|
||
// A background image reports no error of its own: probe it once.
|
||
useEffect(() => {
|
||
if (kind !== "codex") return;
|
||
const probe = new Image();
|
||
probe.onerror = onBroken;
|
||
probe.src = url;
|
||
return () => {
|
||
probe.onerror = null;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [url, kind]);
|
||
|
||
if (kind === "image") {
|
||
return (
|
||
<img
|
||
src={url}
|
||
alt=""
|
||
data-mood={mood}
|
||
className="sagi-pet-image"
|
||
width={size}
|
||
height={size}
|
||
draggable={false}
|
||
onError={onBroken}
|
||
style={{ width: size, height: size, objectFit: "contain" }}
|
||
/>
|
||
);
|
||
}
|
||
const scale = size / CELL.h;
|
||
return (
|
||
<div
|
||
data-testid="pet-sprite"
|
||
data-row={row}
|
||
aria-hidden
|
||
style={{
|
||
width: Math.round(CELL.w * scale),
|
||
height: size,
|
||
margin: "0 auto",
|
||
backgroundImage: `url("${url}")`,
|
||
backgroundRepeat: "no-repeat",
|
||
backgroundSize: `${SHEET.cols * CELL.w * scale}px ${SHEET.rows * CELL.h * scale}px`,
|
||
backgroundPosition: `${-frame * CELL.w * scale}px ${-row * CELL.h * scale}px`,
|
||
}}
|
||
/>
|
||
);
|
||
}
|