Files
AgentHub/client/src/components/Sagi/usePets.ts
T
nntrivi2001 daf5b7be92 feat: edit in the Changes panel, git graph history, plan quota (v2.3.0)
- Changes panel: a working file opens in a CodeMirror editor with its diff
  inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the
  unchanged lines, Whole file shows them; side by side at 50/50 and full
  width; a Revert per block; original from HEAD or the branch's merge base
  (`mergeBase` on GET /git/changes?range=branch)
- Every diff: green = line added whole, red = removed whole, blue = edited
  in place (--diff-mod)
- History drawn as a commit graph (#58: `parents` on GET /git/log,
  --date-order; lib/commitGraph.ts)
- Per-workspace never-stage list, drawn orange in the Files tree (--kept);
  closed Terminal rows past three deleted
- Status bar plan quota for Claude (per account) and Codex
  (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that
  stays open, Refresh button with a spinner
- Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies;
  compact sidebar navigation and short session times
2026-10-06 15:38:25 +07:00

99 lines
3.5 KiB
TypeScript

/**
* @file usePets — the pet list and its actions, shared by the mascot's
* right-click menu (`PetMenu.tsx`) and Settings → Appearance (`settings/PetSettings`):
* `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing
* a featured pet (downloaded on its first pick), uploading an image or a
* `.codex-pet.zip` (worn at once), and deleting your own after a confirm
* (Sagi comes back when it was the one worn). Plus `useWornPet`, the live worn pet / size /
* shown / speech-bubble prefs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { api, type PetList, type PetView } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import i18n from "../../i18n";
import { sagiPrefs, type PetChoice } from "./prefs";
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
export function usePets() {
const [list, setList] = useState<PetList | null>(null);
// Which action is running: a featured id, a pet id, "image" or "bundle".
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(
() =>
api.pets
.list()
.then(setList)
.catch((err: unknown) => setError(message(err))),
[]
);
useEffect(() => {
void reload();
}, [reload]);
const run = async (key: string, work: () => Promise<void>) => {
setBusy(key);
setError(null);
try {
await work();
} catch (err) {
setError(message(err));
} finally {
setBusy(null);
}
};
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind });
const install = (featuredId: string) =>
run(featuredId, async () => {
wear((await api.pets.installFeatured(featuredId)).pet);
await reload();
});
const add = (file: File, bundle: boolean) =>
run(bundle ? "bundle" : "image", async () => {
const { pet } = bundle ? await api.pets.importBundle(file) : await api.pets.upload(file);
wear(pet);
await reload();
});
// One place both callers route through, so neither can delete on one click.
const remove = async (p: PetView) => {
const ok = await confirmDialog({
title: i18n.t("settings:sagi.pet.deleteTitle", { name: p.displayName }),
message: i18n.t("settings:sagi.pet.deleteMessage"),
confirmLabel: i18n.t("settings:sagi.pet.deleteConfirm"),
});
if (!ok) return;
await run(p.id, async () => {
await api.pets.remove(p.id);
if (sagiPrefs.getPet().id === p.id) sagiPrefs.setPet({ id: "sagi" });
await reload();
});
};
const own = list?.pets.filter((p) => p.source === "upload") ?? [];
// Featured id → the installed copy, once downloaded.
const installed = new Map(
(list?.pets ?? []).filter((p) => p.featuredId).map((p) => [p.featuredId as string, p])
);
return { list, own, installed, busy, error, wear, install, add, remove };
}
type Worn = { pet: PetChoice; size: number; shown: boolean; bubbles: boolean };
const readWorn = (): Worn => ({
pet: sagiPrefs.getPet(),
size: sagiPrefs.getSize(),
shown: sagiPrefs.getEnabled(),
bubbles: !sagiPrefs.getMuted(),
});
/** The worn pet and the mascot's prefs, following every change (any tab). */
export function useWornPet(): Worn {
const [worn, setWorn] = useState(readWorn);
useEffect(() => sagiPrefs.subscribe(() => setWorn(readWorn())), []);
return worn;
}