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
+4 -4
View File
@@ -1,7 +1,7 @@
/**
* @file Layout.tsx
* @description Application shell that frames every authenticated route: persistent
* sidebar, main content column, update notifier, and the Tabby assistant overlay.
* sidebar, main content column, update notifier, and the Sagi assistant overlay.
* The layout is the single parent route in {@link App} — child pages render inside
* React Router's `<Outlet />` so navigation never remounts chrome.
*
@@ -38,7 +38,7 @@
* ## Internal dependencies
* - `./Sidebar`
* - `./UpdateNotifier`
* - `./Tabby/Tabby`
* - `./Sagi/Sagi`
*
* ## Public surface
* - `Layout` — exported API; see TSDoc on the symbol for behavior.
@@ -68,7 +68,7 @@ import { Outlet } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
import { UpdateNotifier } from "./UpdateNotifier";
import { Tabby } from "./Tabby/Tabby";
import { Sagi } from "./Sagi/Sagi";
/** Props for {@link Layout}. */
interface LayoutProps {
@@ -100,7 +100,7 @@ export function Layout({ wsConnected }: LayoutProps) {
{t("skipToContent")}
</a>
<UpdateNotifier />
<Tabby />
<Sagi />
<Sidebar wsConnected={wsConnected} collapsed={collapsed} onToggle={toggle} />
<main
id="main-content"
@@ -1,12 +1,12 @@
/**
* @file Tabby.tsx
* @description Floating cat companion shell. Mounts once (next to UpdateNotifier
* @file Sagi.tsx
* @description Floating mascot companion shell. Mounts once (next to UpdateNotifier
* in Layout) so it persists across routes and shares the single WebSocket.
* Owns the open/closed panel state, the B / Esc shortcuts, reduced-motion
* detection, and route navigation. Reactive personality + status/Ask come
* from useTabbyBrain; the avatar is draggable (AssistiveTouch-style) via
* useTabbyPosition, and the bubble/panel render in a self-clamping flyout so
* they never spill off any screen edge regardless of where the cat is docked.
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
* useSagiPosition, and the bubble/panel render in a self-clamping flyout so
* they never spill off any screen edge regardless of where the mascot is docked.
*
* The "do the job" path reuses the existing Run page: unmatched Ask queries
* deep-link to /run?prompt=&autostart=1 - no new LLM backend.
@@ -15,7 +15,7 @@
/* =============================================================================
* MODULE_GUIDE extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Tabby is the optional on-screen cat assistant quips, intents, and lightweight event reactions layered above the dashboard chrome.
* **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.
@@ -30,16 +30,16 @@
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Internal dependencies
* - `./CatAvatar`
* - `./SagiAvatar`
* - `./SpeechBubble`
* - `./TabbyPanel`
* - `./useTabbyBrain`
* - `./useTabbyPosition`
* - `./SagiPanel`
* - `./useSagiBrain`
* - `./useSagiPosition`
* - `./intents`
* - `./prefs`
*
* ## Public surface
* - `Tabby` exported API; see TSDoc on the symbol for behavior.
* - `Sagi` exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
@@ -54,7 +54,7 @@
/* -----------------------------------------------------------------------------
* EXPORT CATALOG quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **Tabby**
* **Sagi**
* 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.
@@ -71,14 +71,14 @@ import {
type ReactNode,
} from "react";
import { useNavigate } from "react-router-dom";
import { CatAvatar } from "./CatAvatar";
import { SagiAvatar } from "./SagiAvatar";
import { SpeechBubble } from "./SpeechBubble";
import { TabbyPanel } from "./TabbyPanel";
import { useTabbyBrain } from "./useTabbyBrain";
import { useTabbyPosition, TABBY_SIZE } from "./useTabbyPosition";
import { SagiPanel } from "./SagiPanel";
import { useSagiBrain } from "./useSagiBrain";
import { useSagiPosition, SAGI_SIZE } from "./useSagiPosition";
import { matchIntent } from "./intents";
import { tabbyPrefs } from "./prefs";
import "./tabby.css";
import { sagiPrefs } from "./prefs";
import "./sagi.css";
const FLYOUT_GAP = 10; // px between avatar and flyout
const VIEWPORT_MARGIN = 12; // min gap from any screen edge
@@ -113,7 +113,7 @@ function usePrefersReducedMotion(): boolean {
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
* into view instead of being cropped.
*/
function TabbyFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode }) {
function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
@@ -129,8 +129,8 @@ function TabbyFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode
let left = anchor.side === "left" ? anchor.left : anchor.left + anchor.size - w;
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
// Vertical: prefer above the cat (feels natural). Only drop below when
// there isn't room above - i.e. the cat is near the top edge.
// Vertical: prefer above the mascot (feels natural). Only drop below when
// there isn't room above - i.e. the mascot is near the top edge.
const above = anchor.top - h - FLYOUT_GAP;
const below = anchor.top + anchor.size + FLYOUT_GAP;
let top = above >= VIEWPORT_MARGIN ? above : below;
@@ -153,22 +153,22 @@ function TabbyFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode
}, [place]);
return (
<div ref={ref} className="tabby-flyout" style={style}>
<div ref={ref} className="sagi-flyout" style={style}>
{children}
</div>
);
}
export function Tabby() {
const [enabled, setEnabled] = useState(() => tabbyPrefs.getEnabled());
export function Sagi() {
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
const [open, setOpen] = useState(false);
const reducedMotion = usePrefersReducedMotion();
const navigate = useNavigate();
const brain = useTabbyBrain();
const place = useTabbyPosition();
const brain = useSagiBrain();
const place = useSagiPosition();
// Keep enabled in sync with Settings / other tabs.
useEffect(() => tabbyPrefs.subscribe(() => setEnabled(tabbyPrefs.getEnabled())), []);
useEffect(() => sagiPrefs.subscribe(() => setEnabled(sagiPrefs.getEnabled())), []);
// ⌘B / Ctrl+B toggles the panel; Esc closes it.
useEffect(() => {
@@ -218,10 +218,10 @@ export function Tabby() {
return (
<>
{/* Flyouts are hidden while dragging so they don't chase the cat. */}
{/* Flyouts are hidden while dragging so they don't chase the mascot. */}
{!place.dragging && open && (
<TabbyFlyout anchor={anchor}>
<TabbyPanel
<SagiFlyout anchor={anchor}>
<SagiPanel
status={brain.status}
muted={brain.muted}
onToggleMute={brain.toggleMute}
@@ -230,19 +230,19 @@ export function Tabby() {
onAsk={onAsk}
onClose={() => setOpen(false)}
/>
</TabbyFlyout>
</SagiFlyout>
)}
{!place.dragging && !open && brain.bubble && (
<TabbyFlyout anchor={anchor}>
<SagiFlyout anchor={anchor}>
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
</TabbyFlyout>
</SagiFlyout>
)}
<button
className="tabby-avatar-btn"
className="sagi-avatar-btn"
data-dragging={place.dragging ? "1" : "0"}
style={{ left: place.left, top: place.top, width: TABBY_SIZE, height: TABBY_SIZE }}
style={{ left: place.left, top: place.top, width: SAGI_SIZE, height: SAGI_SIZE }}
onPointerDown={place.onPointerDown}
onPointerMove={place.onPointerMove}
onPointerUp={place.onPointerUp}
@@ -252,13 +252,13 @@ export function Tabby() {
if (place.consumeDrag()) return;
setOpen((v) => !v);
}}
aria-label={open ? "Close Tabby" : "Open Tabby companion"}
aria-label={open ? "Close Sagi" : "Open Sagi companion"}
aria-expanded={open}
title="Tabby - ⌘B · drag to move"
title="Sagi - ⌘B · drag to move"
>
<CatAvatar mood={brain.mood} reducedMotion={reducedMotion} />
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} />
{brain.status.errorCount > 0 && (
<span className="tabby-error-dot" aria-hidden>
<span className="sagi-error-dot" aria-hidden>
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
</span>
)}
+784
View File
@@ -0,0 +1,784 @@
/**
* @file SagiAvatar.tsx
* @description Pure presentational SVG mascot - Sagi, a blue superhero pig. Traced
* from the user-provided mascot.svg (186 anonymous paths, no part grouping), so
* only the eyes could be safely extracted into their own groups; everything else
* renders as the original flat artwork. Eye pupils track the cursor and blink via
* nested `sagi-eye-track` / `sagi-eye-blink` groups. Mood is expressed via a
* `data-mood` attribute driving whole-mascot CSS transforms (bob/shake/tilt/breathe)
* in sagi.css, plus zzz/bang/sparkle overlay glyphs layered above the artwork.
* No data access - fully testable / reusable in isolation.
* @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.
*
* ## Internal dependencies
* - `./brain`
*
* ## Public surface
* - `SagiAvatar` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
import { useEffect, useRef, useState } from "react";
import type { Mood } from "./brain";
interface SagiAvatarProps {
mood: Mood;
reducedMotion: boolean;
size?: number;
}
const MAX_PUPIL_SHIFT = 36; // px in the 0-1280 viewBox (scaled from the old 100x100 art)
// Module-level last-known cursor position, tracked from the moment this module
// first loads (well before any avatar mounts). This is what makes eye tracking
// feel *immediate*: as soon as Sagi mounts it aims at wherever the cursor
// already is, instead of sitting centered until the next mousemove. `null`
// until the very first pointer event of the page's life.
let lastCursor: { x: number; y: number } | null = null;
if (typeof window !== "undefined") {
const remember = (e: MouseEvent) => {
lastCursor = { x: e.clientX, y: e.clientY };
};
// capture phase + passive so we never interfere with anything else.
window.addEventListener("mousemove", remember, { capture: true, passive: true });
window.addEventListener("pointermove", remember as EventListener, {
capture: true,
passive: true,
});
}
export function SagiAvatar({ mood, reducedMotion, size = 60 }: SagiAvatarProps) {
const rootRef = useRef<SVGSVGElement | null>(null);
const rafRef = useRef<number>();
const [pupil, setPupil] = useState({ x: 0, y: 0 });
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
// group via CSS, so tracking there is harmless and keeps eyes pre-aimed for
// the instant the connection returns.)
const tracking = !reducedMotion && mood !== "sleeping";
useEffect(() => {
if (!tracking) {
setPupil({ x: 0, y: 0 });
return;
}
const aimAt = (clientX: number, clientY: number) => {
const el = rootRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const dx = clientX - cx;
const dy = clientY - cy;
const dist = Math.hypot(dx, dy) || 1;
// Normalize then clamp to the eye socket range.
const nx = (dx / dist) * Math.min(1, dist / 240);
const ny = (dy / dist) * Math.min(1, dist / 240);
setPupil({ x: nx * MAX_PUPIL_SHIFT, y: ny * MAX_PUPIL_SHIFT });
};
// Immediately aim at the last-known cursor (next frame, so layout is ready)
// - no waiting for the user to move the mouse first.
let initRaf = 0;
if (lastCursor) {
const c = lastCursor;
initRaf = requestAnimationFrame(() => aimAt(c.x, c.y));
}
const onMove = (e: MouseEvent) => {
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = undefined;
aimAt(e.clientX, e.clientY);
});
};
window.addEventListener("mousemove", onMove, { passive: true });
return () => {
window.removeEventListener("mousemove", onMove);
if (initRaf) cancelAnimationFrame(initRaf);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = undefined;
};
}, [tracking]);
return (
<svg
ref={rootRef}
className="sagi-avatar"
data-mood={mood}
data-reduced={reducedMotion ? "1" : "0"}
width={size}
height={size}
viewBox="0 0 1280 1280"
role="img"
aria-label={`Sagi (${mood})`}
>
<path
transform="translate(430,173)"
d="m0 0h15l11 4 11 7 12 11 7 7 3 4 6-1 25-10 30-9 31-6 26-3 17-1h41l29 3 33 6 25 7 20 7 17 7 7-6 12-12 14-10 9-4 4-1h13l10 3 12 7 13 12 8 5 26 10 8 5 7 8 4 13 1 7v13l-3 23-5 13-7 8-7 3h-8l-8-1 14 24 11 27 7 23 4 22 2 17v40l-3 23-6 24-7 19-9 19-12 19-13 16-11 12-11 10-13 10-13 9-11 7h-2v6l3 3 15 8 16 10 12 9 11 9 18 18 12 17 5 11 4 13 1 7v20l-4 14-7 13-5 7 3 9 16 40 17 39 5 13 2 9v12l-4 14-6 11-9 10-11 9-15 9-7 1-2-2-1-7-1-19-2-10h-14l-12 6-13 11-14 9-11 4-18 2-3 7-7 28-7 23-4 10-6 8-7 6-9 3-6 1h-36l-9-2-6-4-6-7-7-15-9-29-4-1-22 3-14 1h-11l-25-2-19-2-8 25-5 13-6 11-7 6-6 2-7 1h-35l-12-3-6-4-7-7-6-13-11-38-6-21-18-2-11-4-10-6-11-9-11-8-7-3h-15l-2 16-1 20-1 1h-8l-15-9-9-7-9-9-7-11-5-15v-17l5-16 9-19 20-48 8-20v-5l-8-11-5-12-3-14v-11l2-14 5-14 6-11 9-12 11-12 10-10 17-13 15-10 21-12 1-7-24-16-13-10-13-12-12-12-11-14-10-16-8-15-9-22-7-27-3-21-1-18v-13l2-26 4-22 7-25 8-20 8-16 10-18-8 2h-8l-7-3-8-9-4-11-3-20v-19l2-12 4-8 5-6 8-5 25-9 10-6 11-11 11-7z"
fill="#17519E"
/>
<path
transform="translate(629,184)"
d="m0 0h33l32 3 28 5 27 7 21 8 25 11 11 6 10 6 12 8 16 12 12 12 12 16 16 16 3 5v2l-2 1 8 16 5 12 5 13 6 25 3 19v38l-2 17-5 23-7 21-7 16-9 16-10 14-9 11-9 10-13 12-17 13-17 11-21 11-25 10-14 5-24 7-25 5-27 4-36 2h-31l-33-3-30-5-27-6-30-10-21-9-16-8-17-10-19-14-12-11-7-6-9-11-7-9-6-10-3-3-6-16-5-18-3-19-2-19v-17l2-24 4-24 5-17 7-21 10-21 11-18 8-12 17-17 10-13 4-5 8-7 13-10 18-12 12-7 26-12 21-8 16-5 33-7 21-3z"
fill="#17519E"
/>
<path
transform="translate(430,173)"
d="m0 0h15l11 4 11 7 12 11 7 7 3 4 6-1 25-10 30-9 31-6 26-3 17-1h41l29 3 33 6 25 7 20 7 17 7 7-6 12-12 14-10 9-4 4-1h13l10 3 12 7 13 12 8 5 26 10 8 5 7 8 4 13 1 7v13l-3 23-5 13-7 8-7 3h-8l-8-1 14 24 11 27 7 23 4 22 2 17v40l-3 23-6 24-7 19-9 19-12 19-13 16-11 12-11 10-13 10-13 9-11 7h-2v6l3 3 15 8 16 10 12 9 11 9 18 18 12 17 5 11 4 13 1 7v20l-4 14-7 13-5 7 3 9 16 40 17 39 5 13 2 9v12l-4 14-6 11-9 10-11 9-15 9-7 1-2-2-1-7-1-19-2-10h-14l-12 6-13 11-14 9-11 4-18 2-3 7-7 28-7 23-4 10-6 8-7 6-9 3-6 1h-36l-9-2-6-4-6-7-7-15-9-29-4-1-22 3-14 1h-11l-25-2-19-2-8 25-5 13-6 11-7 6-6 2-7 1h-35l-12-3-6-4-7-7-6-13-11-38-6-21-18-2-11-4-10-6-11-9-11-8-7-3h-15l-2 16-1 20-1 1h-8l-15-9-9-7-9-9-7-11-5-15v-17l5-16 9-19 20-48 8-20v-5l-8-11-5-12-3-14v-11l2-14 5-14 6-11 9-12 11-12 10-10 17-13 15-10 21-12 1-7-24-16-13-10-13-12-12-12-11-14-10-16-8-15-9-22-7-27-3-21-1-18v-13l2-26 4-22 7-25 8-20 8-16 10-18-8 2h-8l-7-3-8-9-4-11-3-20v-19l2-12 4-8 5-6 8-5 25-9 10-6 11-11 11-7zm199 11-32 2-25 4-29 6-30 10-29 13-21 12-14 10-13 10-9 8-10 13-9 10-11 11-12 19-10 18-8 18-5 16-6 21-4 29-1 15v17l4 34 5 19 6 17 2 4 3 3 7 11 11 13 5 6 8 7 10 9 14 10 11 7 17 10 25 11 28 10 24 6 21 4 27 4 25 2h31l36-2 34-5 29-7 22-7 25-10 21-10 16-10 14-10 13-11 12-11 9-10 9-12 9-13 9-17 5-12 7-21 6-29 1-11v-38l-4-24-5-20-7-19-7-15-4-7 2-1-2-5-7-8-11-11-12-16-12-12-19-14-13-8-10-6-17-8-24-10-15-5-23-6-36-6-24-2zm-173 455-21 11-15 10-13 10v2l-4 2-15 15-11 14-6 12-4 12-2 10v16l4 14 7 12 15 15 7 5 6 3 1 3-12-1-2 3v6l5 6 7 4 5 1h13l9-3 12-9 5-42-1-6-6-10-9-12-8-10v-4l6 1 13 13 12-7 3-7 10-30 4-6h3l-1 8-6 18-7 28-5 25-3 19-2 22v22l2 23 4 21 6 16 7 16 8 12 12 16v4l-5-2-9-9-1 3 13 53 9 29 6 10 8 7 5 2 17 2h14l15-2 6-4 6-10 12-36-7-3-17-6-5-2v-3l4-1 39 9 27 3h31l23-3 28-6 7-2 10-1-2 5-24 8-3 2 2 9 9 26 6 11 10 5 27 1 13-1 11-4 7-8 5-12 9-32 8-29 3-17v-3l-4 2-8 8-3 1 3-7 7-9 8-11 8-16 6-15 5-22 2-18v-38l-2-21-6-34-11-40-5-17v-2l4 1 4 6 6 16 5 17 4 4 7 5h4l7-9 6-4 6-1-3 7-9 10-10 15-3 7v9l2 11 2 22 2 5 9 7 9 4h16l10-5 4-4 1-9-1-2-13 1v-4l9-3 9-7 7-8 6-7 7-14 2-7v-22l-4-14-5-12-7-10-9-11-13-13-14-11-13-9-21-12-6-2-12 12-14 11-10 7-14 8-16 8-21 8-29 8-1 4 5 11 4 14 1 20-1 2-14-1-16-5-8-4-5-3-12-11-4-5-5-11-3-7-16 1-4 9-6 12-16 16h3l-8 2-5 2-13 5-8 1-10-1-1-1v-9l2-14 5-16 4-8v-2l-24-7-18-6-18-8-20-11-16-11-12-11-10-9z"
fill="#06080D"
/>
<path
transform="translate(645,705)"
d="m0 0 4 1 5 13 5 8 9 9 6 5 20 8 13 4-1 36 28-10 10-2-1 107-30 10-43 14-36 12h-4v-22l-8 3-5 1-1-3v-9l-13 4-34 12-7 1v-14l10-4h3v-41l-36 13h-2l-1-93v-12l1-4 18-6 8-3 1 8 1 1 11 1 9-2 14-5 4-1 5-2-2-1 13-12 4-5 5-10 4-9z"
fill="#2D3374"
/>
<path
transform="translate(804,692)"
d="m0 0 2 2 12 42 5 20 5 29 2 21v38l-3 23-5 20-9 21-10 16-8 9-3 5-1 4 5-3 8-8h2l-2 14-4 16-12 45-6 16-7 9-8 4-4 1-16 1-24-1-10-5-7-14-10-29 1-4 19-7 7-2 2-5-14 2-25 6-21 3-8 1h-31l-27-3-30-7-11-2-2 2v2l5 2 17 6 7 3-12 36-6 10-6 4-15 2h-14l-17-2-8-4-8-9-5-12-7-23-13-53 1-3 10 10 4 1-1-5-7-10-9-26-2-5 1-4 4 8 10 10 14 10 17 10 18 8 23 7 23 5 24 3h49l18-2 25-5 17-5 14-6 20-11 12-9 10-9 9-10 12-19 8-17 5-15 5-25 2-20v-44l-5-43-2-13v-7z"
fill="#0F347E"
/>
<path
transform="translate(887,300)"
d="m0 0 4 4 6 11 6 9 8 17 7 18 6 22 4 25 1 10v38l-2 18-3 14-7 23-7 17-10 18-6 9-9 13h-2l-2 4h-2l-2 4-16 16h-2l-1 3-16 12-13 8-22 12-21 9-19 7-22 6-18 4-23 4-29 3-19 1h-34l-38-3-25-4-23-5-22-6-20-7-18-8-16-8-12-7-10-7-11-8-11-9-16-16-11-14-15-23-5-11 1-3 9 14 11 13 4 5 8 7 10 9 14 10 11 7 17 10 25 11 28 10 24 6 21 4 27 4 25 2h31l36-2 34-5 29-7 22-7 25-10 21-10 16-10 14-10 13-11 12-11 13-15 14-21 8-15 5-12 7-21 6-29 1-11v-38l-4-24-5-20-7-19-7-15-4-8z"
fill="#0F347E"
/>
<path
transform="translate(659,735)"
d="m0 0h3v13l14-4h6l9 4 16 5-1 36 28-10 10-2-1 107-30 10-43 14-36 12h-4v-109l10-4 9-3 1-65z"
fill="#4BA1C2"
/>
<path
transform="translate(636,416)"
d="m0 0 17 1 17 4 15 7 10 8 8 9 5 12 1 12-2 10-6 12-10 10-8 5-17 6-8 2h-34l-10-2-13-4-14-9-8-9-5-9-2-8v-15l5-13 10-11 12-8 13-5 16-4z"
fill="#7ED1DA"
/>
<path
transform="translate(629,184)"
d="m0 0h33l32 3 28 5 27 7 21 8 25 11 11 6 10 6 12 8 16 12 12 12 12 16 16 16 3 5-1 2-5-3-3-6-6-4-5-7-8-7-10-9-16-12-19-12-27-14-21-8-22-7-30-7-28-4-27-2h-33l-26 2-17 1v2l3 1 2 3v6l-6 9-10 7-12 6-16 5-13 2h-15l-8-3-4-4v-8l2-4-17 8-23 13-14 10-16 12-12 11-14 15-13 18-11 18-10 19-8 21-6 23-5 26-1 6v52l-2-3-3-27v-17l2-24 4-24 5-17 7-21 10-21 11-18 8-12 17-17 10-13 4-5 8-7 13-10 18-12 12-7 26-12 21-8 16-5 33-7 21-3z"
fill="#70A2CD"
/>
<path
transform="translate(432,181)"
d="m0 0h12l9 3 13 9 5 4v2h2l7 8 1 5-7 4-16 9-8 6-10 7h-2l2-9 4-16v-10h-3l-3 9-4 13-12 26-7 10-6 8-14 14-11 8-12 4-8-1-6-8-4-10-2-19v-12l2-14 3-6 8-7 10-4 15-5h2l-2 7-1 9h3l2-5 9-17 8-10 13-9z"
fill="#16519E"
/>
<path
transform="translate(836,181)"
d="m0 0h12l12 5 11 9 8 10 10 19 2 2 1-7-4-10 9 2 18 7 8 6 3 5 2 9v25l-1 12-5 13-5 6-8 1-11-4-11-7-10-9-8-8-8-14-7-10-8-19-6-19v-2h-3v11l6 24-4-2-18-12-14-8-8-5 4-6 10-11 13-9z"
fill="#17519E"
/>
<g className="sagi-eye sagi-eye-left">
<g
className="sagi-eye-track"
style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}
>
<g className="sagi-eye-blink">
<path
transform="translate(503,354)"
d="m0 0h15l12 4 8 5 8 7 3 4v2h2l5 13v21l-4 11-7 11-7 6-10 6-5 2h-19l-9-2-12-7-10-10-6-12-2-9v-9l2-11 4-9 7-9 8-7 10-5z"
fill="#020406"
/>
<path
transform="translate(498,338)"
d="m0 0h13l17 4 15 8v2l4 2 9 10 6 11 4 13v18l-2 13-5 12-3 5h-2l-2 4-7 8-10 7-5 2-8 3-7 2h-16l-13-3-10-4-12-9-5-4-9-13-4-11-2-8v-20l4-14 7-12 9-10 9-7 8-4 9-3zm5 16-12 4-11 8-7 8-4 6-3 9-1 8v9l2 9 6 12 10 10 12 7 9 2h19l12-6 9-7 7-10 4-10 1-3v-21l-5-13h-2l-2-4-6-7-9-6-10-4-4-1z"
fill="#F9FBFC"
/>
<path
transform="translate(523,371)"
d="m0 0h9l5 3 2 3v9l-3 5-5 3-9-1-5-6-1-2v-7l5-6z"
fill="#F6F9FB"
/>
</g>
</g>
</g>
<g className="sagi-eye sagi-eye-right">
<g
className="sagi-eye-track"
style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}
>
<g className="sagi-eye-blink">
<path
transform="translate(764,354)"
d="m0 0h12l13 4 12 8 9 12 4 11 1 7v9l-2 9-7 14-5 6-11 8-7 3-6 1h-19l-12-5v-2l-5-2-5-5-7-9-4-10-1-4v-19l2-7 9-14 10-8h2v-2l10-4z"
fill="#020406"
/>
<path
transform="translate(770,338)"
d="m0 0h10l14 3 10 4 10 7 5 4v2h2l7 10 5 11 2 5 1 9v18l-4 13-5 10-7 9h-2v2l-4 3h-2v2l-10 6-6 1v2l-11 3h-16l-15-3-10-5-10-8-9-10-4-7-5-12-2-9v-19l6-17 8-13h2l1-3h2v-2h2v-2l14-9 12-4zm-6 16-10 2-7 3v2l-5 2-8 7-6 9-3 6-1 5v19l3 10 6 10 6 7 5 3h2v2l12 5h19l9-2 10-6 6-5 6-8 6-13 1-7v-9l-2-10-4-9-6-8-5-5-11-7-11-3z"
fill="#F9FBFC"
/>
<path
transform="translate(781,371)"
d="m0 0h10l5 4 1 2v8l-2 5-4 3-7 1-6-3-4-7 1-7 4-5z"
fill="#F4F8FA"
/>
</g>
</g>
</g>
<path
transform="translate(440,824)"
d="m0 0h2l2 30 2 16 2 6 2 9 6 17 4 8 4 9 5 26v4h-15l-10-4-9-6-9-8-11-7-10-5-16-2 1-17 5-28 5-23 5-18h19l9-3z"
fill="#3098A7"
/>
<path
transform="translate(839,824)"
d="m0 0 5 2 12 5 20 1 4 15 6 27 4 24 1 9v10l-16 2-10 5-13 9-10 9-12 6-10 2-9-1 3-17 3-15 7-14 4-10 3-8 5-27z"
fill="#2F98A7"
/>
<path
transform="translate(898,796)"
d="m0 0 4 2 8 21 19 45 10 23 4 12v8l-3 11-4 8-9 12-9 8-13 8h-2l-2-27-6-43-5-26-6-25v-5l8-6 3-5 1-5v-14z"
fill="#3CCCD4"
/>
<path
transform="translate(380,796)"
d="m0 0 4 2 1 3-1 9 3 10 4 5 5 3-1 10-4 17-5 26-3 16-3 25-2 32-6-2-10-6v-2l-4-2-7-7-9-14-3-9v-17l8-22 7-15 6-13 16-40z"
fill="#3CCCD4"
/>
<path
transform="translate(844,451)"
d="m0 0h14l10 4 8 6 7 7 4 9 2 8v8l-3 11-6 9-8 8-8 4-8 2h-12l-13-5-8-7-6-7-4-10-1-3v-12l4-11 6-8 9-8 9-4z"
fill="#7DD1DA"
/>
<path
transform="translate(424,451)"
d="m0 0h13l10 4 9 6 7 9 5 12v15l-7 14-7 8-8 5-10 3h-12l-11-4-9-6-7-8-5-11v-17l5-12 9-10 8-5z"
fill="#7DD1DA"
/>
<path
transform="translate(474,931)"
d="m0 0 7 6 6 7 3 5 13 11 20 13 12 7 14 7 5 6 2 8-3 16-4 9-5 6-8 2h-24l-6-4-7-8-5-12-7-23-13-53z"
fill="#17529E"
/>
<path
transform="translate(712,800)"
d="m0 0h5l1 2v13l-33 11-7 5-5 11v21l2 7 4 1h7l14-5v-14l-16 5v-15l10-5 20-6h4v44l-5 4-30 10h-11l-9-4-5-7-2-5-1-6v-20l2-10 6-12 6-7 10-6z"
fill="#FEFEFE"
/>
<path
transform="translate(620,740)"
d="m0 0h2v16l-13 4-11 4-14 4-6 4-1 9 5 2 10-3 10-4 4-1h13l5 7 1 3v13l-1 7-7 10-10 7-20 7-19 6-1 1h-5v-16l16-5 17-6 9-3v-2h2l1-9-1-1h-7l-18 6-5 1h-10v-2l-3-1-4-9v-12l3-10 4-6 11-7 22-8 4-1z"
fill="#F9FBFC"
/>
<path
transform="translate(776,960)"
d="m0 0h5l2 3v13l-4 19-2 8-7 25-3 7-4 1-16 1-3-2-11-1-6-9-2-9v-18l3-6 12-10 15-10 10-6z"
fill="#17519E"
/>
<path
transform="translate(462,629)"
d="m0 0 4 1 10 5 8 3 11 5 24 7 17 5 23 5 15 2 3 1 15 2 23 1-1 9-1 2-7 2-11-1-20-4-6-2-4 1 10 5 13 4 6 1-5 5-7 6-11-1-23-6-19-8-8-3-10-6-8-4-15-10-14-11-11-11z"
fill="#3ECDD4"
/>
<path
transform="translate(816,629)"
d="m0 0h2l-1 5-7 8-13 11-16 11-22 12-8 4-17 6-9 3-21 5h-6l-11-10 3-2 13-3 11-3 7-4v-1h-7l-7 3-22 4h-16l-4-6v-5l2-1 38-3 28-6 26-6 17-5 20-8 12-5z"
fill="#3FCBD4"
/>
<path
transform="translate(628,410)"
d="m0 0h26l16 4 12 5 8 4 13 10 7 10 5 13 1 5v15l-4 11-7 10-5 6-13 9-11 5-19 5-9 1h-15l-14-2-17-5-14-7-12-11-8-11-4-13v-11l2-11 7-14 9-9 10-7 15-7 14-4zm8 6-18 3-12 4-12 6-10 8-8 10-4 11v15l3 10 7 11 9 8 10 6 15 5 8 1h34l14-4 13-5 9-7 7-7 6-12 2-10-1-12-5-12-11-12-8-6-14-6-17-4z"
fill="#030509"
/>
<path
transform="translate(365,737)"
d="m0 0 4 4 9 17 9 12 11 12 13 9 13 5h8l5-3 3-7h2l2-7h1l-1 16-2 17-12 9-9 3h-13l-9-3-7-6-1-2v-6l3-3 11 1-2-4-8-4-12-11-7-7-7-12-4-14z"
fill="#10347E"
/>
<path
transform="translate(913,728)"
d="m0 0h1l1 5v22l-4 12-7 12-15 15-5 4-9 3v4l13-1 1 2-1 9-7 6-7 3h-16l-12-6-7-6-2-15-2-21h2l3 8 4 10 4 3h10l12-5 13-10 7-7 10-13 10-19z"
fill="#11347E"
/>
<path
transform="translate(564,474)"
d="m0 0h1l4 13 8 11 7 7 13 8 18 6 9 2 9 1h15l14-2 17-5 13-7 10-8 7-10 5-10 1-4h2l-3 16-7 14-7 9-11 9-14 7-17 4-7 1h-19l-17-3-11-4-12-6-6-4v-2l-4-2-5-5-6-8-5-11-2-9z"
fill="#10347E"
/>
<path
transform="translate(442,198)"
d="m0 0h9l-1 3 11 6 8 7h8l-3 2-16 9-8 6-10 7h-2l2-9 4-16v-10h-3l-3 9-4 13-12 26-7 10-6 8-14 14-11 8-12 4-8-1-6-8-3-7 1-3 7 3h11l14-7 11-9 8-7 9-11 9-12 8-16 5-15z"
fill="#11347E"
/>
<path
transform="translate(611,685)"
d="m0 0 4 1 1 7-8 6-7 9-1 3h-2v4l8-7 11-10 3-1 3 4v11l-6 8-7 8-9 7-16 6v2l-6 1h-8l2-16 3-10 8-14 7-8 14-9z"
fill="#3BCED5"
/>
<path
transform="translate(475,693)"
d="m0 0h1l-1 12-4 22-2 13-2 7-5 33-2 32v16l1 19 4 27 4 15 5 15 7 20-1 2-7-10-8-14-9-24-4-21-2-23v-22l3-29 6-34 7-27 5-17z"
fill="#71A3CE"
/>
<path
transform="translate(834,200)"
d="m0 0 6 1 4 5 3 13 8 13 7 10 8 10 7 8 11 9 11 7 8 3h10l6-3 3-3 1 2-5 13-5 6-8 1-11-4-11-7-10-9-8-8-8-14-7-10-8-19-6-19v-2h-3v11l6 24-4-2-18-12-14-8-4-3 10 1 7-7 8-5z"
fill="#11347E"
/>
<path
transform="translate(666,685)"
d="m0 0 9 2 11 6 8 8 7 10 4 9 3 9 1 15-10-1-15-6-12-7-5-4-4-7-6-13 1-7 4-2 4 5 3 2v2l5 2 7 6 3 1-4-8-16-16v-5z"
fill="#3BCDD4"
/>
<path
transform="translate(743,494)"
d="m0 0 11 2 9 5 6 8 1 7-1 2h-5l-5-7-3-1-2 5-4 5-7 8-7 7-14 10-16 8-15 6-16 4-11 2-13 1h-24l-5-2-2-3 2-3 26-1 24-3 19-5 17-7 15-9 14-12 10-13 1-3-9-2-1-1v-5z"
fill="#020304"
/>
<path
transform="translate(477,637)"
d="m0 0h6l7 4 25 8 26 7 25 5 11 2 15 2 23 1-1 9-1 2-7 2-11-1-20-4-6-2-4 1 12 6-4 1-11-4-12-5-17-5-36-14-20-11z"
fill="#3194A7"
/>
<path
transform="translate(819,643)"
d="m0 0 5 3-6 7-8 9-16 12-13 9-17 10-10 4-8 4-32 11-1 2 4 14 1 22h-1l-2-20-5-17-4-8 1-4 35-10 19-8 16-8 16-10 14-11z"
fill="#12347E"
/>
<path
transform="translate(380,796)"
d="m0 0 4 2 1 3-1 9 2 10-3-2-5-1-1 5h-2l-1 9-11 32-8 19-7 21-3 14 1 5 1 4 3-4v9l-5-5-4-8-2-6v-17l8-22 7-15 6-13 16-40z"
fill="#80D1DB"
/>
<path
transform="translate(461,643)"
d="m0 0 7 6 7 7 14 10 13 8 21 11 21 8 29 8 2 1-2 6-4 9h-1l-1-5-30-10-19-8-20-12-12-9-10-9-18-18z"
fill="#11347E"
/>
<path
transform="translate(898,796)"
d="m0 0 4 2 8 21 19 45 10 23 4 12v8l-3 11-4 8-2 1 1-9-4-19-10-29-8-21-10-29-2-11h-3l-2-7 1-5z"
fill="#83D1DB"
/>
<path
transform="translate(625,667)"
d="m0 0h28l4 2 2 6-1 10-4 8-8 5h-14v-2l-4-1-5-8-1-3v-12z"
fill="#3CCED5"
/>
<path
transform="translate(733,657)"
d="m0 0h2v2l24 1-2 2-25 9-8 4-3 1-4-2 4-3h-7l-7 3-22 4h-16l-4-6v-5l2-1 38-3z"
fill="#499ABE"
/>
<path
transform="translate(823,639)"
d="m0 0 6 2 26 15 13 10 13 11 12 12 10 13 6 11 4 12-1 11-2-2-5-16-9-13-11-12-8-8-8-6-4-4-13-10-21-14-11-7 2-4z"
fill="#71A3CE"
/>
<path
transform="translate(575,466)"
d="m0 0h1l1 13 4 6 4 5 10 6 13 6 21 4h24l20-4 9-4 9-5 9-7 2-3 3 1-6 9-8 8-8 5-17 6-8 2h-34l-10-2-13-4-14-9-8-9-5-9-1-5 1-3z"
fill="#4F9FC3"
/>
<path
transform="translate(456,639)"
d="m0 0 5 5-17 10-23 16-13 11-10 9-9 9-12 17-5 10-3 10-3-1 2-12 6-15 7-10 11-13 13-13h2v-2l13-10 17-11z"
fill="#71A4CE"
/>
<path
transform="translate(645,705)"
d="m0 0 4 1 5 13 5 8 9 9 6 5 5 2-3 2-11 3h-3v-13l-11 4-1 65-5 1 2-1v-64l2-10-1-12-2-1-18 7h-3l3-9 4-9z"
fill="#113881"
/>
<path
transform="translate(432,181)"
d="m0 0h12l9 3 13 9 5 4v2h2l7 8 1 5-3 2h-3l-4-6v-2h-2v-2l-4-2-13-8-7-2h-13l-12 6-9 7-12 11-4 4 2-6 8-14 6-7 13-9z"
fill="#6DA1CC"
/>
<path
transform="translate(836,181)"
d="m0 0h12l12 5 11 9 8 10 5 10-1 2-12-12-13-9-10-4h-13l-10 4-10 7-4 4h-2l-2 4-2 2-5-1v-3l9-11 8-7 11-7z"
fill="#6FA4CD"
/>
<path
transform="translate(474,931)"
d="m0 0 7 6 6 7 3 20 9 35 9 27 3 5 4 3-5-1-8-7-6-10-7-23-5-19-10-40z"
fill="#72A7CE"
/>
<path
transform="translate(834,200)"
d="m0 0 6 1 4 5 3 13 3 5v5h-2l-8-24v-2h-3v11l6 24-4-2-18-12-14-8-4-3 10 1 7-7 8-5z"
fill="#12347D"
/>
<path
transform="translate(609,450)"
d="m0 0h6l5 4 3 9v7l-3 9-4 4-6 1-6-5-2-5v-14l3-7z"
fill="#04060A"
/>
<path
transform="translate(666,450)"
d="m0 0h6l5 6 2 5v12l-4 8-4 3-6-1-5-5-2-6v-10l3-8z"
fill="#030408"
/>
<path
transform="translate(816,629)"
d="m0 0h2l-1 5-7 8-4 3-3-1h-2l-9 4-23 10-7 1-11-1-10-2v-1l25-6 10-3 20-8 12-5z"
fill="#3396A9"
/>
<path
transform="translate(813,696)"
d="m0 0h2l6 9 13 12 3 2v2l4 2 6 4 4 2-6 5-5 7-6-1-5-4-4-4-7-23z"
fill="#11347E"
/>
<path
transform="translate(619,697)"
d="m0 0 4 4v11l-6 8-7 8-9 7-16 6v2l-6 1h-8l1-4 2-4 2-7h1v9l11-3 9-6 10-8 4-5 3-9v-2l-4 5-9 7-3-1 5-5 14-13z"
fill="#4C9FC2"
/>
<path
transform="translate(461,704)"
d="m0 0 3 1-9 29-12 8-7-6-7-7v-1l6-2 6-4 12-11 6-5h2z"
fill="#12347E"
/>
<path
transform="translate(390,209)"
d="m0 0h2l-2 7-2 4-13 4-10 6-5 7-3 10-1 28-2-1-2-17v-12l2-14 3-6 8-7 10-4z"
fill="#6EA2CD"
/>
<path
transform="translate(661,697)"
d="m0 0 7 6 1 3 5 2 7 6-3 1-6-3h-2l1 5 6 8 5 3 2 4 8 3 11 3v-6l2 1 4 11-10-1-15-6-12-7-5-4-4-7-6-13 1-7z"
fill="#4EA0C3"
/>
<path
transform="translate(477,637)"
d="m0 0h6l7 4 25 8 26 7 18 4v1l-41-1-21-8-20-11z"
fill="#3496AA"
/>
<path
transform="translate(888,209)"
d="m0 0 9 2 18 7 8 6 3 5 2 9v25l-2 9h-1l-1-22-2-11-5-8-5-4-12-5-7-2-3-5z"
fill="#6DA2CC"
/>
<path
transform="translate(459,836)"
d="m0 0h1l4 33 4 17 6 18 7 20-1 2-7-10-8-14-9-24-1-9h1l2-13 1-1z"
fill="#71A7CE"
/>
<path
transform="translate(651,667)"
d="m0 0 6 2 2 6-1 10-4 8-8 5h-14v-2l-4-1-5-8-1-5h2l3 4 7 2h8l9-4 2-5v-6z"
fill="#499FBF"
/>
<path
transform="translate(733,657)"
d="m0 0h2v2l24 1-2 2-25 9-8 4-3 1-4-2 4-3-9-2-4-2 6-2 4-1-2-1 3-3z"
fill="#3192A7"
/>
<path
transform="translate(481,250)"
d="m0 0 7 1 5 4v7l-4 5-6 3h-8l-5-4v-7l5-6z"
fill="#70A2CD"
/>
<path
transform="translate(636,416)"
d="m0 0 17 1 12 3-3 2h-26v-2h-9l-1 2-5 1h-8l-2 2v3l-2 2-9 1-12 6-6 7-2-2 7-8 12-8 13-5 16-4z"
fill="#EEF4F8"
/>
<path
transform="translate(611,685)"
d="m0 0 4 1 1 7-8 6-7 9-1 3h-2l-1 4-4-2 1-8-3-4 4-5 14-8h2z"
fill="#479EBF"
/>
<path
transform="translate(585,665)"
d="m0 0 30 1-1 9-3 1v-2l-5 1-3-1h-11l-17-2-4-3 14-1z"
fill="#4A9FC0"
/>
<path
transform="translate(419,187)"
d="m0 0 9 1 2 3-3 3-10 6-14 12-7 8-1-2 8-15 8-10z"
fill="#6DA5CD"
/>
<path
transform="translate(607,686)"
d="m0 0h4v2l-16 9-3 5-1 4-3 1-1 5-6 7-5 13-3 10h-1v-10l4-14 8-14 7-8 14-9z"
fill="#8FCFDC"
/>
<path
transform="translate(886,827)"
d="m0 0 3 2v9l1 10 2 5 1 9 1 5h2l1 15 3 20 3 19v17h-1l-4-34-5-32-9-39v-5z"
fill="#6CB7D0"
/>
<path transform="translate(560,907)" d="m0 0h2v14l-11 4h-2l-1-4v-9l8-4z" fill="#283477" />
<path
transform="translate(664,686)"
d="m0 0 14 7 4 4v4h2l2 10-3 1-4-6-15-15z"
fill="#4C9EC1"
/>
<path transform="translate(567,856)" d="m0 0h2v14l-9 4h-4v-14z" fill="#273478" />
<path
transform="translate(600,727)"
d="m0 0 3 1 1 3-4 2 1 2-16 6v2l-6 1h-8l1-4 2-4 2-7h1v9l11-3 9-6z"
fill="#4290B0"
/>
<path transform="translate(615,903)" d="m0 0h1v14l-10 4h-3v-13l9-4z" fill="#263478" />
<path
transform="translate(661,697)"
d="m0 0 7 6 1 3 5 2 7 6-3 1-6-3-6 2-4-2-2 1-3-7 1-7z"
fill="#4FA1C4"
/>
<path transform="translate(732,711)" d="m0 0 2 1v12l-8 3h-4l-1-3v-9l7-3z" fill="#49A0C0" />
<path
transform="translate(462,629)"
d="m0 0 4 1 10 5 3 2h-2l-1 2v-2h-4v2l7 7 6 4v2l4 1 1 2 8 4v2l6 2v2l5 2 1 2-6-2-10-7-14-10-12-11-6-7z"
fill="#67A6CB"
/>
<path
transform="translate(759,651)"
d="m0 0h11l5 3-1 2-8 3h-8l-10-2-7-1v-1z"
fill="#3093A7"
/>
<path transform="translate(723,761)" d="m0 0h1v14l-9 3h-3v-13l8-3z" fill="#49A0C1" />
<path
transform="translate(676,728)"
d="m0 0 12 6 15 4v-6l2 1 4 11-10-1-15-6-5-5-3-2z"
fill="#3D9AB2"
/>
<path transform="translate(697,671)" d="m0 0h3l9 1-2 2-22 4h-16l-1-5h29z" fill="#338BA5" />
<path
transform="translate(625,667)"
d="m0 0h26v1l-20 1-1 2-1 16-4-2-1-3h-2v-10z"
fill="#83D1DC"
/>
<path
transform="translate(574,671)"
d="m0 0 20 2h10l7 1v2l2 1-7 2-11-1-20-4-4-2z"
fill="#2F86A1"
/>
<path transform="translate(630,829)" d="m0 0 1 3v51l-1 15-4 1 2-6v-61z" fill="#0F3882" />
<path
transform="translate(816,629)"
d="m0 0h2l-1 5-7 8-4 3-3-1-1-3-2-2 2-4 13-5z"
fill="#53A4C5"
/>
<path
transform="translate(607,686)"
d="m0 0h4v2l-16 9-5 4-5 9h-3l-1 5-4 6-1-3 8-14 7-8 14-9z"
fill="#E1ECF4"
/>
<path
transform="translate(859,269)"
d="m0 0 4 2 7 9 14 14 3 5-1 2-5-3-3-6-6-4-5-7-8-7-1-3z"
fill="#6AA3CC"
/>
<path
transform="translate(563,378)"
d="m0 0 2 2 2 6 1-8h1v52h-1l-2-19-1-23z"
fill="#123780"
/>
<path
transform="translate(619,697)"
d="m0 0 4 4v7h-1l-2-6v3l-2 4h-4v-4l-4 5-9 7-3-1 5-5 14-13z"
fill="#59A1C5"
/>
<path
transform="translate(390,209)"
d="m0 0h2l-2 7-2 4-4-1-3-2-5 2-7-2 4-2z"
fill="#5F9CC6"
/>
<path
transform="translate(898,796)"
d="m0 0 4 2 6 16-1 5-4-4-2-6h-3l-2-7 1-5z"
fill="#7FCEDA"
/>
<path
transform="translate(669,422)"
d="m0 0 5 1 11 5 10 8 4 4-1 2-8-7-7-4-12-1-2-7z"
fill="#D3EAF1"
/>
<path
transform="translate(673,688)"
d="m0 0 6 1 9 6 10 11 6 11-1 3-3-4v-3h-2v-2l-3-1-4-6-5-8-6-3-7-4z"
fill="#DCEDF3"
/>
<path
transform="translate(715,661)"
d="m0 0h2l1 5-9 1 9 2-6 3-9-1-4-3 6-2v-3z"
fill="#4497B9"
/>
<path
transform="translate(475,693)"
d="m0 0h1l-1 12-4 20-2 1v-2h-2l-1 2 1-8z"
fill="#70B2D0"
/>
<path
transform="translate(619,700)"
d="m0 0 3 3 1 9-5 6-4 2-1-3-2 1 3-9 5-2-1-5z"
fill="#328FA7"
/>
<path transform="translate(532,655)" d="m0 0 9 1 18 4v1l-41-1-1-2 17-1z" fill="#4E9FC3" />
<path
transform="translate(611,685)"
d="m0 0 4 1v5l-3 1-5 1-1 2-12 6-2-2 8-6 9-5h2z"
fill="#5FCCD6"
/>
<path
transform="translate(459,836)"
d="m0 0h1l1 11v10h-2v22l1 6-2-1-3-11 1-4 2-13 1-1z"
fill="#7DD0DA"
/>
<path transform="translate(838,380)" d="m0 0h1v40l-2-2-1-7v-23h1z" fill="#123881" />
<path
transform="translate(658,733)"
d="m0 0 5 1 4 3v3h-2v-3h-2l1 7 1-3 2 1 12 1-3 2-11 3h-3v-13l-4 1z"
fill="#17509D"
/>
<path
transform="translate(666,685)"
d="m0 0 9 2-1 2 6 3 8 5 2 4-7-1-6-7-12-6z"
fill="#7ED2DB"
/>
<path transform="translate(604,424)" d="m0 0 2 1-5 5-13 7-6 7-2-2 7-8 12-8z" fill="#D0E9F0" />
<path transform="translate(739,656)" d="m0 0h10l10 2 4 1-4 2-25-1 1-3z" fill="#45AEC7" />
<path transform="translate(543,196)" d="m0 0 7 1 21 1-1 2h-30l-2-2z" fill="#7FD0DA" />
<path
transform="translate(391,826)"
d="m0 0 5 2-1 10-4 15-1-4 1-4v-15l-1-3z"
fill="#7BCCD9"
/>
<path transform="translate(441,389)" d="m0 0 3 1v19l-2 10h-1z" fill="#0F357F" />
<path transform="translate(886,827)" d="m0 0 3 2v9l1 14-2-2-4-17v-5z" fill="#7ACCD8" />
<path transform="translate(372,716)" d="m0 0 2 1v5l-3 7-2 7-3-1 2-12z" fill="#6AA5CC" />
<path
transform="translate(590,700)"
d="m0 0 2 1-1 5-3 1-1 5-6 7h-2l3-9h3l2-5z"
fill="#7FD2DB"
/>
<path transform="translate(389,859)" d="m0 0 1 3-2 9-2 10-2-2-1-14h2l2-5z" fill="#4C92BB" />
<path transform="translate(651,667)" d="m0 0 6 2 2 6-1 10-2 2 1-9-3 1-2-8z" fill="#509FC3" />
<path transform="translate(469,904)" d="m0 0 3 4 2 4 3 1 4 11-1 2-7-10-5-8z" fill="#84C8DA" />
<path transform="translate(607,686)" d="m0 0h4v2l-16 9-3 3-2-1 5-6z" fill="#EBF4F8" />
<path transform="translate(780,997)" d="m0 0 1 2 2 1-1 5h2l1 4-4 9h-1z" fill="#16519E" />
<path transform="translate(776,646)" d="m0 0 2 1-1 2h3v3l-6 2-6-2-5-1 3-2z" fill="#4C9DC0" />
<path transform="translate(890,850)" d="m0 0 2 3 1 9 1 5h2v11l-2 1-5-27z" fill="#5EA4C8" />
<path transform="translate(378,932)" d="m0 0h1l-1 22-6-2-2-1v-2l5-1 2-14z" fill="#5392BC" />
<path
transform="translate(483,651)"
d="m0 0 6 2 1 2 8 4v2l6 2v2l5 2 1 2-6-2-10-7-11-8z"
fill="#65ACCC"
/>
<path transform="translate(381,216)" d="m0 0 4 4-19 1-1-3 6-1 6 1z" fill="#7BCFD9" />
<path
transform="translate(600,727)"
d="m0 0 3 1 1 3-4 2 1 2-5 1h-2l-3-1 5-5z"
fill="#308EA5"
/>
<path transform="translate(619,697)" d="m0 0 4 4v7h-1l-2-6v3l-2 4h-4l3-9z" fill="#4E9DC1" />
<path transform="translate(713,390)" d="m0 0h1l1 24-2-1-1-3v12l-2-3 1-15h1z" fill="#10357F" />
<path transform="translate(548,684)" d="m0 0h7l2 2 9 3v2l-11-2-9-3z" fill="#4C96BE" />
<path transform="translate(405,831)" d="m0 0h21l-1 3-20 1-1 2z" fill="#204992" />
<path transform="translate(608,422)" d="m0 0h5l-2 3v3l-2 2h-8l2-3h2l1-4z" fill="#F5F8FB" />
<path transform="translate(440,824)" d="m0 0h2v24h-1l-2-19h-5v-2z" fill="#5092BD" />
<path
transform="translate(578,680)"
d="m0 0 10 2 6 1-5 5-5 3v-3l2-1v-2l-8-4z"
fill="#5BA2C8"
/>
<path transform="translate(557,669)" d="m0 0 5 2 10 6 5 2-4 1-11-4-4-2z" fill="#50A1C3" />
<path transform="translate(354,444)" d="m0 0h1l1 32-2-3-2-21h1z" fill="#63A1C9" />
<path transform="translate(669,422)" d="m0 0 5 1 4 2 1 4-1 1h-7l-2-7z" fill="#F3F9FA" />
<path
transform="translate(700,716)"
d="m0 0 3 2v2h2l3 9 1 14h-1l-2-12-3-9h-2z"
fill="#7CCCD9"
/>
<path transform="translate(522,674)" d="m0 0h5l6 3v2h3v2l6 1 2 2-9-2-14-6z" fill="#58A2C5" />
<path
transform="translate(690,701)"
d="m0 0h2l3 4v2l5 2 4 8-1 3-3-4v-3h-2v-2l-3-1-4-6z"
fill="#AADFE6"
/>
<path transform="translate(898,796)" d="m0 0 4 2 1 7h-5l-2-3 1-5z" fill="#EEF4F8" />
<path transform="translate(727,684)" d="m0 0h7v2l-9 3-13 3 3-3 11-3z" fill="#478EB9" />
<path transform="translate(396,219)" d="m0 0v3l-5 8-4-1-1-2v-7h2l1 5h3l2-5z" fill="#1A357C" />
<path transform="translate(723,531)" d="m0 0 2 1-10 7-7 4h-2l2-4z" fill="#1D357C" />
<path transform="translate(822,359)" d="m0 0 5 5 2-3 1 1 1 9 2 6-3-3-7-12z" fill="#153B81" />
<path transform="translate(699,544)" d="m0 0 4 1-12 5-9 2v-2l12-4z" fill="#1B347B" />
<path transform="translate(630,829)" d="m0 0 1 3v16h-3v-16z" fill="#103983" />
<path transform="translate(655,678)" d="m0 0h2v10l-5 4 1-13z" fill="#2F98A8" />
<path transform="translate(758,673)" d="m0 0h3v2l-10 5-6 1-1-2 10-5z" fill="#549FC3" />
<path transform="translate(677,746)" d="m0 0h8l5 2-2 2-13-1-1-2z" fill="#6DA1CC" />
<path transform="translate(690,423)" d="m0 0 5 2 6 3 8 10-2 2-6-8-11-8z" fill="#1F347A" />
<path transform="translate(537,836)" d="m0 0h1v22l9-1-3 2-7 2z" fill="#0F357F" />
<path transform="translate(460,781)" d="m0 0h1v16l-1 6-2-2v-15z" fill="#77C5D6" />
<path transform="translate(622,682)" d="m0 0h2l3 4 3 1 1 4-5 2-4-9z" fill="#4B9DBE" />
<path transform="translate(623,675)" d="m0 0h4l1 12-5-5z" fill="#3BCED5" />
<path transform="translate(737,349)" d="m0 0 2 1-4 2v2h-2v2l-4 2v-7l1-1z" fill="#173A80" />
<path transform="translate(878,289)" d="m0 0 6 5 3 5-1 2-5-3-3-6z" fill="#71B5D1" />
<path transform="translate(842,210)" d="m0 0h2l4 10 2 4v5h-2l-6-18z" fill="#2A3477" />
<path transform="translate(379,213)" d="m0 0h2l1 3-6 3-7-2 4-2z" fill="#70A1CD" />
<path transform="translate(839,824)" d="m0 0 5 2 5 3-2 1-7-1v12h-1z" fill="#5E9DC6" />
<path transform="translate(803,645)" d="m0 0 2 1-8 7-8 5 2-4h2l2-4z" fill="#75BBD3" />
<path transform="translate(652,554)" d="m0 0h11l3 2-16 1-5-1v-1z" fill="#1E347A" />
<path transform="translate(706,678)" d="m0 0 4 1-10 4-10 2 2-3 11-3z" fill="#4895BB" />
<path transform="translate(455,869)" d="m0 0 4 2v8l1 6-2-1-3-11z" fill="#7ECBD9" />
<path transform="translate(672,690)" d="m0 0 6 3 4 4v2l-6-2-5-6z" fill="#3CCAD3" />
<path transform="translate(461,914)" d="m0 0 2 3 1 2v7l-2 5-1-4z" fill="#4C98BE" />
<path transform="translate(662,711)" d="m0 0 5 2h2l1 4-3 1-2 2-4-8z" fill="#309AA9" />
<path transform="translate(739,656)" d="m0 0 9 1-4 2-3 1h-7l1-3z" fill="#51A1C4" />
<path transform="translate(823,639)" d="m0 0 4 2 1 2h-2l-2 4-4-3 2-4z" fill="#588FBC" />
<path transform="translate(437,205)" d="m0 0h3l-3 12h-3l1-6z" fill="#1E347A" />
<path transform="translate(575,466)" d="m0 0h1l1 13 1 3-2 3-3-9 1-3z" fill="#6EA3CD" />
<path transform="translate(836,402)" d="m0 0h1l1 5h1v13l-2-2-1-7z" fill="#0F357F" />
<path transform="translate(858,832)" d="m0 0h18l-3 2-7 1-8-1z" fill="#244188" />
<path transform="translate(676,728)" d="m0 0 12 6-1 3h-3l-5-5-3-2z" fill="#399BAF" />
<path
transform="translate(630,689)"
d="m0 0 4 1-1 2h-2l4 5-6-1-2-2 1-4v2l2-2z"
fill="#6B9FCB"
/>
<path transform="translate(343,922)" d="m0 0h3l1 4 3-4v9l-5-5z" fill="#7CCBD9" />
<path transform="translate(666,685)" d="m0 0 9 2v3l-2 1-8-4z" fill="#88C8D9" />
<path transform="translate(461,350)" d="m0 0h7l-6 7-3 2 2-4z" fill="#1E347A" />
<path transform="translate(380,796)" d="m0 0 4 2 1 3h-5v2h-2l1-6z" fill="#DFEFF4" />
<path transform="translate(611,685)" d="m0 0 4 1v5l-3 1-4-1 1-2h4v-2h-2z" fill="#92D1DE" />
<path transform="translate(549,443)" d="m0 0 1 4v3h-8l5-5z" fill="#113780" />
<path transform="translate(608,770)" d="m0 0h8v2l2 1v2l-7-1v-2h-3z" fill="#0B2455" />
<path transform="translate(467,718)" d="m0 0h3l1 7-2 1v-2h-2l-1 2z" fill="#6FAACF" />
<path transform="translate(496,660)" d="m0 0 7 2 1 3 5 2 1 2-6-2-8-6z" fill="#70B7D1" />
<path transform="translate(617,703)" d="m0 0 3 1-1 4-5 1 1-5z" fill="#47A0BF" />
<path transform="translate(756,510)" d="m0 0 4 1 1 3-5 1-3 3-2-1z" fill="#213479" />
<path transform="translate(715,476)" d="m0 0h2l-2 11-5 3 2-5z" fill="#1C347B" />
{/* mood overlay glyphs - new additions, not extracted from mascot art */}
<g className="sagi-zzz">
<text x="990" y="260">
z
</text>
<text x="1075" y="150">
z
</text>
</g>
<g className="sagi-bang">
<text x="1020" y="260">
!
</text>
</g>
<g className="sagi-sparkle">
<path d="M1050 380 l18 46 l46 18 l-46 18 l-18 46 l-18 -46 l-46 -18 l46 -18 z" />
</g>
</svg>
);
}
@@ -1,6 +1,6 @@
/**
* @file TabbyPanel.tsx
* @description Expanded Tabby panel: a live status strip (live / waiting /
* @file SagiPanel.tsx
* @description Expanded Sagi panel: a live status strip (live / waiting /
* errored stat chips + connection state), quick navigation actions, and a
* local "Ask" box. Pure presentational - all data and the ask/navigation
* behavior are injected by the container.
@@ -9,7 +9,7 @@
/* =============================================================================
* MODULE_GUIDE extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Tabby is the optional on-screen cat assistant quips, intents, and lightweight event reactions layered above the dashboard chrome.
* **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.
@@ -27,7 +27,7 @@
* - `./brain`
*
* ## Public surface
* - `TabbyPanel` exported API; see TSDoc on the symbol for behavior.
* - `SagiPanel` exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
@@ -42,7 +42,7 @@
/* -----------------------------------------------------------------------------
* EXPORT CATALOG quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **TabbyPanel**
* **SagiPanel**
* 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.
@@ -64,10 +64,10 @@ import {
Radio,
type LucideIcon,
} from "lucide-react";
import type { TabbyStatus } from "./brain";
import type { SagiStatus } from "./brain";
interface TabbyPanelProps {
status: TabbyStatus;
interface SagiPanelProps {
status: SagiStatus;
muted: boolean;
onToggleMute: () => void;
onClearAlerts: () => void;
@@ -77,7 +77,7 @@ interface TabbyPanelProps {
onClose: () => void;
}
export function TabbyPanel({
export function SagiPanel({
status,
muted,
onToggleMute,
@@ -85,7 +85,7 @@ export function TabbyPanel({
onNavigate,
onAsk,
onClose,
}: TabbyPanelProps) {
}: SagiPanelProps) {
const [query, setQuery] = useState("");
const [answer, setAnswer] = useState<string | null>(null);
@@ -100,7 +100,7 @@ export function TabbyPanel({
<div
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl shadow-black/50 backdrop-blur-md animate-slide-up"
role="dialog"
aria-label="Tabby companion"
aria-label="Sagi companion"
>
{/* header */}
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
@@ -108,7 +108,7 @@ export function TabbyPanel({
<span className="text-base leading-none" aria-hidden>
🐾
</span>
<span className="text-sm font-semibold text-fg-primary">Tabby</span>
<span className="text-sm font-semibold text-fg-primary">Sagi</span>
<span
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
status.connected
@@ -128,7 +128,7 @@ export function TabbyPanel({
<button
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
onClick={onClose}
aria-label="Close Tabby"
aria-label="Close Sagi"
>
<X size={15} />
</button>
@@ -189,10 +189,10 @@ export function TabbyPanel({
<form onSubmit={submit} className="flex items-center gap-1.5">
<input
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
placeholder="Ask Tabby… (e.g. any errors?)"
placeholder="Ask Sagi… (e.g. any errors?)"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Ask Tabby"
aria-label="Ask Sagi"
/>
<button
type="submit"
@@ -1,8 +1,8 @@
/**
* @file SpeechBubble.tsx
* @description Transient speech bubble rendered above the Tabby cat mascot.
* @description Transient speech bubble rendered above the Sagi mascot.
* Pure presentation visibility timing and quip selection live in
* {@link useTabbyBrain}; this component only paints the bubble and handles
* {@link useSagiBrain}; this component only paints the bubble and handles
* user dismissal.
*
* ## Accessibility
@@ -15,7 +15,7 @@
/* =============================================================================
* MODULE_GUIDE extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Tabby is the optional on-screen cat assistant quips, intents, and lightweight event reactions layered above the dashboard chrome.
* **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.
@@ -61,13 +61,13 @@ interface SpeechBubbleProps {
}
/**
* Animated speech bubble above Tabby.
* Animated speech bubble above Sagi.
* @param props See {@link SpeechBubbleProps}.
*/
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
return (
<div
className="tabby-bubble tabby-bubble-enter"
className="sagi-bubble sagi-bubble-enter"
role="status"
aria-live="polite"
onClick={onDismiss}
@@ -1,20 +1,20 @@
/**
* @file Tabby.test.tsx
* @description Render tests for the Tabby companion component mounting, mood rendering, the B panel toggle, and accessibility attributes.
* @file Sagi.test.tsx
* @description Render tests for the Sagi companion component mounting, mood rendering, the B panel toggle, and accessibility attributes.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, act, cleanup, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { Tabby } from "../Tabby";
import { Sagi } from "../Sagi";
import { eventBus } from "../../../lib/eventBus";
import type { WSMessage, Session } from "../../../lib/types";
function renderTabby() {
function renderSagi() {
return render(
<MemoryRouter>
<Tabby />
<Sagi />
</MemoryRouter>
);
}
@@ -38,27 +38,27 @@ afterEach(() => {
vi.useRealTimers();
});
describe("Tabby widget", () => {
describe("Sagi widget", () => {
it("renders the avatar button by default", () => {
renderTabby();
expect(screen.getByRole("button", { name: /open tabby companion/i })).toBeInTheDocument();
expect(screen.getByRole("img", { name: /tabby/i })).toBeInTheDocument();
renderSagi();
expect(screen.getByRole("button", { name: /open sagi companion/i })).toBeInTheDocument();
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
});
it("opens the panel on click and answers a local status question", () => {
renderTabby();
fireEvent.click(screen.getByRole("button", { name: /open tabby companion/i }));
const panel = screen.getByRole("dialog", { name: /tabby companion/i });
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
expect(panel).toBeInTheDocument();
const input = within(panel).getByLabelText(/ask tabby/i);
const input = within(panel).getByLabelText(/ask sagi/i);
fireEvent.change(input, { target: { value: "status" } });
fireEvent.submit(input.closest("form")!);
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
});
it("toggles open/closed with Cmd/Ctrl+B and closes with Esc", () => {
renderTabby();
renderSagi();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
act(() => {
fireEvent.keyDown(window, { key: "b", metaKey: true });
@@ -71,47 +71,47 @@ describe("Tabby widget", () => {
});
it("shows an error badge when a session errors", () => {
renderTabby();
renderSagi();
act(() => {
eventBus.publish(sessionMsg("a", "error"));
});
const btn = screen.getByRole("button", { name: /open tabby companion/i });
const btn = screen.getByRole("button", { name: /open sagi companion/i });
expect(within(btn).getByText("1")).toBeInTheDocument();
});
it("reflects the live count in the panel status", () => {
renderTabby();
renderSagi();
act(() => {
eventBus.publish(sessionMsg("a", "active"));
eventBus.publish(sessionMsg("b", "active"));
});
fireEvent.click(screen.getByRole("button", { name: /open tabby companion/i }));
const panel = screen.getByRole("dialog", { name: /tabby companion/i });
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
// The "live" stat chip shows value 2 next to its label.
const liveChip = within(panel).getByText("live").closest("div")!;
expect(within(liveChip).getByText("2")).toBeInTheDocument();
});
it("respects the enabled preference", () => {
localStorage.setItem("agent-dashboard-tabby-enabled", "false");
renderTabby();
expect(screen.queryByRole("button", { name: /open tabby companion/i })).not.toBeInTheDocument();
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
renderSagi();
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
});
it("a tap (no movement) still opens the panel", () => {
renderTabby();
const btn = screen.getByRole("button", { name: /open tabby companion/i });
renderSagi();
const btn = screen.getByRole("button", { name: /open sagi companion/i });
act(() => {
fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 });
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
});
fireEvent.click(btn);
expect(screen.getByRole("dialog", { name: /tabby companion/i })).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
});
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
renderTabby();
const btn = screen.getByRole("button", { name: /open tabby companion/i });
renderSagi();
const btn = screen.getByRole("button", { name: /open sagi companion/i });
// Default dock is bottom-right. Drag far to the left past the threshold.
act(() => {
fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 });
@@ -121,29 +121,29 @@ describe("Tabby widget", () => {
// The synthetic click that follows a drag must be swallowed.
fireEvent.click(btn);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
const saved = JSON.parse(localStorage.getItem("agent-dashboard-tabby-pos") || "{}");
const saved = JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}");
expect(saved.side).toBe("left");
expect(typeof saved.y).toBe("number");
});
it("a sub-threshold pointer move is treated as a tap, not a drag", () => {
renderTabby();
const btn = screen.getByRole("button", { name: /open tabby companion/i });
renderSagi();
const btn = screen.getByRole("button", { name: /open sagi companion/i });
act(() => {
fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 });
fireEvent.pointerMove(btn, { clientX: 992, clientY: 701 }); // < 5px threshold
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
});
fireEvent.click(btn);
expect(screen.getByRole("dialog", { name: /tabby companion/i })).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
// No position was persisted because no real drag happened.
expect(localStorage.getItem("agent-dashboard-tabby-pos")).toBeNull();
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
});
it("restores a persisted left-edge position on mount", () => {
localStorage.setItem("agent-dashboard-tabby-pos", JSON.stringify({ side: "left", y: 0.2 }));
renderTabby();
const btn = screen.getByRole("button", { name: /open tabby companion/i }) as HTMLElement;
localStorage.setItem("agent-dashboard-sagi-pos", JSON.stringify({ side: "left", y: 0.2 }));
renderSagi();
const btn = screen.getByRole("button", { name: /open sagi companion/i }) as HTMLElement;
// Left-docked → inline left equals the edge margin (16px).
expect(btn.style.left).toBe("16px");
});
@@ -1,13 +1,13 @@
/**
* @file brain.test.ts
* @description Unit tests for the Tabby state brain initial state, mood transitions driven by dashboard events, and status derivation.
* @description Unit tests for the Sagi state brain initial state, mood transitions driven by dashboard events, and status derivation.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import {
initialTabbyState,
reduceTabby,
initialSagiState,
reduceSagi,
deriveMood,
statusOf,
clearErrors,
@@ -16,7 +16,7 @@ import {
WORRIED_MS,
STUCK_MS,
SLEEP_MS,
type TabbyState,
type SagiState,
} from "../brain";
import type {
WSMessage,
@@ -53,65 +53,65 @@ function runStatusMsg(d: Partial<RunStatusPayload>): WSMessage {
describe("deriveMood priority", () => {
it("disconnected outranks everything", () => {
const s: TabbyState = { ...initialTabbyState(T0), connected: false, worriedUntil: T0 + 9999 };
const s: SagiState = { ...initialSagiState(T0), connected: false, worriedUntil: T0 + 9999 };
expect(deriveMood(s, T0)).toBe("disconnected");
});
it("worried outranks stuck", () => {
let s = initialTabbyState(T0);
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0)); // live
let s = initialSagiState(T0);
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0)); // live
s = { ...s, lastActivityAt: T0 - STUCK_MS - 1, worriedUntil: T0 + 100 };
expect(deriveMood(s, T0)).toBe("worried");
});
it("stuck when a live session goes silent past STUCK_MS", () => {
let s = initialTabbyState(T0);
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
let s = initialSagiState(T0);
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
expect(deriveMood(s, T0 + STUCK_MS + 1)).toBe("stuck");
});
it("happy is transient then falls back to idle when nothing live", () => {
let s = initialTabbyState(T0);
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
({ state: s } = reduceTabby(s, sessionMsg("a", "completed"), T0)); // no longer live
let s = initialSagiState(T0);
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
({ state: s } = reduceSagi(s, sessionMsg("a", "completed"), T0)); // no longer live
expect(deriveMood(s, T0 + 10)).toBe("happy");
expect(deriveMood(s, T0 + HAPPY_MS + 1)).toBe("idle");
});
it("thinking shows when set and nothing higher applies", () => {
const s = { ...initialTabbyState(T0), thinking: true };
const s = { ...initialSagiState(T0), thinking: true };
expect(deriveMood(s, T0)).toBe("thinking");
});
it("watching when a session is live and recent", () => {
let s = initialTabbyState(T0);
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
let s = initialSagiState(T0);
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
expect(deriveMood(s, T0 + 1000)).toBe("watching");
});
it("sleeping after SLEEP_MS of no activity and nothing live", () => {
const s = initialTabbyState(T0);
const s = initialSagiState(T0);
expect(deriveMood(s, T0 + SLEEP_MS + 1)).toBe("sleeping");
});
it("idle by default", () => {
expect(deriveMood(initialTabbyState(T0), T0)).toBe("idle");
expect(deriveMood(initialSagiState(T0), T0)).toBe("idle");
});
});
describe("reduceTabby counts and pulses", () => {
describe("reduceSagi counts and pulses", () => {
it("tracks live count accurately across transitions", () => {
let s = initialTabbyState(T0);
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
({ state: s } = reduceTabby(s, sessionMsg("b", "active"), T0));
let s = initialSagiState(T0);
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
({ state: s } = reduceSagi(s, sessionMsg("b", "active"), T0));
expect(statusOf(s).liveCount).toBe(2);
({ state: s } = reduceTabby(s, sessionMsg("a", "completed"), T0));
({ state: s } = reduceSagi(s, sessionMsg("a", "completed"), T0));
expect(statusOf(s).liveCount).toBe(1);
});
it("counts errored sessions and emits error pulse", () => {
let s = initialTabbyState(T0);
const r = reduceTabby(s, sessionMsg("a", "error"), T0);
let s = initialSagiState(T0);
const r = reduceSagi(s, sessionMsg("a", "error"), T0);
s = r.state;
expect(r.pulse).toBe("error");
expect(statusOf(s).errorCount).toBe(1);
@@ -119,70 +119,70 @@ describe("reduceTabby counts and pulses", () => {
});
it("session_start pulse only on first active transition", () => {
let s = initialTabbyState(T0);
const r1 = reduceTabby(s, sessionMsg("a", "active"), T0);
let s = initialSagiState(T0);
const r1 = reduceSagi(s, sessionMsg("a", "active"), T0);
expect(r1.pulse).toBe("session_start");
const r2 = reduceTabby(r1.state, sessionMsg("a", "active"), T0);
const r2 = reduceSagi(r1.state, sessionMsg("a", "active"), T0);
expect(r2.pulse).toBe(null);
});
it("session_done pulse only when the session was tracked", () => {
let s = initialTabbyState(T0);
const untracked = reduceTabby(s, sessionMsg("ghost", "completed"), T0);
let s = initialSagiState(T0);
const untracked = reduceSagi(s, sessionMsg("ghost", "completed"), T0);
expect(untracked.pulse).toBe(null);
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
const done = reduceTabby(s, sessionMsg("a", "completed"), T0);
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
const done = reduceSagi(s, sessionMsg("a", "completed"), T0);
expect(done.pulse).toBe("session_done");
});
it("agent error triggers worried via pulse", () => {
const r = reduceTabby(initialTabbyState(T0), agentMsg("error"), T0);
const r = reduceSagi(initialSagiState(T0), agentMsg("error"), T0);
expect(r.pulse).toBe("error");
expect(deriveMood(r.state, T0)).toBe("worried");
});
it("a newly created subagent emits subagent_spawn, a main agent does not", () => {
expect(reduceTabby(initialTabbyState(T0), agentCreatedMsg("subagent"), T0).pulse).toBe(
expect(reduceSagi(initialSagiState(T0), agentCreatedMsg("subagent"), T0).pulse).toBe(
"subagent_spawn"
);
expect(reduceTabby(initialTabbyState(T0), agentCreatedMsg("main"), T0).pulse).toBe(null);
expect(reduceSagi(initialSagiState(T0), agentCreatedMsg("main"), T0).pulse).toBe(null);
});
it("waiting transition emits a waiting pulse once and still counts as live", () => {
let s = initialTabbyState(T0);
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0)); // active
const first = reduceTabby(s, waitingMsg("a"), T0);
let s = initialSagiState(T0);
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0)); // active
const first = reduceSagi(s, waitingMsg("a"), T0);
expect(first.pulse).toBe("waiting");
expect(statusOf(first.state).liveCount).toBe(1);
// A repeat waiting update does not re-announce.
const second = reduceTabby(first.state, waitingMsg("a"), T0);
const second = reduceSagi(first.state, waitingMsg("a"), T0);
expect(second.pulse).toBe(null);
});
it("failure event types set worried, normal events do not", () => {
const fail = reduceTabby(initialTabbyState(T0), eventMsg("toolError"), T0);
const fail = reduceSagi(initialSagiState(T0), eventMsg("toolError"), T0);
expect(fail.pulse).toBe("error");
const ok = reduceTabby(initialTabbyState(T0), eventMsg("postToolUse"), T0);
const ok = reduceSagi(initialSagiState(T0), eventMsg("postToolUse"), T0);
expect(ok.pulse).toBe(null);
expect(ok.state.worriedUntil).toBe(0);
});
it("run_status updates activity timestamp only (no exit code available)", () => {
const running = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "running" }), T0);
const running = reduceSagi(initialSagiState(T0), runStatusMsg({ status: "running" }), T0);
expect(running.pulse).toBe(null);
const gone = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "gone" }), T0);
const gone = reduceSagi(initialSagiState(T0), runStatusMsg({ status: "gone" }), T0);
expect(gone.pulse).toBe(null);
});
it("any handled message refreshes lastActivityAt", () => {
const s = { ...initialTabbyState(T0), lastActivityAt: T0 - 99999 };
const { state } = reduceTabby(s, eventMsg("postToolUse"), T0 + 5);
const s = { ...initialSagiState(T0), lastActivityAt: T0 - 99999 };
const { state } = reduceSagi(s, eventMsg("postToolUse"), T0 + 5);
expect(state.lastActivityAt).toBe(T0 + 5);
});
it("ignores unrelated message types without mutating", () => {
const s = initialTabbyState(T0);
const r = reduceTabby(s, { type: "import.progress", data: {} as never, timestamp: "x" }, T0);
const s = initialSagiState(T0);
const r = reduceSagi(s, { type: "import.progress", data: {} as never, timestamp: "x" }, T0);
expect(r.state).toBe(s);
expect(r.pulse).toBe(null);
});
@@ -191,7 +191,7 @@ describe("reduceTabby counts and pulses", () => {
describe("seedSessions", () => {
it("hydrates live/waiting/errored counts from a REST snapshot", () => {
const s = seedSessions(
initialTabbyState(T0),
initialSagiState(T0),
[
{ id: "a", status: "active" },
{ id: "b", status: "active", awaiting_input_since: "2026-05-29T00:00:00Z" },
@@ -209,17 +209,17 @@ describe("seedSessions", () => {
describe("statusOf waiting", () => {
it("counts a waiting session as both live and waiting", () => {
let s = initialTabbyState(T0);
({ state: s } = reduceTabby(s, waitingMsg("a"), T0));
let s = initialSagiState(T0);
({ state: s } = reduceSagi(s, waitingMsg("a"), T0));
expect(statusOf(s)).toMatchObject({ liveCount: 1, waitingCount: 1, errorCount: 0 });
});
});
describe("clearErrors", () => {
it("drops errored sessions but keeps active ones", () => {
let s = initialTabbyState(T0);
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
({ state: s } = reduceTabby(s, sessionMsg("b", "error"), T0));
let s = initialSagiState(T0);
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
({ state: s } = reduceSagi(s, sessionMsg("b", "error"), T0));
s = clearErrors(s);
expect(statusOf(s).errorCount).toBe(0);
expect(statusOf(s).liveCount).toBe(1);
@@ -1,14 +1,14 @@
/**
* @file intents.test.ts
* @description Unit tests for Tabby's intent matcher natural-language queries mapped to dashboard navigation and status intents.
* @description Unit tests for Sagi's intent matcher natural-language queries mapped to dashboard navigation and status intents.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { matchIntent } from "../intents";
import type { TabbyStatus } from "../brain";
import type { SagiStatus } from "../brain";
const status = (over: Partial<TabbyStatus> = {}): TabbyStatus => ({
const status = (over: Partial<SagiStatus> = {}): SagiStatus => ({
liveCount: 0,
waitingCount: 0,
errorCount: 0,
@@ -1,6 +1,6 @@
/**
* @file quips.test.ts
* @description Unit tests for Tabby's quip picker full key coverage and stable selection behavior for the speech-bubble lines.
* @description Unit tests for Sagi's quip picker full key coverage and stable selection behavior for the speech-bubble lines.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
@@ -1,16 +1,16 @@
/**
* @file brain.ts
* @description Pure, framework-free core of the Tabby companion. Reduces the
* @description Pure, framework-free core of the Sagi companion. Reduces the
* dashboard's live WebSocket stream into a small mood model and derives the
* current cat mood from that model plus the wall clock. Kept side-effect free
* current mood from that model plus the wall clock. Kept side-effect free
* so it can be unit-tested without React, timers, or the DOM. The React hook
* (`useTabbyBrain`) wires this to the event bus and to real timers.
* (`useSagiBrain`) wires this to the event bus and to real timers.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Tabby is the optional on-screen cat assistant quips, intents, and lightweight event reactions layered above the dashboard chrome.
* **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.
@@ -29,18 +29,18 @@
*
* ## Public surface
* - `Mood` exported API; see TSDoc on the symbol for behavior.
* - `TabbyPulse` exported API; see TSDoc on the symbol for behavior.
* - `TabbyStatus` exported API; see TSDoc on the symbol for behavior.
* - `TabbyState` exported API; see TSDoc on the symbol for behavior.
* - `SagiPulse` exported API; see TSDoc on the symbol for behavior.
* - `SagiStatus` exported API; see TSDoc on the symbol for behavior.
* - `SagiState` exported API; see TSDoc on the symbol for behavior.
* - `HAPPY_MS` exported API; see TSDoc on the symbol for behavior.
* - `WORRIED_MS` exported API; see TSDoc on the symbol for behavior.
* - `STUCK_MS` exported API; see TSDoc on the symbol for behavior.
* - `SLEEP_MS` exported API; see TSDoc on the symbol for behavior.
* - `FAILURE_EVENT_TYPES` exported API; see TSDoc on the symbol for behavior.
* - `initialTabbyState` exported API; see TSDoc on the symbol for behavior.
* - `initialSagiState` exported API; see TSDoc on the symbol for behavior.
* - `statusOf` exported API; see TSDoc on the symbol for behavior.
* - `deriveMood` exported API; see TSDoc on the symbol for behavior.
* - `reduceTabby` exported API; see TSDoc on the symbol for behavior.
* - `reduceSagi` exported API; see TSDoc on the symbol for behavior.
* - `seedSessions` exported API; see TSDoc on the symbol for behavior.
* - `clearErrors` exported API; see TSDoc on the symbol for behavior.
*
@@ -62,17 +62,17 @@
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **TabbyPulse**
* **SagiPulse**
* 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.
*
* **TabbyStatus**
* **SagiStatus**
* 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.
*
* **TabbyState**
* **SagiState**
* 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.
@@ -102,7 +102,7 @@
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **initialTabbyState**
* **initialSagiState**
* 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.
@@ -117,7 +117,7 @@
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **reduceTabby**
* **reduceSagi**
* 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.
@@ -136,7 +136,7 @@
import type { WSMessage, Session, Agent, RunStatusPayload, DashboardEvent } from "../../lib/types";
/** All moods Tabby can express, highest priority first (see `deriveMood`). */
/** All moods Sagi can express, highest priority first (see `deriveMood`). */
export type Mood =
| "disconnected"
| "worried"
@@ -148,11 +148,11 @@ export type Mood =
| "idle";
/**
* A one-shot signal describing what just happened, emitted by `reduceTabby`.
* A one-shot signal describing what just happened, emitted by `reduceSagi`.
* The hook turns pulses into transient speech bubbles. `null` means the message
* was irrelevant or non-notable.
*/
export type TabbyPulse =
export type SagiPulse =
| "session_done"
| "session_start"
| "subagent_spawn"
@@ -161,7 +161,7 @@ export type TabbyPulse =
| "run_done"
| null;
export interface TabbyStatus {
export interface SagiStatus {
/** Active + waiting sessions (everything not finished/errored). */
liveCount: number;
/** Subset of liveCount currently blocked on user input. */
@@ -170,7 +170,7 @@ export interface TabbyStatus {
connected: boolean;
}
export interface TabbyState {
export interface SagiState {
connected: boolean;
/** Latest status per session id we still care about. "waiting" = active but
* blocked on user input; counts as live for the status line. */
@@ -204,7 +204,7 @@ export const FAILURE_EVENT_TYPES: ReadonlySet<string> = new Set([
"diagnosticError",
]);
export function initialTabbyState(now: number): TabbyState {
export function initialSagiState(now: number): SagiState {
return {
connected: true,
sessions: {},
@@ -215,7 +215,7 @@ export function initialTabbyState(now: number): TabbyState {
};
}
export function statusOf(state: TabbyState): TabbyStatus {
export function statusOf(state: SagiState): SagiStatus {
let liveCount = 0;
let waitingCount = 0;
let errorCount = 0;
@@ -233,7 +233,7 @@ export function statusOf(state: TabbyState): TabbyStatus {
* so callers (and tests) control the clock; transient windows (happy/worried)
* and inactivity windows (stuck/sleeping) are evaluated against it.
*/
export function deriveMood(state: TabbyState, now: number): Mood {
export function deriveMood(state: SagiState, now: number): Mood {
if (!state.connected) return "disconnected";
if (now < state.worriedUntil) return "worried";
@@ -249,22 +249,22 @@ export function deriveMood(state: TabbyState, now: number): Mood {
}
/**
* Fold a single WebSocket message into the Tabby state. Returns the next state
* Fold a single WebSocket message into the Sagi state. Returns the next state
* (new object) and a one-shot pulse describing what happened. Unknown or
* irrelevant message types pass through unchanged with a `null` pulse.
*/
export function reduceTabby(
state: TabbyState,
export function reduceSagi(
state: SagiState,
msg: WSMessage,
now: number
): { state: TabbyState; pulse: TabbyPulse } {
): { state: SagiState; pulse: SagiPulse } {
switch (msg.type) {
case "session_created":
case "session_updated": {
const s = msg.data as Session;
if (!s || !s.id) return { state, pulse: null };
const sessions = { ...state.sessions };
let pulse: TabbyPulse = null;
let pulse: SagiPulse = null;
let happyUntil = state.happyUntil;
let worriedUntil = state.worriedUntil;
@@ -309,7 +309,7 @@ export function reduceTabby(
}
// A freshly spawned subagent is worth announcing; the main agent landing
// is already covered by session_start.
const pulse: TabbyPulse = a && a.type === "subagent" ? "subagent_spawn" : null;
const pulse: SagiPulse = a && a.type === "subagent" ? "subagent_spawn" : null;
return { state: { ...state, lastActivityAt: now }, pulse };
}
@@ -361,10 +361,10 @@ export function reduceTabby(
* never clears the error window. Live WS deltas continue to refine this.
*/
export function seedSessions(
state: TabbyState,
state: SagiState,
rows: ReadonlyArray<{ id: string; status: string; awaiting_input_since?: string | null }>,
now: number
): TabbyState {
): SagiState {
const sessions = { ...state.sessions };
for (const r of rows) {
if (!r || !r.id) continue;
@@ -376,8 +376,8 @@ export function seedSessions(
}
/** Drop all errored sessions from tracking (used by "clear alerts"). */
export function clearErrors(state: TabbyState): TabbyState {
const sessions: TabbyState["sessions"] = {};
export function clearErrors(state: SagiState): SagiState {
const sessions: SagiState["sessions"] = {};
for (const [id, s] of Object.entries(state.sessions)) {
if (s !== "error") sessions[id] = s;
}
@@ -1,6 +1,6 @@
/**
* @file intents.ts
* @description Tabby's local "Ask" brain. Matches a free-text question against a
* @description Sagi's local "Ask" brain. Matches a free-text question against a
* small set of intents answerable from cached dashboard status. Anything it
* can't answer becomes a handoff to the Run page (spawn a real `claude`).
* Pure function - no network, no DOM - so it's fully unit-testable.
@@ -9,7 +9,7 @@
/* =============================================================================
* MODULE_GUIDE extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Tabby is the optional on-screen cat assistant quips, intents, and lightweight event reactions layered above the dashboard chrome.
* **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.
@@ -55,13 +55,13 @@
*
* ----------------------------------------------------------------------------- */
import type { TabbyStatus } from "./brain";
import type { SagiStatus } from "./brain";
export type AskResult = { kind: "answer"; text: string } | { kind: "handoff"; prompt: string };
const plural = (n: number) => (n === 1 ? "" : "s");
export function matchIntent(query: string, status: TabbyStatus): AskResult {
export function matchIntent(query: string, status: SagiStatus): AskResult {
const q = query.trim().toLowerCase();
if (!q) {
return {
@@ -1,6 +1,6 @@
/**
* @file prefs.ts
* @description Tiny localStorage-backed preference store for Tabby (enabled +
* @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í <vinnt@smartgift.vn>
@@ -8,7 +8,7 @@
/* =============================================================================
* MODULE_GUIDE extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Tabby is the optional on-screen cat assistant quips, intents, and lightweight event reactions layered above the dashboard chrome.
* **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.
@@ -23,8 +23,8 @@
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Public surface
* - `TabbyPos` exported API; see TSDoc on the symbol for behavior.
* - `tabbyPrefs` exported API; see TSDoc on the symbol for behavior.
* - `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.
@@ -39,29 +39,29 @@
/* -----------------------------------------------------------------------------
* EXPORT CATALOG quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **TabbyPos**
* **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.
*
* **tabbyPrefs**
* **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-tabby-enabled";
const MUTED_KEY = "agent-dashboard-tabby-muted";
const POS_KEY = "agent-dashboard-tabby-pos";
const EVENT = "tabby:prefs";
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 (01) so it survives window resizes.
*/
export interface TabbyPos {
export interface SagiPos {
side: "left" | "right";
y: number;
}
@@ -88,11 +88,11 @@ function writeBool(key: string, value: boolean): void {
}
}
function readPos(): TabbyPos | null {
function readPos(): SagiPos | null {
try {
const raw = localStorage.getItem(POS_KEY);
if (!raw) return null;
const p = JSON.parse(raw) as Partial<TabbyPos>;
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)) };
}
@@ -102,7 +102,7 @@ function readPos(): TabbyPos | null {
}
}
function writePos(pos: TabbyPos): void {
function writePos(pos: SagiPos): void {
try {
localStorage.setItem(POS_KEY, JSON.stringify(pos));
} catch {
@@ -112,7 +112,7 @@ function writePos(pos: TabbyPos): void {
// are local to the widget and shouldn't churn the Settings toggle listeners.
}
export const tabbyPrefs = {
export const sagiPrefs = {
getEnabled: () => readBool(ENABLED_KEY, true),
setEnabled: (v: boolean) => writeBool(ENABLED_KEY, v),
getMuted: () => readBool(MUTED_KEY, false),
@@ -1,6 +1,6 @@
/**
* @file quips.ts
* @description Tabby's personality: pools of short phrases keyed by pulse/mood,
* @description Sagi's personality: pools of short phrases keyed by pulse/mood,
* plus a deterministic-by-injection picker. Pure data + a pure function so it
* can be unit-tested without randomness leaking in.
* @author Nguyễn Ngọc Trí <vinnt@smartgift.vn>
@@ -8,7 +8,7 @@
/* =============================================================================
* MODULE_GUIDE extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Tabby is the optional on-screen cat assistant quips, intents, and lightweight event reactions layered above the dashboard chrome.
* **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.
@@ -60,9 +60,9 @@
*
* ----------------------------------------------------------------------------- */
import type { Mood, TabbyPulse } from "./brain";
import type { Mood, SagiPulse } from "./brain";
export type QuipKey = NonNullable<TabbyPulse> | Mood;
export type QuipKey = NonNullable<SagiPulse> | Mood;
const QUIPS: Record<QuipKey, string[]> = {
// Pulses (event-driven, transient bubbles)
+273
View File
@@ -0,0 +1,273 @@
/**
* sagi.css - animations + mood expressions for the Sagi companion.
* Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale)
* with warm pink accents (ears, cheeks, nose) for cuteness. All continuous
* motion is disabled under [data-reduced="1"] and prefers-reduced-motion.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
.sagi-avatar {
overflow: visible;
cursor: pointer;
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5));
}
/* ── palette ── */
/* The mascot artwork is a flat trace (186 self-colored paths) with no part
grouping beyond the eyes, so there is no shared fill/stroke palette to set
here - every path carries its own `fill`. Only the extracted eye parts and
the new overlay glyphs need rules. */
.sagi-sparkle path {
fill: #fde68a;
}
.sagi-zzz text,
.sagi-bang text {
fill: #a5b4fc;
font-family: "JetBrains Mono", monospace;
font-size: 44px;
font-weight: 700;
}
/* ── default visibility: hide mood-only overlays ── */
.sagi-zzz,
.sagi-bang,
.sagi-sparkle {
display: none;
}
/* ── happy ── */
.sagi-avatar[data-mood="happy"] .sagi-sparkle {
display: block;
}
/* ── watching ── (alert, tail/ear motion handled below via whole-mascot transform) */
/* ── worried ── (whole-mascot shake handled below) */
/* ── stuck ── (alert bang) */
.sagi-avatar[data-mood="stuck"] .sagi-bang {
display: block;
}
/* ── thinking ── head tilt handled below */
/* ── sleeping / disconnected ── (eyes shut via static scaleY, no cursor tracking) */
.sagi-avatar[data-mood="sleeping"] .sagi-eye-blink,
.sagi-avatar[data-mood="disconnected"] .sagi-eye-blink {
transform: scaleY(0.08);
}
.sagi-avatar[data-mood="sleeping"] .sagi-zzz {
display: block;
}
.sagi-avatar[data-mood="disconnected"] {
opacity: 0.5;
filter: grayscale(0.65) drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5));
}
/* ── eyes: per-eye blink transform-origin (native mascot.svg coordinates) ── */
.sagi-eye-left .sagi-eye-blink {
transform-origin: 560px 375px;
}
.sagi-eye-right .sagi-eye-blink {
transform-origin: 823px 375px;
}
/* ════════ animations ════════ */
@keyframes sagi-breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.035);
}
}
@keyframes sagi-blink {
0%,
92%,
100% {
transform: scaleY(1);
}
96% {
transform: scaleY(0.1);
}
}
@keyframes sagi-shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-2px);
}
75% {
transform: translateX(2px);
}
}
@keyframes sagi-bob {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
@keyframes sagi-sparkle-twinkle {
0%,
100% {
transform: scale(0.6);
opacity: 0.4;
}
50% {
transform: scale(1);
opacity: 1;
}
}
/* idle: gentle breathing + occasional blink */
.sagi-avatar[data-mood="idle"] {
animation: sagi-breathe 4s ease-in-out infinite;
transform-origin: 640px 720px;
}
/* Blink lives on the inner group so it never overrides the outer group's
eye-tracking translate (which would freeze the eyes - see the CSS-blink
note in SagiAvatar.tsx). */
.sagi-avatar[data-mood="idle"] .sagi-eye-blink {
animation: sagi-blink 5s ease-in-out infinite;
}
/* happy: whole-mascot bob + twinkling sparkle */
.sagi-avatar[data-mood="happy"] {
animation: sagi-bob 0.5s ease-in-out 0s 4;
transform-origin: 640px 720px;
}
.sagi-avatar[data-mood="happy"] .sagi-sparkle {
animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite;
transform-origin: 1073px 424px;
}
/* worried: shake */
.sagi-avatar[data-mood="worried"] {
animation: sagi-shake 0.35s ease-in-out 0s 3;
transform-origin: 640px 720px;
}
/* stuck: slow breathe */
.sagi-avatar[data-mood="stuck"] {
animation: sagi-breathe 2.4s ease-in-out infinite;
transform-origin: 640px 720px;
}
/* thinking: subtle head tilt */
.sagi-avatar[data-mood="thinking"] {
transform: rotate(-6deg);
transform-origin: 640px 720px;
}
/* sleeping: slow breathe, droop */
.sagi-avatar[data-mood="sleeping"] {
animation: sagi-breathe 5s ease-in-out infinite;
transform-origin: 640px 720px;
}
/* ── reduced motion: kill all continuous animation ── */
.sagi-avatar[data-reduced="1"],
.sagi-avatar[data-reduced="1"] * {
animation: none !important;
}
@media (prefers-reduced-motion: reduce) {
.sagi-avatar,
.sagi-avatar * {
animation: none !important;
}
}
/* ════════ shell (avatar button, bubble, panel) ════════ */
/* The draggable avatar. Fixed-positioned; left/top are set inline by the drag
hook. It docks to an edge (AssistiveTouch-style) and remembers where. */
.sagi-avatar-btn {
position: fixed;
z-index: 41; /* above the flyout */
background: transparent;
border: none;
padding: 0;
line-height: 0;
border-radius: 9999px;
cursor: grab;
touch-action: none; /* pointer drives the drag instead of scrolling on touch */
/* Glide to the snapped edge after a drag; killed mid-drag for 1:1 tracking. */
transition:
left 0.28s cubic-bezier(0.22, 1, 0.36, 1),
top 0.28s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.15s ease;
}
.sagi-avatar-btn:hover {
transform: scale(1.06);
}
.sagi-avatar-btn:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 3px;
}
.sagi-avatar-btn[data-dragging="1"] {
cursor: grabbing;
transition: transform 0.15s ease; /* no left/top easing while dragging */
}
.sagi-avatar-btn[data-dragging="1"]:hover {
transform: scale(1.1);
}
/* Self-clamping flyout that holds the bubble / panel next to the avatar. Its
left/top are computed and set inline by SagiFlyout so it never crops. */
.sagi-flyout {
position: fixed;
z-index: 40;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sagi-avatar-btn:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 3px;
}
.sagi-error-dot {
position: absolute;
top: 2px;
right: 2px;
width: 14px;
height: 14px;
border-radius: 9999px;
background: #ef4444;
color: #fff;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #0c0c14;
}
.sagi-bubble {
max-width: 16rem;
background: #1a1a28;
border: 1px solid #363650;
color: #e8e8f0;
font-size: 0.8125rem;
line-height: 1.2rem;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
border-bottom-right-radius: 0.25rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.sagi-bubble-enter {
animation: sagi-bubble-in 0.22s ease-out;
}
@keyframes sagi-bubble-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.sagi-avatar[data-reduced="1"] ~ * .sagi-bubble-enter,
.sagi-bubble.sagi-no-anim {
animation: none;
}
@@ -1,6 +1,6 @@
/**
* @file useTabbyBrain.ts
* @description React hook that wires the pure Tabby brain to the live event bus
* @file useSagiBrain.ts
* @description React hook that wires the pure Sagi brain to the live event bus
* and to real timers. It is the only unit that subscribes to `eventBus`. It
* exposes the derived mood, a status summary, the current speech bubble, and
* imperative controls (mute, clear alerts, set thinking) for the UI shell.
@@ -9,7 +9,7 @@
/* =============================================================================
* MODULE_GUIDE extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Tabby is the optional on-screen cat assistant quips, intents, and lightweight event reactions layered above the dashboard chrome. React hook: isolates side effects and subscription wiring so presentational components stay declarative.
* **Purpose:** Sagi is the optional on-screen mascot assistant quips, intents, and lightweight event reactions layered above the dashboard chrome. React hook: isolates side effects and subscription wiring so presentational components stay declarative.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
@@ -32,8 +32,8 @@
* - `./prefs`
*
* ## Public surface
* - `TabbyBrain` exported API; see TSDoc on the symbol for behavior.
* - `useTabbyBrain` exported API; see TSDoc on the symbol for behavior.
* - `SagiBrain` exported API; see TSDoc on the symbol for behavior.
* - `useSagiBrain` exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
@@ -48,12 +48,12 @@
/* -----------------------------------------------------------------------------
* EXPORT CATALOG quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **TabbyBrain**
* **SagiBrain**
* 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.
*
* **useTabbyBrain**
* **useSagiBrain**
* 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.
@@ -65,26 +65,26 @@ import { eventBus } from "../../lib/eventBus";
import { api } from "../../lib/api";
import type { WSMessage } from "../../lib/types";
import {
initialTabbyState,
reduceTabby,
initialSagiState,
reduceSagi,
deriveMood,
statusOf,
clearErrors,
seedSessions,
type Mood,
type TabbyState,
type TabbyStatus,
type SagiState,
type SagiStatus,
} from "./brain";
import { pickQuip } from "./quips";
import { tabbyPrefs } from "./prefs";
import { sagiPrefs } from "./prefs";
const BUBBLE_MS = 4500;
// Minimum gap between non-error bubbles, so a burst of activity doesn't spam.
const BUBBLE_THROTTLE_MS = 3000;
export interface TabbyBrain {
export interface SagiBrain {
mood: Mood;
status: TabbyStatus;
status: SagiStatus;
bubble: string | null;
dismissBubble: () => void;
muted: boolean;
@@ -93,18 +93,18 @@ export interface TabbyBrain {
setThinking: (v: boolean) => void;
}
export function useTabbyBrain(): TabbyBrain {
export function useSagiBrain(): SagiBrain {
const now0 = Date.now();
// Start optimistically connected (idle, open eyes) so the cursor-tracking
// eyes are live from the first frame instead of after the WebSocket finishes
// its initial handshake. onConnection below corrects this if it's truly down.
const [state, setState] = useState<TabbyState>(() => ({
...initialTabbyState(now0),
const [state, setState] = useState<SagiState>(() => ({
...initialSagiState(now0),
connected: true,
}));
const [tick, setTick] = useState(now0);
const [bubble, setBubble] = useState<string | null>(null);
const [muted, setMuted] = useState<boolean>(() => tabbyPrefs.getMuted());
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
const lastBubbleAt = useRef(0);
@@ -112,7 +112,7 @@ export function useTabbyBrain(): TabbyBrain {
mutedRef.current = muted;
// Keep mute in sync with the Settings page / other tabs.
useEffect(() => tabbyPrefs.subscribe(() => setMuted(tabbyPrefs.getMuted())), []);
useEffect(() => sagiPrefs.subscribe(() => setMuted(sagiPrefs.getMuted())), []);
const showBubble = useCallback((text: string, force: boolean) => {
if (!text) return;
@@ -148,7 +148,7 @@ export function useTabbyBrain(): TabbyBrain {
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
const t = Date.now();
setState((prev) => {
const { state: next, pulse } = reduceTabby(prev, msg, t);
const { state: next, pulse } = reduceSagi(prev, msg, t);
if (pulse) showBubble(pickQuip(pulse), pulse === "error");
return next;
});
@@ -181,7 +181,7 @@ export function useTabbyBrain(): TabbyBrain {
const toggleMute = useCallback(() => {
const next = !mutedRef.current;
tabbyPrefs.setMuted(next);
sagiPrefs.setMuted(next);
setMuted(next);
if (next) dismissBubble();
}, [dismissBubble]);
@@ -1,6 +1,6 @@
/**
* @file useTabbyPosition.ts
* @description AssistiveTouch-style draggable docking for the Tabby avatar. The
* @file useSagiPosition.ts
* @description AssistiveTouch-style draggable docking for the Sagi avatar. The
* avatar follows the pointer 1:1 while dragging (via Pointer Capture, so it
* keeps tracking even if the cursor outruns it), and on release snaps to the
* nearest left/right edge, remembering its vertical offset (persisted as a
@@ -11,7 +11,7 @@
/* =============================================================================
* MODULE_GUIDE extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Tabby is the optional on-screen cat assistant quips, intents, and lightweight event reactions layered above the dashboard chrome. React hook: isolates side effects and subscription wiring so presentational components stay declarative.
* **Purpose:** Sagi is the optional on-screen mascot assistant quips, intents, and lightweight event reactions layered above the dashboard chrome. React hook: isolates side effects and subscription wiring so presentational components stay declarative.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
@@ -29,10 +29,10 @@
* - `./prefs`
*
* ## Public surface
* - `TABBY_SIZE` exported API; see TSDoc on the symbol for behavior.
* - `TABBY_MARGIN` exported API; see TSDoc on the symbol for behavior.
* - `TabbyPlacement` exported API; see TSDoc on the symbol for behavior.
* - `useTabbyPosition` exported API; see TSDoc on the symbol for behavior.
* - `SAGI_SIZE` exported API; see TSDoc on the symbol for behavior.
* - `SAGI_MARGIN` exported API; see TSDoc on the symbol for behavior.
* - `SagiPlacement` exported API; see TSDoc on the symbol for behavior.
* - `useSagiPosition` exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
@@ -47,22 +47,22 @@
/* -----------------------------------------------------------------------------
* EXPORT CATALOG quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **TABBY_SIZE**
* **SAGI_SIZE**
* 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.
*
* **TABBY_MARGIN**
* **SAGI_MARGIN**
* 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.
*
* **TabbyPlacement**
* **SagiPlacement**
* 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.
*
* **useTabbyPosition**
* **useSagiPosition**
* 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.
@@ -70,30 +70,30 @@
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useRef, useState } from "react";
import { tabbyPrefs, type TabbyPos } from "./prefs";
import { sagiPrefs, type SagiPos } from "./prefs";
import type { PointerEvent as ReactPointerEvent } from "react";
// Avatar footprint + edge gap, in px. SIZE matches CatAvatar's default size.
export const TABBY_SIZE = 60;
export const TABBY_MARGIN = 16;
// Avatar footprint + edge gap, in px. SIZE matches SagiAvatar's default size.
export const SAGI_SIZE = 60;
export const SAGI_MARGIN = 16;
const DRAG_THRESHOLD = 5;
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
function defaultPos(): TabbyPos {
function defaultPos(): SagiPos {
return { side: "right", y: 0.5 }; // right edge, vertically centered
}
/** Resting top-left screen coords for a docked position. */
function restingScreen(pos: TabbyPos) {
const avail = Math.max(0, vh() - TABBY_SIZE - 2 * TABBY_MARGIN);
const left = pos.side === "left" ? TABBY_MARGIN : vw() - TABBY_SIZE - TABBY_MARGIN;
const top = TABBY_MARGIN + pos.y * avail;
function restingScreen(pos: SagiPos) {
const avail = Math.max(0, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
const left = pos.side === "left" ? SAGI_MARGIN : vw() - SAGI_SIZE - SAGI_MARGIN;
const top = SAGI_MARGIN + pos.y * avail;
return { left, top };
}
export interface TabbyPlacement {
export interface SagiPlacement {
/** Avatar top-left, in screen px. */
left: number;
top: number;
@@ -109,8 +109,8 @@ export interface TabbyPlacement {
consumeDrag: () => boolean;
}
export function useTabbyPosition(): TabbyPlacement {
const [pos, setPos] = useState<TabbyPos>(() => tabbyPrefs.getPos() ?? defaultPos());
export function useSagiPosition(): SagiPlacement {
const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos());
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
const [, force] = useState(0); // re-derive resting coords on resize
@@ -154,11 +154,8 @@ export function useTabbyPosition(): TabbyPlacement {
const dy = e.clientY - start.py;
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
movedRef.current = true;
const left = Math.min(
vw() - TABBY_SIZE - TABBY_MARGIN,
Math.max(TABBY_MARGIN, start.left + dx)
);
const top = Math.min(vh() - TABBY_SIZE - TABBY_MARGIN, Math.max(TABBY_MARGIN, start.top + dy));
const left = Math.min(vw() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
const top = Math.min(vh() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.top + dy));
liveRef.current = { left, top };
setDrag({ left, top });
}, []);
@@ -172,11 +169,11 @@ export function useTabbyPosition(): TabbyPlacement {
const live = liveRef.current;
if (live) {
draggedRef.current = true;
const side: "left" | "right" = live.left + TABBY_SIZE / 2 < vw() / 2 ? "left" : "right";
const avail = Math.max(1, vh() - TABBY_SIZE - 2 * TABBY_MARGIN);
const y = Math.min(1, Math.max(0, (live.top - TABBY_MARGIN) / avail));
const next: TabbyPos = { side, y };
tabbyPrefs.setPos(next);
const side: "left" | "right" = live.left + SAGI_SIZE / 2 < vw() / 2 ? "left" : "right";
const avail = Math.max(1, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
const next: SagiPos = { side, y };
sagiPrefs.setPos(next);
setPos(next);
setDrag(null); // leave drag mode; resting coords (with transition) take over
}
@@ -194,9 +191,9 @@ export function useTabbyPosition(): TabbyPlacement {
return {
left: screen.left,
top: screen.top,
size: TABBY_SIZE,
size: SAGI_SIZE,
side: pos.side,
openUp: screen.top + TABBY_SIZE / 2 > vh() / 2,
openUp: screen.top + SAGI_SIZE / 2 > vh() / 2,
dragging: drag !== null,
onPointerDown,
onPointerMove,
-290
View File
@@ -1,290 +0,0 @@
/**
* @file CatAvatar.tsx
* @description Pure presentational SVG cat - Tabby. Given a mood it renders the
* matching expression (ears, eyes, cheeks, mouth, tail, paws) via a `data-mood`
* attribute that drives the CSS in tabby.css. When motion is allowed, the
* pupils track the cursor. No data access - fully testable / reusable in
* isolation. Geometry tuned for max cuteness: big round head, oversized
* sparkly eyes, pink ear-insides + cheek blush, classic tabby forehead
* stripes, a fluffy tail, and little paws peeking at the bottom.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Tabby is the optional on-screen cat 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.
*
* ## Internal dependencies
* - `./brain`
*
* ## Public surface
* - `CatAvatar` — 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).
* -----------------------------------------------------------------------------
* **CatAvatar**
* 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.
*
* ----------------------------------------------------------------------------- */
import { useEffect, useRef, useState } from "react";
import type { Mood } from "./brain";
interface CatAvatarProps {
mood: Mood;
reducedMotion: boolean;
size?: number;
}
const MAX_PUPIL_SHIFT = 2.8; // px in the 100x100 viewBox
// Module-level last-known cursor position, tracked from the moment this module
// first loads (well before any avatar mounts). This is what makes eye tracking
// feel *immediate*: as soon as the cat mounts it aims at wherever the cursor
// already is, instead of sitting centered until the next mousemove. `null`
// until the very first pointer event of the page's life.
let lastCursor: { x: number; y: number } | null = null;
if (typeof window !== "undefined") {
const remember = (e: MouseEvent) => {
lastCursor = { x: e.clientX, y: e.clientY };
};
// capture phase + passive so we never interfere with anything else.
window.addEventListener("mousemove", remember, { capture: true, passive: true });
window.addEventListener("pointermove", remember as EventListener, {
capture: true,
passive: true,
});
}
export function CatAvatar({ mood, reducedMotion, size = 60 }: CatAvatarProps) {
const rootRef = useRef<SVGSVGElement | null>(null);
const rafRef = useRef<number>();
const [pupil, setPupil] = useState({ x: 0, y: 0 });
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
// group via CSS, so tracking there is harmless and keeps eyes pre-aimed for
// the instant the connection returns.)
const tracking = !reducedMotion && mood !== "sleeping";
useEffect(() => {
if (!tracking) {
setPupil({ x: 0, y: 0 });
return;
}
const aimAt = (clientX: number, clientY: number) => {
const el = rootRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const dx = clientX - cx;
const dy = clientY - cy;
const dist = Math.hypot(dx, dy) || 1;
// Normalize then clamp to the eye socket range.
const nx = (dx / dist) * Math.min(1, dist / 240);
const ny = (dy / dist) * Math.min(1, dist / 240);
setPupil({ x: nx * MAX_PUPIL_SHIFT, y: ny * MAX_PUPIL_SHIFT });
};
// Immediately aim at the last-known cursor (next frame, so layout is ready)
// - no waiting for the user to move the mouse first.
let initRaf = 0;
if (lastCursor) {
const c = lastCursor;
initRaf = requestAnimationFrame(() => aimAt(c.x, c.y));
}
const onMove = (e: MouseEvent) => {
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = undefined;
aimAt(e.clientX, e.clientY);
});
};
window.addEventListener("mousemove", onMove, { passive: true });
return () => {
window.removeEventListener("mousemove", onMove);
if (initRaf) cancelAnimationFrame(initRaf);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = undefined;
};
}, [tracking]);
return (
<svg
ref={rootRef}
className="tabby-cat"
data-mood={mood}
data-reduced={reducedMotion ? "1" : "0"}
width={size}
height={size}
viewBox="0 0 100 100"
role="img"
aria-label={`Tabby (${mood})`}
>
<defs>
{/* Soft top-lit gradient for the body/head - gives a rounded, plush feel. */}
<radialGradient id="tabbyFur" cx="50%" cy="34%" r="72%">
<stop offset="0%" stopColor="#5b5b86" />
<stop offset="60%" stopColor="#43436a" />
<stop offset="100%" stopColor="#343352" />
</radialGradient>
<linearGradient id="tabbyHalo" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#a5b4fc" />
<stop offset="100%" stopColor="#6366f1" />
</linearGradient>
</defs>
{/* soft glow halo */}
<circle className="tabby-halo" cx="50" cy="56" r="33" />
{/* tail - fluffy curl to the right */}
<path
className="tabby-tail"
d="M78 74 q24 4 19 -22 q-3 -14 -12 -11 q8 4 6 14 q-3 12 -16 9 z"
/>
{/* body / chest peeking up from the bottom */}
<ellipse className="tabby-body" cx="50" cy="92" rx="27" ry="18" />
{/* paws */}
<g className="tabby-paws">
<ellipse className="tabby-paw" cx="38" cy="98" rx="8" ry="6" />
<ellipse className="tabby-paw" cx="62" cy="98" rx="8" ry="6" />
<path className="tabby-toe" d="M35 96 v4 M38 96.5 v4 M41 96 v4" />
<path className="tabby-toe" d="M59 96 v4 M62 96.5 v4 M65 96 v4" />
</g>
{/* ears - rounded, with pink inner */}
<g className="tabby-ears">
<path className="tabby-ear" d="M30 33 Q20 8 44 24 Q38 28 34 33 Z" />
<path className="tabby-ear-inner" d="M31 30 Q26 16 39 25 Q35 27 33 30 Z" />
<path className="tabby-ear" d="M70 33 Q80 8 56 24 Q62 28 66 33 Z" />
<path className="tabby-ear-inner" d="M69 30 Q74 16 61 25 Q65 27 67 30 Z" />
</g>
{/* head - big and round */}
<ellipse className="tabby-head" cx="50" cy="49" rx="33" ry="30" />
{/* classic tabby forehead stripes */}
<g className="tabby-stripes">
<path d="M50 22 L50 31" />
<path d="M43 24 L45 32" />
<path d="M57 24 L55 32" />
</g>
{/* cheek blush */}
<g className="tabby-cheeks">
<ellipse cx="26" cy="57" rx="6.5" ry="4" />
<ellipse cx="74" cy="57" rx="6.5" ry="4" />
</g>
{/* eyes - open state (big + sparkly) */}
<g className="tabby-eyes-open">
<ellipse className="tabby-eye" cx="37" cy="50" rx="9.5" ry="11.5" />
<ellipse className="tabby-eye" cx="63" cy="50" rx="9.5" ry="11.5" />
{/* Outer group carries the eye-tracking translate; the inner group
carries the blink (scaleY) animation. They MUST be separate
elements - a CSS animation on `transform` overrides an inline
`transform`, so putting both on one node makes the blink clobber
the tracking (the original "eyes only track after a while" bug). */}
<g className="tabby-pupils" style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}>
<g className="tabby-pupils-blink">
<circle className="tabby-pupil" cx="37" cy="51" r="5.4" />
<circle className="tabby-pupil" cx="63" cy="51" r="5.4" />
<circle className="tabby-glint" cx="39.4" cy="48" r="2.1" />
<circle className="tabby-glint" cx="65.4" cy="48" r="2.1" />
<circle className="tabby-glint tabby-glint-sm" cx="34.8" cy="53" r="1.1" />
<circle className="tabby-glint tabby-glint-sm" cx="60.8" cy="53" r="1.1" />
</g>
</g>
</g>
{/* eyes - happy (^ ^) */}
<g className="tabby-eyes-happy">
<path d="M29 52 q8 -10 16 0" />
<path d="M55 52 q8 -10 16 0" />
</g>
{/* eyes - closed (sleeping / offline) */}
<g className="tabby-eyes-closed">
<path d="M29 51 q8 7 16 0" />
<path d="M55 51 q8 7 16 0" />
</g>
{/* worried brows */}
<g className="tabby-brows">
<path d="M29 39 L45 45" />
<path d="M71 39 L55 45" />
</g>
{/* nose - tiny heart */}
<path
className="tabby-nose"
d="M50 64 C47 60 43 62 45 65 C46 67 50 69 50 69 C50 69 54 67 55 65 C57 62 53 60 50 64 Z"
/>
{/* mouths */}
<path className="tabby-mouth-idle" d="M50 67 q-4 4 -8 1 M50 67 q4 4 8 1" />
<path className="tabby-mouth-happy" d="M42 66 q8 7 16 0" />
<path className="tabby-mouth-worried" d="M44 71 q6 -5 12 0" />
{/* whiskers */}
<g className="tabby-whiskers">
<path d="M14 55 Q24 55 33 58" />
<path d="M13 62 Q24 63 33 63" />
<path d="M86 55 Q76 55 67 58" />
<path d="M87 62 Q76 63 67 63" />
</g>
{/* zzz for sleeping */}
<g className="tabby-zzz">
<text x="76" y="28">
z
</text>
<text x="84" y="19">
z
</text>
</g>
{/* alert bang for stuck */}
<g className="tabby-bang">
<text x="80" y="28">
!
</text>
</g>
{/* sparkle for happy */}
<g className="tabby-sparkle">
<path d="M82 40 l1.4 3.6 l3.6 1.4 l-3.6 1.4 l-1.4 3.6 l-1.4 -3.6 l-3.6 -1.4 l3.6 -1.4 z" />
</g>
</svg>
);
}
-412
View File
@@ -1,412 +0,0 @@
/**
* tabby.css - animations + mood expressions for the Tabby companion.
* Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale)
* with warm pink accents (ears, cheeks, nose) for cuteness. All continuous
* motion is disabled under [data-reduced="1"] and prefers-reduced-motion.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
.tabby-cat {
overflow: visible;
cursor: pointer;
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5));
}
/* ── palette ── */
.tabby-head,
.tabby-body {
fill: url(#tabbyFur);
stroke: #8b93f9;
stroke-width: 2;
}
.tabby-ear {
fill: url(#tabbyFur);
stroke: #8b93f9;
stroke-width: 2;
stroke-linejoin: round;
}
.tabby-ear-inner {
fill: #f7a8c8;
}
.tabby-paw {
fill: #4a4a72;
stroke: #8b93f9;
stroke-width: 1.5;
}
.tabby-toe {
fill: none;
stroke: #2c2c46;
stroke-width: 1.2;
stroke-linecap: round;
opacity: 0.7;
}
.tabby-stripes path {
fill: none;
stroke: #8b93f9;
stroke-width: 2;
stroke-linecap: round;
opacity: 0.55;
}
.tabby-cheeks ellipse {
fill: #f7849f;
opacity: 0.5;
}
.tabby-eye {
fill: #f3f4ff;
}
.tabby-pupil {
fill: #15131f;
}
.tabby-glint {
fill: #ffffff;
opacity: 0.95;
}
.tabby-glint-sm {
opacity: 0.75;
}
.tabby-nose {
fill: #f7849f;
}
.tabby-tail {
fill: url(#tabbyFur);
stroke: #8b93f9;
stroke-width: 2;
stroke-linejoin: round;
transform-origin: 80px 68px;
}
.tabby-whiskers path,
.tabby-brows path,
.tabby-eyes-happy path,
.tabby-eyes-closed path,
.tabby-mouth-idle,
.tabby-mouth-happy,
.tabby-mouth-worried {
fill: none;
stroke: #dfe1ff;
stroke-width: 2.2;
stroke-linecap: round;
}
.tabby-whiskers path {
stroke: #9aa0c8;
stroke-width: 1.4;
opacity: 0.8;
}
.tabby-brows path {
stroke: #8b93f9;
}
.tabby-halo {
fill: url(#tabbyHalo);
opacity: 0.18;
}
.tabby-sparkle path {
fill: #fde68a;
}
.tabby-zzz text,
.tabby-bang text {
fill: #a5b4fc;
font-family: "JetBrains Mono", monospace;
font-size: 11px;
font-weight: 700;
}
/* ── default visibility: show open eyes + idle mouth, hide the rest ── */
.tabby-eyes-happy,
.tabby-eyes-closed,
.tabby-brows,
.tabby-mouth-happy,
.tabby-mouth-worried,
.tabby-zzz,
.tabby-bang,
.tabby-sparkle {
display: none;
}
/* ── happy ── */
.tabby-cat[data-mood="happy"] .tabby-eyes-open {
display: none;
}
.tabby-cat[data-mood="happy"] .tabby-eyes-happy,
.tabby-cat[data-mood="happy"] .tabby-mouth-happy,
.tabby-cat[data-mood="happy"] .tabby-sparkle {
display: block;
}
.tabby-cat[data-mood="happy"] .tabby-mouth-idle {
display: none;
}
/* ── watching ── (alert, smiling, tail flick) */
.tabby-cat[data-mood="watching"] .tabby-mouth-idle {
display: none;
}
.tabby-cat[data-mood="watching"] .tabby-mouth-happy {
display: block;
}
/* ── worried ── (brows down, frown) */
.tabby-cat[data-mood="worried"] .tabby-brows,
.tabby-cat[data-mood="worried"] .tabby-mouth-worried {
display: block;
}
.tabby-cat[data-mood="worried"] .tabby-mouth-idle {
display: none;
}
.tabby-cat[data-mood="worried"] .tabby-cheeks ellipse {
opacity: 0.7;
}
/* ── stuck ── (alert bang, ears up) */
.tabby-cat[data-mood="stuck"] .tabby-bang {
display: block;
}
/* ── thinking ── keeps open eyes + idle mouth; head tilt handled below */
/* ── sleeping / disconnected ── (closed eyes) */
.tabby-cat[data-mood="sleeping"] .tabby-eyes-open,
.tabby-cat[data-mood="disconnected"] .tabby-eyes-open {
display: none;
}
.tabby-cat[data-mood="sleeping"] .tabby-eyes-closed,
.tabby-cat[data-mood="disconnected"] .tabby-eyes-closed {
display: block;
}
.tabby-cat[data-mood="sleeping"] .tabby-zzz {
display: block;
}
.tabby-cat[data-mood="disconnected"] {
opacity: 0.5;
filter: grayscale(0.65) drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5));
}
/* ════════ animations ════════ */
@keyframes tabby-breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.035);
}
}
@keyframes tabby-blink {
0%,
92%,
100% {
transform: scaleY(1);
}
96% {
transform: scaleY(0.1);
}
}
@keyframes tabby-shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-2px);
}
75% {
transform: translateX(2px);
}
}
@keyframes tabby-bob {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
@keyframes tabby-tail-flick {
0%,
100% {
transform: rotate(0deg);
}
50% {
transform: rotate(-12deg);
}
}
@keyframes tabby-ears-perk {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-1.5px);
}
}
@keyframes tabby-sparkle-twinkle {
0%,
100% {
transform: scale(0.6);
opacity: 0.4;
}
50% {
transform: scale(1);
opacity: 1;
}
}
/* idle: gentle breathing + occasional blink */
.tabby-cat[data-mood="idle"] {
animation: tabby-breathe 4s ease-in-out infinite;
transform-origin: 50px 60px;
}
/* Blink lives on the inner group so it never overrides the outer group's
eye-tracking translate (which would freeze the eyes - see CatAvatar). */
.tabby-cat[data-mood="idle"] .tabby-pupils-blink {
animation: tabby-blink 5s ease-in-out infinite;
transform-origin: 50px 50px;
}
/* watching: tail flicks, ears perk */
.tabby-cat[data-mood="watching"] .tabby-tail {
animation: tabby-tail-flick 1.8s ease-in-out infinite;
}
.tabby-cat[data-mood="watching"] .tabby-ears {
animation: tabby-ears-perk 1.8s ease-in-out infinite;
transform-origin: 50px 24px;
}
/* happy: head bob + twinkling sparkle */
.tabby-cat[data-mood="happy"] {
animation: tabby-bob 0.5s ease-in-out 0s 4;
transform-origin: 50px 60px;
}
.tabby-cat[data-mood="happy"] .tabby-sparkle {
animation: tabby-sparkle-twinkle 0.9s ease-in-out infinite;
transform-origin: 84px 45px;
}
/* worried: shake + puff (halo grows) */
.tabby-cat[data-mood="worried"] {
animation: tabby-shake 0.35s ease-in-out 0s 3;
transform-origin: 50px 60px;
}
.tabby-cat[data-mood="worried"] .tabby-halo {
opacity: 0.32;
}
/* stuck: ears stay perked, slow breathe */
.tabby-cat[data-mood="stuck"] {
animation: tabby-breathe 2.4s ease-in-out infinite;
transform-origin: 50px 60px;
}
.tabby-cat[data-mood="stuck"] .tabby-ears {
transform: translateY(-2px);
transform-origin: 50px 24px;
}
/* thinking: subtle head tilt */
.tabby-cat[data-mood="thinking"] {
transform: rotate(-6deg);
transform-origin: 50px 60px;
}
/* sleeping: slow breathe, droop */
.tabby-cat[data-mood="sleeping"] {
animation: tabby-breathe 5s ease-in-out infinite;
transform-origin: 50px 60px;
}
/* ── reduced motion: kill all continuous animation ── */
.tabby-cat[data-reduced="1"],
.tabby-cat[data-reduced="1"] * {
animation: none !important;
}
@media (prefers-reduced-motion: reduce) {
.tabby-cat,
.tabby-cat * {
animation: none !important;
}
}
/* ════════ shell (avatar button, bubble, panel) ════════ */
/* The draggable avatar. Fixed-positioned; left/top are set inline by the drag
hook. It docks to an edge (AssistiveTouch-style) and remembers where. */
.tabby-avatar-btn {
position: fixed;
z-index: 41; /* above the flyout */
background: transparent;
border: none;
padding: 0;
line-height: 0;
border-radius: 9999px;
cursor: grab;
touch-action: none; /* pointer drives the drag instead of scrolling on touch */
/* Glide to the snapped edge after a drag; killed mid-drag for 1:1 tracking. */
transition:
left 0.28s cubic-bezier(0.22, 1, 0.36, 1),
top 0.28s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.15s ease;
}
.tabby-avatar-btn:hover {
transform: scale(1.06);
}
.tabby-avatar-btn:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 3px;
}
.tabby-avatar-btn[data-dragging="1"] {
cursor: grabbing;
transition: transform 0.15s ease; /* no left/top easing while dragging */
}
.tabby-avatar-btn[data-dragging="1"]:hover {
transform: scale(1.1);
}
/* Self-clamping flyout that holds the bubble / panel next to the avatar. Its
left/top are computed and set inline by TabbyFlyout so it never crops. */
.tabby-flyout {
position: fixed;
z-index: 40;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tabby-avatar-btn:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 3px;
}
.tabby-error-dot {
position: absolute;
top: 2px;
right: 2px;
width: 14px;
height: 14px;
border-radius: 9999px;
background: #ef4444;
color: #fff;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #0c0c14;
}
.tabby-bubble {
max-width: 16rem;
background: #1a1a28;
border: 1px solid #363650;
color: #e8e8f0;
font-size: 0.8125rem;
line-height: 1.2rem;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
border-bottom-right-radius: 0.25rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.tabby-bubble-enter {
animation: tabby-bubble-in 0.22s ease-out;
}
@keyframes tabby-bubble-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.tabby-cat[data-reduced="1"] ~ * .tabby-bubble-enter,
.tabby-bubble.tabby-no-anim {
animation: none;
}
+1 -1
View File
@@ -166,7 +166,7 @@ export function useWebSocket(onMessage: MessageHandler) {
// back: tab refocus, regained network, or page becoming visible again. This
// cancels any pending backoff timer and resets the attempt counter so we
// don't sit out a long delay - e.g. after the dashboard server restarts, the
// socket (and the Tabby eyes) recover the moment you look at the tab.
// socket (and the Sagi eyes) recover the moment you look at the tab.
useEffect(() => {
const reconnectNow = () => {
if (!mountedRef.current) return;
+4 -4
View File
@@ -4,10 +4,10 @@
"exportData": "Export Data",
"acrossSessions": "Across all tracked sessions",
"basedOnUsage": "Based on per-model token usage",
"tabby": {
"title": "Tabby companion",
"description": "A floating cat that reacts to your live sessions.",
"enable": "Show Tabby",
"sagi": {
"title": "Sagi companion",
"description": "A floating mascot that reacts to your live sessions.",
"enable": "Show Sagi",
"enableDesc": "Display the corner companion across the dashboard (⌘B to open)"
},
"pricing": {
+4 -4
View File
@@ -4,10 +4,10 @@
"exportData": "Xuất dữ liệu",
"acrossSessions": "Trên toàn bộ phiên đang được theo dõi",
"basedOnUsage": "Dựa trên mức dùng token theo mô hình",
"tabby": {
"title": "Bạn đồng hành Tabby",
"description": "Chú mèo nổi phản ứng với các phiên trực tiếp của bạn.",
"enable": "Hiện Tabby",
"sagi": {
"title": "Bạn đồng hành Sagi",
"description": "Linh vật nổi phản ứng với các phiên trực tiếp của bạn.",
"enable": "Hiện Sagi",
"enableDesc": "Hiển thị bạn đồng hành ở góc trên toàn bảng điều khiển (⌘B để mở)"
},
"pricing": {
+19 -19
View File
@@ -23,7 +23,7 @@
* ## Internal dependencies
* - `../lib/api`
* - `../lib/eventBus`
* - `../components/Tabby/prefs`
* - `../components/Sagi/prefs`
* - `../lib/format`
* - `../lib/push`
* - `../components/Tip`
@@ -98,7 +98,7 @@ import {
Settings as SettingsIcon,
FolderOpen,
Info,
Cat,
PiggyBank,
History,
ChevronLeft,
ChevronRight,
@@ -107,7 +107,7 @@ import {
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { tabbyPrefs } from "../components/Tabby/prefs";
import { sagiPrefs } from "../components/Sagi/prefs";
import { fmt, fmtCost, getCurrentLocale } from "../lib/format";
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
import { Tip } from "../components/Tip";
@@ -136,7 +136,7 @@ const SETTINGS_SECTIONS: {
fallback: "Remote Data Sources",
Icon: Cloud,
},
{ id: "tabby", labelKey: "tabby.title", fallback: "Tabby", Icon: Cat },
{ id: "sagi", labelKey: "sagi.title", fallback: "Sagi", Icon: PiggyBank },
{ id: "notifications", labelKey: "notifications.title", Icon: Bell },
{ id: "alerts", labelKey: "alertsHub.title", Icon: BellRing },
{ id: "data", labelKey: "data.title", Icon: Database },
@@ -457,10 +457,10 @@ export function Settings() {
} | null>(null);
const [confirmAction, setConfirmAction] = useState<string | null>(null);
const [notifPrefs, setNotifPrefs] = useState<NotifPrefs>(loadNotifPrefs);
const [tabbyEnabled, setTabbyEnabled] = useState(() => tabbyPrefs.getEnabled());
const setTabby = useCallback((v: boolean) => {
tabbyPrefs.setEnabled(v);
setTabbyEnabled(v);
const [sagiEnabled, setSagiEnabled] = useState(() => sagiPrefs.getEnabled());
const setSagi = useCallback((v: boolean) => {
sagiPrefs.setEnabled(v);
setSagiEnabled(v);
}, []);
const [abandonHours, setAbandonHours] = useState("24");
const [purgeDays, setPurgeDays] = useState("90");
@@ -1466,37 +1466,37 @@ export function Settings() {
<RemoteSources />
</section>
{/* ─── TABBY COMPANION ─── */}
<section id="tabby" className="scroll-mt-24">
{/* ─── SAGI COMPANION ─── */}
<section id="sagi" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<span className="text-base leading-none" aria-hidden>
🐾
🦸
</span>
{t("tabby.title", "Tabby companion")}
{t("sagi.title", "Sagi companion")}
</h3>
<p className="text-xs text-fg-muted mb-4">
{t("tabby.description", "A floating cat that reacts to your live sessions.")}
{t("sagi.description", "A floating mascot that reacts to your live sessions.")}
</p>
<div className="card p-5">
<div className="flex items-center gap-3">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center transition-colors ${
tabbyEnabled
sagiEnabled
? "bg-blue-600/10 border border-blue-600/20"
: "bg-surface-2 border border-border"
}`}
>
<span className="text-lg leading-none" aria-hidden>
🐾
🦸
</span>
</div>
<Toggle
checked={tabbyEnabled}
onChange={setTabby}
label={t("tabby.enable", "Show Tabby")}
checked={sagiEnabled}
onChange={setSagi}
label={t("sagi.enable", "Show Sagi")}
description={t(
"tabby.enableDesc",
"sagi.enableDesc",
"Display the corner companion across the dashboard (⌘B to open)"
)}
/>
@@ -7641,7 +7641,7 @@ exports[`screen snapshots > Settings 1`] = `
type="button"
>
<svg
class="lucide lucide-cat w-3.5 h-3.5"
class="lucide lucide-piggy-bank w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
@@ -7653,19 +7653,16 @@ exports[`screen snapshots > Settings 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 5c.67 0 1.35.09 2 .26 1.78-2 5.03-2.84 6.42-2.26 1.4.58-.42 7-.42 7 .57 1.07 1 2.24 1 3.44C21 17.9 16.97 21 12 21s-9-3-9-7.56c0-1.25.5-2.4 1-3.44 0 0-1.89-6.42-.5-7 1.39-.58 4.72.23 6.5 2.23A9.04 9.04 0 0 1 12 5Z"
d="M19 5c-1.5 0-2.8 1.4-3 2-3.5-1.5-11-.3-11 5 0 1.8 0 3 2 4.5V20h4v-2h3v2h4v-4c1-.5 1.7-1 2-2h2v-4h-2c0-1-.5-1.5-1-2V5z"
/>
<path
d="M8 14v.5"
d="M2 9v1c0 1.1.9 2 2 2h1"
/>
<path
d="M16 14v.5"
/>
<path
d="M11.25 16.25h1.5L12 17l-.75-.75Z"
d="M16 11h.01"
/>
</svg>
Tabby companion
Sagi companion
</button>
<button
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
@@ -9245,7 +9242,7 @@ exports[`screen snapshots > Settings 1`] = `
</section>
<section
class="scroll-mt-24"
id="tabby"
id="sagi"
>
<h3
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
@@ -9254,14 +9251,14 @@ exports[`screen snapshots > Settings 1`] = `
aria-hidden="true"
class="text-base leading-none"
>
🐾
🦸
</span>
Tabby companion
Sagi companion
</h3>
<p
class="text-xs text-fg-muted mb-4"
>
A floating cat that reacts to your live sessions.
A floating mascot that reacts to your live sessions.
</p>
<div
class="card p-5"
@@ -9276,7 +9273,7 @@ exports[`screen snapshots > Settings 1`] = `
aria-hidden="true"
class="text-lg leading-none"
>
🐾
🦸
</span>
</div>
<label
@@ -9288,7 +9285,7 @@ exports[`screen snapshots > Settings 1`] = `
<p
class="text-sm text-fg-secondary group-hover:text-fg-secondary transition-colors"
>
Show Tabby
Show Sagi
</p>
<p
class="text-xs text-fg-muted mt-0.5"