feat(sagi): custom pets — Codex pet bundles, images, featured downloads

The mascot can wear a pet. Right-click it for Hide pet, Size (48-250
px), Choose pet (Sagi, featured, your own - deletable), Upload your
own..., Import .codex-pet bundle..., Find more on codex-pets.net and
Pet settings.... The worn pet and its size are viewer prefs
(agenthub.pet, agenthub.pet.size); the pets live on the server under
<data dir>/pets/<id>/ (server/lib/pets.js, /api/pets).

A Codex pet is the .codex-pet.zip format: pet.json + a 1536x1872
spritesheet.webp, 8x9 frames of 192x208, one row per state.
PetSprite.tsx plays the row of the mascot's mood; an uploaded image
bobs. Only the two bundle files are read, sizes are checked from the
zip directory before inflating, the sheet's WebP header must match,
uploads are raster only (no SVG), ids are made by the server, every
write is same-origin.

The four featured pets (Goku, Bac Ho, Border Collie, 11) are
codex-pets.net uploads with no redistribution licence, so none is
bundled: a pick downloads it from codex-pets.net by a fixed id.
This commit is contained in:
2026-10-03 16:25:42 +07:00
parent 8098f446b6
commit ed1bf20e6b
18 changed files with 1615 additions and 55 deletions
+314
View File
@@ -0,0 +1,314 @@
/**
* @file PetMenu.tsx
* @description The mascot's right-click menu: hide it, size it, choose the
* pet it wears — Sagi, a featured pet (downloaded from codex-pets.net into
* this install on the first pick, `POST /api/pets/featured/:id`), or one of
* your own (deletable) — upload an image or import a `.codex-pet.zip`, browse
* codex-pets.net for more, and open the Sagi settings. The choice and the size
* are viewer preferences (`prefs.ts`); the pets themselves live on the server
* (`server/lib/pets.js`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Check,
ChevronDown,
ChevronRight,
Download,
EyeOff,
ExternalLink,
Loader2,
PackageOpen,
Settings2,
Trash2,
Upload,
} from "lucide-react";
import { api, type PetList, type PetView } from "../../lib/api";
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
const ITEM =
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary disabled:opacity-50";
export function PetMenu({
pet,
size,
onClose,
onSettings,
}: {
pet: PetChoice;
size: number;
onClose: () => void;
onSettings: () => void;
}) {
const { t } = useTranslation("settings");
const ref = useRef<HTMLDivElement>(null);
const imageInput = useRef<HTMLInputElement>(null);
const bundleInput = useRef<HTMLInputElement>(null);
const [list, setList] = useState<PetList | null>(null);
const [choosing, setChoosing] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const reload = () =>
api.pets
.list()
.then(setList)
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)));
useEffect(() => {
void reload();
}, []);
// Closes on a press outside the menu, or Escape.
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [onClose]);
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind });
const run = async (key: string, work: () => Promise<void>) => {
setBusy(key);
setError(null);
try {
await work();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
}
};
const addFile = (file: File | undefined, bundle: boolean) =>
file &&
void run(bundle ? "bundle" : "image", async () => {
const { pet: added } = bundle
? await api.pets.importBundle(file)
: await api.pets.upload(file);
wear(added);
await reload();
});
const own = list?.pets.filter((p) => p.source === "upload") ?? [];
const installed = new Map(
(list?.pets ?? []).filter((p) => p.featuredId).map((p) => [p.featuredId as string, p])
);
const mark = (id: string) =>
pet.id === id ? <Check className="ml-auto h-3.5 w-3.5 shrink-0 text-accent" /> : null;
return (
<div
ref={ref}
role="menu"
data-testid="pet-menu"
className="w-72 rounded-xl border border-border bg-surface-2 p-1.5 text-sm shadow-lg"
>
<div className="px-2.5 pb-1 pt-0.5 text-xs font-medium text-fg-muted">
{t("sagi.pet.title")}
</div>
<button
type="button"
role="menuitem"
className={ITEM}
onClick={() => {
sagiPrefs.setEnabled(false);
onClose();
}}
>
<EyeOff className="h-4 w-4" />
{t("sagi.pet.hide")}
</button>
<label className="block px-2.5 py-1.5">
<span className="flex items-center justify-between text-sm text-fg-secondary">
{t("sagi.pet.size")}
<span className="tabular-nums text-xs text-fg-muted">{size}px</span>
</span>
<input
type="range"
data-testid="pet-size"
min={PET_SIZE.min}
max={PET_SIZE.max}
value={size}
onChange={(e) => sagiPrefs.setSize(Number(e.target.value))}
className="mt-1 w-full accent-[rgb(var(--accent))]"
/>
</label>
<div className="my-1.5 border-t border-border" />
<button
type="button"
role="menuitem"
aria-expanded={choosing}
data-testid="pet-choose"
className={ITEM}
onClick={() => setChoosing((v) => !v)}
>
{choosing ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
{t("sagi.pet.choose")}
</button>
{choosing && (
<div className="max-h-80 overflow-y-auto pl-2">
<button
type="button"
role="menuitemradio"
aria-checked={pet.id === "sagi"}
className={ITEM}
onClick={() => sagiPrefs.setPet({ id: "sagi" })}
>
Sagi
{mark("sagi")}
</button>
{list?.featured.map((f) => {
const have = installed.get(f.id);
return (
<button
key={f.id}
type="button"
role="menuitemradio"
aria-checked={!!have && pet.id === have.id}
data-testid={`pet-featured-${f.id}`}
disabled={busy !== null}
title={t("sagi.pet.featuredBy", { by: f.by })}
className={ITEM}
onClick={() =>
have
? wear(have)
: void run(f.id, async () => {
wear((await api.pets.installFeatured(f.id)).pet);
await reload();
})
}
>
<span className="min-w-0 truncate">{f.displayName}</span>
{busy === f.id ? (
<Loader2 className="ml-auto h-3.5 w-3.5 shrink-0 animate-spin" />
) : have ? (
mark(have.id)
) : (
<Download
aria-label={t("sagi.pet.download")}
className="ml-auto h-3.5 w-3.5 shrink-0 text-fg-muted"
/>
)}
</button>
);
})}
{own.length > 0 && <div className="my-1.5 border-t border-border" />}
{own.map((p) => (
<div key={p.id} className="group flex items-center">
<button
type="button"
role="menuitemradio"
aria-checked={pet.id === p.id}
data-testid={`pet-own-${p.id}`}
className={ITEM}
onClick={() => wear(p)}
>
<span className="min-w-0 truncate">{p.displayName}</span>
{mark(p.id)}
</button>
<button
type="button"
data-testid={`pet-delete-${p.id}`}
aria-label={t("sagi.pet.delete", { name: p.displayName })}
title={t("sagi.pet.delete", { name: p.displayName })}
disabled={busy !== null}
onClick={() =>
void run(p.id, async () => {
await api.pets.remove(p.id);
if (pet.id === p.id) sagiPrefs.setPet({ id: "sagi" });
await reload();
})
}
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
<div className="my-1.5 border-t border-border" />
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<PackageOpen className="h-4 w-4" />
)}
{t("sagi.pet.import")}
</button>
<a
href="https://codex-pets.net/"
target="_blank"
rel="noreferrer noopener"
className={`${ITEM} !text-fg-muted`}
>
<ExternalLink className="h-4 w-4" />
{t("sagi.pet.browse")}
</a>
</div>
)}
<input
ref={imageInput}
type="file"
hidden
data-testid="pet-image-input"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={(e) => {
addFile(e.target.files?.[0], false);
e.target.value = "";
}}
/>
<input
ref={bundleInput}
type="file"
hidden
data-testid="pet-bundle-input"
accept=".zip,application/zip"
onChange={(e) => {
addFile(e.target.files?.[0], true);
e.target.value = "";
}}
/>
{error && (
<p role="alert" className="px-2.5 py-1 text-xs text-status-danger">
{error}
</p>
)}
<div className="my-1.5 border-t border-border" />
<button
type="button"
role="menuitem"
className={`${ITEM} !text-fg-muted`}
onClick={onSettings}
>
<Settings2 className="h-4 w-4" />
{t("sagi.pet.settings")}
</button>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
/**
* @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. 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,
};
export function PetSprite({
id,
kind,
mood,
size,
reducedMotion,
onBroken,
}: {
id: string;
kind: "codex" | "image";
mood: Mood;
size: number;
reducedMotion: boolean;
onBroken: () => void;
}) {
const url = api.pets.spriteUrl(id);
const row = ROW[mood] ?? 0;
const [frame, setFrame] = useState(0);
useEffect(() => {
setFrame(0);
if (kind !== "codex" || reducedMotion) return;
const timer = window.setInterval(
() => setFrame((f) => (f + 1) % (FRAMES[row] ?? 1)),
mood === "sleeping" ? 400 : 125
);
return () => window.clearInterval(timer);
}, [kind, row, mood, 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`,
}}
/>
);
}
+65 -9
View File
@@ -71,10 +71,12 @@ import {
} from "react";
import { useNavigate } from "react-router-dom";
import { SagiAvatar } from "./SagiAvatar";
import { PetSprite } from "./PetSprite";
import { PetMenu } from "./PetMenu";
import { SpeechBubble } from "./SpeechBubble";
import { SagiPanel } from "./SagiPanel";
import { useSagiBrain } from "./useSagiBrain";
import { useSagiPosition, SAGI_SIZE } from "./useSagiPosition";
import { useSagiPosition } from "./useSagiPosition";
import { matchIntent } from "./intents";
import { sagiPrefs } from "./prefs";
import { api } from "../../lib/api";
@@ -114,7 +116,16 @@ function usePrefersReducedMotion(): boolean {
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
* into view instead of being cropped.
*/
function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode }) {
/** `front`: above the mascot itself — the pet menu, which may overlap it. */
function SagiFlyout({
anchor,
children,
front = false,
}: {
anchor: Anchor;
children: ReactNode;
front?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
@@ -160,6 +171,7 @@ function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode
// Mirrors the avatar button: eased left/top so the flyout glides along
// with the mascot when it snaps to an edge, 1:1 (no easing) mid-drag.
data-dragging={anchor.dragging ? "1" : "0"}
data-front={front ? "1" : undefined}
style={style}
>
{children}
@@ -169,14 +181,26 @@ function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode
export function Sagi() {
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
const [pet, setPet] = useState(() => sagiPrefs.getPet());
const [size, setSize] = useState(() => sagiPrefs.getSize());
const [open, setOpen] = useState(false);
// The right-click pet menu (`PetMenu.tsx`).
const [menu, setMenu] = useState(false);
const reducedMotion = usePrefersReducedMotion();
const navigate = useNavigate();
const brain = useSagiBrain();
const place = useSagiPosition();
const place = useSagiPosition(size);
// Keep enabled in sync with Settings / other tabs.
useEffect(() => sagiPrefs.subscribe(() => setEnabled(sagiPrefs.getEnabled())), []);
// Keep enabled, the pet and its size in sync with Settings / other tabs.
useEffect(
() =>
sagiPrefs.subscribe(() => {
setEnabled(sagiPrefs.getEnabled());
setPet(sagiPrefs.getPet());
setSize(sagiPrefs.getSize());
}),
[]
);
// ⌘B / Ctrl+B toggles the panel; Esc closes it.
useEffect(() => {
@@ -263,7 +287,21 @@ export function Sagi() {
</SagiFlyout>
)}
{!open && brain.bubble && (
{!place.dragging && menu && (
<SagiFlyout anchor={anchor} front>
<PetMenu
pet={pet}
size={size}
onClose={() => setMenu(false)}
onSettings={() => {
setMenu(false);
navigate("/settings/general#sagi");
}}
/>
</SagiFlyout>
)}
{!open && !menu && brain.bubble && (
<SagiFlyout anchor={anchor}>
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
</SagiFlyout>
@@ -272,7 +310,7 @@ export function Sagi() {
<button
className="sagi-avatar-btn"
data-dragging={place.dragging ? "1" : "0"}
style={{ left: place.left, top: place.top, width: SAGI_SIZE, height: SAGI_SIZE }}
style={{ left: place.left, top: place.top, width: size, height: size }}
onPointerDown={place.onPointerDown}
onPointerMove={place.onPointerMove}
onPointerUp={place.onPointerUp}
@@ -282,11 +320,29 @@ export function Sagi() {
if (place.consumeDrag()) return;
setOpen((v) => !v);
}}
// Right-click: the pet menu (hide, size, choose, upload, import).
onContextMenu={(e) => {
e.preventDefault();
setOpen(false);
setMenu(true);
}}
aria-label={open ? "Close Sagi" : "Open Sagi companion"}
aria-expanded={open}
title="Sagi - ⌘B · drag to move"
title="Sagi - ⌘B · drag to move · right-click for pets"
>
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} />
{pet.id === "sagi" || !("kind" in pet) ? (
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} size={size} />
) : (
<PetSprite
id={pet.id}
kind={pet.kind}
mood={brain.mood}
size={size}
reducedMotion={reducedMotion}
// Deleted elsewhere, or unreadable: back to Sagi.
onBroken={() => sagiPrefs.setPet({ id: "sagi" })}
/>
)}
{brain.status.errorCount > 0 && (
<span className="sagi-error-dot" aria-hidden>
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
@@ -0,0 +1,177 @@
/**
* @file Pets.test.tsx
* @description The mascot's custom pets: `PetSprite` plays the spritesheet
* row of Sagi's mood and steps through its frames (a still image bobs instead),
* and `PetMenu` hides the mascot, sizes it, picks Sagi / a featured pet
* (downloading it on the first pick) / an own pet, deletes an own pet (falling
* back to Sagi when it was the one worn), and uploads an image.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const own = {
id: "p-aaaaaaaaaaaa",
displayName: "My cat",
description: "",
kind: "image",
source: "upload",
};
return {
...actual,
api: {
pets: {
spriteUrl: (id: string) => `/api/pets/${id}/sprite`,
list: vi.fn(async () => ({
pets: [own],
featured: [
{ id: "goku", displayName: "Goku", by: "hackminit", installed: false, page: "" },
],
sheet: { width: 1536, height: 1872, cellWidth: 192, cellHeight: 208 },
})),
installFeatured: vi.fn(async () => ({
pet: {
id: "cp-goku",
displayName: "Goku",
description: "",
kind: "codex",
source: "featured",
featuredId: "goku",
},
})),
upload: vi.fn(async () => ({
pet: {
id: "p-bbbbbbbbbbbb",
displayName: "Dog",
description: "",
kind: "image",
source: "upload",
},
})),
importBundle: vi.fn(),
remove: vi.fn(async () => ({ ok: true })),
},
},
};
});
import { api } from "../../../lib/api";
import { PetSprite } from "../PetSprite";
import { PetMenu } from "../PetMenu";
import { sagiPrefs } from "../prefs";
describe("PetSprite", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("plays the row of the mood and steps through its frames", () => {
const { rerender } = render(
<PetSprite
id="cp-goku"
kind="codex"
mood="thinking"
size={104}
reducedMotion={false}
onBroken={() => {}}
/>
);
const sprite = screen.getByTestId("pet-sprite");
// 104 px tall = half a 208 px cell: row 7 (running) starts 7 × 104 px down.
expect(sprite.dataset.row).toBe("7");
expect(sprite.style.backgroundPosition).toBe("0px -728px");
act(() => vi.advanceTimersByTime(130));
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
rerender(
<PetSprite
id="cp-goku"
kind="codex"
mood="worried"
size={104}
reducedMotion={false}
onBroken={() => {}}
/>
);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("5");
});
it("holds the first frame under reduced motion", () => {
render(
<PetSprite
id="cp-goku"
kind="codex"
mood="idle"
size={208}
reducedMotion
onBroken={() => {}}
/>
);
act(() => vi.advanceTimersByTime(1000));
expect(screen.getByTestId("pet-sprite").style.backgroundPosition).toBe("0px 0px");
});
it("shows an uploaded image still, and reports one that will not load", () => {
const broken = vi.fn();
const { container } = render(
<PetSprite
id="p-aaaaaaaaaaaa"
kind="image"
mood="idle"
size={80}
reducedMotion={false}
onBroken={broken}
/>
);
const img = container.querySelector("img") as HTMLImageElement;
expect(img.getAttribute("src")).toBe("/api/pets/p-aaaaaaaaaaaa/sprite");
fireEvent.error(img);
expect(broken).toHaveBeenCalled();
});
});
describe("PetMenu", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
const renderMenu = () =>
render(<PetMenu pet={sagiPrefs.getPet()} size={68} onClose={() => {}} onSettings={() => {}} />);
it("hides the mascot and sizes it", () => {
renderMenu();
fireEvent.change(screen.getByTestId("pet-size"), { target: { value: "250" } });
expect(sagiPrefs.getSize()).toBe(250);
fireEvent.click(screen.getByText("Hide pet"));
expect(sagiPrefs.getEnabled()).toBe(false);
});
it("downloads a featured pet on its first pick, then wears it", async () => {
renderMenu();
fireEvent.click(screen.getByTestId("pet-choose"));
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
});
it("deleting the pet being worn puts Sagi back", async () => {
sagiPrefs.setPet({ id: "p-aaaaaaaaaaaa", kind: "image" });
renderMenu();
fireEvent.click(screen.getByTestId("pet-choose"));
fireEvent.click(await screen.findByTestId("pet-delete-p-aaaaaaaaaaaa"));
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
});
it("uploads an image and wears it", async () => {
renderMenu();
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "Dog.png", {
type: "image/png",
});
fireEvent.change(screen.getByTestId("pet-image-input"), { target: { files: [file] } });
await waitFor(() =>
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
);
expect(api.pets.upload).toHaveBeenCalledWith(file);
});
});
+66 -3
View File
@@ -1,8 +1,9 @@
/**
* @file prefs.ts
* @description Tiny localStorage-backed preference store for Sagi (enabled +
* muted). Broadcasts changes via a window CustomEvent so the Settings toggle
* and the live widget stay in sync within the same tab without a reload.
* @description Tiny localStorage-backed preference store for Sagi (enabled,
* muted, the pet it wears and its size). Broadcasts changes via a window
* CustomEvent so the Settings toggle and the live widget stay in sync within
* the same tab without a reload.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -53,6 +54,54 @@
const ENABLED_KEY = "agent-dashboard-sagi-enabled";
const MUTED_KEY = "agent-dashboard-sagi-muted";
const POS_KEY = "agent-dashboard-sagi-pos";
const PET_KEY = "agenthub.pet";
const SIZE_KEY = "agenthub.pet.size";
/** Sagi's own size, and the slider's range. */
export const PET_SIZE = { default: 68, min: 48, max: 250 };
/** The pet the mascot wears: Sagi itself, or a custom one (`/api/pets`).
* The kind rides along so the mascot renders without a fetch. */
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image" };
const SAGI: PetChoice = { id: "sagi" };
function readPet(): PetChoice {
try {
const p = JSON.parse(localStorage.getItem(PET_KEY) ?? "null") as Partial<{
id: string;
kind: string;
}> | null;
if (
p &&
typeof p.id === "string" &&
p.id !== "sagi" &&
(p.kind === "codex" || p.kind === "image")
)
return { id: p.id, kind: p.kind };
} catch {
/* unreadable: Sagi */
}
return SAGI;
}
function readSize(): number {
const n = Number(localStorage.getItem(SIZE_KEY));
return Number.isFinite(n) && n > 0
? Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, n)))
: PET_SIZE.default;
}
function writeAndNotify(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
// Best-effort, like every pref here.
}
try {
window.dispatchEvent(new CustomEvent(EVENT));
} catch {
// SSR / non-DOM contexts: nothing to notify.
}
}
const EVENT = "sagi:prefs";
/**
@@ -118,6 +167,20 @@ export const sagiPrefs = {
setMuted: (v: boolean) => writeBool(MUTED_KEY, v),
getPos: readPos,
setPos: writePos,
getPet: readPet,
setPet: (pet: PetChoice) => writeAndNotify(PET_KEY, JSON.stringify(pet)),
getSize: () => {
try {
return readSize();
} catch {
return PET_SIZE.default;
}
},
setSize: (px: number) =>
writeAndNotify(
SIZE_KEY,
String(Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, px))))
),
/** Subscribe to any pref change; returns an unsubscribe fn. */
subscribe(handler: () => void): () => void {
const listener = () => handler();
+34
View File
@@ -369,3 +369,37 @@
.sagi-bubble.sagi-no-anim {
animation: none;
}
/* ════════ a custom pet (PetSprite.tsx) ════════ */
/* An uploaded still image has no frames of its own: it bobs, faster while an
agent works and slower asleep. A Codex spritesheet animates by its frames. */
.sagi-pet-image {
animation: sagi-pet-bob 2.6s ease-in-out infinite;
-webkit-user-drag: none;
}
.sagi-pet-image[data-mood="thinking"] {
animation-duration: 0.9s;
}
.sagi-pet-image[data-mood="sleeping"] {
animation-duration: 5s;
}
@keyframes sagi-pet-bob {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4%);
}
}
@media (prefers-reduced-motion: reduce) {
.sagi-pet-image {
animation: none;
}
}
/* The pet menu may overlap the mascot; it sits above it while open. */
.sagi-flyout[data-front="1"] {
z-index: 42;
}
+47 -40
View File
@@ -85,9 +85,9 @@ function defaultPos(): SagiPos {
}
/** Resting top-left screen coords for a docked position. */
function restingScreen(pos: SagiPos) {
const avail = Math.max(0, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
const left = pos.side === "left" ? SAGI_MARGIN : vw() - SAGI_SIZE - SAGI_MARGIN;
function restingScreen(pos: SagiPos, size: number) {
const avail = Math.max(0, vh() - size - 2 * SAGI_MARGIN);
const left = pos.side === "left" ? SAGI_MARGIN : vw() - size - SAGI_MARGIN;
const top = SAGI_MARGIN + pos.y * avail;
return { left, top };
}
@@ -108,7 +108,8 @@ export interface SagiPlacement {
consumeDrag: () => boolean;
}
export function useSagiPosition(): SagiPlacement {
/** `size`: the avatar's current footprint (the pet size pref), in px. */
export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos());
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
const [, force] = useState(0); // re-derive resting coords on resize
@@ -127,7 +128,7 @@ export function useSagiPosition(): SagiPlacement {
return () => window.removeEventListener("resize", onResize);
}, []);
const resting = restingScreen(pos);
const resting = restingScreen(pos, size);
const screen = drag ?? resting;
const onPointerDown = useCallback(
@@ -146,40 +147,46 @@ export function useSagiPosition(): SagiPlacement {
[screen.left, screen.top]
);
const onPointerMove = useCallback((e: ReactPointerEvent) => {
const start = startRef.current;
if (!start) return;
const dx = e.clientX - start.px;
const dy = e.clientY - start.py;
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
movedRef.current = true;
const left = Math.min(vw() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
const top = Math.min(vh() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.top + dy));
liveRef.current = { left, top };
setDrag({ left, top });
}, []);
const onPointerMove = useCallback(
(e: ReactPointerEvent) => {
const start = startRef.current;
if (!start) return;
const dx = e.clientX - start.px;
const dy = e.clientY - start.py;
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
movedRef.current = true;
const left = Math.min(vw() - size - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
const top = Math.min(vh() - size - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.top + dy));
liveRef.current = { left, top };
setDrag({ left, top });
},
[size]
);
const onPointerUp = useCallback((e: ReactPointerEvent) => {
try {
(e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
} catch {
/* ignore */
}
const live = liveRef.current;
if (live) {
draggedRef.current = true;
const side: "left" | "right" = live.left + SAGI_SIZE / 2 < vw() / 2 ? "left" : "right";
const avail = Math.max(1, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
const next: SagiPos = { side, y };
sagiPrefs.setPos(next);
setPos(next);
setDrag(null); // leave drag mode; resting coords (with transition) take over
}
liveRef.current = null;
startRef.current = null;
movedRef.current = false;
}, []);
const onPointerUp = useCallback(
(e: ReactPointerEvent) => {
try {
(e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
} catch {
/* ignore */
}
const live = liveRef.current;
if (live) {
draggedRef.current = true;
const side: "left" | "right" = live.left + size / 2 < vw() / 2 ? "left" : "right";
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN);
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
const next: SagiPos = { side, y };
sagiPrefs.setPos(next);
setPos(next);
setDrag(null); // leave drag mode; resting coords (with transition) take over
}
liveRef.current = null;
startRef.current = null;
movedRef.current = false;
},
[size]
);
const consumeDrag = useCallback(() => {
const was = draggedRef.current;
@@ -190,9 +197,9 @@ export function useSagiPosition(): SagiPlacement {
return {
left: screen.left,
top: screen.top,
size: SAGI_SIZE,
size,
side: pos.side,
openUp: screen.top + SAGI_SIZE / 2 > vh() / 2,
openUp: screen.top + size / 2 > vh() / 2,
dragging: drag !== null,
onPointerDown,
onPointerMove,
+15 -1
View File
@@ -8,7 +8,21 @@
"title": "Sagi companion",
"description": "A floating mascot that reacts to your live sessions.",
"enable": "Show Sagi",
"enableDesc": "Display the corner companion across the dashboard (⌘B to open)"
"enableDesc": "Display the corner companion across the dashboard (⌘B to open)",
"pet": {
"title": "Pet",
"hide": "Hide pet",
"size": "Size",
"choose": "Choose pet",
"featuredBy": "By {{by}} on codex-pets.net — downloaded into this install on first pick",
"download": "Download",
"delete": "Delete {{name}}",
"upload": "Upload your own…",
"import": "Import .codex-pet bundle…",
"browse": "Find more on codex-pets.net",
"settings": "Pet settings…"
},
"petHint": "Right-click the mascot to choose its pet — Sagi, a featured or imported Codex pet, or your own image — and its size."
},
"pricing": {
"vendor": {
+15 -1
View File
@@ -8,7 +8,21 @@
"title": "Bạn đồng hành Sagi",
"description": "Linh vật nổi phản ứng với các phiên trực tiếp của bạn.",
"enable": "Hiện Sagi",
"enableDesc": "Hiển thị bạn đồng hành ở góc trên toàn bảng điều khiển (⌘B để mở)"
"enableDesc": "Hiển thị bạn đồng hành ở góc trên toàn bảng điều khiển (⌘B để mở)",
"pet": {
"title": "Pet",
"hide": "Ẩn pet",
"size": "Kích thước",
"choose": "Chọn pet",
"featuredBy": "Của {{by}} trên codex-pets.net — tải về máy này ở lần chọn đầu",
"download": "Tải về",
"delete": "Xoá {{name}}",
"upload": "Tải ảnh của bạn lên…",
"import": "Nhập bundle .codex-pet…",
"browse": "Tìm thêm trên codex-pets.net",
"settings": "Cài đặt pet…"
},
"petHint": "Nhấp chuột phải vào mascot để chọn pet — Sagi, pet Codex có sẵn hoặc tự nhập, hay ảnh của bạn — và kích thước."
},
"pricing": {
"vendor": {
+50
View File
@@ -2010,6 +2010,31 @@ export const api = {
{ method: "DELETE" }
),
},
/** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */
pets: {
list: () => request<PetList>("/pets"),
/** The pet's spritesheet (a Codex pet) or image, served raster-only. */
spriteUrl: (id: string) => `${BASE}/pets/${encodeURIComponent(id)}/sprite`,
/** POST /api/pets/import — a `.codex-pet.zip` (pet.json + spritesheet.webp). */
importBundle: (file: File) =>
request<{ pet: PetView }>("/pets/import", {
method: "POST",
body: file,
headers: { "Content-Type": "application/octet-stream" },
}),
/** POST /api/pets/upload — one PNG, JPEG, GIF or WebP image. */
upload: (file: File) =>
request<{ pet: PetView }>(`/pets/upload?name=${encodeURIComponent(file.name)}`, {
method: "POST",
body: file,
headers: { "Content-Type": "application/octet-stream" },
}),
/** POST /api/pets/featured/:id — download a featured pet from codex-pets.net. */
installFeatured: (id: string) =>
request<{ pet: PetView }>(`/pets/featured/${encodeURIComponent(id)}`, { method: "POST" }),
remove: (id: string) =>
request<{ ok: true }>(`/pets/${encodeURIComponent(id)}`, { method: "DELETE" }),
},
aiTools: {
/** GET /api/ai-tools - the catalog, what is installed, enabled, and the default. */
list: () => request<AiToolsView>("/ai-tools"),
@@ -4127,3 +4152,28 @@ export interface ImportBackupResult {
/** Bundle entries that could not be restored (e.g. a session with no id). */
errors: number;
}
/** A custom pet (`GET /api/pets`). `codex` is a Codex spritesheet, `image` one still image. */
export interface PetView {
id: string;
displayName: string;
description: string;
kind: "codex" | "image";
source: "upload" | "featured";
featuredId?: string;
by?: string;
}
export interface FeaturedPet {
id: string;
displayName: string;
by: string;
installed: boolean;
page: string;
}
export interface PetList {
pets: PetView[];
featured: FeaturedPet[];
sheet: { width: number; height: number; cellWidth: number; cellHeight: number };
}
+3
View File
@@ -496,6 +496,9 @@ export function Settings({
/>
</div>
</div>
<p data-testid="sagi-pet-hint" className="mt-3 text-xs text-fg-muted">
{t("sagi.petHint")}
</p>
</div>
</section>
</>
@@ -7753,6 +7753,12 @@ exports[`screen snapshots > Settings 1`] = `
</div>
</div>
</div>
<p
class="mt-3 text-xs text-fg-muted"
data-testid="sagi-pet-hint"
>
Right-click the mascot to choose its pet — Sagi, a featured or imported Codex pet, or your own image — and its size.
</p>
</div>
</section>
<section