/** * @file Sagi.tsx * @description Floating mascot companion shell. Mounted once by `SagiMount` (next * to UpdateNotifier in Layout), which loads this chunk only while the mascot is * enabled; `Sagi` gates on the same preference itself, and the body (brain, * tick, WS reducer) runs only while it is on. 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 (a v2 Codex pet also looks toward the pointer), 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 { useSettingsHref } from "../../lib/settingsLink"; 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 { useSagiEnabled } from "./SagiMount"; import { matchIntent } from "./intents"; import { petName, sagiPrefs } from "./prefs"; import type { SagiSubject } from "./brain"; import { api } from "../../lib/api"; import { readDefaultTool } from "../../lib/sessionDefaults"; import { startInFreeTabDetailed } from "../lanes/startSession"; import { workspaceHref } from "../SessionRowMenu"; import { PetChat, petChatTool } from "./PetChat"; // Stroking: this many left/right turns of the pointer over the pet, within // STROKE_MS and with no button held, is a pat; then a pause before the next. const STROKE_TURNS = 4; const STROKE_MS = 1200; const STROKE_COOLDOWN_MS = 1500; 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. * `beside`: at the mascot's side instead of above it — the chat window, on the * screen side away from the edge it docks to, its bottom level with the pet's. */ function SagiFlyout({ anchor, children, front = false, beside = false, }: { anchor: Anchor; children: ReactNode; front?: boolean; beside?: 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; if (beside) { let left = anchor.side === "left" ? anchor.left + anchor.size + FLYOUT_GAP : anchor.left - w - FLYOUT_GAP; left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left)); let top = anchor.top + anchor.size - h; top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top)); setStyle({ left, top, visibility: "visible" }); return; } // 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, beside]); 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}
); } /** Renders nothing — and runs no hook below — while the mascot is disabled. */ export function Sagi() { return useSagiEnabled() ? : null; } function SagiBody() { const { t } = useTranslation("settings"); const [pet, setPet] = useState(() => sagiPrefs.getPet()); // The name it goes by: the user's nickname, the pet's own, else Sagi. const [name, setName] = useState(() => petName(sagiPrefs.getPet())); const [size, setSize] = useState(() => sagiPrefs.getSize()); const [open, setOpen] = useState(false); // The chat window (PetChat.tsx), and the Ask question it should send. const [chatOpen, setChatOpen] = useState(false); const [chatPrompt, setChatPrompt] = useState(null); // The right-click pet menu (`PetMenu.tsx`). const [menu, setMenu] = useState(false); const [hover, setHover] = useState(false); const reducedMotion = usePrefersReducedMotion(); const navigate = useNavigate(); const href = useSettingsHref(); const brain = useSagiBrain(); const place = useSagiPosition(size); // Keep the pet and its size in sync with Settings / other tabs (enabled is // the `Sagi` gate's: it unmounts this body when the mascot is turned off). useEffect( () => sagiPrefs.subscribe(() => { setPet(sagiPrefs.getPet()); setName(petName(sagiPrefs.getPet())); setSize(sagiPrefs.getSize()); }), [] ); // A pet worn before names rode along with the choice: look its name up once. const petId = pet.id; const unnamed = "kind" in pet && !pet.name; useEffect(() => { if (!unnamed) return; let live = true; api.pets .list() .then((list) => { const found = list.pets.find((p) => p.id === petId); const cur = sagiPrefs.getPet(); if (live && found && cur.id === petId && "kind" in cur) sagiPrefs.setPet({ ...cur, name: found.displayName }); }) .catch(() => undefined); return () => { live = false; }; }, [petId, unnamed]); // ⌘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(() => { 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); setChatOpen(false); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); // A v2 pet at idle looks toward the pointer: one of 16 poses by the // pointer's angle around it (0 = up, clockwise), dropped once it rests. // ponytail: rest after 2 s is a guess; Codex Desktop's own rule is not public. const btn = useRef(null); const [gaze, setGaze] = useState(null); const wearsSheet = "kind" in pet && pet.kind === "codex"; useEffect(() => { if (!wearsSheet || reducedMotion) return; let rest = 0; const onMove = (e: PointerEvent) => { const r = btn.current?.getBoundingClientRect(); if (!r) return; const deg = (Math.atan2(e.clientX - (r.left + r.width / 2), r.top + r.height / 2 - e.clientY) * 180) / Math.PI; setGaze(Math.round((deg + 360) / 22.5) % 16); window.clearTimeout(rest); rest = window.setTimeout(() => setGaze(null), 2000); }; window.addEventListener("pointermove", onMove); return () => { window.removeEventListener("pointermove", onMove); window.clearTimeout(rest); }; }, [wearsSheet, reducedMotion]); const onNavigate = useCallback( (route: string) => { navigate(route); setOpen(false); }, [navigate] ); // A session a bubble or the panel names: its live pane when a dashboard run // holds it, else its workspace's replay, else its analysis page. Opening // never resumes anything. const openSession = useCallback( (s: SagiSubject) => { const go = (runs?: Parameters[1]) => { navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`); brain.dismissBubble(); setOpen(false); setChatOpen(false); }; api.run .list() .then((r) => go(r.items)) .catch(() => go()); }, [brain, navigate] ); const onAsk = useCallback( (query: string): string | null => { const result = matchIntent(query, brain.status, { name, attention: brain.attention }); if (result.kind === "answer") { if (result.moment) brain.playMoment(result.moment); return result.text; } // Handoff: a question the pet cannot answer goes to the chat window, // which starts a session of the default AI tool and shows it as chat. if (petChatTool()) { setChatPrompt(result.prompt); setOpen(false); setChatOpen(true); return null; } // A default tool the chat cannot read back (not Claude/Codex): start it // in the "Sagi" workspace and open its pane there, as before. const prompt = result.prompt; brain.setThinking(true); (async () => { try { const { lane } = await api.lanes.ensure({ title: "Sagi" }); const tool = readDefaultTool(); const r = await startInFreeTabDetailed(lane.id, { prompt, ...(tool && tool !== lane.tool ? { tool } : {}), }); navigate(`/workspace?lane=${r.laneId}&run=${encodeURIComponent(r.runId)}`); 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, name] ); // Stroke the pet (move the pointer back and forth over it) to pat it. const stroke = useRef({ dir: 0, x: 0, turns: [] as number[], until: 0 }); const onStroke = (e: React.PointerEvent) => { if (e.buttons !== 0 || e.pointerType === "touch") return; const s = stroke.current; const now = performance.now(); const dx = e.clientX - s.x; s.x = e.clientX; if (Math.abs(dx) < 2 || now < s.until) return; const dir = Math.sign(dx); if (s.dir && dir !== s.dir) { s.turns = [...s.turns.filter((t0) => now - t0 < STROKE_MS), now]; if (s.turns.length >= STROKE_TURNS) { s.turns = []; s.until = now + STROKE_COOLDOWN_MS; brain.pat(); } } s.dir = dir; }; const onBusy = useCallback((b: boolean) => brain.setThinking(b), [brain.setThinking]); 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 && ( sagiPrefs.setNickname(pet.id, next)} onPat={brain.pat} attention={brain.attention} onOpenSession={openSession} status={brain.status} muted={brain.muted} onToggleMute={brain.toggleMute} onClearAlerts={brain.clearAlerts} onNavigate={onNavigate} onAsk={onAsk} onClose={() => setOpen(false)} /> )} {/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows it 1:1), and kept mounted: unmounting would detach its terminal and lose a half-typed message. */} {chatOpen && ( setChatPrompt(null)} onBack={() => { setChatOpen(false); setOpen(true); }} onClose={() => setChatOpen(false)} onOpenWorkspace={(laneId, runId) => { navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`); setChatOpen(false); }} onBusy={onBusy} /> )} {!place.dragging && menu && ( setMenu(false)} onSettings={() => { setMenu(false); navigate(href("appearance#pet")); }} /> )} {!open && !menu && !chatOpen && brain.bubble && ( )} ); }