feat(companion): rename Tabby companion to Sagi, replace cat avatar with mascot

Full rename across files, identifiers, CSS classes, and user-facing strings
(en/vi locales, ARCHITECTURE.md). Replaces the hand-drawn animated cat
avatar with the pig-superhero mascot artwork, split into parts to keep
per-eye cursor tracking and blink animation working; whole-mascot mood
transforms (breathe/bob/shake/tilt) and new zzz/bang/sparkle overlays
carry the rest of the mood expression since the traced art has no shared
palette to key off of.
This commit is contained in:
2026-08-19 11:07:59 +07:00
parent dce54c8a3a
commit 4cc39f5069
25 changed files with 1390 additions and 1041 deletions
+133
View File
@@ -0,0 +1,133 @@
/**
* @file prefs.ts
* @description Tiny localStorage-backed preference store for Sagi (enabled +
* muted). 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.
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `.claude/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 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,
/** 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);
};
},
};