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>
256 lines
9.2 KiB
TypeScript
256 lines
9.2 KiB
TypeScript
/**
|
||
* @file prefs.ts
|
||
* @description Tiny localStorage-backed preference store for Sagi (enabled,
|
||
* muted, the pet it wears and its size). Broadcasts changes via a window
|
||
* CustomEvent so the Settings toggle and the live widget stay in sync within
|
||
* the same tab without a reload.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
/* =============================================================================
|
||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||
* =============================================================================
|
||
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||
*
|
||
* ## Design constraints
|
||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||
*
|
||
* ## Remote data & SSH
|
||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||
*
|
||
* ## Public surface
|
||
* - `SagiPos` — exported API; see TSDoc on the symbol for behavior.
|
||
* - `sagiPrefs` — exported API; see TSDoc on the symbol for behavior.
|
||
*
|
||
* ## Testing pointers
|
||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||
*
|
||
* ## Related docs
|
||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||
* - `docs/API.md` — REST reference.
|
||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||
* ============================================================================= */
|
||
/* -----------------------------------------------------------------------------
|
||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||
* -----------------------------------------------------------------------------
|
||
* **SagiPos**
|
||
* Part of this module's public contract. Downstream imports should treat
|
||
* the signature and return type as stable unless release notes say otherwise.
|
||
* When behavior changes, update the `@file` overview and relevant tests.
|
||
*
|
||
* **sagiPrefs**
|
||
* Part of this module's public contract. Downstream imports should treat
|
||
* the signature and return type as stable unless release notes say otherwise.
|
||
* When behavior changes, update the `@file` overview and relevant tests.
|
||
*
|
||
* ----------------------------------------------------------------------------- */
|
||
|
||
const ENABLED_KEY = "agent-dashboard-sagi-enabled";
|
||
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 (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 {
|
||
try {
|
||
const p = JSON.parse(localStorage.getItem(PET_KEY) ?? "null") as Partial<{
|
||
id: string;
|
||
kind: string;
|
||
name: string;
|
||
}> | null;
|
||
if (
|
||
p &&
|
||
typeof p.id === "string" &&
|
||
p.id !== "sagi" &&
|
||
(p.kind === "codex" || p.kind === "image")
|
||
)
|
||
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
|
||
? Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, n)))
|
||
: PET_SIZE.default;
|
||
}
|
||
|
||
function writeAndNotify(key: string, value: string): void {
|
||
try {
|
||
localStorage.setItem(key, value);
|
||
} catch {
|
||
// Best-effort, like every pref here.
|
||
}
|
||
try {
|
||
window.dispatchEvent(new CustomEvent(EVENT));
|
||
} catch {
|
||
// SSR / non-DOM contexts: nothing to notify.
|
||
}
|
||
}
|
||
const EVENT = "sagi:prefs";
|
||
|
||
/**
|
||
* Persisted resting position, AssistiveTouch-style: the widget always docks to
|
||
* the left or right edge, remembering its vertical offset. `y` is stored as a
|
||
* fraction of the viewport height (0–1) so it survives window resizes.
|
||
*/
|
||
export interface SagiPos {
|
||
side: "left" | "right";
|
||
y: number;
|
||
}
|
||
|
||
function readBool(key: string, fallback: boolean): boolean {
|
||
try {
|
||
const v = localStorage.getItem(key);
|
||
return v === null ? fallback : v === "true";
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
function writeBool(key: string, value: boolean): void {
|
||
try {
|
||
localStorage.setItem(key, String(value));
|
||
} catch {
|
||
// Ignore storage failures (private mode, quota) - prefs are best-effort.
|
||
}
|
||
try {
|
||
window.dispatchEvent(new CustomEvent(EVENT));
|
||
} catch {
|
||
// SSR / non-DOM contexts: nothing to notify.
|
||
}
|
||
}
|
||
|
||
function readPos(): SagiPos | null {
|
||
try {
|
||
const raw = localStorage.getItem(POS_KEY);
|
||
if (!raw) return null;
|
||
const p = JSON.parse(raw) as Partial<SagiPos>;
|
||
if ((p.side === "left" || p.side === "right") && typeof p.y === "number") {
|
||
return { side: p.side, y: Math.min(1, Math.max(0, p.y)) };
|
||
}
|
||
return null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function writePos(pos: SagiPos): void {
|
||
try {
|
||
localStorage.setItem(POS_KEY, JSON.stringify(pos));
|
||
} catch {
|
||
// Ignore storage failures - position is best-effort.
|
||
}
|
||
// Note: intentionally does NOT dispatch the prefs event - position changes
|
||
// are local to the widget and shouldn't churn the Settings toggle listeners.
|
||
}
|
||
|
||
export const sagiPrefs = {
|
||
getEnabled: () => readBool(ENABLED_KEY, true),
|
||
setEnabled: (v: boolean) => writeBool(ENABLED_KEY, v),
|
||
getMuted: () => readBool(MUTED_KEY, false),
|
||
setMuted: (v: boolean) => writeBool(MUTED_KEY, v),
|
||
getPos: readPos,
|
||
setPos: writePos,
|
||
getPet: readPet,
|
||
setPet: (pet: PetChoice) => writeAndNotify(PET_KEY, JSON.stringify(pet)),
|
||
getSize: () => {
|
||
try {
|
||
return readSize();
|
||
} catch {
|
||
return PET_SIZE.default;
|
||
}
|
||
},
|
||
setSize: (px: number) =>
|
||
writeAndNotify(
|
||
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();
|
||
window.addEventListener(EVENT, listener);
|
||
// Also react to changes from other tabs.
|
||
window.addEventListener("storage", listener);
|
||
return () => {
|
||
window.removeEventListener(EVENT, listener);
|
||
window.removeEventListener("storage", listener);
|
||
};
|
||
},
|
||
};
|