4cc39f5069
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.
198 lines
7.6 KiB
TypeScript
198 lines
7.6 KiB
TypeScript
/**
|
|
* @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 };
|
|
}
|