f78c7f9a2e
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
203 lines
7.9 KiB
TypeScript
203 lines
7.9 KiB
TypeScript
/**
|
|
* @file useTabbyBrain.ts
|
|
* @description React hook that wires the pure Tabby 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:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome. React hook: isolates side effects and subscription wiring so presentational components stay declarative.
|
|
*
|
|
* ## 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.
|
|
*
|
|
* ## Observability
|
|
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
|
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
|
* Docker Compose profiles are documented in `monitoring/README.md`.
|
|
*
|
|
* ## Internal dependencies
|
|
* - `../../lib/eventBus`
|
|
* - `../../lib/api`
|
|
* - `../../lib/types`
|
|
* - `./brain`
|
|
* - `./quips`
|
|
* - `./prefs`
|
|
*
|
|
* ## Public surface
|
|
* - `TabbyBrain` — exported API; see TSDoc on the symbol for behavior.
|
|
* - `useTabbyBrain` — 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).
|
|
* -----------------------------------------------------------------------------
|
|
* **TabbyBrain**
|
|
* 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.
|
|
*
|
|
* **useTabbyBrain**
|
|
* 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 {
|
|
initialTabbyState,
|
|
reduceTabby,
|
|
deriveMood,
|
|
statusOf,
|
|
clearErrors,
|
|
seedSessions,
|
|
type Mood,
|
|
type TabbyState,
|
|
type TabbyStatus,
|
|
} from "./brain";
|
|
import { pickQuip } from "./quips";
|
|
import { tabbyPrefs } 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 TabbyBrain {
|
|
mood: Mood;
|
|
status: TabbyStatus;
|
|
bubble: string | null;
|
|
dismissBubble: () => void;
|
|
muted: boolean;
|
|
toggleMute: () => void;
|
|
clearAlerts: () => void;
|
|
setThinking: (v: boolean) => void;
|
|
}
|
|
|
|
export function useTabbyBrain(): TabbyBrain {
|
|
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<TabbyState>(() => ({
|
|
...initialTabbyState(now0),
|
|
connected: true,
|
|
}));
|
|
const [tick, setTick] = useState(now0);
|
|
const [bubble, setBubble] = useState<string | null>(null);
|
|
const [muted, setMuted] = useState<boolean>(() => tabbyPrefs.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(() => tabbyPrefs.subscribe(() => setMuted(tabbyPrefs.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 } = reduceTabby(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;
|
|
tabbyPrefs.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 };
|
|
}
|