/** * @file Sagi.tsx * @description Floating mascot 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 useSagiBrain; the avatar is draggable (AssistiveTouch-style) via * useSagiPosition, and the bubble/panel render in a self-clamping flyout so * they never spill off any screen edge regardless of where the mascot is docked. * * The "do the job" path: an unmatched Ask query starts a claude session in * the "Sagi" workspace carrying it as the first prompt, then opens that * workspace's session view (`/workspace?lane=&run=`) — no new LLM * backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not * in an editable field (a terminal's Ctrl+B is tmux's prefix). * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome. * * ## 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 * - `./SagiAvatar` * - `./SpeechBubble` * - `./SagiPanel` * - `./useSagiBrain` * - `./useSagiPosition` * - `./intents` * - `./prefs` * * ## Public surface * - `Sagi` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **Sagi** * 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 { useTranslation } from "react-i18next"; import { SagiAvatar } from "./SagiAvatar"; import { PetSprite } from "./PetSprite"; import { PetMenu } from "./PetMenu"; import { SpeechBubble } from "./SpeechBubble"; import { SagiPanel } from "./SagiPanel"; import { useSagiBrain } from "./useSagiBrain"; import { useSagiPosition } from "./useSagiPosition"; import { matchIntent } from "./intents"; import { sagiPrefs } from "./prefs"; import { api } from "../../lib/api"; import "./sagi.css"; const FLYOUT_GAP = 10; // px between avatar and flyout const VIEWPORT_MARGIN = 12; // min gap from any screen edge /** True while focus sits where typing happens (an input, a terminal's textarea). */ function isEditable(el: EventTarget | null): boolean { if (!(el instanceof HTMLElement)) return false; return ( el.isContentEditable || el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" ); } interface Anchor { left: number; top: number; size: number; side: "left" | "right"; openUp: boolean; dragging: boolean; } /** The OS "reduce motion" preference, followed live. */ export 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. */ /** `front`: above the mascot itself — the pet menu, which may overlap it. */ function SagiFlyout({ anchor, children, front = false, }: { anchor: Anchor; children: ReactNode; front?: boolean; }) { const ref = useRef(null); const [style, setStyle] = useState({ 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 mascot (feels natural). Only drop below when // there isn't room above - i.e. the mascot 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 (
{children}
); } export function Sagi() { const { t } = useTranslation("settings"); const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled()); const [pet, setPet] = useState(() => sagiPrefs.getPet()); const [size, setSize] = useState(() => sagiPrefs.getSize()); const [open, setOpen] = useState(false); // The right-click pet menu (`PetMenu.tsx`). const [menu, setMenu] = useState(false); const [hover, setHover] = useState(false); const reducedMotion = usePrefersReducedMotion(); const navigate = useNavigate(); const brain = useSagiBrain(); const place = useSagiPosition(size); // Keep enabled, the pet and its size in sync with Settings / other tabs. useEffect( () => sagiPrefs.subscribe(() => { setEnabled(sagiPrefs.getEnabled()); setPet(sagiPrefs.getPet()); setSize(sagiPrefs.getSize()); }), [] ); // ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is // shown, and never from an editable field, where Ctrl+B belongs to the field. useEffect(() => { if (!enabled) return; const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") { if (isEditable(e.target) || isEditable(document.activeElement)) return; e.preventDefault(); setOpen((v) => !v); } else if (e.key === "Escape") { setOpen(false); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [enabled]); 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: ensure the "Sagi" lane exists for this dashboard, spawn a // fresh claude session in it carrying the user's prompt as its first // message, then open that workspace's session view on the new pane. // Fire-and-forget — the panel already clears its composer on null and // closes; errors surface as an answer instead of silently swallowing. const prompt = result.prompt; brain.setThinking(true); (async () => { try { const { lane } = await api.lanes.ensure({ title: "Sagi" }); const run = await api.run.start({ laneId: lane.id, cwd: lane.cwd, initialPrompt: prompt, permissionMode: "acceptEdits", }); navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`); setOpen(false); } catch (err) { const msg = err instanceof Error ? err.message : String(err); brain.setBubble(t("sagi.startFailed", { error: msg })); } finally { brain.setThinking(false); } })(); return null; }, [brain, navigate, t] ); if (!enabled) return null; const anchor: Anchor = { left: place.left, top: place.top, size: place.size, side: place.side, openUp: place.openUp, dragging: place.dragging, }; return ( <> {/* The panel is too big to drag around, so it hides mid-drag. The bubble does not - it is Sagi talking, and it has to stay by its mouth. */} {!place.dragging && open && ( setOpen(false)} /> )} {!place.dragging && menu && ( setMenu(false)} onSettings={() => { setMenu(false); navigate("/settings/appearance#pet"); }} /> )} {!open && !menu && brain.bubble && ( )} ); }