feat(pet): cleared alerts stay cleared, contextual bubbles, i18n, moments, chat window

- 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>
This commit is contained in:
2026-10-14 12:14:29 +07:00
parent 0c2f49876d
commit 0cb5e41faf
25 changed files with 2378 additions and 290 deletions
+63 -3
View File
@@ -56,12 +56,19 @@ 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";
const DISMISSED_KEY = "agenthub.pet.dismissedErrors";
/** Per-pet nicknames the user typed, `{ [petId]: name }`. */
const NAMES_KEY = "agenthub.pet.names";
export const PET_NAME_MAX = 40;
/** Most recent dismissed error sessions kept across reloads. */
const DISMISSED_MAX = 200;
/** 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" };
* The kind (and its display name) ride along so the mascot renders, and
* introduces itself, without a fetch. */
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image"; name?: string };
const SAGI: PetChoice = { id: "sagi" };
function readPet(): PetChoice {
@@ -69,6 +76,7 @@ function readPet(): PetChoice {
const p = JSON.parse(localStorage.getItem(PET_KEY) ?? "null") as Partial<{
id: string;
kind: string;
name: string;
}> | null;
if (
p &&
@@ -76,13 +84,34 @@ function readPet(): PetChoice {
p.id !== "sagi" &&
(p.kind === "codex" || p.kind === "image")
)
return { id: p.id, kind: p.kind };
return typeof p.name === "string" && p.name
? { id: p.id, kind: p.kind, name: p.name.slice(0, PET_NAME_MAX) }
: { id: p.id, kind: p.kind };
} catch {
/* unreadable: Sagi */
}
return SAGI;
}
function readNames(): Record<string, string> {
try {
const raw = JSON.parse(localStorage.getItem(NAMES_KEY) ?? "{}") as unknown;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const out: Record<string, string> = {};
for (const [id, v] of Object.entries(raw)) if (typeof v === "string" && v) out[id] = v;
return out;
} catch {
return {};
}
}
/** The name the pet goes by: the user's nickname, else the pet's own, else Sagi. */
export function petName(pet: PetChoice): string {
const nick = readNames()[pet.id];
if (nick) return nick;
return ("name" in pet && pet.name) || "Sagi";
}
function readSize(): number {
const n = Number(localStorage.getItem(SIZE_KEY));
return Number.isFinite(n) && n > 0
@@ -181,6 +210,37 @@ export const sagiPrefs = {
SIZE_KEY,
String(Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, px))))
),
/** Name a pet; an empty name gives it back its own. */
setNickname: (id: string, name: string) => {
const names = readNames();
const clean = name.replace(/\s+/g, " ").trim().slice(0, PET_NAME_MAX);
if (clean) names[id] = clean;
else delete names[id];
writeAndNotify(NAMES_KEY, JSON.stringify(names));
},
/** The user's nickname for a pet, "" when none. */
getNickname: (id: string) => readNames()[id] ?? "",
/** Errored session ids the user cleared, so a reload keeps them quiet. */
getDismissed: (): Record<string, true> => {
try {
const ids = JSON.parse(localStorage.getItem(DISMISSED_KEY) || "[]");
const out: Record<string, true> = {};
if (Array.isArray(ids)) for (const id of ids) if (typeof id === "string") out[id] = true;
return out;
} catch {
return {};
}
},
setDismissed: (dismissed: Record<string, true>) => {
try {
localStorage.setItem(
DISMISSED_KEY,
JSON.stringify(Object.keys(dismissed).slice(-DISMISSED_MAX))
);
} catch {
// Best-effort, like every pref here; no prefs event — widget-local state.
}
},
/** Subscribe to any pref change; returns an unsubscribe fn. */
subscribe(handler: () => void): () => void {
const listener = () => handler();