From f460fff406545c932172a996f22bee549bb81662 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 16 Oct 2026 10:27:22 +0700 Subject: [PATCH] feat: pet chat window replaces the Sagi panel; Windows pane attach no longer crashes the server - Sagi: clicking the pet opens its chat window directly (PetChat + SagiInfo status strip, Needs you, Mute / Clear alerts); SagiPanel removed. - Chat view answers Claude's unnumbered folder-trust prompt (parseBareMenu). - pty-attach: resize/write after a Windows attach exited, and node-pty's pipe errors, no longer throw out of the WebSocket handler. - restart-helper: the server started by an update logs to data/agenthub-server.log instead of discarding its output. --- ARCHITECTURE.md | 16 +- client/src/components/Sagi/PetChat.tsx | 114 +++-- client/src/components/Sagi/Sagi.tsx | 127 +++--- client/src/components/Sagi/SagiInfo.tsx | 297 +++++++++++++ client/src/components/Sagi/SagiPanel.tsx | 392 ------------------ .../components/Sagi/__tests__/Sagi.test.tsx | 78 ++-- client/src/i18n/locales/en/settings.json | 9 +- client/src/i18n/locales/vi/settings.json | 9 +- client/src/lib/__tests__/panePaste.test.ts | 54 ++- client/src/lib/panePaste.ts | 41 +- docs/API.md | 2 +- scripts/restart-helper.js | 27 +- server/__tests__/pty-attach.test.js | 39 ++ server/__tests__/self-restart.test.js | 32 +- server/lib/pty-attach.js | 47 ++- server/lib/self-restart.js | 5 +- 16 files changed, 734 insertions(+), 555 deletions(-) create mode 100644 client/src/components/Sagi/SagiInfo.tsx delete mode 100644 client/src/components/Sagi/SagiPanel.tsx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0917ae55..427eafc7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2325,7 +2325,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status` - **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button. - **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved. -What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js`: a server our own service unit runs (its cgroup ends in `/agenthub.service`, or it is our launchd job) is restarted BY that manager (`systemctl restart --no-block`, via `sudo -n` for the server-mode system unit; launchd's KeepAlive after an exit), anything else by the detached `scripts/restart-helper.js`. A detached replacement under a `Restart=always` unit held the port while the unit restarted every 3 s, each start exiting "already running" (v2.7.2). +What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js`: a server our own service unit runs (its cgroup ends in `/agenthub.service`, or it is our launchd job) is restarted BY that manager (`systemctl restart --no-block`, via `sudo -n` for the server-mode system unit; launchd's KeepAlive after an exit), anything else by the detached `scripts/restart-helper.js`, which appends the new server's stdout/stderr to `data/agenthub-server.log` like `agenthub start` (it used to discard them, so a crash after an update left no stack trace). A detached replacement under a `Restart=always` unit held the port while the unit restarted every 3 s, each start exiting "already running" (v2.7.2). --- @@ -2356,7 +2356,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 + Needs you + Mute / Clear alerts + Ask box"] + PANEL["PetChat.tsx + SagiInfo.tsx
the chat window: status strip, Needs you,
Mute / Clear alerts, local answers, AI chat"] CSS["sagi.css
keyframes + per-mood expressions"] end @@ -2386,8 +2386,8 @@ flowchart LR SUB --> DERIVED["derived state
{ mood, status, bubble }"] DERIVED --> AVATAR["SagiAvatar"] DERIVED --> BUBBLE["SpeechBubble"] - DERIVED --> PANEL["SagiPanel"] - PANEL -->|"unmatched Ask"| ENSURE["POST /api/lanes/ensure
(adopt 'Sagi' lane, cwd = home)"] + DERIVED --> PANEL["PetChat (+ SagiInfo)"] + PANEL -->|"unmatched question"| ENSURE["POST /api/lanes/ensure
(adopt 'Sagi' lane, cwd = home)"] ENSURE --> START["POST /api/run
(spawn tmux-backed claude
in Sagi lane, initialPrompt=…)"] START --> RUN["/run?laneId=…
(existing Run page picks up
the live run)"] @@ -2407,10 +2407,10 @@ The mood state machine in `deriveMood` resolves to a single expression using a f | **`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: 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. | +| **`SagiInfo.tsx`** | Pure presentational pieces of the chat window: `PetNameButton` (click to rename — `agenthub.pet.names`, synced to the account) and `SagiInfo`, the status strip under the header — live / waiting / errored and the connection always shown; expanded (by default while something needs you) a **Needs you** list of errored/waiting sessions by name, Mute and Clear alerts. | +| **`PetChat.tsx`** | The pet's whole UI — clicking the pet opens it (there is no separate panel): header (name, pat heart, Open in workspace, New chat, close), the `SagiInfo` strip, then the conversation. Before a chat runs, a question `intents.ts` can answer is answered in place (`onLocal`); anything else goes to the AI. 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` — numbered menus and Claude's unnumbered folder-trust prompt alike) 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, so closing and reopening the window continues it; **New chat** stops that session (`DELETE /api/run/:id?archive=1`, asking first only mid-reply); an ended pane is never resumed — the next message starts a new session. | +| **`Sagi.tsx`** | Shell component. A pat (the chat's heart, a typed pat, 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 chat is open. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns the chat's 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 go to `PetChat`'s AI session; 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. | diff --git a/client/src/components/Sagi/PetChat.tsx b/client/src/components/Sagi/PetChat.tsx index 870237ff..7eea2a7e 100644 --- a/client/src/components/Sagi/PetChat.tsx +++ b/client/src/components/Sagi/PetChat.tsx @@ -1,7 +1,11 @@ /** * @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 + * @description The pet's chat window — its whole UI: clicking the pet opens + * it. Above the conversation sits what the pet knows (`info`: live / waiting / + * errored, the sessions that need you, Mute, Clear alerts — `SagiInfo.tsx`). + * Before a chat runs, a question the pet can answer itself (`onLocal`: status, + * who are you…) is answered right here; anything else — and every message once + * a chat runs — goes to the AI: 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 @@ -11,16 +15,17 @@ * 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** + * `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and + * reopening the window continues it; **New chat** STOPS that session (kills + * its pane and archives it, asking first only mid-reply), **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 { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { ArrowLeft, ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react"; +import { 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"; @@ -33,6 +38,8 @@ import { type PaneMenu, } from "../../lib/panePaste"; import { isSubmitEnter } from "../../lib/keys"; +import { confirmDialog } from "../../lib/confirm"; +import { toast } from "../../lib/toast"; import { startInFreeTabDetailed } from "../lanes/startSession"; import { TerminalView } from "../run/TerminalView"; import { PaneMenuCard } from "../run/PaneChat"; @@ -141,14 +148,21 @@ function toolSummary(names: string[]): string { return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · "); } +/** What the container made of a message: an answer the pet gives itself, or + * `handled` (it took the message elsewhere); null sends it to the AI. */ +export type PetLocalReply = { answer: string } | { handled: true } | null; + 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; + /** Shown in place of the plain name (the rename button). */ + title?: ReactNode; + /** Extra header buttons, before Open in workspace (the pat heart). */ + headerActions?: ReactNode; + /** Under the header: what the pet knows (status, sessions that need you). */ + info?: ReactNode; + /** Asked first while no chat runs. */ + onLocal?: (text: string) => PetLocalReply; onClose: () => void; /** Opens the chat's pane in its workspace. */ onOpenWorkspace: (laneId: number, runId: string) => void; @@ -158,9 +172,10 @@ interface PetChatProps { export function PetChat({ name, - initialPrompt, - onPromptTaken, - onBack, + title, + headerActions, + info, + onLocal, onClose, onOpenWorkspace, onBusy, @@ -175,6 +190,8 @@ export function PetChat({ const [error, setError] = useState(null); const [draft, setDraft] = useState(""); const [menu, setMenu] = useState(null); + // The pet's own answers, given before any chat ran. + const [local, setLocal] = useState([]); const listRef = useRef(null); const remember = (ref: PetChatRef | null) => { @@ -248,7 +265,7 @@ export function PetChat({ return () => window.clearInterval(timer); }, [chat]); - const lines = chatLines(messages); + const lines = [...local, ...chatLines(messages)]; // The pending line shows until the transcript has it. useEffect(() => { if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim())) @@ -269,6 +286,19 @@ export function PetChat({ const prompt = text.trim(); if (!prompt || starting) return; setError(null); + if (!chat && onLocal) { + const reply = onLocal(prompt); + if (reply && "handled" in reply) return; + if (reply) { + const n = Date.now(); + setLocal((prev) => [ + ...prev, + { key: `lu${n}`, role: "user", text: prompt }, + { key: `la${n}`, role: "assistant", text: reply.answer }, + ]); + return; + } + } setPending(prompt); if (chat) { // Typed into the attached pane; it may still be attaching. @@ -302,17 +332,35 @@ export function PetChat({ } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [chat, starting, t] + [chat, starting, t, onLocal] ); - // 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]); + // New chat: the old one's session is stopped, not left running unseen — + // asking first only while it is still writing a reply. + const newChat = async () => { + if (chat) { + if ( + busy && + !(await confirmDialog({ + title: t("sagi.chat.endTitle"), + message: t("sagi.chat.endBody"), + confirmLabel: t("sagi.chat.endConfirm"), + destructive: true, + })) + ) + return; + try { + await api.run.kill(chat.runId, { archive: true }); + } catch (err) { + toast.error(err); + return; + } + } + remember(null); + setLocal([]); + setPending(null); + setError(null); + }; const submit = (e?: FormEvent) => { e?.preventDefault(); @@ -333,11 +381,11 @@ export function PetChat({ > {/* header */}
- - - + + 🐾 +
-
{name}
+ {title ??
{name}
}
+ {headerActions} {chat && ( remember(null)} - disabled={!chat || starting} + onClick={() => void newChat()} + disabled={(!chat && !local.length) || starting} > @@ -372,6 +421,8 @@ export function PetChat({
+ {info} + {/* conversation */}
{empty && ( @@ -475,10 +526,9 @@ export function PetChat({ e.preventDefault(); submit(); } else if (e.key === "Escape") { - // Esc leaves the chat for the panel, not the whole mascot. e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); - onBack(); + onClose(); } }} /> diff --git a/client/src/components/Sagi/Sagi.tsx b/client/src/components/Sagi/Sagi.tsx index eea91143..4ed41e8b 100644 --- a/client/src/components/Sagi/Sagi.tsx +++ b/client/src/components/Sagi/Sagi.tsx @@ -5,17 +5,20 @@ * 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 + * Clicking the pet opens its chat window (`PetChat`), which is its whole UI: + * the status strip (`SagiInfo`) sits on top, a question the pet can answer + * itself is answered there, anything else goes to the AI. + * Owns the open/closed chat 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=&run=`) — no new LLM - * backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not + * The "do the job" path: an unmatched question is the chat's — it starts a + * session of the default AI tool in the "Sagi" workspace; a default tool the + * chat cannot read back starts there and its session view opens + * (`/workspace?lane=&run=`) — 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ĩ */ @@ -39,7 +42,7 @@ * ## Internal dependencies * - `./SagiAvatar` * - `./SpeechBubble` - * - `./SagiPanel` + * - `./SagiInfo` * - `./useSagiBrain` * - `./useSagiPosition` * - `./intents` @@ -83,7 +86,7 @@ import { SagiAvatar } from "./SagiAvatar"; import { PetSprite } from "./PetSprite"; import { PetMenu } from "./PetMenu"; import { SpeechBubble } from "./SpeechBubble"; -import { SagiPanel } from "./SagiPanel"; +import { PetNameButton, SagiInfo } from "./SagiInfo"; import { useSagiBrain } from "./useSagiBrain"; import { useSagiPosition } from "./useSagiPosition"; import { useSagiEnabled } from "./SagiMount"; @@ -94,7 +97,8 @@ import { api } from "../../lib/api"; import { readDefaultTool } from "../../lib/sessionDefaults"; import { startInFreeTabDetailed } from "../lanes/startSession"; import { workspaceHref } from "../SessionRowMenu"; -import { PetChat, petChatTool } from "./PetChat"; +import { Heart } from "lucide-react"; +import { PetChat, petChatTool, type PetLocalReply } 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. @@ -241,10 +245,8 @@ function SagiBody() { // 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()); + // The chat window (PetChat.tsx): the pet's whole UI. 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(null); // The right-click pet menu (`PetMenu.tsx`). const [menu, setMenu] = useState(false); const [hover, setHover] = useState(false); @@ -296,7 +298,6 @@ function SagiBody() { setOpen((v) => !v); } else if (e.key === "Escape") { setOpen(false); - setChatOpen(false); } }; window.addEventListener("keydown", onKey); @@ -346,7 +347,6 @@ function SagiBody() { navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`); brain.dismissBubble(); setOpen(false); - setChatOpen(false); }; api.run .list() @@ -356,21 +356,17 @@ function SagiBody() { [brain, navigate] ); - const onAsk = useCallback( - (query: string): string | null => { + // Asked by the chat before any session runs. + const onLocal = useCallback( + (query: string): PetLocalReply => { 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; + return { answer: result.text }; } + // A question the pet cannot answer: the chat starts a session of the + // default AI tool and shows it as chat. + if (petChatTool()) 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; @@ -392,7 +388,7 @@ function SagiBody() { brain.setThinking(false); } })(); - return null; + return { handled: true }; }, [brain, navigate, t, name] ); @@ -431,46 +427,49 @@ function SagiBody() { 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 && ( - - 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} - onClose={() => setOpen(false)} - /> - - )} - {/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows - it 1:1), and kept mounted: unmounting would detach its terminal and - lose a half-typed message. */} - {chatOpen && ( + it 1:1). Closing unmounts it; its chat is remembered, so reopening + re-attaches the same session. */} + {open && ( setChatPrompt(null)} - onBack={() => { - setChatOpen(false); - setOpen(true); - }} - onClose={() => setChatOpen(false)} + title={ + sagiPrefs.setNickname(pet.id, next)} + /> + } + headerActions={ + + } + info={ + 0} + /> + } + onLocal={onLocal} + onClose={() => setOpen(false)} onOpenWorkspace={(laneId, runId) => { navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`); - setChatOpen(false); + setOpen(false); }} onBusy={onBusy} /> @@ -491,7 +490,7 @@ function SagiBody() { )} - {!open && !menu && !chatOpen && brain.bubble && ( + {!open && !menu && brain.bubble && ( !v); + 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} + aria-label={open ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })} + aria-expanded={open} title={t("sagi.avatarTitle", { name })} > {pet.id === "sagi" || !("kind" in pet) ? ( diff --git a/client/src/components/Sagi/SagiInfo.tsx b/client/src/components/Sagi/SagiInfo.tsx new file mode 100644 index 00000000..68dff124 --- /dev/null +++ b/client/src/components/Sagi/SagiInfo.tsx @@ -0,0 +1,297 @@ +/** + * @file SagiInfo.tsx + * @description What the pet knows, shown inside its chat window (`PetChat`): + * the name you rename by clicking it (`PetNameButton`), and a status strip + * (`SagiInfo`) — live / waiting / errored counts and the connection state + * always in sight, and on expanding, the sessions that need the user, by + * name, plus Mute and Clear alerts. Pure presentational: all data and + * behavior are injected by the container (`Sagi.tsx`). + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useState } from "react"; +import { + AlertTriangle, + Bell, + BellOff, + ChevronDown, + ChevronRight, + Hourglass, + Radio, + Trash2, + type LucideIcon, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +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; + +/** The pet's name; a click renames it in place (empty = its own name). */ +export function PetNameButton({ + name, + ownName, + nickname, + onRename, +}: { + name: string; + ownName: string; + nickname: string; + onRename: (name: string) => void; +}) { + const { t } = useTranslation("settings"); + // The name being typed, or null while not renaming. + const [renaming, setRenaming] = useState(null); + const save = () => { + if (renaming === null) return; + onRename(renaming); + setRenaming(null); + }; + return renaming === null ? ( + + ) : ( + setRenaming(e.target.value)} + onBlur={save} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + save(); + } else if (e.key === "Escape") { + // Esc cancels the rename only, not the whole chat. + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + setRenaming(null); + } + }} + /> + ); +} + +interface SagiInfoProps { + attention: ReadonlyArray; + onOpenSession: (s: SagiSubject) => void; + status: SagiStatus; + muted: boolean; + onToggleMute: () => void; + onClearAlerts: () => void; + onNavigate: (route: string) => void; + /** Expanded at first: the sessions that need the user and the actions. */ + defaultOpen?: boolean; +} + +/** The status strip under the chat's header. */ +export function SagiInfo({ + attention, + onOpenSession, + status, + muted, + onToggleMute, + onClearAlerts, + onNavigate, + defaultOpen = false, +}: SagiInfoProps) { + const { t } = useTranslation("settings"); + const [open, setOpen] = useState(defaultOpen); + const Chevron = open ? ChevronDown : ChevronRight; + return ( +
+ + + {open && ( +
+ {/* the sessions that need the user, by name */} + {attention.length > 0 && ( +
+
+ {t("sagi.panel.attention")} +
+
    + {attention.slice(0, ATTENTION_SHOWN).map((item) => ( +
  • + +
  • + ))} +
+ {attention.length > ATTENTION_SHOWN && ( + + )} +
+ )} + +
+ + +
+
+ )} +
+ ); +} + +interface Tone { + wrap: string; + value: string; + icon: string; +} + +const TONE_MUTED: Tone = { + wrap: "border-border bg-surface-1", + value: "text-fg-secondary", + icon: "text-fg-muted", +}; + +const TONES: Record = { + accent: { wrap: "border-accent/30 bg-accent/10", value: "text-fg-primary", icon: "text-accent" }, + amber: { + wrap: "border-status-warning/30 bg-status-warning/10", + value: "text-status-warning", + icon: "text-status-warning", + }, + red: { + wrap: "border-status-danger/30 bg-status-danger/10", + value: "text-status-danger", + icon: "text-status-danger", + }, + muted: TONE_MUTED, +}; + +/** One count, compact: icon, number, label on one line. */ +function StatChip({ + icon: Icon, + label, + value, + tone, +}: { + icon: LucideIcon; + label: string; + value: number; + tone: string; +}) { + const t = TONES[tone] ?? TONE_MUTED; + return ( + + + {value} + {label} + + ); +} + +function ActionButton({ + icon: Icon, + label, + onClick, + disabled, +}: { + icon: LucideIcon; + label: string; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} diff --git a/client/src/components/Sagi/SagiPanel.tsx b/client/src/components/Sagi/SagiPanel.tsx deleted file mode 100644 index a9679495..00000000 --- a/client/src/components/Sagi/SagiPanel.tsx +++ /dev/null @@ -1,392 +0,0 @@ -/** - * @file SagiPanel.tsx - * @description Expanded Sagi panel: a live status strip (live / waiting / - * errored stat chips + connection state), quick navigation actions, and a - * local "Ask" box. Pure presentational - all data and the ask/navigation - * behavior are injected by the container. - * @author Nguyễn Ngọc Trí Vĩ - */ -/* ============================================================================= - * 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 - * - `./brain` - * - * ## Public surface - * - `SagiPanel` — 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). - * ----------------------------------------------------------------------------- - * **SagiPanel** - * 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 { useState, type FormEvent, type ReactNode } from "react"; -import { - Bell, - BellOff, - Trash2, - X, - Send, - AlertTriangle, - Hourglass, - Radio, - Heart, - ChevronRight, - type LucideIcon, -} from "lucide-react"; -import { useTranslation } from "react-i18next"; -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; - onOpenSession: (s: SagiSubject) => void; - status: SagiStatus; - muted: boolean; - onToggleMute: () => void; - onClearAlerts: () => void; - onNavigate: (route: string) => void; - /** Returns an answer to display, or null when the query was handed off. */ - onAsk: (query: string) => string | null; - onClose: () => void; -} - -export function SagiPanel({ - name, - ownName, - nickname, - onRename, - onPat, - attention, - onOpenSession, - status, - muted, - onToggleMute, - onClearAlerts, - onNavigate, - onAsk, - onClose, -}: SagiPanelProps) { - const { t } = useTranslation("settings"); - const [query, setQuery] = useState(""); - const [answer, setAnswer] = useState(null); - // The name being typed, or null while not renaming. - const [renaming, setRenaming] = useState(null); - const saveName = () => { - if (renaming === null) return; - onRename(renaming); - setRenaming(null); - }; - - const submit = (e: FormEvent) => { - e.preventDefault(); - const result = onAsk(query); - setAnswer(result); // null means it handed off (container navigates/closes) - setQuery(""); - }; - - return ( -
- {/* header */} -
-
- - 🐾 - - {renaming === null ? ( - - ) : ( - 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); - } - }} - /> - )} - - - {status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")} - -
-
- - -
-
- -
- {/* status stat chips */} -
- 0 ? "accent" : "muted"} - /> - 0 ? "amber" : "muted"} - /> - 0 ? "red" : "muted"} - /> -
- - {/* the sessions that need the user, by name */} - {attention.length > 0 && ( -
-
- {t("sagi.panel.attention")} -
-
    - {attention.slice(0, ATTENTION_SHOWN).map((item) => ( -
  • - -
  • - ))} -
- {attention.length > ATTENTION_SHOWN && ( - - )} -
- )} - - {/* quick actions */} -
- - -
- - {/* ask */} -
- setQuery(e.target.value)} - aria-label={t("sagi.panel.ask", { name })} - /> - -
- {answer && ( -

- {answer} -

- )} -
-
- ); -} - -interface Tone { - wrap: string; - value: string; - icon: string; -} - -const TONE_MUTED: Tone = { - wrap: "border-border bg-surface-1", - value: "text-fg-secondary", - icon: "text-fg-muted", -}; - -const TONES: Record = { - accent: { wrap: "border-accent/30 bg-accent/10", value: "text-fg-primary", icon: "text-accent" }, - amber: { - wrap: "border-status-warning/30 bg-status-warning/10", - value: "text-status-warning", - icon: "text-status-warning", - }, - red: { - wrap: "border-status-danger/30 bg-status-danger/10", - value: "text-status-danger", - icon: "text-status-danger", - }, - muted: TONE_MUTED, -}; - -function StatChip({ - icon: Icon, - label, - value, - tone, -}: { - icon: LucideIcon; - label: string; - value: number; - tone: string; -}): ReactNode { - const t = TONES[tone] ?? TONE_MUTED; - return ( -
- - - {value} - - {label} -
- ); -} - -function ActionButton({ - icon: Icon, - label, - onClick, - disabled, -}: { - icon: LucideIcon; - label: string; - onClick: () => void; - disabled?: boolean; -}) { - return ( - - ); -} diff --git a/client/src/components/Sagi/__tests__/Sagi.test.tsx b/client/src/components/Sagi/__tests__/Sagi.test.tsx index b642d583..196aabb0 100644 --- a/client/src/components/Sagi/__tests__/Sagi.test.tsx +++ b/client/src/components/Sagi/__tests__/Sagi.test.tsx @@ -1,6 +1,6 @@ /** * @file Sagi.test.tsx - * @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B panel toggle, and accessibility attributes. + * @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B chat toggle (the chat window is the pet's whole UI: status strip, local answers, New chat stopping the old session), and accessibility attributes. * @author Nguyễn Ngọc Trí Vĩ */ @@ -18,6 +18,7 @@ import { useSagiPosition } from "../useSagiPosition"; import { MemoryRouter } from "react-router-dom"; import { Sagi } from "../Sagi"; import { eventBus } from "../../../lib/eventBus"; +import { api } from "../../../lib/api"; import type { WSMessage, Session } from "../../../lib/types"; function renderSagi() { @@ -54,13 +55,13 @@ describe("Sagi widget", () => { expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument(); }); - it("opens the panel on click and answers a local status question", () => { + it("opens the chat on click and answers a local status question there", () => { renderSagi(); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); - const panel = screen.getByRole("dialog", { name: /sagi companion/i }); + const panel = screen.getByRole("dialog", { name: /chat with sagi/i }); expect(panel).toBeInTheDocument(); - const input = within(panel).getByLabelText(/ask sagi/i); + const input = within(panel).getByLabelText(/ask sagi anything/i); fireEvent.change(input, { target: { value: "status" } }); fireEvent.submit(input.closest("form")!); expect(within(panel).getByText(/live ·/i)).toBeInTheDocument(); @@ -88,7 +89,7 @@ describe("Sagi widget", () => { expect(within(btn).getByText("1")).toBeInTheDocument(); }); - it("names the errored session in its bubble and in the panel", () => { + it("names the errored session in its bubble and in the chat", () => { renderSagi(); act(() => { eventBus.publish({ @@ -101,7 +102,7 @@ describe("Sagi widget", () => { expect(within(bubble).getByText("Fix login")).toBeInTheDocument(); expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); - const panel = screen.getByRole("dialog", { name: /sagi companion/i }); + const panel = screen.getByRole("dialog", { name: /chat with sagi/i }); expect(within(panel).getByText("Needs you")).toBeInTheDocument(); expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument(); }); @@ -121,43 +122,54 @@ describe("Sagi widget", () => { expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]); }); - it("renames the pet from the panel header", () => { + it("renames the pet from the chat header", () => { renderSagi(); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); fireEvent.click(screen.getByRole("button", { name: /rename sagi/i })); const input = screen.getByLabelText("Pet name"); fireEvent.change(input, { target: { value: "Mochi" } }); fireEvent.keyDown(input, { key: "Enter" }); - expect(screen.getByRole("dialog", { name: /mochi companion/i })).toBeInTheDocument(); - expect(screen.getByLabelText(/ask mochi/i)).toBeInTheDocument(); + expect(screen.getByRole("dialog", { name: /chat with mochi/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/ask mochi anything/i)).toBeInTheDocument(); }); - it("hands a question it cannot answer to the chat window", () => { + it("hands a question it cannot answer to the AI", () => { localStorage.setItem("agenthub.defaultTool", "claude"); + const ensure = vi.spyOn(api.lanes, "ensure").mockReturnValue(new Promise(() => {})); renderSagi(); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); - const input = screen.getByLabelText(/ask sagi/i); - fireEvent.change(input, { target: { value: "write me a haiku" } }); - fireEvent.submit(input.closest("form")!); const chat = screen.getByRole("dialog", { name: /chat with sagi/i }); - expect(within(chat).getByText("write me a haiku")).toBeInTheDocument(); - expect(screen.queryByRole("dialog", { name: /sagi companion/i })).not.toBeInTheDocument(); - }); - - it("has no Chat button — Ask opens the chat — and Back returns to the panel", () => { - localStorage.setItem("agenthub.defaultTool", "claude"); - renderSagi(); - fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); - expect(screen.queryByRole("button", { name: /chat with sagi/i })).not.toBeInTheDocument(); - const input = screen.getByLabelText(/ask sagi/i); + const input = within(chat).getByLabelText(/ask sagi anything/i); fireEvent.change(input, { target: { value: "write me a haiku" } }); fireEvent.submit(input.closest("form")!); - expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Back" })); - expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument(); + expect(within(chat).getByText("write me a haiku")).toBeInTheDocument(); + expect(ensure).toHaveBeenCalledWith({ title: "Sagi" }); + ensure.mockRestore(); }); - it("a pat shows its reaction on the pet even while the panel is open", () => { + it("reopens the remembered chat, and New chat stops its session", async () => { + localStorage.setItem( + "agenthub.pet.chat", + JSON.stringify({ laneId: 7, runId: "agenthub-lane-7-1", tool: "claude" }) + ); + const get = vi + .spyOn(api.run, "get") + .mockReturnValue(new Promise(() => {}) as ReturnType); + const kill = vi.spyOn(api.run, "kill").mockResolvedValue({ ok: true }); + renderSagi(); + fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); + expect(screen.getByRole("button", { name: /open in workspace/i })).toBeInTheDocument(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /new chat/i })); + }); + expect(kill).toHaveBeenCalledWith("agenthub-lane-7-1", { archive: true }); + expect(localStorage.getItem("agenthub.pet.chat")).toBeNull(); + expect(screen.queryByRole("button", { name: /open in workspace/i })).not.toBeInTheDocument(); + get.mockRestore(); + kill.mockRestore(); + }); + + it("a pat shows its reaction on the pet even while the chat is open", () => { renderSagi(); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); fireEvent.click(screen.getByRole("button", { name: /pat sagi/i })); @@ -175,16 +187,16 @@ describe("Sagi widget", () => { expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull(); }); - it("reflects the live count in the panel status", () => { + it("reflects the live count in the chat status", () => { renderSagi(); act(() => { eventBus.publish(sessionMsg("a", "active")); eventBus.publish(sessionMsg("b", "active")); }); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); - const panel = screen.getByRole("dialog", { name: /sagi companion/i }); + const panel = screen.getByRole("dialog", { name: /chat with sagi/i }); // The "live" stat chip shows value 2 next to its label. - const liveChip = within(panel).getByText("live").closest("div")!; + const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement; expect(within(liveChip).getByText("2")).toBeInTheDocument(); }); @@ -194,7 +206,7 @@ describe("Sagi widget", () => { expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument(); }); - it("a tap (no movement) still opens the panel", () => { + it("a tap (no movement) still opens the chat", () => { renderSagi(); const btn = screen.getByRole("button", { name: /open sagi companion/i }); act(() => { @@ -202,7 +214,7 @@ describe("Sagi widget", () => { fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 }); }); fireEvent.click(btn); - expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument(); + expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument(); }); it("dragging snaps to an edge, persists position, and does not open the panel", () => { @@ -231,7 +243,7 @@ describe("Sagi widget", () => { fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 }); }); fireEvent.click(btn); - expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument(); + expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument(); // No position was persisted because no real drag happened. expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull(); }); diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index 476fa4fe..9e40469f 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -37,18 +37,14 @@ "chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved to your account.", "uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood.", "panel": { - "label": "{{name}} companion", "live": "Live", "offline": "Offline", - "close": "Close {{name}}", "statLive": "live", "statWaiting": "waiting", "statErrored": "errored", "mute": "Mute", "unmute": "Unmute", "clearAlerts": "Clear alerts", - "askPlaceholder": "Ask {{name}}… (e.g. any errors?)", - "ask": "Ask {{name}}", "send": "Send", "rename": "Click to rename {{name}}", "renameLabel": "Pet name", @@ -217,9 +213,12 @@ }, "chat": { "label": "Chat with {{name}}", - "back": "Back", "close": "Close {{name}}", "new": "New chat", + "status": "Status — click to show the sessions that need you", + "endTitle": "End this chat?", + "endBody": "It is still writing a reply. A new chat stops this session.", + "endConfirm": "Stop and start new", "openWorkspace": "Open in workspace", "typing": "typing…", "placeholder": "Ask {{name}} anything…", diff --git a/client/src/i18n/locales/vi/settings.json b/client/src/i18n/locales/vi/settings.json index fe387658..b760b7eb 100644 --- a/client/src/i18n/locales/vi/settings.json +++ b/client/src/i18n/locales/vi/settings.json @@ -37,18 +37,14 @@ "chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu vào tài khoản của bạn.", "uploadHint": "Ảnh (PNG, JPEG, GIF, WebP — không nhận SVG) hiển thị tĩnh; file .codex-pet.zip (pet.json + spritesheet.webp 1536×1872) chuyển động theo tâm trạng.", "panel": { - "label": "Trợ lý {{name}}", "live": "Trực tuyến", "offline": "Ngoại tuyến", - "close": "Đóng {{name}}", "statLive": "đang chạy", "statWaiting": "đang chờ", "statErrored": "lỗi", "mute": "Tắt tiếng", "unmute": "Bật tiếng", "clearAlerts": "Xoá cảnh báo", - "askPlaceholder": "Hỏi {{name}}… (vd: có lỗi nào không?)", - "ask": "Hỏi {{name}}", "send": "Gửi", "rename": "Bấm để đổi tên {{name}}", "renameLabel": "Tên pet", @@ -217,9 +213,12 @@ }, "chat": { "label": "Trò chuyện với {{name}}", - "back": "Quay lại", "close": "Đóng {{name}}", "new": "Cuộc trò chuyện mới", + "status": "Trạng thái — bấm để xem các phiên cần bạn", + "endTitle": "Kết thúc chat này?", + "endBody": "Nó vẫn đang trả lời. Chat mới sẽ dừng phiên này.", + "endConfirm": "Dừng và bắt đầu mới", "openWorkspace": "Mở trong workspace", "typing": "đang trả lời…", "placeholder": "Hỏi {{name}} bất cứ điều gì…", diff --git a/client/src/lib/__tests__/panePaste.test.ts b/client/src/lib/__tests__/panePaste.test.ts index e2495762..61b78cc1 100644 --- a/client/src/lib/__tests__/panePaste.test.ts +++ b/client/src/lib/__tests__/panePaste.test.ts @@ -207,6 +207,35 @@ describe("panePaste", () => { expect(m.options.map((o) => o.n)).toEqual([1, 2]); }); + it("reads Claude's unnumbered trust prompt and picks by arrows", () => { + vi.useFakeTimers(); + const m = parsePaneMenu(TRUST)!; + expect(m.options.map((o) => o.label)).toEqual(["No, exit", "Yes, I trust this folder"]); + expect(m.options.every((o) => o.n === null)).toBe(true); + expect(m.cursor).toBe(0); + expect(m.question.split("\n")[0]).toBe("Accessing workspace:"); + expect(m.question).toContain("Security guide"); + const keys: string[] = []; + const off = registerPane( + "agenthub-lane-12", + () => {}, + () => true, + (k) => keys.push(k) + ); + expect(chooseRunOption("agenthub-lane-12", m, 1)).toBe(true); + vi.runAllTimers(); + expect(keys).toEqual(["\x1b[B", "\r"]); + off(); + vi.useRealTimers(); + }); + + it("finds no unnumbered menu in Claude's input box or a wrapped prompt above it", () => { + expect(parsePaneMenu(CLAUDE)).toBeNull(); + expect( + parsePaneMenu(["❯ a long prompt that", " wrapped onto two rows", "", RULE, "❯\u00a0", RULE]) + ).toBeNull(); + }); + it("finds no menu in a reply's numbered list or a typed `1.`", () => { // No cursor on the list. expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull(); @@ -313,12 +342,35 @@ const CODEX_APPROVAL = [ " enteresc", ]; +// Claude Code's folder-trust prompt: no numbers, its footer dim (left out). +const TRUST = [ + RULE, + " Accessing workspace:", + "", + " /home/me", + "", + " Quick safety check: Is this a project you created or one you trust?", + "", + " Security guide", + "", + " ❯ No, exit", + " Yes, I trust this folder", + "", + "", +]; + describe("MENU_CURSOR_ROW", () => { // The chat's 700 ms poll reads the screen cell by cell only when a row // matches it, so every menu parsePaneMenu finds must have such a row. it("matches a row of every menu, and no row of an idle prompt", () => { const has = (rows: string[]) => rows.some((r) => MENU_CURSOR_ROW.test(r)); - for (const menu of [ASK, MULTI, PERMISSION, CODEX_APPROVAL]) { + for (const menu of [ + ASK, + MULTI, + PERMISSION, + CODEX_APPROVAL, + [...TRUST, " Enter to confirm · Esc to cancel"], + ]) { expect(parsePaneMenu(menu)).not.toBeNull(); expect(has(menu)).toBe(true); } diff --git a/client/src/lib/panePaste.ts b/client/src/lib/panePaste.ts index 39c81249..ee99c7be 100644 --- a/client/src/lib/panePaste.ts +++ b/client/src/lib/panePaste.ts @@ -176,8 +176,9 @@ export type PaneMenu = { }; const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/; -/** A row a menu's cursor sits on (a numbered option or Submit): a menu has one. */ -export const MENU_CURSOR_ROW = /^ *[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)/; +/** A row only a menu shows: its cursor on a numbered option or Submit, or the + * "Enter to confirm" footer of Claude's unnumbered menus (the trust prompt). */ +export const MENU_CURSOR_ROW = /^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)|Enter to confirm)/; const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/; const RULE_ROW = /^ *[─╌]{3,} *$/; const CHECK = /^\[([ ✔])\] *(.*)$/; @@ -224,7 +225,8 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null { } } const numbered = options.filter((o) => o.n !== null); - if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? "")) return null; + if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? "")) + return parseBareMenu(r.slice(0, end + 1)); if (numbered.some((o, i) => o.n !== i + 1)) return null; // The question: the block of text right above the first option. let q = top - 1; @@ -241,6 +243,39 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null { }; } +const BARE_CURSOR = /^( {0,4})❯[ \u00a0]+(\S.*)$/; + +/** Claude's UNNUMBERED menu at the bottom of `r` (the folder-trust prompt: + * `❯ No, exit` over ` Yes, I trust this folder`), or null. Its rows share the + * cursor row's text column, and nothing under them is a rule — Claude's input + * box draws one above and below its `❯` row, so neither the box nor a prompt + * in the history above it reads as a menu. Picked by arrows and Enter. */ +function parseBareMenu(r: string[]): PaneMenu | null { + let at = -1; + for (let i = r.length - 1; i >= 0 && at < 0; i--) if (BARE_CURSOR.test(r[i] ?? "")) at = i; + if (at < 0 || RULE_ROW.test(r[at - 1] ?? "") || r.slice(at + 1).some((l) => RULE_ROW.test(l))) + return null; + const m = BARE_CURSOR.exec(r[at]!)!; + const indent = " ".repeat((m[1] ?? "").length + 2); + const sibling = (l = "") => l.startsWith(indent) && /\S/.test(l[indent.length] ?? " "); + let top = at; + while (top > 0 && sibling(r[top - 1])) top--; + let last = at; + while (last < r.length - 1 && sibling(r[last + 1])) last++; + if (last === top) return null; + const options = r.slice(top, last + 1).map((l, i) => ({ + n: null, + label: top + i === at ? (m[2] ?? "").trim() : l.trim(), + hint: "", + checked: null, + })); + // The question: the paragraphs above, up to the dialog's top rule. + const lines: string[] = []; + for (let q = top - 1; q >= 0 && !RULE_ROW.test(r[q]!) && lines.length < 8; q--) + if (r[q]) lines.unshift(r[q]!.trim()); + return { question: lines.join("\n"), options, cursor: at - top, multi: false, typeAt: -1 }; +} + /** The "Type something" row. Text typed into it replaces its label, and it is * still the last numbered row above "Chat about this". */ function typeRow(options: PaneMenuOption[]): number { diff --git a/docs/API.md b/docs/API.md index a3219ed4..d2c84baf 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2954,7 +2954,7 @@ POST /api/pets/featured/:id Download a FEATURED pet from codex-pets.ne DELETE /api/pets/:id Delete a pet (its folder under the data dir) ``` -The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn (with its display name, which the mascot goes by) and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); a name the user types by clicking it in the mascot's panel is `agenthub.pet.names` (`{ [petId]: name }`, empty = the pet's own); the pets live on the server, one folder each under `/pets//` with a `meta.json` AgentHub writes. +The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn (with its display name, which the mascot goes by) and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); a name the user types by clicking it in the mascot's chat window header is `agenthub.pet.names` (`{ [petId]: name }`, empty = the pet's own); the pets live on the server, one folder each under `/pets//` with a `meta.json` AgentHub writes. A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames — or a v2 atlas (`spriteVersionNumber: 2`, 1536×2288, 11 rows) whose rows 9–10 add 16 gaze poses, 22.5° apart clockwise from straight up. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, a live session at rest → review, idle/asleep → idle), and on an event it plays a moment over the mood (a new session → waving, a finish or a pat → jumping, a new subagent → review, a session waiting on you → waiting, an error → failed); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves, and a v2 pet at idle turns toward the pointer (back to idle 2 s after the pointer rests). Frames play at Codex's own timings (`codex-rs/tui/src/pets/model.rs`: e.g. running 120 ms a frame, its last 220 ms); idle plays at the drawn pace (280/110/110/140/140/320 ms), a sleeping pet at Codex's 6× slower one. Waving, jumping, failed and review are moments — they play three times and settle into idle; running, waiting and the run rows loop. Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly one of those sizes (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script. diff --git a/scripts/restart-helper.js b/scripts/restart-helper.js index 362739c9..d44dce32 100644 --- a/scripts/restart-helper.js +++ b/scripts/restart-helper.js @@ -3,14 +3,20 @@ * @file Detached helper spawned by `server/lib/self-restart.js`. Polls the * old dashboard PID until it exits (server/index.js's own SIGTERM handler * has a 5s force-exit backstop, so this never waits forever), then starts a - * fresh server from the same entry point and exits itself. + * fresh server from the same entry point and exits itself. The new server's + * stdout/stderr are appended to the log file it is given (the checkout's + * `data/agenthub-server.log`, the one `agenthub start` writes), so a crash + * after an update still leaves Node's stack trace behind. * @author Nguyễn Ngọc Trí Vĩ */ require("../server/lib/windows-hide").install(); // before child_process is read +const fs = require("fs"); +const path = require("path"); const { spawn } = require("child_process"); +const { rotateIfLarge } = require("./log-rotate"); -const [, , pidArg, entry] = process.argv; +const [, , pidArg, entry, logArg] = process.argv; const pid = parseInt(pidArg, 10); // ponytail: fixed poll cadence/cap, not configurable — this script has one caller. @@ -26,10 +32,25 @@ function isAlive(p) { } } +/** Append fd for the new server's output, or "ignore" when there is none. */ +function openLog(file) { + if (!file) return "ignore"; + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + rotateIfLarge(file); + const fd = fs.openSync(file, "a"); + fs.writeSync(fd, `[${new Date().toISOString()}] restart-helper: old server ${pid} gone, starting ${entry}\n`); + return fd; + } catch { + return "ignore"; // a log must never stop the restart + } +} + function restart() { + const out = openLog(logArg); const child = spawn(process.execPath, [entry], { detached: true, - stdio: "ignore", + stdio: ["ignore", out, out], env: process.env, }); child.unref(); diff --git a/server/__tests__/pty-attach.test.js b/server/__tests__/pty-attach.test.js index 113012fe..ed013bae 100644 --- a/server/__tests__/pty-attach.test.js +++ b/server/__tests__/pty-attach.test.js @@ -204,6 +204,45 @@ describe("pty-attach", () => { assert.deepEqual(JSON.parse(a.__sent.at(-1).data), { type: "lead" }); }); + it("survives a stopped pane: pipe errors, typing and resizes after the attach exited", () => { + // Windows node-pty re-throws a read-pipe error nobody listens for, and its + // write pipe has no listener at all: Stop (kill-session) ended the attach + // with EPIPE and the uncaught error killed the whole dashboard. + const ptys = []; + ptyAttach.__setSpawnImpl(() => { + const p = makeFakePty(); + p.on = (evt, fn) => p.__emitter.on(evt, fn); + p._agent = { inSocket: new EventEmitter() }; + p.resize = () => { + if (p.__gone) throw new Error("Cannot resize a pty that has already exited"); + }; + p.write = () => { + if (p.__gone) throw new Error("write EPIPE"); + }; + ptys.push(p); + return p; + }); + const a = makeFakeWs(); + const b = makeFakeWs(); + a.close = b.close = () => {}; + ptyAttach.attach(a, "agenthub-lane-1", { cols: 80, rows: 24 }); + ptyAttach.attach(b, "agenthub-lane-1", { cols: 80, rows: 24 }); + + const epipe = Object.assign(new Error("read EPIPE"), { code: "EPIPE" }); + assert.doesNotThrow(() => ptys[0].__emitter.emit("error", epipe)); + assert.doesNotThrow(() => ptys[0]._agent.inSocket.emit("error", epipe)); + + ptys[0].__gone = true; + ptys[0].__emitter.emit("exit", { exitCode: 1 }); // a's close event not yet in + const msg = (ws, data, binary) => ws.__emitter.emit("message", Buffer.from(data), { binary }); + assert.doesNotThrow(() => + msg(b, JSON.stringify({ type: "resize", cols: 90, rows: 30, drive: true }), false) + ); + assert.doesNotThrow(() => msg(a, "x", true)); + ptys[1].__gone = true; // exited, onExit still queued + assert.doesNotThrow(() => msg(b, "y", true)); + }); + it("kills the PTY attach process when the WS connection closes", () => { const ws = makeFakeWs(); ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 }); diff --git a/server/__tests__/self-restart.test.js b/server/__tests__/self-restart.test.js index dab2c4dd..a9c26426 100644 --- a/server/__tests__/self-restart.test.js +++ b/server/__tests__/self-restart.test.js @@ -3,12 +3,17 @@ * @description After an update, a server our service unit runs is restarted * by the unit, never by a detached replacement: v2.7.2 on the S3 system unit * left the replacement holding the port while `Restart=always` started the - * unit every 3 s, each start exiting "already running". + * unit every 3 s, each start exiting "already running". Without a unit, the + * detached helper's replacement server logs to `data/agenthub-server.log`. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it } = require("node:test"); const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); const { scheduleRestart, owningUnit } = require("../lib/self-restart"); function deps(unit, restartAnswer = "restarted") { @@ -23,8 +28,9 @@ function deps(unit, restartAnswer = "restarted") { }, }), kill: () => calls.kill++, - spawnHelper: () => { + spawnHelper: (_exe, args) => { calls.spawn++; + calls.helperArgs = args; return { unref() {} }; }, }; @@ -84,5 +90,27 @@ describe("self-restart", () => { assert.equal(scheduleRestart("/r", d), "helper"); assert.equal(d.calls.spawn, 1); assert.equal(d.calls.kill, 1); + // The new server logs where `agenthub start` does — never into nothing. + assert.equal(d.calls.helperArgs.at(-1), path.join("/r", "data", "agenthub-server.log")); + }); + + it("restart-helper appends the new server's stdout and stderr to the log", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "restart-helper-")); + const entry = path.join(dir, "entry.js"); + const log = path.join(dir, "data", "agenthub-server.log"); + fs.writeFileSync(entry, 'console.log("up"); throw new Error("boom");\n'); + const helper = path.join(__dirname, "..", "..", "scripts", "restart-helper.js"); + // pid 0x7fffffff is never alive, so the helper restarts at once. + execFileSync(process.execPath, [helper, "2147483647", entry, log]); + const deadline = Date.now() + 5000; + let text = ""; + while (Date.now() < deadline && !/boom/.test(text)) { + await new Promise((r) => setTimeout(r, 50)); + text = fs.existsSync(log) ? fs.readFileSync(log, "utf8") : ""; + } + fs.rmSync(dir, { recursive: true, force: true }); + assert.match(text, /restart-helper: old server 2147483647 gone/); + assert.match(text, /^up$/m); + assert.match(text, /Error: boom/); }); }); diff --git a/server/lib/pty-attach.js b/server/lib/pty-attach.js index 89018e17..1b307591 100644 --- a/server/lib/pty-attach.js +++ b/server/lib/pty-attach.js @@ -84,17 +84,49 @@ const tell = (v, msg) => { } }; +// node-pty throws from resize() once its process has exited ("Cannot resize a +// pty that has already exited" on Windows), and a viewer's attach can exit +// before its socket's close event takes it out of the group — a throw here, +// inside a WebSocket message handler, would take the whole server down. +const resize = (v, cols, rows) => { + if (v.exited) return; + try { + v.pty.resize(cols, rows); + } catch { + /* attach already gone — its exit/close handlers clean up */ + } +}; + /** `v` sizes the window now: every other attach is held at its size and told. */ function drive(group, v) { group.driver = v; - v.pty.resize(v.cols, v.rows); + resize(v, v.cols, v.rows); for (const o of group.viewers) { if (o === v) continue; - o.pty.resize(v.cols, v.rows); + resize(o, v.cols, v.rows); tell(o, { type: "size", cols: v.cols, rows: v.rows }); } } +/** + * Windows node-pty re-throws any read-pipe error that is not EIO unless a + * second `error` listener exists, and its write pipe has no listener at all — + * so the EPIPE/ECONNRESET a pane's attach raises when the session is stopped + * (Stop, kill-session) became an uncaught exception that killed the dashboard. + * The attach's exit is reported by `onExit`; the error itself carries nothing. + */ +function swallowPipeErrors(pty) { + const ignore = () => {}; + try { + pty.on?.("error", ignore); + } catch { + /* no event emitter on this pty — nothing to guard */ + } + // Private on node-pty's Windows agent; absent elsewhere and in tests. + const input = pty._agent?.inSocket; + if (input && typeof input.on === "function") input.on("error", ignore); +} + /** * Attach `ws` to the tmux session `runId`. Spawns one PTY-backed * `tmux attach-session -t ` per call. @@ -132,7 +164,8 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) { // newer one VS Code uses for the same reason. Ignored elsewhere. ...(platform === "win32" && { useConptyDll: true }), }); - const me = { ws, pty, cols: cols || 80, rows: rows || 24 }; + swallowPipeErrors(pty); + const me = { ws, pty, cols: cols || 80, rows: rows || 24, exited: false }; group.viewers.add(me); if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows }); else group.driver = me; @@ -179,16 +212,22 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) { } }; function write(text) { + if (me.exited) return; // the attach is gone: nothing reads this pipe const wait = holdUntil - Date.now(); if (held.length || wait > 0) { if (held.push(text) === 1) setTimeout(flush, Math.max(0, wait)); return; } - pty.write(text); + try { + pty.write(text); + } catch { + return; // exited between the check and the write — onExit follows + } if (holdsPaste && text.includes("\x1b[201~")) holdUntil = Date.now() + PASTE_HOLD_MS; } pty.onExit(({ exitCode }) => { + me.exited = true; try { ws.send(JSON.stringify({ type: "exit", code: exitCode }), { binary: false }); } catch { diff --git a/server/lib/self-restart.js b/server/lib/self-restart.js index 62411ef4..726e3c3c 100644 --- a/server/lib/self-restart.js +++ b/server/lib/self-restart.js @@ -77,7 +77,10 @@ function scheduleRestart(root = path.join(__dirname, "..", ".."), deps = {}) { } const helperPath = path.join(root, "scripts", "restart-helper.js"); const entry = process.argv[1] || path.join(root, "server", "index.js"); - const helper = spawnHelper(process.execPath, [helperPath, String(process.pid), entry], { + // The same log `agenthub start` writes: without it every server started by + // an update ran with no stdout/stderr at all, and a crash left no trace. + const logFile = path.join(root, "data", "agenthub-server.log"); + const helper = spawnHelper(process.execPath, [helperPath, String(process.pid), entry, logFile], { cwd: root, detached: true, stdio: "ignore",