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>
This commit is contained in:
2026-10-14 12:14:29 +07:00
parent 0c2f49876d
commit 0cb5e41faf
25 changed files with 2378 additions and 290 deletions
+212 -35
View File
@@ -2,8 +2,12 @@
* @file useSagiBrain.ts
* @description React hook that wires the pure Sagi brain to the live event bus
* and to real timers. It is the only unit that subscribes to `eventBus`. It
* exposes the derived mood, a status summary, the current speech bubble, and
* imperative controls (mute, clear alerts, set thinking) for the UI shell.
* exposes the derived mood, a status summary, the sessions that need the user,
* the current speech bubble (naming the session it is about, and coalescing a
* burst of the same event into one count), the pet's one-shot animation
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
* for the UI shell. It also greets once per browser session and makes an
* idle pet fidget now and then.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -62,108 +66,235 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { eventBus } from "../../lib/eventBus";
import { api } from "../../lib/api";
import i18n from "../../i18n";
import type { WSMessage } from "../../lib/types";
import {
initialSagiState,
reduceSagi,
deriveMood,
statusOf,
attentionOf,
clearErrors,
seedSessions,
subjectLabel,
MOMENT_OF_PULSE,
type AttentionItem,
type Mood,
type PetMoment,
type SagiPulse,
type SagiState,
type SagiStatus,
type SagiSubject,
} from "./brain";
import { pickQuip } from "./quips";
import { greetingKey, pickLine, pickQuip } from "./quips";
import { statusSummary } from "./intents";
import { sagiPrefs } from "./prefs";
const BUBBLE_MS = 4500;
// A bubble that names a session stays long enough to click it.
const SUBJECT_BUBBLE_MS = 7000;
// Minimum gap between non-error bubbles, so a burst of activity doesn't spam.
const BUBBLE_THROTTLE_MS = 3000;
// The same pulse again within this window joins the bubble on show as a count.
const COALESCE_MS = 8000;
// How long a moment (wave, hop, flinch…) plays over the mood.
const MOMENT_MS = 2400;
// An idle pet fidgets (waves or looks around) once in a while.
const FIDGET_MIN_MS = 45_000;
const FIDGET_SPAN_MS = 60_000;
// Greet once per browser session, shortly after the snapshot lands.
const GREETED_KEY = "agenthub.pet.greeted";
const GREET_DELAY_MS = 1200;
/** What the speech bubble shows. */
export interface SagiBubble {
text: string;
/** Which session(s) it is about, shown under the text. */
detail?: string;
/** The one session it is about, which a click opens. */
subject?: SagiSubject;
/** Errors and waits are drawn with an accent. */
tone?: "error" | "waiting";
}
export interface SagiBrain {
mood: Mood;
status: SagiStatus;
bubble: string | null;
/** Errored, then waiting sessions, named. */
attention: AttentionItem[];
bubble: SagiBubble | null;
dismissBubble: () => void;
/** Imperative bubble setter for one-off messages from the shell (e.g. spawn
* errors). Bypasses the throttle because the caller decided this message
* must be seen right now. Auto-clears after the standard bubble TTL. */
setBubble: (text: string) => void;
/** The one-shot animation playing now, if any; `key` changes per moment so a
* repeat of the same one restarts it. */
moment: { kind: PetMoment; key: number } | null;
/** Play a moment by hand (an answer, a pat). */
playMoment: (kind: PetMoment) => void;
/** Pat the pet: a hop, hearts and a happy line right on the pet — shown
* even while the panel or chat is open, and while muted (it was asked). */
pat: () => void;
/** The pet's answer to a pat, drawn on the pet itself; `key` restarts it. */
reaction: { text: string; key: number } | null;
muted: boolean;
toggleMute: () => void;
clearAlerts: () => void;
setThinking: (v: boolean) => void;
}
type Burst = { pulse: NonNullable<SagiPulse>; count: number; subjects: SagiSubject[]; at: number };
function burstBubble(b: Burst): SagiBubble {
const tone = b.pulse === "error" ? "error" : b.pulse === "waiting" ? "waiting" : undefined;
const one = b.subjects.length === 1 ? b.subjects[0] : undefined;
const labels = b.subjects.map(subjectLabel);
const detail =
labels.length > 2
? i18n.t("settings:sagi.bubbleMore", {
list: labels.slice(0, 2).join(", "),
count: labels.length - 2,
})
: labels.join(", ") || undefined;
const text =
b.count > 1 ? i18n.t(`settings:sagi.notify.${b.pulse}`, { count: b.count }) : pickQuip(b.pulse);
return { text, detail, subject: one, tone };
}
export function useSagiBrain(): SagiBrain {
const now0 = Date.now();
// Start optimistically connected (idle, open eyes) so the cursor-tracking
// eyes are live from the first frame instead of after the WebSocket finishes
// its initial handshake. onConnection below corrects this if it's truly down.
const [state, setState] = useState<SagiState>(() => ({
const [state, setStateRaw] = useState<SagiState>(() => ({
...initialSagiState(now0),
connected: true,
dismissed: sagiPrefs.getDismissed(),
}));
// The reducer runs OUTSIDE React's updater (which StrictMode calls twice),
// so a pulse notifies exactly once; this ref is the state it folds into.
const stateRef = useRef(state);
const update = useCallback((fn: (prev: SagiState) => SagiState) => {
const next = fn(stateRef.current);
if (next === stateRef.current) return;
stateRef.current = next;
setStateRaw(next);
}, []);
const [tick, setTick] = useState(now0);
const [bubble, setBubbleState] = useState<string | null>(null);
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
const lastBubbleAt = useRef(0);
const burst = useRef<Burst | null>(null);
const momentKey = useRef(0);
const mutedRef = useRef(muted);
mutedRef.current = muted;
// Keep cleared alerts cleared across reloads.
useEffect(() => sagiPrefs.setDismissed(state.dismissed), [state.dismissed]);
// Keep mute in sync with the Settings page / other tabs.
useEffect(() => sagiPrefs.subscribe(() => setMuted(sagiPrefs.getMuted())), []);
const showBubble = useCallback((text: string, force: boolean) => {
if (!text) return;
if (mutedRef.current) return;
const t = Date.now();
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
lastBubbleAt.current = t;
const putBubble = useCallback((b: SagiBubble, ms = BUBBLE_MS) => {
clearTimeout(bubbleTimer.current);
setBubbleState(text);
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
setBubbleState(b);
bubbleTimer.current = setTimeout(() => {
setBubbleState(null);
burst.current = null;
}, ms);
}, []);
const playMoment = useCallback((kind: PetMoment) => {
clearTimeout(momentTimer.current);
setMoment({ kind, key: ++momentKey.current });
momentTimer.current = setTimeout(() => setMoment(null), MOMENT_MS);
}, []);
const notify = useCallback(
(pulse: NonNullable<SagiPulse>, subject?: SagiSubject) => {
playMoment(MOMENT_OF_PULSE[pulse]);
if (mutedRef.current) return;
const t = Date.now();
const b = burst.current;
// The same kind of news again, while its bubble is still up: count it in.
if (b && b.pulse === pulse && t - b.at < COALESCE_MS) {
b.count++;
b.at = t;
if (subject && !b.subjects.some((s) => s.id === subject.id)) b.subjects.push(subject);
putBubble(burstBubble(b), SUBJECT_BUBBLE_MS);
return;
}
const force = pulse === "error" || pulse === "waiting";
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
lastBubbleAt.current = t;
const next: Burst = { pulse, count: 1, subjects: subject ? [subject] : [], at: t };
burst.current = next;
putBubble(burstBubble(next), subject ? SUBJECT_BUBBLE_MS : BUBBLE_MS);
},
[playMoment, putBubble]
);
// Seed from the REST snapshot on mount so counts are accurate immediately -
// the brain otherwise only learns about sessions from WS deltas that arrive
// after it mounts, showing "0 live" on a fresh load even when sessions exist.
// Pull a generous page of non-finished sessions; live WS deltas refine it.
// Then say hello, once per browser session.
useEffect(() => {
let cancelled = false;
let greetTimer: ReturnType<typeof setTimeout> | undefined;
const greet = () => {
try {
if (sessionStorage.getItem(GREETED_KEY)) return;
sessionStorage.setItem(GREETED_KEY, "1");
} catch {
return; // No storage: rather silent than a hello on every page.
}
greetTimer = setTimeout(() => {
if (cancelled || mutedRef.current) return;
const hello = pickLine(`greet.${greetingKey(new Date().getHours())}`);
if (!hello) return;
lastBubbleAt.current = Date.now();
putBubble({ text: hello, detail: statusSummary(statusOf(stateRef.current)) });
playMoment("wave");
}, GREET_DELAY_MS);
};
api.sessions
.list({ status: "active", limit: 100 })
.then((res) => {
if (cancelled) return;
setState((prev) => seedSessions(prev, res.sessions, Date.now()));
update((prev) => seedSessions(prev, res.sessions, Date.now()));
})
.catch(() => undefined);
.catch(() => undefined)
.finally(greet);
return () => {
cancelled = true;
clearTimeout(greetTimer);
};
}, []);
}, [update, putBubble, playMoment]);
// Subscribe to the live stream and connection status.
useEffect(() => {
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
const t = Date.now();
setState((prev) => {
const { state: next, pulse } = reduceSagi(prev, msg, t);
if (pulse) showBubble(pickQuip(pulse), pulse === "error");
return next;
});
const { state: next, pulse, subject } = reduceSagi(stateRef.current, msg, Date.now());
update(() => next);
if (pulse) notify(pulse, subject);
});
const unsubConn = eventBus.onConnection((connected) => {
setState((prev) => ({ ...prev, connected }));
update((prev) => (prev.connected === connected ? prev : { ...prev, connected }));
});
return () => {
unsubMsg();
unsubConn();
};
}, [showBubble]);
}, [update, notify]);
// Advance the clock so timed moods (stuck/sleeping, and exit from
// happy/worried) re-evaluate without needing a new event.
@@ -175,14 +306,42 @@ export function useSagiBrain(): SagiBrain {
return () => clearInterval(id);
}, []);
useEffect(() => () => clearTimeout(bubbleTimer.current), []);
useEffect(
() => () => {
clearTimeout(bubbleTimer.current);
clearTimeout(momentTimer.current);
clearTimeout(reactionTimer.current);
},
[]
);
const mood = useMemo(() => deriveMood(state, tick), [state, tick]);
const status = useMemo(() => statusOf(state), [state]);
const attention = useMemo(() => attentionOf(state), [state]);
// An idle pet is not a statue: every minute or so it waves or looks around.
const moodRef = useRef(mood);
moodRef.current = mood;
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const schedule = () => {
timer = setTimeout(
() => {
if (moodRef.current === "idle" && document.visibilityState === "visible")
playMoment(Math.random() < 0.5 ? "wave" : "review");
schedule();
},
FIDGET_MIN_MS + Math.random() * FIDGET_SPAN_MS
);
};
schedule();
return () => clearTimeout(timer);
}, [playMoment]);
const dismissBubble = useCallback(() => {
clearTimeout(bubbleTimer.current);
setBubbleState(null);
burst.current = null;
}, []);
const toggleMute = useCallback(() => {
@@ -192,29 +351,47 @@ export function useSagiBrain(): SagiBrain {
if (next) dismissBubble();
}, [dismissBubble]);
const clearAlerts = useCallback(() => setState((prev) => clearErrors(prev)), []);
const clearAlerts = useCallback(() => {
update((prev) => clearErrors(prev));
if (burst.current?.pulse === "error") dismissBubble();
}, [update, dismissBubble]);
const setThinking = useCallback(
(v: boolean) => setState((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })),
[]
(v: boolean) => update((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })),
[update]
);
// One-shot bubble: clears itself after BUBBLE_MS. Used by the shell for
// imperative messages (e.g. spawn errors) that must appear now and fade,
// bypassing the throttled mood-driven showBubble path.
const setBubble = useCallback((text: string) => {
if (!text) return;
clearTimeout(bubbleTimer.current);
setBubbleState(text);
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
}, []);
const setBubble = useCallback(
(text: string) => {
if (!text) return;
burst.current = null;
putBubble({ text });
},
[putBubble]
);
const pat = useCallback(() => {
playMoment("jump");
update((prev) => ({ ...prev, happyUntil: Date.now() + MOMENT_MS }));
clearTimeout(reactionTimer.current);
setReaction({ text: pickLine("pat"), key: ++momentKey.current });
reactionTimer.current = setTimeout(() => setReaction(null), MOMENT_MS);
}, [playMoment, update]);
return {
mood,
status,
attention,
bubble,
dismissBubble,
setBubble,
moment,
playMoment,
pat,
reaction,
muted,
toggleMute,
clearAlerts,