Files
AgentHub/client/src/components/Sagi/PetMenu.tsx
T
nntrivi2001 b7f3735b29 feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
2026-10-08 23:04:57 +07:00

279 lines
9.1 KiB
TypeScript

/**
* @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`); the list and its actions are `usePets`, shared
* with Settings → Appearance.
* @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 { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
import { usePets } from "./usePets";
import { usePermissions } from "../AuthGate";
import { toast } from "../../lib/toast";
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 [choosing, setChoosing] = useState(false);
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
// Adding or deleting a pet changes the whole install: an admin's.
const { isAdmin } = usePermissions();
// 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 addFile = (file: File | undefined, bundle: boolean) => file && void add(file, bundle);
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);
toast.info(t("sagi.pet.hiddenToast"));
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);
if (!have && !isAdmin) return null;
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 install(f.id))}
>
<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>
{isAdmin && (
<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 remove(p)}
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" />
{isAdmin && (
<>
<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>
);
}