From 0cb5e41fafb581d69905ff32690cb17d9ef688ad Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 14 Oct 2026 12:14:29 +0700 Subject: [PATCH] feat(pet): cleared alerts stay cleared, contextual bubbles, i18n, moments, chat window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ARCHITECTURE.md | 23 +- client/src/components/Sagi/PetChat.tsx | 541 ++++++++++++++++++ client/src/components/Sagi/PetSprite.tsx | 19 +- client/src/components/Sagi/Sagi.tsx | 207 ++++++- client/src/components/Sagi/SagiAvatar.tsx | 6 +- client/src/components/Sagi/SagiPanel.tsx | 182 ++++-- client/src/components/Sagi/SpeechBubble.tsx | 40 +- .../components/Sagi/__tests__/PetChat.test.ts | 46 ++ .../components/Sagi/__tests__/Pets.test.tsx | 10 +- .../components/Sagi/__tests__/Sagi.test.tsx | 83 +++ .../components/Sagi/__tests__/brain.test.ts | 37 ++ .../components/Sagi/__tests__/intents.test.ts | 23 + client/src/components/Sagi/brain.ts | 177 +++++- client/src/components/Sagi/intents.ts | 157 +++-- client/src/components/Sagi/prefs.ts | 66 ++- client/src/components/Sagi/quips.ts | 117 ++-- client/src/components/Sagi/sagi.css | 283 +++++++++ client/src/components/Sagi/usePets.ts | 2 +- client/src/components/Sagi/useSagiBrain.ts | 247 ++++++-- client/src/components/run/PaneChat.tsx | 2 +- client/src/i18n/locales/en/settings.json | 196 ++++++- client/src/i18n/locales/vi/settings.json | 196 ++++++- client/src/lib/userPrefs.ts | 1 + docs/API.md | 6 +- server/routes/me.js | 1 + 25 files changed, 2378 insertions(+), 290 deletions(-) create mode 100644 client/src/components/Sagi/PetChat.tsx create mode 100644 client/src/components/Sagi/__tests__/PetChat.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 844d7194..ce7fe789 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2354,7 +2354,7 @@ graph TD SHELL["Sagi.tsx
shell: open/closed state,
⌘B / Esc, reduced-motion,
navigation"] AVATAR["SagiAvatar.tsx
SVG mascot; data-mood drives CSS;
cursor-tracking pupils"] BUBBLE["SpeechBubble.tsx
bubble"] - PANEL["SagiPanel.tsx
status + quick actions + Ask box"] + PANEL["SagiPanel.tsx
status + Needs you + Mute / Clear alerts + Ask box"] CSS["sagi.css
keyframes + per-mood expressions"] end @@ -2401,16 +2401,17 @@ The mood state machine in `deriveMood` resolves to a single expression using a f | Component | Responsibility | | --- | --- | -| **`brain.ts`** | Pure, framework-free core. Exposes a `WSMessage` reducer (`reduceSagi`) and a mood state machine (`deriveMood`) with the priority order `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation. | -| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech-bubble queue, mute, and clear-alerts. Produces the derived `{ mood, status, bubble }` the presentational components render. | +| **`brain.ts`** | Pure, framework-free core. Exposes a `WSMessage` reducer (`reduceSagi`, which also returns the `subject` session a pulse is about — name, cwd, lane) and a mood state machine (`deriveMood`) with the priority order `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. An error alerts on the transition INTO `error` only, and **Clear alerts** (`clearErrors`) moves the errored sessions into `dismissed`: their later updates, agent errors and failure events stay silent until the session goes active again or ends. `attentionOf` lists errored then waiting sessions by name; `MOMENT_OF_PULSE` maps each pulse to a pet animation. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation. | +| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech bubble (naming the session it is about; the same pulse again within 8 s joins it as a count), the one-shot `moment` animation, a once-per-browser-session greeting, an idle fidget every minute or so, pat, mute, and clear-alerts (the dismissed ids persist in `localStorage` `agenthub.pet.dismissedErrors`, newest 200). The reducer runs outside React's updater so StrictMode never doubles a pulse. Produces `{ mood, status, attention, bubble, moment }`. | | **`SagiAvatar.tsx`** | Pure presentational SVG mascot. The `data-mood` attribute drives CSS; pupils track the cursor. Rendered at 90×90 px (`size` default, kept in sync with `SAGI_SIZE` in `useSagiPosition.ts`, which is the draggable button's real footprint) through a `viewBox` cropped to the artwork's measured bounding box instead of the exporter's padded `0 0 1280 1280` canvas. Path data is never rescaled, so `MAX_PUPIL_SHIFT` stays in the original 1280-unit space. The `transform-origin` values in `sagi.css` do **not**: an origin on an inner SVG element resolves against `transform-box: view-box`, whose 0,0 sits at the `viewBox` min (`253.5 106`), so those are mascot coordinates **minus that offset**. The root `.sagi-avatar` origin is a percentage on purpose — on the outermost `` a length would be measured against the 68 px CSS box, and the resulting ~9× overshoot turns every mood animation into a whole-avatar lurch. | -| **`SpeechBubble.tsx`** | Pure presentational speech bubble. | -| **`SagiPanel.tsx`** | Pure presentational panel: status readout + quick actions + the Ask box. | -| **`Sagi.tsx`** | Shell component. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. | -| **`intents.ts`** | Pure local Q&A over the cached status snapshot. Queries that don't match a local intent become a handoff: `Sagi.tsx` calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. | -| **`quips.ts`** | Pure mood → phrase pools. | -| **`prefs.ts`** | `localStorage`-backed enabled / muted preferences with cross-tab sync. | -| **`sagi.css`** | Keyframes and per-mood expressions; selected via the `data-mood` attribute. | +| **`SpeechBubble.tsx`** | Pure presentational speech bubble: the line, the session(s) it is about, and an **Open** button for a single one (`workspaceHref` — its live pane, else its replay, else `/sessions/`; never a resume). | +| **`SagiPanel.tsx`** | Pure presentational panel: the pet's name (click to rename — `agenthub.pet.names`, synced to the account), a pat button, status readout, a **Needs you** list of errored/waiting sessions by name, Mute and Clear alerts, and the Ask box. | +| **`PetChat.tsx`** | The pet's chat window (Ask handoff, or **Chat with …** in the panel), styled as a soft accent gradient with chat bubbles. The first message starts a session of the user's default AI tool (`agenthub.defaultTool`; Claude or Codex — any other tool still opens its pane in the workspace) in the "Sagi" workspace's first free tab; replies are read from the session's transcript every 1.5 s, and follow-ups and menu answers (`PaneMenuCard`) go through a `TerminalView` attached off-screen (`submitToRun`), so no new route exists. The chat is kept in `localStorage` (`agenthub.pet.chat`) while its pane lives; an ended pane is never resumed — the next message starts a new session. | +| **`Sagi.tsx`** | Shell component. A pat (the panel's heart, the Ask box, or stroking the pet — four left/right turns of the pointer over it within 1.2 s) shows hearts and a line ON the pet, so it reads while the panel or chat is open. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. | +| **`intents.ts`** | Local Q&A over the cached status snapshot, in English and Vietnamese keywords, answering in the UI language and naming the sessions it counts (also: who are you, hello, thanks, pat). Queries that don't match a local intent open `PetChat`; with a default tool it cannot read back, `Sagi.tsx` instead calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. | +| **`quips.ts`** | Mood/pulse → phrase pools, plus greetings and pat lines, read from `settings:sagi.{quips,greet,pat}` so they follow the UI language. | +| **`prefs.ts`** | `localStorage`-backed enabled / muted / pet / size / nickname preferences with cross-tab sync; `petName` = nickname, else the worn pet's own name, else Sagi. | +| **`sagi.css`** | Keyframes and per-mood expressions (`data-mood`), plus the one-shot moments (`data-moment`: jump, wave, fail, wait, review) on Sagi's art and an uploaded image. | ### Touchpoints Outside the Folder @@ -2422,7 +2423,7 @@ Sagi's only contact with the rest of the app is four light, additive touchpoints | `client/src/pages/Settings.tsx` | On/off toggle wired to `sagiPrefs` (`localStorage`). | | `client/src/pages/Run.tsx` | Consumes `?laneId=` to attach the page to the lane whose run Sagi just spawned. | | `server/routes/lanes.js` (`POST /api/lanes/ensure`) | Accepts a missing `cwd` and resolves it to `os.homedir()` so the mascot can adopt a stable "Sagi" lane. | -| `client/src/i18n/locales/{en,vi}/settings.json` | `sagi.*` strings for the Settings toggle (zh / ko fall back to the hardcoded English defaults). | +| `client/src/i18n/locales/{en,vi}/settings.json` | Every `sagi.*` string: the Settings toggle, the panel, the bubbles' quips, coalesced counts, greetings and Ask answers. | --- diff --git a/client/src/components/Sagi/PetChat.tsx b/client/src/components/Sagi/PetChat.tsx new file mode 100644 index 00000000..870237ff --- /dev/null +++ b/client/src/components/Sagi/PetChat.tsx @@ -0,0 +1,541 @@ +/** + * @file PetChat.tsx + * @description The pet's chat window. A question its Ask box cannot answer + * locally opens here instead of leaving the page: the first message starts a + * session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or + * Codex — the two with a readable transcript) in the "Sagi" workspace, in its + * first free tab (`startInFreeTabDetailed`), and the conversation is shown as + * chat bubbles read from the session's transcript. Follow-ups are typed into + * that session's pane through a terminal attached OFF-SCREEN (`TerminalView` + * in a fixed box left of the viewport — `submitToRun` needs a mounted, + * attached pane, exactly like the workspace's own Chat view), so nothing new + * reaches the server. A menu the CLI asks with (a permission or trust prompt) + * is read off that pane and answered with `PaneMenuCard`. The chat is kept in + * `localStorage` (`agenthub.pet.chat`) while its pane lives; **New chat** + * forgets it (the session stays in the workspace), **Open in workspace** + * opens its pane there. Opening never resumes an ended session: once the pane + * is gone, the next message starts a new one. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { ArrowLeft, ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react"; +import { api } from "../../lib/api"; +import type { TranscriptMessage } from "../../lib/types"; +import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults"; +import { + answerRunMenu, + chooseRunOption, + interruptRun, + readRunMenu, + submitToRun, + type PaneMenu, +} from "../../lib/panePaste"; +import { isSubmitEnter } from "../../lib/keys"; +import { startInFreeTabDetailed } from "../lanes/startSession"; +import { TerminalView } from "../run/TerminalView"; +import { PaneMenuCard } from "../run/PaneChat"; +import { MarkdownContent } from "../conversation/MarkdownContent"; + +/** Tools whose session the chat can read back (a transcript AgentHub parses). */ +export const PET_CHAT_TOOLS = new Set(["claude", "codex"]); +const CHAT_KEY = "agenthub.pet.chat"; +const POLL_MS = 1500; +const MENU_POLL_MS = 700; +// A follow-up waits this long for the off-screen pane to attach. +const SEND_TRIES = 20; +const SEND_RETRY_MS = 250; + +/** The session the chat is talking to. */ +export interface PetChatRef { + laneId: number; + runId: string; + tool: string; +} + +export function readPetChat(): PetChatRef | null { + try { + const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial | null; + if ( + v && + typeof v.laneId === "number" && + typeof v.runId === "string" && + typeof v.tool === "string" + ) + return { laneId: v.laneId, runId: v.runId, tool: v.tool }; + } catch { + /* unreadable: no chat */ + } + return null; +} + +function writePetChat(ref: PetChatRef | null) { + try { + if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref)); + else localStorage.removeItem(CHAT_KEY); + } catch { + /* best-effort */ + } +} + +/** The tool a new chat starts: the user's default, when the chat can show it. */ +export function petChatTool(): string | null { + const tool = readDefaultTool() ?? "claude"; + return PET_CHAT_TOOLS.has(tool) ? tool : null; +} + +type Line = + | { key: string; role: "user" | "assistant"; text: string } + | { key: string; role: "tools"; names: string[] }; + +/** A human line's text: Claude Code's own injected lines (``, + * ``…) are not something the user said. */ +const humanText = (m: TranscriptMessage) => + m.content + .filter((c) => c.type === "text" && c.text) + .map((c) => c.text!.trim()) + .filter((t) => t && !t.startsWith("<")) + .join("\n\n"); + +/** The transcript as chat lines: the user's words, the assistant's prose, and + * each run of tool calls between them folded into one chip. */ +export function chatLines(messages: TranscriptMessage[]): Line[] { + const out: Line[] = []; + messages.forEach((m, i) => { + const sender = m.sender ?? m.type; + if (sender === "user") { + const text = humanText(m); + if (text) out.push({ key: `u${i}`, role: "user", text }); + return; + } + if (m.type !== "assistant") return; + m.content.forEach((c, j) => { + if (c.type === "text" && c.text?.trim()) { + out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() }); + } else if (c.type === "tool_use" && c.name) { + const last = out[out.length - 1]; + if (last?.role === "tools") last.names.push(c.name); + else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] }); + } + }); + }); + return out; +} + +/** Still working: the last line is the user's, or an assistant turn not ended. */ +function isBusy(messages: TranscriptMessage[]): boolean { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]!; + const sender = m.sender ?? m.type; + if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn"; + if (sender === "user" && humanText(m)) return true; + } + return false; +} + +/** "Read ×2 · Bash" */ +function toolSummary(names: string[]): string { + const counts = new Map(); + names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1)); + return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · "); +} + +interface PetChatProps { + /** The pet's shown name, for the header and greeting. */ + name: string; + /** A question to send as soon as the chat opens (the Ask box's handoff). */ + initialPrompt?: string | null; + /** Called once the initial prompt has been taken, so a re-render never sends it twice. */ + onPromptTaken?: () => void; + onBack: () => void; + onClose: () => void; + /** Opens the chat's pane in its workspace. */ + onOpenWorkspace: (laneId: number, runId: string) => void; + /** The thinking mood while a reply is being written. */ + onBusy?: (busy: boolean) => void; +} + +export function PetChat({ + name, + initialPrompt, + onPromptTaken, + onBack, + onClose, + onOpenWorkspace, + onBusy, +}: PetChatProps) { + const { t } = useTranslation("settings"); + const [chat, setChat] = useState(readPetChat); + const [sessionId, setSessionId] = useState(null); + const [messages, setMessages] = useState([]); + // Sent but not in the transcript yet. + const [pending, setPending] = useState(null); + const [starting, setStarting] = useState(false); + const [error, setError] = useState(null); + const [draft, setDraft] = useState(""); + const [menu, setMenu] = useState(null); + const listRef = useRef(null); + + const remember = (ref: PetChatRef | null) => { + writePetChat(ref); + setChat(ref); + setSessionId(null); + setMessages([]); + setMenu(null); + }; + + // A remembered chat whose pane is gone is over: forget it, the next message starts anew. + useEffect(() => { + if (!chat) return; + let live = true; + const check = () => + api.run + .get(chat.runId) + .then((h) => { + if (!live) return; + if (h.status !== "running") remember(null); + else if (h.sessionId) setSessionId(h.sessionId); + }) + .catch(() => live && remember(null)); + void check(); + // Codex learns its session only after a while; Claude's is known at start. + const timer = window.setInterval(() => { + if (!sessionId) void check(); + }, POLL_MS); + return () => { + live = false; + window.clearInterval(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chat?.runId, sessionId]); + + // The conversation, polled while the window is open. + useEffect(() => { + if (!sessionId) return; + let live = true; + const load = () => + api.sessions + .transcript(sessionId, { limit: 60 }) + .then((r) => { + if (!live) return; + setMessages((prev) => + prev.length === r.messages.length && + JSON.stringify(prev[prev.length - 1]) === + JSON.stringify(r.messages[r.messages.length - 1]) + ? prev + : r.messages + ); + }) + .catch(() => undefined); // not started yet: the next poll asks again + void load(); + const timer = window.setInterval(load, POLL_MS); + return () => { + live = false; + window.clearInterval(timer); + }; + }, [sessionId]); + + // A menu the CLI shows in the pane (permission, trust, a question). + useEffect(() => { + if (!chat) return; + const read = () => + setMenu((prev) => { + const next = readRunMenu(chat.runId); + return JSON.stringify(next) === JSON.stringify(prev) ? prev : next; + }); + const timer = window.setInterval(read, MENU_POLL_MS); + return () => window.clearInterval(timer); + }, [chat]); + + const lines = chatLines(messages); + // The pending line shows until the transcript has it. + useEffect(() => { + if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim())) + setPending(null); + }, [lines, pending]); + const busy = starting || !!pending || isBusy(messages); + useEffect(() => onBusy?.(busy), [busy, onBusy]); + useEffect(() => () => onBusy?.(false), [onBusy]); + + // Keep the newest in sight. + useEffect(() => { + const el = listRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [lines.length, pending, busy, menu]); + + const send = useCallback( + async (text: string) => { + const prompt = text.trim(); + if (!prompt || starting) return; + setError(null); + setPending(prompt); + if (chat) { + // Typed into the attached pane; it may still be attaching. + for (let i = 0; i < SEND_TRIES; i++) { + if (submitToRun(chat.runId, prompt)) return; + await new Promise((r) => setTimeout(r, SEND_RETRY_MS)); + } + setPending(null); + setDraft(prompt); + setError(t("sagi.chat.notSent")); + return; + } + const tool = petChatTool() ?? "claude"; + setStarting(true); + try { + const { lane } = await api.lanes.ensure({ title: "Sagi" }); + const r = await startInFreeTabDetailed(lane.id, { + prompt, + ...(tool !== lane.tool ? { tool } : {}), + ...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}), + }); + remember({ laneId: r.laneId, runId: r.runId, tool }); + } catch (err) { + setPending(null); + setDraft(prompt); + setError( + t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) }) + ); + } finally { + setStarting(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [chat, starting, t] + ); + + // The Ask box's question, sent once. + const taken = useRef(false); + useEffect(() => { + if (!initialPrompt || taken.current) return; + taken.current = true; + onPromptTaken?.(); + void send(initialPrompt); + }, [initialPrompt, onPromptTaken, send]); + + const submit = (e?: FormEvent) => { + e?.preventDefault(); + if (!draft.trim()) return; + const text = draft; + setDraft(""); + void send(text); + }; + + const toolLabel = chat?.tool ?? petChatTool() ?? "claude"; + const empty = lines.length === 0 && !pending; + + return ( +
+ {/* header */} +
+ + + +
+
{name}
+
+ + {busy ? t("sagi.chat.typing") : toolLabel} +
+
+ {chat && ( + onOpenWorkspace(chat.laneId, chat.runId)} + > + + + )} + remember(null)} + disabled={!chat || starting} + > + + + + + +
+ + {/* conversation */} +
+ {empty && ( +
+ + 🐾 + +

+ {t("sagi.chat.emptyTitle", { name })} +

+

{t("sagi.chat.emptyHint", { tool: toolLabel })}

+
+ )} + {lines.map((l) => + l.role === "tools" ? ( +
+ + + {toolSummary(l.names)} + +
+ ) : l.role === "user" ? ( +
+
+ {l.text} +
+
+ ) : ( +
+
+ +
+
+ ) + )} + {pending && ( +
+
+ {pending} +
+
+ )} + {busy && !menu && ( +
+
+ + + +
+
+ )} + {starting && ( +

+ + {t("sagi.chat.starting", { tool: toolLabel })} +

+ )} + {chat && !sessionId && !starting && ( +

{t("sagi.chat.notStarted")}

+ )} +
+ + {error && ( +

+ {error} +

+ )} + + {/* prompt, or the question the CLI asks */} +
+ {menu && chat ? ( +
+ { + if (chooseRunOption(chat.runId, menu, i)) setMenu(null); + else setError(t("sagi.chat.notSent")); + }} + onAnswer={(text) => { + if (answerRunMenu(chat.runId, menu, text)) setMenu(null); + else setError(t("sagi.chat.notSent")); + }} + onCancel={() => { + if (interruptRun(chat.runId)) setMenu(null); + else setError(t("sagi.chat.notSent")); + }} + /> +
+ ) : ( +
+