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:
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* @file Sagi.tsx
|
||||
* @description Floating mascot companion shell. Mounts once (next to UpdateNotifier
|
||||
* in Layout) so it persists across routes and shares the single WebSocket.
|
||||
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
||||
* detection, and route navigation. Reactive personality + status/Ask come
|
||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||
* useSagiPosition, and the bubble/panel render in a self-clamping flyout so
|
||||
* they never spill off any screen edge regardless of where the mascot is docked.
|
||||
*
|
||||
* The "do the job" path reuses the existing Run page: unmatched Ask queries
|
||||
* deep-link to /run?prompt=…&autostart=1 - no new LLM backend.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./SagiAvatar`
|
||||
* - `./SpeechBubble`
|
||||
* - `./SagiPanel`
|
||||
* - `./useSagiBrain`
|
||||
* - `./useSagiPosition`
|
||||
* - `./intents`
|
||||
* - `./prefs`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `Sagi` — 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).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **Sagi**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { SagiAvatar } from "./SagiAvatar";
|
||||
import { SpeechBubble } from "./SpeechBubble";
|
||||
import { SagiPanel } from "./SagiPanel";
|
||||
import { useSagiBrain } from "./useSagiBrain";
|
||||
import { useSagiPosition, SAGI_SIZE } from "./useSagiPosition";
|
||||
import { matchIntent } from "./intents";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
import "./sagi.css";
|
||||
|
||||
const FLYOUT_GAP = 10; // px between avatar and flyout
|
||||
const VIEWPORT_MARGIN = 12; // min gap from any screen edge
|
||||
|
||||
interface Anchor {
|
||||
left: number;
|
||||
top: number;
|
||||
size: number;
|
||||
side: "left" | "right";
|
||||
openUp: boolean;
|
||||
}
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(
|
||||
() =>
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
||||
if (!mq) return;
|
||||
const onChange = () => setReduced(mq.matches);
|
||||
mq.addEventListener?.("change", onChange);
|
||||
return () => mq.removeEventListener?.("change", onChange);
|
||||
}, []);
|
||||
return reduced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed-position wrapper that places its content next to the avatar and clamps
|
||||
* it inside the viewport. It measures itself (and re-measures on content/size
|
||||
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
|
||||
* into view instead of being cropped.
|
||||
*/
|
||||
function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
|
||||
|
||||
const place = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const w = el.offsetWidth;
|
||||
const h = el.offsetHeight;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
// Horizontal: hug the avatar's docked edge, then clamp on-screen.
|
||||
let left = anchor.side === "left" ? anchor.left : anchor.left + anchor.size - w;
|
||||
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
||||
|
||||
// Vertical: prefer above the mascot (feels natural). Only drop below when
|
||||
// there isn't room above - i.e. the mascot is near the top edge.
|
||||
const above = anchor.top - h - FLYOUT_GAP;
|
||||
const below = anchor.top + anchor.size + FLYOUT_GAP;
|
||||
let top = above >= VIEWPORT_MARGIN ? above : below;
|
||||
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
|
||||
|
||||
setStyle({ left, top, visibility: "visible" });
|
||||
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
place();
|
||||
const el = ref.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => place());
|
||||
ro.observe(el);
|
||||
window.addEventListener("resize", place);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", place);
|
||||
};
|
||||
}, [place]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="sagi-flyout" style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sagi() {
|
||||
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
|
||||
const [open, setOpen] = useState(false);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const navigate = useNavigate();
|
||||
const brain = useSagiBrain();
|
||||
const place = useSagiPosition();
|
||||
|
||||
// Keep enabled in sync with Settings / other tabs.
|
||||
useEffect(() => sagiPrefs.subscribe(() => setEnabled(sagiPrefs.getEnabled())), []);
|
||||
|
||||
// ⌘B / Ctrl+B toggles the panel; Esc closes it.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
|
||||
e.preventDefault();
|
||||
setOpen((v) => !v);
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const onNavigate = useCallback(
|
||||
(route: string) => {
|
||||
navigate(route);
|
||||
setOpen(false);
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const onAsk = useCallback(
|
||||
(query: string): string | null => {
|
||||
const result = matchIntent(query, brain.status);
|
||||
if (result.kind === "answer") return result.text;
|
||||
// Handoff: spawn a real claude via the existing Run page. `autostart=1`
|
||||
// tells Run to fire the prompt automatically once it's prefilled, so the
|
||||
// question is actually sent instead of just dropped into the composer.
|
||||
navigate(`/run?prompt=${encodeURIComponent(result.prompt)}&autostart=1`);
|
||||
setOpen(false);
|
||||
return null;
|
||||
},
|
||||
[brain, navigate]
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const anchor: Anchor = {
|
||||
left: place.left,
|
||||
top: place.top,
|
||||
size: place.size,
|
||||
side: place.side,
|
||||
openUp: place.openUp,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Flyouts are hidden while dragging so they don't chase the mascot. */}
|
||||
{!place.dragging && open && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SagiPanel
|
||||
status={brain.status}
|
||||
muted={brain.muted}
|
||||
onToggleMute={brain.toggleMute}
|
||||
onClearAlerts={brain.clearAlerts}
|
||||
onNavigate={onNavigate}
|
||||
onAsk={onAsk}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!place.dragging && !open && brain.bubble && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="sagi-avatar-btn"
|
||||
data-dragging={place.dragging ? "1" : "0"}
|
||||
style={{ left: place.left, top: place.top, width: SAGI_SIZE, height: SAGI_SIZE }}
|
||||
onPointerDown={place.onPointerDown}
|
||||
onPointerMove={place.onPointerMove}
|
||||
onPointerUp={place.onPointerUp}
|
||||
onClick={() => {
|
||||
// A drag just ended - swallow the synthetic click so the panel
|
||||
// doesn't toggle when the user only repositioned the avatar.
|
||||
if (place.consumeDrag()) return;
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
aria-label={open ? "Close Sagi" : "Open Sagi companion"}
|
||||
aria-expanded={open}
|
||||
title="Sagi - ⌘B · drag to move"
|
||||
>
|
||||
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} />
|
||||
{brain.status.errorCount > 0 && (
|
||||
<span className="sagi-error-dot" aria-hidden>
|
||||
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* @file SagiPanel.tsx
|
||||
* @description Expanded Sagi panel: a live status strip (live / waiting /
|
||||
* errored stat chips + connection state), quick navigation actions, and a
|
||||
* local "Ask" box. Pure presentational - all data and the ask/navigation
|
||||
* behavior are injected by the container.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./brain`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SagiPanel` — 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).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SagiPanel**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Play,
|
||||
Activity,
|
||||
LayoutList,
|
||||
Bell,
|
||||
BellOff,
|
||||
Trash2,
|
||||
X,
|
||||
Send,
|
||||
AlertTriangle,
|
||||
Hourglass,
|
||||
Radio,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { SagiStatus } from "./brain";
|
||||
|
||||
interface SagiPanelProps {
|
||||
status: SagiStatus;
|
||||
muted: boolean;
|
||||
onToggleMute: () => void;
|
||||
onClearAlerts: () => void;
|
||||
onNavigate: (route: string) => void;
|
||||
/** Returns an answer to display, or null when the query was handed off. */
|
||||
onAsk: (query: string) => string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SagiPanel({
|
||||
status,
|
||||
muted,
|
||||
onToggleMute,
|
||||
onClearAlerts,
|
||||
onNavigate,
|
||||
onAsk,
|
||||
onClose,
|
||||
}: SagiPanelProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [answer, setAnswer] = useState<string | null>(null);
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const result = onAsk(query);
|
||||
setAnswer(result); // null means it handed off (container navigates/closes)
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl shadow-black/50 backdrop-blur-md animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label="Sagi companion"
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-base leading-none" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-fg-primary">Sagi</span>
|
||||
<span
|
||||
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
status.connected
|
||||
? "bg-status-success/15 text-status-success"
|
||||
: "bg-status-danger/15 text-status-danger"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
status.connected ? "bg-status-success" : "bg-status-danger"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{status.connected ? "Live" : "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
||||
onClick={onClose}
|
||||
aria-label="Close Sagi"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
{/* status stat chips */}
|
||||
<div className="mb-3 grid grid-cols-3 gap-1.5">
|
||||
<StatChip
|
||||
icon={Radio}
|
||||
label="live"
|
||||
value={status.liveCount}
|
||||
tone={status.liveCount > 0 ? "accent" : "muted"}
|
||||
/>
|
||||
<StatChip
|
||||
icon={Hourglass}
|
||||
label="waiting"
|
||||
value={status.waitingCount}
|
||||
tone={status.waitingCount > 0 ? "amber" : "muted"}
|
||||
/>
|
||||
<StatChip
|
||||
icon={AlertTriangle}
|
||||
label="errored"
|
||||
value={status.errorCount}
|
||||
tone={status.errorCount > 0 ? "red" : "muted"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* quick actions */}
|
||||
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
||||
<ActionButton icon={Play} label="Run Claude" onClick={() => onNavigate("/run")} />
|
||||
<ActionButton icon={Activity} label="Activity" onClick={() => onNavigate("/activity")} />
|
||||
<ActionButton
|
||||
icon={LayoutList}
|
||||
label="Sessions"
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={AlertTriangle}
|
||||
label="Errored"
|
||||
disabled={status.errorCount === 0}
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={muted ? BellOff : Bell}
|
||||
label={muted ? "Unmute" : "Mute"}
|
||||
onClick={onToggleMute}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={Trash2}
|
||||
label="Clear alerts"
|
||||
disabled={status.errorCount === 0}
|
||||
onClick={onClearAlerts}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ask */}
|
||||
<form onSubmit={submit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder="Ask Sagi… (e.g. any errors?)"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label="Ask Sagi"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center justify-center rounded-lg bg-accent px-2.5 py-2 text-white transition-colors hover:bg-accent-hover"
|
||||
aria-label="Send"
|
||||
>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
</form>
|
||||
{answer && (
|
||||
<p className="mt-2 rounded-lg bg-surface-1/70 px-2.5 py-2 text-xs leading-relaxed text-fg-secondary">
|
||||
{answer}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Tone {
|
||||
wrap: string;
|
||||
value: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const TONE_MUTED: Tone = {
|
||||
wrap: "border-border bg-surface-1",
|
||||
value: "text-fg-secondary",
|
||||
icon: "text-fg-muted",
|
||||
};
|
||||
|
||||
const TONES: Record<string, Tone> = {
|
||||
accent: { wrap: "border-accent/30 bg-accent/10", value: "text-fg-primary", icon: "text-accent" },
|
||||
amber: {
|
||||
wrap: "border-status-warning/30 bg-status-warning/10",
|
||||
value: "text-status-warning",
|
||||
icon: "text-status-warning",
|
||||
},
|
||||
red: {
|
||||
wrap: "border-status-danger/30 bg-status-danger/10",
|
||||
value: "text-status-danger",
|
||||
icon: "text-status-danger",
|
||||
},
|
||||
muted: TONE_MUTED,
|
||||
};
|
||||
|
||||
function StatChip({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: number;
|
||||
tone: string;
|
||||
}): ReactNode {
|
||||
const t = TONES[tone] ?? TONE_MUTED;
|
||||
return (
|
||||
<div className={`flex flex-col items-center gap-0.5 rounded-xl border py-1.5 ${t.wrap}`}>
|
||||
<Icon size={13} className={t.icon} aria-hidden />
|
||||
<span className={`text-base font-semibold leading-none tabular-nums ${t.value}`}>
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-[9px] uppercase tracking-wider text-fg-muted">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon size={14} className="shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file SpeechBubble.tsx
|
||||
* @description Transient speech bubble rendered above the Sagi mascot.
|
||||
* Pure presentation — visibility timing and quip selection live in
|
||||
* {@link useSagiBrain}; this component only paints the bubble and handles
|
||||
* user dismissal.
|
||||
*
|
||||
* ## Accessibility
|
||||
* Uses `role="status"` with `aria-live="polite"` so screen readers announce
|
||||
* new quips without interrupting current speech. Click anywhere on the bubble
|
||||
* to dismiss early.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SpeechBubble` — 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).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SpeechBubble**
|
||||
* 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.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
/** Props for {@link SpeechBubble}. */
|
||||
interface SpeechBubbleProps {
|
||||
/** Quip text to display inside the bubble. */
|
||||
text: string;
|
||||
/** Called when the user clicks to dismiss. */
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated speech bubble above Sagi.
|
||||
* @param props See {@link SpeechBubbleProps}.
|
||||
*/
|
||||
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
|
||||
return (
|
||||
<div
|
||||
className="sagi-bubble sagi-bubble-enter"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
onClick={onDismiss}
|
||||
title="Dismiss"
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @file Sagi.test.tsx
|
||||
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B panel toggle, and accessibility attributes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act, cleanup, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sagi } from "../Sagi";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
import type { WSMessage, Session } from "../../../lib/types";
|
||||
|
||||
function renderSagi() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Sagi />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
const sessionMsg = (id: string, status: Session["status"]): WSMessage => ({
|
||||
type: "session_updated",
|
||||
data: { id, status } as Session,
|
||||
timestamp: "t",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
eventBus.setConnected(true);
|
||||
// Freeze timers so the brain's 1s heartbeat tick can't fire a state update
|
||||
// outside act() mid-assertion. We never advance them in these tests.
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Sagi widget", () => {
|
||||
it("renders the avatar button by default", () => {
|
||||
renderSagi();
|
||||
expect(screen.getByRole("button", { name: /open sagi companion/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the panel on click and answers a local status question", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
||||
expect(panel).toBeInTheDocument();
|
||||
|
||||
const input = within(panel).getByLabelText(/ask sagi/i);
|
||||
fireEvent.change(input, { target: { value: "status" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles open/closed with Cmd/Ctrl+B and closes with Esc", () => {
|
||||
renderSagi();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: "b", metaKey: true });
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error badge when a session errors", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "error"));
|
||||
});
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||
expect(within(btn).getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reflects the live count in the panel status", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "active"));
|
||||
eventBus.publish(sessionMsg("b", "active"));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
||||
// The "live" stat chip shows value 2 next to its label.
|
||||
const liveChip = within(panel).getByText("live").closest("div")!;
|
||||
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("respects the enabled preference", () => {
|
||||
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
|
||||
renderSagi();
|
||||
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a tap (no movement) still opens the panel", () => {
|
||||
renderSagi();
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||
act(() => {
|
||||
fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 });
|
||||
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
||||
});
|
||||
fireEvent.click(btn);
|
||||
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
|
||||
renderSagi();
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||
// Default dock is bottom-right. Drag far to the left past the threshold.
|
||||
act(() => {
|
||||
fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 });
|
||||
fireEvent.pointerMove(btn, { clientX: 80, clientY: 300 });
|
||||
fireEvent.pointerUp(btn, { clientX: 80, clientY: 300 });
|
||||
});
|
||||
// The synthetic click that follows a drag must be swallowed.
|
||||
fireEvent.click(btn);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
const saved = JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}");
|
||||
expect(saved.side).toBe("left");
|
||||
expect(typeof saved.y).toBe("number");
|
||||
});
|
||||
|
||||
it("a sub-threshold pointer move is treated as a tap, not a drag", () => {
|
||||
renderSagi();
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||
act(() => {
|
||||
fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 });
|
||||
fireEvent.pointerMove(btn, { clientX: 992, clientY: 701 }); // < 5px threshold
|
||||
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
|
||||
});
|
||||
fireEvent.click(btn);
|
||||
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
||||
// No position was persisted because no real drag happened.
|
||||
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
|
||||
});
|
||||
|
||||
it("restores a persisted left-edge position on mount", () => {
|
||||
localStorage.setItem("agent-dashboard-sagi-pos", JSON.stringify({ side: "left", y: 0.2 }));
|
||||
renderSagi();
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i }) as HTMLElement;
|
||||
// Left-docked → inline left equals the edge margin (16px).
|
||||
expect(btn.style.left).toBe("16px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* @file brain.test.ts
|
||||
* @description Unit tests for the Sagi state brain — initial state, mood transitions driven by dashboard events, and status derivation.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
initialSagiState,
|
||||
reduceSagi,
|
||||
deriveMood,
|
||||
statusOf,
|
||||
clearErrors,
|
||||
seedSessions,
|
||||
HAPPY_MS,
|
||||
WORRIED_MS,
|
||||
STUCK_MS,
|
||||
SLEEP_MS,
|
||||
type SagiState,
|
||||
} from "../brain";
|
||||
import type {
|
||||
WSMessage,
|
||||
Session,
|
||||
Agent,
|
||||
DashboardEvent,
|
||||
RunStatusPayload,
|
||||
} from "../../../lib/types";
|
||||
|
||||
const T0 = 1_000_000;
|
||||
|
||||
function sessionMsg(id: string, status: Session["status"], ts = T0): WSMessage {
|
||||
return { type: "session_updated", data: { id, status } as Session, timestamp: String(ts) };
|
||||
}
|
||||
function agentMsg(status: Agent["status"]): WSMessage {
|
||||
return { type: "agent_updated", data: { status } as Agent, timestamp: String(T0) };
|
||||
}
|
||||
function agentCreatedMsg(type: Agent["type"], status: Agent["status"] = "working"): WSMessage {
|
||||
return { type: "agent_created", data: { type, status } as Agent, timestamp: String(T0) };
|
||||
}
|
||||
function waitingMsg(id: string): WSMessage {
|
||||
return {
|
||||
type: "session_updated",
|
||||
data: { id, status: "active", awaiting_input_since: "2026-05-29T00:00:00Z" } as Session,
|
||||
timestamp: String(T0),
|
||||
};
|
||||
}
|
||||
function eventMsg(event_type: string): WSMessage {
|
||||
return { type: "new_event", data: { event_type } as DashboardEvent, timestamp: String(T0) };
|
||||
}
|
||||
function runStatusMsg(d: Partial<RunStatusPayload>): WSMessage {
|
||||
return { type: "run_status", data: d as RunStatusPayload, timestamp: String(T0) };
|
||||
}
|
||||
|
||||
describe("deriveMood priority", () => {
|
||||
it("disconnected outranks everything", () => {
|
||||
const s: SagiState = { ...initialSagiState(T0), connected: false, worriedUntil: T0 + 9999 };
|
||||
expect(deriveMood(s, T0)).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("worried outranks stuck", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0)); // live
|
||||
s = { ...s, lastActivityAt: T0 - STUCK_MS - 1, worriedUntil: T0 + 100 };
|
||||
expect(deriveMood(s, T0)).toBe("worried");
|
||||
});
|
||||
|
||||
it("stuck when a live session goes silent past STUCK_MS", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||
expect(deriveMood(s, T0 + STUCK_MS + 1)).toBe("stuck");
|
||||
});
|
||||
|
||||
it("happy is transient then falls back to idle when nothing live", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "completed"), T0)); // no longer live
|
||||
expect(deriveMood(s, T0 + 10)).toBe("happy");
|
||||
expect(deriveMood(s, T0 + HAPPY_MS + 1)).toBe("idle");
|
||||
});
|
||||
|
||||
it("thinking shows when set and nothing higher applies", () => {
|
||||
const s = { ...initialSagiState(T0), thinking: true };
|
||||
expect(deriveMood(s, T0)).toBe("thinking");
|
||||
});
|
||||
|
||||
it("watching when a session is live and recent", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||
expect(deriveMood(s, T0 + 1000)).toBe("watching");
|
||||
});
|
||||
|
||||
it("sleeping after SLEEP_MS of no activity and nothing live", () => {
|
||||
const s = initialSagiState(T0);
|
||||
expect(deriveMood(s, T0 + SLEEP_MS + 1)).toBe("sleeping");
|
||||
});
|
||||
|
||||
it("idle by default", () => {
|
||||
expect(deriveMood(initialSagiState(T0), T0)).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceSagi counts and pulses", () => {
|
||||
it("tracks live count accurately across transitions", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||
({ state: s } = reduceSagi(s, sessionMsg("b", "active"), T0));
|
||||
expect(statusOf(s).liveCount).toBe(2);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "completed"), T0));
|
||||
expect(statusOf(s).liveCount).toBe(1);
|
||||
});
|
||||
|
||||
it("counts errored sessions and emits error pulse", () => {
|
||||
let s = initialSagiState(T0);
|
||||
const r = reduceSagi(s, sessionMsg("a", "error"), T0);
|
||||
s = r.state;
|
||||
expect(r.pulse).toBe("error");
|
||||
expect(statusOf(s).errorCount).toBe(1);
|
||||
expect(deriveMood(s, T0)).toBe("worried");
|
||||
});
|
||||
|
||||
it("session_start pulse only on first active transition", () => {
|
||||
let s = initialSagiState(T0);
|
||||
const r1 = reduceSagi(s, sessionMsg("a", "active"), T0);
|
||||
expect(r1.pulse).toBe("session_start");
|
||||
const r2 = reduceSagi(r1.state, sessionMsg("a", "active"), T0);
|
||||
expect(r2.pulse).toBe(null);
|
||||
});
|
||||
|
||||
it("session_done pulse only when the session was tracked", () => {
|
||||
let s = initialSagiState(T0);
|
||||
const untracked = reduceSagi(s, sessionMsg("ghost", "completed"), T0);
|
||||
expect(untracked.pulse).toBe(null);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||
const done = reduceSagi(s, sessionMsg("a", "completed"), T0);
|
||||
expect(done.pulse).toBe("session_done");
|
||||
});
|
||||
|
||||
it("agent error triggers worried via pulse", () => {
|
||||
const r = reduceSagi(initialSagiState(T0), agentMsg("error"), T0);
|
||||
expect(r.pulse).toBe("error");
|
||||
expect(deriveMood(r.state, T0)).toBe("worried");
|
||||
});
|
||||
|
||||
it("a newly created subagent emits subagent_spawn, a main agent does not", () => {
|
||||
expect(reduceSagi(initialSagiState(T0), agentCreatedMsg("subagent"), T0).pulse).toBe(
|
||||
"subagent_spawn"
|
||||
);
|
||||
expect(reduceSagi(initialSagiState(T0), agentCreatedMsg("main"), T0).pulse).toBe(null);
|
||||
});
|
||||
|
||||
it("waiting transition emits a waiting pulse once and still counts as live", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0)); // active
|
||||
const first = reduceSagi(s, waitingMsg("a"), T0);
|
||||
expect(first.pulse).toBe("waiting");
|
||||
expect(statusOf(first.state).liveCount).toBe(1);
|
||||
// A repeat waiting update does not re-announce.
|
||||
const second = reduceSagi(first.state, waitingMsg("a"), T0);
|
||||
expect(second.pulse).toBe(null);
|
||||
});
|
||||
|
||||
it("failure event types set worried, normal events do not", () => {
|
||||
const fail = reduceSagi(initialSagiState(T0), eventMsg("toolError"), T0);
|
||||
expect(fail.pulse).toBe("error");
|
||||
const ok = reduceSagi(initialSagiState(T0), eventMsg("postToolUse"), T0);
|
||||
expect(ok.pulse).toBe(null);
|
||||
expect(ok.state.worriedUntil).toBe(0);
|
||||
});
|
||||
|
||||
it("run_status updates activity timestamp only (no exit code available)", () => {
|
||||
const running = reduceSagi(initialSagiState(T0), runStatusMsg({ status: "running" }), T0);
|
||||
expect(running.pulse).toBe(null);
|
||||
const gone = reduceSagi(initialSagiState(T0), runStatusMsg({ status: "gone" }), T0);
|
||||
expect(gone.pulse).toBe(null);
|
||||
});
|
||||
|
||||
it("any handled message refreshes lastActivityAt", () => {
|
||||
const s = { ...initialSagiState(T0), lastActivityAt: T0 - 99999 };
|
||||
const { state } = reduceSagi(s, eventMsg("postToolUse"), T0 + 5);
|
||||
expect(state.lastActivityAt).toBe(T0 + 5);
|
||||
});
|
||||
|
||||
it("ignores unrelated message types without mutating", () => {
|
||||
const s = initialSagiState(T0);
|
||||
const r = reduceSagi(s, { type: "import.progress", data: {} as never, timestamp: "x" }, T0);
|
||||
expect(r.state).toBe(s);
|
||||
expect(r.pulse).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seedSessions", () => {
|
||||
it("hydrates live/waiting/errored counts from a REST snapshot", () => {
|
||||
const s = seedSessions(
|
||||
initialSagiState(T0),
|
||||
[
|
||||
{ id: "a", status: "active" },
|
||||
{ id: "b", status: "active", awaiting_input_since: "2026-05-29T00:00:00Z" },
|
||||
{ id: "c", status: "error" },
|
||||
{ id: "d", status: "completed" }, // ignored
|
||||
],
|
||||
T0
|
||||
);
|
||||
const st = statusOf(s);
|
||||
expect(st.liveCount).toBe(2); // a + b
|
||||
expect(st.waitingCount).toBe(1); // b
|
||||
expect(st.errorCount).toBe(1); // c
|
||||
});
|
||||
});
|
||||
|
||||
describe("statusOf waiting", () => {
|
||||
it("counts a waiting session as both live and waiting", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, waitingMsg("a"), T0));
|
||||
expect(statusOf(s)).toMatchObject({ liveCount: 1, waitingCount: 1, errorCount: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearErrors", () => {
|
||||
it("drops errored sessions but keeps active ones", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||
({ state: s } = reduceSagi(s, sessionMsg("b", "error"), T0));
|
||||
s = clearErrors(s);
|
||||
expect(statusOf(s).errorCount).toBe(0);
|
||||
expect(statusOf(s).liveCount).toBe(1);
|
||||
expect(s.worriedUntil).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Reference the imported constant so it is exercised and tsc-clean.
|
||||
it("WORRIED_MS is a positive window", () => {
|
||||
expect(WORRIED_MS).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @file intents.test.ts
|
||||
* @description Unit tests for Sagi's intent matcher — natural-language queries mapped to dashboard navigation and status intents.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { matchIntent } from "../intents";
|
||||
import type { SagiStatus } from "../brain";
|
||||
|
||||
const status = (over: Partial<SagiStatus> = {}): SagiStatus => ({
|
||||
liveCount: 0,
|
||||
waitingCount: 0,
|
||||
errorCount: 0,
|
||||
connected: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("matchIntent", () => {
|
||||
it("reports live sessions", () => {
|
||||
const r = matchIntent("what's running?", status({ liveCount: 3 }));
|
||||
expect(r).toEqual({ kind: "answer", text: expect.stringContaining("3 sessions live") });
|
||||
});
|
||||
|
||||
it("singularizes correctly", () => {
|
||||
const r = matchIntent("anything active", status({ liveCount: 1 }));
|
||||
expect(r.kind).toBe("answer");
|
||||
if (r.kind === "answer") expect(r.text).toContain("1 session live");
|
||||
});
|
||||
|
||||
it("says all quiet when nothing live", () => {
|
||||
const r = matchIntent("what is running", status());
|
||||
if (r.kind === "answer") expect(r.text).toContain("nothing's running");
|
||||
});
|
||||
|
||||
it("reports errors and prioritizes error intent over live", () => {
|
||||
const r = matchIntent("any failed runs?", status({ liveCount: 2, errorCount: 1 }));
|
||||
if (r.kind === "answer") expect(r.text).toContain("1 session errored");
|
||||
});
|
||||
|
||||
it("clean when no errors", () => {
|
||||
const r = matchIntent("are there errors", status({ liveCount: 2 }));
|
||||
if (r.kind === "answer") expect(r.text).toContain("all clean");
|
||||
});
|
||||
|
||||
it("gives a combined status summary", () => {
|
||||
const r = matchIntent(
|
||||
"status",
|
||||
status({ liveCount: 2, waitingCount: 1, errorCount: 1, connected: true })
|
||||
);
|
||||
if (r.kind === "answer") expect(r.text).toBe("2 live · 1 waiting · 1 errored · connected.");
|
||||
});
|
||||
|
||||
it("reports sessions waiting on the user", () => {
|
||||
const r = matchIntent("anything waiting on me?", status({ liveCount: 2, waitingCount: 1 }));
|
||||
if (r.kind === "answer") expect(r.text).toContain("1 session waiting on you");
|
||||
});
|
||||
|
||||
it("reflects offline in summary", () => {
|
||||
const r = matchIntent("overview", status({ connected: false }));
|
||||
if (r.kind === "answer") expect(r.text).toContain("offline");
|
||||
});
|
||||
|
||||
it("explains itself on help", () => {
|
||||
const r = matchIntent("help", status());
|
||||
if (r.kind === "answer") expect(r.text.toLowerCase()).toContain("watch your sessions");
|
||||
});
|
||||
|
||||
it("empty query nudges the user", () => {
|
||||
const r = matchIntent(" ", status());
|
||||
expect(r.kind).toBe("answer");
|
||||
});
|
||||
|
||||
it("hands unknown questions to Claude, preserving original casing", () => {
|
||||
const r = matchIntent("Refactor my auth module", status());
|
||||
expect(r).toEqual({ kind: "handoff", prompt: "Refactor my auth module" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @file quips.test.ts
|
||||
* @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>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { pickQuip, ALL_QUIP_KEYS } from "../quips";
|
||||
|
||||
describe("pickQuip", () => {
|
||||
it("returns a non-empty string for every known key", () => {
|
||||
for (const key of ALL_QUIP_KEYS) {
|
||||
expect(pickQuip(key, () => 0).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic given an injected rand", () => {
|
||||
expect(pickQuip("session_done", () => 0)).toBe(pickQuip("session_done", () => 0));
|
||||
});
|
||||
|
||||
it("rand=0.999 stays within bounds (no out-of-range index)", () => {
|
||||
for (const key of ALL_QUIP_KEYS) {
|
||||
expect(typeof pickQuip(key, () => 0.999)).toBe("string");
|
||||
expect(pickQuip(key, () => 0.999).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns empty string for an unknown key without throwing", () => {
|
||||
// @ts-expect-error intentionally passing an invalid key
|
||||
expect(pickQuip("nope", () => 0)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* @file brain.ts
|
||||
* @description Pure, framework-free core of the Sagi companion. Reduces the
|
||||
* dashboard's live WebSocket stream into a small mood model and derives the
|
||||
* current 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
|
||||
* (`useSagiBrain`) wires this to the event bus and to real timers.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../lib/types`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `Mood` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `SagiPulse` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `SagiStatus` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `SagiState` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `HAPPY_MS` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `WORRIED_MS` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `STUCK_MS` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `SLEEP_MS` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `FAILURE_EVENT_TYPES` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `initialSagiState` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `statusOf` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `deriveMood` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `reduceSagi` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `seedSessions` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `clearErrors` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## 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).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **Mood**
|
||||
* 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.
|
||||
*
|
||||
* **SagiPulse**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **SagiStatus**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **SagiState**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **HAPPY_MS**
|
||||
* 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.
|
||||
*
|
||||
* **WORRIED_MS**
|
||||
* 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.
|
||||
*
|
||||
* **STUCK_MS**
|
||||
* 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.
|
||||
*
|
||||
* **SLEEP_MS**
|
||||
* 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.
|
||||
*
|
||||
* **FAILURE_EVENT_TYPES**
|
||||
* 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.
|
||||
*
|
||||
* **initialSagiState**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **statusOf**
|
||||
* 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.
|
||||
*
|
||||
* **deriveMood**
|
||||
* 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.
|
||||
*
|
||||
* **reduceSagi**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **seedSessions**
|
||||
* 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.
|
||||
*
|
||||
* **clearErrors**
|
||||
* 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 type { WSMessage, Session, Agent, RunStatusPayload, DashboardEvent } from "../../lib/types";
|
||||
|
||||
/** All moods Sagi can express, highest priority first (see `deriveMood`). */
|
||||
export type Mood =
|
||||
| "disconnected"
|
||||
| "worried"
|
||||
| "stuck"
|
||||
| "happy"
|
||||
| "thinking"
|
||||
| "watching"
|
||||
| "sleeping"
|
||||
| "idle";
|
||||
|
||||
/**
|
||||
* A one-shot signal describing what just happened, emitted by `reduceSagi`.
|
||||
* The hook turns pulses into transient speech bubbles. `null` means the message
|
||||
* was irrelevant or non-notable.
|
||||
*/
|
||||
export type SagiPulse =
|
||||
| "session_done"
|
||||
| "session_start"
|
||||
| "subagent_spawn"
|
||||
| "waiting"
|
||||
| "error"
|
||||
| "run_done"
|
||||
| null;
|
||||
|
||||
export interface SagiStatus {
|
||||
/** Active + waiting sessions (everything not finished/errored). */
|
||||
liveCount: number;
|
||||
/** Subset of liveCount currently blocked on user input. */
|
||||
waitingCount: number;
|
||||
errorCount: number;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export interface SagiState {
|
||||
connected: boolean;
|
||||
/** Latest status per session id we still care about. "waiting" = active but
|
||||
* blocked on user input; counts as live for the status line. */
|
||||
sessions: Record<string, "active" | "error" | "waiting">;
|
||||
/** Epoch ms of the last meaningful activity; drives stuck/sleeping. */
|
||||
lastActivityAt: number;
|
||||
/** While `now < happyUntil`, mood can be `happy`. */
|
||||
happyUntil: number;
|
||||
/** While `now < worriedUntil`, mood can be `worried`. */
|
||||
worriedUntil: number;
|
||||
/** True while an Ask request is in flight (panel). */
|
||||
thinking: boolean;
|
||||
}
|
||||
|
||||
// Tunable timing constants (ms).
|
||||
export const HAPPY_MS = 4000;
|
||||
export const WORRIED_MS = 4500;
|
||||
export const STUCK_MS = 10 * 60_000;
|
||||
export const SLEEP_MS = 3 * 60_000;
|
||||
|
||||
/** Event types from the hook ingestion that represent a genuine failure. */
|
||||
export const FAILURE_EVENT_TYPES: ReadonlySet<string> = new Set([
|
||||
"error",
|
||||
"toolError",
|
||||
"agentError",
|
||||
"subagentError",
|
||||
"errorEvent",
|
||||
"errorReport",
|
||||
"errorBoundary",
|
||||
"crashReport",
|
||||
"diagnosticError",
|
||||
]);
|
||||
|
||||
export function initialSagiState(now: number): SagiState {
|
||||
return {
|
||||
connected: true,
|
||||
sessions: {},
|
||||
lastActivityAt: now,
|
||||
happyUntil: 0,
|
||||
worriedUntil: 0,
|
||||
thinking: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function statusOf(state: SagiState): SagiStatus {
|
||||
let liveCount = 0;
|
||||
let waitingCount = 0;
|
||||
let errorCount = 0;
|
||||
for (const s of Object.values(state.sessions)) {
|
||||
if (s === "active" || s === "waiting") {
|
||||
liveCount++;
|
||||
if (s === "waiting") waitingCount++;
|
||||
} else if (s === "error") errorCount++;
|
||||
}
|
||||
return { liveCount, waitingCount, errorCount, connected: state.connected };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure mood resolver. Highest-priority matching state wins. `now` is injected
|
||||
* so callers (and tests) control the clock; transient windows (happy/worried)
|
||||
* and inactivity windows (stuck/sleeping) are evaluated against it.
|
||||
*/
|
||||
export function deriveMood(state: SagiState, now: number): Mood {
|
||||
if (!state.connected) return "disconnected";
|
||||
if (now < state.worriedUntil) return "worried";
|
||||
|
||||
const { liveCount } = statusOf(state);
|
||||
const silent = now - state.lastActivityAt;
|
||||
|
||||
if (liveCount > 0 && silent > STUCK_MS) return "stuck";
|
||||
if (now < state.happyUntil) return "happy";
|
||||
if (state.thinking) return "thinking";
|
||||
if (liveCount > 0) return "watching";
|
||||
if (silent > SLEEP_MS) return "sleeping";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a single WebSocket message into the Sagi state. Returns the next state
|
||||
* (new object) and a one-shot pulse describing what happened. Unknown or
|
||||
* irrelevant message types pass through unchanged with a `null` pulse.
|
||||
*/
|
||||
export function reduceSagi(
|
||||
state: SagiState,
|
||||
msg: WSMessage,
|
||||
now: number
|
||||
): { state: SagiState; pulse: SagiPulse } {
|
||||
switch (msg.type) {
|
||||
case "session_created":
|
||||
case "session_updated": {
|
||||
const s = msg.data as Session;
|
||||
if (!s || !s.id) return { state, pulse: null };
|
||||
const sessions = { ...state.sessions };
|
||||
let pulse: SagiPulse = null;
|
||||
let happyUntil = state.happyUntil;
|
||||
let worriedUntil = state.worriedUntil;
|
||||
|
||||
if (s.status === "active") {
|
||||
// "waiting" = active session blocked on user input (permission prompt
|
||||
// or sitting at a fresh prompt). Announce the transition once each way.
|
||||
const isWaiting = !!s.awaiting_input_since;
|
||||
const prev = sessions[s.id];
|
||||
if (isWaiting) {
|
||||
sessions[s.id] = "waiting";
|
||||
if (prev !== "waiting") pulse = "waiting";
|
||||
} else {
|
||||
sessions[s.id] = "active";
|
||||
if (prev === undefined) pulse = "session_start";
|
||||
}
|
||||
} else if (s.status === "error") {
|
||||
sessions[s.id] = "error";
|
||||
worriedUntil = now + WORRIED_MS;
|
||||
pulse = "error";
|
||||
} else if (s.status === "completed" || s.status === "abandoned") {
|
||||
const wasTracked = s.id in sessions;
|
||||
delete sessions[s.id];
|
||||
if (s.status === "completed") {
|
||||
happyUntil = now + HAPPY_MS;
|
||||
if (wasTracked) pulse = "session_done";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: { ...state, sessions, happyUntil, worriedUntil, lastActivityAt: now },
|
||||
pulse,
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_created": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
};
|
||||
}
|
||||
// A freshly spawned subagent is worth announcing; the main agent landing
|
||||
// is already covered by session_start.
|
||||
const pulse: SagiPulse = a && a.type === "subagent" ? "subagent_spawn" : null;
|
||||
return { state: { ...state, lastActivityAt: now }, pulse };
|
||||
}
|
||||
|
||||
case "agent_updated": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
};
|
||||
}
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
}
|
||||
|
||||
case "new_event": {
|
||||
const e = msg.data as DashboardEvent;
|
||||
const isFailure = !!e && FAILURE_EVENT_TYPES.has(e.event_type);
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
lastActivityAt: now,
|
||||
worriedUntil: isFailure ? now + WORRIED_MS : state.worriedUntil,
|
||||
},
|
||||
pulse: isFailure ? "error" : null,
|
||||
};
|
||||
}
|
||||
|
||||
case "run_status": {
|
||||
const r = msg.data as RunStatusPayload;
|
||||
if (!r) return { state, pulse: null };
|
||||
// running / gone → activity only (no exit code to distinguish success/failure).
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
}
|
||||
|
||||
case "run_stream":
|
||||
// Streaming output counts as activity but is not itself notable.
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
|
||||
default:
|
||||
return { state, pulse: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate session tracking from a REST snapshot (the same data the dashboard
|
||||
* fetches on load). Without this, the brain only learns about sessions from
|
||||
* live WS deltas that arrive *after* it mounts, so a freshly-loaded page shows
|
||||
* "0 live" even when sessions already exist. Merges in non-finished sessions;
|
||||
* never clears the error window. Live WS deltas continue to refine this.
|
||||
*/
|
||||
export function seedSessions(
|
||||
state: SagiState,
|
||||
rows: ReadonlyArray<{ id: string; status: string; awaiting_input_since?: string | null }>,
|
||||
now: number
|
||||
): SagiState {
|
||||
const sessions = { ...state.sessions };
|
||||
for (const r of rows) {
|
||||
if (!r || !r.id) continue;
|
||||
if (r.status === "error") sessions[r.id] = "error";
|
||||
else if (r.status === "active") sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
|
||||
// completed / abandoned: leave untracked.
|
||||
}
|
||||
return { ...state, sessions, lastActivityAt: now };
|
||||
}
|
||||
|
||||
/** Drop all errored sessions from tracking (used by "clear alerts"). */
|
||||
export function clearErrors(state: SagiState): SagiState {
|
||||
const sessions: SagiState["sessions"] = {};
|
||||
for (const [id, s] of Object.entries(state.sessions)) {
|
||||
if (s !== "error") sessions[id] = s;
|
||||
}
|
||||
return { ...state, sessions, worriedUntil: 0 };
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @file intents.ts
|
||||
* @description Sagi's local "Ask" brain. Matches a free-text question against a
|
||||
* small set of intents answerable from cached dashboard status. Anything it
|
||||
* can't answer becomes a handoff to the Run page (spawn a real `claude`).
|
||||
* Pure function - no network, no DOM - so it's fully unit-testable.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./brain`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `AskResult` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `matchIntent` — 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).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **AskResult**
|
||||
* 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.
|
||||
*
|
||||
* **matchIntent**
|
||||
* 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 type { SagiStatus } from "./brain";
|
||||
|
||||
export type AskResult = { kind: "answer"; text: string } | { kind: "handoff"; prompt: string };
|
||||
|
||||
const plural = (n: number) => (n === 1 ? "" : "s");
|
||||
|
||||
export function matchIntent(query: string, status: SagiStatus): AskResult {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: "ask me about your sessions - what's running, any errors, or a quick status.",
|
||||
};
|
||||
}
|
||||
|
||||
const has = (...words: string[]) => words.some((w) => q.includes(w));
|
||||
|
||||
if (has("help", "what can you", "what do you do")) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: 'I watch your sessions. Try "what\'s running", "any errors", or "status". Anything else, I\'ll hand to Claude.',
|
||||
};
|
||||
}
|
||||
|
||||
// Errors first: "any failed runs" should report errors, not live count.
|
||||
if (has("error", "broke", "broken", "fail", "wrong", "crash")) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.errorCount > 0
|
||||
? `${status.errorCount} session${plural(status.errorCount)} errored - open the panel to jump to them.`
|
||||
: "no errors - all clean 🐾",
|
||||
};
|
||||
}
|
||||
|
||||
if (has("waiting", "stuck", "blocked", "input", "my turn", "paused")) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.waitingCount > 0
|
||||
? `${status.waitingCount} session${plural(status.waitingCount)} waiting on you 👀`
|
||||
: "nothing's waiting on you right now 🐾",
|
||||
};
|
||||
}
|
||||
|
||||
if (has("running", "active", "live", "going on", "happening", "in progress")) {
|
||||
const tail = status.waitingCount > 0 ? ` (${status.waitingCount} waiting on you 👀)` : "";
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.liveCount > 0
|
||||
? `${status.liveCount} session${plural(status.liveCount)} live right now 🐾${tail}`
|
||||
: "nothing's running right now - all quiet.",
|
||||
};
|
||||
}
|
||||
|
||||
if (has("status", "summary", "overview", "how are things", "how's it", "how is it")) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: `${status.liveCount} live · ${status.waitingCount} waiting · ${status.errorCount} errored · ${
|
||||
status.connected ? "connected" : "offline"
|
||||
}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: "handoff", prompt: query.trim() };
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @file prefs.ts
|
||||
* @description Tiny localStorage-backed preference store for Sagi (enabled +
|
||||
* muted). Broadcasts changes via a window CustomEvent so the Settings toggle
|
||||
* and the live widget stay in sync within the same tab without a reload.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SagiPos` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `sagiPrefs` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SagiPos**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **sagiPrefs**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
const ENABLED_KEY = "agent-dashboard-sagi-enabled";
|
||||
const MUTED_KEY = "agent-dashboard-sagi-muted";
|
||||
const POS_KEY = "agent-dashboard-sagi-pos";
|
||||
const EVENT = "sagi:prefs";
|
||||
|
||||
/**
|
||||
* Persisted resting position, AssistiveTouch-style: the widget always docks to
|
||||
* the left or right edge, remembering its vertical offset. `y` is stored as a
|
||||
* fraction of the viewport height (0–1) so it survives window resizes.
|
||||
*/
|
||||
export interface SagiPos {
|
||||
side: "left" | "right";
|
||||
y: number;
|
||||
}
|
||||
|
||||
function readBool(key: string, fallback: boolean): boolean {
|
||||
try {
|
||||
const v = localStorage.getItem(key);
|
||||
return v === null ? fallback : v === "true";
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeBool(key: string, value: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(key, String(value));
|
||||
} catch {
|
||||
// Ignore storage failures (private mode, quota) - prefs are best-effort.
|
||||
}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent(EVENT));
|
||||
} catch {
|
||||
// SSR / non-DOM contexts: nothing to notify.
|
||||
}
|
||||
}
|
||||
|
||||
function readPos(): SagiPos | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(POS_KEY);
|
||||
if (!raw) return null;
|
||||
const p = JSON.parse(raw) as Partial<SagiPos>;
|
||||
if ((p.side === "left" || p.side === "right") && typeof p.y === "number") {
|
||||
return { side: p.side, y: Math.min(1, Math.max(0, p.y)) };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePos(pos: SagiPos): void {
|
||||
try {
|
||||
localStorage.setItem(POS_KEY, JSON.stringify(pos));
|
||||
} catch {
|
||||
// Ignore storage failures - position is best-effort.
|
||||
}
|
||||
// Note: intentionally does NOT dispatch the prefs event - position changes
|
||||
// are local to the widget and shouldn't churn the Settings toggle listeners.
|
||||
}
|
||||
|
||||
export const sagiPrefs = {
|
||||
getEnabled: () => readBool(ENABLED_KEY, true),
|
||||
setEnabled: (v: boolean) => writeBool(ENABLED_KEY, v),
|
||||
getMuted: () => readBool(MUTED_KEY, false),
|
||||
setMuted: (v: boolean) => writeBool(MUTED_KEY, v),
|
||||
getPos: readPos,
|
||||
setPos: writePos,
|
||||
/** Subscribe to any pref change; returns an unsubscribe fn. */
|
||||
subscribe(handler: () => void): () => void {
|
||||
const listener = () => handler();
|
||||
window.addEventListener(EVENT, listener);
|
||||
// Also react to changes from other tabs.
|
||||
window.addEventListener("storage", listener);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT, listener);
|
||||
window.removeEventListener("storage", listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @file quips.ts
|
||||
* @description Sagi's personality: pools of short phrases keyed by pulse/mood,
|
||||
* plus a deterministic-by-injection picker. Pure data + a pure function so it
|
||||
* can be unit-tested without randomness leaking in.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./brain`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `QuipKey` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `pickQuip` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ALL_QUIP_KEYS` — 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).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **QuipKey**
|
||||
* 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.
|
||||
*
|
||||
* **pickQuip**
|
||||
* 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.
|
||||
*
|
||||
* **ALL_QUIP_KEYS**
|
||||
* 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 type { Mood, SagiPulse } from "./brain";
|
||||
|
||||
export type QuipKey = NonNullable<SagiPulse> | Mood;
|
||||
|
||||
const QUIPS: Record<QuipKey, string[]> = {
|
||||
// Pulses (event-driven, transient bubbles)
|
||||
session_done: [
|
||||
"a session just wrapped up! 🐾",
|
||||
"a session finished - nice work! ✨",
|
||||
"that session's all done 😺",
|
||||
"clean finish on that one 💜",
|
||||
],
|
||||
session_start: [
|
||||
"a new session started! 👀",
|
||||
"a fresh session just landed 🐾",
|
||||
"ooh, a new session to watch 😻",
|
||||
"something new is cooking 🍲",
|
||||
],
|
||||
subagent_spawn: [
|
||||
"a subagent just spawned! 🐾",
|
||||
"a little helper joined in 🤝",
|
||||
"a subagent's on the job 🚀",
|
||||
"reinforcements - new subagent! 😺",
|
||||
],
|
||||
waiting: [
|
||||
"a session needs your input 👀",
|
||||
"a session is waiting on you ⏳",
|
||||
"a session paused for your reply 💬",
|
||||
"your turn - a session's waiting 🐾",
|
||||
],
|
||||
error: [
|
||||
"uh oh, a session hit an error 😿",
|
||||
"something broke - wanna peek? 🙀",
|
||||
"a hook tripped on something ⚠️",
|
||||
"hiss… an error popped up 💢",
|
||||
],
|
||||
run_done: [
|
||||
"your run just finished! 🐾",
|
||||
"the run's all wrapped up ✨",
|
||||
"run complete - that's a wrap 😸",
|
||||
"all done with that run 💜",
|
||||
],
|
||||
// Moods (steady-state flavor, used by the panel / idle bubbles)
|
||||
disconnected: [
|
||||
"lost the connection… 😴",
|
||||
"can't reach the server 📡",
|
||||
"no signal - taking a nap 💤",
|
||||
],
|
||||
worried: ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
|
||||
stuck: [
|
||||
"a session's been quiet a while… 🤔",
|
||||
"is something stuck? ⏳",
|
||||
"still chewing on it… 😾",
|
||||
],
|
||||
happy: ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
|
||||
thinking: ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
|
||||
watching: ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
||||
sleeping: ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
||||
idle: ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Pick a quip for a key. `rand` is injectable for deterministic tests; defaults
|
||||
* to Math.random. Returns "" only for an unknown key (never throws).
|
||||
*/
|
||||
export function pickQuip(key: QuipKey, rand: () => number = Math.random): string {
|
||||
const pool = QUIPS[key];
|
||||
if (!pool || pool.length === 0) return "";
|
||||
const i = Math.min(pool.length - 1, Math.max(0, Math.floor(rand() * pool.length)));
|
||||
return pool[i] ?? "";
|
||||
}
|
||||
|
||||
export const ALL_QUIP_KEYS = Object.keys(QUIPS) as QuipKey[];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* @file useSagiBrain.ts
|
||||
* @description React hook that wires the pure Sagi brain to the live event bus
|
||||
* and to real timers. It is the only unit that subscribes to `eventBus`. It
|
||||
* exposes the derived mood, a status summary, the current speech bubble, and
|
||||
* imperative controls (mute, clear alerts, set thinking) for the UI shell.
|
||||
* @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. React hook: isolates side effects and subscription wiring so presentational components stay declarative.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - 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
|
||||
* - `../../lib/eventBus`
|
||||
* - `../../lib/api`
|
||||
* - `../../lib/types`
|
||||
* - `./brain`
|
||||
* - `./quips`
|
||||
* - `./prefs`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SagiBrain` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `useSagiBrain` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - 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).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SagiBrain**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **useSagiBrain**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { api } from "../../lib/api";
|
||||
import type { WSMessage } from "../../lib/types";
|
||||
import {
|
||||
initialSagiState,
|
||||
reduceSagi,
|
||||
deriveMood,
|
||||
statusOf,
|
||||
clearErrors,
|
||||
seedSessions,
|
||||
type Mood,
|
||||
type SagiState,
|
||||
type SagiStatus,
|
||||
} from "./brain";
|
||||
import { pickQuip } from "./quips";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
|
||||
const BUBBLE_MS = 4500;
|
||||
// Minimum gap between non-error bubbles, so a burst of activity doesn't spam.
|
||||
const BUBBLE_THROTTLE_MS = 3000;
|
||||
|
||||
export interface SagiBrain {
|
||||
mood: Mood;
|
||||
status: SagiStatus;
|
||||
bubble: string | null;
|
||||
dismissBubble: () => void;
|
||||
muted: boolean;
|
||||
toggleMute: () => void;
|
||||
clearAlerts: () => void;
|
||||
setThinking: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export function useSagiBrain(): SagiBrain {
|
||||
const now0 = Date.now();
|
||||
// Start optimistically connected (idle, open eyes) so the cursor-tracking
|
||||
// eyes are live from the first frame instead of after the WebSocket finishes
|
||||
// its initial handshake. onConnection below corrects this if it's truly down.
|
||||
const [state, setState] = useState<SagiState>(() => ({
|
||||
...initialSagiState(now0),
|
||||
connected: true,
|
||||
}));
|
||||
const [tick, setTick] = useState(now0);
|
||||
const [bubble, setBubble] = useState<string | null>(null);
|
||||
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
||||
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const lastBubbleAt = useRef(0);
|
||||
const mutedRef = useRef(muted);
|
||||
mutedRef.current = muted;
|
||||
|
||||
// Keep mute in sync with the Settings page / other tabs.
|
||||
useEffect(() => sagiPrefs.subscribe(() => setMuted(sagiPrefs.getMuted())), []);
|
||||
|
||||
const showBubble = useCallback((text: string, force: boolean) => {
|
||||
if (!text) return;
|
||||
if (mutedRef.current) return;
|
||||
const t = Date.now();
|
||||
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
|
||||
lastBubbleAt.current = t;
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubble(text);
|
||||
bubbleTimer.current = setTimeout(() => setBubble(null), BUBBLE_MS);
|
||||
}, []);
|
||||
|
||||
// Seed from the REST snapshot on mount so counts are accurate immediately -
|
||||
// the brain otherwise only learns about sessions from WS deltas that arrive
|
||||
// after it mounts, showing "0 live" on a fresh load even when sessions exist.
|
||||
// Pull a generous page of non-finished sessions; live WS deltas refine it.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 100 })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setState((prev) => seedSessions(prev, res.sessions, Date.now()));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Subscribe to the live stream and connection status.
|
||||
useEffect(() => {
|
||||
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
||||
const t = Date.now();
|
||||
setState((prev) => {
|
||||
const { state: next, pulse } = reduceSagi(prev, msg, t);
|
||||
if (pulse) showBubble(pickQuip(pulse), pulse === "error");
|
||||
return next;
|
||||
});
|
||||
});
|
||||
const unsubConn = eventBus.onConnection((connected) => {
|
||||
setState((prev) => ({ ...prev, connected }));
|
||||
});
|
||||
return () => {
|
||||
unsubMsg();
|
||||
unsubConn();
|
||||
};
|
||||
}, [showBubble]);
|
||||
|
||||
// Advance the clock so timed moods (stuck/sleeping, and exit from
|
||||
// happy/worried) re-evaluate without needing a new event.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => clearTimeout(bubbleTimer.current), []);
|
||||
|
||||
const mood = useMemo(() => deriveMood(state, tick), [state, tick]);
|
||||
const status = useMemo(() => statusOf(state), [state]);
|
||||
|
||||
const dismissBubble = useCallback(() => {
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubble(null);
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const next = !mutedRef.current;
|
||||
sagiPrefs.setMuted(next);
|
||||
setMuted(next);
|
||||
if (next) dismissBubble();
|
||||
}, [dismissBubble]);
|
||||
|
||||
const clearAlerts = useCallback(() => setState((prev) => clearErrors(prev)), []);
|
||||
|
||||
const setThinking = useCallback(
|
||||
(v: boolean) => setState((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })),
|
||||
[]
|
||||
);
|
||||
|
||||
return { mood, status, bubble, dismissBubble, muted, toggleMute, clearAlerts, setThinking };
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @file useSagiPosition.ts
|
||||
* @description AssistiveTouch-style draggable docking for the Sagi avatar. The
|
||||
* avatar follows the pointer 1:1 while dragging (via Pointer Capture, so it
|
||||
* keeps tracking even if the cursor outruns it), and on release snaps to the
|
||||
* nearest left/right edge, remembering its vertical offset (persisted as a
|
||||
* viewport fraction so it survives resizes). A small movement threshold tells
|
||||
* a drag apart from a tap so dragging never opens the panel.
|
||||
* @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. React hook: isolates side effects and subscription wiring so presentational components stay declarative.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - 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
|
||||
* - `./prefs`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SAGI_SIZE` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `SAGI_MARGIN` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `SagiPlacement` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `useSagiPosition` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - 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).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SAGI_SIZE**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **SAGI_MARGIN**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **SagiPlacement**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **useSagiPosition**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { sagiPrefs, type SagiPos } from "./prefs";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
|
||||
// Avatar footprint + edge gap, in px. SIZE matches SagiAvatar's default size.
|
||||
export const SAGI_SIZE = 60;
|
||||
export const SAGI_MARGIN = 16;
|
||||
const DRAG_THRESHOLD = 5;
|
||||
|
||||
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
|
||||
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
|
||||
|
||||
function defaultPos(): SagiPos {
|
||||
return { side: "right", y: 0.5 }; // right edge, vertically centered
|
||||
}
|
||||
|
||||
/** Resting top-left screen coords for a docked position. */
|
||||
function restingScreen(pos: SagiPos) {
|
||||
const avail = Math.max(0, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
|
||||
const left = pos.side === "left" ? SAGI_MARGIN : vw() - SAGI_SIZE - SAGI_MARGIN;
|
||||
const top = SAGI_MARGIN + pos.y * avail;
|
||||
return { left, top };
|
||||
}
|
||||
|
||||
export interface SagiPlacement {
|
||||
/** Avatar top-left, in screen px. */
|
||||
left: number;
|
||||
top: number;
|
||||
size: number;
|
||||
side: "left" | "right";
|
||||
/** True when the avatar sits in the lower half - flyouts open upward. */
|
||||
openUp: boolean;
|
||||
dragging: boolean;
|
||||
onPointerDown: (e: ReactPointerEvent) => void;
|
||||
onPointerMove: (e: ReactPointerEvent) => void;
|
||||
onPointerUp: (e: ReactPointerEvent) => void;
|
||||
/** Returns true (once) if a drag just ended, so the click handler can skip. */
|
||||
consumeDrag: () => boolean;
|
||||
}
|
||||
|
||||
export function useSagiPosition(): SagiPlacement {
|
||||
const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos());
|
||||
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
|
||||
const [, force] = useState(0); // re-derive resting coords on resize
|
||||
|
||||
const draggedRef = useRef(false);
|
||||
const startRef = useRef<{ px: number; py: number; left: number; top: number } | null>(null);
|
||||
const movedRef = useRef(false);
|
||||
// Latest dragged coords, mirrored in a ref so pointerup can read them
|
||||
// synchronously - the setDrag state may not have committed yet under React's
|
||||
// event batching, so we never rely on its functional-updater `cur`.
|
||||
const liveRef = useRef<{ left: number; top: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => force((n) => n + 1);
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
const resting = restingScreen(pos);
|
||||
const screen = drag ?? resting;
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: ReactPointerEvent) => {
|
||||
if (e.button !== undefined && e.button !== 0) return;
|
||||
// Capture so the avatar keeps receiving move/up events even when the
|
||||
// pointer leaves it - essential for a fast, 1:1 drag.
|
||||
try {
|
||||
(e.currentTarget as Element).setPointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
/* capture unsupported - window-free fallback still works via props */
|
||||
}
|
||||
startRef.current = { px: e.clientX, py: e.clientY, left: screen.left, top: screen.top };
|
||||
movedRef.current = false;
|
||||
},
|
||||
[screen.left, screen.top]
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback((e: ReactPointerEvent) => {
|
||||
const start = startRef.current;
|
||||
if (!start) return;
|
||||
const dx = e.clientX - start.px;
|
||||
const dy = e.clientY - start.py;
|
||||
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||||
movedRef.current = true;
|
||||
const left = Math.min(vw() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
|
||||
const top = Math.min(vh() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.top + dy));
|
||||
liveRef.current = { left, top };
|
||||
setDrag({ left, top });
|
||||
}, []);
|
||||
|
||||
const onPointerUp = useCallback((e: ReactPointerEvent) => {
|
||||
try {
|
||||
(e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const live = liveRef.current;
|
||||
if (live) {
|
||||
draggedRef.current = true;
|
||||
const side: "left" | "right" = live.left + SAGI_SIZE / 2 < vw() / 2 ? "left" : "right";
|
||||
const avail = Math.max(1, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
|
||||
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
|
||||
const next: SagiPos = { side, y };
|
||||
sagiPrefs.setPos(next);
|
||||
setPos(next);
|
||||
setDrag(null); // leave drag mode; resting coords (with transition) take over
|
||||
}
|
||||
liveRef.current = null;
|
||||
startRef.current = null;
|
||||
movedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const consumeDrag = useCallback(() => {
|
||||
const was = draggedRef.current;
|
||||
draggedRef.current = false;
|
||||
return was;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
left: screen.left,
|
||||
top: screen.top,
|
||||
size: SAGI_SIZE,
|
||||
side: pos.side,
|
||||
openUp: screen.top + SAGI_SIZE / 2 > vh() / 2,
|
||||
dragging: drag !== null,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
consumeDrag,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user