/** * @file SagiPanel.tsx * @description Expanded Sagi 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ĩ */ /* ============================================================================= * 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 * - `./brain` * * ## Public surface * - `SagiPanel` — 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). * ----------------------------------------------------------------------------- * **SagiPanel** * 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 { Bell, BellOff, Trash2, X, Send, AlertTriangle, Hourglass, Radio, Heart, ChevronRight, type LucideIcon, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain"; import { PET_NAME_MAX } from "./prefs"; /** How many sessions the "Needs you" list shows before pointing at Sessions. */ const ATTENTION_SHOWN = 4; interface SagiPanelProps { /** The name the pet goes by now. */ name: string; /** The pet's own name, which an empty nickname falls back to. */ ownName: string; /** The user's nickname for the pet, "" when none. */ nickname: string; onRename: (name: string) => void; onPat: () => void; /** Errored, then waiting sessions, named. */ attention: ReadonlyArray; onOpenSession: (s: SagiSubject) => void; status: SagiStatus; 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 SagiPanel({ name, ownName, nickname, onRename, onPat, attention, onOpenSession, status, muted, onToggleMute, onClearAlerts, onNavigate, onAsk, onClose, }: SagiPanelProps) { const { t } = useTranslation("settings"); const [query, setQuery] = useState(""); const [answer, setAnswer] = useState(null); // The name being typed, or null while not renaming. const [renaming, setRenaming] = useState(null); const saveName = () => { if (renaming === null) return; onRename(renaming); setRenaming(null); }; const submit = (e: FormEvent) => { e.preventDefault(); const result = onAsk(query); setAnswer(result); // null means it handed off (container navigates/closes) setQuery(""); }; return (
{/* header */}
🐾 {renaming === null ? ( ) : ( setRenaming(e.target.value)} onBlur={saveName} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); saveName(); } else if (e.key === "Escape") { // Esc cancels the rename only, not the whole panel. e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); setRenaming(null); } }} /> )} {status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
{/* status stat chips */}
0 ? "accent" : "muted"} /> 0 ? "amber" : "muted"} /> 0 ? "red" : "muted"} />
{/* the sessions that need the user, by name */} {attention.length > 0 && (
{t("sagi.panel.attention")}
    {attention.slice(0, ATTENTION_SHOWN).map((item) => (
  • ))}
{attention.length > ATTENTION_SHOWN && ( )}
)} {/* quick actions */}
{/* ask */}
setQuery(e.target.value)} aria-label={t("sagi.panel.ask", { name })} />
{answer && (

{answer}

)}
); } interface Tone { wrap: string; value: string; icon: string; } const TONE_MUTED: Tone = { wrap: "border-border bg-surface-1", value: "text-fg-secondary", icon: "text-fg-muted", }; const TONES: Record = { accent: { wrap: "border-accent/30 bg-accent/10", value: "text-fg-primary", icon: "text-accent" }, amber: { wrap: "border-status-warning/30 bg-status-warning/10", value: "text-status-warning", icon: "text-status-warning", }, red: { wrap: "border-status-danger/30 bg-status-danger/10", value: "text-status-danger", icon: "text-status-danger", }, 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 (
{value} {label}
); } function ActionButton({ icon: Icon, label, onClick, disabled, }: { icon: LucideIcon; label: string; onClick: () => void; disabled?: boolean; }) { return ( ); }