0cb5e41faf
- 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>
574 lines
21 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|