ca821008e2
Measured on a copy of a real 1.2 GB / 253K-event database, 10 simulated browsers + 10 hooks/s for 90 s: event-loop delay p99 1239 ms → 32 ms (max 2.0 s → 94 ms), 266 failed requests → 0, 74 → 111 requests/s served, server RSS 633 → 346 MB. Entry bundle 975 KB raw → 394 KB (106 KB brotli). Server - /api/analytics: the events-table scans run in the read worker (scoped-stats eventAggregates, read-pool job analyticsEvents); runRead takes maxAgeMs — analytics 10 s, /api/workflows 30 s — keyed by the args - /api/workflows/skill-usage: +event_type and a tool_name range instead of LIKE, so it uses idx_events_tool (784 ms → a few ms) - idx_events_session_type_tool (session_id, event_type, tool_name, created_at) replaces idx_events_session_type: covering reads for session stats, the hook dedupes and the import resume point (1.2 s → 20 ms); 64 MB cache_size and 256 MB mmap_size on both connections - WAL: the server sets wal_autocheckpoint = 0 and checkpoints PASSIVE every DASHBOARD_WAL_CHECKPOINT_MS (5 s) from the read worker's own writable connection; a failed checkpoint restores the automatic one (hook COMMIT p95 109 ms → 15 ms at 100 hooks/s) - tmux: one list-sessions answers a whole GET /api/lanes and the heal beat (liveSessions), shared for 1 s and cleared by this process's new/kill — GET /api/run's sync spawns had blocked 60 s of a 90 s run; the heal beat skips tmux when nothing runs; attach options chained into 2 spawns - hooks: TurnDuration/APIError deduped against one read per hook instead of a query per transcript entry (23 ms/hook on a 763-turn session); one lane scan per hook; hourly upserts skip rows that did not grow; frames are sent after the transaction commits (and after the pane binding); new_event carries its row id; a split pane's (slot 1–9) hooks no longer rebind the lane's session_id - watchdog liveness probe runs ps asynchronously; transcript newline scan via indexOf; startup subagent backfill fingerprints the files it already checked - pty attach: backpressure (pause past 1 MB unsent, resume under 256 KB) - static: hashed assets served from their pre-compressed .br/.gz sibling Client - hidden tabs neither refetch on WS events nor poll (useLiveRefresh, panes, lane cards, git header, changes panel, settings); no per-tab hourly /updates/check (the server already checks) - refetch storms throttled (workspace tree/session list 3 s, /api/run only on a run-key change, notification pane lookup only when shown); WS reconnect jitter; terminal retry backoff 1.5 → 30 s, stops on 1008 - heavier throttles for aggregates (15 s) and detail views; Monitor keepIfSame; GET in-flight dedupe in request() - lazy Dashboard/History, xterm (with the first terminal), lane modals, Sagi (only while enabled); only the active locale loads; Workspace chunk prefetched on a /workspace deep link - Sagi: no work while disabled, pupil via CSS variables, memo avatar - build: .br/.gz siblings, sourcemap hidden; service worker no longer caches /assets/ (cache v3 drops the old one) Tests: scripts/test-server.js drops the GIT_DIR-family variables a hook exports, so a suite run from a linked worktree's pre-commit can no longer commit into the real repository or set core.bare. Docs: ARCHITECTURE, DATABASE, API, DEPLOYMENT, client README, docs/plans/perf/README.md (audit, checklist, results).
224 lines
8.4 KiB
TypeScript
224 lines
8.4 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.
|
|
*
|
|
* ## 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 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;
|
|
/** 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;
|
|
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, setBubbleState] = 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);
|
|
setBubbleState(text);
|
|
bubbleTimer.current = setTimeout(() => setBubbleState(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(() => {
|
|
// Hidden tab: no re-render; the first visible tick catches the mood up.
|
|
const id = setInterval(() => {
|
|
if (document.visibilityState === "visible") 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);
|
|
setBubbleState(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 })),
|
|
[]
|
|
);
|
|
|
|
// 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;
|
|
clearTimeout(bubbleTimer.current);
|
|
setBubbleState(text);
|
|
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
|
|
}, []);
|
|
|
|
return {
|
|
mood,
|
|
status,
|
|
bubble,
|
|
dismissBubble,
|
|
setBubble,
|
|
muted,
|
|
toggleMute,
|
|
clearAlerts,
|
|
setThinking,
|
|
};
|
|
}
|