0cb5e41faf
- Fix: an errored session alerted on every update, and Clear alerts only dropped it in memory, so its next update (or a reload) brought the alert straight back. An error now alerts on the transition into error only; cleared sessions are remembered (agenthub.pet.dismissedErrors) and stay quiet until they recover and fail anew. - Quips, greetings, notifications and Ask answers move to i18n (EN/VI); Ask understands Vietnamese keywords and names the sessions it counts. - The pet goes by the worn pet's name; click it in the panel to rename (agenthub.pet.names, synced to the account). - Bubbles name the session they are about, with an Open button (never a resume); a burst of the same event coalesces into one count. - Moments: a new session waves, a finish hops, a subagent peeks, a wait taps, an error flinches — on Sagi, Codex sprites and still images. Idle fidgets, a once-per-session greeting, a "Needs you" list, an amber waiting dot. - Pat (heart button, Ask, or stroking the pet) reacts on the pet itself, so it shows while the panel or chat is open. - Panel drops Run Claude / Activity / Sessions / Errored. - Ask hands off to a chat window (PetChat) on the default AI tool (Claude/Codex): transcript as bubbles, follow-ups and CLI menus through an off-screen attached terminal; no new server route. Verified on HEAD + this diff in a clean worktree: tsc -b, the full client suite (1334/1335; the one failure, ConversationView.follow, is flaky under load and passes 3/3 alone) and server me-prefs.test.js. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
99 lines
3.5 KiB
TypeScript
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, name: p.displayName });
|
|
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;
|
|
}
|