feat: Claude Code Monitor — lanes, pipelines and a merged workspace

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.
This commit is contained in:
2026-07-29 17:07:45 +07:00
commit 57dc91585d
783 changed files with 221743 additions and 0 deletions
+295
View File
@@ -0,0 +1,295 @@
/**
* @file CatAvatar.tsx
* @description Pure presentational SVG cat - Tabby. Given a mood it renders the
* matching expression (ears, eyes, cheeks, mouth, tail, paws) via a `data-mood`
* attribute that drives the CSS in tabby.css. When motion is allowed, the
* pupils track the cursor. No data access - fully testable / reusable in
* isolation. Geometry tuned for max cuteness: big round head, oversized
* sparkly eyes, pink ear-insides + cheek blush, classic tabby forehead
* stripes, a fluffy tail, and little paws peeking at the bottom.
* @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.
*
* ## 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
* - `./brain`
*
* ## Public surface
* - `CatAvatar` — 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).
* -----------------------------------------------------------------------------
* **CatAvatar**
* 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 { useEffect, useRef, useState } from "react";
import type { Mood } from "./brain";
interface CatAvatarProps {
mood: Mood;
reducedMotion: boolean;
size?: number;
}
const MAX_PUPIL_SHIFT = 2.8; // px in the 100x100 viewBox
// 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 the cat 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 CatAvatar({ mood, reducedMotion, size = 60 }: CatAvatarProps) {
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="tabby-cat"
data-mood={mood}
data-reduced={reducedMotion ? "1" : "0"}
width={size}
height={size}
viewBox="0 0 100 100"
role="img"
aria-label={`Tabby (${mood})`}
>
<defs>
{/* Soft top-lit gradient for the body/head - gives a rounded, plush feel. */}
<radialGradient id="tabbyFur" cx="50%" cy="34%" r="72%">
<stop offset="0%" stopColor="#5b5b86" />
<stop offset="60%" stopColor="#43436a" />
<stop offset="100%" stopColor="#343352" />
</radialGradient>
<linearGradient id="tabbyHalo" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#a5b4fc" />
<stop offset="100%" stopColor="#6366f1" />
</linearGradient>
</defs>
{/* soft glow halo */}
<circle className="tabby-halo" cx="50" cy="56" r="33" />
{/* tail - fluffy curl to the right */}
<path
className="tabby-tail"
d="M78 74 q24 4 19 -22 q-3 -14 -12 -11 q8 4 6 14 q-3 12 -16 9 z"
/>
{/* body / chest peeking up from the bottom */}
<ellipse className="tabby-body" cx="50" cy="92" rx="27" ry="18" />
{/* paws */}
<g className="tabby-paws">
<ellipse className="tabby-paw" cx="38" cy="98" rx="8" ry="6" />
<ellipse className="tabby-paw" cx="62" cy="98" rx="8" ry="6" />
<path className="tabby-toe" d="M35 96 v4 M38 96.5 v4 M41 96 v4" />
<path className="tabby-toe" d="M59 96 v4 M62 96.5 v4 M65 96 v4" />
</g>
{/* ears - rounded, with pink inner */}
<g className="tabby-ears">
<path className="tabby-ear" d="M30 33 Q20 8 44 24 Q38 28 34 33 Z" />
<path className="tabby-ear-inner" d="M31 30 Q26 16 39 25 Q35 27 33 30 Z" />
<path className="tabby-ear" d="M70 33 Q80 8 56 24 Q62 28 66 33 Z" />
<path className="tabby-ear-inner" d="M69 30 Q74 16 61 25 Q65 27 67 30 Z" />
</g>
{/* head - big and round */}
<ellipse className="tabby-head" cx="50" cy="49" rx="33" ry="30" />
{/* classic tabby forehead stripes */}
<g className="tabby-stripes">
<path d="M50 22 L50 31" />
<path d="M43 24 L45 32" />
<path d="M57 24 L55 32" />
</g>
{/* cheek blush */}
<g className="tabby-cheeks">
<ellipse cx="26" cy="57" rx="6.5" ry="4" />
<ellipse cx="74" cy="57" rx="6.5" ry="4" />
</g>
{/* eyes - open state (big + sparkly) */}
<g className="tabby-eyes-open">
<ellipse className="tabby-eye" cx="37" cy="50" rx="9.5" ry="11.5" />
<ellipse className="tabby-eye" cx="63" cy="50" rx="9.5" ry="11.5" />
{/* Outer group carries the eye-tracking translate; the inner group
carries the blink (scaleY) animation. They MUST be separate
elements - a CSS animation on `transform` overrides an inline
`transform`, so putting both on one node makes the blink clobber
the tracking (the original "eyes only track after a while" bug). */}
<g className="tabby-pupils" style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}>
<g className="tabby-pupils-blink">
<circle className="tabby-pupil" cx="37" cy="51" r="5.4" />
<circle className="tabby-pupil" cx="63" cy="51" r="5.4" />
<circle className="tabby-glint" cx="39.4" cy="48" r="2.1" />
<circle className="tabby-glint" cx="65.4" cy="48" r="2.1" />
<circle className="tabby-glint tabby-glint-sm" cx="34.8" cy="53" r="1.1" />
<circle className="tabby-glint tabby-glint-sm" cx="60.8" cy="53" r="1.1" />
</g>
</g>
</g>
{/* eyes - happy (^ ^) */}
<g className="tabby-eyes-happy">
<path d="M29 52 q8 -10 16 0" />
<path d="M55 52 q8 -10 16 0" />
</g>
{/* eyes - closed (sleeping / offline) */}
<g className="tabby-eyes-closed">
<path d="M29 51 q8 7 16 0" />
<path d="M55 51 q8 7 16 0" />
</g>
{/* worried brows */}
<g className="tabby-brows">
<path d="M29 39 L45 45" />
<path d="M71 39 L55 45" />
</g>
{/* nose - tiny heart */}
<path
className="tabby-nose"
d="M50 64 C47 60 43 62 45 65 C46 67 50 69 50 69 C50 69 54 67 55 65 C57 62 53 60 50 64 Z"
/>
{/* mouths */}
<path className="tabby-mouth-idle" d="M50 67 q-4 4 -8 1 M50 67 q4 4 8 1" />
<path className="tabby-mouth-happy" d="M42 66 q8 7 16 0" />
<path className="tabby-mouth-worried" d="M44 71 q6 -5 12 0" />
{/* whiskers */}
<g className="tabby-whiskers">
<path d="M14 55 Q24 55 33 58" />
<path d="M13 62 Q24 63 33 63" />
<path d="M86 55 Q76 55 67 58" />
<path d="M87 62 Q76 63 67 63" />
</g>
{/* zzz for sleeping */}
<g className="tabby-zzz">
<text x="76" y="28">
z
</text>
<text x="84" y="19">
z
</text>
</g>
{/* alert bang for stuck */}
<g className="tabby-bang">
<text x="80" y="28">
!
</text>
</g>
{/* sparkle for happy */}
<g className="tabby-sparkle">
<path d="M82 40 l1.4 3.6 l3.6 1.4 l-3.6 1.4 l-1.4 3.6 l-1.4 -3.6 l-3.6 -1.4 l3.6 -1.4 z" />
</g>
</svg>
);
}
@@ -0,0 +1,84 @@
/**
* @file SpeechBubble.tsx
* @description Transient speech bubble rendered above the Tabby cat mascot.
* Pure presentation — visibility timing and quip selection live in
* {@link useTabbyBrain}; 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:** Tabby is the optional on-screen cat 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.
*
* ## 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`.
*
* ## 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 Tabby.
* @param props See {@link SpeechBubbleProps}.
*/
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
return (
<div
className="tabby-bubble tabby-bubble-enter"
role="status"
aria-live="polite"
onClick={onDismiss}
title="Dismiss"
>
{text}
</div>
);
}
+273
View File
@@ -0,0 +1,273 @@
/**
* @file Tabby.tsx
* @description Floating cat 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 useTabbyBrain; the avatar is draggable (AssistiveTouch-style) via
* useTabbyPosition, and the bubble/panel render in a self-clamping flyout so
* they never spill off any screen edge regardless of where the cat 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:** Tabby is the optional on-screen cat 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.
*
* ## 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
* - `./CatAvatar`
* - `./SpeechBubble`
* - `./TabbyPanel`
* - `./useTabbyBrain`
* - `./useTabbyPosition`
* - `./intents`
* - `./prefs`
*
* ## Public surface
* - `Tabby` — 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).
* -----------------------------------------------------------------------------
* **Tabby**
* 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 { CatAvatar } from "./CatAvatar";
import { SpeechBubble } from "./SpeechBubble";
import { TabbyPanel } from "./TabbyPanel";
import { useTabbyBrain } from "./useTabbyBrain";
import { useTabbyPosition, TABBY_SIZE } from "./useTabbyPosition";
import { matchIntent } from "./intents";
import { tabbyPrefs } from "./prefs";
import "./tabby.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 TabbyFlyout({ 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 cat (feels natural). Only drop below when
// there isn't room above - i.e. the cat 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="tabby-flyout" style={style}>
{children}
</div>
);
}
export function Tabby() {
const [enabled, setEnabled] = useState(() => tabbyPrefs.getEnabled());
const [open, setOpen] = useState(false);
const reducedMotion = usePrefersReducedMotion();
const navigate = useNavigate();
const brain = useTabbyBrain();
const place = useTabbyPosition();
// Keep enabled in sync with Settings / other tabs.
useEffect(() => tabbyPrefs.subscribe(() => setEnabled(tabbyPrefs.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 cat. */}
{!place.dragging && open && (
<TabbyFlyout anchor={anchor}>
<TabbyPanel
status={brain.status}
muted={brain.muted}
onToggleMute={brain.toggleMute}
onClearAlerts={brain.clearAlerts}
onNavigate={onNavigate}
onAsk={onAsk}
onClose={() => setOpen(false)}
/>
</TabbyFlyout>
)}
{!place.dragging && !open && brain.bubble && (
<TabbyFlyout anchor={anchor}>
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
</TabbyFlyout>
)}
<button
className="tabby-avatar-btn"
data-dragging={place.dragging ? "1" : "0"}
style={{ left: place.left, top: place.top, width: TABBY_SIZE, height: TABBY_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 Tabby" : "Open Tabby companion"}
aria-expanded={open}
title="Tabby - ⌘B · drag to move"
>
<CatAvatar mood={brain.mood} reducedMotion={reducedMotion} />
{brain.status.errorCount > 0 && (
<span className="tabby-error-dot" aria-hidden>
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
</span>
)}
</button>
</>
);
}
+285
View File
@@ -0,0 +1,285 @@
/**
* @file TabbyPanel.tsx
* @description Expanded Tabby 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:** Tabby is the optional on-screen cat 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.
*
* ## 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
* - `./brain`
*
* ## Public surface
* - `TabbyPanel` — 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).
* -----------------------------------------------------------------------------
* **TabbyPanel**
* 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 { TabbyStatus } from "./brain";
interface TabbyPanelProps {
status: TabbyStatus;
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 TabbyPanel({
status,
muted,
onToggleMute,
onClearAlerts,
onNavigate,
onAsk,
onClose,
}: TabbyPanelProps) {
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="Tabby 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-gray-100">Tabby</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-emerald-500/15 text-emerald-300" : "bg-red-500/15 text-red-300"
}`}
>
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${
status.connected ? "bg-emerald-400" : "bg-red-500"
}`}
aria-hidden
/>
{status.connected ? "Live" : "Offline"}
</span>
</div>
<button
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-surface-4 hover:text-gray-200"
onClick={onClose}
aria-label="Close Tabby"
>
<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-gray-200 placeholder-gray-500 transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
placeholder="Ask Tabby… (e.g. any errors?)"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Ask Tabby"
/>
<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-gray-300">
{answer}
</p>
)}
</div>
</div>
);
}
interface Tone {
wrap: string;
value: string;
icon: string;
}
const TONE_MUTED: Tone = {
wrap: "border-border bg-surface-1",
value: "text-gray-300",
icon: "text-gray-500",
};
const TONES: Record<string, Tone> = {
accent: { wrap: "border-accent/30 bg-accent/10", value: "text-gray-100", icon: "text-accent" },
amber: {
wrap: "border-amber-500/30 bg-amber-500/10",
value: "text-amber-200",
icon: "text-amber-400",
},
red: { wrap: "border-red-500/30 bg-red-500/10", value: "text-red-200", icon: "text-red-400" },
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-gray-500">{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-gray-300 transition-colors hover:bg-surface-4 hover:text-gray-100 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,150 @@
/**
* @file Tabby.test.tsx
* @description Render tests for the Tabby 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 { Tabby } from "../Tabby";
import { eventBus } from "../../../lib/eventBus";
import type { WSMessage, Session } from "../../../lib/types";
function renderTabby() {
return render(
<MemoryRouter>
<Tabby />
</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("Tabby widget", () => {
it("renders the avatar button by default", () => {
renderTabby();
expect(screen.getByRole("button", { name: /open tabby companion/i })).toBeInTheDocument();
expect(screen.getByRole("img", { name: /tabby/i })).toBeInTheDocument();
});
it("opens the panel on click and answers a local status question", () => {
renderTabby();
fireEvent.click(screen.getByRole("button", { name: /open tabby companion/i }));
const panel = screen.getByRole("dialog", { name: /tabby companion/i });
expect(panel).toBeInTheDocument();
const input = within(panel).getByLabelText(/ask tabby/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", () => {
renderTabby();
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", () => {
renderTabby();
act(() => {
eventBus.publish(sessionMsg("a", "error"));
});
const btn = screen.getByRole("button", { name: /open tabby companion/i });
expect(within(btn).getByText("1")).toBeInTheDocument();
});
it("reflects the live count in the panel status", () => {
renderTabby();
act(() => {
eventBus.publish(sessionMsg("a", "active"));
eventBus.publish(sessionMsg("b", "active"));
});
fireEvent.click(screen.getByRole("button", { name: /open tabby companion/i }));
const panel = screen.getByRole("dialog", { name: /tabby 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-tabby-enabled", "false");
renderTabby();
expect(screen.queryByRole("button", { name: /open tabby companion/i })).not.toBeInTheDocument();
});
it("a tap (no movement) still opens the panel", () => {
renderTabby();
const btn = screen.getByRole("button", { name: /open tabby 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: /tabby companion/i })).toBeInTheDocument();
});
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
renderTabby();
const btn = screen.getByRole("button", { name: /open tabby 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-tabby-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", () => {
renderTabby();
const btn = screen.getByRole("button", { name: /open tabby 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: /tabby companion/i })).toBeInTheDocument();
// No position was persisted because no real drag happened.
expect(localStorage.getItem("agent-dashboard-tabby-pos")).toBeNull();
});
it("restores a persisted left-edge position on mount", () => {
localStorage.setItem("agent-dashboard-tabby-pos", JSON.stringify({ side: "left", y: 0.2 }));
renderTabby();
const btn = screen.getByRole("button", { name: /open tabby companion/i }) as HTMLElement;
// Left-docked → inline left equals the edge margin (16px).
expect(btn.style.left).toBe("16px");
});
});
@@ -0,0 +1,248 @@
/**
* @file brain.test.ts
* @description Unit tests for the Tabby 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 {
initialTabbyState,
reduceTabby,
deriveMood,
statusOf,
clearErrors,
seedSessions,
HAPPY_MS,
WORRIED_MS,
STUCK_MS,
SLEEP_MS,
type TabbyState,
} 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: TabbyState = { ...initialTabbyState(T0), connected: false, worriedUntil: T0 + 9999 };
expect(deriveMood(s, T0)).toBe("disconnected");
});
it("worried outranks stuck", () => {
let s = initialTabbyState(T0);
({ state: s } = reduceTabby(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 = initialTabbyState(T0);
({ state: s } = reduceTabby(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 = initialTabbyState(T0);
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
({ state: s } = reduceTabby(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 = { ...initialTabbyState(T0), thinking: true };
expect(deriveMood(s, T0)).toBe("thinking");
});
it("watching when a session is live and recent", () => {
let s = initialTabbyState(T0);
({ state: s } = reduceTabby(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 = initialTabbyState(T0);
expect(deriveMood(s, T0 + SLEEP_MS + 1)).toBe("sleeping");
});
it("idle by default", () => {
expect(deriveMood(initialTabbyState(T0), T0)).toBe("idle");
});
});
describe("reduceTabby counts and pulses", () => {
it("tracks live count accurately across transitions", () => {
let s = initialTabbyState(T0);
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
({ state: s } = reduceTabby(s, sessionMsg("b", "active"), T0));
expect(statusOf(s).liveCount).toBe(2);
({ state: s } = reduceTabby(s, sessionMsg("a", "completed"), T0));
expect(statusOf(s).liveCount).toBe(1);
});
it("counts errored sessions and emits error pulse", () => {
let s = initialTabbyState(T0);
const r = reduceTabby(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 = initialTabbyState(T0);
const r1 = reduceTabby(s, sessionMsg("a", "active"), T0);
expect(r1.pulse).toBe("session_start");
const r2 = reduceTabby(r1.state, sessionMsg("a", "active"), T0);
expect(r2.pulse).toBe(null);
});
it("session_done pulse only when the session was tracked", () => {
let s = initialTabbyState(T0);
const untracked = reduceTabby(s, sessionMsg("ghost", "completed"), T0);
expect(untracked.pulse).toBe(null);
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
const done = reduceTabby(s, sessionMsg("a", "completed"), T0);
expect(done.pulse).toBe("session_done");
});
it("agent error triggers worried via pulse", () => {
const r = reduceTabby(initialTabbyState(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(reduceTabby(initialTabbyState(T0), agentCreatedMsg("subagent"), T0).pulse).toBe(
"subagent_spawn"
);
expect(reduceTabby(initialTabbyState(T0), agentCreatedMsg("main"), T0).pulse).toBe(null);
});
it("waiting transition emits a waiting pulse once and still counts as live", () => {
let s = initialTabbyState(T0);
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0)); // active
const first = reduceTabby(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 = reduceTabby(first.state, waitingMsg("a"), T0);
expect(second.pulse).toBe(null);
});
it("failure event types set worried, normal events do not", () => {
const fail = reduceTabby(initialTabbyState(T0), eventMsg("toolError"), T0);
expect(fail.pulse).toBe("error");
const ok = reduceTabby(initialTabbyState(T0), eventMsg("postToolUse"), T0);
expect(ok.pulse).toBe(null);
expect(ok.state.worriedUntil).toBe(0);
});
it("run_status completed exit 0 is happy, nonzero/error/killed is worried", () => {
const good = reduceTabby(
initialTabbyState(T0),
runStatusMsg({ status: "completed", exitCode: 0 }),
T0
);
expect(good.pulse).toBe("run_done");
expect(deriveMood(good.state, T0)).toBe("happy");
const bad = reduceTabby(
initialTabbyState(T0),
runStatusMsg({ status: "completed", exitCode: 1 }),
T0
);
expect(bad.pulse).toBe("error");
const err = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "error" }), T0);
expect(err.pulse).toBe("error");
const killed = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "killed" }), T0);
expect(killed.pulse).toBe("error");
const running = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "running" }), T0);
expect(running.pulse).toBe(null);
});
it("any handled message refreshes lastActivityAt", () => {
const s = { ...initialTabbyState(T0), lastActivityAt: T0 - 99999 };
const { state } = reduceTabby(s, eventMsg("postToolUse"), T0 + 5);
expect(state.lastActivityAt).toBe(T0 + 5);
});
it("ignores unrelated message types without mutating", () => {
const s = initialTabbyState(T0);
const r = reduceTabby(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(
initialTabbyState(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 = initialTabbyState(T0);
({ state: s } = reduceTabby(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 = initialTabbyState(T0);
({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0));
({ state: s } = reduceTabby(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 Tabby'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 { TabbyStatus } from "../brain";
const status = (over: Partial<TabbyStatus> = {}): TabbyStatus => ({
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 Tabby'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("");
});
});
+408
View File
@@ -0,0 +1,408 @@
/**
* @file brain.ts
* @description Pure, framework-free core of the Tabby companion. Reduces the
* dashboard's live WebSocket stream into a small mood model and derives the
* current cat 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
* (`useTabbyBrain`) 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:** Tabby is the optional on-screen cat 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.
*
* ## 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/types`
*
* ## Public surface
* - `Mood` — exported API; see TSDoc on the symbol for behavior.
* - `TabbyPulse` — exported API; see TSDoc on the symbol for behavior.
* - `TabbyStatus` — exported API; see TSDoc on the symbol for behavior.
* - `TabbyState` — 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.
* - `initialTabbyState` — 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.
* - `reduceTabby` — 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.
*
* **TabbyPulse**
* 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.
*
* **TabbyStatus**
* 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.
*
* **TabbyState**
* 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.
*
* **initialTabbyState**
* 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.
*
* **reduceTabby**
* 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 Tabby 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 `reduceTabby`.
* The hook turns pulses into transient speech bubbles. `null` means the message
* was irrelevant or non-notable.
*/
export type TabbyPulse =
| "session_done"
| "session_start"
| "subagent_spawn"
| "waiting"
| "error"
| "run_done"
| null;
export interface TabbyStatus {
/** 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 TabbyState {
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 initialTabbyState(now: number): TabbyState {
return {
connected: true,
sessions: {},
lastActivityAt: now,
happyUntil: 0,
worriedUntil: 0,
thinking: false,
};
}
export function statusOf(state: TabbyState): TabbyStatus {
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: TabbyState, 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 Tabby 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 reduceTabby(
state: TabbyState,
msg: WSMessage,
now: number
): { state: TabbyState; pulse: TabbyPulse } {
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: TabbyPulse = 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: TabbyPulse = 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 };
// A run that finished cleanly (exit 0, or no exit code reported) → happy.
if (r.status === "completed" && (r.exitCode == null || r.exitCode === 0)) {
return {
state: { ...state, happyUntil: now + HAPPY_MS, lastActivityAt: now },
pulse: "run_done",
};
}
// Errored, killed, or completed with a nonzero exit code → worried.
if (
r.status === "error" ||
r.status === "killed" ||
(r.status === "completed" && r.exitCode != null && r.exitCode !== 0)
) {
return {
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
pulse: "error",
};
}
// spawning / running → activity only.
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: TabbyState,
rows: ReadonlyArray<{ id: string; status: string; awaiting_input_since?: string | null }>,
now: number
): TabbyState {
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: TabbyState): TabbyState {
const sessions: TabbyState["sessions"] = {};
for (const [id, s] of Object.entries(state.sessions)) {
if (s !== "error") sessions[id] = s;
}
return { ...state, sessions, worriedUntil: 0 };
}
+129
View File
@@ -0,0 +1,129 @@
/**
* @file intents.ts
* @description Tabby'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:** Tabby is the optional on-screen cat 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.
*
* ## 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
* - `./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 { TabbyStatus } 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: TabbyStatus): 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() };
}
+138
View File
@@ -0,0 +1,138 @@
/**
* @file prefs.ts
* @description Tiny localStorage-backed preference store for Tabby (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:** Tabby is the optional on-screen cat 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.
*
* ## 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`.
*
* ## Public surface
* - `TabbyPos` — exported API; see TSDoc on the symbol for behavior.
* - `tabbyPrefs` — 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).
* -----------------------------------------------------------------------------
* **TabbyPos**
* 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.
*
* **tabbyPrefs**
* 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-tabby-enabled";
const MUTED_KEY = "agent-dashboard-tabby-muted";
const POS_KEY = "agent-dashboard-tabby-pos";
const EVENT = "tabby: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 (01) so it survives window resizes.
*/
export interface TabbyPos {
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(): TabbyPos | null {
try {
const raw = localStorage.getItem(POS_KEY);
if (!raw) return null;
const p = JSON.parse(raw) as Partial<TabbyPos>;
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: TabbyPos): 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 tabbyPrefs = {
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);
};
},
};
+140
View File
@@ -0,0 +1,140 @@
/**
* @file quips.ts
* @description Tabby'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:** Tabby is the optional on-screen cat 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.
*
* ## 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
* - `./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, TabbyPulse } from "./brain";
export type QuipKey = NonNullable<TabbyPulse> | 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[];
+412
View File
@@ -0,0 +1,412 @@
/**
* tabby.css - animations + mood expressions for the Tabby 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>
*/
.tabby-cat {
overflow: visible;
cursor: pointer;
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5));
}
/* ── palette ── */
.tabby-head,
.tabby-body {
fill: url(#tabbyFur);
stroke: #8b93f9;
stroke-width: 2;
}
.tabby-ear {
fill: url(#tabbyFur);
stroke: #8b93f9;
stroke-width: 2;
stroke-linejoin: round;
}
.tabby-ear-inner {
fill: #f7a8c8;
}
.tabby-paw {
fill: #4a4a72;
stroke: #8b93f9;
stroke-width: 1.5;
}
.tabby-toe {
fill: none;
stroke: #2c2c46;
stroke-width: 1.2;
stroke-linecap: round;
opacity: 0.7;
}
.tabby-stripes path {
fill: none;
stroke: #8b93f9;
stroke-width: 2;
stroke-linecap: round;
opacity: 0.55;
}
.tabby-cheeks ellipse {
fill: #f7849f;
opacity: 0.5;
}
.tabby-eye {
fill: #f3f4ff;
}
.tabby-pupil {
fill: #15131f;
}
.tabby-glint {
fill: #ffffff;
opacity: 0.95;
}
.tabby-glint-sm {
opacity: 0.75;
}
.tabby-nose {
fill: #f7849f;
}
.tabby-tail {
fill: url(#tabbyFur);
stroke: #8b93f9;
stroke-width: 2;
stroke-linejoin: round;
transform-origin: 80px 68px;
}
.tabby-whiskers path,
.tabby-brows path,
.tabby-eyes-happy path,
.tabby-eyes-closed path,
.tabby-mouth-idle,
.tabby-mouth-happy,
.tabby-mouth-worried {
fill: none;
stroke: #dfe1ff;
stroke-width: 2.2;
stroke-linecap: round;
}
.tabby-whiskers path {
stroke: #9aa0c8;
stroke-width: 1.4;
opacity: 0.8;
}
.tabby-brows path {
stroke: #8b93f9;
}
.tabby-halo {
fill: url(#tabbyHalo);
opacity: 0.18;
}
.tabby-sparkle path {
fill: #fde68a;
}
.tabby-zzz text,
.tabby-bang text {
fill: #a5b4fc;
font-family: "JetBrains Mono", monospace;
font-size: 11px;
font-weight: 700;
}
/* ── default visibility: show open eyes + idle mouth, hide the rest ── */
.tabby-eyes-happy,
.tabby-eyes-closed,
.tabby-brows,
.tabby-mouth-happy,
.tabby-mouth-worried,
.tabby-zzz,
.tabby-bang,
.tabby-sparkle {
display: none;
}
/* ── happy ── */
.tabby-cat[data-mood="happy"] .tabby-eyes-open {
display: none;
}
.tabby-cat[data-mood="happy"] .tabby-eyes-happy,
.tabby-cat[data-mood="happy"] .tabby-mouth-happy,
.tabby-cat[data-mood="happy"] .tabby-sparkle {
display: block;
}
.tabby-cat[data-mood="happy"] .tabby-mouth-idle {
display: none;
}
/* ── watching ── (alert, smiling, tail flick) */
.tabby-cat[data-mood="watching"] .tabby-mouth-idle {
display: none;
}
.tabby-cat[data-mood="watching"] .tabby-mouth-happy {
display: block;
}
/* ── worried ── (brows down, frown) */
.tabby-cat[data-mood="worried"] .tabby-brows,
.tabby-cat[data-mood="worried"] .tabby-mouth-worried {
display: block;
}
.tabby-cat[data-mood="worried"] .tabby-mouth-idle {
display: none;
}
.tabby-cat[data-mood="worried"] .tabby-cheeks ellipse {
opacity: 0.7;
}
/* ── stuck ── (alert bang, ears up) */
.tabby-cat[data-mood="stuck"] .tabby-bang {
display: block;
}
/* ── thinking ── keeps open eyes + idle mouth; head tilt handled below */
/* ── sleeping / disconnected ── (closed eyes) */
.tabby-cat[data-mood="sleeping"] .tabby-eyes-open,
.tabby-cat[data-mood="disconnected"] .tabby-eyes-open {
display: none;
}
.tabby-cat[data-mood="sleeping"] .tabby-eyes-closed,
.tabby-cat[data-mood="disconnected"] .tabby-eyes-closed {
display: block;
}
.tabby-cat[data-mood="sleeping"] .tabby-zzz {
display: block;
}
.tabby-cat[data-mood="disconnected"] {
opacity: 0.5;
filter: grayscale(0.65) drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5));
}
/* ════════ animations ════════ */
@keyframes tabby-breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.035);
}
}
@keyframes tabby-blink {
0%,
92%,
100% {
transform: scaleY(1);
}
96% {
transform: scaleY(0.1);
}
}
@keyframes tabby-shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-2px);
}
75% {
transform: translateX(2px);
}
}
@keyframes tabby-bob {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
@keyframes tabby-tail-flick {
0%,
100% {
transform: rotate(0deg);
}
50% {
transform: rotate(-12deg);
}
}
@keyframes tabby-ears-perk {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-1.5px);
}
}
@keyframes tabby-sparkle-twinkle {
0%,
100% {
transform: scale(0.6);
opacity: 0.4;
}
50% {
transform: scale(1);
opacity: 1;
}
}
/* idle: gentle breathing + occasional blink */
.tabby-cat[data-mood="idle"] {
animation: tabby-breathe 4s ease-in-out infinite;
transform-origin: 50px 60px;
}
/* Blink lives on the inner group so it never overrides the outer group's
eye-tracking translate (which would freeze the eyes - see CatAvatar). */
.tabby-cat[data-mood="idle"] .tabby-pupils-blink {
animation: tabby-blink 5s ease-in-out infinite;
transform-origin: 50px 50px;
}
/* watching: tail flicks, ears perk */
.tabby-cat[data-mood="watching"] .tabby-tail {
animation: tabby-tail-flick 1.8s ease-in-out infinite;
}
.tabby-cat[data-mood="watching"] .tabby-ears {
animation: tabby-ears-perk 1.8s ease-in-out infinite;
transform-origin: 50px 24px;
}
/* happy: head bob + twinkling sparkle */
.tabby-cat[data-mood="happy"] {
animation: tabby-bob 0.5s ease-in-out 0s 4;
transform-origin: 50px 60px;
}
.tabby-cat[data-mood="happy"] .tabby-sparkle {
animation: tabby-sparkle-twinkle 0.9s ease-in-out infinite;
transform-origin: 84px 45px;
}
/* worried: shake + puff (halo grows) */
.tabby-cat[data-mood="worried"] {
animation: tabby-shake 0.35s ease-in-out 0s 3;
transform-origin: 50px 60px;
}
.tabby-cat[data-mood="worried"] .tabby-halo {
opacity: 0.32;
}
/* stuck: ears stay perked, slow breathe */
.tabby-cat[data-mood="stuck"] {
animation: tabby-breathe 2.4s ease-in-out infinite;
transform-origin: 50px 60px;
}
.tabby-cat[data-mood="stuck"] .tabby-ears {
transform: translateY(-2px);
transform-origin: 50px 24px;
}
/* thinking: subtle head tilt */
.tabby-cat[data-mood="thinking"] {
transform: rotate(-6deg);
transform-origin: 50px 60px;
}
/* sleeping: slow breathe, droop */
.tabby-cat[data-mood="sleeping"] {
animation: tabby-breathe 5s ease-in-out infinite;
transform-origin: 50px 60px;
}
/* ── reduced motion: kill all continuous animation ── */
.tabby-cat[data-reduced="1"],
.tabby-cat[data-reduced="1"] * {
animation: none !important;
}
@media (prefers-reduced-motion: reduce) {
.tabby-cat,
.tabby-cat * {
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. */
.tabby-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;
}
.tabby-avatar-btn:hover {
transform: scale(1.06);
}
.tabby-avatar-btn:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 3px;
}
.tabby-avatar-btn[data-dragging="1"] {
cursor: grabbing;
transition: transform 0.15s ease; /* no left/top easing while dragging */
}
.tabby-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 TabbyFlyout so it never crops. */
.tabby-flyout {
position: fixed;
z-index: 40;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tabby-avatar-btn:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 3px;
}
.tabby-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;
}
.tabby-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);
}
.tabby-bubble-enter {
animation: tabby-bubble-in 0.22s ease-out;
}
@keyframes tabby-bubble-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.tabby-cat[data-reduced="1"] ~ * .tabby-bubble-enter,
.tabby-bubble.tabby-no-anim {
animation: none;
}
@@ -0,0 +1,202 @@
/**
* @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 };
}
@@ -0,0 +1,211 @@
/**
* @file useTabbyPosition.ts
* @description AssistiveTouch-style draggable docking for the Tabby 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:** 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
* - `./prefs`
*
* ## Public surface
* - `TABBY_SIZE` — exported API; see TSDoc on the symbol for behavior.
* - `TABBY_MARGIN` — exported API; see TSDoc on the symbol for behavior.
* - `TabbyPlacement` — exported API; see TSDoc on the symbol for behavior.
* - `useTabbyPosition` — 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).
* -----------------------------------------------------------------------------
* **TABBY_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.
*
* **TABBY_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.
*
* **TabbyPlacement**
* 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.
*
* **useTabbyPosition**
* 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 { tabbyPrefs, type TabbyPos } from "./prefs";
import type { PointerEvent as ReactPointerEvent } from "react";
// Avatar footprint + edge gap, in px. SIZE matches CatAvatar's default size.
export const TABBY_SIZE = 60;
export const TABBY_MARGIN = 16;
const DRAG_THRESHOLD = 5;
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
function defaultPos(): TabbyPos {
return { side: "right", y: 0.5 }; // right edge, vertically centered
}
/** Resting top-left screen coords for a docked position. */
function restingScreen(pos: TabbyPos) {
const avail = Math.max(0, vh() - TABBY_SIZE - 2 * TABBY_MARGIN);
const left = pos.side === "left" ? TABBY_MARGIN : vw() - TABBY_SIZE - TABBY_MARGIN;
const top = TABBY_MARGIN + pos.y * avail;
return { left, top };
}
export interface TabbyPlacement {
/** 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 useTabbyPosition(): TabbyPlacement {
const [pos, setPos] = useState<TabbyPos>(() => tabbyPrefs.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() - TABBY_SIZE - TABBY_MARGIN,
Math.max(TABBY_MARGIN, start.left + dx)
);
const top = Math.min(vh() - TABBY_SIZE - TABBY_MARGIN, Math.max(TABBY_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 + TABBY_SIZE / 2 < vw() / 2 ? "left" : "right";
const avail = Math.max(1, vh() - TABBY_SIZE - 2 * TABBY_MARGIN);
const y = Math.min(1, Math.max(0, (live.top - TABBY_MARGIN) / avail));
const next: TabbyPos = { side, y };
tabbyPrefs.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: TABBY_SIZE,
side: pos.side,
openUp: screen.top + TABBY_SIZE / 2 > vh() / 2,
dragging: drag !== null,
onPointerDown,
onPointerMove,
onPointerUp,
consumeDrag,
};
}