feat(pet): cleared alerts stay cleared, contextual bubbles, i18n, moments, chat window
- Fix: an errored session alerted on every update, and Clear alerts only dropped it in memory, so its next update (or a reload) brought the alert straight back. An error now alerts on the transition into error only; cleared sessions are remembered (agenthub.pet.dismissedErrors) and stay quiet until they recover and fail anew. - Quips, greetings, notifications and Ask answers move to i18n (EN/VI); Ask understands Vietnamese keywords and names the sessions it counts. - The pet goes by the worn pet's name; click it in the panel to rename (agenthub.pet.names, synced to the account). - Bubbles name the session they are about, with an Open button (never a resume); a burst of the same event coalesces into one count. - Moments: a new session waves, a finish hops, a subagent peeks, a wait taps, an error flinches — on Sagi, Codex sprites and still images. Idle fidgets, a once-per-session greeting, a "Needs you" list, an amber waiting dot. - Pat (heart button, Ask, or stroking the pet) reacts on the pet itself, so it shows while the panel or chat is open. - Panel drops Run Claude / Activity / Sessions / Errored. - Ask hands off to a chat window (PetChat) on the default AI tool (Claude/Codex): transcript as bubbles, follow-ups and CLI menus through an off-screen attached terminal; no new server route. Verified on HEAD + this diff in a clean worktree: tsc -b, the full client suite (1334/1335; the one failure, ConversationView.follow, is flaky under load and passes 3/3 alone) and server me-prefs.test.js. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+12
-11
@@ -2354,7 +2354,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 + quick actions + Ask box"]
|
PANEL["SagiPanel.tsx<br/>status + Needs you + Mute / Clear alerts + Ask box"]
|
||||||
CSS["sagi.css<br/>keyframes + per-mood expressions"]
|
CSS["sagi.css<br/>keyframes + per-mood expressions"]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2401,16 +2401,17 @@ The mood state machine in `deriveMood` resolves to a single expression using a f
|
|||||||
|
|
||||||
| Component | Responsibility |
|
| Component | Responsibility |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **`brain.ts`** | Pure, framework-free core. Exposes a `WSMessage` reducer (`reduceSagi`) and a mood state machine (`deriveMood`) with the priority order `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation. |
|
| **`brain.ts`** | Pure, framework-free core. Exposes a `WSMessage` reducer (`reduceSagi`, which also returns the `subject` session a pulse is about — name, cwd, lane) and a mood state machine (`deriveMood`) with the priority order `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. An error alerts on the transition INTO `error` only, and **Clear alerts** (`clearErrors`) moves the errored sessions into `dismissed`: their later updates, agent errors and failure events stay silent until the session goes active again or ends. `attentionOf` lists errored then waiting sessions by name; `MOMENT_OF_PULSE` maps each pulse to a pet animation. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation. |
|
||||||
| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech-bubble queue, mute, and clear-alerts. Produces the derived `{ mood, status, bubble }` the presentational components render. |
|
| **`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. |
|
| **`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: status readout + quick actions + the Ask box. |
|
| **`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. |
|
||||||
| **`Sagi.tsx`** | Shell component. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
|
| **`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. |
|
||||||
| **`intents.ts`** | Pure local Q&A over the cached status snapshot. Queries that don't match a local intent become a handoff: `Sagi.tsx` calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. |
|
| **`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. |
|
||||||
| **`quips.ts`** | Pure mood → phrase pools. |
|
| **`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. |
|
||||||
| **`prefs.ts`** | `localStorage`-backed enabled / muted preferences with cross-tab sync. |
|
| **`quips.ts`** | Mood/pulse → phrase pools, plus greetings and pat lines, read from `settings:sagi.{quips,greet,pat}` so they follow the UI language. |
|
||||||
| **`sagi.css`** | Keyframes and per-mood expressions; selected via the `data-mood` attribute. |
|
| **`prefs.ts`** | `localStorage`-backed enabled / muted / pet / size / nickname preferences with cross-tab sync; `petName` = nickname, else the worn pet's own name, else Sagi. |
|
||||||
|
| **`sagi.css`** | Keyframes and per-mood expressions (`data-mood`), plus the one-shot moments (`data-moment`: jump, wave, fail, wait, review) on Sagi's art and an uploaded image. |
|
||||||
|
|
||||||
### Touchpoints Outside the Folder
|
### Touchpoints Outside the Folder
|
||||||
|
|
||||||
@@ -2422,7 +2423,7 @@ Sagi's only contact with the rest of the app is four light, additive touchpoints
|
|||||||
| `client/src/pages/Settings.tsx` | On/off toggle wired to `sagiPrefs` (`localStorage`). |
|
| `client/src/pages/Settings.tsx` | On/off toggle wired to `sagiPrefs` (`localStorage`). |
|
||||||
| `client/src/pages/Run.tsx` | Consumes `?laneId=` to attach the page to the lane whose run Sagi just spawned. |
|
| `client/src/pages/Run.tsx` | Consumes `?laneId=` to attach the page to the lane whose run Sagi just spawned. |
|
||||||
| `server/routes/lanes.js` (`POST /api/lanes/ensure`) | Accepts a missing `cwd` and resolves it to `os.homedir()` so the mascot can adopt a stable "Sagi" lane. |
|
| `server/routes/lanes.js` (`POST /api/lanes/ensure`) | Accepts a missing `cwd` and resolves it to `os.homedir()` so the mascot can adopt a stable "Sagi" lane. |
|
||||||
| `client/src/i18n/locales/{en,vi}/settings.json` | `sagi.*` strings for the Settings toggle (zh / ko fall back to the hardcoded English defaults). |
|
| `client/src/i18n/locales/{en,vi}/settings.json` | Every `sagi.*` string: the Settings toggle, the panel, the bubbles' quips, coalesced counts, greetings and Ask answers. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,541 @@
|
|||||||
|
/**
|
||||||
|
* @file PetChat.tsx
|
||||||
|
* @description The pet's chat window. A question its Ask box cannot answer
|
||||||
|
* locally opens here instead of leaving the page: the first message starts a
|
||||||
|
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
|
||||||
|
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
|
||||||
|
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
|
||||||
|
* chat bubbles read from the session's transcript. Follow-ups are typed into
|
||||||
|
* that session's pane through a terminal attached OFF-SCREEN (`TerminalView`
|
||||||
|
* in a fixed box left of the viewport — `submitToRun` needs a mounted,
|
||||||
|
* attached pane, exactly like the workspace's own Chat view), so nothing new
|
||||||
|
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
|
||||||
|
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
|
||||||
|
* `localStorage` (`agenthub.pet.chat`) while its pane lives; **New chat**
|
||||||
|
* forgets it (the session stays in the workspace), **Open in workspace**
|
||||||
|
* opens its pane there. Opening never resumes an ended session: once the pane
|
||||||
|
* is gone, the next message starts a new one.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ArrowLeft, ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import type { TranscriptMessage } from "../../lib/types";
|
||||||
|
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
|
||||||
|
import {
|
||||||
|
answerRunMenu,
|
||||||
|
chooseRunOption,
|
||||||
|
interruptRun,
|
||||||
|
readRunMenu,
|
||||||
|
submitToRun,
|
||||||
|
type PaneMenu,
|
||||||
|
} from "../../lib/panePaste";
|
||||||
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
|
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||||
|
import { TerminalView } from "../run/TerminalView";
|
||||||
|
import { PaneMenuCard } from "../run/PaneChat";
|
||||||
|
import { MarkdownContent } from "../conversation/MarkdownContent";
|
||||||
|
|
||||||
|
/** Tools whose session the chat can read back (a transcript AgentHub parses). */
|
||||||
|
export const PET_CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||||
|
const CHAT_KEY = "agenthub.pet.chat";
|
||||||
|
const POLL_MS = 1500;
|
||||||
|
const MENU_POLL_MS = 700;
|
||||||
|
// A follow-up waits this long for the off-screen pane to attach.
|
||||||
|
const SEND_TRIES = 20;
|
||||||
|
const SEND_RETRY_MS = 250;
|
||||||
|
|
||||||
|
/** The session the chat is talking to. */
|
||||||
|
export interface PetChatRef {
|
||||||
|
laneId: number;
|
||||||
|
runId: string;
|
||||||
|
tool: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPetChat(): PetChatRef | null {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial<PetChatRef> | null;
|
||||||
|
if (
|
||||||
|
v &&
|
||||||
|
typeof v.laneId === "number" &&
|
||||||
|
typeof v.runId === "string" &&
|
||||||
|
typeof v.tool === "string"
|
||||||
|
)
|
||||||
|
return { laneId: v.laneId, runId: v.runId, tool: v.tool };
|
||||||
|
} catch {
|
||||||
|
/* unreadable: no chat */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePetChat(ref: PetChatRef | null) {
|
||||||
|
try {
|
||||||
|
if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref));
|
||||||
|
else localStorage.removeItem(CHAT_KEY);
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tool a new chat starts: the user's default, when the chat can show it. */
|
||||||
|
export function petChatTool(): string | null {
|
||||||
|
const tool = readDefaultTool() ?? "claude";
|
||||||
|
return PET_CHAT_TOOLS.has(tool) ? tool : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Line =
|
||||||
|
| { key: string; role: "user" | "assistant"; text: string }
|
||||||
|
| { key: string; role: "tools"; names: string[] };
|
||||||
|
|
||||||
|
/** A human line's text: Claude Code's own injected lines (`<command-…>`,
|
||||||
|
* `<system-reminder>`…) are not something the user said. */
|
||||||
|
const humanText = (m: TranscriptMessage) =>
|
||||||
|
m.content
|
||||||
|
.filter((c) => c.type === "text" && c.text)
|
||||||
|
.map((c) => c.text!.trim())
|
||||||
|
.filter((t) => t && !t.startsWith("<"))
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
/** The transcript as chat lines: the user's words, the assistant's prose, and
|
||||||
|
* each run of tool calls between them folded into one chip. */
|
||||||
|
export function chatLines(messages: TranscriptMessage[]): Line[] {
|
||||||
|
const out: Line[] = [];
|
||||||
|
messages.forEach((m, i) => {
|
||||||
|
const sender = m.sender ?? m.type;
|
||||||
|
if (sender === "user") {
|
||||||
|
const text = humanText(m);
|
||||||
|
if (text) out.push({ key: `u${i}`, role: "user", text });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m.type !== "assistant") return;
|
||||||
|
m.content.forEach((c, j) => {
|
||||||
|
if (c.type === "text" && c.text?.trim()) {
|
||||||
|
out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() });
|
||||||
|
} else if (c.type === "tool_use" && c.name) {
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
if (last?.role === "tools") last.names.push(c.name);
|
||||||
|
else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Still working: the last line is the user's, or an assistant turn not ended. */
|
||||||
|
function isBusy(messages: TranscriptMessage[]): boolean {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const m = messages[i]!;
|
||||||
|
const sender = m.sender ?? m.type;
|
||||||
|
if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn";
|
||||||
|
if (sender === "user" && humanText(m)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Read ×2 · Bash" */
|
||||||
|
function toolSummary(names: string[]): string {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1));
|
||||||
|
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PetChatProps {
|
||||||
|
/** The pet's shown name, for the header and greeting. */
|
||||||
|
name: string;
|
||||||
|
/** A question to send as soon as the chat opens (the Ask box's handoff). */
|
||||||
|
initialPrompt?: string | null;
|
||||||
|
/** Called once the initial prompt has been taken, so a re-render never sends it twice. */
|
||||||
|
onPromptTaken?: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Opens the chat's pane in its workspace. */
|
||||||
|
onOpenWorkspace: (laneId: number, runId: string) => void;
|
||||||
|
/** The thinking mood while a reply is being written. */
|
||||||
|
onBusy?: (busy: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PetChat({
|
||||||
|
name,
|
||||||
|
initialPrompt,
|
||||||
|
onPromptTaken,
|
||||||
|
onBack,
|
||||||
|
onClose,
|
||||||
|
onOpenWorkspace,
|
||||||
|
onBusy,
|
||||||
|
}: PetChatProps) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [chat, setChat] = useState<PetChatRef | null>(readPetChat);
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
|
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||||
|
// Sent but not in the transcript yet.
|
||||||
|
const [pending, setPending] = useState<string | null>(null);
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const remember = (ref: PetChatRef | null) => {
|
||||||
|
writePetChat(ref);
|
||||||
|
setChat(ref);
|
||||||
|
setSessionId(null);
|
||||||
|
setMessages([]);
|
||||||
|
setMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A remembered chat whose pane is gone is over: forget it, the next message starts anew.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chat) return;
|
||||||
|
let live = true;
|
||||||
|
const check = () =>
|
||||||
|
api.run
|
||||||
|
.get(chat.runId)
|
||||||
|
.then((h) => {
|
||||||
|
if (!live) return;
|
||||||
|
if (h.status !== "running") remember(null);
|
||||||
|
else if (h.sessionId) setSessionId(h.sessionId);
|
||||||
|
})
|
||||||
|
.catch(() => live && remember(null));
|
||||||
|
void check();
|
||||||
|
// Codex learns its session only after a while; Claude's is known at start.
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
if (!sessionId) void check();
|
||||||
|
}, POLL_MS);
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [chat?.runId, sessionId]);
|
||||||
|
|
||||||
|
// The conversation, polled while the window is open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
let live = true;
|
||||||
|
const load = () =>
|
||||||
|
api.sessions
|
||||||
|
.transcript(sessionId, { limit: 60 })
|
||||||
|
.then((r) => {
|
||||||
|
if (!live) return;
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.length === r.messages.length &&
|
||||||
|
JSON.stringify(prev[prev.length - 1]) ===
|
||||||
|
JSON.stringify(r.messages[r.messages.length - 1])
|
||||||
|
? prev
|
||||||
|
: r.messages
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => undefined); // not started yet: the next poll asks again
|
||||||
|
void load();
|
||||||
|
const timer = window.setInterval(load, POLL_MS);
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// A menu the CLI shows in the pane (permission, trust, a question).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chat) return;
|
||||||
|
const read = () =>
|
||||||
|
setMenu((prev) => {
|
||||||
|
const next = readRunMenu(chat.runId);
|
||||||
|
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
|
||||||
|
});
|
||||||
|
const timer = window.setInterval(read, MENU_POLL_MS);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [chat]);
|
||||||
|
|
||||||
|
const lines = chatLines(messages);
|
||||||
|
// The pending line shows until the transcript has it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
|
||||||
|
setPending(null);
|
||||||
|
}, [lines, pending]);
|
||||||
|
const busy = starting || !!pending || isBusy(messages);
|
||||||
|
useEffect(() => onBusy?.(busy), [busy, onBusy]);
|
||||||
|
useEffect(() => () => onBusy?.(false), [onBusy]);
|
||||||
|
|
||||||
|
// Keep the newest in sight.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = listRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [lines.length, pending, busy, menu]);
|
||||||
|
|
||||||
|
const send = useCallback(
|
||||||
|
async (text: string) => {
|
||||||
|
const prompt = text.trim();
|
||||||
|
if (!prompt || starting) return;
|
||||||
|
setError(null);
|
||||||
|
setPending(prompt);
|
||||||
|
if (chat) {
|
||||||
|
// Typed into the attached pane; it may still be attaching.
|
||||||
|
for (let i = 0; i < SEND_TRIES; i++) {
|
||||||
|
if (submitToRun(chat.runId, prompt)) return;
|
||||||
|
await new Promise((r) => setTimeout(r, SEND_RETRY_MS));
|
||||||
|
}
|
||||||
|
setPending(null);
|
||||||
|
setDraft(prompt);
|
||||||
|
setError(t("sagi.chat.notSent"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tool = petChatTool() ?? "claude";
|
||||||
|
setStarting(true);
|
||||||
|
try {
|
||||||
|
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||||
|
const r = await startInFreeTabDetailed(lane.id, {
|
||||||
|
prompt,
|
||||||
|
...(tool !== lane.tool ? { tool } : {}),
|
||||||
|
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}),
|
||||||
|
});
|
||||||
|
remember({ laneId: r.laneId, runId: r.runId, tool });
|
||||||
|
} catch (err) {
|
||||||
|
setPending(null);
|
||||||
|
setDraft(prompt);
|
||||||
|
setError(
|
||||||
|
t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) })
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setStarting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[chat, starting, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
// The Ask box's question, sent once.
|
||||||
|
const taken = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialPrompt || taken.current) return;
|
||||||
|
taken.current = true;
|
||||||
|
onPromptTaken?.();
|
||||||
|
void send(initialPrompt);
|
||||||
|
}, [initialPrompt, onPromptTaken, send]);
|
||||||
|
|
||||||
|
const submit = (e?: FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (!draft.trim()) return;
|
||||||
|
const text = draft;
|
||||||
|
setDraft("");
|
||||||
|
void send(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolLabel = chat?.tool ?? petChatTool() ?? "claude";
|
||||||
|
const empty = lines.length === 0 && !pending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||||
|
role="dialog"
|
||||||
|
aria-label={t("sagi.chat.label", { name })}
|
||||||
|
>
|
||||||
|
{/* header */}
|
||||||
|
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||||
|
<IconButton label={t("sagi.chat.back")} onClick={onBack}>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
</IconButton>
|
||||||
|
<div className="min-w-0 flex-1 px-1">
|
||||||
|
<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">
|
||||||
|
<span
|
||||||
|
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||||
|
busy
|
||||||
|
? "animate-pulse bg-status-warning"
|
||||||
|
: chat
|
||||||
|
? "bg-status-success"
|
||||||
|
: "bg-fg-muted"
|
||||||
|
}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{busy ? t("sagi.chat.typing") : toolLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{chat && (
|
||||||
|
<IconButton
|
||||||
|
label={t("sagi.chat.openWorkspace")}
|
||||||
|
onClick={() => onOpenWorkspace(chat.laneId, chat.runId)}
|
||||||
|
>
|
||||||
|
<ExternalLink size={15} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
label={t("sagi.chat.new")}
|
||||||
|
onClick={() => remember(null)}
|
||||||
|
disabled={!chat || starting}
|
||||||
|
>
|
||||||
|
<SquarePen size={15} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton label={t("sagi.chat.close", { name })} onClick={onClose}>
|
||||||
|
<X size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* conversation */}
|
||||||
|
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||||
|
{empty && (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
|
||||||
|
<span className="text-3xl" aria-hidden>
|
||||||
|
🐾
|
||||||
|
</span>
|
||||||
|
<p className="text-sm font-medium text-fg-primary">
|
||||||
|
{t("sagi.chat.emptyTitle", { name })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-fg-muted">{t("sagi.chat.emptyHint", { tool: toolLabel })}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lines.map((l) =>
|
||||||
|
l.role === "tools" ? (
|
||||||
|
<div key={l.key} className="flex justify-start">
|
||||||
|
<span className="pet-chat-tools inline-flex max-w-[85%] items-center gap-1 truncate rounded-full px-2 py-0.5 text-[10px] text-fg-muted">
|
||||||
|
<Wrench size={10} className="shrink-0" aria-hidden />
|
||||||
|
<span className="truncate">{toolSummary(l.names)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : l.role === "user" ? (
|
||||||
|
<div key={l.key} className="flex justify-end">
|
||||||
|
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white shadow-sm">
|
||||||
|
{l.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={l.key} className="flex justify-start">
|
||||||
|
<div className="pet-chat-reply max-w-[90%] break-words rounded-2xl rounded-bl-md px-3 py-2 text-sm text-fg-primary shadow-sm">
|
||||||
|
<MarkdownContent text={l.text} dense prose />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{pending && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white opacity-70 shadow-sm">
|
||||||
|
{pending}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{busy && !menu && (
|
||||||
|
<div className="flex justify-start" aria-label={t("sagi.chat.typing")}>
|
||||||
|
<div className="pet-chat-reply flex gap-1 rounded-2xl rounded-bl-md px-3 py-3 shadow-sm">
|
||||||
|
<span className="pet-chat-dot" />
|
||||||
|
<span className="pet-chat-dot" />
|
||||||
|
<span className="pet-chat-dot" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{starting && (
|
||||||
|
<p className="flex items-center justify-center gap-1.5 text-[11px] text-fg-muted">
|
||||||
|
<Loader2 size={12} className="animate-spin" aria-hidden />
|
||||||
|
{t("sagi.chat.starting", { tool: toolLabel })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{chat && !sessionId && !starting && (
|
||||||
|
<p className="text-center text-[11px] text-fg-muted">{t("sagi.chat.notStarted")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mx-3 mb-1 rounded-lg bg-status-danger/10 px-2 py-1 text-[11px] text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* prompt, or the question the CLI asks */}
|
||||||
|
<div className="pet-chat-foot px-2.5 py-2.5">
|
||||||
|
{menu && chat ? (
|
||||||
|
<div className="max-h-56 overflow-y-auto">
|
||||||
|
<PaneMenuCard
|
||||||
|
menu={menu}
|
||||||
|
onChoose={(i) => {
|
||||||
|
if (chooseRunOption(chat.runId, menu, i)) setMenu(null);
|
||||||
|
else setError(t("sagi.chat.notSent"));
|
||||||
|
}}
|
||||||
|
onAnswer={(text) => {
|
||||||
|
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
||||||
|
else setError(t("sagi.chat.notSent"));
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
if (interruptRun(chat.runId)) setMenu(null);
|
||||||
|
else setError(t("sagi.chat.notSent"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={submit} className="flex items-end gap-1.5">
|
||||||
|
<textarea
|
||||||
|
rows={1}
|
||||||
|
autoFocus
|
||||||
|
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||||
|
placeholder={t("sagi.chat.placeholder", { name })}
|
||||||
|
aria-label={t("sagi.chat.placeholder", { name })}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isSubmitEnter(e) && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
// Esc leaves the chat for the panel, not the whole mascot.
|
||||||
|
e.stopPropagation();
|
||||||
|
e.nativeEvent.stopImmediatePropagation();
|
||||||
|
onBack();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!draft.trim() || starting}
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||||
|
aria-label={t("sagi.panel.send")}
|
||||||
|
>
|
||||||
|
<ArrowUp size={16} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The chat's pane, attached off-screen: the only way a follow-up or a
|
||||||
|
menu answer reaches it. A real size, so tmux does not shrink the pane. */}
|
||||||
|
{chat && (
|
||||||
|
<div className="pet-chat-term" aria-hidden>
|
||||||
|
<TerminalView
|
||||||
|
runId={chat.runId}
|
||||||
|
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||||
|
autoFocus={false}
|
||||||
|
// The pane ended: the conversation stays on screen, the next
|
||||||
|
// message starts a new session.
|
||||||
|
onExit={() => {
|
||||||
|
writePetChat(null);
|
||||||
|
setChat(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconButton({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
* timings; a moment (waving, jumping, failed, review) plays three times and
|
* timings; a moment (waving, jumping, failed, review) plays three times and
|
||||||
* settles into idle, a condition loops. An `action` (dragged left/right,
|
* settles into idle, a condition loops. An `action` (dragged left/right,
|
||||||
* hovered) plays the run-left/run-right or waving row over the mood's, like
|
* hovered) plays the run-left/run-right or waving row over the mood's, like
|
||||||
* Codex's own pet, and a v2 pet at idle holds the gaze pose `gaze` names. An
|
* Codex's own pet; a `moment` (a new session waves, a finish jumps, an error
|
||||||
|
* fails, a wait waits, a new subagent reviews — `useSagiBrain`) plays its row
|
||||||
|
* over the mood's, under the action; a v2 pet at idle holds the gaze pose `gaze` names. An
|
||||||
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
||||||
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
|
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
|
||||||
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
||||||
@@ -18,7 +20,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import type { Mood } from "./brain";
|
import type { Mood, PetMoment } from "./brain";
|
||||||
|
|
||||||
const CELL = { w: 192, h: 208 };
|
const CELL = { w: 192, h: 208 };
|
||||||
const COLS = 8;
|
const COLS = 8;
|
||||||
@@ -58,12 +60,16 @@ const ROW: Record<Mood, number> = {
|
|||||||
/** What the user is doing to the pet, which outranks its mood. */
|
/** What the user is doing to the pet, which outranks its mood. */
|
||||||
export type PetAction = "run-left" | "run-right" | "wave";
|
export type PetAction = "run-left" | "run-right" | "wave";
|
||||||
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
|
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
|
||||||
|
/** A moment's row: wave, jump, failed, waiting, review. It outranks the mood,
|
||||||
|
* and the user's own action outranks it. */
|
||||||
|
const MOMENT_ROW: Record<PetMoment, number> = { wave: 3, jump: 4, fail: 5, wait: 6, review: 8 };
|
||||||
|
|
||||||
export function PetSprite({
|
export function PetSprite({
|
||||||
id,
|
id,
|
||||||
kind,
|
kind,
|
||||||
mood,
|
mood,
|
||||||
action,
|
action,
|
||||||
|
moment,
|
||||||
gaze,
|
gaze,
|
||||||
size,
|
size,
|
||||||
reducedMotion,
|
reducedMotion,
|
||||||
@@ -73,6 +79,7 @@ export function PetSprite({
|
|||||||
kind: "codex" | "image";
|
kind: "codex" | "image";
|
||||||
mood: Mood;
|
mood: Mood;
|
||||||
action?: PetAction | null;
|
action?: PetAction | null;
|
||||||
|
moment?: PetMoment | null;
|
||||||
/** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */
|
/** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */
|
||||||
gaze?: number | null;
|
gaze?: number | null;
|
||||||
size: number;
|
size: number;
|
||||||
@@ -80,8 +87,8 @@ export function PetSprite({
|
|||||||
onBroken: () => void;
|
onBroken: () => void;
|
||||||
}) {
|
}) {
|
||||||
const url = api.pets.spriteUrl(id);
|
const url = api.pets.spriteUrl(id);
|
||||||
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
|
const row = action ? ACTION_ROW[action] : moment ? MOMENT_ROW[moment] : (ROW[mood] ?? 0);
|
||||||
const sleeping = !action && mood === "sleeping";
|
const sleeping = !action && !moment && mood === "sleeping";
|
||||||
// The cell on show, tagged with the row it was played for: a cell left over
|
// The cell on show, tagged with the row it was played for: a cell left over
|
||||||
// from the previous row (say column 7 of a run) is never drawn on the next.
|
// from the previous row (say column 7 of a run) is never drawn on the next.
|
||||||
const [cell, setCell] = useState({ of: row, row, col: 0 });
|
const [cell, setCell] = useState({ of: row, row, col: 0 });
|
||||||
@@ -132,6 +139,7 @@ export function PetSprite({
|
|||||||
src={url}
|
src={url}
|
||||||
alt=""
|
alt=""
|
||||||
data-mood={mood}
|
data-mood={mood}
|
||||||
|
data-moment={moment ?? undefined}
|
||||||
className="sagi-pet-image"
|
className="sagi-pet-image"
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
@@ -141,7 +149,8 @@ export function PetSprite({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const pose = gaze != null && rows > GAZE_ROW && !action && mood === "idle" ? gaze : null;
|
const pose =
|
||||||
|
gaze != null && rows > GAZE_ROW && !action && !moment && mood === "idle" ? gaze : null;
|
||||||
const shown =
|
const shown =
|
||||||
pose != null
|
pose != null
|
||||||
? { row: GAZE_ROW + Math.floor(pose / COLS), col: pose % COLS }
|
? { row: GAZE_ROW + Math.floor(pose / COLS), col: pose % COLS }
|
||||||
|
|||||||
@@ -88,8 +88,19 @@ import { useSagiBrain } from "./useSagiBrain";
|
|||||||
import { useSagiPosition } from "./useSagiPosition";
|
import { useSagiPosition } from "./useSagiPosition";
|
||||||
import { useSagiEnabled } from "./SagiMount";
|
import { useSagiEnabled } from "./SagiMount";
|
||||||
import { matchIntent } from "./intents";
|
import { matchIntent } from "./intents";
|
||||||
import { sagiPrefs } from "./prefs";
|
import { petName, sagiPrefs } from "./prefs";
|
||||||
|
import type { SagiSubject } from "./brain";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||||
|
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||||
|
import { workspaceHref } from "../SessionRowMenu";
|
||||||
|
import { PetChat, petChatTool } from "./PetChat";
|
||||||
|
|
||||||
|
// Stroking: this many left/right turns of the pointer over the pet, within
|
||||||
|
// STROKE_MS and with no button held, is a pat; then a pause before the next.
|
||||||
|
const STROKE_TURNS = 4;
|
||||||
|
const STROKE_MS = 1200;
|
||||||
|
const STROKE_COOLDOWN_MS = 1500;
|
||||||
import "./sagi.css";
|
import "./sagi.css";
|
||||||
|
|
||||||
const FLYOUT_GAP = 10; // px between avatar and flyout
|
const FLYOUT_GAP = 10; // px between avatar and flyout
|
||||||
@@ -209,8 +220,13 @@ export function Sagi() {
|
|||||||
function SagiBody() {
|
function SagiBody() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [pet, setPet] = useState(() => sagiPrefs.getPet());
|
const [pet, setPet] = useState(() => sagiPrefs.getPet());
|
||||||
|
// The name it goes by: the user's nickname, the pet's own, else Sagi.
|
||||||
|
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
||||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||||
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);
|
||||||
@@ -226,11 +242,32 @@ function SagiBody() {
|
|||||||
() =>
|
() =>
|
||||||
sagiPrefs.subscribe(() => {
|
sagiPrefs.subscribe(() => {
|
||||||
setPet(sagiPrefs.getPet());
|
setPet(sagiPrefs.getPet());
|
||||||
|
setName(petName(sagiPrefs.getPet()));
|
||||||
setSize(sagiPrefs.getSize());
|
setSize(sagiPrefs.getSize());
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A pet worn before names rode along with the choice: look its name up once.
|
||||||
|
const petId = pet.id;
|
||||||
|
const unnamed = "kind" in pet && !pet.name;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!unnamed) return;
|
||||||
|
let live = true;
|
||||||
|
api.pets
|
||||||
|
.list()
|
||||||
|
.then((list) => {
|
||||||
|
const found = list.pets.find((p) => p.id === petId);
|
||||||
|
const cur = sagiPrefs.getPet();
|
||||||
|
if (live && found && cur.id === petId && "kind" in cur)
|
||||||
|
sagiPrefs.setPet({ ...cur, name: found.displayName });
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [petId, unnamed]);
|
||||||
|
|
||||||
// ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is
|
// ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is
|
||||||
// shown, and never from an editable field, where Ctrl+B belongs to the field.
|
// shown, and never from an editable field, where Ctrl+B belongs to the field.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -241,6 +278,7 @@ 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);
|
||||||
@@ -281,27 +319,53 @@ function SagiBody() {
|
|||||||
[navigate]
|
[navigate]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A session a bubble or the panel names: its live pane when a dashboard run
|
||||||
|
// holds it, else its workspace's replay, else its analysis page. Opening
|
||||||
|
// never resumes anything.
|
||||||
|
const openSession = useCallback(
|
||||||
|
(s: SagiSubject) => {
|
||||||
|
const go = (runs?: Parameters<typeof workspaceHref>[1]) => {
|
||||||
|
navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`);
|
||||||
|
brain.dismissBubble();
|
||||||
|
setOpen(false);
|
||||||
|
setChatOpen(false);
|
||||||
|
};
|
||||||
|
api.run
|
||||||
|
.list()
|
||||||
|
.then((r) => go(r.items))
|
||||||
|
.catch(() => go());
|
||||||
|
},
|
||||||
|
[brain, navigate]
|
||||||
|
);
|
||||||
|
|
||||||
const onAsk = useCallback(
|
const onAsk = useCallback(
|
||||||
(query: string): string | null => {
|
(query: string): string | null => {
|
||||||
const result = matchIntent(query, brain.status);
|
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
|
||||||
if (result.kind === "answer") return result.text;
|
if (result.kind === "answer") {
|
||||||
// Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a
|
if (result.moment) brain.playMoment(result.moment);
|
||||||
// fresh claude session in it carrying the user's prompt as its first
|
return result.text;
|
||||||
// message, then open that workspace's session view on the new pane.
|
}
|
||||||
// Fire-and-forget — the panel already clears its composer on null and
|
// Handoff: a question the pet cannot answer goes to the chat window,
|
||||||
// closes; errors surface as an answer instead of silently swallowing.
|
// 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 default tool the chat cannot read back (not Claude/Codex): start it
|
||||||
|
// in the "Sagi" workspace and open its pane there, as before.
|
||||||
const prompt = result.prompt;
|
const prompt = result.prompt;
|
||||||
brain.setThinking(true);
|
brain.setThinking(true);
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||||
const run = await api.run.start({
|
const tool = readDefaultTool();
|
||||||
laneId: lane.id,
|
const r = await startInFreeTabDetailed(lane.id, {
|
||||||
cwd: lane.cwd,
|
prompt,
|
||||||
initialPrompt: prompt,
|
...(tool && tool !== lane.tool ? { tool } : {}),
|
||||||
permissionMode: "acceptEdits",
|
|
||||||
});
|
});
|
||||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`);
|
navigate(`/workspace?lane=${r.laneId}&run=${encodeURIComponent(r.runId)}`);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -312,9 +376,32 @@ function SagiBody() {
|
|||||||
})();
|
})();
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[brain, navigate, t]
|
[brain, navigate, t, name]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stroke the pet (move the pointer back and forth over it) to pat it.
|
||||||
|
const stroke = useRef({ dir: 0, x: 0, turns: [] as number[], until: 0 });
|
||||||
|
const onStroke = (e: React.PointerEvent) => {
|
||||||
|
if (e.buttons !== 0 || e.pointerType === "touch") return;
|
||||||
|
const s = stroke.current;
|
||||||
|
const now = performance.now();
|
||||||
|
const dx = e.clientX - s.x;
|
||||||
|
s.x = e.clientX;
|
||||||
|
if (Math.abs(dx) < 2 || now < s.until) return;
|
||||||
|
const dir = Math.sign(dx);
|
||||||
|
if (s.dir && dir !== s.dir) {
|
||||||
|
s.turns = [...s.turns.filter((t0) => now - t0 < STROKE_MS), now];
|
||||||
|
if (s.turns.length >= STROKE_TURNS) {
|
||||||
|
s.turns = [];
|
||||||
|
s.until = now + STROKE_COOLDOWN_MS;
|
||||||
|
brain.pat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.dir = dir;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBusy = useCallback((b: boolean) => brain.setThinking(b), [brain.setThinking]);
|
||||||
|
|
||||||
const anchor: Anchor = {
|
const anchor: Anchor = {
|
||||||
left: place.left,
|
left: place.left,
|
||||||
top: place.top,
|
top: place.top,
|
||||||
@@ -331,17 +418,52 @@ function SagiBody() {
|
|||||||
{!place.dragging && open && (
|
{!place.dragging && open && (
|
||||||
<SagiFlyout anchor={anchor}>
|
<SagiFlyout anchor={anchor}>
|
||||||
<SagiPanel
|
<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}
|
status={brain.status}
|
||||||
muted={brain.muted}
|
muted={brain.muted}
|
||||||
onToggleMute={brain.toggleMute}
|
onToggleMute={brain.toggleMute}
|
||||||
onClearAlerts={brain.clearAlerts}
|
onClearAlerts={brain.clearAlerts}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
onAsk={onAsk}
|
onAsk={onAsk}
|
||||||
|
onOpenChat={() => {
|
||||||
|
setOpen(false);
|
||||||
|
setChatOpen(true);
|
||||||
|
}}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
/>
|
/>
|
||||||
</SagiFlyout>
|
</SagiFlyout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Kept mounted while dragging (only hidden): unmounting would detach
|
||||||
|
its terminal and lose a half-typed message. */}
|
||||||
|
{chatOpen && (
|
||||||
|
<div style={place.dragging ? { display: "none" } : undefined}>
|
||||||
|
<SagiFlyout anchor={anchor}>
|
||||||
|
<PetChat
|
||||||
|
name={name}
|
||||||
|
initialPrompt={chatPrompt}
|
||||||
|
onPromptTaken={() => setChatPrompt(null)}
|
||||||
|
onBack={() => {
|
||||||
|
setChatOpen(false);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onClose={() => setChatOpen(false)}
|
||||||
|
onOpenWorkspace={(laneId, runId) => {
|
||||||
|
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||||
|
setChatOpen(false);
|
||||||
|
}}
|
||||||
|
onBusy={onBusy}
|
||||||
|
/>
|
||||||
|
</SagiFlyout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!place.dragging && menu && (
|
{!place.dragging && menu && (
|
||||||
<SagiFlyout anchor={anchor} front>
|
<SagiFlyout anchor={anchor} front>
|
||||||
<PetMenu
|
<PetMenu
|
||||||
@@ -356,9 +478,13 @@ function SagiBody() {
|
|||||||
</SagiFlyout>
|
</SagiFlyout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!open && !menu && brain.bubble && (
|
{!open && !menu && !chatOpen && brain.bubble && (
|
||||||
<SagiFlyout anchor={anchor}>
|
<SagiFlyout anchor={anchor}>
|
||||||
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
|
<SpeechBubble
|
||||||
|
bubble={brain.bubble}
|
||||||
|
onDismiss={brain.dismissBubble}
|
||||||
|
onOpen={openSession}
|
||||||
|
/>
|
||||||
</SagiFlyout>
|
</SagiFlyout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -370,34 +496,47 @@ function SagiBody() {
|
|||||||
onPointerEnter={() => setHover(true)}
|
onPointerEnter={() => setHover(true)}
|
||||||
onPointerLeave={() => setHover(false)}
|
onPointerLeave={() => setHover(false)}
|
||||||
onPointerDown={place.onPointerDown}
|
onPointerDown={place.onPointerDown}
|
||||||
onPointerMove={place.onPointerMove}
|
onPointerMove={(e) => {
|
||||||
|
place.onPointerMove(e);
|
||||||
|
onStroke(e);
|
||||||
|
}}
|
||||||
onPointerUp={place.onPointerUp}
|
onPointerUp={place.onPointerUp}
|
||||||
onKeyDown={place.onKeyDown}
|
onKeyDown={place.onKeyDown}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// 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;
|
||||||
setOpen((v) => !v);
|
if (chatOpen) setChatOpen(false);
|
||||||
|
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 ? t("sagi.closeAvatar") : t("sagi.open")}
|
aria-label={open || chatOpen ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
|
||||||
aria-expanded={open}
|
aria-expanded={open || chatOpen}
|
||||||
title={t("sagi.avatarTitle")}
|
title={t("sagi.avatarTitle", { name })}
|
||||||
>
|
>
|
||||||
{pet.id === "sagi" || !("kind" in pet) ? (
|
{pet.id === "sagi" || !("kind" in pet) ? (
|
||||||
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} size={size} />
|
<SagiAvatar
|
||||||
|
mood={brain.mood}
|
||||||
|
moment={reducedMotion ? null : brain.moment?.kind}
|
||||||
|
reducedMotion={reducedMotion}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PetSprite
|
<PetSprite
|
||||||
id={pet.id}
|
id={pet.id}
|
||||||
kind={pet.kind}
|
kind={pet.kind}
|
||||||
mood={brain.mood}
|
mood={brain.mood}
|
||||||
// Dragged → runs that way; hovered → waves (Codex's pet does both).
|
// Dragged → runs that way; hovered → waves (Codex's pet does both).
|
||||||
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
|
// A moment (a pat's hop) outranks the hover wave, or stroking the
|
||||||
|
// pet would only ever show it waving.
|
||||||
|
action={place.moving ? `run-${place.moving}` : hover && !brain.moment ? "wave" : null}
|
||||||
|
moment={brain.moment?.kind}
|
||||||
gaze={gaze}
|
gaze={gaze}
|
||||||
size={size}
|
size={size}
|
||||||
reducedMotion={reducedMotion}
|
reducedMotion={reducedMotion}
|
||||||
@@ -405,10 +544,28 @@ function SagiBody() {
|
|||||||
onBroken={() => sagiPrefs.setPet({ id: "sagi" })}
|
onBroken={() => sagiPrefs.setPet({ id: "sagi" })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{brain.status.errorCount > 0 && (
|
{brain.reaction && (
|
||||||
|
<span key={brain.reaction.key} className="sagi-reaction" aria-live="polite">
|
||||||
|
<span className="sagi-hearts" aria-hidden>
|
||||||
|
<span>💜</span>
|
||||||
|
<span>💗</span>
|
||||||
|
<span>💜</span>
|
||||||
|
</span>
|
||||||
|
<span className="sagi-reaction-text" data-side={place.side}>
|
||||||
|
{brain.reaction.text}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{brain.status.errorCount > 0 ? (
|
||||||
<span className="sagi-error-dot" aria-hidden>
|
<span className="sagi-error-dot" aria-hidden>
|
||||||
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
brain.status.waitingCount > 0 && (
|
||||||
|
<span className="sagi-error-dot" data-kind="waiting" aria-hidden>
|
||||||
|
{brain.status.waitingCount > 9 ? "9+" : brain.status.waitingCount}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -37,12 +37,14 @@
|
|||||||
* ============================================================================= */
|
* ============================================================================= */
|
||||||
|
|
||||||
import { memo, useEffect, useRef } from "react";
|
import { memo, useEffect, useRef } from "react";
|
||||||
import type { Mood } from "./brain";
|
import type { Mood, PetMoment } from "./brain";
|
||||||
|
|
||||||
interface SagiAvatarProps {
|
interface SagiAvatarProps {
|
||||||
mood: Mood;
|
mood: Mood;
|
||||||
reducedMotion: boolean;
|
reducedMotion: boolean;
|
||||||
size?: number;
|
size?: number;
|
||||||
|
/** A one-shot reaction (hop, wiggle, flinch…) played over the mood. */
|
||||||
|
moment?: PetMoment | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_PUPIL_SHIFT = 36; // px in the 1280-unit path coordinate space
|
const MAX_PUPIL_SHIFT = 36; // px in the 1280-unit path coordinate space
|
||||||
@@ -88,6 +90,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
|||||||
mood,
|
mood,
|
||||||
reducedMotion,
|
reducedMotion,
|
||||||
size = 68,
|
size = 68,
|
||||||
|
moment = null,
|
||||||
}: SagiAvatarProps) {
|
}: SagiAvatarProps) {
|
||||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||||
const rafRef = useRef<number>();
|
const rafRef = useRef<number>();
|
||||||
@@ -148,6 +151,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
|||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className="sagi-avatar"
|
className="sagi-avatar"
|
||||||
data-mood={mood}
|
data-mood={mood}
|
||||||
|
data-moment={moment ?? undefined}
|
||||||
data-reduced={reducedMotion ? "1" : "0"}
|
data-reduced={reducedMotion ? "1" : "0"}
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
|
|||||||
@@ -50,9 +50,6 @@
|
|||||||
|
|
||||||
import { useState, type FormEvent, type ReactNode } from "react";
|
import { useState, type FormEvent, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Play,
|
|
||||||
Activity,
|
|
||||||
LayoutList,
|
|
||||||
Bell,
|
Bell,
|
||||||
BellOff,
|
BellOff,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -61,12 +58,30 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Hourglass,
|
Hourglass,
|
||||||
Radio,
|
Radio,
|
||||||
|
Heart,
|
||||||
|
ChevronRight,
|
||||||
|
MessageCircle,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { SagiStatus } from "./brain";
|
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
|
||||||
|
import { PET_NAME_MAX } from "./prefs";
|
||||||
|
|
||||||
|
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
|
||||||
|
const ATTENTION_SHOWN = 4;
|
||||||
|
|
||||||
interface SagiPanelProps {
|
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;
|
status: SagiStatus;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
onToggleMute: () => void;
|
onToggleMute: () => void;
|
||||||
@@ -74,21 +89,38 @@ interface SagiPanelProps {
|
|||||||
onNavigate: (route: string) => void;
|
onNavigate: (route: string) => void;
|
||||||
/** Returns an answer to display, or null when the query was handed off. */
|
/** Returns an answer to display, or null when the query was handed off. */
|
||||||
onAsk: (query: string) => string | null;
|
onAsk: (query: string) => string | null;
|
||||||
|
/** Opens the chat window (PetChat). */
|
||||||
|
onOpenChat: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SagiPanel({
|
export function SagiPanel({
|
||||||
|
name,
|
||||||
|
ownName,
|
||||||
|
nickname,
|
||||||
|
onRename,
|
||||||
|
onPat,
|
||||||
|
attention,
|
||||||
|
onOpenSession,
|
||||||
status,
|
status,
|
||||||
muted,
|
muted,
|
||||||
onToggleMute,
|
onToggleMute,
|
||||||
onClearAlerts,
|
onClearAlerts,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
onAsk,
|
onAsk,
|
||||||
|
onOpenChat,
|
||||||
onClose,
|
onClose,
|
||||||
}: SagiPanelProps) {
|
}: SagiPanelProps) {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [answer, setAnswer] = useState<string | null>(null);
|
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) => {
|
const submit = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -101,7 +133,7 @@ export function SagiPanel({
|
|||||||
<div
|
<div
|
||||||
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl backdrop-blur-md animate-slide-up"
|
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"
|
role="dialog"
|
||||||
aria-label={t("sagi.panel.label")}
|
aria-label={t("sagi.panel.label", { name })}
|
||||||
>
|
>
|
||||||
{/* header */}
|
{/* 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 justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
|
||||||
@@ -109,7 +141,40 @@ export function SagiPanel({
|
|||||||
<span className="text-base leading-none" aria-hidden>
|
<span className="text-base leading-none" aria-hidden>
|
||||||
🐾
|
🐾
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold text-fg-primary">Sagi</span>
|
{renaming === null ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
|
||||||
|
onClick={() => setRenaming(nickname)}
|
||||||
|
aria-label={t("sagi.panel.rename", { name })}
|
||||||
|
title={t("sagi.panel.rename", { name })}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
|
||||||
|
value={renaming}
|
||||||
|
maxLength={PET_NAME_MAX}
|
||||||
|
placeholder={ownName}
|
||||||
|
aria-label={t("sagi.panel.renameLabel")}
|
||||||
|
title={t("sagi.panel.renameHint")}
|
||||||
|
onChange={(e) => setRenaming(e.target.value)}
|
||||||
|
onBlur={saveName}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
saveName();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
// Esc cancels the rename only, not the whole panel.
|
||||||
|
e.stopPropagation();
|
||||||
|
e.nativeEvent.stopImmediatePropagation();
|
||||||
|
setRenaming(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||||
status.connected
|
status.connected
|
||||||
@@ -126,13 +191,23 @@ export function SagiPanel({
|
|||||||
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
|
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex shrink-0 items-center">
|
||||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
<button
|
||||||
onClick={onClose}
|
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
||||||
aria-label={t("sagi.panel.close")}
|
onClick={onPat}
|
||||||
>
|
aria-label={t("sagi.panel.pat", { name })}
|
||||||
<X size={15} />
|
title={t("sagi.panel.pat", { name })}
|
||||||
</button>
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
@@ -158,29 +233,62 @@ export function SagiPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* quick actions */}
|
||||||
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
||||||
<ActionButton
|
<div className="col-span-2">
|
||||||
icon={Play}
|
<ActionButton
|
||||||
label={t("sagi.panel.runClaude")}
|
icon={MessageCircle}
|
||||||
onClick={() => onNavigate("/workspace")}
|
label={t("sagi.panel.chat", { name })}
|
||||||
/>
|
onClick={onOpenChat}
|
||||||
<ActionButton
|
/>
|
||||||
icon={Activity}
|
</div>
|
||||||
label={t("sagi.panel.activity")}
|
|
||||||
onClick={() => onNavigate("/activity")}
|
|
||||||
/>
|
|
||||||
<ActionButton
|
|
||||||
icon={LayoutList}
|
|
||||||
label={t("sagi.panel.sessions")}
|
|
||||||
onClick={() => onNavigate("/sessions")}
|
|
||||||
/>
|
|
||||||
<ActionButton
|
|
||||||
icon={AlertTriangle}
|
|
||||||
label={t("sagi.panel.errored")}
|
|
||||||
disabled={status.errorCount === 0}
|
|
||||||
onClick={() => onNavigate("/sessions")}
|
|
||||||
/>
|
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={muted ? BellOff : Bell}
|
icon={muted ? BellOff : Bell}
|
||||||
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
||||||
@@ -198,10 +306,10 @@ export function SagiPanel({
|
|||||||
<form onSubmit={submit} className="flex items-center gap-1.5">
|
<form onSubmit={submit} className="flex items-center gap-1.5">
|
||||||
<input
|
<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"
|
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||||
placeholder={t("sagi.panel.askPlaceholder")}
|
placeholder={t("sagi.panel.askPlaceholder", { name })}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
aria-label={t("sagi.panel.ask")}
|
aria-label={t("sagi.panel.ask", { name })}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -284,7 +392,7 @@ function ActionButton({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
className="flex w-full items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* @file SpeechBubble.tsx
|
* @file SpeechBubble.tsx
|
||||||
* @description Transient speech bubble rendered above the Sagi mascot.
|
* @description Transient speech bubble rendered above the Sagi mascot.
|
||||||
* Pure presentation — visibility timing and quip selection live in
|
* Pure presentation — visibility timing, coalescing and quip selection live in
|
||||||
* {@link useSagiBrain}; this component only paints the bubble and handles
|
* {@link useSagiBrain}; this component paints the line, the session(s) it is
|
||||||
* user dismissal.
|
* about, an Open button when it is about exactly one, and handles dismissal.
|
||||||
*
|
*
|
||||||
* ## Accessibility
|
* ## Accessibility
|
||||||
* Uses `role="status"` with `aria-live="polite"` so screen readers announce
|
* Uses `role="status"` with `aria-live="polite"` so screen readers announce
|
||||||
* new quips without interrupting current speech. Click anywhere on the bubble
|
* new quips without interrupting current speech. Click the bubble to dismiss
|
||||||
* to dismiss early.
|
* it early; its Open button opens the session instead.
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -52,30 +52,52 @@
|
|||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { SagiBubble } from "./useSagiBrain";
|
||||||
|
import type { SagiSubject } from "./brain";
|
||||||
|
|
||||||
/** Props for {@link SpeechBubble}. */
|
/** Props for {@link SpeechBubble}. */
|
||||||
interface SpeechBubbleProps {
|
interface SpeechBubbleProps {
|
||||||
/** Quip text to display inside the bubble. */
|
bubble: SagiBubble;
|
||||||
text: string;
|
|
||||||
/** Called when the user clicks to dismiss. */
|
/** Called when the user clicks to dismiss. */
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
|
/** Opens the session the bubble is about. */
|
||||||
|
onOpen?: (subject: SagiSubject) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Animated speech bubble above Sagi.
|
* Animated speech bubble above Sagi.
|
||||||
* @param props See {@link SpeechBubbleProps}.
|
* @param props See {@link SpeechBubbleProps}.
|
||||||
*/
|
*/
|
||||||
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
|
export function SpeechBubble({ bubble, onDismiss, onOpen }: SpeechBubbleProps) {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
|
const { text, detail, subject, tone } = bubble;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="sagi-bubble sagi-bubble-enter"
|
className="sagi-bubble sagi-bubble-enter"
|
||||||
|
data-tone={tone}
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
title={t("sagi.bubbleDismiss")}
|
title={t("sagi.bubbleDismiss")}
|
||||||
>
|
>
|
||||||
{text}
|
<div>{text}</div>
|
||||||
|
{(detail || (subject && onOpen)) && (
|
||||||
|
<div className="sagi-bubble-detail">
|
||||||
|
{detail && <span className="sagi-bubble-subject">{detail}</span>}
|
||||||
|
{subject && onOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sagi-bubble-open"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpen(subject);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("sagi.bubbleOpen")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* @file PetChat.test.ts
|
||||||
|
* @description Unit tests for the pet chat's transcript-to-bubbles folding (`chatLines`).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { chatLines } from "../PetChat";
|
||||||
|
import type { TranscriptMessage } from "../../../lib/types";
|
||||||
|
|
||||||
|
const msg = (m: Partial<TranscriptMessage>): TranscriptMessage =>
|
||||||
|
({ type: "user", timestamp: null, content: [], ...m }) as TranscriptMessage;
|
||||||
|
|
||||||
|
describe("chatLines", () => {
|
||||||
|
it("keeps the user's words and the assistant's prose, folding tool calls into one chip", () => {
|
||||||
|
const lines = chatLines([
|
||||||
|
msg({ type: "user", sender: "user", content: [{ type: "text", text: "hi" }] }),
|
||||||
|
msg({
|
||||||
|
type: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "thinking", text: "hmm" },
|
||||||
|
{ type: "tool_use", name: "Read" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
msg({ type: "user", sender: "tool", content: [{ type: "tool_result", output: "x" }] }),
|
||||||
|
msg({
|
||||||
|
type: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "tool_use", name: "Read" },
|
||||||
|
{ type: "text", text: "hello!" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(lines.map((l) => (l.role === "tools" ? l.names : l.text))).toEqual([
|
||||||
|
"hi",
|
||||||
|
["Read", "Read"],
|
||||||
|
"hello!",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops Claude Code's own injected lines", () => {
|
||||||
|
const lines = chatLines([
|
||||||
|
msg({ sender: "user", content: [{ type: "text", text: "<command-name>/clear</command-name>" }] }),
|
||||||
|
]);
|
||||||
|
expect(lines).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -261,7 +261,7 @@ describe("PetMenu", () => {
|
|||||||
renderMenu();
|
renderMenu();
|
||||||
fireEvent.click(screen.getByTestId("pet-choose"));
|
fireEvent.click(screen.getByTestId("pet-choose"));
|
||||||
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
|
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
|
||||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
|
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
|
||||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ describe("PetMenu", () => {
|
|||||||
});
|
});
|
||||||
fireEvent.change(screen.getByTestId("pet-image-input"), { target: { files: [file] } });
|
fireEvent.change(screen.getByTestId("pet-image-input"), { target: { files: [file] } });
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
|
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image", name: "Dog" })
|
||||||
);
|
);
|
||||||
expect(api.pets.upload).toHaveBeenCalledWith(file);
|
expect(api.pets.upload).toHaveBeenCalledWith(file);
|
||||||
});
|
});
|
||||||
@@ -313,11 +313,11 @@ describe("PetSettings", () => {
|
|||||||
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
|
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
|
||||||
render(<PetSettings />);
|
render(<PetSettings />);
|
||||||
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
|
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
|
||||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
|
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
|
||||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
|
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
|
||||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image" });
|
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image", name: "My cat" });
|
||||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
|
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
|
||||||
answerConfirm(true);
|
answerConfirm(true);
|
||||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
|
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
|
||||||
@@ -329,7 +329,7 @@ describe("PetSettings", () => {
|
|||||||
const file = new File([new Uint8Array([0x89, 0x50])], "Dog.png", { type: "image/png" });
|
const file = new File([new Uint8Array([0x89, 0x50])], "Dog.png", { type: "image/png" });
|
||||||
fireEvent.change(screen.getByTestId("pet-settings-image-input"), { target: { files: [file] } });
|
fireEvent.change(screen.getByTestId("pet-settings-image-input"), { target: { files: [file] } });
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
|
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image", name: "Dog" })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,89 @@ 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", () => {
|
||||||
|
renderSagi();
|
||||||
|
act(() => {
|
||||||
|
eventBus.publish({
|
||||||
|
type: "session_updated",
|
||||||
|
data: { id: "a", status: "error", name: "Fix login", cwd: "/w/app", lane_id: 3 } as Session,
|
||||||
|
timestamp: "t",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const bubble = screen.getByRole("status");
|
||||||
|
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
||||||
|
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
|
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
||||||
|
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
|
||||||
|
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays quiet about a cleared error when that session updates again", () => {
|
||||||
|
renderSagi();
|
||||||
|
act(() => {
|
||||||
|
eventBus.publish(sessionMsg("a", "error"));
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Clear alerts" }));
|
||||||
|
act(() => {
|
||||||
|
eventBus.publish(sessionMsg("a", "error"));
|
||||||
|
});
|
||||||
|
const btn = screen.getByRole("button", { name: /close sagi/i, expanded: true });
|
||||||
|
expect(within(btn).queryByText("1")).not.toBeInTheDocument();
|
||||||
|
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renames the pet from the panel header", () => {
|
||||||
|
renderSagi();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
||||||
|
const input = screen.getByLabelText("Pet name");
|
||||||
|
fireEvent.change(input, { target: { value: "Mochi" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
expect(screen.getByRole("dialog", { name: /mochi companion/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/ask mochi/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands a question it cannot answer to the chat window", () => {
|
||||||
|
localStorage.setItem("agenthub.defaultTool", "claude");
|
||||||
|
renderSagi();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
|
const input = screen.getByLabelText(/ask sagi/i);
|
||||||
|
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
||||||
|
fireEvent.submit(input.closest("form")!);
|
||||||
|
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||||
|
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("dialog", { name: /sagi companion/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the chat from the panel and goes back to it", () => {
|
||||||
|
renderSagi();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /chat with sagi/i }));
|
||||||
|
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||||
|
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a pat shows its reaction on the pet even while the panel is open", () => {
|
||||||
|
renderSagi();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
|
||||||
|
const pet = screen.getByRole("button", { name: /close sagi/i, expanded: true });
|
||||||
|
expect(pet.querySelector(".sagi-reaction-text")?.textContent).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stroking the pet back and forth pats it", () => {
|
||||||
|
renderSagi();
|
||||||
|
const pet = screen.getByRole("button", { name: /open sagi companion/i });
|
||||||
|
const xs = [100, 130, 100, 130, 100, 130, 100];
|
||||||
|
act(() => {
|
||||||
|
for (const x of xs) fireEvent.pointerMove(pet, { clientX: x, clientY: 50, buttons: 0 });
|
||||||
|
});
|
||||||
|
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("reflects the live count in the panel status", () => {
|
it("reflects the live count in the panel status", () => {
|
||||||
renderSagi();
|
renderSagi();
|
||||||
act(() => {
|
act(() => {
|
||||||
|
|||||||
@@ -225,6 +225,43 @@ describe("clearErrors", () => {
|
|||||||
expect(statusOf(s).liveCount).toBe(1);
|
expect(statusOf(s).liveCount).toBe(1);
|
||||||
expect(s.worriedUntil).toBe(0);
|
expect(s.worriedUntil).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a cleared session quiet while it stays errored", () => {
|
||||||
|
let s = initialSagiState(T0);
|
||||||
|
({ state: s } = reduceSagi(s, sessionMsg("b", "error"), T0));
|
||||||
|
s = clearErrors(s);
|
||||||
|
const again = reduceSagi(s, sessionMsg("b", "error", T0 + 1), T0 + 1);
|
||||||
|
expect(again.pulse).toBeNull();
|
||||||
|
expect(statusOf(again.state).errorCount).toBe(0);
|
||||||
|
expect(again.state.worriedUntil).toBe(0);
|
||||||
|
const ev = reduceSagi(
|
||||||
|
again.state,
|
||||||
|
{ type: "new_event", data: { session_id: "b", event_type: "toolError" }, timestamp: "" } as WSMessage,
|
||||||
|
T0 + 2
|
||||||
|
);
|
||||||
|
expect(ev.pulse).toBeNull();
|
||||||
|
expect(seedSessions(s, [{ id: "b", status: "error" }], T0).sessions.b).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("alerts again once a cleared session recovers and fails anew", () => {
|
||||||
|
let s = initialSagiState(T0);
|
||||||
|
({ state: s } = reduceSagi(s, sessionMsg("b", "error"), T0));
|
||||||
|
s = clearErrors(s);
|
||||||
|
({ state: s } = reduceSagi(s, sessionMsg("b", "active"), T0));
|
||||||
|
expect(s.dismissed).not.toHaveProperty("b");
|
||||||
|
const r = reduceSagi(s, sessionMsg("b", "error"), T0);
|
||||||
|
expect(r.pulse).toBe("error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error alerts fire on the transition only", () => {
|
||||||
|
it("does not re-alert on each update of a still-errored session", () => {
|
||||||
|
let s = initialSagiState(T0);
|
||||||
|
({ state: s } = reduceSagi(s, sessionMsg("a", "error"), T0));
|
||||||
|
const r = reduceSagi(s, sessionMsg("a", "error"), T0 + 10_000);
|
||||||
|
expect(r.pulse).toBeNull();
|
||||||
|
expect(statusOf(r.state).errorCount).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reference the imported constant so it is exercised and tsc-clean.
|
// Reference the imported constant so it is exercised and tsc-clean.
|
||||||
|
|||||||
@@ -75,4 +75,27 @@ describe("matchIntent", () => {
|
|||||||
const r = matchIntent("Refactor my auth module", status());
|
const r = matchIntent("Refactor my auth module", status());
|
||||||
expect(r).toEqual({ kind: "handoff", prompt: "Refactor my auth module" });
|
expect(r).toEqual({ kind: "handoff", prompt: "Refactor my auth module" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("understands Vietnamese keywords", () => {
|
||||||
|
const r = matchIntent("có lỗi nào không?", status({ errorCount: 2 }));
|
||||||
|
if (r.kind === "answer") expect(r.text).toContain("2 sessions errored");
|
||||||
|
expect(matchIntent("đang chạy gì vậy", status({ liveCount: 1 })).kind).toBe("answer");
|
||||||
|
expect(matchIntent("tình hình sao rồi", status()).kind).toBe("answer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the sessions it reports when it knows them", () => {
|
||||||
|
const r = matchIntent("any errors?", status({ errorCount: 1 }), {
|
||||||
|
attention: [{ id: "a", kind: "error", name: "Fix login", cwd: null, lane_id: null }],
|
||||||
|
});
|
||||||
|
if (r.kind === "answer") expect(r.text).toContain("errored: Fix login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("introduces itself by the pet's name and reacts to a pat", () => {
|
||||||
|
const who = matchIntent("who are you", status(), { name: "Mochi" });
|
||||||
|
expect(who).toMatchObject({ kind: "answer", moment: "wave" });
|
||||||
|
if (who.kind === "answer") expect(who.text).toContain("Mochi");
|
||||||
|
expect(matchIntent("pat", status())).toMatchObject({ kind: "answer", moment: "jump" });
|
||||||
|
// "pat" inside another word is not a pat.
|
||||||
|
expect(matchIntent("update the path", status()).kind).toBe("handoff");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -137,14 +137,7 @@ import type { WSMessage, Session, Agent, RunStatusPayload, DashboardEvent } from
|
|||||||
|
|
||||||
/** All moods Sagi can express, highest priority first (see `deriveMood`). */
|
/** All moods Sagi can express, highest priority first (see `deriveMood`). */
|
||||||
export type Mood =
|
export type Mood =
|
||||||
| "disconnected"
|
"disconnected" | "worried" | "stuck" | "happy" | "thinking" | "watching" | "sleeping" | "idle";
|
||||||
| "worried"
|
|
||||||
| "stuck"
|
|
||||||
| "happy"
|
|
||||||
| "thinking"
|
|
||||||
| "watching"
|
|
||||||
| "sleeping"
|
|
||||||
| "idle";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A one-shot signal describing what just happened, emitted by `reduceSagi`.
|
* A one-shot signal describing what just happened, emitted by `reduceSagi`.
|
||||||
@@ -152,13 +145,21 @@ export type Mood =
|
|||||||
* was irrelevant or non-notable.
|
* was irrelevant or non-notable.
|
||||||
*/
|
*/
|
||||||
export type SagiPulse =
|
export type SagiPulse =
|
||||||
| "session_done"
|
"session_done" | "session_start" | "subagent_spawn" | "waiting" | "error" | "run_done" | null;
|
||||||
| "session_start"
|
|
||||||
| "subagent_spawn"
|
/** A short animation the pet plays on an event or a pat, over its mood. */
|
||||||
| "waiting"
|
export type PetMoment = "wave" | "jump" | "fail" | "wait" | "review";
|
||||||
| "error"
|
|
||||||
| "run_done"
|
/** Which moment each pulse plays: greet a new session, hop at a finish,
|
||||||
| null;
|
* inspect a new subagent, tap a foot while you are awaited, flinch at an error. */
|
||||||
|
export const MOMENT_OF_PULSE: Record<NonNullable<SagiPulse>, PetMoment> = {
|
||||||
|
session_start: "wave",
|
||||||
|
session_done: "jump",
|
||||||
|
run_done: "jump",
|
||||||
|
subagent_spawn: "review",
|
||||||
|
waiting: "wait",
|
||||||
|
error: "fail",
|
||||||
|
};
|
||||||
|
|
||||||
export interface SagiStatus {
|
export interface SagiStatus {
|
||||||
/** Active + waiting sessions (everything not finished/errored). */
|
/** Active + waiting sessions (everything not finished/errored). */
|
||||||
@@ -182,6 +183,47 @@ export interface SagiState {
|
|||||||
worriedUntil: number;
|
worriedUntil: number;
|
||||||
/** True while an Ask request is in flight (panel). */
|
/** True while an Ask request is in flight (panel). */
|
||||||
thinking: boolean;
|
thinking: boolean;
|
||||||
|
/** Errored sessions the user cleared ("clear alerts"). Their further
|
||||||
|
* updates, agent errors and failure events stay silent until the session
|
||||||
|
* goes active again or ends — otherwise every update of a still-errored
|
||||||
|
* session re-raised the alert the user had just dismissed. */
|
||||||
|
dismissed: Record<string, true>;
|
||||||
|
/** Name and owning workspace of each tracked session, so a bubble or the
|
||||||
|
* panel can say WHICH session it means and open it. */
|
||||||
|
info: Record<string, SessionInfo>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What Sagi knows about a session beyond its status. */
|
||||||
|
export interface SessionInfo {
|
||||||
|
name: string | null;
|
||||||
|
cwd: string | null;
|
||||||
|
lane_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session a pulse is about, when it is about one. */
|
||||||
|
export interface SagiSubject extends SessionInfo {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A session that needs the user: errored, or waiting on their input. */
|
||||||
|
export interface AttentionItem extends SagiSubject {
|
||||||
|
kind: "error" | "waiting";
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoOf = (s: {
|
||||||
|
name?: string | null;
|
||||||
|
cwd?: string | null;
|
||||||
|
lane_id?: number | null;
|
||||||
|
}): SessionInfo => ({ name: s.name ?? null, cwd: s.cwd ?? null, lane_id: s.lane_id ?? null });
|
||||||
|
|
||||||
|
/** A session's label for a bubble: its name, else its folder, else a short id. */
|
||||||
|
export function subjectLabel(s: SagiSubject): string {
|
||||||
|
if (s.name && s.name.trim()) return s.name.trim();
|
||||||
|
const base = s.cwd
|
||||||
|
?.replace(/[\\/]+$/, "")
|
||||||
|
.split(/[\\/]/)
|
||||||
|
.pop();
|
||||||
|
return base || s.id.slice(0, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tunable timing constants (ms).
|
// Tunable timing constants (ms).
|
||||||
@@ -211,6 +253,8 @@ export function initialSagiState(now: number): SagiState {
|
|||||||
happyUntil: 0,
|
happyUntil: 0,
|
||||||
worriedUntil: 0,
|
worriedUntil: 0,
|
||||||
thinking: false,
|
thinking: false,
|
||||||
|
dismissed: {},
|
||||||
|
info: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,16 +300,28 @@ export function reduceSagi(
|
|||||||
state: SagiState,
|
state: SagiState,
|
||||||
msg: WSMessage,
|
msg: WSMessage,
|
||||||
now: number
|
now: number
|
||||||
): { state: SagiState; pulse: SagiPulse } {
|
): { state: SagiState; pulse: SagiPulse; subject?: SagiSubject } {
|
||||||
|
// The tracked session an agent/event message belongs to, when known.
|
||||||
|
const known = (id: string | undefined): SagiSubject | undefined =>
|
||||||
|
id ? { id, ...(state.info[id] ?? { name: null, cwd: null, lane_id: null }) } : undefined;
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "session_created":
|
case "session_created":
|
||||||
case "session_updated": {
|
case "session_updated": {
|
||||||
const s = msg.data as Session;
|
const s = msg.data as Session;
|
||||||
if (!s || !s.id) return { state, pulse: null };
|
if (!s || !s.id) return { state, pulse: null };
|
||||||
const sessions = { ...state.sessions };
|
const sessions = { ...state.sessions };
|
||||||
|
const info = { ...state.info, [s.id]: infoOf(s) };
|
||||||
|
const subject: SagiSubject = { id: s.id, ...info[s.id]! };
|
||||||
|
let dismissed = state.dismissed;
|
||||||
let pulse: SagiPulse = null;
|
let pulse: SagiPulse = null;
|
||||||
let happyUntil = state.happyUntil;
|
let happyUntil = state.happyUntil;
|
||||||
let worriedUntil = state.worriedUntil;
|
let worriedUntil = state.worriedUntil;
|
||||||
|
// Anything but a (still) errored status ends a dismissal: a later error
|
||||||
|
// of that session is a new one and must alert again.
|
||||||
|
if (s.status !== "error" && s.id in dismissed) {
|
||||||
|
dismissed = { ...dismissed };
|
||||||
|
delete dismissed[s.id];
|
||||||
|
}
|
||||||
|
|
||||||
if (s.status === "active") {
|
if (s.status === "active") {
|
||||||
// "waiting" = active session blocked on user input (permission prompt
|
// "waiting" = active session blocked on user input (permission prompt
|
||||||
@@ -280,12 +336,20 @@ export function reduceSagi(
|
|||||||
if (prev === undefined) pulse = "session_start";
|
if (prev === undefined) pulse = "session_start";
|
||||||
}
|
}
|
||||||
} else if (s.status === "error") {
|
} else if (s.status === "error") {
|
||||||
|
if (s.id in dismissed) {
|
||||||
|
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||||
|
}
|
||||||
|
// Alert on the transition INTO error only; a session that stays
|
||||||
|
// errored keeps getting updates and must not re-alert on each one.
|
||||||
|
if (sessions[s.id] !== "error") {
|
||||||
|
worriedUntil = now + WORRIED_MS;
|
||||||
|
pulse = "error";
|
||||||
|
}
|
||||||
sessions[s.id] = "error";
|
sessions[s.id] = "error";
|
||||||
worriedUntil = now + WORRIED_MS;
|
|
||||||
pulse = "error";
|
|
||||||
} else if (s.status === "completed" || s.status === "abandoned") {
|
} else if (s.status === "completed" || s.status === "abandoned") {
|
||||||
const wasTracked = s.id in sessions;
|
const wasTracked = s.id in sessions;
|
||||||
delete sessions[s.id];
|
delete sessions[s.id];
|
||||||
|
delete info[s.id];
|
||||||
if (s.status === "completed") {
|
if (s.status === "completed") {
|
||||||
happyUntil = now + HAPPY_MS;
|
happyUntil = now + HAPPY_MS;
|
||||||
if (wasTracked) pulse = "session_done";
|
if (wasTracked) pulse = "session_done";
|
||||||
@@ -293,31 +357,46 @@ export function reduceSagi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state: { ...state, sessions, happyUntil, worriedUntil, lastActivityAt: now },
|
state: {
|
||||||
|
...state,
|
||||||
|
sessions,
|
||||||
|
dismissed,
|
||||||
|
info,
|
||||||
|
happyUntil,
|
||||||
|
worriedUntil,
|
||||||
|
lastActivityAt: now,
|
||||||
|
},
|
||||||
pulse,
|
pulse,
|
||||||
|
subject,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "agent_created": {
|
case "agent_created": {
|
||||||
const a = msg.data as Agent;
|
const a = msg.data as Agent;
|
||||||
if (a && a.status === "error") {
|
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
|
||||||
return {
|
return {
|
||||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||||
pulse: "error",
|
pulse: "error",
|
||||||
|
subject: known(a.session_id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// A freshly spawned subagent is worth announcing; the main agent landing
|
// A freshly spawned subagent is worth announcing; the main agent landing
|
||||||
// is already covered by session_start.
|
// is already covered by session_start.
|
||||||
const pulse: SagiPulse = a && a.type === "subagent" ? "subagent_spawn" : null;
|
const pulse: SagiPulse = a && a.type === "subagent" ? "subagent_spawn" : null;
|
||||||
return { state: { ...state, lastActivityAt: now }, pulse };
|
return {
|
||||||
|
state: { ...state, lastActivityAt: now },
|
||||||
|
pulse,
|
||||||
|
subject: pulse ? known(a.session_id) : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "agent_updated": {
|
case "agent_updated": {
|
||||||
const a = msg.data as Agent;
|
const a = msg.data as Agent;
|
||||||
if (a && a.status === "error") {
|
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
|
||||||
return {
|
return {
|
||||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||||
pulse: "error",
|
pulse: "error",
|
||||||
|
subject: known(a.session_id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||||
@@ -325,7 +404,8 @@ export function reduceSagi(
|
|||||||
|
|
||||||
case "new_event": {
|
case "new_event": {
|
||||||
const e = msg.data as DashboardEvent;
|
const e = msg.data as DashboardEvent;
|
||||||
const isFailure = !!e && FAILURE_EVENT_TYPES.has(e.event_type);
|
const isFailure =
|
||||||
|
!!e && FAILURE_EVENT_TYPES.has(e.event_type) && !(e.session_id in state.dismissed);
|
||||||
return {
|
return {
|
||||||
state: {
|
state: {
|
||||||
...state,
|
...state,
|
||||||
@@ -333,6 +413,7 @@ export function reduceSagi(
|
|||||||
worriedUntil: isFailure ? now + WORRIED_MS : state.worriedUntil,
|
worriedUntil: isFailure ? now + WORRIED_MS : state.worriedUntil,
|
||||||
},
|
},
|
||||||
pulse: isFailure ? "error" : null,
|
pulse: isFailure ? "error" : null,
|
||||||
|
subject: isFailure ? known(e.session_id) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,24 +442,60 @@ export function reduceSagi(
|
|||||||
*/
|
*/
|
||||||
export function seedSessions(
|
export function seedSessions(
|
||||||
state: SagiState,
|
state: SagiState,
|
||||||
rows: ReadonlyArray<{ id: string; status: string; awaiting_input_since?: string | null }>,
|
rows: ReadonlyArray<{
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
awaiting_input_since?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
cwd?: string | null;
|
||||||
|
lane_id?: number | null;
|
||||||
|
}>,
|
||||||
now: number
|
now: number
|
||||||
): SagiState {
|
): SagiState {
|
||||||
const sessions = { ...state.sessions };
|
const sessions = { ...state.sessions };
|
||||||
|
const info = { ...state.info };
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (!r || !r.id) continue;
|
if (!r || !r.id) continue;
|
||||||
if (r.status === "error") sessions[r.id] = "error";
|
if (r.status === "error") {
|
||||||
else if (r.status === "active") sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
|
if (r.id in state.dismissed) continue;
|
||||||
// completed / abandoned: leave untracked.
|
sessions[r.id] = "error";
|
||||||
|
} else if (r.status === "active") {
|
||||||
|
sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
|
||||||
|
} else continue; // completed / abandoned: leave untracked.
|
||||||
|
info[r.id] = infoOf(r);
|
||||||
}
|
}
|
||||||
return { ...state, sessions, lastActivityAt: now };
|
return { ...state, sessions, info, lastActivityAt: now };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drop all errored sessions from tracking (used by "clear alerts"). */
|
/** Errored sessions first, then the ones waiting on the user — the panel's list. */
|
||||||
|
export function attentionOf(state: SagiState): AttentionItem[] {
|
||||||
|
const out: AttentionItem[] = [];
|
||||||
|
for (const kind of ["error", "waiting"] as const) {
|
||||||
|
for (const [id, s] of Object.entries(state.sessions)) {
|
||||||
|
if (s !== kind) continue;
|
||||||
|
out.push({
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
...(state.info[id] ?? { name: null, cwd: null, lane_id: null }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop all errored sessions from tracking (used by "clear alerts") and
|
||||||
|
* remember them as dismissed, so their next update does not bring the alert
|
||||||
|
* straight back. */
|
||||||
export function clearErrors(state: SagiState): SagiState {
|
export function clearErrors(state: SagiState): SagiState {
|
||||||
const sessions: SagiState["sessions"] = {};
|
const sessions: SagiState["sessions"] = {};
|
||||||
|
const dismissed = { ...state.dismissed };
|
||||||
|
const info = { ...state.info };
|
||||||
for (const [id, s] of Object.entries(state.sessions)) {
|
for (const [id, s] of Object.entries(state.sessions)) {
|
||||||
if (s !== "error") sessions[id] = s;
|
if (s !== "error") sessions[id] = s;
|
||||||
|
else {
|
||||||
|
dismissed[id] = true;
|
||||||
|
delete info[id];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { ...state, sessions, worriedUntil: 0 };
|
return { ...state, sessions, dismissed, info, worriedUntil: 0 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
* @description Sagi's local "Ask" brain. Matches a free-text question against a
|
* @description Sagi's local "Ask" brain. Matches a free-text question against a
|
||||||
* small set of intents answerable from cached dashboard status. Anything it
|
* small set of intents answerable from cached dashboard status. Anything it
|
||||||
* can't answer becomes a handoff to the Run page (spawn a real `claude`).
|
* can't answer becomes a handoff to the Run page (spawn a real `claude`).
|
||||||
* Pure function - no network, no DOM - so it's fully unit-testable.
|
* Understands English and Vietnamese keywords, names the sessions it reports
|
||||||
|
* when it knows them, and answers in the UI language (`settings:sagi.intent`).
|
||||||
|
* No network, no DOM - so it's fully unit-testable.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -28,6 +30,8 @@
|
|||||||
*
|
*
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `AskResult` — exported API; see TSDoc on the symbol for behavior.
|
* - `AskResult` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
* - `AskContext` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
* - `statusSummary` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `matchIntent` — exported API; see TSDoc on the symbol for behavior.
|
* - `matchIntent` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
* ## Testing pointers
|
* ## Testing pointers
|
||||||
@@ -54,70 +58,157 @@
|
|||||||
*
|
*
|
||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import type { SagiStatus } from "./brain";
|
import i18n from "../../i18n";
|
||||||
|
import { subjectLabel, type AttentionItem, type PetMoment, type SagiStatus } from "./brain";
|
||||||
|
import { pickLine } from "./quips";
|
||||||
|
|
||||||
export type AskResult = { kind: "answer"; text: string } | { kind: "handoff"; prompt: string };
|
export type AskResult =
|
||||||
|
{ kind: "answer"; text: string; moment?: PetMoment } | { kind: "handoff"; prompt: string };
|
||||||
|
|
||||||
const plural = (n: number) => (n === 1 ? "" : "s");
|
/** What the answer may mention beyond the counts. */
|
||||||
|
export interface AskContext {
|
||||||
|
/** The pet's shown name. */
|
||||||
|
name?: string;
|
||||||
|
/** Errored and waiting sessions, named (`attentionOf`). */
|
||||||
|
attention?: ReadonlyArray<AttentionItem>;
|
||||||
|
}
|
||||||
|
|
||||||
export function matchIntent(query: string, status: SagiStatus): AskResult {
|
const t = (key: string, vars?: Record<string, unknown>) =>
|
||||||
const q = query.trim().toLowerCase();
|
i18n.t(`settings:sagi.intent.${key}`, vars ?? {});
|
||||||
if (!q) {
|
|
||||||
return {
|
/** ": a, b, c" for up to three named sessions of a kind, "" when none known. */
|
||||||
kind: "answer",
|
function listOf(items: ReadonlyArray<AttentionItem> | undefined, kind: AttentionItem["kind"]) {
|
||||||
text: "ask me about your sessions - what's running, any errors, or a quick status.",
|
const names = (items ?? []).filter((i) => i.kind === kind).map(subjectLabel);
|
||||||
};
|
if (names.length === 0) return "";
|
||||||
}
|
const shown = names.slice(0, 3).join(", ");
|
||||||
|
return `: ${names.length > 3 ? `${shown}…` : shown}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2 sessions live, 1 waiting on you" — the one-line status a greeting carries. */
|
||||||
|
export function statusSummary(status: SagiStatus): string {
|
||||||
|
const parts = [i18n.t("settings:sagi.greet.summary", { count: status.liveCount })];
|
||||||
|
if (status.waitingCount > 0)
|
||||||
|
parts.push(i18n.t("settings:sagi.greet.summaryWaiting", { count: status.waitingCount }));
|
||||||
|
if (status.errorCount > 0)
|
||||||
|
parts.push(i18n.t("settings:sagi.greet.summaryErrors", { count: status.errorCount }));
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchIntent(query: string, status: SagiStatus, ctx: AskContext = {}): AskResult {
|
||||||
|
const q = query.trim().toLowerCase().normalize("NFC");
|
||||||
|
const name = ctx.name || "Sagi";
|
||||||
|
if (!q) return { kind: "answer", text: t("empty") };
|
||||||
|
|
||||||
const has = (...words: string[]) => words.some((w) => q.includes(w));
|
const has = (...words: string[]) => words.some((w) => q.includes(w));
|
||||||
|
// Whole-word match, for short words that hide inside longer ones ("hi" in "this").
|
||||||
|
const word = (...words: string[]) =>
|
||||||
|
words.some((w) => new RegExp(`(^|[^\\p{L}])${w}($|[^\\p{L}])`, "u").test(q));
|
||||||
|
|
||||||
if (has("help", "what can you", "what do you do")) {
|
if (has("help", "what can you", "what do you do", "trợ giúp", "làm được gì", "hướng dẫn")) {
|
||||||
return {
|
return { kind: "answer", text: t("help", { name }) };
|
||||||
kind: "answer",
|
}
|
||||||
text: 'I watch your sessions. Try "what\'s running", "any errors", or "status". Anything else, I\'ll hand to Claude.',
|
|
||||||
};
|
if (has("who are you", "your name", "bạn là ai", "tên gì", "tên bạn", "là ai")) {
|
||||||
|
return { kind: "answer", text: t("whoami", { name }), moment: "wave" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has("thank", "thx", "cảm ơn", "cám ơn") || word("ty", "tks")) {
|
||||||
|
return { kind: "answer", text: pickLine("intent.thanks"), moment: "jump" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
has("good boy", "good girl", "cute", "ngoan", "dễ thương", "xoa đầu", "vuốt ve", "giỏi quá") ||
|
||||||
|
word("pat")
|
||||||
|
) {
|
||||||
|
return { kind: "answer", text: pickLine("pat"), moment: "jump" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Errors first: "any failed runs" should report errors, not live count.
|
// Errors first: "any failed runs" should report errors, not live count.
|
||||||
if (has("error", "broke", "broken", "fail", "wrong", "crash")) {
|
if (
|
||||||
|
has("error", "broke", "broken", "fail", "wrong", "crash", "lỗi", "hỏng", "thất bại", "sự cố")
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
kind: "answer",
|
kind: "answer",
|
||||||
text:
|
text:
|
||||||
status.errorCount > 0
|
status.errorCount > 0
|
||||||
? `${status.errorCount} session${plural(status.errorCount)} errored - open the panel to jump to them.`
|
? t("errors", { count: status.errorCount, list: listOf(ctx.attention, "error") })
|
||||||
: "no errors - all clean 🐾",
|
: t("errorsNone"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (has("waiting", "stuck", "blocked", "input", "my turn", "paused")) {
|
if (
|
||||||
|
has(
|
||||||
|
"waiting",
|
||||||
|
"stuck",
|
||||||
|
"blocked",
|
||||||
|
"input",
|
||||||
|
"my turn",
|
||||||
|
"paused",
|
||||||
|
"chờ",
|
||||||
|
"đợi",
|
||||||
|
"kẹt",
|
||||||
|
"tới lượt",
|
||||||
|
"đến lượt"
|
||||||
|
)
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
kind: "answer",
|
kind: "answer",
|
||||||
text:
|
text:
|
||||||
status.waitingCount > 0
|
status.waitingCount > 0
|
||||||
? `${status.waitingCount} session${plural(status.waitingCount)} waiting on you 👀`
|
? t("waiting", { count: status.waitingCount, list: listOf(ctx.attention, "waiting") })
|
||||||
: "nothing's waiting on you right now 🐾",
|
: t("waitingNone"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (has("running", "active", "live", "going on", "happening", "in progress")) {
|
if (
|
||||||
const tail = status.waitingCount > 0 ? ` (${status.waitingCount} waiting on you 👀)` : "";
|
has(
|
||||||
|
"running",
|
||||||
|
"active",
|
||||||
|
"live",
|
||||||
|
"going on",
|
||||||
|
"happening",
|
||||||
|
"in progress",
|
||||||
|
"đang chạy",
|
||||||
|
"đang làm",
|
||||||
|
"hoạt động"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const tail = status.waitingCount > 0 ? t("liveTail", { count: status.waitingCount }) : "";
|
||||||
return {
|
return {
|
||||||
kind: "answer",
|
kind: "answer",
|
||||||
text:
|
text: status.liveCount > 0 ? t("live", { count: status.liveCount, tail }) : t("liveNone"),
|
||||||
status.liveCount > 0
|
|
||||||
? `${status.liveCount} session${plural(status.liveCount)} live right now 🐾${tail}`
|
|
||||||
: "nothing's running right now - all quiet.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (has("status", "summary", "overview", "how are things", "how's it", "how is it")) {
|
if (
|
||||||
|
has(
|
||||||
|
"status",
|
||||||
|
"summary",
|
||||||
|
"overview",
|
||||||
|
"how are things",
|
||||||
|
"how's it",
|
||||||
|
"how is it",
|
||||||
|
"tình hình",
|
||||||
|
"trạng thái",
|
||||||
|
"tổng quan",
|
||||||
|
"thế nào",
|
||||||
|
"sao rồi"
|
||||||
|
)
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
kind: "answer",
|
kind: "answer",
|
||||||
text: `${status.liveCount} live · ${status.waitingCount} waiting · ${status.errorCount} errored · ${
|
text: t("status", {
|
||||||
status.connected ? "connected" : "offline"
|
live: status.liveCount,
|
||||||
}.`,
|
waiting: status.waitingCount,
|
||||||
|
errors: status.errorCount,
|
||||||
|
conn: t(status.connected ? "connected" : "offline"),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (word("hi", "hello", "hey", "yo", "chào", "xin chào", "alo")) {
|
||||||
|
return { kind: "answer", text: t("hello", { summary: statusSummary(status) }), moment: "wave" };
|
||||||
|
}
|
||||||
|
|
||||||
return { kind: "handoff", prompt: query.trim() };
|
return { kind: "handoff", prompt: query.trim() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,12 +56,19 @@ const MUTED_KEY = "agent-dashboard-sagi-muted";
|
|||||||
const POS_KEY = "agent-dashboard-sagi-pos";
|
const POS_KEY = "agent-dashboard-sagi-pos";
|
||||||
const PET_KEY = "agenthub.pet";
|
const PET_KEY = "agenthub.pet";
|
||||||
const SIZE_KEY = "agenthub.pet.size";
|
const SIZE_KEY = "agenthub.pet.size";
|
||||||
|
const DISMISSED_KEY = "agenthub.pet.dismissedErrors";
|
||||||
|
/** Per-pet nicknames the user typed, `{ [petId]: name }`. */
|
||||||
|
const NAMES_KEY = "agenthub.pet.names";
|
||||||
|
export const PET_NAME_MAX = 40;
|
||||||
|
/** Most recent dismissed error sessions kept across reloads. */
|
||||||
|
const DISMISSED_MAX = 200;
|
||||||
/** Sagi's own size, and the slider's range. */
|
/** Sagi's own size, and the slider's range. */
|
||||||
export const PET_SIZE = { default: 68, min: 48, max: 250 };
|
export const PET_SIZE = { default: 68, min: 48, max: 250 };
|
||||||
|
|
||||||
/** The pet the mascot wears: Sagi itself, or a custom one (`/api/pets`).
|
/** The pet the mascot wears: Sagi itself, or a custom one (`/api/pets`).
|
||||||
* The kind rides along so the mascot renders without a fetch. */
|
* The kind (and its display name) ride along so the mascot renders, and
|
||||||
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image" };
|
* introduces itself, without a fetch. */
|
||||||
|
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image"; name?: string };
|
||||||
const SAGI: PetChoice = { id: "sagi" };
|
const SAGI: PetChoice = { id: "sagi" };
|
||||||
|
|
||||||
function readPet(): PetChoice {
|
function readPet(): PetChoice {
|
||||||
@@ -69,6 +76,7 @@ function readPet(): PetChoice {
|
|||||||
const p = JSON.parse(localStorage.getItem(PET_KEY) ?? "null") as Partial<{
|
const p = JSON.parse(localStorage.getItem(PET_KEY) ?? "null") as Partial<{
|
||||||
id: string;
|
id: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
|
name: string;
|
||||||
}> | null;
|
}> | null;
|
||||||
if (
|
if (
|
||||||
p &&
|
p &&
|
||||||
@@ -76,13 +84,34 @@ function readPet(): PetChoice {
|
|||||||
p.id !== "sagi" &&
|
p.id !== "sagi" &&
|
||||||
(p.kind === "codex" || p.kind === "image")
|
(p.kind === "codex" || p.kind === "image")
|
||||||
)
|
)
|
||||||
return { id: p.id, kind: p.kind };
|
return typeof p.name === "string" && p.name
|
||||||
|
? { id: p.id, kind: p.kind, name: p.name.slice(0, PET_NAME_MAX) }
|
||||||
|
: { id: p.id, kind: p.kind };
|
||||||
} catch {
|
} catch {
|
||||||
/* unreadable: Sagi */
|
/* unreadable: Sagi */
|
||||||
}
|
}
|
||||||
return SAGI;
|
return SAGI;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readNames(): Record<string, string> {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(localStorage.getItem(NAMES_KEY) ?? "{}") as unknown;
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [id, v] of Object.entries(raw)) if (typeof v === "string" && v) out[id] = v;
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name the pet goes by: the user's nickname, else the pet's own, else Sagi. */
|
||||||
|
export function petName(pet: PetChoice): string {
|
||||||
|
const nick = readNames()[pet.id];
|
||||||
|
if (nick) return nick;
|
||||||
|
return ("name" in pet && pet.name) || "Sagi";
|
||||||
|
}
|
||||||
|
|
||||||
function readSize(): number {
|
function readSize(): number {
|
||||||
const n = Number(localStorage.getItem(SIZE_KEY));
|
const n = Number(localStorage.getItem(SIZE_KEY));
|
||||||
return Number.isFinite(n) && n > 0
|
return Number.isFinite(n) && n > 0
|
||||||
@@ -181,6 +210,37 @@ export const sagiPrefs = {
|
|||||||
SIZE_KEY,
|
SIZE_KEY,
|
||||||
String(Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, px))))
|
String(Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, px))))
|
||||||
),
|
),
|
||||||
|
/** Name a pet; an empty name gives it back its own. */
|
||||||
|
setNickname: (id: string, name: string) => {
|
||||||
|
const names = readNames();
|
||||||
|
const clean = name.replace(/\s+/g, " ").trim().slice(0, PET_NAME_MAX);
|
||||||
|
if (clean) names[id] = clean;
|
||||||
|
else delete names[id];
|
||||||
|
writeAndNotify(NAMES_KEY, JSON.stringify(names));
|
||||||
|
},
|
||||||
|
/** The user's nickname for a pet, "" when none. */
|
||||||
|
getNickname: (id: string) => readNames()[id] ?? "",
|
||||||
|
/** Errored session ids the user cleared, so a reload keeps them quiet. */
|
||||||
|
getDismissed: (): Record<string, true> => {
|
||||||
|
try {
|
||||||
|
const ids = JSON.parse(localStorage.getItem(DISMISSED_KEY) || "[]");
|
||||||
|
const out: Record<string, true> = {};
|
||||||
|
if (Array.isArray(ids)) for (const id of ids) if (typeof id === "string") out[id] = true;
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setDismissed: (dismissed: Record<string, true>) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
DISMISSED_KEY,
|
||||||
|
JSON.stringify(Object.keys(dismissed).slice(-DISMISSED_MAX))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Best-effort, like every pref here; no prefs event — widget-local state.
|
||||||
|
}
|
||||||
|
},
|
||||||
/** Subscribe to any pref change; returns an unsubscribe fn. */
|
/** Subscribe to any pref change; returns an unsubscribe fn. */
|
||||||
subscribe(handler: () => void): () => void {
|
subscribe(handler: () => void): () => void {
|
||||||
const listener = () => handler();
|
const listener = () => handler();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file quips.ts
|
* @file quips.ts
|
||||||
* @description Sagi's personality: pools of short phrases keyed by pulse/mood,
|
* @description Sagi's personality: pools of short phrases keyed by pulse/mood
|
||||||
* plus a deterministic-by-injection picker. Pure data + a pure function so it
|
* (and greetings, pat reactions), read from the `settings:sagi.*` i18n arrays
|
||||||
* can be unit-tested without randomness leaking in.
|
* so they follow the UI language, plus a deterministic-by-injection picker.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -24,10 +24,13 @@
|
|||||||
*
|
*
|
||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./brain`
|
* - `./brain`
|
||||||
|
* - `../../i18n`
|
||||||
*
|
*
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `QuipKey` — exported API; see TSDoc on the symbol for behavior.
|
* - `QuipKey` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `pickQuip` — exported API; see TSDoc on the symbol for behavior.
|
* - `pickQuip` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
* - `pickLine` — exported API; see TSDoc on the symbol for behavior.
|
||||||
|
* - `greetingKey` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `ALL_QUIP_KEYS` — exported API; see TSDoc on the symbol for behavior.
|
* - `ALL_QUIP_KEYS` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
* ## Testing pointers
|
* ## Testing pointers
|
||||||
@@ -59,76 +62,60 @@
|
|||||||
*
|
*
|
||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
import i18n from "../../i18n";
|
||||||
import type { Mood, SagiPulse } from "./brain";
|
import type { Mood, SagiPulse } from "./brain";
|
||||||
|
|
||||||
export type QuipKey = NonNullable<SagiPulse> | Mood;
|
export type QuipKey = NonNullable<SagiPulse> | Mood;
|
||||||
|
|
||||||
const QUIPS: Record<QuipKey, string[]> = {
|
export const ALL_QUIP_KEYS: QuipKey[] = [
|
||||||
// Pulses (event-driven, transient bubbles)
|
// Pulses (event-driven, transient bubbles)
|
||||||
session_done: [
|
"session_done",
|
||||||
"a session just wrapped up! 🐾",
|
"session_start",
|
||||||
"a session finished - nice work! ✨",
|
"subagent_spawn",
|
||||||
"that session's all done 😺",
|
"waiting",
|
||||||
"clean finish on that one 💜",
|
"error",
|
||||||
],
|
"run_done",
|
||||||
session_start: [
|
// Moods (steady-state flavor)
|
||||||
"a new session started! 👀",
|
"disconnected",
|
||||||
"a fresh session just landed 🐾",
|
"worried",
|
||||||
"ooh, a new session to watch 😻",
|
"stuck",
|
||||||
"something new is cooking 🍲",
|
"happy",
|
||||||
],
|
"thinking",
|
||||||
subagent_spawn: [
|
"watching",
|
||||||
"a subagent just spawned! 🐾",
|
"sleeping",
|
||||||
"a little helper joined in 🤝",
|
"idle",
|
||||||
"a subagent's on the job 🚀",
|
];
|
||||||
"reinforcements - new subagent! 😺",
|
const KNOWN = new Set<string>(ALL_QUIP_KEYS);
|
||||||
],
|
|
||||||
waiting: [
|
|
||||||
"a session needs your input 👀",
|
|
||||||
"a session is waiting on you ⏳",
|
|
||||||
"a session paused for your reply 💬",
|
|
||||||
"your turn - a session's waiting 🐾",
|
|
||||||
],
|
|
||||||
error: [
|
|
||||||
"uh oh, a session hit an error 😿",
|
|
||||||
"something broke - wanna peek? 🙀",
|
|
||||||
"a hook tripped on something ⚠️",
|
|
||||||
"hiss… an error popped up 💢",
|
|
||||||
],
|
|
||||||
run_done: [
|
|
||||||
"your run just finished! 🐾",
|
|
||||||
"the run's all wrapped up ✨",
|
|
||||||
"run complete - that's a wrap 😸",
|
|
||||||
"all done with that run 💜",
|
|
||||||
],
|
|
||||||
// Moods (steady-state flavor, used by the panel / idle bubbles)
|
|
||||||
disconnected: [
|
|
||||||
"lost the connection… 😴",
|
|
||||||
"can't reach the server 📡",
|
|
||||||
"no signal - taking a nap 💤",
|
|
||||||
],
|
|
||||||
worried: ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
|
|
||||||
stuck: [
|
|
||||||
"a session's been quiet a while… 🤔",
|
|
||||||
"is something stuck? ⏳",
|
|
||||||
"still chewing on it… 😾",
|
|
||||||
],
|
|
||||||
happy: ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
|
|
||||||
thinking: ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
|
|
||||||
watching: ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
|
||||||
sleeping: ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
|
||||||
idle: ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick a quip for a key. `rand` is injectable for deterministic tests; defaults
|
* Pick one line from an i18n string array under `settings:sagi.<path>`.
|
||||||
* to Math.random. Returns "" only for an unknown key (never throws).
|
* `rand` is injectable for deterministic tests. Returns "" for a missing or
|
||||||
|
* empty pool (never throws).
|
||||||
*/
|
*/
|
||||||
export function pickQuip(key: QuipKey, rand: () => number = Math.random): string {
|
export function pickLine(
|
||||||
const pool = QUIPS[key];
|
path: string,
|
||||||
if (!pool || pool.length === 0) return "";
|
rand: () => number = Math.random,
|
||||||
|
vars?: Record<string, unknown>
|
||||||
|
): string {
|
||||||
|
const pool = i18n.t(`settings:sagi.${path}`, { returnObjects: true, ...vars }) as unknown;
|
||||||
|
if (!Array.isArray(pool) || pool.length === 0) return "";
|
||||||
const i = Math.min(pool.length - 1, Math.max(0, Math.floor(rand() * pool.length)));
|
const i = Math.min(pool.length - 1, Math.max(0, Math.floor(rand() * pool.length)));
|
||||||
return pool[i] ?? "";
|
const line = pool[i];
|
||||||
|
return typeof line === "string" ? line : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ALL_QUIP_KEYS = Object.keys(QUIPS) as QuipKey[];
|
/**
|
||||||
|
* Pick a quip for a key, in the UI language. Returns "" only for an unknown
|
||||||
|
* key (never throws).
|
||||||
|
*/
|
||||||
|
export function pickQuip(key: QuipKey, rand: () => number = Math.random): string {
|
||||||
|
return KNOWN.has(key) ? pickLine(`quips.${key}`, rand) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which greeting pool fits the hour: morning 5–11, afternoon 12–17, evening 18–22, else night. */
|
||||||
|
export function greetingKey(hour: number): "morning" | "afternoon" | "evening" | "night" {
|
||||||
|
if (hour >= 5 && hour < 12) return "morning";
|
||||||
|
if (hour >= 12 && hour < 18) return "afternoon";
|
||||||
|
if (hour >= 18 && hour < 23) return "evening";
|
||||||
|
return "night";
|
||||||
|
}
|
||||||
|
|||||||
@@ -243,6 +243,84 @@
|
|||||||
animation: sagi-breathe 5s ease-in-out infinite;
|
animation: sagi-breathe 5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── moments: one-shot reactions to an event or a pat (useSagiBrain), played
|
||||||
|
over the mood for ~2.4 s. Declared after the moods so they win the cascade.
|
||||||
|
Shared by Sagi's own art and an uploaded still image. ── */
|
||||||
|
.sagi-avatar[data-moment="jump"],
|
||||||
|
.sagi-pet-image[data-moment="jump"] {
|
||||||
|
animation: sagi-hop 0.6s cubic-bezier(0.3, 0, 0.4, 1) 0s 3;
|
||||||
|
}
|
||||||
|
.sagi-avatar[data-moment="wave"],
|
||||||
|
.sagi-pet-image[data-moment="wave"] {
|
||||||
|
animation: sagi-wiggle 0.5s ease-in-out 0s 4;
|
||||||
|
}
|
||||||
|
.sagi-avatar[data-moment="fail"],
|
||||||
|
.sagi-pet-image[data-moment="fail"] {
|
||||||
|
animation: sagi-shake 0.35s ease-in-out 0s 5;
|
||||||
|
}
|
||||||
|
.sagi-avatar[data-moment="wait"],
|
||||||
|
.sagi-pet-image[data-moment="wait"] {
|
||||||
|
animation: sagi-tap 0.4s ease-in-out 0s 5;
|
||||||
|
}
|
||||||
|
.sagi-avatar[data-moment="review"],
|
||||||
|
.sagi-pet-image[data-moment="review"] {
|
||||||
|
animation: sagi-peek 2.2s ease-in-out 0s 1;
|
||||||
|
}
|
||||||
|
.sagi-avatar[data-moment="jump"] .sagi-sparkle {
|
||||||
|
animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite;
|
||||||
|
transform-origin: 797px 274px;
|
||||||
|
}
|
||||||
|
@keyframes sagi-hop {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0) scale(1, 1);
|
||||||
|
}
|
||||||
|
15% {
|
||||||
|
transform: translateY(0) scale(1.08, 0.92);
|
||||||
|
}
|
||||||
|
45% {
|
||||||
|
transform: translateY(-14%) scale(0.96, 1.05);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateY(0) scale(1.04, 0.96);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes sagi-wiggle {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: rotate(-9deg);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: rotate(9deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes sagi-tap {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-4%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes sagi-peek {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg) translateX(0);
|
||||||
|
}
|
||||||
|
25%,
|
||||||
|
40% {
|
||||||
|
transform: rotate(-7deg) translateX(-4%);
|
||||||
|
}
|
||||||
|
60%,
|
||||||
|
75% {
|
||||||
|
transform: rotate(7deg) translateX(4%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── reduced motion: kill all continuous animation ── */
|
/* ── reduced motion: kill all continuous animation ── */
|
||||||
.sagi-avatar[data-reduced="1"],
|
.sagi-avatar[data-reduced="1"],
|
||||||
.sagi-avatar[data-reduced="1"] * {
|
.sagi-avatar[data-reduced="1"] * {
|
||||||
@@ -341,6 +419,14 @@
|
|||||||
border: 2px solid #0c0c14;
|
border: 2px solid #0c0c14;
|
||||||
animation: sagi-error-pulse 1.2s ease-in-out infinite;
|
animation: sagi-error-pulse 1.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
/* Sessions waiting on the user, when none errored: the same dot, amber. */
|
||||||
|
.sagi-error-dot[data-kind="waiting"] {
|
||||||
|
background: #f59e0b;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.sagi-pet-image {
|
||||||
|
transform-origin: 50% 92%;
|
||||||
|
}
|
||||||
.sagi-bubble {
|
.sagi-bubble {
|
||||||
max-width: 16rem;
|
max-width: 16rem;
|
||||||
background: #1a1a28;
|
background: #1a1a28;
|
||||||
@@ -356,6 +442,41 @@
|
|||||||
.sagi-bubble-enter {
|
.sagi-bubble-enter {
|
||||||
animation: sagi-bubble-in 0.22s ease-out;
|
animation: sagi-bubble-in 0.22s ease-out;
|
||||||
}
|
}
|
||||||
|
.sagi-bubble[data-tone="error"] {
|
||||||
|
border-color: rgba(239, 68, 68, 0.6);
|
||||||
|
}
|
||||||
|
.sagi-bubble[data-tone="waiting"] {
|
||||||
|
border-color: rgba(245, 158, 11, 0.6);
|
||||||
|
}
|
||||||
|
/* The session(s) a bubble is about, and the button that opens it. */
|
||||||
|
.sagi-bubble-detail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1rem;
|
||||||
|
color: #a8a8c0;
|
||||||
|
}
|
||||||
|
.sagi-bubble-subject {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sagi-bubble-open {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(139, 92, 246, 0.2);
|
||||||
|
color: #c4b5fd;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.sagi-bubble-open:hover {
|
||||||
|
background: rgba(139, 92, 246, 0.35);
|
||||||
|
}
|
||||||
@keyframes sagi-bubble-in {
|
@keyframes sagi-bubble-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -403,3 +524,165 @@
|
|||||||
.sagi-flyout[data-front="1"] {
|
.sagi-flyout[data-front="1"] {
|
||||||
z-index: 42;
|
z-index: 42;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ════════ the pet's chat window (PetChat.tsx) ════════ */
|
||||||
|
|
||||||
|
/* The reference's soft gradient, in the theme's own accent rather than a fixed
|
||||||
|
colour: a tint of the accent at the top fading into the surface, so it
|
||||||
|
reads in every light and dark theme. Replies sit on the plain surface like
|
||||||
|
the reference's white bubbles; the user's own are accent-solid (white text
|
||||||
|
is only ever on accent-solid). */
|
||||||
|
.pet-chat {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, rgb(var(--accent)) 22%, rgb(var(--surface-1))) 0%,
|
||||||
|
color-mix(in srgb, rgb(var(--accent)) 9%, rgb(var(--surface-1))) 45%,
|
||||||
|
rgb(var(--surface-1)) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.pet-chat-head {
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent);
|
||||||
|
}
|
||||||
|
.pet-chat-reply {
|
||||||
|
background: rgb(var(--surface-0));
|
||||||
|
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 12%, transparent);
|
||||||
|
}
|
||||||
|
.pet-chat-tools {
|
||||||
|
background: color-mix(in srgb, rgb(var(--surface-0)) 70%, transparent);
|
||||||
|
border: 1px dashed color-mix(in srgb, rgb(var(--accent)) 25%, transparent);
|
||||||
|
}
|
||||||
|
.pet-chat-foot {
|
||||||
|
background: rgb(var(--surface-0));
|
||||||
|
border-top: 1px solid rgb(var(--border) / 0.6);
|
||||||
|
}
|
||||||
|
/* Three bouncing dots while a reply is written. */
|
||||||
|
.pet-chat-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgb(var(--accent));
|
||||||
|
opacity: 0.5;
|
||||||
|
animation: pet-chat-dot 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.pet-chat-dot:nth-child(2) {
|
||||||
|
animation-delay: 0.15s;
|
||||||
|
}
|
||||||
|
.pet-chat-dot:nth-child(3) {
|
||||||
|
animation-delay: 0.3s;
|
||||||
|
}
|
||||||
|
@keyframes pet-chat-dot {
|
||||||
|
0%,
|
||||||
|
60%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* The chat's terminal, attached but off-screen. It keeps a real size (so tmux
|
||||||
|
does not shrink the pane to fit it) and a non-zero box (so the paste
|
||||||
|
registry counts it as on screen). */
|
||||||
|
.pet-chat-term {
|
||||||
|
position: fixed;
|
||||||
|
left: -10000px;
|
||||||
|
top: 0;
|
||||||
|
width: 760px;
|
||||||
|
height: 460px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.pet-chat-dot {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════ a pat's reaction, drawn on the pet itself ════════ */
|
||||||
|
|
||||||
|
/* Hearts float up from the pet and its line shows beside it, inside the
|
||||||
|
avatar button — so a pat reads even while the panel or the chat is open
|
||||||
|
(the speech bubble hides then). */
|
||||||
|
.sagi-reaction {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.sagi-hearts {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 10%;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.sagi-hearts > span {
|
||||||
|
animation: sagi-heart-up 1.6s ease-out forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.sagi-hearts > span:nth-child(2) {
|
||||||
|
animation-delay: 0.15s;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.sagi-hearts > span:nth-child(3) {
|
||||||
|
animation-delay: 0.3s;
|
||||||
|
}
|
||||||
|
@keyframes sagi-heart-up {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px) scale(0.6);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-4px) scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-34px) scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* The line sits on the side away from the screen edge the pet docks to. */
|
||||||
|
.sagi-reaction-text {
|
||||||
|
position: absolute;
|
||||||
|
top: 30%;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgb(var(--surface-0));
|
||||||
|
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 35%, transparent);
|
||||||
|
color: rgb(var(--fg-primary));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.1rem;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
||||||
|
animation: sagi-reaction-in 2.4s ease-out forwards;
|
||||||
|
}
|
||||||
|
.sagi-reaction-text[data-side="right"] {
|
||||||
|
right: calc(100% + 6px);
|
||||||
|
}
|
||||||
|
.sagi-reaction-text[data-side="left"] {
|
||||||
|
left: calc(100% + 6px);
|
||||||
|
}
|
||||||
|
@keyframes sagi-reaction-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
10%,
|
||||||
|
80% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sagi-hearts > span,
|
||||||
|
.sagi-reaction-text {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function usePets() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind });
|
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind, name: p.displayName });
|
||||||
const install = (featuredId: string) =>
|
const install = (featuredId: string) =>
|
||||||
run(featuredId, async () => {
|
run(featuredId, async () => {
|
||||||
wear((await api.pets.installFeatured(featuredId)).pet);
|
wear((await api.pets.installFeatured(featuredId)).pet);
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
* @file useSagiBrain.ts
|
* @file useSagiBrain.ts
|
||||||
* @description React hook that wires the pure Sagi brain to the live event bus
|
* @description React hook that wires the pure Sagi brain to the live event bus
|
||||||
* and to real timers. It is the only unit that subscribes to `eventBus`. It
|
* and to real timers. It is the only unit that subscribes to `eventBus`. It
|
||||||
* exposes the derived mood, a status summary, the current speech bubble, and
|
* exposes the derived mood, a status summary, the sessions that need the user,
|
||||||
* imperative controls (mute, clear alerts, set thinking) for the UI shell.
|
* the current speech bubble (naming the session it is about, and coalescing a
|
||||||
|
* burst of the same event into one count), the pet's one-shot animation
|
||||||
|
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
|
||||||
|
* for the UI shell. It also greets once per browser session and makes an
|
||||||
|
* idle pet fidget now and then.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -62,108 +66,235 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { eventBus } from "../../lib/eventBus";
|
import { eventBus } from "../../lib/eventBus";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import i18n from "../../i18n";
|
||||||
import type { WSMessage } from "../../lib/types";
|
import type { WSMessage } from "../../lib/types";
|
||||||
import {
|
import {
|
||||||
initialSagiState,
|
initialSagiState,
|
||||||
reduceSagi,
|
reduceSagi,
|
||||||
deriveMood,
|
deriveMood,
|
||||||
statusOf,
|
statusOf,
|
||||||
|
attentionOf,
|
||||||
clearErrors,
|
clearErrors,
|
||||||
seedSessions,
|
seedSessions,
|
||||||
|
subjectLabel,
|
||||||
|
MOMENT_OF_PULSE,
|
||||||
|
type AttentionItem,
|
||||||
type Mood,
|
type Mood,
|
||||||
|
type PetMoment,
|
||||||
|
type SagiPulse,
|
||||||
type SagiState,
|
type SagiState,
|
||||||
type SagiStatus,
|
type SagiStatus,
|
||||||
|
type SagiSubject,
|
||||||
} from "./brain";
|
} from "./brain";
|
||||||
import { pickQuip } from "./quips";
|
import { greetingKey, pickLine, pickQuip } from "./quips";
|
||||||
|
import { statusSummary } from "./intents";
|
||||||
import { sagiPrefs } from "./prefs";
|
import { sagiPrefs } from "./prefs";
|
||||||
|
|
||||||
const BUBBLE_MS = 4500;
|
const BUBBLE_MS = 4500;
|
||||||
|
// A bubble that names a session stays long enough to click it.
|
||||||
|
const SUBJECT_BUBBLE_MS = 7000;
|
||||||
// Minimum gap between non-error bubbles, so a burst of activity doesn't spam.
|
// Minimum gap between non-error bubbles, so a burst of activity doesn't spam.
|
||||||
const BUBBLE_THROTTLE_MS = 3000;
|
const BUBBLE_THROTTLE_MS = 3000;
|
||||||
|
// The same pulse again within this window joins the bubble on show as a count.
|
||||||
|
const COALESCE_MS = 8000;
|
||||||
|
// How long a moment (wave, hop, flinch…) plays over the mood.
|
||||||
|
const MOMENT_MS = 2400;
|
||||||
|
// An idle pet fidgets (waves or looks around) once in a while.
|
||||||
|
const FIDGET_MIN_MS = 45_000;
|
||||||
|
const FIDGET_SPAN_MS = 60_000;
|
||||||
|
// Greet once per browser session, shortly after the snapshot lands.
|
||||||
|
const GREETED_KEY = "agenthub.pet.greeted";
|
||||||
|
const GREET_DELAY_MS = 1200;
|
||||||
|
|
||||||
|
/** What the speech bubble shows. */
|
||||||
|
export interface SagiBubble {
|
||||||
|
text: string;
|
||||||
|
/** Which session(s) it is about, shown under the text. */
|
||||||
|
detail?: string;
|
||||||
|
/** The one session it is about, which a click opens. */
|
||||||
|
subject?: SagiSubject;
|
||||||
|
/** Errors and waits are drawn with an accent. */
|
||||||
|
tone?: "error" | "waiting";
|
||||||
|
}
|
||||||
|
|
||||||
export interface SagiBrain {
|
export interface SagiBrain {
|
||||||
mood: Mood;
|
mood: Mood;
|
||||||
status: SagiStatus;
|
status: SagiStatus;
|
||||||
bubble: string | null;
|
/** Errored, then waiting sessions, named. */
|
||||||
|
attention: AttentionItem[];
|
||||||
|
bubble: SagiBubble | null;
|
||||||
dismissBubble: () => void;
|
dismissBubble: () => void;
|
||||||
/** Imperative bubble setter for one-off messages from the shell (e.g. spawn
|
/** Imperative bubble setter for one-off messages from the shell (e.g. spawn
|
||||||
* errors). Bypasses the throttle because the caller decided this message
|
* errors). Bypasses the throttle because the caller decided this message
|
||||||
* must be seen right now. Auto-clears after the standard bubble TTL. */
|
* must be seen right now. Auto-clears after the standard bubble TTL. */
|
||||||
setBubble: (text: string) => void;
|
setBubble: (text: string) => void;
|
||||||
|
/** The one-shot animation playing now, if any; `key` changes per moment so a
|
||||||
|
* repeat of the same one restarts it. */
|
||||||
|
moment: { kind: PetMoment; key: number } | null;
|
||||||
|
/** Play a moment by hand (an answer, a pat). */
|
||||||
|
playMoment: (kind: PetMoment) => void;
|
||||||
|
/** Pat the pet: a hop, hearts and a happy line right on the pet — shown
|
||||||
|
* even while the panel or chat is open, and while muted (it was asked). */
|
||||||
|
pat: () => void;
|
||||||
|
/** The pet's answer to a pat, drawn on the pet itself; `key` restarts it. */
|
||||||
|
reaction: { text: string; key: number } | null;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
toggleMute: () => void;
|
toggleMute: () => void;
|
||||||
clearAlerts: () => void;
|
clearAlerts: () => void;
|
||||||
setThinking: (v: boolean) => void;
|
setThinking: (v: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Burst = { pulse: NonNullable<SagiPulse>; count: number; subjects: SagiSubject[]; at: number };
|
||||||
|
|
||||||
|
function burstBubble(b: Burst): SagiBubble {
|
||||||
|
const tone = b.pulse === "error" ? "error" : b.pulse === "waiting" ? "waiting" : undefined;
|
||||||
|
const one = b.subjects.length === 1 ? b.subjects[0] : undefined;
|
||||||
|
const labels = b.subjects.map(subjectLabel);
|
||||||
|
const detail =
|
||||||
|
labels.length > 2
|
||||||
|
? i18n.t("settings:sagi.bubbleMore", {
|
||||||
|
list: labels.slice(0, 2).join(", "),
|
||||||
|
count: labels.length - 2,
|
||||||
|
})
|
||||||
|
: labels.join(", ") || undefined;
|
||||||
|
const text =
|
||||||
|
b.count > 1 ? i18n.t(`settings:sagi.notify.${b.pulse}`, { count: b.count }) : pickQuip(b.pulse);
|
||||||
|
return { text, detail, subject: one, tone };
|
||||||
|
}
|
||||||
|
|
||||||
export function useSagiBrain(): SagiBrain {
|
export function useSagiBrain(): SagiBrain {
|
||||||
const now0 = Date.now();
|
const now0 = Date.now();
|
||||||
// Start optimistically connected (idle, open eyes) so the cursor-tracking
|
// Start optimistically connected (idle, open eyes) so the cursor-tracking
|
||||||
// eyes are live from the first frame instead of after the WebSocket finishes
|
// eyes are live from the first frame instead of after the WebSocket finishes
|
||||||
// its initial handshake. onConnection below corrects this if it's truly down.
|
// its initial handshake. onConnection below corrects this if it's truly down.
|
||||||
const [state, setState] = useState<SagiState>(() => ({
|
const [state, setStateRaw] = useState<SagiState>(() => ({
|
||||||
...initialSagiState(now0),
|
...initialSagiState(now0),
|
||||||
connected: true,
|
connected: true,
|
||||||
|
dismissed: sagiPrefs.getDismissed(),
|
||||||
}));
|
}));
|
||||||
|
// The reducer runs OUTSIDE React's updater (which StrictMode calls twice),
|
||||||
|
// so a pulse notifies exactly once; this ref is the state it folds into.
|
||||||
|
const stateRef = useRef(state);
|
||||||
|
const update = useCallback((fn: (prev: SagiState) => SagiState) => {
|
||||||
|
const next = fn(stateRef.current);
|
||||||
|
if (next === stateRef.current) return;
|
||||||
|
stateRef.current = next;
|
||||||
|
setStateRaw(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [tick, setTick] = useState(now0);
|
const [tick, setTick] = useState(now0);
|
||||||
const [bubble, setBubbleState] = useState<string | null>(null);
|
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
|
||||||
|
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
|
||||||
|
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
|
||||||
|
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
||||||
|
|
||||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
const lastBubbleAt = useRef(0);
|
const lastBubbleAt = useRef(0);
|
||||||
|
const burst = useRef<Burst | null>(null);
|
||||||
|
const momentKey = useRef(0);
|
||||||
const mutedRef = useRef(muted);
|
const mutedRef = useRef(muted);
|
||||||
mutedRef.current = muted;
|
mutedRef.current = muted;
|
||||||
|
|
||||||
|
// Keep cleared alerts cleared across reloads.
|
||||||
|
useEffect(() => sagiPrefs.setDismissed(state.dismissed), [state.dismissed]);
|
||||||
|
|
||||||
// Keep mute in sync with the Settings page / other tabs.
|
// Keep mute in sync with the Settings page / other tabs.
|
||||||
useEffect(() => sagiPrefs.subscribe(() => setMuted(sagiPrefs.getMuted())), []);
|
useEffect(() => sagiPrefs.subscribe(() => setMuted(sagiPrefs.getMuted())), []);
|
||||||
|
|
||||||
const showBubble = useCallback((text: string, force: boolean) => {
|
const putBubble = useCallback((b: SagiBubble, ms = BUBBLE_MS) => {
|
||||||
if (!text) return;
|
|
||||||
if (mutedRef.current) return;
|
|
||||||
const t = Date.now();
|
|
||||||
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
|
|
||||||
lastBubbleAt.current = t;
|
|
||||||
clearTimeout(bubbleTimer.current);
|
clearTimeout(bubbleTimer.current);
|
||||||
setBubbleState(text);
|
setBubbleState(b);
|
||||||
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
|
bubbleTimer.current = setTimeout(() => {
|
||||||
|
setBubbleState(null);
|
||||||
|
burst.current = null;
|
||||||
|
}, ms);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const playMoment = useCallback((kind: PetMoment) => {
|
||||||
|
clearTimeout(momentTimer.current);
|
||||||
|
setMoment({ kind, key: ++momentKey.current });
|
||||||
|
momentTimer.current = setTimeout(() => setMoment(null), MOMENT_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const notify = useCallback(
|
||||||
|
(pulse: NonNullable<SagiPulse>, subject?: SagiSubject) => {
|
||||||
|
playMoment(MOMENT_OF_PULSE[pulse]);
|
||||||
|
if (mutedRef.current) return;
|
||||||
|
const t = Date.now();
|
||||||
|
const b = burst.current;
|
||||||
|
// The same kind of news again, while its bubble is still up: count it in.
|
||||||
|
if (b && b.pulse === pulse && t - b.at < COALESCE_MS) {
|
||||||
|
b.count++;
|
||||||
|
b.at = t;
|
||||||
|
if (subject && !b.subjects.some((s) => s.id === subject.id)) b.subjects.push(subject);
|
||||||
|
putBubble(burstBubble(b), SUBJECT_BUBBLE_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const force = pulse === "error" || pulse === "waiting";
|
||||||
|
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
|
||||||
|
lastBubbleAt.current = t;
|
||||||
|
const next: Burst = { pulse, count: 1, subjects: subject ? [subject] : [], at: t };
|
||||||
|
burst.current = next;
|
||||||
|
putBubble(burstBubble(next), subject ? SUBJECT_BUBBLE_MS : BUBBLE_MS);
|
||||||
|
},
|
||||||
|
[playMoment, putBubble]
|
||||||
|
);
|
||||||
|
|
||||||
// Seed from the REST snapshot on mount so counts are accurate immediately -
|
// Seed from the REST snapshot on mount so counts are accurate immediately -
|
||||||
// the brain otherwise only learns about sessions from WS deltas that arrive
|
// the brain otherwise only learns about sessions from WS deltas that arrive
|
||||||
// after it mounts, showing "0 live" on a fresh load even when sessions exist.
|
// after it mounts, showing "0 live" on a fresh load even when sessions exist.
|
||||||
// Pull a generous page of non-finished sessions; live WS deltas refine it.
|
// Pull a generous page of non-finished sessions; live WS deltas refine it.
|
||||||
|
// Then say hello, once per browser session.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let greetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const greet = () => {
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(GREETED_KEY)) return;
|
||||||
|
sessionStorage.setItem(GREETED_KEY, "1");
|
||||||
|
} catch {
|
||||||
|
return; // No storage: rather silent than a hello on every page.
|
||||||
|
}
|
||||||
|
greetTimer = setTimeout(() => {
|
||||||
|
if (cancelled || mutedRef.current) return;
|
||||||
|
const hello = pickLine(`greet.${greetingKey(new Date().getHours())}`);
|
||||||
|
if (!hello) return;
|
||||||
|
lastBubbleAt.current = Date.now();
|
||||||
|
putBubble({ text: hello, detail: statusSummary(statusOf(stateRef.current)) });
|
||||||
|
playMoment("wave");
|
||||||
|
}, GREET_DELAY_MS);
|
||||||
|
};
|
||||||
api.sessions
|
api.sessions
|
||||||
.list({ status: "active", limit: 100 })
|
.list({ status: "active", limit: 100 })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setState((prev) => seedSessions(prev, res.sessions, Date.now()));
|
update((prev) => seedSessions(prev, res.sessions, Date.now()));
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined)
|
||||||
|
.finally(greet);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
clearTimeout(greetTimer);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [update, putBubble, playMoment]);
|
||||||
|
|
||||||
// Subscribe to the live stream and connection status.
|
// Subscribe to the live stream and connection status.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
||||||
const t = Date.now();
|
const { state: next, pulse, subject } = reduceSagi(stateRef.current, msg, Date.now());
|
||||||
setState((prev) => {
|
update(() => next);
|
||||||
const { state: next, pulse } = reduceSagi(prev, msg, t);
|
if (pulse) notify(pulse, subject);
|
||||||
if (pulse) showBubble(pickQuip(pulse), pulse === "error");
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
const unsubConn = eventBus.onConnection((connected) => {
|
const unsubConn = eventBus.onConnection((connected) => {
|
||||||
setState((prev) => ({ ...prev, connected }));
|
update((prev) => (prev.connected === connected ? prev : { ...prev, connected }));
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
unsubMsg();
|
unsubMsg();
|
||||||
unsubConn();
|
unsubConn();
|
||||||
};
|
};
|
||||||
}, [showBubble]);
|
}, [update, notify]);
|
||||||
|
|
||||||
// Advance the clock so timed moods (stuck/sleeping, and exit from
|
// Advance the clock so timed moods (stuck/sleeping, and exit from
|
||||||
// happy/worried) re-evaluate without needing a new event.
|
// happy/worried) re-evaluate without needing a new event.
|
||||||
@@ -175,14 +306,42 @@ export function useSagiBrain(): SagiBrain {
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => () => clearTimeout(bubbleTimer.current), []);
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
clearTimeout(bubbleTimer.current);
|
||||||
|
clearTimeout(momentTimer.current);
|
||||||
|
clearTimeout(reactionTimer.current);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const mood = useMemo(() => deriveMood(state, tick), [state, tick]);
|
const mood = useMemo(() => deriveMood(state, tick), [state, tick]);
|
||||||
const status = useMemo(() => statusOf(state), [state]);
|
const status = useMemo(() => statusOf(state), [state]);
|
||||||
|
const attention = useMemo(() => attentionOf(state), [state]);
|
||||||
|
|
||||||
|
// An idle pet is not a statue: every minute or so it waves or looks around.
|
||||||
|
const moodRef = useRef(mood);
|
||||||
|
moodRef.current = mood;
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const schedule = () => {
|
||||||
|
timer = setTimeout(
|
||||||
|
() => {
|
||||||
|
if (moodRef.current === "idle" && document.visibilityState === "visible")
|
||||||
|
playMoment(Math.random() < 0.5 ? "wave" : "review");
|
||||||
|
schedule();
|
||||||
|
},
|
||||||
|
FIDGET_MIN_MS + Math.random() * FIDGET_SPAN_MS
|
||||||
|
);
|
||||||
|
};
|
||||||
|
schedule();
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [playMoment]);
|
||||||
|
|
||||||
const dismissBubble = useCallback(() => {
|
const dismissBubble = useCallback(() => {
|
||||||
clearTimeout(bubbleTimer.current);
|
clearTimeout(bubbleTimer.current);
|
||||||
setBubbleState(null);
|
setBubbleState(null);
|
||||||
|
burst.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
const toggleMute = useCallback(() => {
|
||||||
@@ -192,29 +351,47 @@ export function useSagiBrain(): SagiBrain {
|
|||||||
if (next) dismissBubble();
|
if (next) dismissBubble();
|
||||||
}, [dismissBubble]);
|
}, [dismissBubble]);
|
||||||
|
|
||||||
const clearAlerts = useCallback(() => setState((prev) => clearErrors(prev)), []);
|
const clearAlerts = useCallback(() => {
|
||||||
|
update((prev) => clearErrors(prev));
|
||||||
|
if (burst.current?.pulse === "error") dismissBubble();
|
||||||
|
}, [update, dismissBubble]);
|
||||||
|
|
||||||
const setThinking = useCallback(
|
const setThinking = useCallback(
|
||||||
(v: boolean) => setState((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })),
|
(v: boolean) => update((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })),
|
||||||
[]
|
[update]
|
||||||
);
|
);
|
||||||
|
|
||||||
// One-shot bubble: clears itself after BUBBLE_MS. Used by the shell for
|
// One-shot bubble: clears itself after BUBBLE_MS. Used by the shell for
|
||||||
// imperative messages (e.g. spawn errors) that must appear now and fade,
|
// imperative messages (e.g. spawn errors) that must appear now and fade,
|
||||||
// bypassing the throttled mood-driven showBubble path.
|
// bypassing the throttled mood-driven showBubble path.
|
||||||
const setBubble = useCallback((text: string) => {
|
const setBubble = useCallback(
|
||||||
if (!text) return;
|
(text: string) => {
|
||||||
clearTimeout(bubbleTimer.current);
|
if (!text) return;
|
||||||
setBubbleState(text);
|
burst.current = null;
|
||||||
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
|
putBubble({ text });
|
||||||
}, []);
|
},
|
||||||
|
[putBubble]
|
||||||
|
);
|
||||||
|
|
||||||
|
const pat = useCallback(() => {
|
||||||
|
playMoment("jump");
|
||||||
|
update((prev) => ({ ...prev, happyUntil: Date.now() + MOMENT_MS }));
|
||||||
|
clearTimeout(reactionTimer.current);
|
||||||
|
setReaction({ text: pickLine("pat"), key: ++momentKey.current });
|
||||||
|
reactionTimer.current = setTimeout(() => setReaction(null), MOMENT_MS);
|
||||||
|
}, [playMoment, update]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mood,
|
mood,
|
||||||
status,
|
status,
|
||||||
|
attention,
|
||||||
bubble,
|
bubble,
|
||||||
dismissBubble,
|
dismissBubble,
|
||||||
setBubble,
|
setBubble,
|
||||||
|
moment,
|
||||||
|
playMoment,
|
||||||
|
pat,
|
||||||
|
reaction,
|
||||||
muted,
|
muted,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
clearAlerts,
|
clearAlerts,
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ function PermissionChip({ runId, initial }: { runId: string; initial: Permission
|
|||||||
/** The menu the CLI asks with, as buttons: a pick presses its key in the
|
/** The menu the CLI asks with, as buttons: a pick presses its key in the
|
||||||
* pane; its "Type something" row opens a field right there instead —
|
* pane; its "Type something" row opens a field right there instead —
|
||||||
* pressing that row's digit again would type the digit into the CLI's own. */
|
* pressing that row's digit again would type the digit into the CLI's own. */
|
||||||
function PaneMenuCard({
|
export function PaneMenuCard({
|
||||||
menu,
|
menu,
|
||||||
onChoose,
|
onChoose,
|
||||||
onAnswer,
|
onAnswer,
|
||||||
|
|||||||
@@ -37,29 +37,199 @@
|
|||||||
"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": "Sagi companion",
|
"label": "{{name}} companion",
|
||||||
"live": "Live",
|
"live": "Live",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"close": "Close Sagi",
|
"close": "Close {{name}}",
|
||||||
"statLive": "live",
|
"statLive": "live",
|
||||||
"statWaiting": "waiting",
|
"statWaiting": "waiting",
|
||||||
"statErrored": "errored",
|
"statErrored": "errored",
|
||||||
"runClaude": "Run Claude",
|
|
||||||
"activity": "Activity",
|
|
||||||
"sessions": "Sessions",
|
|
||||||
"errored": "Errored",
|
|
||||||
"mute": "Mute",
|
"mute": "Mute",
|
||||||
"unmute": "Unmute",
|
"unmute": "Unmute",
|
||||||
"clearAlerts": "Clear alerts",
|
"clearAlerts": "Clear alerts",
|
||||||
"askPlaceholder": "Ask Sagi… (e.g. any errors?)",
|
"askPlaceholder": "Ask {{name}}… (e.g. any errors?)",
|
||||||
"ask": "Ask Sagi",
|
"ask": "Ask {{name}}",
|
||||||
"send": "Send"
|
"send": "Send",
|
||||||
|
"rename": "Click to rename {{name}}",
|
||||||
|
"renameLabel": "Pet name",
|
||||||
|
"renameHint": "Enter to save · empty = default name",
|
||||||
|
"pat": "Pat {{name}}",
|
||||||
|
"attention": "Needs you",
|
||||||
|
"attentionError": "errored",
|
||||||
|
"attentionWaiting": "waiting on you",
|
||||||
|
"attentionMore": "+{{count}} more in Sessions",
|
||||||
|
"chat": "Chat with {{name}}"
|
||||||
},
|
},
|
||||||
"bubbleDismiss": "Dismiss",
|
"bubbleDismiss": "Dismiss",
|
||||||
"open": "Open Sagi companion",
|
"open": "Open {{name}} companion",
|
||||||
"closeAvatar": "Close Sagi",
|
"closeAvatar": "Close {{name}}",
|
||||||
"avatarTitle": "Sagi — Ctrl/⌘+B · drag or arrow keys to move · right-click for pets",
|
"avatarTitle": "{{name}} — Ctrl/⌘+B · drag or arrow keys to move · stroke to pat · right-click for pets",
|
||||||
"startFailed": "Couldn't start that session: {{error}}"
|
"startFailed": "Couldn't start that session: {{error}}",
|
||||||
|
"bubbleOpen": "Open",
|
||||||
|
"bubbleMore": "{{list}} +{{count}} more",
|
||||||
|
"notify": {
|
||||||
|
"session_done_one": "{{count}} session finished ✨",
|
||||||
|
"session_done_other": "{{count}} sessions finished ✨",
|
||||||
|
"session_start_one": "{{count}} new session started 👀",
|
||||||
|
"session_start_other": "{{count}} new sessions started 👀",
|
||||||
|
"subagent_spawn_one": "{{count}} subagent joined in 🤝",
|
||||||
|
"subagent_spawn_other": "{{count}} subagents joined in 🤝",
|
||||||
|
"waiting_one": "{{count}} session is waiting on you ⏳",
|
||||||
|
"waiting_other": "{{count}} sessions are waiting on you ⏳",
|
||||||
|
"error_one": "{{count}} error just popped up 😿",
|
||||||
|
"error_other": "{{count}} errors just popped up 😿",
|
||||||
|
"run_done_one": "{{count}} run finished 🐾",
|
||||||
|
"run_done_other": "{{count}} runs finished 🐾"
|
||||||
|
},
|
||||||
|
"greet": {
|
||||||
|
"morning": [
|
||||||
|
"good morning! ☀️",
|
||||||
|
"morning! coffee first? ☕",
|
||||||
|
"rise and shine 🌤️"
|
||||||
|
],
|
||||||
|
"afternoon": [
|
||||||
|
"good afternoon! 🐾",
|
||||||
|
"hey, welcome back 😺",
|
||||||
|
"afternoon! ready when you are ✨"
|
||||||
|
],
|
||||||
|
"evening": [
|
||||||
|
"good evening! 🌆",
|
||||||
|
"evening shift? I'm in 😼",
|
||||||
|
"hey there, night owl-to-be 🌙"
|
||||||
|
],
|
||||||
|
"night": [
|
||||||
|
"burning the midnight oil? 🌙",
|
||||||
|
"late one, huh? I'll keep watch 🦉",
|
||||||
|
"still up? don't forget to rest 💤"
|
||||||
|
],
|
||||||
|
"summary_one": "{{count}} session live",
|
||||||
|
"summary_other": "{{count}} sessions live",
|
||||||
|
"summaryWaiting": "{{count}} waiting on you",
|
||||||
|
"summaryErrors": "{{count}} errored"
|
||||||
|
},
|
||||||
|
"pat": [
|
||||||
|
"purr… 💜",
|
||||||
|
"hehe, that tickles 😸",
|
||||||
|
"*happy wiggle* ✨",
|
||||||
|
"more pats please 🐾",
|
||||||
|
"best human 😻"
|
||||||
|
],
|
||||||
|
"intent": {
|
||||||
|
"empty": "ask me about your sessions - what's running, any errors, or a quick status.",
|
||||||
|
"help": "I'm {{name}} - I watch your sessions. Try \"what's running\", \"any errors\", \"what's waiting\" or \"status\". Anything else, I'll hand to Claude.",
|
||||||
|
"whoami": "I'm {{name}}, your session-watching buddy 🐾 Right-click me to change my look, or click my name in the panel to rename me.",
|
||||||
|
"hello": "hi there! 😺 {{summary}}.",
|
||||||
|
"thanks": [
|
||||||
|
"anytime! 💜",
|
||||||
|
"happy to help 😸",
|
||||||
|
"you got it 🐾"
|
||||||
|
],
|
||||||
|
"errors_one": "{{count}} session errored{{list}} - open the panel to jump to it.",
|
||||||
|
"errors_other": "{{count}} sessions errored{{list}} - open the panel to jump to them.",
|
||||||
|
"errorsNone": "no errors - all clean 🐾",
|
||||||
|
"waiting_one": "{{count}} session waiting on you{{list}} 👀",
|
||||||
|
"waiting_other": "{{count}} sessions waiting on you{{list}} 👀",
|
||||||
|
"waitingNone": "nothing's waiting on you right now 🐾",
|
||||||
|
"live_one": "{{count}} session live right now 🐾{{tail}}",
|
||||||
|
"live_other": "{{count}} sessions live right now 🐾{{tail}}",
|
||||||
|
"liveTail": " ({{count}} waiting on you 👀)",
|
||||||
|
"liveNone": "nothing's running right now - all quiet.",
|
||||||
|
"status": "{{live}} live · {{waiting}} waiting · {{errors}} errored · {{conn}}.",
|
||||||
|
"connected": "connected",
|
||||||
|
"offline": "offline"
|
||||||
|
},
|
||||||
|
"quips": {
|
||||||
|
"session_done": [
|
||||||
|
"a session just wrapped up! 🐾",
|
||||||
|
"a session finished - nice work! ✨",
|
||||||
|
"that session's all done 😺",
|
||||||
|
"clean finish on that one 💜"
|
||||||
|
],
|
||||||
|
"session_start": [
|
||||||
|
"a new session started! 👀",
|
||||||
|
"a fresh session just landed 🐾",
|
||||||
|
"ooh, a new session to watch 😻",
|
||||||
|
"something new is cooking 🍲"
|
||||||
|
],
|
||||||
|
"subagent_spawn": [
|
||||||
|
"a subagent just spawned! 🐾",
|
||||||
|
"a little helper joined in 🤝",
|
||||||
|
"a subagent's on the job 🚀",
|
||||||
|
"reinforcements - new subagent! 😺"
|
||||||
|
],
|
||||||
|
"waiting": [
|
||||||
|
"a session needs your input 👀",
|
||||||
|
"a session is waiting on you ⏳",
|
||||||
|
"a session paused for your reply 💬",
|
||||||
|
"your turn - a session's waiting 🐾"
|
||||||
|
],
|
||||||
|
"error": [
|
||||||
|
"uh oh, a session hit an error 😿",
|
||||||
|
"something broke - wanna peek? 🙀",
|
||||||
|
"a hook tripped on something ⚠️",
|
||||||
|
"hiss… an error popped up 💢"
|
||||||
|
],
|
||||||
|
"run_done": [
|
||||||
|
"your run just finished! 🐾",
|
||||||
|
"the run's all wrapped up ✨",
|
||||||
|
"run complete - that's a wrap 😸",
|
||||||
|
"all done with that run 💜"
|
||||||
|
],
|
||||||
|
"disconnected": [
|
||||||
|
"lost the connection… 😴",
|
||||||
|
"can't reach the server 📡",
|
||||||
|
"no signal - taking a nap 💤"
|
||||||
|
],
|
||||||
|
"worried": [
|
||||||
|
"that didn't look right 😟",
|
||||||
|
"keeping an eye out 👀",
|
||||||
|
"hmm, something's off 🫣"
|
||||||
|
],
|
||||||
|
"stuck": [
|
||||||
|
"a session's been quiet a while… 🤔",
|
||||||
|
"is something stuck? ⏳",
|
||||||
|
"still chewing on it… 😾"
|
||||||
|
],
|
||||||
|
"happy": [
|
||||||
|
"great run! 😻",
|
||||||
|
"love a tidy finish ✨",
|
||||||
|
"purrfect 💜"
|
||||||
|
],
|
||||||
|
"thinking": [
|
||||||
|
"hmm, let me look… 🤔",
|
||||||
|
"sniffing around… 🐾",
|
||||||
|
"one sec, checking 🔍"
|
||||||
|
],
|
||||||
|
"watching": [
|
||||||
|
"on the prowl 👀",
|
||||||
|
"watching your sessions 😼",
|
||||||
|
"eyes peeled 🐾"
|
||||||
|
],
|
||||||
|
"sleeping": [
|
||||||
|
"zzz… 💤",
|
||||||
|
"wake me if something happens 😴",
|
||||||
|
"curled up, all calm 🐈"
|
||||||
|
],
|
||||||
|
"idle": [
|
||||||
|
"all quiet 😺",
|
||||||
|
"ready when you are 🐾",
|
||||||
|
"just vibing ✨"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"label": "Chat with {{name}}",
|
||||||
|
"back": "Back",
|
||||||
|
"close": "Close {{name}}",
|
||||||
|
"new": "New chat",
|
||||||
|
"openWorkspace": "Open in workspace",
|
||||||
|
"typing": "typing…",
|
||||||
|
"placeholder": "Ask {{name}} anything…",
|
||||||
|
"emptyTitle": "Hi! I'm {{name}} 🐾",
|
||||||
|
"emptyHint": "Ask me anything — I'll get {{tool}} to answer right here.",
|
||||||
|
"starting": "Starting {{tool}}…",
|
||||||
|
"notStarted": "Waiting for the session to start — if it asks something, open it in the workspace.",
|
||||||
|
"notSent": "Couldn't reach the session's terminal — try again, or open it in the workspace."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"vendor": {
|
"vendor": {
|
||||||
|
|||||||
@@ -37,29 +37,199 @@
|
|||||||
"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ý Sagi",
|
"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 Sagi",
|
"close": "Đóng {{name}}",
|
||||||
"statLive": "đang chạy",
|
"statLive": "đang chạy",
|
||||||
"statWaiting": "đang chờ",
|
"statWaiting": "đang chờ",
|
||||||
"statErrored": "lỗi",
|
"statErrored": "lỗi",
|
||||||
"runClaude": "Chạy Claude",
|
|
||||||
"activity": "Hoạt động",
|
|
||||||
"sessions": "Phiên",
|
|
||||||
"errored": "Phiên 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 Sagi… (vd: có lỗi nào không?)",
|
"askPlaceholder": "Hỏi {{name}}… (vd: có lỗi nào không?)",
|
||||||
"ask": "Hỏi Sagi",
|
"ask": "Hỏi {{name}}",
|
||||||
"send": "Gửi"
|
"send": "Gửi",
|
||||||
|
"rename": "Bấm để đổi tên {{name}}",
|
||||||
|
"renameLabel": "Tên pet",
|
||||||
|
"renameHint": "Enter để lưu · để trống = tên mặc định",
|
||||||
|
"pat": "Xoa đầu {{name}}",
|
||||||
|
"attention": "Cần bạn xem",
|
||||||
|
"attentionError": "bị lỗi",
|
||||||
|
"attentionWaiting": "đang chờ bạn",
|
||||||
|
"attentionMore": "+{{count}} phiên khác trong Phiên",
|
||||||
|
"chat": "Trò chuyện với {{name}}"
|
||||||
},
|
},
|
||||||
"bubbleDismiss": "Đóng",
|
"bubbleDismiss": "Đóng",
|
||||||
"open": "Mở trợ lý Sagi",
|
"open": "Mở trợ lý {{name}}",
|
||||||
"closeAvatar": "Đóng Sagi",
|
"closeAvatar": "Đóng {{name}}",
|
||||||
"avatarTitle": "Sagi — Ctrl/⌘+B · kéo hoặc dùng phím mũi tên để di chuyển · chuột phải để chọn pet",
|
"avatarTitle": "{{name}} — Ctrl/⌘+B · kéo hoặc dùng phím mũi tên để di chuyển · vuốt qua lại để xoa đầu · chuột phải để chọn pet",
|
||||||
"startFailed": "Không khởi động được phiên: {{error}}"
|
"startFailed": "Không khởi động được phiên: {{error}}",
|
||||||
|
"bubbleOpen": "Mở",
|
||||||
|
"bubbleMore": "{{list}} và {{count}} phiên khác",
|
||||||
|
"notify": {
|
||||||
|
"session_done_one": "{{count}} phiên vừa xong ✨",
|
||||||
|
"session_done_other": "{{count}} phiên vừa xong ✨",
|
||||||
|
"session_start_one": "{{count}} phiên mới vừa bắt đầu 👀",
|
||||||
|
"session_start_other": "{{count}} phiên mới vừa bắt đầu 👀",
|
||||||
|
"subagent_spawn_one": "{{count}} subagent vừa vào việc 🤝",
|
||||||
|
"subagent_spawn_other": "{{count}} subagent vừa vào việc 🤝",
|
||||||
|
"waiting_one": "{{count}} phiên đang chờ bạn ⏳",
|
||||||
|
"waiting_other": "{{count}} phiên đang chờ bạn ⏳",
|
||||||
|
"error_one": "Vừa có {{count}} lỗi 😿",
|
||||||
|
"error_other": "Vừa có {{count}} lỗi 😿",
|
||||||
|
"run_done_one": "{{count}} lượt chạy vừa xong 🐾",
|
||||||
|
"run_done_other": "{{count}} lượt chạy vừa xong 🐾"
|
||||||
|
},
|
||||||
|
"greet": {
|
||||||
|
"morning": [
|
||||||
|
"chào buổi sáng! ☀️",
|
||||||
|
"sáng rồi! cà phê chưa? ☕",
|
||||||
|
"ngày mới tốt lành nha 🌤️"
|
||||||
|
],
|
||||||
|
"afternoon": [
|
||||||
|
"chào buổi chiều! 🐾",
|
||||||
|
"bạn quay lại rồi 😺",
|
||||||
|
"chiều nay làm gì đây? ✨"
|
||||||
|
],
|
||||||
|
"evening": [
|
||||||
|
"chào buổi tối! 🌆",
|
||||||
|
"ca tối hả? có mình đây 😼",
|
||||||
|
"tối rồi, cố lên nhé 🌙"
|
||||||
|
],
|
||||||
|
"night": [
|
||||||
|
"khuya rồi mà vẫn làm à? 🌙",
|
||||||
|
"để mình canh giúp cho 🦉",
|
||||||
|
"nhớ nghỉ ngơi nha 💤"
|
||||||
|
],
|
||||||
|
"summary_one": "{{count}} phiên đang chạy",
|
||||||
|
"summary_other": "{{count}} phiên đang chạy",
|
||||||
|
"summaryWaiting": "{{count}} phiên chờ bạn",
|
||||||
|
"summaryErrors": "{{count}} phiên lỗi"
|
||||||
|
},
|
||||||
|
"pat": [
|
||||||
|
"grừ grừ… 💜",
|
||||||
|
"hihi, nhột quá 😸",
|
||||||
|
"*vẫy đuôi* ✨",
|
||||||
|
"xoa nữa đi mà 🐾",
|
||||||
|
"chủ nhân tuyệt nhất 😻"
|
||||||
|
],
|
||||||
|
"intent": {
|
||||||
|
"empty": "hỏi mình về các phiên nhé - đang chạy gì, có lỗi không, hay tình hình chung.",
|
||||||
|
"help": "Mình là {{name}} - mình canh các phiên của bạn. Thử hỏi \"đang chạy gì\", \"có lỗi không\", \"cái gì đang chờ\" hoặc \"tình hình\". Câu khác mình sẽ chuyển cho Claude.",
|
||||||
|
"whoami": "Mình là {{name}}, bạn đồng hành canh phiên của bạn 🐾 Chuột phải vào mình để đổi ngoại hình, hoặc bấm vào tên trong bảng để đặt tên mới.",
|
||||||
|
"hello": "chào bạn! 😺 {{summary}}.",
|
||||||
|
"thanks": [
|
||||||
|
"có gì đâu! 💜",
|
||||||
|
"rất vui được giúp 😸",
|
||||||
|
"chuyện nhỏ 🐾"
|
||||||
|
],
|
||||||
|
"errors_one": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
||||||
|
"errors_other": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
||||||
|
"errorsNone": "không có lỗi nào - sạch sẽ 🐾",
|
||||||
|
"waiting_one": "{{count}} phiên đang chờ bạn{{list}} 👀",
|
||||||
|
"waiting_other": "{{count}} phiên đang chờ bạn{{list}} 👀",
|
||||||
|
"waitingNone": "hiện không có gì chờ bạn cả 🐾",
|
||||||
|
"live_one": "{{count}} phiên đang chạy 🐾{{tail}}",
|
||||||
|
"live_other": "{{count}} phiên đang chạy 🐾{{tail}}",
|
||||||
|
"liveTail": " ({{count}} phiên chờ bạn 👀)",
|
||||||
|
"liveNone": "hiện không có gì đang chạy - yên ắng.",
|
||||||
|
"status": "{{live}} đang chạy · {{waiting}} đang chờ · {{errors}} lỗi · {{conn}}.",
|
||||||
|
"connected": "đã kết nối",
|
||||||
|
"offline": "mất kết nối"
|
||||||
|
},
|
||||||
|
"quips": {
|
||||||
|
"session_done": [
|
||||||
|
"một phiên vừa xong! 🐾",
|
||||||
|
"xong một phiên rồi - giỏi lắm! ✨",
|
||||||
|
"phiên đó xong xuôi rồi 😺",
|
||||||
|
"kết thúc gọn gàng 💜"
|
||||||
|
],
|
||||||
|
"session_start": [
|
||||||
|
"có phiên mới bắt đầu! 👀",
|
||||||
|
"một phiên mới vừa tới 🐾",
|
||||||
|
"ồ, có phiên mới để canh 😻",
|
||||||
|
"có gì đó đang được nấu 🍲"
|
||||||
|
],
|
||||||
|
"subagent_spawn": [
|
||||||
|
"một subagent vừa xuất hiện! 🐾",
|
||||||
|
"có trợ thủ nhỏ vào cuộc 🤝",
|
||||||
|
"subagent đã nhận việc 🚀",
|
||||||
|
"viện binh tới - subagent mới! 😺"
|
||||||
|
],
|
||||||
|
"waiting": [
|
||||||
|
"một phiên cần bạn trả lời 👀",
|
||||||
|
"có phiên đang chờ bạn ⏳",
|
||||||
|
"một phiên tạm dừng chờ bạn 💬",
|
||||||
|
"tới lượt bạn - có phiên đang chờ 🐾"
|
||||||
|
],
|
||||||
|
"error": [
|
||||||
|
"ối, một phiên bị lỗi 😿",
|
||||||
|
"có gì đó hỏng rồi - xem thử không? 🙀",
|
||||||
|
"một hook vừa vấp phải lỗi ⚠️",
|
||||||
|
"khè… lỗi xuất hiện 💢"
|
||||||
|
],
|
||||||
|
"run_done": [
|
||||||
|
"lượt chạy của bạn xong rồi! 🐾",
|
||||||
|
"lượt chạy đã hoàn tất ✨",
|
||||||
|
"chạy xong - hết phim 😸",
|
||||||
|
"xong lượt chạy đó rồi 💜"
|
||||||
|
],
|
||||||
|
"disconnected": [
|
||||||
|
"mất kết nối rồi… 😴",
|
||||||
|
"không gọi được máy chủ 📡",
|
||||||
|
"mất sóng - đi ngủ chút 💤"
|
||||||
|
],
|
||||||
|
"worried": [
|
||||||
|
"có vẻ không ổn 😟",
|
||||||
|
"mình đang để mắt đây 👀",
|
||||||
|
"hừm, có gì đó lạ lạ 🫣"
|
||||||
|
],
|
||||||
|
"stuck": [
|
||||||
|
"một phiên im lặng lâu rồi… 🤔",
|
||||||
|
"có gì bị kẹt không? ⏳",
|
||||||
|
"vẫn đang nhai… 😾"
|
||||||
|
],
|
||||||
|
"happy": [
|
||||||
|
"tuyệt vời! 😻",
|
||||||
|
"kết thúc gọn ghê ✨",
|
||||||
|
"hoàn hảo 💜"
|
||||||
|
],
|
||||||
|
"thinking": [
|
||||||
|
"để mình xem… 🤔",
|
||||||
|
"đang đánh hơi… 🐾",
|
||||||
|
"chờ chút, đang kiểm tra 🔍"
|
||||||
|
],
|
||||||
|
"watching": [
|
||||||
|
"đang tuần tra 👀",
|
||||||
|
"đang canh các phiên của bạn 😼",
|
||||||
|
"mắt mở to 🐾"
|
||||||
|
],
|
||||||
|
"sleeping": [
|
||||||
|
"zzz… 💤",
|
||||||
|
"có gì thì gọi mình dậy nhé 😴",
|
||||||
|
"cuộn tròn, mọi thứ yên ổn 🐈"
|
||||||
|
],
|
||||||
|
"idle": [
|
||||||
|
"yên ắng quá 😺",
|
||||||
|
"sẵn sàng khi bạn cần 🐾",
|
||||||
|
"chill thôi ✨"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"label": "Trò chuyện với {{name}}",
|
||||||
|
"back": "Quay lại",
|
||||||
|
"close": "Đóng {{name}}",
|
||||||
|
"new": "Cuộc trò chuyện mới",
|
||||||
|
"openWorkspace": "Mở trong workspace",
|
||||||
|
"typing": "đang trả lời…",
|
||||||
|
"placeholder": "Hỏi {{name}} bất cứ điều gì…",
|
||||||
|
"emptyTitle": "Chào bạn! Mình là {{name}} 🐾",
|
||||||
|
"emptyHint": "Hỏi gì cũng được — mình sẽ nhờ {{tool}} trả lời ngay tại đây.",
|
||||||
|
"starting": "Đang khởi động {{tool}}…",
|
||||||
|
"notStarted": "Đang chờ phiên khởi động — nếu nó hỏi gì, hãy mở trong workspace.",
|
||||||
|
"notSent": "Không gửi được tới terminal của phiên — thử lại, hoặc mở trong workspace."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"vendor": {
|
"vendor": {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const PREF_KEYS = [
|
|||||||
"agenthub.terminal.font",
|
"agenthub.terminal.font",
|
||||||
"agenthub.pet",
|
"agenthub.pet",
|
||||||
"agenthub.pet.size",
|
"agenthub.pet.size",
|
||||||
|
"agenthub.pet.names",
|
||||||
"agent-dashboard-sagi-enabled",
|
"agent-dashboard-sagi-enabled",
|
||||||
"agent-dashboard-sagi-muted",
|
"agent-dashboard-sagi-muted",
|
||||||
"agent-monitor-notifications",
|
"agent-monitor-notifications",
|
||||||
|
|||||||
+3
-3
@@ -107,7 +107,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
|
|||||||
| `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays. `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` |
|
| `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays. `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` |
|
||||||
| `POST /api/me/sign-out-others` | `{ended}` — every login of the account but this one ends |
|
| `POST /api/me/sign-out-others` | `{ended}` — every login of the account but this one ends |
|
||||||
|
|
||||||
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.pet`, `agenthub.pet.size`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (never `bypassPermissions` — bypass stays the per-session switch), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
|
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (never `bypassPermissions` — bypass stays the per-session switch), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
|
||||||
|
|
||||||
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
|
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
|
||||||
|
|
||||||
@@ -2763,9 +2763,9 @@ 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 and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); 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 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.
|
||||||
|
|
||||||
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); 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.
|
||||||
|
|
||||||
**Featured pets are downloaded, never bundled.** The three offered (Goku, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
|
**Featured pets are downloaded, never bundled.** The three offered (Goku, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const PREF_KEYS = [
|
|||||||
"agenthub.terminal.font",
|
"agenthub.terminal.font",
|
||||||
"agenthub.pet",
|
"agenthub.pet",
|
||||||
"agenthub.pet.size",
|
"agenthub.pet.size",
|
||||||
|
"agenthub.pet.names",
|
||||||
"agent-dashboard-sagi-enabled",
|
"agent-dashboard-sagi-enabled",
|
||||||
"agent-dashboard-sagi-muted",
|
"agent-dashboard-sagi-muted",
|
||||||
"agent-monitor-notifications",
|
"agent-monitor-notifications",
|
||||||
|
|||||||
Reference in New Issue
Block a user