Files
AgentHub/client/src/components/settings/PetSettings.tsx
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

332 lines
11 KiB
TypeScript

/**
* @file PetSettings — the pet block of Settings → Appearance (`#pet`). Show it or not,
* its speech bubbles, its size (with a live preview), and which pet it wears
* from a grid — Sagi, the featured codex-pets.net pets (downloaded into this
* install on the first pick, never bundled), and your own (deletable) — plus
* uploading an image or importing a `.codex-pet.zip`. Everything here is the
* same state and calls as the mascot's right-click menu (`Sagi/usePets.ts`,
* `Sagi/prefs.ts`): the choice and size are per browser, the pets live on the
* server (`/api/pets`). Previews follow the OS reduced-motion preference; size
* and bubbles are disabled while the pet is hidden; a delete asks first; and
* **Reset to defaults** puts show / bubbles / size / pet back.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Check,
Download,
ExternalLink,
Loader2,
PackageOpen,
RotateCcw,
Trash2,
Upload,
} from "lucide-react";
import { SettingsRow, SettingsSwitchRow } from "./SettingsRow";
import { SagiAvatar } from "../Sagi/SagiAvatar";
import { PetSprite } from "../Sagi/PetSprite";
import { PET_SIZE, sagiPrefs } from "../Sagi/prefs";
import { usePrefersReducedMotion } from "../Sagi/Sagi";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { usePets, useWornPet } from "../Sagi/usePets";
import type { PetView } from "../../lib/api";
const PREVIEW = 72;
function PetCard({
label,
hint,
selected,
busy,
disabled,
preview,
onPick,
onDelete,
deleteLabel,
testId,
}: {
label: string;
hint?: string;
selected: boolean;
busy?: boolean;
disabled?: boolean;
preview: ReactNode;
onPick: () => void;
onDelete?: () => void;
deleteLabel?: string;
testId: string;
}) {
return (
<div className="group relative">
<button
type="button"
data-testid={testId}
onClick={onPick}
disabled={disabled}
aria-pressed={selected}
title={hint}
className={`flex w-full flex-col items-center gap-2 rounded-lg border p-3 transition-colors disabled:opacity-60 ${
selected
? "border-accent ring-2 ring-accent/40"
: "border-border hover:border-border-light"
}`}
>
<span className="flex h-[76px] items-center justify-center">{preview}</span>
<span className="flex max-w-full items-center gap-1 text-xs text-fg-primary">
<span className="truncate">{label}</span>
{busy ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
) : selected ? (
<Check className="h-3 w-3 shrink-0 text-accent" />
) : null}
</span>
</button>
{onDelete && (
<button
type="button"
data-testid={`${testId}-delete`}
onClick={onDelete}
disabled={disabled}
aria-label={deleteLabel}
title={deleteLabel}
className={`absolute right-1.5 top-1.5 rounded p-1 text-fg-muted transition-opacity hover:bg-surface-4 hover:text-status-danger focus:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 ${
selected ? "opacity-100" : "opacity-0"
}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
function Preview({ pet, size }: { pet: PetView; size: number }) {
const reducedMotion = usePrefersReducedMotion();
return (
<PetSprite
id={pet.id}
kind={pet.kind}
mood="idle"
size={size}
reducedMotion={reducedMotion}
onBroken={() => {}}
/>
);
}
export function PetSettings() {
const { t } = useTranslation("settings");
const worn = useWornPet();
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
const imageInput = useRef<HTMLInputElement>(null);
const bundleInput = useRef<HTMLInputElement>(null);
const wornPet = list?.pets.find((p) => p.id === worn.pet.id);
const sizePreview = Math.min(worn.size, 120);
const reducedMotion = usePrefersReducedMotion();
const resetDefaults = async () => {
const ok = await confirmDialog({
title: t("sagi.pet.reset"),
message: t("sagi.pet.resetMessage"),
confirmLabel: t("sagi.pet.reset"),
destructive: false,
});
if (!ok) return;
sagiPrefs.setEnabled(true);
sagiPrefs.setMuted(false);
sagiPrefs.setSize(PET_SIZE.default);
sagiPrefs.setPet({ id: "sagi" });
toast.success(t("sagi.pet.resetDone"));
};
return (
<div className="space-y-6">
<div className="card p-5">
<SettingsSwitchRow
checked={worn.shown}
onChange={(v) => sagiPrefs.setEnabled(v)}
label={t("sagi.enable")}
description={t("sagi.enableDesc")}
/>
<SettingsSwitchRow
checked={worn.bubbles}
onChange={(v) => sagiPrefs.setMuted(!v)}
label={t("sagi.bubbles")}
description={t("sagi.bubblesDesc")}
disabled={!worn.shown}
/>
<SettingsRow
label={t("sagi.pet.size")}
description={t("sagi.sizeDesc", { min: PET_SIZE.min, max: PET_SIZE.max })}
control={
<div className="flex items-center gap-3">
<input
type="range"
data-testid="pet-settings-size"
aria-label={t("sagi.pet.size")}
min={PET_SIZE.min}
max={PET_SIZE.max}
value={worn.size}
disabled={!worn.shown}
onChange={(e) => sagiPrefs.setSize(Number(e.target.value))}
className="w-40 accent-[rgb(var(--accent))] disabled:opacity-50"
/>
<span className="w-12 text-right text-xs tabular-nums text-fg-muted">
{worn.size}px
</span>
</div>
}
/>
<div
data-testid="pet-settings-preview"
className="mt-3 flex h-[132px] items-center justify-center rounded-lg bg-surface-0"
>
{wornPet ? (
<Preview pet={wornPet} size={sizePreview} />
) : (
<SagiAvatar mood="idle" reducedMotion={reducedMotion} size={sizePreview} />
)}
</div>
{worn.size > 120 && (
<p className="mt-1.5 text-[11px] text-fg-muted">
{t("sagi.pet.previewCapped", { size: worn.size })}
</p>
)}
{!worn.shown && (
<p className="mt-1.5 text-[11px] text-fg-muted">{t("sagi.pet.hiddenNote")}</p>
)}
<div className="mt-3 flex justify-end">
<button type="button" className="btn-ghost text-xs" onClick={() => void resetDefaults()}>
<RotateCcw className="h-3.5 w-3.5" />
{t("sagi.pet.reset")}
</button>
</div>
</div>
<div className="card p-5">
<p className="mb-1 text-sm text-fg-secondary">{t("sagi.pet.choose")}</p>
<p className="mb-4 text-xs text-fg-muted">{t("sagi.chooseDesc")}</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<PetCard
testId="pet-card-sagi"
label="Sagi"
selected={worn.pet.id === "sagi"}
preview={<SagiAvatar mood="idle" reducedMotion size={PREVIEW} />}
onPick={() => sagiPrefs.setPet({ id: "sagi" })}
/>
{list?.featured.map((f) => {
const have = installed.get(f.id);
return (
<PetCard
key={f.id}
testId={`pet-card-featured-${f.id}`}
label={f.displayName}
hint={t("sagi.pet.featuredBy", { by: f.by })}
selected={!!have && worn.pet.id === have.id}
busy={busy === f.id}
disabled={busy !== null}
preview={
have ? (
<Preview pet={have} size={PREVIEW} />
) : (
<span className="flex flex-col items-center gap-1 text-[11px] text-fg-muted">
<Download className="h-6 w-6" />
{t("sagi.pet.download")}
</span>
)
}
onPick={() => (have ? wear(have) : void install(f.id))}
/>
);
})}
{own.map((p) => (
<PetCard
key={p.id}
testId={`pet-card-own-${p.id}`}
label={p.displayName}
selected={worn.pet.id === p.id}
busy={busy === p.id}
disabled={busy !== null}
preview={<Preview pet={p} size={PREVIEW} />}
onPick={() => wear(p)}
onDelete={() => void remove(p)}
deleteLabel={t("sagi.pet.delete", { name: p.displayName })}
/>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
className="btn-ghost text-xs"
disabled={busy !== null}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy !== null}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<PackageOpen className="h-3.5 w-3.5" />
)}
{t("sagi.pet.import")}
</button>
<a
href="https://codex-pets.net/"
target="_blank"
rel="noreferrer noopener"
className="btn-ghost text-xs"
>
<ExternalLink className="h-3.5 w-3.5" />
{t("sagi.pet.browse")}
</a>
</div>
<p className="mt-2 text-xs text-fg-muted">{t("sagi.uploadHint")}</p>
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
</p>
)}
<input
ref={imageInput}
type="file"
hidden
data-testid="pet-settings-image-input"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void add(file, false);
e.target.value = "";
}}
/>
<input
ref={bundleInput}
type="file"
hidden
data-testid="pet-settings-bundle-input"
accept=".zip,application/zip"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void add(file, true);
e.target.value = "";
}}
/>
</div>
</div>
);
}