Files
AgentHub/client/src/components/Sagi/Sagi.tsx
T
nntrivi2001 0cb5e41faf feat(pet): cleared alerts stay cleared, contextual bubbles, i18n, moments, chat window
- Fix: an errored session alerted on every update, and Clear alerts only
  dropped it in memory, so its next update (or a reload) brought the alert
  straight back. An error now alerts on the transition into error only;
  cleared sessions are remembered (agenthub.pet.dismissedErrors) and stay
  quiet until they recover and fail anew.
- Quips, greetings, notifications and Ask answers move to i18n (EN/VI);
  Ask understands Vietnamese keywords and names the sessions it counts.
- The pet goes by the worn pet's name; click it in the panel to rename
  (agenthub.pet.names, synced to the account).
- Bubbles name the session they are about, with an Open button (never a
  resume); a burst of the same event coalesces into one count.
- Moments: a new session waves, a finish hops, a subagent peeks, a wait taps,
  an error flinches — on Sagi, Codex sprites and still images. Idle fidgets,
  a once-per-session greeting, a "Needs you" list, an amber waiting dot.
- Pat (heart button, Ask, or stroking the pet) reacts on the pet itself, so
  it shows while the panel or chat is open.
- Panel drops Run Claude / Activity / Sessions / Errored.
- Ask hands off to a chat window (PetChat) on the default AI tool
  (Claude/Codex): transcript as bubbles, follow-ups and CLI menus through an
  off-screen attached terminal; no new server route.

Verified on HEAD + this diff in a clean worktree: tsc -b, the full client
suite (1334/1335; the one failure, ConversationView.follow, is flaky under
load and passes 3/3 alone) and server me-prefs.test.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:14:29 +07:00

574 lines
21 KiB
TypeScript

/**
* @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=<id>&run=<runId>`) — 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ĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* 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. */
function SagiFlyout({
anchor,
children,
front = false,
}: {
anchor: Anchor;
children: ReactNode;
front?: boolean;
}) {
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 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 (
<div
ref={ref}
className="sagi-flyout"
// Mirrors the avatar button: eased left/top so the flyout glides along
// with the mascot when it snaps to an edge, 1:1 (no easing) mid-drag.
data-dragging={anchor.dragging ? "1" : "0"}
data-front={front ? "1" : undefined}
style={style}
>
{children}
</div>
);
}
/** Renders nothing — and runs no hook below — while the mascot is disabled. */
export function Sagi() {
return useSagiEnabled() ? <SagiBody /> : 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<string | null>(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<HTMLButtonElement>(null);
const [gaze, setGaze] = useState<number | null>(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<typeof workspaceHref>[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 && (
<SagiFlyout anchor={anchor}>
<SagiPanel
name={name}
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
nickname={sagiPrefs.getNickname(pet.id)}
onRename={(next) => 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}
onOpenChat={() => {
setOpen(false);
setChatOpen(true);
}}
onClose={() => setOpen(false)}
/>
</SagiFlyout>
)}
{/* Kept mounted while dragging (only hidden): unmounting would detach
its terminal and lose a half-typed message. */}
{chatOpen && (
<div style={place.dragging ? { display: "none" } : undefined}>
<SagiFlyout anchor={anchor}>
<PetChat
name={name}
initialPrompt={chatPrompt}
onPromptTaken={() => setChatPrompt(null)}
onBack={() => {
setChatOpen(false);
setOpen(true);
}}
onClose={() => setChatOpen(false)}
onOpenWorkspace={(laneId, runId) => {
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
setChatOpen(false);
}}
onBusy={onBusy}
/>
</SagiFlyout>
</div>
)}
{!place.dragging && menu && (
<SagiFlyout anchor={anchor} front>
<PetMenu
pet={pet}
size={size}
onClose={() => setMenu(false)}
onSettings={() => {
setMenu(false);
navigate(href("appearance#pet"));
}}
/>
</SagiFlyout>
)}
{!open && !menu && !chatOpen && brain.bubble && (
<SagiFlyout anchor={anchor}>
<SpeechBubble
bubble={brain.bubble}
onDismiss={brain.dismissBubble}
onOpen={openSession}
/>
</SagiFlyout>
)}
<button
ref={btn}
className="sagi-avatar-btn"
data-dragging={place.dragging ? "1" : "0"}
style={{ left: place.left, top: place.top, width: size, height: size }}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
onPointerDown={place.onPointerDown}
onPointerMove={(e) => {
place.onPointerMove(e);
onStroke(e);
}}
onPointerUp={place.onPointerUp}
onKeyDown={place.onKeyDown}
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;
if (chatOpen) setChatOpen(false);
else setOpen((v) => !v);
}}
// Right-click: the pet menu (hide, size, choose, upload, import).
onContextMenu={(e) => {
e.preventDefault();
setOpen(false);
setChatOpen(false);
setMenu(true);
}}
aria-label={open || chatOpen ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
aria-expanded={open || chatOpen}
title={t("sagi.avatarTitle", { name })}
>
{pet.id === "sagi" || !("kind" in pet) ? (
<SagiAvatar
mood={brain.mood}
moment={reducedMotion ? null : brain.moment?.kind}
reducedMotion={reducedMotion}
size={size}
/>
) : (
<PetSprite
id={pet.id}
kind={pet.kind}
mood={brain.mood}
// Dragged → runs that way; hovered → waves (Codex's pet does both).
// A moment (a pat's hop) outranks the hover wave, or stroking the
// pet would only ever show it waving.
action={place.moving ? `run-${place.moving}` : hover && !brain.moment ? "wave" : null}
moment={brain.moment?.kind}
gaze={gaze}
size={size}
reducedMotion={reducedMotion}
// Deleted elsewhere, or unreadable: back to Sagi.
onBroken={() => sagiPrefs.setPet({ id: "sagi" })}
/>
)}
{brain.reaction && (
<span key={brain.reaction.key} className="sagi-reaction" aria-live="polite">
<span className="sagi-hearts" aria-hidden>
<span>💜</span>
<span>💗</span>
<span>💜</span>
</span>
<span className="sagi-reaction-text" data-side={place.side}>
{brain.reaction.text}
</span>
</span>
)}
{brain.status.errorCount > 0 ? (
<span className="sagi-error-dot" aria-hidden>
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
</span>
) : (
brain.status.waitingCount > 0 && (
<span className="sagi-error-dot" data-kind="waiting" aria-hidden>
{brain.status.waitingCount > 9 ? "9+" : brain.status.waitingCount}
</span>
)
)}
</button>
</>
);
}