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:
+30
-30
@@ -63,7 +63,7 @@ Architectural overview and technical reference for the Agent Dashboard system, c
|
||||
- [State Management](#state-management)
|
||||
- [Browser Notification System](#browser-notification-system)
|
||||
- [Update Notifier Subsystem](#update-notifier-subsystem)
|
||||
- [Tabby Companion Subsystem](#tabby-companion-subsystem)
|
||||
- [Sagi Companion Subsystem](#sagi-companion-subsystem)
|
||||
- [VS Code Extension Architecture](#vs-code-extension-architecture)
|
||||
- [Security Considerations](#security-considerations)
|
||||
- [Performance Characteristics](#performance-characteristics)
|
||||
@@ -576,7 +576,7 @@ graph TD
|
||||
STC[StatCard]
|
||||
STB[StatusBadge]
|
||||
ES[EmptyState]
|
||||
TB["Tabby/<br/>(floating cat companion)"]
|
||||
TB["Sagi/<br/>(floating mascot companion)"]
|
||||
end
|
||||
|
||||
D --> STC & AGC & STB
|
||||
@@ -1003,13 +1003,13 @@ graph TD
|
||||
BC --> WS
|
||||
WS --> EB
|
||||
EB --> SUB1 & SUB2 & SUB3 & SUB4 & SUB5 & SUB6
|
||||
EB --> SUB7["Tabby companion subscriber"]
|
||||
EB --> SUB7["Sagi companion subscriber"]
|
||||
|
||||
style BC fill:#10b981,stroke:#34d399,color:#fff
|
||||
style EB fill:#f59e0b,stroke:#fbbf24,color:#000
|
||||
```
|
||||
|
||||
The **Tabby companion** (see [Tabby Companion Subsystem](#tabby-companion-subsystem)) is an additional read-only `eventBus` subscriber. It consumes the existing message envelope above and introduces **no new WebSocket message types** and no protocol changes.
|
||||
The **Sagi companion** (see [Sagi Companion Subsystem](#sagi-companion-subsystem)) is an additional read-only `eventBus` subscriber. It consumes the existing message envelope above and introduces **no new WebSocket message types** and no protocol changes.
|
||||
|
||||
### Client Reconnection
|
||||
|
||||
@@ -1847,7 +1847,7 @@ graph TD
|
||||
|
||||
subgraph "App-Level Hooks"
|
||||
NOTIF_H["useNotifications<br/>reads prefs, fires<br/>browser notifications"]
|
||||
TABBY_H["useTabbyBrain<br/>derives cat mood +<br/>speech from WS stream"]
|
||||
SAGI_H["useSagiBrain<br/>derives mascot mood +<br/>speech from WS stream"]
|
||||
end
|
||||
|
||||
subgraph "Page State"
|
||||
@@ -1865,9 +1865,9 @@ graph TD
|
||||
WSM --> EB
|
||||
EB --> US1 & US2 & US3 & US4 & US5 & US6 & US8 & US7
|
||||
EB --> NOTIF_H
|
||||
EB --> TABBY_H
|
||||
EB --> SAGI_H
|
||||
LS --> NOTIF_H
|
||||
LS --> TABBY_H
|
||||
LS --> SAGI_H
|
||||
LS --> US7
|
||||
```
|
||||
|
||||
@@ -2135,9 +2135,9 @@ The detection layer carries all of the signal value: the dashboard tells the use
|
||||
|
||||
---
|
||||
|
||||
## Tabby Companion Subsystem
|
||||
## Sagi Companion Subsystem
|
||||
|
||||
Tabby is a **client-only** floating cat companion that reacts to live session activity. It is purely additive UI: there is **no server/backend code**, **no new API routes**, **no new WebSocket message types**, and **no database changes**. Tabby reuses the existing real-time event stream (the same `eventBus` every page already consumes) and the existing **Run** page for its "ask a real question" path. The entire subsystem lives under `client/src/components/Tabby/`.
|
||||
Sagi is a **client-only** floating mascot companion that reacts to live session activity. It is purely additive UI: there is **no server/backend code**, **no new API routes**, **no new WebSocket message types**, and **no database changes**. Sagi reuses the existing real-time event stream (the same `eventBus` every page already consumes) and the existing **Run** page for its "ask a real question" path. The entire subsystem lives under `client/src/components/Sagi/`.
|
||||
|
||||
The design follows a strict **pure-core / hook / presentational** split: a framework-free brain (a `WSMessage` reducer plus a mood state machine with an injected clock and zero side effects) is fully unit-tested in isolation, a single React hook is the only consumer of the global `eventBus` and the only owner of timers and side effects, and the SVG/markup components are pure presentational views driven by props.
|
||||
|
||||
@@ -2146,22 +2146,22 @@ The design follows a strict **pure-core / hook / presentational** split: a frame
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Pure Core (framework-free, unit-tested)"
|
||||
BRAIN["brain.ts<br/>reduceTabby reducer +<br/>deriveMood state machine<br/>(injected clock, no side effects)"]
|
||||
BRAIN["brain.ts<br/>reduceSagi reducer +<br/>deriveMood state machine<br/>(injected clock, no side effects)"]
|
||||
INTENTS["intents.ts<br/>local Q&A over cached status;<br/>unmatched → Run handoff"]
|
||||
QUIPS["quips.ts<br/>mood → phrase pools"]
|
||||
PREFS["prefs.ts<br/>localStorage enabled/muted<br/>(cross-tab sync)"]
|
||||
end
|
||||
|
||||
subgraph "Hook (only eventBus consumer)"
|
||||
HOOK["useTabbyBrain.ts<br/>wires brain to real timers<br/>(idle/sleep/stuck), speech-bubble<br/>queue, mute, clear-alerts"]
|
||||
HOOK["useSagiBrain.ts<br/>wires brain to real timers<br/>(idle/sleep/stuck), speech-bubble<br/>queue, mute, clear-alerts"]
|
||||
end
|
||||
|
||||
subgraph "Presentational (pure)"
|
||||
SHELL["Tabby.tsx<br/>shell: open/closed state,<br/>⌘B / Esc, reduced-motion,<br/>navigation"]
|
||||
AVATAR["CatAvatar.tsx<br/>SVG cat; data-mood drives CSS;<br/>cursor-tracking pupils"]
|
||||
SHELL["Sagi.tsx<br/>shell: open/closed state,<br/>⌘B / Esc, reduced-motion,<br/>navigation"]
|
||||
AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"]
|
||||
BUBBLE["SpeechBubble.tsx<br/>bubble"]
|
||||
PANEL["TabbyPanel.tsx<br/>status + quick actions + Ask box"]
|
||||
CSS["tabby.css<br/>keyframes + per-mood expressions"]
|
||||
PANEL["SagiPanel.tsx<br/>status + quick actions + Ask box"]
|
||||
CSS["sagi.css<br/>keyframes + per-mood expressions"]
|
||||
end
|
||||
|
||||
BUS["lib/eventBus.ts<br/>(existing WS stream)"]
|
||||
@@ -2186,44 +2186,44 @@ graph TD
|
||||
flowchart LR
|
||||
WSS["Server WebSocket<br/>broadcast"] --> UWS["useWebSocket"]
|
||||
UWS --> PUB["eventBus.publish"]
|
||||
PUB --> SUB["useTabbyBrain<br/>(subscriber)"]
|
||||
PUB --> SUB["useSagiBrain<br/>(subscriber)"]
|
||||
SUB --> DERIVED["derived state<br/>{ mood, status, bubble }"]
|
||||
DERIVED --> AVATAR["CatAvatar"]
|
||||
DERIVED --> AVATAR["SagiAvatar"]
|
||||
DERIVED --> BUBBLE["SpeechBubble"]
|
||||
DERIVED --> PANEL["TabbyPanel"]
|
||||
DERIVED --> PANEL["SagiPanel"]
|
||||
PANEL -->|"unmatched Ask"| RUN["/run?prompt=…<br/>(existing Run page)"]
|
||||
|
||||
style PUB fill:#f59e0b,stroke:#fbbf24,color:#000
|
||||
style RUN fill:#10b981,stroke:#34d399,color:#fff
|
||||
```
|
||||
|
||||
The mood state machine in `deriveMood` resolves to a single expression using a fixed priority order: `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. The resolved mood is written to a `data-mood` attribute on the SVG cat, and `tabby.css` maps each mood to its keyframe animation and expression.
|
||||
The mood state machine in `deriveMood` resolves to a single expression using a fixed priority order: `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. The resolved mood is written to a `data-mood` attribute on the SVG mascot, and `sagi.css` maps each mood to its keyframe animation and expression.
|
||||
|
||||
### Component Responsibilities
|
||||
|
||||
| Component | Responsibility |
|
||||
| --- | --- |
|
||||
| **`brain.ts`** | Pure, framework-free core. Exposes a `WSMessage` reducer (`reduceTabby`) and a mood state machine (`deriveMood`) with the priority order `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation. |
|
||||
| **`useTabbyBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech-bubble queue, mute, and clear-alerts. Produces the derived `{ mood, status, bubble }` the presentational components render. |
|
||||
| **`CatAvatar.tsx`** | Pure presentational SVG cat. The `data-mood` attribute drives CSS; pupils track the cursor. |
|
||||
| **`brain.ts`** | Pure, framework-free core. Exposes a `WSMessage` reducer (`reduceSagi`) and a mood state machine (`deriveMood`) with the priority order `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation. |
|
||||
| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech-bubble queue, mute, and clear-alerts. Produces the derived `{ mood, status, bubble }` the presentational components render. |
|
||||
| **`SagiAvatar.tsx`** | Pure presentational SVG mascot. The `data-mood` attribute drives CSS; pupils track the cursor. |
|
||||
| **`SpeechBubble.tsx`** | Pure presentational speech bubble. |
|
||||
| **`TabbyPanel.tsx`** | Pure presentational panel: status readout + quick actions + the Ask box. |
|
||||
| **`Tabby.tsx`** | Shell component. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
|
||||
| **`SagiPanel.tsx`** | Pure presentational panel: status readout + quick actions + the Ask box. |
|
||||
| **`Sagi.tsx`** | Shell component. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
|
||||
| **`intents.ts`** | Pure local Q&A over the cached status snapshot. Queries that don't match a local intent become a handoff to the existing Run page via `/run?prompt=…`. |
|
||||
| **`quips.ts`** | Pure mood → phrase pools. |
|
||||
| **`prefs.ts`** | `localStorage`-backed enabled / muted preferences with cross-tab sync. |
|
||||
| **`tabby.css`** | Keyframes and per-mood expressions; selected via the `data-mood` attribute. |
|
||||
| **`sagi.css`** | Keyframes and per-mood expressions; selected via the `data-mood` attribute. |
|
||||
|
||||
### Touchpoints Outside the Folder
|
||||
|
||||
Tabby's only contact with the rest of the app is four light, additive touchpoints — nothing in the server, database, or WebSocket protocol changes:
|
||||
Sagi's only contact with the rest of the app is four light, additive touchpoints — nothing in the server, database, or WebSocket protocol changes:
|
||||
|
||||
| File | Touchpoint |
|
||||
| --- | --- |
|
||||
| `client/src/components/Layout.tsx` | Mounts `<Tabby />` once, as a sibling of `<UpdateNotifier />`. |
|
||||
| `client/src/pages/Settings.tsx` | On/off toggle wired to `tabbyPrefs` (`localStorage`). |
|
||||
| `client/src/pages/Workspace.tsx` | Reads `?prompt=` to prefill the prompt box for Tabby's Ask handoff. |
|
||||
| `client/src/i18n/locales/{en,zh,vi,ko}/settings.json` | `tabby.*` strings for the Settings toggle (en / zh / vi / ko). |
|
||||
| `client/src/components/Layout.tsx` | Mounts `<Sagi />` once, as a sibling of `<UpdateNotifier />`. |
|
||||
| `client/src/pages/Settings.tsx` | On/off toggle wired to `sagiPrefs` (`localStorage`). |
|
||||
| `client/src/pages/Workspace.tsx` | Reads `?prompt=` to prefill the prompt box for Sagi's Ask handoff. |
|
||||
| `client/src/i18n/locales/{en,vi}/settings.json` | `sagi.*` strings for the Settings toggle (zh / ko fall back to the hardcoded English defaults). |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+15
-15
@@ -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"
|
||||
+5
-5
@@ -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}
|
||||
+35
-35
@@ -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í Vĩ <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");
|
||||
});
|
||||
+52
-52
@@ -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í Vĩ <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);
|
||||
+3
-3
@@ -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í Vĩ <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
-1
@@ -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í Vĩ <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í 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.
|
||||
* **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í Vĩ <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 (0–1) 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í Vĩ <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)
|
||||
@@ -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;
|
||||
}
|
||||
+21
-21
@@ -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]);
|
||||
+32
-35
@@ -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,
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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,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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -73,9 +73,9 @@ def topic_blurb(path: Path) -> str:
|
||||
"Renders Claude transcript rows (user, assistant, tool calls) inside Session "
|
||||
"Detail with markdown, syntax highlighting, and TUI-style segments."
|
||||
)
|
||||
if "Tabby" in rel:
|
||||
if "Sagi" in rel:
|
||||
hints.append(
|
||||
"Tabby is the optional on-screen cat assistant — quips, intents, and lightweight "
|
||||
"Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight "
|
||||
"event reactions layered above the dashboard chrome."
|
||||
)
|
||||
if "hook" in rel.lower() or name.startswith("use"):
|
||||
|
||||
Reference in New Issue
Block a user