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
+145 -37
View File
@@ -50,9 +50,6 @@
import { useState, type FormEvent, type ReactNode } from "react";
import {
Play,
Activity,
LayoutList,
Bell,
BellOff,
Trash2,
@@ -61,12 +58,30 @@ import {
AlertTriangle,
Hourglass,
Radio,
Heart,
ChevronRight,
MessageCircle,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SagiStatus } from "./brain";
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<AttentionItem>;
onOpenSession: (s: SagiSubject) => void;
status: SagiStatus;
muted: boolean;
onToggleMute: () => void;
@@ -74,21 +89,38 @@ interface SagiPanelProps {
onNavigate: (route: string) => void;
/** Returns an answer to display, or null when the query was handed off. */
onAsk: (query: string) => string | null;
/** Opens the chat window (PetChat). */
onOpenChat: () => void;
onClose: () => void;
}
export function SagiPanel({
name,
ownName,
nickname,
onRename,
onPat,
attention,
onOpenSession,
status,
muted,
onToggleMute,
onClearAlerts,
onNavigate,
onAsk,
onOpenChat,
onClose,
}: SagiPanelProps) {
const { t } = useTranslation("settings");
const [query, setQuery] = useState("");
const [answer, setAnswer] = useState<string | null>(null);
// The name being typed, or null while not renaming.
const [renaming, setRenaming] = useState<string | null>(null);
const saveName = () => {
if (renaming === null) return;
onRename(renaming);
setRenaming(null);
};
const submit = (e: FormEvent) => {
e.preventDefault();
@@ -101,7 +133,7 @@ export function SagiPanel({
<div
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl backdrop-blur-md animate-slide-up"
role="dialog"
aria-label={t("sagi.panel.label")}
aria-label={t("sagi.panel.label", { name })}
>
{/* header */}
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
@@ -109,7 +141,40 @@ export function SagiPanel({
<span className="text-base leading-none" aria-hidden>
🐾
</span>
<span className="text-sm font-semibold text-fg-primary">Sagi</span>
{renaming === null ? (
<button
type="button"
className="min-w-0 truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
onClick={() => setRenaming(nickname)}
aria-label={t("sagi.panel.rename", { name })}
title={t("sagi.panel.rename", { name })}
>
{name}
</button>
) : (
<input
autoFocus
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
value={renaming}
maxLength={PET_NAME_MAX}
placeholder={ownName}
aria-label={t("sagi.panel.renameLabel")}
title={t("sagi.panel.renameHint")}
onChange={(e) => 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);
}
}}
/>
)}
<span
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
status.connected
@@ -126,13 +191,23 @@ export function SagiPanel({
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
</span>
</div>
<button
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
onClick={onClose}
aria-label={t("sagi.panel.close")}
>
<X size={15} />
</button>
<div className="flex shrink-0 items-center">
<button
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
onClick={onPat}
aria-label={t("sagi.panel.pat", { name })}
title={t("sagi.panel.pat", { name })}
>
<Heart size={15} />
</button>
<button
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
onClick={onClose}
aria-label={t("sagi.panel.close", { name })}
>
<X size={15} />
</button>
</div>
</div>
<div className="p-3">
@@ -158,29 +233,62 @@ export function SagiPanel({
/>
</div>
{/* the sessions that need the user, by name */}
{attention.length > 0 && (
<div className="mb-3">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">
{t("sagi.panel.attention")}
</div>
<ul className="space-y-1">
{attention.slice(0, ATTENTION_SHOWN).map((item) => (
<li key={item.id}>
<button
className="group flex w-full items-center gap-2 rounded-lg bg-surface-1 px-2 py-1.5 text-left text-xs transition-colors hover:bg-surface-4"
onClick={() => onOpenSession(item)}
title={item.cwd ?? item.id}
>
{item.kind === "error" ? (
<AlertTriangle
size={13}
className="shrink-0 text-status-danger"
aria-hidden
/>
) : (
<Hourglass size={13} className="shrink-0 text-status-warning" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-fg-secondary group-hover:text-fg-primary">
{subjectLabel(item)}
</span>
<span className="shrink-0 text-[10px] text-fg-muted">
{item.kind === "error"
? t("sagi.panel.attentionError")
: t("sagi.panel.attentionWaiting")}
</span>
<ChevronRight size={12} className="shrink-0 text-fg-muted" aria-hidden />
</button>
</li>
))}
</ul>
{attention.length > ATTENTION_SHOWN && (
<button
className="mt-1 text-[11px] text-accent hover:underline"
onClick={() => onNavigate("/sessions")}
>
{t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
</button>
)}
</div>
)}
{/* quick actions */}
<div className="mb-3 grid grid-cols-2 gap-1.5">
<ActionButton
icon={Play}
label={t("sagi.panel.runClaude")}
onClick={() => onNavigate("/workspace")}
/>
<ActionButton
icon={Activity}
label={t("sagi.panel.activity")}
onClick={() => onNavigate("/activity")}
/>
<ActionButton
icon={LayoutList}
label={t("sagi.panel.sessions")}
onClick={() => onNavigate("/sessions")}
/>
<ActionButton
icon={AlertTriangle}
label={t("sagi.panel.errored")}
disabled={status.errorCount === 0}
onClick={() => onNavigate("/sessions")}
/>
<div className="col-span-2">
<ActionButton
icon={MessageCircle}
label={t("sagi.panel.chat", { name })}
onClick={onOpenChat}
/>
</div>
<ActionButton
icon={muted ? BellOff : Bell}
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
@@ -198,10 +306,10 @@ export function SagiPanel({
<form onSubmit={submit} className="flex items-center gap-1.5">
<input
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
placeholder={t("sagi.panel.askPlaceholder")}
placeholder={t("sagi.panel.askPlaceholder", { name })}
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label={t("sagi.panel.ask")}
aria-label={t("sagi.panel.ask", { name })}
/>
<button
type="submit"
@@ -284,7 +392,7 @@ function ActionButton({
}) {
return (
<button
className="flex items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
className="flex w-full items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
onClick={onClick}
disabled={disabled}
>