/** * @file SagiInfo.tsx * @description What the pet knows, shown inside its chat window (`PetChat`): * the name you rename by clicking it (`PetNameButton`), and a status strip * (`SagiInfo`) — live / waiting / errored counts and the connection state * always in sight, and on expanding, the sessions that need the user, by * name, plus Mute and Clear alerts. Pure presentational: all data and * behavior are injected by the container (`Sagi.tsx`). * @author Nguyễn Ngọc Trí Vĩ */ import { useState } from "react"; import { AlertTriangle, Bell, BellOff, ChevronDown, ChevronRight, Hourglass, Radio, Trash2, 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"; import { isSubmitEnter } from "../../lib/keys"; /** How many sessions the "Needs you" list shows before pointing at Sessions. */ const ATTENTION_SHOWN = 4; /** The pet's name; a click renames it in place (empty = its own name). */ export function PetNameButton({ name, ownName, nickname, onRename, }: { name: string; ownName: string; nickname: string; onRename: (name: string) => void; }) { const { t } = useTranslation("settings"); // The name being typed, or null while not renaming. const [renaming, setRenaming] = useState(null); const save = () => { if (renaming === null) return; onRename(renaming); setRenaming(null); }; return renaming === null ? ( ) : ( setRenaming(e.target.value)} onBlur={save} onKeyDown={(e) => { if (isSubmitEnter(e)) { e.preventDefault(); save(); } else if (e.key === "Escape") { // Esc cancels the rename only, not the whole chat. e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); setRenaming(null); } }} /> ); } interface SagiInfoProps { attention: ReadonlyArray; onOpenSession: (s: SagiSubject) => void; status: SagiStatus; muted: boolean; onToggleMute: () => void; onClearAlerts: () => void; onNavigate: (route: string) => void; /** Expanded at first: the sessions that need the user and the actions. */ defaultOpen?: boolean; } /** The status strip under the chat's header. */ export function SagiInfo({ attention, onOpenSession, status, muted, onToggleMute, onClearAlerts, onNavigate, defaultOpen = false, }: SagiInfoProps) { const { t } = useTranslation("settings"); const [open, setOpen] = useState(defaultOpen); const Chevron = open ? ChevronDown : ChevronRight; return (
{open && (
{/* 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 && ( )}
)}
)}
); } 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, }; /** One count, compact: icon, number, label on one line. */ function StatChip({ icon: Icon, label, value, tone, }: { icon: LucideIcon; label: string; value: number; tone: string; }) { 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 ( ); }