7357070fb9
Deletes desktop/ (Electron wrapper), deployments/ (Helm/Kustomize/ Terraform/CI for cloud deploy), and monitoring/ (Prometheus + Grafana stack) along with DESKTOP.md, DEPLOYMENT.md, docker-compose.full.yml, their npm scripts, and every dangling reference across README, ARCHITECTURE, INSTALL, SETUP, docs/, and the repeated per-file MODULE_GUIDE "Observability" boilerplate comment. The GET /api/metrics endpoint itself is untouched — it's the dashboard's own route, not part of the removed monitoring stack.
269 lines
9.8 KiB
TypeScript
269 lines
9.8 KiB
TypeScript
/**
|
|
* @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.
|
|
*
|
|
* ## 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>
|
|
</>
|
|
);
|
|
}
|