/** * @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 sessions that need the user, * the current speech bubble (naming the session it is about, and coalescing a * burst of the same event into one count), the pet's one-shot animation * "moment", and imperative controls (mute, clear alerts, pat, set thinking) * for the UI shell. It also greets once per browser session and makes an * idle pet fidget now and then. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * 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. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `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 i18n from "../../i18n"; import type { WSMessage } from "../../lib/types"; import { initialSagiState, reduceSagi, deriveMood, statusOf, attentionOf, clearErrors, seedSessions, subjectLabel, MOMENT_OF_PULSE, type AttentionItem, type Mood, type PetMoment, type SagiPulse, type SagiState, type SagiStatus, type SagiSubject, } from "./brain"; import { greetingKey, pickLine, pickQuip } from "./quips"; import { statusSummary } from "./intents"; import { sagiPrefs } from "./prefs"; /** `next` differs from `prev` only by a lastActivityAt less than a second * newer — nothing the mascot shows, at the moods' 1 s resolution. */ function onlyActivityMoved(prev: SagiState, next: SagiState): boolean { if (next === prev) return true; if (next.lastActivityAt - prev.lastActivityAt >= 1000) return false; for (const k of Object.keys(next) as (keyof SagiState)[]) { if (k !== "lastActivityAt" && next[k] !== prev[k]) return false; } return true; } const BUBBLE_MS = 4500; // A bubble that names a session stays long enough to click it. const SUBJECT_BUBBLE_MS = 7000; // Minimum gap between non-error bubbles, so a burst of activity doesn't spam. const BUBBLE_THROTTLE_MS = 3000; // The same pulse again within this window joins the bubble on show as a count. const COALESCE_MS = 8000; // How long a moment (wave, hop, flinch…) plays over the mood. const MOMENT_MS = 2400; // An idle pet fidgets (waves or looks around) once in a while. const FIDGET_MIN_MS = 45_000; const FIDGET_SPAN_MS = 60_000; // Greet once per browser session, shortly after the snapshot lands. const GREETED_KEY = "agenthub.pet.greeted"; const GREET_DELAY_MS = 1200; /** What the speech bubble shows. */ export interface SagiBubble { text: string; /** Which session(s) it is about, shown under the text. */ detail?: string; /** The one session it is about, which a click opens. */ subject?: SagiSubject; /** Errors and waits are drawn with an accent. */ tone?: "error" | "waiting"; } export interface SagiBrain { mood: Mood; status: SagiStatus; /** Errored, then waiting sessions, named. */ attention: AttentionItem[]; bubble: SagiBubble | null; dismissBubble: () => void; /** Imperative bubble setter for one-off messages from the shell (e.g. spawn * errors). Bypasses the throttle because the caller decided this message * must be seen right now. Auto-clears after the standard bubble TTL. */ setBubble: (text: string) => void; /** The one-shot animation playing now, if any; `key` changes per moment so a * repeat of the same one restarts it. */ moment: { kind: PetMoment; key: number } | null; /** Play a moment by hand (an answer, a pat). */ playMoment: (kind: PetMoment) => void; /** Pat the pet: a hop, hearts and a happy line right on the pet — shown * even while the panel or chat is open, and while muted (it was asked). */ pat: () => void; /** The pet's answer to a pat, drawn on the pet itself; `key` restarts it. */ reaction: { text: string; key: number } | null; muted: boolean; toggleMute: () => void; clearAlerts: () => void; setThinking: (v: boolean) => void; } type Burst = { pulse: NonNullable; count: number; subjects: SagiSubject[]; at: number }; function burstBubble(b: Burst): SagiBubble { const tone = b.pulse === "error" ? "error" : b.pulse === "waiting" ? "waiting" : undefined; const one = b.subjects.length === 1 ? b.subjects[0] : undefined; const labels = b.subjects.map(subjectLabel); const detail = labels.length > 2 ? i18n.t("settings:sagi.bubbleMore", { list: labels.slice(0, 2).join(", "), count: labels.length - 2, }) : labels.join(", ") || undefined; const text = b.count > 1 ? i18n.t(`settings:sagi.notify.${b.pulse}`, { count: b.count }) : pickQuip(b.pulse); return { text, detail, subject: one, tone }; } 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, setStateRaw] = useState(() => ({ ...initialSagiState(now0), connected: true, dismissed: sagiPrefs.getDismissed(), })); // The reducer runs OUTSIDE React's updater (which StrictMode calls twice), // so a pulse notifies exactly once; this ref is the state it folds into. const stateRef = useRef(state); const update = useCallback((fn: (prev: SagiState) => SagiState) => { const next = fn(stateRef.current); if (next === stateRef.current) return; stateRef.current = next; setStateRaw(next); }, []); const [tick, setTick] = useState(now0); const [bubble, setBubbleState] = useState(null); const [moment, setMoment] = useState(null); const [reaction, setReaction] = useState(null); const reactionTimer = useRef>(); const [muted, setMuted] = useState(() => sagiPrefs.getMuted()); const bubbleTimer = useRef>(); const momentTimer = useRef>(); const lastBubbleAt = useRef(0); const burst = useRef(null); const momentKey = useRef(0); const mutedRef = useRef(muted); mutedRef.current = muted; // Keep cleared alerts cleared across reloads. useEffect(() => sagiPrefs.setDismissed(state.dismissed), [state.dismissed]); // Keep mute in sync with the Settings page / other tabs. useEffect(() => sagiPrefs.subscribe(() => setMuted(sagiPrefs.getMuted())), []); const putBubble = useCallback((b: SagiBubble, ms = BUBBLE_MS) => { clearTimeout(bubbleTimer.current); setBubbleState(b); bubbleTimer.current = setTimeout(() => { setBubbleState(null); burst.current = null; }, ms); }, []); const playMoment = useCallback((kind: PetMoment) => { clearTimeout(momentTimer.current); setMoment({ kind, key: ++momentKey.current }); momentTimer.current = setTimeout(() => setMoment(null), MOMENT_MS); }, []); const notify = useCallback( (pulse: NonNullable, subject?: SagiSubject) => { playMoment(MOMENT_OF_PULSE[pulse]); if (mutedRef.current) return; const t = Date.now(); const b = burst.current; // The same kind of news again, while its bubble is still up: count it in. if (b && b.pulse === pulse && t - b.at < COALESCE_MS) { b.count++; b.at = t; if (subject && !b.subjects.some((s) => s.id === subject.id)) b.subjects.push(subject); putBubble(burstBubble(b), SUBJECT_BUBBLE_MS); return; } const force = pulse === "error" || pulse === "waiting"; if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return; lastBubbleAt.current = t; const next: Burst = { pulse, count: 1, subjects: subject ? [subject] : [], at: t }; burst.current = next; putBubble(burstBubble(next), subject ? SUBJECT_BUBBLE_MS : BUBBLE_MS); }, [playMoment, putBubble] ); // 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. // Then say hello, once per browser session. useEffect(() => { let cancelled = false; let greetTimer: ReturnType | undefined; const greet = () => { try { if (sessionStorage.getItem(GREETED_KEY)) return; sessionStorage.setItem(GREETED_KEY, "1"); } catch { return; // No storage: rather silent than a hello on every page. } greetTimer = setTimeout(() => { if (cancelled || mutedRef.current) return; const hello = pickLine(`greet.${greetingKey(new Date().getHours())}`); if (!hello) return; lastBubbleAt.current = Date.now(); putBubble({ text: hello, detail: statusSummary(statusOf(stateRef.current)) }); playMoment("wave"); }, GREET_DELAY_MS); }; api.sessions .list({ status: "active", limit: 100 }) .then((res) => { if (cancelled) return; update((prev) => seedSessions(prev, res.sessions, Date.now())); }) .catch(() => undefined) .finally(greet); return () => { cancelled = true; clearTimeout(greetTimer); }; }, [update, putBubble, playMoment]); // Subscribe to the live stream and connection status. useEffect(() => { const unsubMsg = eventBus.subscribe((msg: WSMessage) => { const prev = stateRef.current; const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now()); // Most frames only move lastActivityAt, which the moods read at a 1 s // resolution: keep the old state then, so a hook burst (several frames a // second) does not re-render the mascot on every frame. if (!onlyActivityMoved(prev, next)) update(() => next); if (pulse) notify(pulse, subject); }); const unsubConn = eventBus.onConnection((connected) => { update((prev) => (prev.connected === connected ? prev : { ...prev, connected })); }); return () => { unsubMsg(); unsubConn(); }; }, [update, notify]); // Advance the clock so timed moods (stuck/sleeping, and exit from // happy/worried) re-evaluate without needing a new event. useEffect(() => { // Hidden tab: no re-render; the first visible tick catches the mood up. // Only a tick that changes the mood re-renders: an idle mascot is not // drawn again every second for nothing. const id = setInterval(() => { if (document.visibilityState !== "visible") return; const now = Date.now(); if (deriveMood(stateRef.current, now) !== moodRef.current) setTick(now); }, 1000); return () => clearInterval(id); }, []); useEffect( () => () => { clearTimeout(bubbleTimer.current); clearTimeout(momentTimer.current); clearTimeout(reactionTimer.current); }, [] ); // Judged at render time: `tick` only moves when the mood would change, so // it may be old when a frame re-renders. const mood = useMemo(() => deriveMood(state, Math.max(tick, Date.now())), [state, tick]); const status = useMemo(() => statusOf(state), [state]); const attention = useMemo(() => attentionOf(state), [state]); // An idle pet is not a statue: every minute or so it waves or looks around. const moodRef = useRef(mood); moodRef.current = mood; useEffect(() => { let timer: ReturnType; const schedule = () => { timer = setTimeout( () => { if (moodRef.current === "idle" && document.visibilityState === "visible") playMoment(Math.random() < 0.5 ? "wave" : "review"); schedule(); }, FIDGET_MIN_MS + Math.random() * FIDGET_SPAN_MS ); }; schedule(); return () => clearTimeout(timer); }, [playMoment]); const dismissBubble = useCallback(() => { clearTimeout(bubbleTimer.current); setBubbleState(null); burst.current = null; }, []); const toggleMute = useCallback(() => { const next = !mutedRef.current; sagiPrefs.setMuted(next); setMuted(next); if (next) dismissBubble(); }, [dismissBubble]); const clearAlerts = useCallback(() => { update((prev) => clearErrors(prev)); if (burst.current?.pulse === "error") dismissBubble(); }, [update, dismissBubble]); const setThinking = useCallback( (v: boolean) => update((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })), [update] ); // One-shot bubble: clears itself after BUBBLE_MS. Used by the shell for // imperative messages (e.g. spawn errors) that must appear now and fade, // bypassing the throttled mood-driven showBubble path. const setBubble = useCallback( (text: string) => { if (!text) return; burst.current = null; putBubble({ text }); }, [putBubble] ); const pat = useCallback(() => { playMoment("jump"); update((prev) => ({ ...prev, happyUntil: Date.now() + MOMENT_MS })); clearTimeout(reactionTimer.current); setReaction({ text: pickLine("pat"), key: ++momentKey.current }); reactionTimer.current = setTimeout(() => setReaction(null), MOMENT_MS); }, [playMoment, update]); return { mood, status, attention, bubble, dismissBubble, setBubble, moment, playMoment, pat, reaction, muted, toggleMute, clearAlerts, setThinking, }; }