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:
@@ -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
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
@@ -18,7 +20,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Mood } from "./brain";
|
||||
import type { Mood, PetMoment } from "./brain";
|
||||
|
||||
const CELL = { w: 192, h: 208 };
|
||||
const COLS = 8;
|
||||
@@ -58,12 +60,16 @@ const ROW: Record<Mood, number> = {
|
||||
/** What the user is doing to the pet, which outranks its mood. */
|
||||
export type PetAction = "run-left" | "run-right" | "wave";
|
||||
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({
|
||||
id,
|
||||
kind,
|
||||
mood,
|
||||
action,
|
||||
moment,
|
||||
gaze,
|
||||
size,
|
||||
reducedMotion,
|
||||
@@ -73,6 +79,7 @@ export function PetSprite({
|
||||
kind: "codex" | "image";
|
||||
mood: Mood;
|
||||
action?: PetAction | null;
|
||||
moment?: PetMoment | null;
|
||||
/** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */
|
||||
gaze?: number | null;
|
||||
size: number;
|
||||
@@ -80,8 +87,8 @@ export function PetSprite({
|
||||
onBroken: () => void;
|
||||
}) {
|
||||
const url = api.pets.spriteUrl(id);
|
||||
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
|
||||
const sleeping = !action && mood === "sleeping";
|
||||
const row = action ? ACTION_ROW[action] : moment ? MOMENT_ROW[moment] : (ROW[mood] ?? 0);
|
||||
const sleeping = !action && !moment && mood === "sleeping";
|
||||
// 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.
|
||||
const [cell, setCell] = useState({ of: row, row, col: 0 });
|
||||
@@ -132,6 +139,7 @@ export function PetSprite({
|
||||
src={url}
|
||||
alt=""
|
||||
data-mood={mood}
|
||||
data-moment={moment ?? undefined}
|
||||
className="sagi-pet-image"
|
||||
width={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 =
|
||||
pose != null
|
||||
? { row: GAZE_ROW + Math.floor(pose / COLS), col: pose % COLS }
|
||||
|
||||
@@ -88,8 +88,19 @@ import { useSagiBrain } from "./useSagiBrain";
|
||||
import { useSagiPosition } from "./useSagiPosition";
|
||||
import { useSagiEnabled } from "./SagiMount";
|
||||
import { matchIntent } from "./intents";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
import { petName, sagiPrefs } from "./prefs";
|
||||
import type { SagiSubject } from "./brain";
|
||||
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";
|
||||
|
||||
const FLYOUT_GAP = 10; // px between avatar and flyout
|
||||
@@ -209,8 +220,13 @@ export function Sagi() {
|
||||
function SagiBody() {
|
||||
const { t } = useTranslation("settings");
|
||||
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 [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`).
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [hover, setHover] = useState(false);
|
||||
@@ -226,11 +242,32 @@ function SagiBody() {
|
||||
() =>
|
||||
sagiPrefs.subscribe(() => {
|
||||
setPet(sagiPrefs.getPet());
|
||||
setName(petName(sagiPrefs.getPet()));
|
||||
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
|
||||
// shown, and never from an editable field, where Ctrl+B belongs to the field.
|
||||
useEffect(() => {
|
||||
@@ -241,6 +278,7 @@ function SagiBody() {
|
||||
setOpen((v) => !v);
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
@@ -281,27 +319,53 @@ function SagiBody() {
|
||||
[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(
|
||||
(query: string): string | null => {
|
||||
const result = matchIntent(query, brain.status);
|
||||
if (result.kind === "answer") return result.text;
|
||||
// Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a
|
||||
// fresh claude session in it carrying the user's prompt as its first
|
||||
// message, then open that workspace's session view on the new pane.
|
||||
// Fire-and-forget — the panel already clears its composer on null and
|
||||
// closes; errors surface as an answer instead of silently swallowing.
|
||||
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
|
||||
if (result.kind === "answer") {
|
||||
if (result.moment) brain.playMoment(result.moment);
|
||||
return result.text;
|
||||
}
|
||||
// Handoff: a question the pet cannot answer goes to the chat window,
|
||||
// which starts a session of the default AI tool and shows it as chat.
|
||||
if (petChatTool()) {
|
||||
setChatPrompt(result.prompt);
|
||||
setOpen(false);
|
||||
setChatOpen(true);
|
||||
return null;
|
||||
}
|
||||
// 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;
|
||||
brain.setThinking(true);
|
||||
(async () => {
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||
const run = await api.run.start({
|
||||
laneId: lane.id,
|
||||
cwd: lane.cwd,
|
||||
initialPrompt: prompt,
|
||||
permissionMode: "acceptEdits",
|
||||
const tool = readDefaultTool();
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
prompt,
|
||||
...(tool && tool !== lane.tool ? { tool } : {}),
|
||||
});
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`);
|
||||
navigate(`/workspace?lane=${r.laneId}&run=${encodeURIComponent(r.runId)}`);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
@@ -312,9 +376,32 @@ function SagiBody() {
|
||||
})();
|
||||
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 = {
|
||||
left: place.left,
|
||||
top: place.top,
|
||||
@@ -331,17 +418,52 @@ function SagiBody() {
|
||||
{!place.dragging && open && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SagiPanel
|
||||
name={name}
|
||||
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
||||
nickname={sagiPrefs.getNickname(pet.id)}
|
||||
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
||||
onPat={brain.pat}
|
||||
attention={brain.attention}
|
||||
onOpenSession={openSession}
|
||||
status={brain.status}
|
||||
muted={brain.muted}
|
||||
onToggleMute={brain.toggleMute}
|
||||
onClearAlerts={brain.clearAlerts}
|
||||
onNavigate={onNavigate}
|
||||
onAsk={onAsk}
|
||||
onOpenChat={() => {
|
||||
setOpen(false);
|
||||
setChatOpen(true);
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</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 && (
|
||||
<SagiFlyout anchor={anchor} front>
|
||||
<PetMenu
|
||||
@@ -356,9 +478,13 @@ function SagiBody() {
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!open && !menu && brain.bubble && (
|
||||
{!open && !menu && !chatOpen && brain.bubble && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
|
||||
<SpeechBubble
|
||||
bubble={brain.bubble}
|
||||
onDismiss={brain.dismissBubble}
|
||||
onOpen={openSession}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
@@ -370,34 +496,47 @@ function SagiBody() {
|
||||
onPointerEnter={() => setHover(true)}
|
||||
onPointerLeave={() => setHover(false)}
|
||||
onPointerDown={place.onPointerDown}
|
||||
onPointerMove={place.onPointerMove}
|
||||
onPointerMove={(e) => {
|
||||
place.onPointerMove(e);
|
||||
onStroke(e);
|
||||
}}
|
||||
onPointerUp={place.onPointerUp}
|
||||
onKeyDown={place.onKeyDown}
|
||||
onClick={() => {
|
||||
// A drag just ended - swallow the synthetic click so the panel
|
||||
// doesn't toggle when the user only repositioned the avatar.
|
||||
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).
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
setMenu(true);
|
||||
}}
|
||||
aria-label={open ? t("sagi.closeAvatar") : t("sagi.open")}
|
||||
aria-expanded={open}
|
||||
title={t("sagi.avatarTitle")}
|
||||
aria-label={open || chatOpen ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
|
||||
aria-expanded={open || chatOpen}
|
||||
title={t("sagi.avatarTitle", { name })}
|
||||
>
|
||||
{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
|
||||
id={pet.id}
|
||||
kind={pet.kind}
|
||||
mood={brain.mood}
|
||||
// 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}
|
||||
size={size}
|
||||
reducedMotion={reducedMotion}
|
||||
@@ -405,10 +544,28 @@ function SagiBody() {
|
||||
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>
|
||||
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
||||
</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>
|
||||
</>
|
||||
|
||||
@@ -37,12 +37,14 @@
|
||||
* ============================================================================= */
|
||||
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import type { Mood } from "./brain";
|
||||
import type { Mood, PetMoment } from "./brain";
|
||||
|
||||
interface SagiAvatarProps {
|
||||
mood: Mood;
|
||||
reducedMotion: boolean;
|
||||
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
|
||||
@@ -88,6 +90,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
||||
mood,
|
||||
reducedMotion,
|
||||
size = 68,
|
||||
moment = null,
|
||||
}: SagiAvatarProps) {
|
||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||
const rafRef = useRef<number>();
|
||||
@@ -148,6 +151,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
||||
ref={rootRef}
|
||||
className="sagi-avatar"
|
||||
data-mood={mood}
|
||||
data-moment={moment ?? undefined}
|
||||
data-reduced={reducedMotion ? "1" : "0"}
|
||||
width={size}
|
||||
height={size}
|
||||
|
||||
@@ -50,9 +50,6 @@
|
||||
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Play,
|
||||
Activity,
|
||||
LayoutList,
|
||||
Bell,
|
||||
BellOff,
|
||||
Trash2,
|
||||
@@ -61,12 +58,30 @@ import {
|
||||
AlertTriangle,
|
||||
Hourglass,
|
||||
Radio,
|
||||
Heart,
|
||||
ChevronRight,
|
||||
MessageCircle,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SagiStatus } from "./brain";
|
||||
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
|
||||
import { PET_NAME_MAX } from "./prefs";
|
||||
|
||||
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
|
||||
const ATTENTION_SHOWN = 4;
|
||||
|
||||
interface SagiPanelProps {
|
||||
/** The name the pet goes by now. */
|
||||
name: string;
|
||||
/** The pet's own name, which an empty nickname falls back to. */
|
||||
ownName: string;
|
||||
/** The user's nickname for the pet, "" when none. */
|
||||
nickname: string;
|
||||
onRename: (name: string) => void;
|
||||
onPat: () => void;
|
||||
/** Errored, then waiting sessions, named. */
|
||||
attention: ReadonlyArray<AttentionItem>;
|
||||
onOpenSession: (s: SagiSubject) => void;
|
||||
status: SagiStatus;
|
||||
muted: boolean;
|
||||
onToggleMute: () => void;
|
||||
@@ -74,21 +89,38 @@ interface SagiPanelProps {
|
||||
onNavigate: (route: string) => void;
|
||||
/** Returns an answer to display, or null when the query was handed off. */
|
||||
onAsk: (query: string) => string | null;
|
||||
/** Opens the chat window (PetChat). */
|
||||
onOpenChat: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SagiPanel({
|
||||
name,
|
||||
ownName,
|
||||
nickname,
|
||||
onRename,
|
||||
onPat,
|
||||
attention,
|
||||
onOpenSession,
|
||||
status,
|
||||
muted,
|
||||
onToggleMute,
|
||||
onClearAlerts,
|
||||
onNavigate,
|
||||
onAsk,
|
||||
onOpenChat,
|
||||
onClose,
|
||||
}: SagiPanelProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [query, setQuery] = useState("");
|
||||
const [answer, setAnswer] = useState<string | null>(null);
|
||||
// The name being typed, or null while not renaming.
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const saveName = () => {
|
||||
if (renaming === null) return;
|
||||
onRename(renaming);
|
||||
setRenaming(null);
|
||||
};
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -101,7 +133,7 @@ export function SagiPanel({
|
||||
<div
|
||||
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl backdrop-blur-md animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={t("sagi.panel.label")}
|
||||
aria-label={t("sagi.panel.label", { name })}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
|
||||
@@ -109,7 +141,40 @@ export function SagiPanel({
|
||||
<span className="text-base leading-none" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-fg-primary">Sagi</span>
|
||||
{renaming === null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
|
||||
onClick={() => setRenaming(nickname)}
|
||||
aria-label={t("sagi.panel.rename", { name })}
|
||||
title={t("sagi.panel.rename", { name })}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
) : (
|
||||
<input
|
||||
autoFocus
|
||||
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
|
||||
value={renaming}
|
||||
maxLength={PET_NAME_MAX}
|
||||
placeholder={ownName}
|
||||
aria-label={t("sagi.panel.renameLabel")}
|
||||
title={t("sagi.panel.renameHint")}
|
||||
onChange={(e) => setRenaming(e.target.value)}
|
||||
onBlur={saveName}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveName();
|
||||
} else if (e.key === "Escape") {
|
||||
// Esc cancels the rename only, not the whole panel.
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
setRenaming(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
status.connected
|
||||
@@ -126,13 +191,23 @@ export function SagiPanel({
|
||||
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
||||
onClick={onClose}
|
||||
aria-label={t("sagi.panel.close")}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
||||
onClick={onPat}
|
||||
aria-label={t("sagi.panel.pat", { name })}
|
||||
title={t("sagi.panel.pat", { name })}
|
||||
>
|
||||
<Heart size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
||||
onClick={onClose}
|
||||
aria-label={t("sagi.panel.close", { name })}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
@@ -158,29 +233,62 @@ export function SagiPanel({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* the sessions that need the user, by name */}
|
||||
{attention.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.panel.attention")}
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{attention.slice(0, ATTENTION_SHOWN).map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
className="group flex w-full items-center gap-2 rounded-lg bg-surface-1 px-2 py-1.5 text-left text-xs transition-colors hover:bg-surface-4"
|
||||
onClick={() => onOpenSession(item)}
|
||||
title={item.cwd ?? item.id}
|
||||
>
|
||||
{item.kind === "error" ? (
|
||||
<AlertTriangle
|
||||
size={13}
|
||||
className="shrink-0 text-status-danger"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Hourglass size={13} className="shrink-0 text-status-warning" aria-hidden />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-fg-secondary group-hover:text-fg-primary">
|
||||
{subjectLabel(item)}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-fg-muted">
|
||||
{item.kind === "error"
|
||||
? t("sagi.panel.attentionError")
|
||||
: t("sagi.panel.attentionWaiting")}
|
||||
</span>
|
||||
<ChevronRight size={12} className="shrink-0 text-fg-muted" aria-hidden />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{attention.length > ATTENTION_SHOWN && (
|
||||
<button
|
||||
className="mt-1 text-[11px] text-accent hover:underline"
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
>
|
||||
{t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* quick actions */}
|
||||
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
||||
<ActionButton
|
||||
icon={Play}
|
||||
label={t("sagi.panel.runClaude")}
|
||||
onClick={() => onNavigate("/workspace")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={Activity}
|
||||
label={t("sagi.panel.activity")}
|
||||
onClick={() => onNavigate("/activity")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={LayoutList}
|
||||
label={t("sagi.panel.sessions")}
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={AlertTriangle}
|
||||
label={t("sagi.panel.errored")}
|
||||
disabled={status.errorCount === 0}
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
/>
|
||||
<div className="col-span-2">
|
||||
<ActionButton
|
||||
icon={MessageCircle}
|
||||
label={t("sagi.panel.chat", { name })}
|
||||
onClick={onOpenChat}
|
||||
/>
|
||||
</div>
|
||||
<ActionButton
|
||||
icon={muted ? BellOff : Bell}
|
||||
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
||||
@@ -198,10 +306,10 @@ export function SagiPanel({
|
||||
<form onSubmit={submit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder={t("sagi.panel.askPlaceholder")}
|
||||
placeholder={t("sagi.panel.askPlaceholder", { name })}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label={t("sagi.panel.ask")}
|
||||
aria-label={t("sagi.panel.ask", { name })}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -284,7 +392,7 @@ function ActionButton({
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="flex w-full items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* @file SpeechBubble.tsx
|
||||
* @description Transient speech bubble rendered above the Sagi mascot.
|
||||
* Pure presentation — visibility timing and quip selection live in
|
||||
* {@link useSagiBrain}; this component only paints the bubble and handles
|
||||
* user dismissal.
|
||||
* Pure presentation — visibility timing, coalescing and quip selection live in
|
||||
* {@link useSagiBrain}; this component paints the line, the session(s) it is
|
||||
* about, an Open button when it is about exactly one, and handles dismissal.
|
||||
*
|
||||
* ## Accessibility
|
||||
* Uses `role="status"` with `aria-live="polite"` so screen readers announce
|
||||
* new quips without interrupting current speech. Click anywhere on the bubble
|
||||
* to dismiss early.
|
||||
* new quips without interrupting current speech. Click the bubble to dismiss
|
||||
* it early; its Open button opens the session instead.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -52,30 +52,52 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SagiBubble } from "./useSagiBrain";
|
||||
import type { SagiSubject } from "./brain";
|
||||
|
||||
/** Props for {@link SpeechBubble}. */
|
||||
interface SpeechBubbleProps {
|
||||
/** Quip text to display inside the bubble. */
|
||||
text: string;
|
||||
bubble: SagiBubble;
|
||||
/** Called when the user clicks to dismiss. */
|
||||
onDismiss: () => void;
|
||||
/** Opens the session the bubble is about. */
|
||||
onOpen?: (subject: SagiSubject) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated speech bubble above Sagi.
|
||||
* @param props See {@link SpeechBubbleProps}.
|
||||
*/
|
||||
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
|
||||
export function SpeechBubble({ bubble, onDismiss, onOpen }: SpeechBubbleProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { text, detail, subject, tone } = bubble;
|
||||
return (
|
||||
<div
|
||||
className="sagi-bubble sagi-bubble-enter"
|
||||
data-tone={tone}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
onClick={onDismiss}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
fireEvent.click(screen.getByTestId("pet-choose"));
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -284,7 +284,7 @@ describe("PetMenu", () => {
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("pet-image-input"), { target: { files: [file] } });
|
||||
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);
|
||||
});
|
||||
@@ -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 () => {
|
||||
render(<PetSettings />);
|
||||
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");
|
||||
|
||||
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"));
|
||||
answerConfirm(true);
|
||||
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" });
|
||||
fireEvent.change(screen.getByTestId("pet-settings-image-input"), { target: { files: [file] } });
|
||||
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();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
|
||||
@@ -225,6 +225,43 @@ describe("clearErrors", () => {
|
||||
expect(statusOf(s).liveCount).toBe(1);
|
||||
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.
|
||||
|
||||
@@ -75,4 +75,27 @@ describe("matchIntent", () => {
|
||||
const r = matchIntent("Refactor my auth module", status());
|
||||
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`). */
|
||||
export type Mood =
|
||||
| "disconnected"
|
||||
| "worried"
|
||||
| "stuck"
|
||||
| "happy"
|
||||
| "thinking"
|
||||
| "watching"
|
||||
| "sleeping"
|
||||
| "idle";
|
||||
"disconnected" | "worried" | "stuck" | "happy" | "thinking" | "watching" | "sleeping" | "idle";
|
||||
|
||||
/**
|
||||
* A one-shot signal describing what just happened, emitted by `reduceSagi`.
|
||||
@@ -152,13 +145,21 @@ export type Mood =
|
||||
* was irrelevant or non-notable.
|
||||
*/
|
||||
export type SagiPulse =
|
||||
| "session_done"
|
||||
| "session_start"
|
||||
| "subagent_spawn"
|
||||
| "waiting"
|
||||
| "error"
|
||||
| "run_done"
|
||||
| null;
|
||||
"session_done" | "session_start" | "subagent_spawn" | "waiting" | "error" | "run_done" | null;
|
||||
|
||||
/** A short animation the pet plays on an event or a pat, over its mood. */
|
||||
export type PetMoment = "wave" | "jump" | "fail" | "wait" | "review";
|
||||
|
||||
/** Which moment each pulse plays: greet a new session, hop at a finish,
|
||||
* 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 {
|
||||
/** Active + waiting sessions (everything not finished/errored). */
|
||||
@@ -182,6 +183,47 @@ export interface SagiState {
|
||||
worriedUntil: number;
|
||||
/** True while an Ask request is in flight (panel). */
|
||||
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).
|
||||
@@ -211,6 +253,8 @@ export function initialSagiState(now: number): SagiState {
|
||||
happyUntil: 0,
|
||||
worriedUntil: 0,
|
||||
thinking: false,
|
||||
dismissed: {},
|
||||
info: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,16 +300,28 @@ export function reduceSagi(
|
||||
state: SagiState,
|
||||
msg: WSMessage,
|
||||
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) {
|
||||
case "session_created":
|
||||
case "session_updated": {
|
||||
const s = msg.data as Session;
|
||||
if (!s || !s.id) return { state, pulse: null };
|
||||
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 happyUntil = state.happyUntil;
|
||||
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") {
|
||||
// "waiting" = active session blocked on user input (permission prompt
|
||||
@@ -280,12 +336,20 @@ export function reduceSagi(
|
||||
if (prev === undefined) pulse = "session_start";
|
||||
}
|
||||
} 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";
|
||||
worriedUntil = now + WORRIED_MS;
|
||||
pulse = "error";
|
||||
} else if (s.status === "completed" || s.status === "abandoned") {
|
||||
const wasTracked = s.id in sessions;
|
||||
delete sessions[s.id];
|
||||
delete info[s.id];
|
||||
if (s.status === "completed") {
|
||||
happyUntil = now + HAPPY_MS;
|
||||
if (wasTracked) pulse = "session_done";
|
||||
@@ -293,31 +357,46 @@ export function reduceSagi(
|
||||
}
|
||||
|
||||
return {
|
||||
state: { ...state, sessions, happyUntil, worriedUntil, lastActivityAt: now },
|
||||
state: {
|
||||
...state,
|
||||
sessions,
|
||||
dismissed,
|
||||
info,
|
||||
happyUntil,
|
||||
worriedUntil,
|
||||
lastActivityAt: now,
|
||||
},
|
||||
pulse,
|
||||
subject,
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_created": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
subject: known(a.session_id),
|
||||
};
|
||||
}
|
||||
// A freshly spawned subagent is worth announcing; the main agent landing
|
||||
// is already covered by session_start.
|
||||
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": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
subject: known(a.session_id),
|
||||
};
|
||||
}
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
@@ -325,7 +404,8 @@ export function reduceSagi(
|
||||
|
||||
case "new_event": {
|
||||
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 {
|
||||
state: {
|
||||
...state,
|
||||
@@ -333,6 +413,7 @@ export function reduceSagi(
|
||||
worriedUntil: isFailure ? now + WORRIED_MS : state.worriedUntil,
|
||||
},
|
||||
pulse: isFailure ? "error" : null,
|
||||
subject: isFailure ? known(e.session_id) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,24 +442,60 @@ export function reduceSagi(
|
||||
*/
|
||||
export function seedSessions(
|
||||
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
|
||||
): SagiState {
|
||||
const sessions = { ...state.sessions };
|
||||
const info = { ...state.info };
|
||||
for (const r of rows) {
|
||||
if (!r || !r.id) continue;
|
||||
if (r.status === "error") sessions[r.id] = "error";
|
||||
else if (r.status === "active") sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
|
||||
// completed / abandoned: leave untracked.
|
||||
if (r.status === "error") {
|
||||
if (r.id in state.dismissed) continue;
|
||||
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 {
|
||||
const sessions: SagiState["sessions"] = {};
|
||||
const dismissed = { ...state.dismissed };
|
||||
const info = { ...state.info };
|
||||
for (const [id, s] of Object.entries(state.sessions)) {
|
||||
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
|
||||
* 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`).
|
||||
* 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>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -28,6 +30,8 @@
|
||||
*
|
||||
* ## Public surface
|
||||
* - `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.
|
||||
*
|
||||
* ## 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 q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: "ask me about your sessions - what's running, any errors, or a quick status.",
|
||||
};
|
||||
}
|
||||
const t = (key: string, vars?: Record<string, unknown>) =>
|
||||
i18n.t(`settings:sagi.intent.${key}`, vars ?? {});
|
||||
|
||||
/** ": a, b, c" for up to three named sessions of a kind, "" when none known. */
|
||||
function listOf(items: ReadonlyArray<AttentionItem> | undefined, kind: AttentionItem["kind"]) {
|
||||
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));
|
||||
// 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")) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: 'I watch your sessions. Try "what\'s running", "any errors", or "status". Anything else, I\'ll hand to Claude.',
|
||||
};
|
||||
if (has("help", "what can you", "what do you do", "trợ giúp", "làm được gì", "hướng dẫn")) {
|
||||
return { kind: "answer", text: t("help", { name }) };
|
||||
}
|
||||
|
||||
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.
|
||||
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 {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.errorCount > 0
|
||||
? `${status.errorCount} session${plural(status.errorCount)} errored - open the panel to jump to them.`
|
||||
: "no errors - all clean 🐾",
|
||||
? t("errors", { count: status.errorCount, list: listOf(ctx.attention, "error") })
|
||||
: 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 {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.waitingCount > 0
|
||||
? `${status.waitingCount} session${plural(status.waitingCount)} waiting on you 👀`
|
||||
: "nothing's waiting on you right now 🐾",
|
||||
? t("waiting", { count: status.waitingCount, list: listOf(ctx.attention, "waiting") })
|
||||
: t("waitingNone"),
|
||||
};
|
||||
}
|
||||
|
||||
if (has("running", "active", "live", "going on", "happening", "in progress")) {
|
||||
const tail = status.waitingCount > 0 ? ` (${status.waitingCount} waiting on you 👀)` : "";
|
||||
if (
|
||||
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 {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.liveCount > 0
|
||||
? `${status.liveCount} session${plural(status.liveCount)} live right now 🐾${tail}`
|
||||
: "nothing's running right now - all quiet.",
|
||||
text: status.liveCount > 0 ? t("live", { count: status.liveCount, tail }) : t("liveNone"),
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
kind: "answer",
|
||||
text: `${status.liveCount} live · ${status.waitingCount} waiting · ${status.errorCount} errored · ${
|
||||
status.connected ? "connected" : "offline"
|
||||
}.`,
|
||||
text: t("status", {
|
||||
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() };
|
||||
}
|
||||
|
||||
@@ -56,12 +56,19 @@ const MUTED_KEY = "agent-dashboard-sagi-muted";
|
||||
const POS_KEY = "agent-dashboard-sagi-pos";
|
||||
const PET_KEY = "agenthub.pet";
|
||||
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. */
|
||||
export const PET_SIZE = { default: 68, min: 48, max: 250 };
|
||||
|
||||
/** The pet the mascot wears: Sagi itself, or a custom one (`/api/pets`).
|
||||
* The kind rides along so the mascot renders without a fetch. */
|
||||
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image" };
|
||||
* The kind (and its display name) ride along so the mascot renders, and
|
||||
* introduces itself, without a fetch. */
|
||||
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image"; name?: string };
|
||||
const SAGI: PetChoice = { id: "sagi" };
|
||||
|
||||
function readPet(): PetChoice {
|
||||
@@ -69,6 +76,7 @@ function readPet(): PetChoice {
|
||||
const p = JSON.parse(localStorage.getItem(PET_KEY) ?? "null") as Partial<{
|
||||
id: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
}> | null;
|
||||
if (
|
||||
p &&
|
||||
@@ -76,13 +84,34 @@ function readPet(): PetChoice {
|
||||
p.id !== "sagi" &&
|
||||
(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 {
|
||||
/* unreadable: 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 {
|
||||
const n = Number(localStorage.getItem(SIZE_KEY));
|
||||
return Number.isFinite(n) && n > 0
|
||||
@@ -181,6 +210,37 @@ export const sagiPrefs = {
|
||||
SIZE_KEY,
|
||||
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(handler: () => void): () => void {
|
||||
const listener = () => handler();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file quips.ts
|
||||
* @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
|
||||
* can be unit-tested without randomness leaking in.
|
||||
* @description Sagi's personality: pools of short phrases keyed by pulse/mood
|
||||
* (and greetings, pat reactions), read from the `settings:sagi.*` i18n arrays
|
||||
* so they follow the UI language, plus a deterministic-by-injection picker.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -24,10 +24,13 @@
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./brain`
|
||||
* - `../../i18n`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `QuipKey` — 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.
|
||||
*
|
||||
* ## Testing pointers
|
||||
@@ -59,76 +62,60 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import i18n from "../../i18n";
|
||||
import type { Mood, SagiPulse } from "./brain";
|
||||
|
||||
export type QuipKey = NonNullable<SagiPulse> | Mood;
|
||||
|
||||
const QUIPS: Record<QuipKey, string[]> = {
|
||||
export const ALL_QUIP_KEYS: QuipKey[] = [
|
||||
// Pulses (event-driven, transient bubbles)
|
||||
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 💜",
|
||||
],
|
||||
// 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 ✨"],
|
||||
};
|
||||
"session_done",
|
||||
"session_start",
|
||||
"subagent_spawn",
|
||||
"waiting",
|
||||
"error",
|
||||
"run_done",
|
||||
// Moods (steady-state flavor)
|
||||
"disconnected",
|
||||
"worried",
|
||||
"stuck",
|
||||
"happy",
|
||||
"thinking",
|
||||
"watching",
|
||||
"sleeping",
|
||||
"idle",
|
||||
];
|
||||
const KNOWN = new Set<string>(ALL_QUIP_KEYS);
|
||||
|
||||
/**
|
||||
* Pick a quip for a key. `rand` is injectable for deterministic tests; defaults
|
||||
* to Math.random. Returns "" only for an unknown key (never throws).
|
||||
* Pick one line from an i18n string array under `settings:sagi.<path>`.
|
||||
* `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 {
|
||||
const pool = QUIPS[key];
|
||||
if (!pool || pool.length === 0) return "";
|
||||
export function pickLine(
|
||||
path: string,
|
||||
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)));
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── 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 ── */
|
||||
.sagi-avatar[data-reduced="1"],
|
||||
.sagi-avatar[data-reduced="1"] * {
|
||||
@@ -341,6 +419,14 @@
|
||||
border: 2px solid #0c0c14;
|
||||
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 {
|
||||
max-width: 16rem;
|
||||
background: #1a1a28;
|
||||
@@ -356,6 +442,41 @@
|
||||
.sagi-bubble-enter {
|
||||
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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -403,3 +524,165 @@
|
||||
.sagi-flyout[data-front="1"] {
|
||||
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) =>
|
||||
run(featuredId, async () => {
|
||||
wear((await api.pets.installFeatured(featuredId)).pet);
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
* @file useSagiBrain.ts
|
||||
* @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
|
||||
* exposes the derived mood, a status summary, the current speech bubble, and
|
||||
* imperative controls (mute, clear alerts, set thinking) for the UI shell.
|
||||
* exposes the derived mood, a status summary, the sessions that need the user,
|
||||
* 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>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -62,108 +66,235 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { api } from "../../lib/api";
|
||||
import i18n from "../../i18n";
|
||||
import type { WSMessage } from "../../lib/types";
|
||||
import {
|
||||
initialSagiState,
|
||||
reduceSagi,
|
||||
deriveMood,
|
||||
statusOf,
|
||||
attentionOf,
|
||||
clearErrors,
|
||||
seedSessions,
|
||||
subjectLabel,
|
||||
MOMENT_OF_PULSE,
|
||||
type AttentionItem,
|
||||
type Mood,
|
||||
type PetMoment,
|
||||
type SagiPulse,
|
||||
type SagiState,
|
||||
type SagiStatus,
|
||||
type SagiSubject,
|
||||
} from "./brain";
|
||||
import { pickQuip } from "./quips";
|
||||
import { greetingKey, pickLine, pickQuip } from "./quips";
|
||||
import { statusSummary } from "./intents";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
|
||||
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.
|
||||
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 {
|
||||
mood: Mood;
|
||||
status: SagiStatus;
|
||||
bubble: string | null;
|
||||
/** Errored, then waiting sessions, named. */
|
||||
attention: AttentionItem[];
|
||||
bubble: SagiBubble | null;
|
||||
dismissBubble: () => void;
|
||||
/** Imperative bubble setter for one-off messages from the shell (e.g. spawn
|
||||
* errors). Bypasses the throttle because the caller decided this message
|
||||
* must be seen right now. Auto-clears after the standard bubble TTL. */
|
||||
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;
|
||||
toggleMute: () => void;
|
||||
clearAlerts: () => 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 {
|
||||
const now0 = Date.now();
|
||||
// Start optimistically connected (idle, open eyes) so the cursor-tracking
|
||||
// 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.
|
||||
const [state, setState] = useState<SagiState>(() => ({
|
||||
const [state, setStateRaw] = useState<SagiState>(() => ({
|
||||
...initialSagiState(now0),
|
||||
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 [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 bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const lastBubbleAt = useRef(0);
|
||||
const burst = useRef<Burst | null>(null);
|
||||
const momentKey = useRef(0);
|
||||
const mutedRef = useRef(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.
|
||||
useEffect(() => sagiPrefs.subscribe(() => setMuted(sagiPrefs.getMuted())), []);
|
||||
|
||||
const showBubble = useCallback((text: string, force: boolean) => {
|
||||
if (!text) return;
|
||||
if (mutedRef.current) return;
|
||||
const t = Date.now();
|
||||
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
|
||||
lastBubbleAt.current = t;
|
||||
const putBubble = useCallback((b: SagiBubble, ms = BUBBLE_MS) => {
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubbleState(text);
|
||||
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
|
||||
setBubbleState(b);
|
||||
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 -
|
||||
// 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.
|
||||
// Pull a generous page of non-finished sessions; live WS deltas refine it.
|
||||
// Then say hello, once per browser session.
|
||||
useEffect(() => {
|
||||
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
|
||||
.list({ status: "active", limit: 100 })
|
||||
.then((res) => {
|
||||
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 () => {
|
||||
cancelled = true;
|
||||
clearTimeout(greetTimer);
|
||||
};
|
||||
}, []);
|
||||
}, [update, putBubble, playMoment]);
|
||||
|
||||
// Subscribe to the live stream and connection status.
|
||||
useEffect(() => {
|
||||
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
||||
const t = Date.now();
|
||||
setState((prev) => {
|
||||
const { state: next, pulse } = reduceSagi(prev, msg, t);
|
||||
if (pulse) showBubble(pickQuip(pulse), pulse === "error");
|
||||
return next;
|
||||
});
|
||||
const { state: next, pulse, subject } = reduceSagi(stateRef.current, msg, Date.now());
|
||||
update(() => next);
|
||||
if (pulse) notify(pulse, subject);
|
||||
});
|
||||
const unsubConn = eventBus.onConnection((connected) => {
|
||||
setState((prev) => ({ ...prev, connected }));
|
||||
update((prev) => (prev.connected === connected ? prev : { ...prev, connected }));
|
||||
});
|
||||
return () => {
|
||||
unsubMsg();
|
||||
unsubConn();
|
||||
};
|
||||
}, [showBubble]);
|
||||
}, [update, notify]);
|
||||
|
||||
// Advance the clock so timed moods (stuck/sleeping, and exit from
|
||||
// happy/worried) re-evaluate without needing a new event.
|
||||
@@ -175,14 +306,42 @@ export function useSagiBrain(): SagiBrain {
|
||||
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 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(() => {
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubbleState(null);
|
||||
burst.current = null;
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
@@ -192,29 +351,47 @@ export function useSagiBrain(): SagiBrain {
|
||||
if (next) 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(
|
||||
(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
|
||||
// imperative messages (e.g. spawn errors) that must appear now and fade,
|
||||
// bypassing the throttled mood-driven showBubble path.
|
||||
const setBubble = useCallback((text: string) => {
|
||||
if (!text) return;
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubbleState(text);
|
||||
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
|
||||
}, []);
|
||||
const setBubble = useCallback(
|
||||
(text: string) => {
|
||||
if (!text) return;
|
||||
burst.current = null;
|
||||
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 {
|
||||
mood,
|
||||
status,
|
||||
attention,
|
||||
bubble,
|
||||
dismissBubble,
|
||||
setBubble,
|
||||
moment,
|
||||
playMoment,
|
||||
pat,
|
||||
reaction,
|
||||
muted,
|
||||
toggleMute,
|
||||
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
|
||||
* 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. */
|
||||
function PaneMenuCard({
|
||||
export function PaneMenuCard({
|
||||
menu,
|
||||
onChoose,
|
||||
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.",
|
||||
"uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood.",
|
||||
"panel": {
|
||||
"label": "Sagi companion",
|
||||
"label": "{{name}} companion",
|
||||
"live": "Live",
|
||||
"offline": "Offline",
|
||||
"close": "Close Sagi",
|
||||
"close": "Close {{name}}",
|
||||
"statLive": "live",
|
||||
"statWaiting": "waiting",
|
||||
"statErrored": "errored",
|
||||
"runClaude": "Run Claude",
|
||||
"activity": "Activity",
|
||||
"sessions": "Sessions",
|
||||
"errored": "Errored",
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"clearAlerts": "Clear alerts",
|
||||
"askPlaceholder": "Ask Sagi… (e.g. any errors?)",
|
||||
"ask": "Ask Sagi",
|
||||
"send": "Send"
|
||||
"askPlaceholder": "Ask {{name}}… (e.g. any errors?)",
|
||||
"ask": "Ask {{name}}",
|
||||
"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",
|
||||
"open": "Open Sagi companion",
|
||||
"closeAvatar": "Close Sagi",
|
||||
"avatarTitle": "Sagi — Ctrl/⌘+B · drag or arrow keys to move · right-click for pets",
|
||||
"startFailed": "Couldn't start that session: {{error}}"
|
||||
"open": "Open {{name}} companion",
|
||||
"closeAvatar": "Close {{name}}",
|
||||
"avatarTitle": "{{name}} — Ctrl/⌘+B · drag or arrow keys to move · stroke to pat · right-click for pets",
|
||||
"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": {
|
||||
"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.",
|
||||
"uploadHint": "Ảnh (PNG, JPEG, GIF, WebP — không nhận SVG) hiển thị tĩnh; file .codex-pet.zip (pet.json + spritesheet.webp 1536×1872) chuyển động theo tâm trạng.",
|
||||
"panel": {
|
||||
"label": "Trợ lý Sagi",
|
||||
"label": "Trợ lý {{name}}",
|
||||
"live": "Trực tuyến",
|
||||
"offline": "Ngoại tuyến",
|
||||
"close": "Đóng Sagi",
|
||||
"close": "Đóng {{name}}",
|
||||
"statLive": "đang chạy",
|
||||
"statWaiting": "đang chờ",
|
||||
"statErrored": "lỗi",
|
||||
"runClaude": "Chạy Claude",
|
||||
"activity": "Hoạt động",
|
||||
"sessions": "Phiên",
|
||||
"errored": "Phiên lỗi",
|
||||
"mute": "Tắt tiếng",
|
||||
"unmute": "Bật tiếng",
|
||||
"clearAlerts": "Xoá cảnh báo",
|
||||
"askPlaceholder": "Hỏi Sagi… (vd: có lỗi nào không?)",
|
||||
"ask": "Hỏi Sagi",
|
||||
"send": "Gửi"
|
||||
"askPlaceholder": "Hỏi {{name}}… (vd: có lỗi nào không?)",
|
||||
"ask": "Hỏi {{name}}",
|
||||
"send": "Gửi",
|
||||
"rename": "Bấm để đổi tên {{name}}",
|
||||
"renameLabel": "Tên pet",
|
||||
"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",
|
||||
"open": "Mở trợ lý Sagi",
|
||||
"closeAvatar": "Đóng Sagi",
|
||||
"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",
|
||||
"startFailed": "Không khởi động được phiên: {{error}}"
|
||||
"open": "Mở trợ lý {{name}}",
|
||||
"closeAvatar": "Đóng {{name}}",
|
||||
"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}}",
|
||||
"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": {
|
||||
"vendor": {
|
||||
|
||||
@@ -29,6 +29,7 @@ export const PREF_KEYS = [
|
||||
"agenthub.terminal.font",
|
||||
"agenthub.pet",
|
||||
"agenthub.pet.size",
|
||||
"agenthub.pet.names",
|
||||
"agent-dashboard-sagi-enabled",
|
||||
"agent-dashboard-sagi-muted",
|
||||
"agent-monitor-notifications",
|
||||
|
||||
Reference in New Issue
Block a user