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.
This commit is contained in:
+8
-8
@@ -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.
|
- **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.
|
- **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<br/>shell: open/closed state,<br/>⌘B / Esc, reduced-motion,<br/>navigation"]
|
SHELL["Sagi.tsx<br/>shell: open/closed state,<br/>⌘B / Esc, reduced-motion,<br/>navigation"]
|
||||||
AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"]
|
AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"]
|
||||||
BUBBLE["SpeechBubble.tsx<br/>bubble"]
|
BUBBLE["SpeechBubble.tsx<br/>bubble"]
|
||||||
PANEL["SagiPanel.tsx<br/>status + Needs you + Mute / Clear alerts + Ask box"]
|
PANEL["PetChat.tsx + SagiInfo.tsx<br/>the chat window: status strip, Needs you,<br/>Mute / Clear alerts, local answers, AI chat"]
|
||||||
CSS["sagi.css<br/>keyframes + per-mood expressions"]
|
CSS["sagi.css<br/>keyframes + per-mood expressions"]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2386,8 +2386,8 @@ flowchart LR
|
|||||||
SUB --> DERIVED["derived state<br/>{ mood, status, bubble }"]
|
SUB --> DERIVED["derived state<br/>{ mood, status, bubble }"]
|
||||||
DERIVED --> AVATAR["SagiAvatar"]
|
DERIVED --> AVATAR["SagiAvatar"]
|
||||||
DERIVED --> BUBBLE["SpeechBubble"]
|
DERIVED --> BUBBLE["SpeechBubble"]
|
||||||
DERIVED --> PANEL["SagiPanel"]
|
DERIVED --> PANEL["PetChat (+ SagiInfo)"]
|
||||||
PANEL -->|"unmatched Ask"| ENSURE["POST /api/lanes/ensure<br/>(adopt 'Sagi' lane, cwd = home)"]
|
PANEL -->|"unmatched question"| ENSURE["POST /api/lanes/ensure<br/>(adopt 'Sagi' lane, cwd = home)"]
|
||||||
ENSURE --> START["POST /api/run<br/>(spawn tmux-backed claude<br/>in Sagi lane, initialPrompt=…)"]
|
ENSURE --> START["POST /api/run<br/>(spawn tmux-backed claude<br/>in Sagi lane, initialPrompt=…)"]
|
||||||
START --> RUN["/run?laneId=…<br/>(existing Run page picks up<br/>the live run)"]
|
START --> RUN["/run?laneId=…<br/>(existing Run page picks up<br/>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 }`. |
|
| **`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 `<svg>` 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. |
|
| **`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 `<svg>` 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/<id>`; never a resume). |
|
| **`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/<id>`; 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. |
|
| **`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 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. |
|
| **`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 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. |
|
| **`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 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. |
|
| **`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. |
|
| **`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. |
|
| **`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. |
|
| **`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. |
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* @file PetChat.tsx
|
* @file PetChat.tsx
|
||||||
* @description The pet's chat window. A question its Ask box cannot answer
|
* @description The pet's chat window — its whole UI: clicking the pet opens
|
||||||
* locally opens here instead of leaving the page: the first message starts a
|
* 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
|
* 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
|
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
|
||||||
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
|
* 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
|
* 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)
|
* 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
|
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
|
||||||
* `localStorage` (`agenthub.pet.chat`) while its pane lives; **New chat**
|
* `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and
|
||||||
* forgets it (the session stays in the workspace), **Open in workspace**
|
* 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
|
* opens its pane there. Opening never resumes an ended session: once the pane
|
||||||
* is gone, the next message starts a new one.
|
* is gone, the next message starts a new one.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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 { 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 { api } from "../../lib/api";
|
||||||
import type { TranscriptMessage } from "../../lib/types";
|
import type { TranscriptMessage } from "../../lib/types";
|
||||||
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
|
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
|
||||||
@@ -33,6 +38,8 @@ import {
|
|||||||
type PaneMenu,
|
type PaneMenu,
|
||||||
} from "../../lib/panePaste";
|
} from "../../lib/panePaste";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||||
import { TerminalView } from "../run/TerminalView";
|
import { TerminalView } from "../run/TerminalView";
|
||||||
import { PaneMenuCard } from "../run/PaneChat";
|
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(" · ");
|
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 {
|
interface PetChatProps {
|
||||||
/** The pet's shown name, for the header and greeting. */
|
/** The pet's shown name, for the header and greeting. */
|
||||||
name: string;
|
name: string;
|
||||||
/** A question to send as soon as the chat opens (the Ask box's handoff). */
|
/** Shown in place of the plain name (the rename button). */
|
||||||
initialPrompt?: string | null;
|
title?: ReactNode;
|
||||||
/** Called once the initial prompt has been taken, so a re-render never sends it twice. */
|
/** Extra header buttons, before Open in workspace (the pat heart). */
|
||||||
onPromptTaken?: () => void;
|
headerActions?: ReactNode;
|
||||||
onBack: () => void;
|
/** 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;
|
onClose: () => void;
|
||||||
/** Opens the chat's pane in its workspace. */
|
/** Opens the chat's pane in its workspace. */
|
||||||
onOpenWorkspace: (laneId: number, runId: string) => void;
|
onOpenWorkspace: (laneId: number, runId: string) => void;
|
||||||
@@ -158,9 +172,10 @@ interface PetChatProps {
|
|||||||
|
|
||||||
export function PetChat({
|
export function PetChat({
|
||||||
name,
|
name,
|
||||||
initialPrompt,
|
title,
|
||||||
onPromptTaken,
|
headerActions,
|
||||||
onBack,
|
info,
|
||||||
|
onLocal,
|
||||||
onClose,
|
onClose,
|
||||||
onOpenWorkspace,
|
onOpenWorkspace,
|
||||||
onBusy,
|
onBusy,
|
||||||
@@ -175,6 +190,8 @@ export function PetChat({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
||||||
|
// The pet's own answers, given before any chat ran.
|
||||||
|
const [local, setLocal] = useState<Line[]>([]);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const remember = (ref: PetChatRef | null) => {
|
const remember = (ref: PetChatRef | null) => {
|
||||||
@@ -248,7 +265,7 @@ export function PetChat({
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [chat]);
|
}, [chat]);
|
||||||
|
|
||||||
const lines = chatLines(messages);
|
const lines = [...local, ...chatLines(messages)];
|
||||||
// The pending line shows until the transcript has it.
|
// The pending line shows until the transcript has it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
|
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
|
||||||
@@ -269,6 +286,19 @@ export function PetChat({
|
|||||||
const prompt = text.trim();
|
const prompt = text.trim();
|
||||||
if (!prompt || starting) return;
|
if (!prompt || starting) return;
|
||||||
setError(null);
|
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);
|
setPending(prompt);
|
||||||
if (chat) {
|
if (chat) {
|
||||||
// Typed into the attached pane; it may still be attaching.
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[chat, starting, t]
|
[chat, starting, t, onLocal]
|
||||||
);
|
);
|
||||||
|
|
||||||
// The Ask box's question, sent once.
|
// New chat: the old one's session is stopped, not left running unseen —
|
||||||
const taken = useRef(false);
|
// asking first only while it is still writing a reply.
|
||||||
useEffect(() => {
|
const newChat = async () => {
|
||||||
if (!initialPrompt || taken.current) return;
|
if (chat) {
|
||||||
taken.current = true;
|
if (
|
||||||
onPromptTaken?.();
|
busy &&
|
||||||
void send(initialPrompt);
|
!(await confirmDialog({
|
||||||
}, [initialPrompt, onPromptTaken, send]);
|
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) => {
|
const submit = (e?: FormEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
@@ -333,11 +381,11 @@ export function PetChat({
|
|||||||
>
|
>
|
||||||
{/* header */}
|
{/* header */}
|
||||||
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||||
<IconButton label={t("sagi.chat.back")} onClick={onBack}>
|
<span className="pl-1.5 text-base leading-none" aria-hidden>
|
||||||
<ArrowLeft size={16} />
|
🐾
|
||||||
</IconButton>
|
</span>
|
||||||
<div className="min-w-0 flex-1 px-1">
|
<div className="min-w-0 flex-1 px-1">
|
||||||
<div className="truncate text-sm font-semibold text-fg-primary">{name}</div>
|
{title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
|
||||||
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
|
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||||
@@ -352,6 +400,7 @@ export function PetChat({
|
|||||||
{busy ? t("sagi.chat.typing") : toolLabel}
|
{busy ? t("sagi.chat.typing") : toolLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{headerActions}
|
||||||
{chat && (
|
{chat && (
|
||||||
<IconButton
|
<IconButton
|
||||||
label={t("sagi.chat.openWorkspace")}
|
label={t("sagi.chat.openWorkspace")}
|
||||||
@@ -362,8 +411,8 @@ export function PetChat({
|
|||||||
)}
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
label={t("sagi.chat.new")}
|
label={t("sagi.chat.new")}
|
||||||
onClick={() => remember(null)}
|
onClick={() => void newChat()}
|
||||||
disabled={!chat || starting}
|
disabled={(!chat && !local.length) || starting}
|
||||||
>
|
>
|
||||||
<SquarePen size={15} />
|
<SquarePen size={15} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -372,6 +421,8 @@ export function PetChat({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{info}
|
||||||
|
|
||||||
{/* conversation */}
|
{/* conversation */}
|
||||||
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||||
{empty && (
|
{empty && (
|
||||||
@@ -475,10 +526,9 @@ export function PetChat({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submit();
|
submit();
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
// Esc leaves the chat for the panel, not the whole mascot.
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.nativeEvent.stopImmediatePropagation();
|
e.nativeEvent.stopImmediatePropagation();
|
||||||
onBack();
|
onClose();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,17 +5,20 @@
|
|||||||
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
|
* 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
|
* tick, WS reducer) runs only while it is on. Persists across routes and
|
||||||
* shares the single WebSocket.
|
* 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
|
* detection, and route navigation. Reactive personality + status/Ask come
|
||||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||||
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
|
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
|
||||||
* bubble/panel render in a self-clamping flyout so
|
* bubble/panel render in a self-clamping flyout so
|
||||||
* they never spill off any screen edge regardless of where the mascot is docked.
|
* 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 "do the job" path: an unmatched question is the chat's — it starts a
|
||||||
* the "Sagi" workspace carrying it as the first prompt, then opens that
|
* session of the default AI tool in the "Sagi" workspace; a default tool the
|
||||||
* workspace's session view (`/workspace?lane=<id>&run=<runId>`) — no new LLM
|
* chat cannot read back starts there and its session view opens
|
||||||
* backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
* (`/workspace?lane=<id>&run=<runId>`) — no new LLM backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
||||||
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
|
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -39,7 +42,7 @@
|
|||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./SagiAvatar`
|
* - `./SagiAvatar`
|
||||||
* - `./SpeechBubble`
|
* - `./SpeechBubble`
|
||||||
* - `./SagiPanel`
|
* - `./SagiInfo`
|
||||||
* - `./useSagiBrain`
|
* - `./useSagiBrain`
|
||||||
* - `./useSagiPosition`
|
* - `./useSagiPosition`
|
||||||
* - `./intents`
|
* - `./intents`
|
||||||
@@ -83,7 +86,7 @@ import { SagiAvatar } from "./SagiAvatar";
|
|||||||
import { PetSprite } from "./PetSprite";
|
import { PetSprite } from "./PetSprite";
|
||||||
import { PetMenu } from "./PetMenu";
|
import { PetMenu } from "./PetMenu";
|
||||||
import { SpeechBubble } from "./SpeechBubble";
|
import { SpeechBubble } from "./SpeechBubble";
|
||||||
import { SagiPanel } from "./SagiPanel";
|
import { PetNameButton, SagiInfo } from "./SagiInfo";
|
||||||
import { useSagiBrain } from "./useSagiBrain";
|
import { useSagiBrain } from "./useSagiBrain";
|
||||||
import { useSagiPosition } from "./useSagiPosition";
|
import { useSagiPosition } from "./useSagiPosition";
|
||||||
import { useSagiEnabled } from "./SagiMount";
|
import { useSagiEnabled } from "./SagiMount";
|
||||||
@@ -94,7 +97,8 @@ import { api } from "../../lib/api";
|
|||||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||||
import { workspaceHref } from "../SessionRowMenu";
|
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
|
// 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.
|
// 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.
|
// The name it goes by: the user's nickname, the pet's own, else Sagi.
|
||||||
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
||||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||||
|
// The chat window (PetChat.tsx): the pet's whole UI.
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// The chat window (PetChat.tsx), and the Ask question it should send.
|
|
||||||
const [chatOpen, setChatOpen] = useState(false);
|
|
||||||
const [chatPrompt, setChatPrompt] = useState<string | null>(null);
|
|
||||||
// The right-click pet menu (`PetMenu.tsx`).
|
// The right-click pet menu (`PetMenu.tsx`).
|
||||||
const [menu, setMenu] = useState(false);
|
const [menu, setMenu] = useState(false);
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
@@ -296,7 +298,6 @@ function SagiBody() {
|
|||||||
setOpen((v) => !v);
|
setOpen((v) => !v);
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setChatOpen(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
@@ -346,7 +347,6 @@ function SagiBody() {
|
|||||||
navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`);
|
navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`);
|
||||||
brain.dismissBubble();
|
brain.dismissBubble();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setChatOpen(false);
|
|
||||||
};
|
};
|
||||||
api.run
|
api.run
|
||||||
.list()
|
.list()
|
||||||
@@ -356,21 +356,17 @@ function SagiBody() {
|
|||||||
[brain, navigate]
|
[brain, navigate]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onAsk = useCallback(
|
// Asked by the chat before any session runs.
|
||||||
(query: string): string | null => {
|
const onLocal = useCallback(
|
||||||
|
(query: string): PetLocalReply => {
|
||||||
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
|
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
|
||||||
if (result.kind === "answer") {
|
if (result.kind === "answer") {
|
||||||
if (result.moment) brain.playMoment(result.moment);
|
if (result.moment) brain.playMoment(result.moment);
|
||||||
return result.text;
|
return { answer: result.text };
|
||||||
}
|
|
||||||
// Handoff: a question the pet cannot answer goes to the chat window,
|
|
||||||
// which starts a session of the default AI tool and shows it as chat.
|
|
||||||
if (petChatTool()) {
|
|
||||||
setChatPrompt(result.prompt);
|
|
||||||
setOpen(false);
|
|
||||||
setChatOpen(true);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
// A 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
|
// A default tool the chat cannot read back (not Claude/Codex): start it
|
||||||
// in the "Sagi" workspace and open its pane there, as before.
|
// in the "Sagi" workspace and open its pane there, as before.
|
||||||
const prompt = result.prompt;
|
const prompt = result.prompt;
|
||||||
@@ -392,7 +388,7 @@ function SagiBody() {
|
|||||||
brain.setThinking(false);
|
brain.setThinking(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return null;
|
return { handled: true };
|
||||||
},
|
},
|
||||||
[brain, navigate, t, name]
|
[brain, navigate, t, name]
|
||||||
);
|
);
|
||||||
@@ -431,46 +427,49 @@ function SagiBody() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* The panel is too big to drag around, so it hides mid-drag. The bubble
|
|
||||||
does not - it is Sagi talking, and it has to stay by its mouth. */}
|
|
||||||
{!place.dragging && open && (
|
|
||||||
<SagiFlyout anchor={anchor}>
|
|
||||||
<SagiPanel
|
|
||||||
name={name}
|
|
||||||
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
|
||||||
nickname={sagiPrefs.getNickname(pet.id)}
|
|
||||||
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
|
||||||
onPat={brain.pat}
|
|
||||||
attention={brain.attention}
|
|
||||||
onOpenSession={openSession}
|
|
||||||
status={brain.status}
|
|
||||||
muted={brain.muted}
|
|
||||||
onToggleMute={brain.toggleMute}
|
|
||||||
onClearAlerts={brain.clearAlerts}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
onAsk={onAsk}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
</SagiFlyout>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
|
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
|
||||||
it 1:1), and kept mounted: unmounting would detach its terminal and
|
it 1:1). Closing unmounts it; its chat is remembered, so reopening
|
||||||
lose a half-typed message. */}
|
re-attaches the same session. */}
|
||||||
{chatOpen && (
|
{open && (
|
||||||
<SagiFlyout anchor={anchor} beside>
|
<SagiFlyout anchor={anchor} beside>
|
||||||
<PetChat
|
<PetChat
|
||||||
name={name}
|
name={name}
|
||||||
initialPrompt={chatPrompt}
|
title={
|
||||||
onPromptTaken={() => setChatPrompt(null)}
|
<PetNameButton
|
||||||
onBack={() => {
|
name={name}
|
||||||
setChatOpen(false);
|
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
||||||
setOpen(true);
|
nickname={sagiPrefs.getNickname(pet.id)}
|
||||||
}}
|
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
||||||
onClose={() => setChatOpen(false)}
|
/>
|
||||||
|
}
|
||||||
|
headerActions={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
||||||
|
onClick={brain.pat}
|
||||||
|
aria-label={t("sagi.panel.pat", { name })}
|
||||||
|
title={t("sagi.panel.pat", { name })}
|
||||||
|
>
|
||||||
|
<Heart size={15} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
info={
|
||||||
|
<SagiInfo
|
||||||
|
attention={brain.attention}
|
||||||
|
onOpenSession={openSession}
|
||||||
|
status={brain.status}
|
||||||
|
muted={brain.muted}
|
||||||
|
onToggleMute={brain.toggleMute}
|
||||||
|
onClearAlerts={brain.clearAlerts}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
defaultOpen={brain.attention.length > 0}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onLocal={onLocal}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
onOpenWorkspace={(laneId, runId) => {
|
onOpenWorkspace={(laneId, runId) => {
|
||||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||||
setChatOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
onBusy={onBusy}
|
onBusy={onBusy}
|
||||||
/>
|
/>
|
||||||
@@ -491,7 +490,7 @@ function SagiBody() {
|
|||||||
</SagiFlyout>
|
</SagiFlyout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!open && !menu && !chatOpen && brain.bubble && (
|
{!open && !menu && brain.bubble && (
|
||||||
<SagiFlyout anchor={anchor}>
|
<SagiFlyout anchor={anchor}>
|
||||||
<SpeechBubble
|
<SpeechBubble
|
||||||
bubble={brain.bubble}
|
bubble={brain.bubble}
|
||||||
@@ -519,18 +518,16 @@ function SagiBody() {
|
|||||||
// A drag just ended - swallow the synthetic click so the panel
|
// A drag just ended - swallow the synthetic click so the panel
|
||||||
// doesn't toggle when the user only repositioned the avatar.
|
// doesn't toggle when the user only repositioned the avatar.
|
||||||
if (place.consumeDrag()) return;
|
if (place.consumeDrag()) return;
|
||||||
if (chatOpen) setChatOpen(false);
|
setOpen((v) => !v);
|
||||||
else setOpen((v) => !v);
|
|
||||||
}}
|
}}
|
||||||
// Right-click: the pet menu (hide, size, choose, upload, import).
|
// Right-click: the pet menu (hide, size, choose, upload, import).
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setChatOpen(false);
|
|
||||||
setMenu(true);
|
setMenu(true);
|
||||||
}}
|
}}
|
||||||
aria-label={open || chatOpen ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
|
aria-label={open ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
|
||||||
aria-expanded={open || chatOpen}
|
aria-expanded={open}
|
||||||
title={t("sagi.avatarTitle", { name })}
|
title={t("sagi.avatarTitle", { name })}
|
||||||
>
|
>
|
||||||
{pet.id === "sagi" || !("kind" in pet) ? (
|
{pet.id === "sagi" || !("kind" in pet) ? (
|
||||||
|
|||||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
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<string | null>(null);
|
||||||
|
const save = () => {
|
||||||
|
if (renaming === null) return;
|
||||||
|
onRename(renaming);
|
||||||
|
setRenaming(null);
|
||||||
|
};
|
||||||
|
return renaming === null ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="max-w-full truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
|
||||||
|
onClick={() => setRenaming(nickname)}
|
||||||
|
aria-label={t("sagi.panel.rename", { name })}
|
||||||
|
title={t("sagi.panel.rename", { name })}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
|
||||||
|
value={renaming}
|
||||||
|
maxLength={PET_NAME_MAX}
|
||||||
|
placeholder={ownName}
|
||||||
|
aria-label={t("sagi.panel.renameLabel")}
|
||||||
|
title={t("sagi.panel.renameHint")}
|
||||||
|
onChange={(e) => setRenaming(e.target.value)}
|
||||||
|
onBlur={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<AttentionItem>;
|
||||||
|
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 (
|
||||||
|
<div className="pet-chat-info border-b border-border/60 px-3 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-1.5 rounded-lg text-left"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={t("sagi.chat.status")}
|
||||||
|
title={t("sagi.chat.status")}
|
||||||
|
>
|
||||||
|
<StatChip
|
||||||
|
icon={Radio}
|
||||||
|
label={t("sagi.panel.statLive")}
|
||||||
|
value={status.liveCount}
|
||||||
|
tone={status.liveCount > 0 ? "accent" : "muted"}
|
||||||
|
/>
|
||||||
|
<StatChip
|
||||||
|
icon={Hourglass}
|
||||||
|
label={t("sagi.panel.statWaiting")}
|
||||||
|
value={status.waitingCount}
|
||||||
|
tone={status.waitingCount > 0 ? "amber" : "muted"}
|
||||||
|
/>
|
||||||
|
<StatChip
|
||||||
|
icon={AlertTriangle}
|
||||||
|
label={t("sagi.panel.statErrored")}
|
||||||
|
value={status.errorCount}
|
||||||
|
tone={status.errorCount > 0 ? "red" : "muted"}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`ml-auto inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||||
|
status.connected
|
||||||
|
? "bg-status-success/15 text-status-success"
|
||||||
|
: "bg-status-danger/15 text-status-danger"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||||
|
status.connected ? "bg-status-success" : "bg-status-danger"
|
||||||
|
}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
|
||||||
|
</span>
|
||||||
|
<Chevron size={13} className="shrink-0 text-fg-muted" aria-hidden />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2 max-h-48 overflow-y-auto">
|
||||||
|
{/* the sessions that need the user, by name */}
|
||||||
|
{attention.length > 0 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">
|
||||||
|
{t("sagi.panel.attention")}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{attention.slice(0, ATTENTION_SHOWN).map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<button
|
||||||
|
className="group flex w-full items-center gap-2 rounded-lg bg-surface-1 px-2 py-1.5 text-left text-xs transition-colors hover:bg-surface-4"
|
||||||
|
onClick={() => onOpenSession(item)}
|
||||||
|
title={item.cwd ?? item.id}
|
||||||
|
>
|
||||||
|
{item.kind === "error" ? (
|
||||||
|
<AlertTriangle
|
||||||
|
size={13}
|
||||||
|
className="shrink-0 text-status-danger"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Hourglass size={13} className="shrink-0 text-status-warning" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-fg-secondary group-hover:text-fg-primary">
|
||||||
|
{subjectLabel(item)}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[10px] text-fg-muted">
|
||||||
|
{item.kind === "error"
|
||||||
|
? t("sagi.panel.attentionError")
|
||||||
|
: t("sagi.panel.attentionWaiting")}
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={12} className="shrink-0 text-fg-muted" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{attention.length > ATTENTION_SHOWN && (
|
||||||
|
<button
|
||||||
|
className="mt-1 text-[11px] text-accent hover:underline"
|
||||||
|
onClick={() => onNavigate("/sessions")}
|
||||||
|
>
|
||||||
|
{t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<ActionButton
|
||||||
|
icon={muted ? BellOff : Bell}
|
||||||
|
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
||||||
|
onClick={onToggleMute}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("sagi.panel.clearAlerts")}
|
||||||
|
disabled={status.errorCount === 0}
|
||||||
|
onClick={onClearAlerts}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, Tone> = {
|
||||||
|
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 (
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 ${t.wrap}`}>
|
||||||
|
<Icon size={11} className={t.icon} aria-hidden />
|
||||||
|
<span className={`text-xs font-semibold tabular-nums ${t.value}`}>{value}</span>
|
||||||
|
<span className="text-[9px] uppercase tracking-wider text-fg-muted">{label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Icon size={14} className="shrink-0" />
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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ĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
/* =============================================================================
|
|
||||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
|
||||||
* =============================================================================
|
|
||||||
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
|
||||||
*
|
|
||||||
* ## Design constraints
|
|
||||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
|
||||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
|
||||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
|
||||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
|
||||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
|
||||||
*
|
|
||||||
* ## Remote data & SSH
|
|
||||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
|
||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
|
||||||
* - `./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<AttentionItem>;
|
|
||||||
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<string | null>(null);
|
|
||||||
// The name being typed, or null while not renaming.
|
|
||||||
const [renaming, setRenaming] = useState<string | null>(null);
|
|
||||||
const saveName = () => {
|
|
||||||
if (renaming === null) return;
|
|
||||||
onRename(renaming);
|
|
||||||
setRenaming(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const result = onAsk(query);
|
|
||||||
setAnswer(result); // null means it handed off (container navigates/closes)
|
|
||||||
setQuery("");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl backdrop-blur-md animate-slide-up"
|
|
||||||
role="dialog"
|
|
||||||
aria-label={t("sagi.panel.label", { name })}
|
|
||||||
>
|
|
||||||
{/* header */}
|
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<span className="text-base leading-none" aria-hidden>
|
|
||||||
🐾
|
|
||||||
</span>
|
|
||||||
{renaming === null ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="min-w-0 truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
|
|
||||||
onClick={() => setRenaming(nickname)}
|
|
||||||
aria-label={t("sagi.panel.rename", { name })}
|
|
||||||
title={t("sagi.panel.rename", { name })}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
|
|
||||||
value={renaming}
|
|
||||||
maxLength={PET_NAME_MAX}
|
|
||||||
placeholder={ownName}
|
|
||||||
aria-label={t("sagi.panel.renameLabel")}
|
|
||||||
title={t("sagi.panel.renameHint")}
|
|
||||||
onChange={(e) => setRenaming(e.target.value)}
|
|
||||||
onBlur={saveName}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
saveName();
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
// Esc cancels the rename only, not the whole panel.
|
|
||||||
e.stopPropagation();
|
|
||||||
e.nativeEvent.stopImmediatePropagation();
|
|
||||||
setRenaming(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
|
||||||
status.connected
|
|
||||||
? "bg-status-success/15 text-status-success"
|
|
||||||
: "bg-status-danger/15 text-status-danger"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
|
||||||
status.connected ? "bg-status-success" : "bg-status-danger"
|
|
||||||
}`}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center">
|
|
||||||
<button
|
|
||||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
|
||||||
onClick={onPat}
|
|
||||||
aria-label={t("sagi.panel.pat", { name })}
|
|
||||||
title={t("sagi.panel.pat", { name })}
|
|
||||||
>
|
|
||||||
<Heart size={15} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label={t("sagi.panel.close", { name })}
|
|
||||||
>
|
|
||||||
<X size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3">
|
|
||||||
{/* status stat chips */}
|
|
||||||
<div className="mb-3 grid grid-cols-3 gap-1.5">
|
|
||||||
<StatChip
|
|
||||||
icon={Radio}
|
|
||||||
label={t("sagi.panel.statLive")}
|
|
||||||
value={status.liveCount}
|
|
||||||
tone={status.liveCount > 0 ? "accent" : "muted"}
|
|
||||||
/>
|
|
||||||
<StatChip
|
|
||||||
icon={Hourglass}
|
|
||||||
label={t("sagi.panel.statWaiting")}
|
|
||||||
value={status.waitingCount}
|
|
||||||
tone={status.waitingCount > 0 ? "amber" : "muted"}
|
|
||||||
/>
|
|
||||||
<StatChip
|
|
||||||
icon={AlertTriangle}
|
|
||||||
label={t("sagi.panel.statErrored")}
|
|
||||||
value={status.errorCount}
|
|
||||||
tone={status.errorCount > 0 ? "red" : "muted"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* the sessions that need the user, by name */}
|
|
||||||
{attention.length > 0 && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">
|
|
||||||
{t("sagi.panel.attention")}
|
|
||||||
</div>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{attention.slice(0, ATTENTION_SHOWN).map((item) => (
|
|
||||||
<li key={item.id}>
|
|
||||||
<button
|
|
||||||
className="group flex w-full items-center gap-2 rounded-lg bg-surface-1 px-2 py-1.5 text-left text-xs transition-colors hover:bg-surface-4"
|
|
||||||
onClick={() => onOpenSession(item)}
|
|
||||||
title={item.cwd ?? item.id}
|
|
||||||
>
|
|
||||||
{item.kind === "error" ? (
|
|
||||||
<AlertTriangle
|
|
||||||
size={13}
|
|
||||||
className="shrink-0 text-status-danger"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Hourglass size={13} className="shrink-0 text-status-warning" aria-hidden />
|
|
||||||
)}
|
|
||||||
<span className="min-w-0 flex-1 truncate text-fg-secondary group-hover:text-fg-primary">
|
|
||||||
{subjectLabel(item)}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-[10px] text-fg-muted">
|
|
||||||
{item.kind === "error"
|
|
||||||
? t("sagi.panel.attentionError")
|
|
||||||
: t("sagi.panel.attentionWaiting")}
|
|
||||||
</span>
|
|
||||||
<ChevronRight size={12} className="shrink-0 text-fg-muted" aria-hidden />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{attention.length > ATTENTION_SHOWN && (
|
|
||||||
<button
|
|
||||||
className="mt-1 text-[11px] text-accent hover:underline"
|
|
||||||
onClick={() => onNavigate("/sessions")}
|
|
||||||
>
|
|
||||||
{t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* quick actions */}
|
|
||||||
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
|
||||||
<ActionButton
|
|
||||||
icon={muted ? BellOff : Bell}
|
|
||||||
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
|
||||||
onClick={onToggleMute}
|
|
||||||
/>
|
|
||||||
<ActionButton
|
|
||||||
icon={Trash2}
|
|
||||||
label={t("sagi.panel.clearAlerts")}
|
|
||||||
disabled={status.errorCount === 0}
|
|
||||||
onClick={onClearAlerts}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ask */}
|
|
||||||
<form onSubmit={submit} className="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
|
||||||
placeholder={t("sagi.panel.askPlaceholder", { name })}
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
aria-label={t("sagi.panel.ask", { name })}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn-primary justify-center px-2.5 py-2"
|
|
||||||
aria-label={t("sagi.panel.send")}
|
|
||||||
>
|
|
||||||
<Send size={14} />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{answer && (
|
|
||||||
<p className="mt-2 rounded-lg bg-surface-1/70 px-2.5 py-2 text-xs leading-relaxed text-fg-secondary">
|
|
||||||
{answer}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, Tone> = {
|
|
||||||
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 (
|
|
||||||
<div className={`flex flex-col items-center gap-0.5 rounded-xl border py-1.5 ${t.wrap}`}>
|
|
||||||
<Icon size={13} className={t.icon} aria-hidden />
|
|
||||||
<span className={`text-base font-semibold leading-none tabular-nums ${t.value}`}>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
<span className="text-[9px] uppercase tracking-wider text-fg-muted">{label}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionButton({
|
|
||||||
icon: Icon,
|
|
||||||
label,
|
|
||||||
onClick,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
icon: LucideIcon;
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="flex w-full items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<Icon size={14} className="shrink-0" />
|
|
||||||
<span className="truncate">{label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file Sagi.test.tsx
|
* @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ĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ import { useSagiPosition } from "../useSagiPosition";
|
|||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { Sagi } from "../Sagi";
|
import { Sagi } from "../Sagi";
|
||||||
import { eventBus } from "../../../lib/eventBus";
|
import { eventBus } from "../../../lib/eventBus";
|
||||||
|
import { api } from "../../../lib/api";
|
||||||
import type { WSMessage, Session } from "../../../lib/types";
|
import type { WSMessage, Session } from "../../../lib/types";
|
||||||
|
|
||||||
function renderSagi() {
|
function renderSagi() {
|
||||||
@@ -54,13 +55,13 @@ describe("Sagi widget", () => {
|
|||||||
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
|
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();
|
renderSagi();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
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();
|
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.change(input, { target: { value: "status" } });
|
||||||
fireEvent.submit(input.closest("form")!);
|
fireEvent.submit(input.closest("form")!);
|
||||||
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
|
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
|
||||||
@@ -88,7 +89,7 @@ describe("Sagi widget", () => {
|
|||||||
expect(within(btn).getByText("1")).toBeInTheDocument();
|
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();
|
renderSagi();
|
||||||
act(() => {
|
act(() => {
|
||||||
eventBus.publish({
|
eventBus.publish({
|
||||||
@@ -101,7 +102,7 @@ describe("Sagi widget", () => {
|
|||||||
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
||||||
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
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).getByText("Needs you")).toBeInTheDocument();
|
||||||
expect(within(panel).getByRole("button", { name: /Fix login/ })).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"]);
|
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();
|
renderSagi();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
||||||
const input = screen.getByLabelText("Pet name");
|
const input = screen.getByLabelText("Pet name");
|
||||||
fireEvent.change(input, { target: { value: "Mochi" } });
|
fireEvent.change(input, { target: { value: "Mochi" } });
|
||||||
fireEvent.keyDown(input, { key: "Enter" });
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
expect(screen.getByRole("dialog", { name: /mochi companion/i })).toBeInTheDocument();
|
expect(screen.getByRole("dialog", { name: /chat with mochi/i })).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/ask 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");
|
localStorage.setItem("agenthub.defaultTool", "claude");
|
||||||
|
const ensure = vi.spyOn(api.lanes, "ensure").mockReturnValue(new Promise(() => {}));
|
||||||
renderSagi();
|
renderSagi();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
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 });
|
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||||
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
|
const input = within(chat).getByLabelText(/ask sagi anything/i);
|
||||||
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);
|
|
||||||
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
||||||
fireEvent.submit(input.closest("form")!);
|
fireEvent.submit(input.closest("form")!);
|
||||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
expect(ensure).toHaveBeenCalledWith({ title: "Sagi" });
|
||||||
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
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<typeof api.run.get>);
|
||||||
|
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();
|
renderSagi();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /pat sagi/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();
|
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();
|
renderSagi();
|
||||||
act(() => {
|
act(() => {
|
||||||
eventBus.publish(sessionMsg("a", "active"));
|
eventBus.publish(sessionMsg("a", "active"));
|
||||||
eventBus.publish(sessionMsg("b", "active"));
|
eventBus.publish(sessionMsg("b", "active"));
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
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.
|
// 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();
|
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,7 +206,7 @@ describe("Sagi widget", () => {
|
|||||||
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
|
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();
|
renderSagi();
|
||||||
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -202,7 +214,7 @@ describe("Sagi widget", () => {
|
|||||||
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
||||||
});
|
});
|
||||||
fireEvent.click(btn);
|
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", () => {
|
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.pointerUp(btn, { clientX: 992, clientY: 701 });
|
||||||
});
|
});
|
||||||
fireEvent.click(btn);
|
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.
|
// No position was persisted because no real drag happened.
|
||||||
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
|
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.",
|
"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.",
|
"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": {
|
"panel": {
|
||||||
"label": "{{name}} companion",
|
|
||||||
"live": "Live",
|
"live": "Live",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"close": "Close {{name}}",
|
|
||||||
"statLive": "live",
|
"statLive": "live",
|
||||||
"statWaiting": "waiting",
|
"statWaiting": "waiting",
|
||||||
"statErrored": "errored",
|
"statErrored": "errored",
|
||||||
"mute": "Mute",
|
"mute": "Mute",
|
||||||
"unmute": "Unmute",
|
"unmute": "Unmute",
|
||||||
"clearAlerts": "Clear alerts",
|
"clearAlerts": "Clear alerts",
|
||||||
"askPlaceholder": "Ask {{name}}… (e.g. any errors?)",
|
|
||||||
"ask": "Ask {{name}}",
|
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"rename": "Click to rename {{name}}",
|
"rename": "Click to rename {{name}}",
|
||||||
"renameLabel": "Pet name",
|
"renameLabel": "Pet name",
|
||||||
@@ -217,9 +213,12 @@
|
|||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"label": "Chat with {{name}}",
|
"label": "Chat with {{name}}",
|
||||||
"back": "Back",
|
|
||||||
"close": "Close {{name}}",
|
"close": "Close {{name}}",
|
||||||
"new": "New chat",
|
"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",
|
"openWorkspace": "Open in workspace",
|
||||||
"typing": "typing…",
|
"typing": "typing…",
|
||||||
"placeholder": "Ask {{name}} anything…",
|
"placeholder": "Ask {{name}} anything…",
|
||||||
|
|||||||
@@ -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.",
|
"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.",
|
"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": {
|
"panel": {
|
||||||
"label": "Trợ lý {{name}}",
|
|
||||||
"live": "Trực tuyến",
|
"live": "Trực tuyến",
|
||||||
"offline": "Ngoại tuyến",
|
"offline": "Ngoại tuyến",
|
||||||
"close": "Đóng {{name}}",
|
|
||||||
"statLive": "đang chạy",
|
"statLive": "đang chạy",
|
||||||
"statWaiting": "đang chờ",
|
"statWaiting": "đang chờ",
|
||||||
"statErrored": "lỗi",
|
"statErrored": "lỗi",
|
||||||
"mute": "Tắt tiếng",
|
"mute": "Tắt tiếng",
|
||||||
"unmute": "Bật tiếng",
|
"unmute": "Bật tiếng",
|
||||||
"clearAlerts": "Xoá cảnh báo",
|
"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",
|
"send": "Gửi",
|
||||||
"rename": "Bấm để đổi tên {{name}}",
|
"rename": "Bấm để đổi tên {{name}}",
|
||||||
"renameLabel": "Tên pet",
|
"renameLabel": "Tên pet",
|
||||||
@@ -217,9 +213,12 @@
|
|||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"label": "Trò chuyện với {{name}}",
|
"label": "Trò chuyện với {{name}}",
|
||||||
"back": "Quay lại",
|
|
||||||
"close": "Đóng {{name}}",
|
"close": "Đóng {{name}}",
|
||||||
"new": "Cuộc trò chuyện mới",
|
"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",
|
"openWorkspace": "Mở trong workspace",
|
||||||
"typing": "đang trả lời…",
|
"typing": "đang trả lời…",
|
||||||
"placeholder": "Hỏi {{name}} bất cứ điều gì…",
|
"placeholder": "Hỏi {{name}} bất cứ điều gì…",
|
||||||
|
|||||||
@@ -207,6 +207,35 @@ describe("panePaste", () => {
|
|||||||
expect(m.options.map((o) => o.n)).toEqual([1, 2]);
|
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.`", () => {
|
it("finds no menu in a reply's numbered list or a typed `1.`", () => {
|
||||||
// No cursor on the list.
|
// No cursor on the list.
|
||||||
expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull();
|
expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull();
|
||||||
@@ -313,12 +342,35 @@ const CODEX_APPROVAL = [
|
|||||||
" enteresc",
|
" 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", () => {
|
describe("MENU_CURSOR_ROW", () => {
|
||||||
// The chat's 700 ms poll reads the screen cell by cell only when a 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.
|
// 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", () => {
|
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));
|
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(parsePaneMenu(menu)).not.toBeNull();
|
||||||
expect(has(menu)).toBe(true);
|
expect(has(menu)).toBe(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,8 +176,9 @@ export type PaneMenu = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
|
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. */
|
/** A row only a menu shows: its cursor on a numbered option or Submit, or the
|
||||||
export const MENU_CURSOR_ROW = /^ *[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)/;
|
* "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 SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
|
||||||
const RULE_ROW = /^ *[─╌]{3,} *$/;
|
const RULE_ROW = /^ *[─╌]{3,} *$/;
|
||||||
const CHECK = /^\[([ ✔])\] *(.*)$/;
|
const CHECK = /^\[([ ✔])\] *(.*)$/;
|
||||||
@@ -224,7 +225,8 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const numbered = options.filter((o) => o.n !== 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;
|
if (numbered.some((o, i) => o.n !== i + 1)) return null;
|
||||||
// The question: the block of text right above the first option.
|
// The question: the block of text right above the first option.
|
||||||
let q = top - 1;
|
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
|
/** The "Type something" row. Text typed into it replaces its label, and it is
|
||||||
* still the last numbered row above "Chat about this". */
|
* still the last numbered row above "Chat about this". */
|
||||||
function typeRow(options: PaneMenuOption[]): number {
|
function typeRow(options: PaneMenuOption[]): number {
|
||||||
|
|||||||
+1
-1
@@ -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)
|
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 `<data dir>/pets/<id>/` 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 `<data dir>/pets/<id>/` 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,20 @@
|
|||||||
* @file Detached helper spawned by `server/lib/self-restart.js`. Polls the
|
* @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
|
* 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
|
* 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ĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require("../server/lib/windows-hide").install(); // before child_process is read
|
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 { 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);
|
const pid = parseInt(pidArg, 10);
|
||||||
|
|
||||||
// ponytail: fixed poll cadence/cap, not configurable — this script has one caller.
|
// 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() {
|
function restart() {
|
||||||
|
const out = openLog(logArg);
|
||||||
const child = spawn(process.execPath, [entry], {
|
const child = spawn(process.execPath, [entry], {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: "ignore",
|
stdio: ["ignore", out, out],
|
||||||
env: process.env,
|
env: process.env,
|
||||||
});
|
});
|
||||||
child.unref();
|
child.unref();
|
||||||
|
|||||||
@@ -204,6 +204,45 @@ describe("pty-attach", () => {
|
|||||||
assert.deepEqual(JSON.parse(a.__sent.at(-1).data), { type: "lead" });
|
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", () => {
|
it("kills the PTY attach process when the WS connection closes", () => {
|
||||||
const ws = makeFakeWs();
|
const ws = makeFakeWs();
|
||||||
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
|
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
|
||||||
|
|||||||
@@ -3,12 +3,17 @@
|
|||||||
* @description After an update, a server our service unit runs is restarted
|
* @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
|
* 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
|
* 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ĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { describe, it } = require("node:test");
|
const { describe, it } = require("node:test");
|
||||||
const assert = require("node:assert/strict");
|
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");
|
const { scheduleRestart, owningUnit } = require("../lib/self-restart");
|
||||||
|
|
||||||
function deps(unit, restartAnswer = "restarted") {
|
function deps(unit, restartAnswer = "restarted") {
|
||||||
@@ -23,8 +28,9 @@ function deps(unit, restartAnswer = "restarted") {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
kill: () => calls.kill++,
|
kill: () => calls.kill++,
|
||||||
spawnHelper: () => {
|
spawnHelper: (_exe, args) => {
|
||||||
calls.spawn++;
|
calls.spawn++;
|
||||||
|
calls.helperArgs = args;
|
||||||
return { unref() {} };
|
return { unref() {} };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -84,5 +90,27 @@ describe("self-restart", () => {
|
|||||||
assert.equal(scheduleRestart("/r", d), "helper");
|
assert.equal(scheduleRestart("/r", d), "helper");
|
||||||
assert.equal(d.calls.spawn, 1);
|
assert.equal(d.calls.spawn, 1);
|
||||||
assert.equal(d.calls.kill, 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/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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. */
|
/** `v` sizes the window now: every other attach is held at its size and told. */
|
||||||
function drive(group, v) {
|
function drive(group, v) {
|
||||||
group.driver = v;
|
group.driver = v;
|
||||||
v.pty.resize(v.cols, v.rows);
|
resize(v, v.cols, v.rows);
|
||||||
for (const o of group.viewers) {
|
for (const o of group.viewers) {
|
||||||
if (o === v) continue;
|
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 });
|
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
|
* Attach `ws` to the tmux session `runId`. Spawns one PTY-backed
|
||||||
* `tmux attach-session -t <runId>` per call.
|
* `tmux attach-session -t <runId>` 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.
|
// newer one VS Code uses for the same reason. Ignored elsewhere.
|
||||||
...(platform === "win32" && { useConptyDll: true }),
|
...(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);
|
group.viewers.add(me);
|
||||||
if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
|
if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
|
||||||
else group.driver = me;
|
else group.driver = me;
|
||||||
@@ -179,16 +212,22 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
function write(text) {
|
function write(text) {
|
||||||
|
if (me.exited) return; // the attach is gone: nothing reads this pipe
|
||||||
const wait = holdUntil - Date.now();
|
const wait = holdUntil - Date.now();
|
||||||
if (held.length || wait > 0) {
|
if (held.length || wait > 0) {
|
||||||
if (held.push(text) === 1) setTimeout(flush, Math.max(0, wait));
|
if (held.push(text) === 1) setTimeout(flush, Math.max(0, wait));
|
||||||
return;
|
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;
|
if (holdsPaste && text.includes("\x1b[201~")) holdUntil = Date.now() + PASTE_HOLD_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
pty.onExit(({ exitCode }) => {
|
pty.onExit(({ exitCode }) => {
|
||||||
|
me.exited = true;
|
||||||
try {
|
try {
|
||||||
ws.send(JSON.stringify({ type: "exit", code: exitCode }), { binary: false });
|
ws.send(JSON.stringify({ type: "exit", code: exitCode }), { binary: false });
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ function scheduleRestart(root = path.join(__dirname, "..", ".."), deps = {}) {
|
|||||||
}
|
}
|
||||||
const helperPath = path.join(root, "scripts", "restart-helper.js");
|
const helperPath = path.join(root, "scripts", "restart-helper.js");
|
||||||
const entry = process.argv[1] || path.join(root, "server", "index.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,
|
cwd: root,
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
|
|||||||
Reference in New Issue
Block a user