import type { AgentSnapshot, SessionEntry, SubagentProgressPayload } from "@oh-my-pi/pi-wire"; import { OctagonX, RotateCcw, SendHorizontal, X } from "lucide-react"; import type { ReactNode } from "react"; import { useEffect, useState } from "react"; import type { GuestClient } from "../../lib/client"; import { fmtCost, fmtDuration, fmtTokens } from "../../lib/format"; import { decideTranscriptPoll } from "../../lib/transcript-poll"; import type { TranscriptProps } from "../transcript/Transcript"; import { Transcript } from "../transcript/Transcript"; const EMPTY_TOOLS: TranscriptProps["activeTools"] = new Map(); const POLL_MS = 1200; export function AgentDrawer(props: { agent: AgentSnapshot; progress?: SubagentProgressPayload; client: GuestClient; /** View-link guests: hide kill/revive/chat (the host rejects them anyway). */ readOnly?: boolean; /** Forwarded to tool renderers so nested task cards can drill further. */ host?: TranscriptProps["host"]; onClose(): void; }): ReactNode { const { agent, progress, client, readOnly, host, onClose } = props; const [entries, setEntries] = useState([]); const [fetchError, setFetchError] = useState(null); const [draft, setDraft] = useState(""); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); // Live transcript: poll the host-side session file while the drawer is // open, appending parsed JSONL entries. State resets when the agent // changes; the interval and any in-flight reply are dropped on cleanup. // A frame-level host error is terminal: stop polling and show it (the // host replies with an unchanged cursor, so retrying would loop hot). useEffect(() => { setEntries([]); setFetchError(null); if (!agent.hasSessionFile) return; let disposed = false; let inFlight = false; let cursor = 0; let carry = ""; let acc: readonly SessionEntry[] = []; let timer: Timer | null = null; const stopPolling = () => { if (timer !== null) { clearInterval(timer); timer = null; } }; const poll = async (): Promise => { if (disposed || inFlight) return; inFlight = true; try { const reply = await client.fetchTranscript(agent.id, cursor); if (disposed) return; const decision = decideTranscriptPoll(reply, carry); switch (decision.action) { case "retry": return; // timeout/transient → keep polling from the same cursor case "stop": stopPolling(); setFetchError(decision.message); return; case "advance": cursor = decision.newSize; carry = decision.carry; if (decision.fresh.length > 0) { acc = [...acc, ...decision.fresh]; setEntries(acc); } return; } } finally { inFlight = false; } }; void poll(); timer = setInterval(() => { void poll(); }, POLL_MS); return () => { disposed = true; stopPolling(); }; }, [agent.id, agent.hasSessionFile, client]); const sendChat = () => { const text = draft.trim(); if (!text) return; client.sendAgentCmd("chat", agent.id, text); setDraft(""); }; const p = progress?.progress; const model = p?.resolvedModel; const ctxPct = p?.contextTokens !== undefined && p.contextWindow ? Math.min(100, (p.contextTokens / p.contextWindow) * 100) : null; return ( ); }