a9c71cd905
Usage panel (status bar): - every account shows its plan (Claude subscriptionType, Codex chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier) - new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's own file, sent only to its own host while valid, never refreshed - in-flight quota reads are shared between tabs AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added; Gemini CLI, Hermes and OpenClaw removed. Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat on an editor selection, Settings in three groups, JetBrains-style Changes panel (ticked files/hunks, ignore, blame, file history), terminal font. --no-verify: :3000 is taken by another dev server (lane-detect tests); both suites run by hand — server 2636/2639 (the 3 are :3000), client 1131/1131.
169 lines
5.7 KiB
TypeScript
169 lines
5.7 KiB
TypeScript
/**
|
||
* @file PetSprite.tsx
|
||
* @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is
|
||
* a spritesheet of 192×208 frames, 8 columns wide: 9 rows (v1, 1536×1872),
|
||
* one per animation state (idle, run right, run left, waving, jumping, failed,
|
||
* waiting, running, review), or 11 (v2, 1536×2288), whose rows 9–10 are 16
|
||
* gaze poses. The sprite plays the row of Sagi's mood at Codex's own frame
|
||
* timings; a moment (waving, jumping, failed, review) plays three times and
|
||
* settles into idle, a condition loops. An `action` (dragged left/right,
|
||
* hovered) plays the run-left/run-right or waving row over the mood's, like
|
||
* Codex's own pet, and a v2 pet at idle holds the gaze pose `gaze` names. An
|
||
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
||
* Sagi. 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 COLS = 8;
|
||
/** `n` frames of `step` ms, the last one `last` ms. */
|
||
const frames = (n: number, step: number, last: number) =>
|
||
Array.from({ length: n }, (_, i) => (i === n - 1 ? last : step));
|
||
/** Per row, each frame's ms: Codex's own table (codex-rs/tui/src/pets/model.rs).
|
||
* Idle is the pace the pets are drawn at; Codex plays it 6× slower, which is
|
||
* how a sleeping pet breathes here. */
|
||
const DURATIONS = [
|
||
[280, 110, 110, 140, 140, 320], // idle
|
||
frames(8, 120, 220), // run right
|
||
frames(8, 120, 220), // run left
|
||
frames(4, 140, 280), // waving
|
||
frames(5, 140, 280), // jumping
|
||
frames(8, 140, 240), // failed
|
||
frames(6, 150, 260), // waiting
|
||
frames(6, 120, 220), // running (working)
|
||
frames(6, 150, 280), // review
|
||
];
|
||
const SLEEP_SLOWDOWN = 6;
|
||
/** Rows that play three times and settle into idle; the others loop. */
|
||
const MOMENTS = new Set([3, 4, 5, 8]);
|
||
/** v2 only: 16 gaze poses, 22.5° apart clockwise from straight up. */
|
||
const GAZE_ROW = 9;
|
||
/** 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 };
|
||
|
||
export function PetSprite({
|
||
id,
|
||
kind,
|
||
mood,
|
||
action,
|
||
gaze,
|
||
size,
|
||
reducedMotion,
|
||
onBroken,
|
||
}: {
|
||
id: string;
|
||
kind: "codex" | "image";
|
||
mood: Mood;
|
||
action?: PetAction | null;
|
||
/** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */
|
||
gaze?: number | 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";
|
||
// The cell on show, tagged with the row it was played for: a cell left over
|
||
// from the previous row (say column 7 of a run) is never drawn on the next.
|
||
const [cell, setCell] = useState({ of: row, row, col: 0 });
|
||
const [rows, setRows] = useState(9);
|
||
|
||
useEffect(() => {
|
||
setCell({ of: row, row, col: 0 });
|
||
if (kind !== "codex" || reducedMotion) return;
|
||
const play = (r: number, slow = 1) =>
|
||
(DURATIONS[r] ?? [200]).map((ms, col) => ({ of: row, row: r, col, ms: ms * slow }));
|
||
const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1);
|
||
let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own;
|
||
let i = 0;
|
||
let timer = 0;
|
||
// `i` always indexes `list`: it wraps at its end.
|
||
const tick = () => {
|
||
timer = window.setTimeout(() => {
|
||
if (++i === list.length) {
|
||
i = 0;
|
||
if (MOMENTS.has(row)) list = play(0);
|
||
}
|
||
setCell(list[i]!);
|
||
tick();
|
||
}, list[i]!.ms);
|
||
};
|
||
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;
|
||
// It also tells a v2 sheet (11 rows) from a v1 (9).
|
||
const probe = new Image();
|
||
probe.onload = () => setRows(Math.round(probe.naturalHeight / CELL.h) || 9);
|
||
probe.onerror = onBroken;
|
||
probe.src = url;
|
||
return () => {
|
||
probe.onload = null;
|
||
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 pose = gaze != null && rows > GAZE_ROW && !action && mood === "idle" ? gaze : null;
|
||
const shown =
|
||
pose != null
|
||
? { row: GAZE_ROW + Math.floor(pose / COLS), col: pose % COLS }
|
||
: cell.of === row
|
||
? cell
|
||
: { row, col: 0 };
|
||
const scale = size / CELL.h;
|
||
return (
|
||
<div
|
||
data-testid="pet-sprite"
|
||
data-row={shown.row}
|
||
aria-hidden
|
||
style={{
|
||
width: Math.round(CELL.w * scale),
|
||
height: size,
|
||
margin: "0 auto",
|
||
backgroundImage: `url("${url}")`,
|
||
backgroundRepeat: "no-repeat",
|
||
backgroundSize: `${COLS * CELL.w * scale}px ${rows * CELL.h * scale}px`,
|
||
backgroundPosition: `${-shown.col * CELL.w * scale}px ${-shown.row * CELL.h * scale}px`,
|
||
}}
|
||
/>
|
||
);
|
||
}
|