/** * @file PetChat.tsx * @description The pet's chat window — its whole UI: clicking the pet opens * it. Above the conversation sits what the pet knows (`info`: live / waiting / * errored, the sessions that need you, Mute, Clear alerts — `SagiInfo.tsx`). * Before a chat runs, a question the pet can answer itself (`onLocal`: status, * who are you…) is answered right here; anything else — and every message once * a chat runs — goes to the AI: the first message starts a * session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or * Codex — the two with a readable transcript) in the "Sagi" workspace, in its * first free tab (`startInFreeTabDetailed`), and the conversation is shown as * 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, so closing and * reopening the window continues it; **New chat** STOPS that session (kills * its pane and archives it, asking first only mid-reply), **Open in workspace** * opens its pane there. Opening never resumes an ended session: once the pane * is gone, the next message starts a new one. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react"; import { api } from "../../lib/api"; import type { TranscriptMessage } from "../../lib/types"; import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults"; import { answerRunMenu, chooseRunOption, interruptRun, readRunMenu, submitToRun, type PaneMenu, } from "../../lib/panePaste"; import { isSubmitEnter } from "../../lib/keys"; import { confirmDialog } from "../../lib/confirm"; import { toast } from "../../lib/toast"; import { startInFreeTabDetailed } from "../lanes/startSession"; import { TerminalView } from "../run/TerminalView"; import { PaneMenuCard } from "../run/PaneChat"; import { MarkdownContent } from "../conversation/MarkdownContent"; /** Tools whose session the chat can read back (a transcript AgentHub parses). */ export const PET_CHAT_TOOLS = new Set(["claude", "codex"]); const CHAT_KEY = "agenthub.pet.chat"; const POLL_MS = 1500; const MENU_POLL_MS = 700; // A follow-up waits this long for the off-screen pane to attach. const SEND_TRIES = 20; const SEND_RETRY_MS = 250; /** The session the chat is talking to. */ export interface PetChatRef { laneId: number; runId: string; tool: string; } export function readPetChat(): PetChatRef | null { try { const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial | null; if ( v && typeof v.laneId === "number" && typeof v.runId === "string" && typeof v.tool === "string" ) return { laneId: v.laneId, runId: v.runId, tool: v.tool }; } catch { /* unreadable: no chat */ } return null; } function writePetChat(ref: PetChatRef | null) { try { if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref)); else localStorage.removeItem(CHAT_KEY); } catch { /* best-effort */ } } /** The tool a new chat starts: the user's default, when the chat can show it. */ export function petChatTool(): string | null { const tool = readDefaultTool() ?? "claude"; return PET_CHAT_TOOLS.has(tool) ? tool : null; } type Line = | { key: string; role: "user" | "assistant"; text: string } | { key: string; role: "tools"; names: string[] }; /** A human line's text: Claude Code's own injected lines (``, * ``…) are not something the user said. */ const humanText = (m: TranscriptMessage) => m.content .filter((c) => c.type === "text" && c.text) .map((c) => c.text!.trim()) .filter((t) => t && !t.startsWith("<")) .join("\n\n"); /** The transcript as chat lines: the user's words, the assistant's prose, and * each run of tool calls between them folded into one chip. */ export function chatLines(messages: TranscriptMessage[]): Line[] { const out: Line[] = []; messages.forEach((m, i) => { const sender = m.sender ?? m.type; if (sender === "user") { const text = humanText(m); if (text) out.push({ key: `u${i}`, role: "user", text }); return; } if (m.type !== "assistant") return; m.content.forEach((c, j) => { if (c.type === "text" && c.text?.trim()) { out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() }); } else if (c.type === "tool_use" && c.name) { const last = out[out.length - 1]; if (last?.role === "tools") last.names.push(c.name); else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] }); } }); }); return out; } /** Still working: the last line is the user's, or an assistant turn not ended. */ function isBusy(messages: TranscriptMessage[]): boolean { for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]!; const sender = m.sender ?? m.type; if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn"; if (sender === "user" && humanText(m)) return true; } return false; } /** "Read ×2 · Bash" */ function toolSummary(names: string[]): string { const counts = new Map(); names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1)); return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · "); } /** What the container made of a message: an answer the pet gives itself, or * `handled` (it took the message elsewhere); null sends it to the AI. */ export type PetLocalReply = { answer: string } | { handled: true } | null; interface PetChatProps { /** The pet's shown name, for the header and greeting. */ name: string; /** Shown in place of the plain name (the rename button). */ title?: ReactNode; /** Extra header buttons, before Open in workspace (the pat heart). */ headerActions?: ReactNode; /** Under the header: what the pet knows (status, sessions that need you). */ info?: ReactNode; /** Asked first while no chat runs. */ onLocal?: (text: string) => PetLocalReply; onClose: () => void; /** Opens the chat's pane in its workspace. */ onOpenWorkspace: (laneId: number, runId: string) => void; /** The thinking mood while a reply is being written. */ onBusy?: (busy: boolean) => void; } export function PetChat({ name, title, headerActions, info, onLocal, onClose, onOpenWorkspace, onBusy, }: PetChatProps) { const { t } = useTranslation("settings"); const [chat, setChat] = useState(readPetChat); const [sessionId, setSessionId] = useState(null); const [messages, setMessages] = useState([]); // Sent but not in the transcript yet. const [pending, setPending] = useState(null); const [starting, setStarting] = useState(false); const [error, setError] = useState(null); const [draft, setDraft] = useState(""); const [menu, setMenu] = useState(null); // The pet's own answers, given before any chat ran. const [local, setLocal] = useState([]); const listRef = useRef(null); const remember = (ref: PetChatRef | null) => { writePetChat(ref); setChat(ref); setSessionId(null); setMessages([]); setMenu(null); }; // A remembered chat whose pane is gone is over: forget it, the next message starts anew. useEffect(() => { if (!chat) return; let live = true; const check = () => api.run .get(chat.runId) .then((h) => { if (!live) return; if (h.status !== "running") remember(null); else if (h.sessionId) setSessionId(h.sessionId); }) .catch(() => live && remember(null)); void check(); // Codex learns its session only after a while; Claude's is known at start. const timer = window.setInterval(() => { if (!sessionId) void check(); }, POLL_MS); return () => { live = false; window.clearInterval(timer); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [chat?.runId, sessionId]); // The conversation, polled while the window is open. useEffect(() => { if (!sessionId) return; let live = true; const load = () => api.sessions .transcript(sessionId, { limit: 60 }) .then((r) => { if (!live) return; setMessages((prev) => prev.length === r.messages.length && JSON.stringify(prev[prev.length - 1]) === JSON.stringify(r.messages[r.messages.length - 1]) ? prev : r.messages ); }) .catch(() => undefined); // not started yet: the next poll asks again void load(); const timer = window.setInterval(load, POLL_MS); return () => { live = false; window.clearInterval(timer); }; }, [sessionId]); // A menu the CLI shows in the pane (permission, trust, a question). useEffect(() => { if (!chat) return; const read = () => setMenu((prev) => { const next = readRunMenu(chat.runId); return JSON.stringify(next) === JSON.stringify(prev) ? prev : next; }); const timer = window.setInterval(read, MENU_POLL_MS); return () => window.clearInterval(timer); }, [chat]); const lines = [...local, ...chatLines(messages)]; // The pending line shows until the transcript has it. useEffect(() => { if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim())) 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); if (!chat && onLocal) { const reply = onLocal(prompt); if (reply && "handled" in reply) return; if (reply) { const n = Date.now(); setLocal((prev) => [ ...prev, { key: `lu${n}`, role: "user", text: prompt }, { key: `la${n}`, role: "assistant", text: reply.answer }, ]); return; } } setPending(prompt); if (chat) { // Typed into the attached pane; it may still be attaching. 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, onLocal] ); // New chat: the old one's session is stopped, not left running unseen — // asking first only while it is still writing a reply. const newChat = async () => { if (chat) { if ( busy && !(await confirmDialog({ title: t("sagi.chat.endTitle"), message: t("sagi.chat.endBody"), confirmLabel: t("sagi.chat.endConfirm"), destructive: true, })) ) return; try { await api.run.kill(chat.runId, { archive: true }); } catch (err) { toast.error(err); return; } } remember(null); setLocal([]); setPending(null); setError(null); }; const submit = (e?: FormEvent) => { e?.preventDefault(); if (!draft.trim()) return; const text = draft; setDraft(""); void send(text); }; const toolLabel = chat?.tool ?? petChatTool() ?? "claude"; const empty = lines.length === 0 && !pending; return (
{/* header */}
🐾
{title ??
{name}
}
{busy ? t("sagi.chat.typing") : toolLabel}
{headerActions} {chat && ( onOpenWorkspace(chat.laneId, chat.runId)} > )} void newChat()} disabled={(!chat && !local.length) || starting} >
{info} {/* conversation */}
{empty && (
🐾

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

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

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

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

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

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

)}
{error && (

{error}

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