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)
|
- [State Management](#state-management)
|
||||||
- [Browser Notification System](#browser-notification-system)
|
- [Browser Notification System](#browser-notification-system)
|
||||||
- [Update Notifier Subsystem](#update-notifier-subsystem)
|
- [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)
|
- [VS Code Extension Architecture](#vs-code-extension-architecture)
|
||||||
- [Security Considerations](#security-considerations)
|
- [Security Considerations](#security-considerations)
|
||||||
- [Performance Characteristics](#performance-characteristics)
|
- [Performance Characteristics](#performance-characteristics)
|
||||||
@@ -576,7 +576,7 @@ graph TD
|
|||||||
STC[StatCard]
|
STC[StatCard]
|
||||||
STB[StatusBadge]
|
STB[StatusBadge]
|
||||||
ES[EmptyState]
|
ES[EmptyState]
|
||||||
TB["Tabby/<br/>(floating cat companion)"]
|
TB["Sagi/<br/>(floating mascot companion)"]
|
||||||
end
|
end
|
||||||
|
|
||||||
D --> STC & AGC & STB
|
D --> STC & AGC & STB
|
||||||
@@ -1003,13 +1003,13 @@ graph TD
|
|||||||
BC --> WS
|
BC --> WS
|
||||||
WS --> EB
|
WS --> EB
|
||||||
EB --> SUB1 & SUB2 & SUB3 & SUB4 & SUB5 & SUB6
|
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 BC fill:#10b981,stroke:#34d399,color:#fff
|
||||||
style EB fill:#f59e0b,stroke:#fbbf24,color:#000
|
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
|
### Client Reconnection
|
||||||
|
|
||||||
@@ -1847,7 +1847,7 @@ graph TD
|
|||||||
|
|
||||||
subgraph "App-Level Hooks"
|
subgraph "App-Level Hooks"
|
||||||
NOTIF_H["useNotifications<br/>reads prefs, fires<br/>browser notifications"]
|
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
|
end
|
||||||
|
|
||||||
subgraph "Page State"
|
subgraph "Page State"
|
||||||
@@ -1865,9 +1865,9 @@ graph TD
|
|||||||
WSM --> EB
|
WSM --> EB
|
||||||
EB --> US1 & US2 & US3 & US4 & US5 & US6 & US8 & US7
|
EB --> US1 & US2 & US3 & US4 & US5 & US6 & US8 & US7
|
||||||
EB --> NOTIF_H
|
EB --> NOTIF_H
|
||||||
EB --> TABBY_H
|
EB --> SAGI_H
|
||||||
LS --> NOTIF_H
|
LS --> NOTIF_H
|
||||||
LS --> TABBY_H
|
LS --> SAGI_H
|
||||||
LS --> US7
|
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.
|
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
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
subgraph "Pure Core (framework-free, unit-tested)"
|
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"]
|
INTENTS["intents.ts<br/>local Q&A over cached status;<br/>unmatched → Run handoff"]
|
||||||
QUIPS["quips.ts<br/>mood → phrase pools"]
|
QUIPS["quips.ts<br/>mood → phrase pools"]
|
||||||
PREFS["prefs.ts<br/>localStorage enabled/muted<br/>(cross-tab sync)"]
|
PREFS["prefs.ts<br/>localStorage enabled/muted<br/>(cross-tab sync)"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Hook (only eventBus consumer)"
|
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
|
end
|
||||||
|
|
||||||
subgraph "Presentational (pure)"
|
subgraph "Presentational (pure)"
|
||||||
SHELL["Tabby.tsx<br/>shell: open/closed state,<br/>⌘B / Esc, reduced-motion,<br/>navigation"]
|
SHELL["Sagi.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"]
|
AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"]
|
||||||
BUBBLE["SpeechBubble.tsx<br/>bubble"]
|
BUBBLE["SpeechBubble.tsx<br/>bubble"]
|
||||||
PANEL["TabbyPanel.tsx<br/>status + quick actions + Ask box"]
|
PANEL["SagiPanel.tsx<br/>status + quick actions + Ask box"]
|
||||||
CSS["tabby.css<br/>keyframes + per-mood expressions"]
|
CSS["sagi.css<br/>keyframes + per-mood expressions"]
|
||||||
end
|
end
|
||||||
|
|
||||||
BUS["lib/eventBus.ts<br/>(existing WS stream)"]
|
BUS["lib/eventBus.ts<br/>(existing WS stream)"]
|
||||||
@@ -2186,44 +2186,44 @@ graph TD
|
|||||||
flowchart LR
|
flowchart LR
|
||||||
WSS["Server WebSocket<br/>broadcast"] --> UWS["useWebSocket"]
|
WSS["Server WebSocket<br/>broadcast"] --> UWS["useWebSocket"]
|
||||||
UWS --> PUB["eventBus.publish"]
|
UWS --> PUB["eventBus.publish"]
|
||||||
PUB --> SUB["useTabbyBrain<br/>(subscriber)"]
|
PUB --> SUB["useSagiBrain<br/>(subscriber)"]
|
||||||
SUB --> DERIVED["derived state<br/>{ mood, status, bubble }"]
|
SUB --> DERIVED["derived state<br/>{ mood, status, bubble }"]
|
||||||
DERIVED --> AVATAR["CatAvatar"]
|
DERIVED --> AVATAR["SagiAvatar"]
|
||||||
DERIVED --> BUBBLE["SpeechBubble"]
|
DERIVED --> BUBBLE["SpeechBubble"]
|
||||||
DERIVED --> PANEL["TabbyPanel"]
|
DERIVED --> PANEL["SagiPanel"]
|
||||||
PANEL -->|"unmatched Ask"| RUN["/run?prompt=…<br/>(existing Run page)"]
|
PANEL -->|"unmatched Ask"| RUN["/run?prompt=…<br/>(existing Run page)"]
|
||||||
|
|
||||||
style PUB fill:#f59e0b,stroke:#fbbf24,color:#000
|
style PUB fill:#f59e0b,stroke:#fbbf24,color:#000
|
||||||
style RUN fill:#10b981,stroke:#34d399,color:#fff
|
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 Responsibilities
|
||||||
|
|
||||||
| Component | Responsibility |
|
| 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. |
|
| **`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. |
|
||||||
| **`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. |
|
| **`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. |
|
||||||
| **`CatAvatar.tsx`** | Pure presentational SVG cat. The `data-mood` attribute drives CSS; pupils track the cursor. |
|
| **`SagiAvatar.tsx`** | Pure presentational SVG mascot. The `data-mood` attribute drives CSS; pupils track the cursor. |
|
||||||
| **`SpeechBubble.tsx`** | Pure presentational speech bubble. |
|
| **`SpeechBubble.tsx`** | Pure presentational speech bubble. |
|
||||||
| **`TabbyPanel.tsx`** | Pure presentational panel: status readout + quick actions + the Ask box. |
|
| **`SagiPanel.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. |
|
| **`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=…`. |
|
| **`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. |
|
| **`quips.ts`** | Pure mood → phrase pools. |
|
||||||
| **`prefs.ts`** | `localStorage`-backed enabled / muted preferences with cross-tab sync. |
|
| **`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
|
### 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 |
|
| File | Touchpoint |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `client/src/components/Layout.tsx` | Mounts `<Tabby />` once, as a sibling of `<UpdateNotifier />`. |
|
| `client/src/components/Layout.tsx` | Mounts `<Sagi />` once, as a sibling of `<UpdateNotifier />`. |
|
||||||
| `client/src/pages/Settings.tsx` | On/off toggle wired to `tabbyPrefs` (`localStorage`). |
|
| `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 Tabby's Ask handoff. |
|
| `client/src/pages/Workspace.tsx` | Reads `?prompt=` to prefill the prompt box for Sagi'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/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
|
* @file Layout.tsx
|
||||||
* @description Application shell that frames every authenticated route: persistent
|
* @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
|
* The layout is the single parent route in {@link App} — child pages render inside
|
||||||
* React Router's `<Outlet />` so navigation never remounts chrome.
|
* React Router's `<Outlet />` so navigation never remounts chrome.
|
||||||
*
|
*
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./Sidebar`
|
* - `./Sidebar`
|
||||||
* - `./UpdateNotifier`
|
* - `./UpdateNotifier`
|
||||||
* - `./Tabby/Tabby`
|
* - `./Sagi/Sagi`
|
||||||
*
|
*
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `Layout` — exported API; see TSDoc on the symbol for behavior.
|
* - `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 { useTranslation } from "react-i18next";
|
||||||
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||||
import { UpdateNotifier } from "./UpdateNotifier";
|
import { UpdateNotifier } from "./UpdateNotifier";
|
||||||
import { Tabby } from "./Tabby/Tabby";
|
import { Sagi } from "./Sagi/Sagi";
|
||||||
|
|
||||||
/** Props for {@link Layout}. */
|
/** Props for {@link Layout}. */
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
@@ -100,7 +100,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
|||||||
{t("skipToContent")}
|
{t("skipToContent")}
|
||||||
</a>
|
</a>
|
||||||
<UpdateNotifier />
|
<UpdateNotifier />
|
||||||
<Tabby />
|
<Sagi />
|
||||||
<Sidebar wsConnected={wsConnected} collapsed={collapsed} onToggle={toggle} />
|
<Sidebar wsConnected={wsConnected} collapsed={collapsed} onToggle={toggle} />
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* @file Tabby.tsx
|
* @file Sagi.tsx
|
||||||
* @description Floating cat companion shell. Mounts once (next to UpdateNotifier
|
* @description Floating mascot companion shell. Mounts once (next to UpdateNotifier
|
||||||
* in Layout) so it persists across routes and shares the single WebSocket.
|
* in Layout) so it persists across routes and shares the single WebSocket.
|
||||||
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
||||||
* detection, and route navigation. Reactive personality + status/Ask come
|
* detection, and route navigation. Reactive personality + status/Ask come
|
||||||
* from useTabbyBrain; the avatar is draggable (AssistiveTouch-style) via
|
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||||
* useTabbyPosition, and the bubble/panel render in a self-clamping flyout so
|
* useSagiPosition, 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.
|
* 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
|
* The "do the job" path reuses the existing Run page: unmatched Ask queries
|
||||||
* deep-link to /run?prompt=…&autostart=1 - no new LLM backend.
|
* 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)
|
* 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
|
* ## Design constraints
|
||||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
* - 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./CatAvatar`
|
* - `./SagiAvatar`
|
||||||
* - `./SpeechBubble`
|
* - `./SpeechBubble`
|
||||||
* - `./TabbyPanel`
|
* - `./SagiPanel`
|
||||||
* - `./useTabbyBrain`
|
* - `./useSagiBrain`
|
||||||
* - `./useTabbyPosition`
|
* - `./useSagiPosition`
|
||||||
* - `./intents`
|
* - `./intents`
|
||||||
* - `./prefs`
|
* - `./prefs`
|
||||||
*
|
*
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `Tabby` — exported API; see TSDoc on the symbol for behavior.
|
* - `Sagi` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
* ## Testing pointers
|
* ## Testing pointers
|
||||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||||
* -----------------------------------------------------------------------------
|
* -----------------------------------------------------------------------------
|
||||||
* **Tabby**
|
* **Sagi**
|
||||||
* Part of this module's public contract. Downstream imports should treat
|
* Part of this module's public contract. Downstream imports should treat
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* When behavior changes, update the `@file` overview and relevant tests.
|
||||||
@@ -71,14 +71,14 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { CatAvatar } from "./CatAvatar";
|
import { SagiAvatar } from "./SagiAvatar";
|
||||||
import { SpeechBubble } from "./SpeechBubble";
|
import { SpeechBubble } from "./SpeechBubble";
|
||||||
import { TabbyPanel } from "./TabbyPanel";
|
import { SagiPanel } from "./SagiPanel";
|
||||||
import { useTabbyBrain } from "./useTabbyBrain";
|
import { useSagiBrain } from "./useSagiBrain";
|
||||||
import { useTabbyPosition, TABBY_SIZE } from "./useTabbyPosition";
|
import { useSagiPosition, SAGI_SIZE } from "./useSagiPosition";
|
||||||
import { matchIntent } from "./intents";
|
import { matchIntent } from "./intents";
|
||||||
import { tabbyPrefs } from "./prefs";
|
import { sagiPrefs } from "./prefs";
|
||||||
import "./tabby.css";
|
import "./sagi.css";
|
||||||
|
|
||||||
const FLYOUT_GAP = 10; // px between avatar and flyout
|
const FLYOUT_GAP = 10; // px between avatar and flyout
|
||||||
const VIEWPORT_MARGIN = 12; // min gap from any screen edge
|
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
|
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
|
||||||
* into view instead of being cropped.
|
* 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 ref = useRef<HTMLDivElement>(null);
|
||||||
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
|
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;
|
let left = anchor.side === "left" ? anchor.left : anchor.left + anchor.size - w;
|
||||||
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
||||||
|
|
||||||
// Vertical: prefer above the cat (feels natural). Only drop below when
|
// Vertical: prefer above the mascot (feels natural). Only drop below when
|
||||||
// there isn't room above - i.e. the cat is near the top edge.
|
// there isn't room above - i.e. the mascot is near the top edge.
|
||||||
const above = anchor.top - h - FLYOUT_GAP;
|
const above = anchor.top - h - FLYOUT_GAP;
|
||||||
const below = anchor.top + anchor.size + FLYOUT_GAP;
|
const below = anchor.top + anchor.size + FLYOUT_GAP;
|
||||||
let top = above >= VIEWPORT_MARGIN ? above : below;
|
let top = above >= VIEWPORT_MARGIN ? above : below;
|
||||||
@@ -153,22 +153,22 @@ function TabbyFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode
|
|||||||
}, [place]);
|
}, [place]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="tabby-flyout" style={style}>
|
<div ref={ref} className="sagi-flyout" style={style}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Tabby() {
|
export function Sagi() {
|
||||||
const [enabled, setEnabled] = useState(() => tabbyPrefs.getEnabled());
|
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const reducedMotion = usePrefersReducedMotion();
|
const reducedMotion = usePrefersReducedMotion();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const brain = useTabbyBrain();
|
const brain = useSagiBrain();
|
||||||
const place = useTabbyPosition();
|
const place = useSagiPosition();
|
||||||
|
|
||||||
// Keep enabled in sync with Settings / other tabs.
|
// 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.
|
// ⌘B / Ctrl+B toggles the panel; Esc closes it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -218,10 +218,10 @@ export function Tabby() {
|
|||||||
|
|
||||||
return (
|
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 && (
|
{!place.dragging && open && (
|
||||||
<TabbyFlyout anchor={anchor}>
|
<SagiFlyout anchor={anchor}>
|
||||||
<TabbyPanel
|
<SagiPanel
|
||||||
status={brain.status}
|
status={brain.status}
|
||||||
muted={brain.muted}
|
muted={brain.muted}
|
||||||
onToggleMute={brain.toggleMute}
|
onToggleMute={brain.toggleMute}
|
||||||
@@ -230,19 +230,19 @@ export function Tabby() {
|
|||||||
onAsk={onAsk}
|
onAsk={onAsk}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
/>
|
/>
|
||||||
</TabbyFlyout>
|
</SagiFlyout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!place.dragging && !open && brain.bubble && (
|
{!place.dragging && !open && brain.bubble && (
|
||||||
<TabbyFlyout anchor={anchor}>
|
<SagiFlyout anchor={anchor}>
|
||||||
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
|
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
|
||||||
</TabbyFlyout>
|
</SagiFlyout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="tabby-avatar-btn"
|
className="sagi-avatar-btn"
|
||||||
data-dragging={place.dragging ? "1" : "0"}
|
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}
|
onPointerDown={place.onPointerDown}
|
||||||
onPointerMove={place.onPointerMove}
|
onPointerMove={place.onPointerMove}
|
||||||
onPointerUp={place.onPointerUp}
|
onPointerUp={place.onPointerUp}
|
||||||
@@ -252,13 +252,13 @@ export function Tabby() {
|
|||||||
if (place.consumeDrag()) return;
|
if (place.consumeDrag()) return;
|
||||||
setOpen((v) => !v);
|
setOpen((v) => !v);
|
||||||
}}
|
}}
|
||||||
aria-label={open ? "Close Tabby" : "Open Tabby companion"}
|
aria-label={open ? "Close Sagi" : "Open Sagi companion"}
|
||||||
aria-expanded={open}
|
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 && (
|
{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}
|
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
||||||
</span>
|
</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
|
* @file SagiPanel.tsx
|
||||||
* @description Expanded Tabby panel: a live status strip (live / waiting /
|
* @description Expanded Sagi panel: a live status strip (live / waiting /
|
||||||
* errored stat chips + connection state), quick navigation actions, and a
|
* errored stat chips + connection state), quick navigation actions, and a
|
||||||
* local "Ask" box. Pure presentational - all data and the ask/navigation
|
* local "Ask" box. Pure presentational - all data and the ask/navigation
|
||||||
* behavior are injected by the container.
|
* behavior are injected by the container.
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
* 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
|
* ## Design constraints
|
||||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
* - `./brain`
|
* - `./brain`
|
||||||
*
|
*
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `TabbyPanel` — exported API; see TSDoc on the symbol for behavior.
|
* - `SagiPanel` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
* ## Testing pointers
|
* ## Testing pointers
|
||||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||||
* -----------------------------------------------------------------------------
|
* -----------------------------------------------------------------------------
|
||||||
* **TabbyPanel**
|
* **SagiPanel**
|
||||||
* Part of this module's public contract. Downstream imports should treat
|
* Part of this module's public contract. Downstream imports should treat
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* When behavior changes, update the `@file` overview and relevant tests.
|
||||||
@@ -64,10 +64,10 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { TabbyStatus } from "./brain";
|
import type { SagiStatus } from "./brain";
|
||||||
|
|
||||||
interface TabbyPanelProps {
|
interface SagiPanelProps {
|
||||||
status: TabbyStatus;
|
status: SagiStatus;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
onToggleMute: () => void;
|
onToggleMute: () => void;
|
||||||
onClearAlerts: () => void;
|
onClearAlerts: () => void;
|
||||||
@@ -77,7 +77,7 @@ interface TabbyPanelProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabbyPanel({
|
export function SagiPanel({
|
||||||
status,
|
status,
|
||||||
muted,
|
muted,
|
||||||
onToggleMute,
|
onToggleMute,
|
||||||
@@ -85,7 +85,7 @@ export function TabbyPanel({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
onAsk,
|
onAsk,
|
||||||
onClose,
|
onClose,
|
||||||
}: TabbyPanelProps) {
|
}: SagiPanelProps) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [answer, setAnswer] = useState<string | null>(null);
|
const [answer, setAnswer] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ export function TabbyPanel({
|
|||||||
<div
|
<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"
|
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"
|
role="dialog"
|
||||||
aria-label="Tabby companion"
|
aria-label="Sagi companion"
|
||||||
>
|
>
|
||||||
{/* header */}
|
{/* 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">
|
<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 className="text-base leading-none" aria-hidden>
|
||||||
🐾
|
🐾
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold text-fg-primary">Tabby</span>
|
<span className="text-sm font-semibold text-fg-primary">Sagi</span>
|
||||||
<span
|
<span
|
||||||
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||||
status.connected
|
status.connected
|
||||||
@@ -128,7 +128,7 @@ export function TabbyPanel({
|
|||||||
<button
|
<button
|
||||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close Tabby"
|
aria-label="Close Sagi"
|
||||||
>
|
>
|
||||||
<X size={15} />
|
<X size={15} />
|
||||||
</button>
|
</button>
|
||||||
@@ -189,10 +189,10 @@ export function TabbyPanel({
|
|||||||
<form onSubmit={submit} className="flex items-center gap-1.5">
|
<form onSubmit={submit} className="flex items-center gap-1.5">
|
||||||
<input
|
<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"
|
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}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
aria-label="Ask Tabby"
|
aria-label="Ask Sagi"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
+5
-5
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file SpeechBubble.tsx
|
* @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
|
* 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.
|
* user dismissal.
|
||||||
*
|
*
|
||||||
* ## Accessibility
|
* ## Accessibility
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
* 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
|
* ## Design constraints
|
||||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
* - 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}.
|
* @param props See {@link SpeechBubbleProps}.
|
||||||
*/
|
*/
|
||||||
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
|
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="tabby-bubble tabby-bubble-enter"
|
className="sagi-bubble sagi-bubble-enter"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
+35
-35
@@ -1,20 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* @file Tabby.test.tsx
|
* @file Sagi.test.tsx
|
||||||
* @description Render tests for the Tabby companion component — mounting, mood rendering, the ⌘B panel toggle, and accessibility attributes.
|
* @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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, act, cleanup, within } from "@testing-library/react";
|
import { render, screen, fireEvent, act, cleanup, within } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { Tabby } from "../Tabby";
|
import { Sagi } from "../Sagi";
|
||||||
import { eventBus } from "../../../lib/eventBus";
|
import { eventBus } from "../../../lib/eventBus";
|
||||||
import type { WSMessage, Session } from "../../../lib/types";
|
import type { WSMessage, Session } from "../../../lib/types";
|
||||||
|
|
||||||
function renderTabby() {
|
function renderSagi() {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Tabby />
|
<Sagi />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -38,27 +38,27 @@ afterEach(() => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Tabby widget", () => {
|
describe("Sagi widget", () => {
|
||||||
it("renders the avatar button by default", () => {
|
it("renders the avatar button by default", () => {
|
||||||
renderTabby();
|
renderSagi();
|
||||||
expect(screen.getByRole("button", { name: /open tabby companion/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /open sagi companion/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("img", { name: /tabby/i })).toBeInTheDocument();
|
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the panel on click and answers a local status question", () => {
|
it("opens the panel on click and answers a local status question", () => {
|
||||||
renderTabby();
|
renderSagi();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open tabby companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
const panel = screen.getByRole("dialog", { name: /tabby companion/i });
|
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
||||||
expect(panel).toBeInTheDocument();
|
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.change(input, { target: { value: "status" } });
|
||||||
fireEvent.submit(input.closest("form")!);
|
fireEvent.submit(input.closest("form")!);
|
||||||
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
|
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles open/closed with Cmd/Ctrl+B and closes with Esc", () => {
|
it("toggles open/closed with Cmd/Ctrl+B and closes with Esc", () => {
|
||||||
renderTabby();
|
renderSagi();
|
||||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
act(() => {
|
act(() => {
|
||||||
fireEvent.keyDown(window, { key: "b", metaKey: true });
|
fireEvent.keyDown(window, { key: "b", metaKey: true });
|
||||||
@@ -71,47 +71,47 @@ describe("Tabby widget", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shows an error badge when a session errors", () => {
|
it("shows an error badge when a session errors", () => {
|
||||||
renderTabby();
|
renderSagi();
|
||||||
act(() => {
|
act(() => {
|
||||||
eventBus.publish(sessionMsg("a", "error"));
|
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();
|
expect(within(btn).getByText("1")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reflects the live count in the panel status", () => {
|
it("reflects the live count in the panel status", () => {
|
||||||
renderTabby();
|
renderSagi();
|
||||||
act(() => {
|
act(() => {
|
||||||
eventBus.publish(sessionMsg("a", "active"));
|
eventBus.publish(sessionMsg("a", "active"));
|
||||||
eventBus.publish(sessionMsg("b", "active"));
|
eventBus.publish(sessionMsg("b", "active"));
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open tabby companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
const panel = screen.getByRole("dialog", { name: /tabby companion/i });
|
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
||||||
// The "live" stat chip shows value 2 next to its label.
|
// The "live" stat chip shows value 2 next to its label.
|
||||||
const liveChip = within(panel).getByText("live").closest("div")!;
|
const liveChip = within(panel).getByText("live").closest("div")!;
|
||||||
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects the enabled preference", () => {
|
it("respects the enabled preference", () => {
|
||||||
localStorage.setItem("agent-dashboard-tabby-enabled", "false");
|
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
|
||||||
renderTabby();
|
renderSagi();
|
||||||
expect(screen.queryByRole("button", { name: /open tabby companion/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a tap (no movement) still opens the panel", () => {
|
it("a tap (no movement) still opens the panel", () => {
|
||||||
renderTabby();
|
renderSagi();
|
||||||
const btn = screen.getByRole("button", { name: /open tabby companion/i });
|
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||||
act(() => {
|
act(() => {
|
||||||
fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 });
|
fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 });
|
||||||
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
||||||
});
|
});
|
||||||
fireEvent.click(btn);
|
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", () => {
|
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
|
||||||
renderTabby();
|
renderSagi();
|
||||||
const btn = screen.getByRole("button", { name: /open tabby companion/i });
|
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||||
// Default dock is bottom-right. Drag far to the left past the threshold.
|
// Default dock is bottom-right. Drag far to the left past the threshold.
|
||||||
act(() => {
|
act(() => {
|
||||||
fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 });
|
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.
|
// The synthetic click that follows a drag must be swallowed.
|
||||||
fireEvent.click(btn);
|
fireEvent.click(btn);
|
||||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
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(saved.side).toBe("left");
|
||||||
expect(typeof saved.y).toBe("number");
|
expect(typeof saved.y).toBe("number");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a sub-threshold pointer move is treated as a tap, not a drag", () => {
|
it("a sub-threshold pointer move is treated as a tap, not a drag", () => {
|
||||||
renderTabby();
|
renderSagi();
|
||||||
const btn = screen.getByRole("button", { name: /open tabby companion/i });
|
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||||
act(() => {
|
act(() => {
|
||||||
fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 });
|
fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 });
|
||||||
fireEvent.pointerMove(btn, { clientX: 992, clientY: 701 }); // < 5px threshold
|
fireEvent.pointerMove(btn, { clientX: 992, clientY: 701 }); // < 5px threshold
|
||||||
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
|
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
|
||||||
});
|
});
|
||||||
fireEvent.click(btn);
|
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.
|
// 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", () => {
|
it("restores a persisted left-edge position on mount", () => {
|
||||||
localStorage.setItem("agent-dashboard-tabby-pos", JSON.stringify({ side: "left", y: 0.2 }));
|
localStorage.setItem("agent-dashboard-sagi-pos", JSON.stringify({ side: "left", y: 0.2 }));
|
||||||
renderTabby();
|
renderSagi();
|
||||||
const btn = screen.getByRole("button", { name: /open tabby companion/i }) as HTMLElement;
|
const btn = screen.getByRole("button", { name: /open sagi companion/i }) as HTMLElement;
|
||||||
// Left-docked → inline left equals the edge margin (16px).
|
// Left-docked → inline left equals the edge margin (16px).
|
||||||
expect(btn.style.left).toBe("16px");
|
expect(btn.style.left).toBe("16px");
|
||||||
});
|
});
|
||||||
+52
-52
@@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* @file brain.test.ts
|
* @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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
initialTabbyState,
|
initialSagiState,
|
||||||
reduceTabby,
|
reduceSagi,
|
||||||
deriveMood,
|
deriveMood,
|
||||||
statusOf,
|
statusOf,
|
||||||
clearErrors,
|
clearErrors,
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
WORRIED_MS,
|
WORRIED_MS,
|
||||||
STUCK_MS,
|
STUCK_MS,
|
||||||
SLEEP_MS,
|
SLEEP_MS,
|
||||||
type TabbyState,
|
type SagiState,
|
||||||
} from "../brain";
|
} from "../brain";
|
||||||
import type {
|
import type {
|
||||||
WSMessage,
|
WSMessage,
|
||||||
@@ -53,65 +53,65 @@ function runStatusMsg(d: Partial<RunStatusPayload>): WSMessage {
|
|||||||
|
|
||||||
describe("deriveMood priority", () => {
|
describe("deriveMood priority", () => {
|
||||||
it("disconnected outranks everything", () => {
|
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");
|
expect(deriveMood(s, T0)).toBe("disconnected");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("worried outranks stuck", () => {
|
it("worried outranks stuck", () => {
|
||||||
let s = initialTabbyState(T0);
|
let s = initialSagiState(T0);
|
||||||
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0)); // live
|
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0)); // live
|
||||||
s = { ...s, lastActivityAt: T0 - STUCK_MS - 1, worriedUntil: T0 + 100 };
|
s = { ...s, lastActivityAt: T0 - STUCK_MS - 1, worriedUntil: T0 + 100 };
|
||||||
expect(deriveMood(s, T0)).toBe("worried");
|
expect(deriveMood(s, T0)).toBe("worried");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stuck when a live session goes silent past STUCK_MS", () => {
|
it("stuck when a live session goes silent past STUCK_MS", () => {
|
||||||
let s = initialTabbyState(T0);
|
let s = initialSagiState(T0);
|
||||||
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
|
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||||
expect(deriveMood(s, T0 + STUCK_MS + 1)).toBe("stuck");
|
expect(deriveMood(s, T0 + STUCK_MS + 1)).toBe("stuck");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("happy is transient then falls back to idle when nothing live", () => {
|
it("happy is transient then falls back to idle when nothing live", () => {
|
||||||
let s = initialTabbyState(T0);
|
let s = initialSagiState(T0);
|
||||||
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
|
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||||
({ state: s } = reduceTabby(s, sessionMsg("a", "completed"), T0)); // no longer live
|
({ state: s } = reduceSagi(s, sessionMsg("a", "completed"), T0)); // no longer live
|
||||||
expect(deriveMood(s, T0 + 10)).toBe("happy");
|
expect(deriveMood(s, T0 + 10)).toBe("happy");
|
||||||
expect(deriveMood(s, T0 + HAPPY_MS + 1)).toBe("idle");
|
expect(deriveMood(s, T0 + HAPPY_MS + 1)).toBe("idle");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("thinking shows when set and nothing higher applies", () => {
|
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");
|
expect(deriveMood(s, T0)).toBe("thinking");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("watching when a session is live and recent", () => {
|
it("watching when a session is live and recent", () => {
|
||||||
let s = initialTabbyState(T0);
|
let s = initialSagiState(T0);
|
||||||
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
|
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||||
expect(deriveMood(s, T0 + 1000)).toBe("watching");
|
expect(deriveMood(s, T0 + 1000)).toBe("watching");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sleeping after SLEEP_MS of no activity and nothing live", () => {
|
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");
|
expect(deriveMood(s, T0 + SLEEP_MS + 1)).toBe("sleeping");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("idle by default", () => {
|
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", () => {
|
it("tracks live count accurately across transitions", () => {
|
||||||
let s = initialTabbyState(T0);
|
let s = initialSagiState(T0);
|
||||||
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
|
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||||
({ state: s } = reduceTabby(s, sessionMsg("b", "active"), T0));
|
({ state: s } = reduceSagi(s, sessionMsg("b", "active"), T0));
|
||||||
expect(statusOf(s).liveCount).toBe(2);
|
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);
|
expect(statusOf(s).liveCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("counts errored sessions and emits error pulse", () => {
|
it("counts errored sessions and emits error pulse", () => {
|
||||||
let s = initialTabbyState(T0);
|
let s = initialSagiState(T0);
|
||||||
const r = reduceTabby(s, sessionMsg("a", "error"), T0);
|
const r = reduceSagi(s, sessionMsg("a", "error"), T0);
|
||||||
s = r.state;
|
s = r.state;
|
||||||
expect(r.pulse).toBe("error");
|
expect(r.pulse).toBe("error");
|
||||||
expect(statusOf(s).errorCount).toBe(1);
|
expect(statusOf(s).errorCount).toBe(1);
|
||||||
@@ -119,70 +119,70 @@ describe("reduceTabby counts and pulses", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("session_start pulse only on first active transition", () => {
|
it("session_start pulse only on first active transition", () => {
|
||||||
let s = initialTabbyState(T0);
|
let s = initialSagiState(T0);
|
||||||
const r1 = reduceTabby(s, sessionMsg("a", "active"), T0);
|
const r1 = reduceSagi(s, sessionMsg("a", "active"), T0);
|
||||||
expect(r1.pulse).toBe("session_start");
|
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);
|
expect(r2.pulse).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("session_done pulse only when the session was tracked", () => {
|
it("session_done pulse only when the session was tracked", () => {
|
||||||
let s = initialTabbyState(T0);
|
let s = initialSagiState(T0);
|
||||||
const untracked = reduceTabby(s, sessionMsg("ghost", "completed"), T0);
|
const untracked = reduceSagi(s, sessionMsg("ghost", "completed"), T0);
|
||||||
expect(untracked.pulse).toBe(null);
|
expect(untracked.pulse).toBe(null);
|
||||||
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
|
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||||
const done = reduceTabby(s, sessionMsg("a", "completed"), T0);
|
const done = reduceSagi(s, sessionMsg("a", "completed"), T0);
|
||||||
expect(done.pulse).toBe("session_done");
|
expect(done.pulse).toBe("session_done");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("agent error triggers worried via pulse", () => {
|
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(r.pulse).toBe("error");
|
||||||
expect(deriveMood(r.state, T0)).toBe("worried");
|
expect(deriveMood(r.state, T0)).toBe("worried");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a newly created subagent emits subagent_spawn, a main agent does not", () => {
|
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"
|
"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", () => {
|
it("waiting transition emits a waiting pulse once and still counts as live", () => {
|
||||||
let s = initialTabbyState(T0);
|
let s = initialSagiState(T0);
|
||||||
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0)); // active
|
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0)); // active
|
||||||
const first = reduceTabby(s, waitingMsg("a"), T0);
|
const first = reduceSagi(s, waitingMsg("a"), T0);
|
||||||
expect(first.pulse).toBe("waiting");
|
expect(first.pulse).toBe("waiting");
|
||||||
expect(statusOf(first.state).liveCount).toBe(1);
|
expect(statusOf(first.state).liveCount).toBe(1);
|
||||||
// A repeat waiting update does not re-announce.
|
// 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);
|
expect(second.pulse).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("failure event types set worried, normal events do not", () => {
|
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");
|
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.pulse).toBe(null);
|
||||||
expect(ok.state.worriedUntil).toBe(0);
|
expect(ok.state.worriedUntil).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("run_status updates activity timestamp only (no exit code available)", () => {
|
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);
|
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);
|
expect(gone.pulse).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("any handled message refreshes lastActivityAt", () => {
|
it("any handled message refreshes lastActivityAt", () => {
|
||||||
const s = { ...initialTabbyState(T0), lastActivityAt: T0 - 99999 };
|
const s = { ...initialSagiState(T0), lastActivityAt: T0 - 99999 };
|
||||||
const { state } = reduceTabby(s, eventMsg("postToolUse"), T0 + 5);
|
const { state } = reduceSagi(s, eventMsg("postToolUse"), T0 + 5);
|
||||||
expect(state.lastActivityAt).toBe(T0 + 5);
|
expect(state.lastActivityAt).toBe(T0 + 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores unrelated message types without mutating", () => {
|
it("ignores unrelated message types without mutating", () => {
|
||||||
const s = initialTabbyState(T0);
|
const s = initialSagiState(T0);
|
||||||
const r = reduceTabby(s, { type: "import.progress", data: {} as never, timestamp: "x" }, T0);
|
const r = reduceSagi(s, { type: "import.progress", data: {} as never, timestamp: "x" }, T0);
|
||||||
expect(r.state).toBe(s);
|
expect(r.state).toBe(s);
|
||||||
expect(r.pulse).toBe(null);
|
expect(r.pulse).toBe(null);
|
||||||
});
|
});
|
||||||
@@ -191,7 +191,7 @@ describe("reduceTabby counts and pulses", () => {
|
|||||||
describe("seedSessions", () => {
|
describe("seedSessions", () => {
|
||||||
it("hydrates live/waiting/errored counts from a REST snapshot", () => {
|
it("hydrates live/waiting/errored counts from a REST snapshot", () => {
|
||||||
const s = seedSessions(
|
const s = seedSessions(
|
||||||
initialTabbyState(T0),
|
initialSagiState(T0),
|
||||||
[
|
[
|
||||||
{ id: "a", status: "active" },
|
{ id: "a", status: "active" },
|
||||||
{ id: "b", status: "active", awaiting_input_since: "2026-05-29T00:00:00Z" },
|
{ id: "b", status: "active", awaiting_input_since: "2026-05-29T00:00:00Z" },
|
||||||
@@ -209,17 +209,17 @@ describe("seedSessions", () => {
|
|||||||
|
|
||||||
describe("statusOf waiting", () => {
|
describe("statusOf waiting", () => {
|
||||||
it("counts a waiting session as both live and waiting", () => {
|
it("counts a waiting session as both live and waiting", () => {
|
||||||
let s = initialTabbyState(T0);
|
let s = initialSagiState(T0);
|
||||||
({ state: s } = reduceTabby(s, waitingMsg("a"), T0));
|
({ state: s } = reduceSagi(s, waitingMsg("a"), T0));
|
||||||
expect(statusOf(s)).toMatchObject({ liveCount: 1, waitingCount: 1, errorCount: 0 });
|
expect(statusOf(s)).toMatchObject({ liveCount: 1, waitingCount: 1, errorCount: 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("clearErrors", () => {
|
describe("clearErrors", () => {
|
||||||
it("drops errored sessions but keeps active ones", () => {
|
it("drops errored sessions but keeps active ones", () => {
|
||||||
let s = initialTabbyState(T0);
|
let s = initialSagiState(T0);
|
||||||
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
|
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||||
({ state: s } = reduceTabby(s, sessionMsg("b", "error"), T0));
|
({ state: s } = reduceSagi(s, sessionMsg("b", "error"), T0));
|
||||||
s = clearErrors(s);
|
s = clearErrors(s);
|
||||||
expect(statusOf(s).errorCount).toBe(0);
|
expect(statusOf(s).errorCount).toBe(0);
|
||||||
expect(statusOf(s).liveCount).toBe(1);
|
expect(statusOf(s).liveCount).toBe(1);
|
||||||
+3
-3
@@ -1,14 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* @file intents.test.ts
|
* @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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { matchIntent } from "../intents";
|
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,
|
liveCount: 0,
|
||||||
waitingCount: 0,
|
waitingCount: 0,
|
||||||
errorCount: 0,
|
errorCount: 0,
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file quips.test.ts
|
* @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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* @file brain.ts
|
* @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
|
* 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
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
* 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
|
* ## Design constraints
|
||||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||||
@@ -29,18 +29,18 @@
|
|||||||
*
|
*
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `Mood` — exported API; see TSDoc on the symbol for behavior.
|
* - `Mood` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `TabbyPulse` — exported API; see TSDoc on the symbol for behavior.
|
* - `SagiPulse` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `TabbyStatus` — exported API; see TSDoc on the symbol for behavior.
|
* - `SagiStatus` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `TabbyState` — 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.
|
* - `HAPPY_MS` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `WORRIED_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.
|
* - `STUCK_MS` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `SLEEP_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.
|
* - `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.
|
* - `statusOf` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `deriveMood` — 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.
|
* - `seedSessions` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `clearErrors` — 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.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* When behavior changes, update the `@file` overview and relevant tests.
|
||||||
*
|
*
|
||||||
* **TabbyPulse**
|
* **SagiPulse**
|
||||||
* Part of this module's public contract. Downstream imports should treat
|
* Part of this module's public contract. Downstream imports should treat
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* When behavior changes, update the `@file` overview and relevant tests.
|
||||||
*
|
*
|
||||||
* **TabbyStatus**
|
* **SagiStatus**
|
||||||
* Part of this module's public contract. Downstream imports should treat
|
* Part of this module's public contract. Downstream imports should treat
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* When behavior changes, update the `@file` overview and relevant tests.
|
||||||
*
|
*
|
||||||
* **TabbyState**
|
* **SagiState**
|
||||||
* Part of this module's public contract. Downstream imports should treat
|
* Part of this module's public contract. Downstream imports should treat
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* 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.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* When behavior changes, update the `@file` overview and relevant tests.
|
||||||
*
|
*
|
||||||
* **initialTabbyState**
|
* **initialSagiState**
|
||||||
* Part of this module's public contract. Downstream imports should treat
|
* Part of this module's public contract. Downstream imports should treat
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* 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.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* When behavior changes, update the `@file` overview and relevant tests.
|
||||||
*
|
*
|
||||||
* **reduceTabby**
|
* **reduceSagi**
|
||||||
* Part of this module's public contract. Downstream imports should treat
|
* Part of this module's public contract. Downstream imports should treat
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* When behavior changes, update the `@file` overview and relevant tests.
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
|
|
||||||
import type { WSMessage, Session, Agent, RunStatusPayload, DashboardEvent } from "../../lib/types";
|
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 =
|
export type Mood =
|
||||||
| "disconnected"
|
| "disconnected"
|
||||||
| "worried"
|
| "worried"
|
||||||
@@ -148,11 +148,11 @@ export type Mood =
|
|||||||
| "idle";
|
| "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
|
* The hook turns pulses into transient speech bubbles. `null` means the message
|
||||||
* was irrelevant or non-notable.
|
* was irrelevant or non-notable.
|
||||||
*/
|
*/
|
||||||
export type TabbyPulse =
|
export type SagiPulse =
|
||||||
| "session_done"
|
| "session_done"
|
||||||
| "session_start"
|
| "session_start"
|
||||||
| "subagent_spawn"
|
| "subagent_spawn"
|
||||||
@@ -161,7 +161,7 @@ export type TabbyPulse =
|
|||||||
| "run_done"
|
| "run_done"
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
export interface TabbyStatus {
|
export interface SagiStatus {
|
||||||
/** Active + waiting sessions (everything not finished/errored). */
|
/** Active + waiting sessions (everything not finished/errored). */
|
||||||
liveCount: number;
|
liveCount: number;
|
||||||
/** Subset of liveCount currently blocked on user input. */
|
/** Subset of liveCount currently blocked on user input. */
|
||||||
@@ -170,7 +170,7 @@ export interface TabbyStatus {
|
|||||||
connected: boolean;
|
connected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TabbyState {
|
export interface SagiState {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
/** Latest status per session id we still care about. "waiting" = active but
|
/** Latest status per session id we still care about. "waiting" = active but
|
||||||
* blocked on user input; counts as live for the status line. */
|
* 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",
|
"diagnosticError",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function initialTabbyState(now: number): TabbyState {
|
export function initialSagiState(now: number): SagiState {
|
||||||
return {
|
return {
|
||||||
connected: true,
|
connected: true,
|
||||||
sessions: {},
|
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 liveCount = 0;
|
||||||
let waitingCount = 0;
|
let waitingCount = 0;
|
||||||
let errorCount = 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)
|
* so callers (and tests) control the clock; transient windows (happy/worried)
|
||||||
* and inactivity windows (stuck/sleeping) are evaluated against it.
|
* 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 (!state.connected) return "disconnected";
|
||||||
if (now < state.worriedUntil) return "worried";
|
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
|
* (new object) and a one-shot pulse describing what happened. Unknown or
|
||||||
* irrelevant message types pass through unchanged with a `null` pulse.
|
* irrelevant message types pass through unchanged with a `null` pulse.
|
||||||
*/
|
*/
|
||||||
export function reduceTabby(
|
export function reduceSagi(
|
||||||
state: TabbyState,
|
state: SagiState,
|
||||||
msg: WSMessage,
|
msg: WSMessage,
|
||||||
now: number
|
now: number
|
||||||
): { state: TabbyState; pulse: TabbyPulse } {
|
): { state: SagiState; pulse: SagiPulse } {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "session_created":
|
case "session_created":
|
||||||
case "session_updated": {
|
case "session_updated": {
|
||||||
const s = msg.data as Session;
|
const s = msg.data as Session;
|
||||||
if (!s || !s.id) return { state, pulse: null };
|
if (!s || !s.id) return { state, pulse: null };
|
||||||
const sessions = { ...state.sessions };
|
const sessions = { ...state.sessions };
|
||||||
let pulse: TabbyPulse = null;
|
let pulse: SagiPulse = null;
|
||||||
let happyUntil = state.happyUntil;
|
let happyUntil = state.happyUntil;
|
||||||
let worriedUntil = state.worriedUntil;
|
let worriedUntil = state.worriedUntil;
|
||||||
|
|
||||||
@@ -309,7 +309,7 @@ export function reduceTabby(
|
|||||||
}
|
}
|
||||||
// A freshly spawned subagent is worth announcing; the main agent landing
|
// A freshly spawned subagent is worth announcing; the main agent landing
|
||||||
// is already covered by session_start.
|
// 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 };
|
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.
|
* never clears the error window. Live WS deltas continue to refine this.
|
||||||
*/
|
*/
|
||||||
export function seedSessions(
|
export function seedSessions(
|
||||||
state: TabbyState,
|
state: SagiState,
|
||||||
rows: ReadonlyArray<{ id: string; status: string; awaiting_input_since?: string | null }>,
|
rows: ReadonlyArray<{ id: string; status: string; awaiting_input_since?: string | null }>,
|
||||||
now: number
|
now: number
|
||||||
): TabbyState {
|
): SagiState {
|
||||||
const sessions = { ...state.sessions };
|
const sessions = { ...state.sessions };
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (!r || !r.id) continue;
|
if (!r || !r.id) continue;
|
||||||
@@ -376,8 +376,8 @@ export function seedSessions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Drop all errored sessions from tracking (used by "clear alerts"). */
|
/** Drop all errored sessions from tracking (used by "clear alerts"). */
|
||||||
export function clearErrors(state: TabbyState): TabbyState {
|
export function clearErrors(state: SagiState): SagiState {
|
||||||
const sessions: TabbyState["sessions"] = {};
|
const sessions: SagiState["sessions"] = {};
|
||||||
for (const [id, s] of Object.entries(state.sessions)) {
|
for (const [id, s] of Object.entries(state.sessions)) {
|
||||||
if (s !== "error") sessions[id] = s;
|
if (s !== "error") sessions[id] = s;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file intents.ts
|
* @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
|
* 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`).
|
* 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.
|
* 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)
|
* 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
|
* ## Design constraints
|
||||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
* - 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 };
|
export type AskResult = { kind: "answer"; text: string } | { kind: "handoff"; prompt: string };
|
||||||
|
|
||||||
const plural = (n: number) => (n === 1 ? "" : "s");
|
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();
|
const q = query.trim().toLowerCase();
|
||||||
if (!q) {
|
if (!q) {
|
||||||
return {
|
return {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file prefs.ts
|
* @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
|
* 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.
|
* and the live widget stay in sync within the same tab without a reload.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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)
|
* 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
|
* ## Design constraints
|
||||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
* - 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.
|
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||||
*
|
*
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `TabbyPos` — exported API; see TSDoc on the symbol for behavior.
|
* - `SagiPos` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `tabbyPrefs` — exported API; see TSDoc on the symbol for behavior.
|
* - `sagiPrefs` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
* ## Testing pointers
|
* ## Testing pointers
|
||||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||||
@@ -39,29 +39,29 @@
|
|||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||||
* -----------------------------------------------------------------------------
|
* -----------------------------------------------------------------------------
|
||||||
* **TabbyPos**
|
* **SagiPos**
|
||||||
* Part of this module's public contract. Downstream imports should treat
|
* Part of this module's public contract. Downstream imports should treat
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* When behavior changes, update the `@file` overview and relevant tests.
|
||||||
*
|
*
|
||||||
* **tabbyPrefs**
|
* **sagiPrefs**
|
||||||
* Part of this module's public contract. Downstream imports should treat
|
* Part of this module's public contract. Downstream imports should treat
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* When behavior changes, update the `@file` overview and relevant tests.
|
||||||
*
|
*
|
||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
const ENABLED_KEY = "agent-dashboard-tabby-enabled";
|
const ENABLED_KEY = "agent-dashboard-sagi-enabled";
|
||||||
const MUTED_KEY = "agent-dashboard-tabby-muted";
|
const MUTED_KEY = "agent-dashboard-sagi-muted";
|
||||||
const POS_KEY = "agent-dashboard-tabby-pos";
|
const POS_KEY = "agent-dashboard-sagi-pos";
|
||||||
const EVENT = "tabby:prefs";
|
const EVENT = "sagi:prefs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persisted resting position, AssistiveTouch-style: the widget always docks to
|
* Persisted resting position, AssistiveTouch-style: the widget always docks to
|
||||||
* the left or right edge, remembering its vertical offset. `y` is stored as a
|
* 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.
|
* fraction of the viewport height (0–1) so it survives window resizes.
|
||||||
*/
|
*/
|
||||||
export interface TabbyPos {
|
export interface SagiPos {
|
||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
@@ -88,11 +88,11 @@ function writeBool(key: string, value: boolean): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readPos(): TabbyPos | null {
|
function readPos(): SagiPos | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(POS_KEY);
|
const raw = localStorage.getItem(POS_KEY);
|
||||||
if (!raw) return null;
|
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") {
|
if ((p.side === "left" || p.side === "right") && typeof p.y === "number") {
|
||||||
return { side: p.side, y: Math.min(1, Math.max(0, p.y)) };
|
return { 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 {
|
try {
|
||||||
localStorage.setItem(POS_KEY, JSON.stringify(pos));
|
localStorage.setItem(POS_KEY, JSON.stringify(pos));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -112,7 +112,7 @@ function writePos(pos: TabbyPos): void {
|
|||||||
// are local to the widget and shouldn't churn the Settings toggle listeners.
|
// are local to the widget and shouldn't churn the Settings toggle listeners.
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tabbyPrefs = {
|
export const sagiPrefs = {
|
||||||
getEnabled: () => readBool(ENABLED_KEY, true),
|
getEnabled: () => readBool(ENABLED_KEY, true),
|
||||||
setEnabled: (v: boolean) => writeBool(ENABLED_KEY, v),
|
setEnabled: (v: boolean) => writeBool(ENABLED_KEY, v),
|
||||||
getMuted: () => readBool(MUTED_KEY, false),
|
getMuted: () => readBool(MUTED_KEY, false),
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file quips.ts
|
* @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
|
* plus a deterministic-by-injection picker. Pure data + a pure function so it
|
||||||
* can be unit-tested without randomness leaking in.
|
* can be unit-tested without randomness leaking in.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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)
|
* 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
|
* ## Design constraints
|
||||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
* - 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[]> = {
|
const QUIPS: Record<QuipKey, string[]> = {
|
||||||
// Pulses (event-driven, transient bubbles)
|
// 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
|
* @file useSagiBrain.ts
|
||||||
* @description React hook that wires the pure Tabby brain to the live event bus
|
* @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
|
* 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
|
* exposes the derived mood, a status summary, the current speech bubble, and
|
||||||
* imperative controls (mute, clear alerts, set thinking) for the UI shell.
|
* 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)
|
* 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
|
* ## Design constraints
|
||||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||||
@@ -32,8 +32,8 @@
|
|||||||
* - `./prefs`
|
* - `./prefs`
|
||||||
*
|
*
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `TabbyBrain` — exported API; see TSDoc on the symbol for behavior.
|
* - `SagiBrain` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `useTabbyBrain` — exported API; see TSDoc on the symbol for behavior.
|
* - `useSagiBrain` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
* ## Testing pointers
|
* ## Testing pointers
|
||||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||||
@@ -48,12 +48,12 @@
|
|||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||||
* -----------------------------------------------------------------------------
|
* -----------------------------------------------------------------------------
|
||||||
* **TabbyBrain**
|
* **SagiBrain**
|
||||||
* Part of this module's public contract. Downstream imports should treat
|
* Part of this module's public contract. Downstream imports should treat
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* When behavior changes, update the `@file` overview and relevant tests.
|
||||||
*
|
*
|
||||||
* **useTabbyBrain**
|
* **useSagiBrain**
|
||||||
* Part of this module's public contract. Downstream imports should treat
|
* Part of this module's public contract. Downstream imports should treat
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* 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 { api } from "../../lib/api";
|
||||||
import type { WSMessage } from "../../lib/types";
|
import type { WSMessage } from "../../lib/types";
|
||||||
import {
|
import {
|
||||||
initialTabbyState,
|
initialSagiState,
|
||||||
reduceTabby,
|
reduceSagi,
|
||||||
deriveMood,
|
deriveMood,
|
||||||
statusOf,
|
statusOf,
|
||||||
clearErrors,
|
clearErrors,
|
||||||
seedSessions,
|
seedSessions,
|
||||||
type Mood,
|
type Mood,
|
||||||
type TabbyState,
|
type SagiState,
|
||||||
type TabbyStatus,
|
type SagiStatus,
|
||||||
} from "./brain";
|
} from "./brain";
|
||||||
import { pickQuip } from "./quips";
|
import { pickQuip } from "./quips";
|
||||||
import { tabbyPrefs } from "./prefs";
|
import { sagiPrefs } from "./prefs";
|
||||||
|
|
||||||
const BUBBLE_MS = 4500;
|
const BUBBLE_MS = 4500;
|
||||||
// Minimum gap between non-error bubbles, so a burst of activity doesn't spam.
|
// Minimum gap between non-error bubbles, so a burst of activity doesn't spam.
|
||||||
const BUBBLE_THROTTLE_MS = 3000;
|
const BUBBLE_THROTTLE_MS = 3000;
|
||||||
|
|
||||||
export interface TabbyBrain {
|
export interface SagiBrain {
|
||||||
mood: Mood;
|
mood: Mood;
|
||||||
status: TabbyStatus;
|
status: SagiStatus;
|
||||||
bubble: string | null;
|
bubble: string | null;
|
||||||
dismissBubble: () => void;
|
dismissBubble: () => void;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
@@ -93,18 +93,18 @@ export interface TabbyBrain {
|
|||||||
setThinking: (v: boolean) => void;
|
setThinking: (v: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTabbyBrain(): TabbyBrain {
|
export function useSagiBrain(): SagiBrain {
|
||||||
const now0 = Date.now();
|
const now0 = Date.now();
|
||||||
// Start optimistically connected (idle, open eyes) so the cursor-tracking
|
// Start optimistically connected (idle, open eyes) so the cursor-tracking
|
||||||
// eyes are live from the first frame instead of after the WebSocket finishes
|
// 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.
|
// its initial handshake. onConnection below corrects this if it's truly down.
|
||||||
const [state, setState] = useState<TabbyState>(() => ({
|
const [state, setState] = useState<SagiState>(() => ({
|
||||||
...initialTabbyState(now0),
|
...initialSagiState(now0),
|
||||||
connected: true,
|
connected: true,
|
||||||
}));
|
}));
|
||||||
const [tick, setTick] = useState(now0);
|
const [tick, setTick] = useState(now0);
|
||||||
const [bubble, setBubble] = useState<string | null>(null);
|
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 bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
const lastBubbleAt = useRef(0);
|
const lastBubbleAt = useRef(0);
|
||||||
@@ -112,7 +112,7 @@ export function useTabbyBrain(): TabbyBrain {
|
|||||||
mutedRef.current = muted;
|
mutedRef.current = muted;
|
||||||
|
|
||||||
// Keep mute in sync with the Settings page / other tabs.
|
// 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) => {
|
const showBubble = useCallback((text: string, force: boolean) => {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
@@ -148,7 +148,7 @@ export function useTabbyBrain(): TabbyBrain {
|
|||||||
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
||||||
const t = Date.now();
|
const t = Date.now();
|
||||||
setState((prev) => {
|
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");
|
if (pulse) showBubble(pickQuip(pulse), pulse === "error");
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -181,7 +181,7 @@ export function useTabbyBrain(): TabbyBrain {
|
|||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
const toggleMute = useCallback(() => {
|
||||||
const next = !mutedRef.current;
|
const next = !mutedRef.current;
|
||||||
tabbyPrefs.setMuted(next);
|
sagiPrefs.setMuted(next);
|
||||||
setMuted(next);
|
setMuted(next);
|
||||||
if (next) dismissBubble();
|
if (next) dismissBubble();
|
||||||
}, [dismissBubble]);
|
}, [dismissBubble]);
|
||||||
+32
-35
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file useTabbyPosition.ts
|
* @file useSagiPosition.ts
|
||||||
* @description AssistiveTouch-style draggable docking for the Tabby avatar. The
|
* @description AssistiveTouch-style draggable docking for the Sagi avatar. The
|
||||||
* avatar follows the pointer 1:1 while dragging (via Pointer Capture, so it
|
* 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
|
* 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
|
* 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)
|
* 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
|
* ## Design constraints
|
||||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||||
@@ -29,10 +29,10 @@
|
|||||||
* - `./prefs`
|
* - `./prefs`
|
||||||
*
|
*
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `TABBY_SIZE` — exported API; see TSDoc on the symbol for behavior.
|
* - `SAGI_SIZE` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `TABBY_MARGIN` — exported API; see TSDoc on the symbol for behavior.
|
* - `SAGI_MARGIN` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `TabbyPlacement` — exported API; see TSDoc on the symbol for behavior.
|
* - `SagiPlacement` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `useTabbyPosition` — exported API; see TSDoc on the symbol for behavior.
|
* - `useSagiPosition` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
* ## Testing pointers
|
* ## Testing pointers
|
||||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||||
@@ -47,22 +47,22 @@
|
|||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
* 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
|
* Part of this module's public contract. Downstream imports should treat
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* 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
|
* Part of this module's public contract. Downstream imports should treat
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* When behavior changes, update the `@file` overview and relevant tests.
|
||||||
*
|
*
|
||||||
* **TabbyPlacement**
|
* **SagiPlacement**
|
||||||
* Part of this module's public contract. Downstream imports should treat
|
* Part of this module's public contract. Downstream imports should treat
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* When behavior changes, update the `@file` overview and relevant tests.
|
||||||
*
|
*
|
||||||
* **useTabbyPosition**
|
* **useSagiPosition**
|
||||||
* Part of this module's public contract. Downstream imports should treat
|
* Part of this module's public contract. Downstream imports should treat
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* When behavior changes, update the `@file` overview and relevant tests.
|
||||||
@@ -70,30 +70,30 @@
|
|||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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";
|
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||||
|
|
||||||
// Avatar footprint + edge gap, in px. SIZE matches CatAvatar's default size.
|
// Avatar footprint + edge gap, in px. SIZE matches SagiAvatar's default size.
|
||||||
export const TABBY_SIZE = 60;
|
export const SAGI_SIZE = 60;
|
||||||
export const TABBY_MARGIN = 16;
|
export const SAGI_MARGIN = 16;
|
||||||
const DRAG_THRESHOLD = 5;
|
const DRAG_THRESHOLD = 5;
|
||||||
|
|
||||||
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
|
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
|
||||||
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
|
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
|
||||||
|
|
||||||
function defaultPos(): TabbyPos {
|
function defaultPos(): SagiPos {
|
||||||
return { side: "right", y: 0.5 }; // right edge, vertically centered
|
return { side: "right", y: 0.5 }; // right edge, vertically centered
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resting top-left screen coords for a docked position. */
|
/** Resting top-left screen coords for a docked position. */
|
||||||
function restingScreen(pos: TabbyPos) {
|
function restingScreen(pos: SagiPos) {
|
||||||
const avail = Math.max(0, vh() - TABBY_SIZE - 2 * TABBY_MARGIN);
|
const avail = Math.max(0, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
|
||||||
const left = pos.side === "left" ? TABBY_MARGIN : vw() - TABBY_SIZE - TABBY_MARGIN;
|
const left = pos.side === "left" ? SAGI_MARGIN : vw() - SAGI_SIZE - SAGI_MARGIN;
|
||||||
const top = TABBY_MARGIN + pos.y * avail;
|
const top = SAGI_MARGIN + pos.y * avail;
|
||||||
return { left, top };
|
return { left, top };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TabbyPlacement {
|
export interface SagiPlacement {
|
||||||
/** Avatar top-left, in screen px. */
|
/** Avatar top-left, in screen px. */
|
||||||
left: number;
|
left: number;
|
||||||
top: number;
|
top: number;
|
||||||
@@ -109,8 +109,8 @@ export interface TabbyPlacement {
|
|||||||
consumeDrag: () => boolean;
|
consumeDrag: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTabbyPosition(): TabbyPlacement {
|
export function useSagiPosition(): SagiPlacement {
|
||||||
const [pos, setPos] = useState<TabbyPos>(() => tabbyPrefs.getPos() ?? defaultPos());
|
const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos());
|
||||||
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
|
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
|
||||||
const [, force] = useState(0); // re-derive resting coords on resize
|
const [, force] = useState(0); // re-derive resting coords on resize
|
||||||
|
|
||||||
@@ -154,11 +154,8 @@ export function useTabbyPosition(): TabbyPlacement {
|
|||||||
const dy = e.clientY - start.py;
|
const dy = e.clientY - start.py;
|
||||||
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||||||
movedRef.current = true;
|
movedRef.current = true;
|
||||||
const left = Math.min(
|
const left = Math.min(vw() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
|
||||||
vw() - TABBY_SIZE - TABBY_MARGIN,
|
const top = Math.min(vh() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.top + dy));
|
||||||
Math.max(TABBY_MARGIN, start.left + dx)
|
|
||||||
);
|
|
||||||
const top = Math.min(vh() - TABBY_SIZE - TABBY_MARGIN, Math.max(TABBY_MARGIN, start.top + dy));
|
|
||||||
liveRef.current = { left, top };
|
liveRef.current = { left, top };
|
||||||
setDrag({ left, top });
|
setDrag({ left, top });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -172,11 +169,11 @@ export function useTabbyPosition(): TabbyPlacement {
|
|||||||
const live = liveRef.current;
|
const live = liveRef.current;
|
||||||
if (live) {
|
if (live) {
|
||||||
draggedRef.current = true;
|
draggedRef.current = true;
|
||||||
const side: "left" | "right" = live.left + TABBY_SIZE / 2 < vw() / 2 ? "left" : "right";
|
const side: "left" | "right" = live.left + SAGI_SIZE / 2 < vw() / 2 ? "left" : "right";
|
||||||
const avail = Math.max(1, vh() - TABBY_SIZE - 2 * TABBY_MARGIN);
|
const avail = Math.max(1, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
|
||||||
const y = Math.min(1, Math.max(0, (live.top - TABBY_MARGIN) / avail));
|
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
|
||||||
const next: TabbyPos = { side, y };
|
const next: SagiPos = { side, y };
|
||||||
tabbyPrefs.setPos(next);
|
sagiPrefs.setPos(next);
|
||||||
setPos(next);
|
setPos(next);
|
||||||
setDrag(null); // leave drag mode; resting coords (with transition) take over
|
setDrag(null); // leave drag mode; resting coords (with transition) take over
|
||||||
}
|
}
|
||||||
@@ -194,9 +191,9 @@ export function useTabbyPosition(): TabbyPlacement {
|
|||||||
return {
|
return {
|
||||||
left: screen.left,
|
left: screen.left,
|
||||||
top: screen.top,
|
top: screen.top,
|
||||||
size: TABBY_SIZE,
|
size: SAGI_SIZE,
|
||||||
side: pos.side,
|
side: pos.side,
|
||||||
openUp: screen.top + TABBY_SIZE / 2 > vh() / 2,
|
openUp: screen.top + SAGI_SIZE / 2 > vh() / 2,
|
||||||
dragging: drag !== null,
|
dragging: drag !== null,
|
||||||
onPointerDown,
|
onPointerDown,
|
||||||
onPointerMove,
|
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
|
// back: tab refocus, regained network, or page becoming visible again. This
|
||||||
// cancels any pending backoff timer and resets the attempt counter so we
|
// 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
|
// 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(() => {
|
useEffect(() => {
|
||||||
const reconnectNow = () => {
|
const reconnectNow = () => {
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
"exportData": "Export Data",
|
"exportData": "Export Data",
|
||||||
"acrossSessions": "Across all tracked sessions",
|
"acrossSessions": "Across all tracked sessions",
|
||||||
"basedOnUsage": "Based on per-model token usage",
|
"basedOnUsage": "Based on per-model token usage",
|
||||||
"tabby": {
|
"sagi": {
|
||||||
"title": "Tabby companion",
|
"title": "Sagi companion",
|
||||||
"description": "A floating cat that reacts to your live sessions.",
|
"description": "A floating mascot that reacts to your live sessions.",
|
||||||
"enable": "Show Tabby",
|
"enable": "Show Sagi",
|
||||||
"enableDesc": "Display the corner companion across the dashboard (⌘B to open)"
|
"enableDesc": "Display the corner companion across the dashboard (⌘B to open)"
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
"exportData": "Xuất dữ liệu",
|
"exportData": "Xuất dữ liệu",
|
||||||
"acrossSessions": "Trên toàn bộ phiên đang được theo dõi",
|
"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",
|
"basedOnUsage": "Dựa trên mức dùng token theo mô hình",
|
||||||
"tabby": {
|
"sagi": {
|
||||||
"title": "Bạn đồng hành Tabby",
|
"title": "Bạn đồng hành Sagi",
|
||||||
"description": "Chú mèo nổi phản ứng với các phiên trực tiếp của bạn.",
|
"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 Tabby",
|
"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ở)"
|
"enableDesc": "Hiển thị bạn đồng hành ở góc trên toàn bảng điều khiển (⌘B để mở)"
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `../lib/api`
|
* - `../lib/api`
|
||||||
* - `../lib/eventBus`
|
* - `../lib/eventBus`
|
||||||
* - `../components/Tabby/prefs`
|
* - `../components/Sagi/prefs`
|
||||||
* - `../lib/format`
|
* - `../lib/format`
|
||||||
* - `../lib/push`
|
* - `../lib/push`
|
||||||
* - `../components/Tip`
|
* - `../components/Tip`
|
||||||
@@ -98,7 +98,7 @@ import {
|
|||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Info,
|
Info,
|
||||||
Cat,
|
PiggyBank,
|
||||||
History,
|
History,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -107,7 +107,7 @@ import {
|
|||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
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 { fmt, fmtCost, getCurrentLocale } from "../lib/format";
|
||||||
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
|
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
|
||||||
import { Tip } from "../components/Tip";
|
import { Tip } from "../components/Tip";
|
||||||
@@ -136,7 +136,7 @@ const SETTINGS_SECTIONS: {
|
|||||||
fallback: "Remote Data Sources",
|
fallback: "Remote Data Sources",
|
||||||
Icon: Cloud,
|
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: "notifications", labelKey: "notifications.title", Icon: Bell },
|
||||||
{ id: "alerts", labelKey: "alertsHub.title", Icon: BellRing },
|
{ id: "alerts", labelKey: "alertsHub.title", Icon: BellRing },
|
||||||
{ id: "data", labelKey: "data.title", Icon: Database },
|
{ id: "data", labelKey: "data.title", Icon: Database },
|
||||||
@@ -457,10 +457,10 @@ export function Settings() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [confirmAction, setConfirmAction] = useState<string | null>(null);
|
const [confirmAction, setConfirmAction] = useState<string | null>(null);
|
||||||
const [notifPrefs, setNotifPrefs] = useState<NotifPrefs>(loadNotifPrefs);
|
const [notifPrefs, setNotifPrefs] = useState<NotifPrefs>(loadNotifPrefs);
|
||||||
const [tabbyEnabled, setTabbyEnabled] = useState(() => tabbyPrefs.getEnabled());
|
const [sagiEnabled, setSagiEnabled] = useState(() => sagiPrefs.getEnabled());
|
||||||
const setTabby = useCallback((v: boolean) => {
|
const setSagi = useCallback((v: boolean) => {
|
||||||
tabbyPrefs.setEnabled(v);
|
sagiPrefs.setEnabled(v);
|
||||||
setTabbyEnabled(v);
|
setSagiEnabled(v);
|
||||||
}, []);
|
}, []);
|
||||||
const [abandonHours, setAbandonHours] = useState("24");
|
const [abandonHours, setAbandonHours] = useState("24");
|
||||||
const [purgeDays, setPurgeDays] = useState("90");
|
const [purgeDays, setPurgeDays] = useState("90");
|
||||||
@@ -1466,37 +1466,37 @@ export function Settings() {
|
|||||||
<RemoteSources />
|
<RemoteSources />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ─── TABBY COMPANION ─── */}
|
{/* ─── SAGI COMPANION ─── */}
|
||||||
<section id="tabby" className="scroll-mt-24">
|
<section id="sagi" className="scroll-mt-24">
|
||||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
<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 className="text-base leading-none" aria-hidden>
|
||||||
🐾
|
🦸
|
||||||
</span>
|
</span>
|
||||||
{t("tabby.title", "Tabby companion")}
|
{t("sagi.title", "Sagi companion")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-fg-muted mb-4">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="card p-5">
|
<div className="card p-5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
className={`w-10 h-10 rounded-xl flex items-center justify-center transition-colors ${
|
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-blue-600/10 border border-blue-600/20"
|
||||||
: "bg-surface-2 border border-border"
|
: "bg-surface-2 border border-border"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-lg leading-none" aria-hidden>
|
<span className="text-lg leading-none" aria-hidden>
|
||||||
🐾
|
🦸
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={tabbyEnabled}
|
checked={sagiEnabled}
|
||||||
onChange={setTabby}
|
onChange={setSagi}
|
||||||
label={t("tabby.enable", "Show Tabby")}
|
label={t("sagi.enable", "Show Sagi")}
|
||||||
description={t(
|
description={t(
|
||||||
"tabby.enableDesc",
|
"sagi.enableDesc",
|
||||||
"Display the corner companion across the dashboard (⌘B to open)"
|
"Display the corner companion across the dashboard (⌘B to open)"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7641,7 +7641,7 @@ exports[`screen snapshots > Settings 1`] = `
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="lucide lucide-cat w-3.5 h-3.5"
|
class="lucide lucide-piggy-bank w-3.5 h-3.5"
|
||||||
fill="none"
|
fill="none"
|
||||||
height="24"
|
height="24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -7653,19 +7653,16 @@ exports[`screen snapshots > Settings 1`] = `
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<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
|
<path
|
||||||
d="M8 14v.5"
|
d="M2 9v1c0 1.1.9 2 2 2h1"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d="M16 14v.5"
|
d="M16 11h.01"
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M11.25 16.25h1.5L12 17l-.75-.75Z"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Tabby companion
|
Sagi companion
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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>
|
||||||
<section
|
<section
|
||||||
class="scroll-mt-24"
|
class="scroll-mt-24"
|
||||||
id="tabby"
|
id="sagi"
|
||||||
>
|
>
|
||||||
<h3
|
<h3
|
||||||
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
|
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"
|
aria-hidden="true"
|
||||||
class="text-base leading-none"
|
class="text-base leading-none"
|
||||||
>
|
>
|
||||||
🐾
|
🦸
|
||||||
</span>
|
</span>
|
||||||
Tabby companion
|
Sagi companion
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
class="text-xs text-fg-muted mb-4"
|
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>
|
</p>
|
||||||
<div
|
<div
|
||||||
class="card p-5"
|
class="card p-5"
|
||||||
@@ -9276,7 +9273,7 @@ exports[`screen snapshots > Settings 1`] = `
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="text-lg leading-none"
|
class="text-lg leading-none"
|
||||||
>
|
>
|
||||||
🐾
|
🦸
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<label
|
<label
|
||||||
@@ -9288,7 +9285,7 @@ exports[`screen snapshots > Settings 1`] = `
|
|||||||
<p
|
<p
|
||||||
class="text-sm text-fg-secondary group-hover:text-fg-secondary transition-colors"
|
class="text-sm text-fg-secondary group-hover:text-fg-secondary transition-colors"
|
||||||
>
|
>
|
||||||
Show Tabby
|
Show Sagi
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
class="text-xs text-fg-muted mt-0.5"
|
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 "
|
"Renders Claude transcript rows (user, assistant, tool calls) inside Session "
|
||||||
"Detail with markdown, syntax highlighting, and TUI-style segments."
|
"Detail with markdown, syntax highlighting, and TUI-style segments."
|
||||||
)
|
)
|
||||||
if "Tabby" in rel:
|
if "Sagi" in rel:
|
||||||
hints.append(
|
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."
|
"event reactions layered above the dashboard chrome."
|
||||||
)
|
)
|
||||||
if "hook" in rel.lower() or name.startswith("use"):
|
if "hook" in rel.lower() or name.startswith("use"):
|
||||||
|
|||||||
Reference in New Issue
Block a user