"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { sendAgentCommand } from "@/lib/agent-client"; import { useI18n } from "@/lib/i18n"; import { formatCost, formatDuration, formatTokens } from "@/lib/subagent-format"; import { MarkdownBody } from "./MarkdownBody"; import { Dialog, DialogContent, DialogTitle, DialogClose } from "./ui/primitives"; import type { SubagentInfo } from "@/hooks/useAgentSession"; import type { SubagentActivityEvent, SubagentSnapshotLike } from "@/lib/subagent-types"; import type { AgentMessage, ToolResultMessage } from "@/lib/types"; interface SubagentMessagesPage { sessionFile: string; fromByte: number; nextByte: number; reset?: boolean; messages: AgentMessage[]; totalBytes?: number; } /** Compact, defensive row for one raw transcript message (content may be a * string, a block array, or absent — legacy pi / omp RPC shapes). */ function SubagentTranscriptRow({ message }: { message: AgentMessage }) { const label = message.role === "user" ? "U" : message.role === "assistant" ? "A" : "R"; const labelColor = message.role === "user" ? "var(--accent)" : message.role === "assistant" ? "var(--text-muted)" : "var(--text-dim)"; const rawContent = (message as ToolResultMessage).content; const blocks: Array<{ type: string; text?: unknown }> = typeof rawContent === "string" ? [{ type: "text", text: rawContent }] : Array.isArray(rawContent) ? rawContent as Array<{ type: string; text?: unknown }> : []; const text = blocks .filter((block) => block.type === "text" && typeof block.text === "string") .map((block) => block.text as string) .join("\n") .slice(0, 400); const isError = (message as ToolResultMessage).isError === true; return (
{label}
{message.role === "assistant" && typeof rawContent !== "string" && Array.isArray(rawContent) ? rawContent.map((block, i) => (
{block && typeof block === "object" && (block as { type?: unknown }).type === "toolCall" ? `→ ${(block as { toolName?: unknown }).toolName ?? "tool"} ${JSON.stringify((block as { input?: unknown }).input ?? {})}` : block && typeof block === "object" && (block as { type?: unknown }).type === "text" ? ((block as { text?: unknown }).text as string) ?? "" : ""}
)) : text || (message.role === "user" || message.role === "assistant" ? "" : isError ? "(error)" : "(no output)")}
); } const BLOCK_LABEL_STYLE: React.CSSProperties = { fontFamily: "var(--font-mono)", fontSize: 10, fontWeight: 700, letterSpacing: 0.4, textTransform: "uppercase", color: "var(--text-dim)", }; /** Recursive renderer for structured completions: string values keep their * line breaks (JSON.parse already unescapes them), arrays become bullet * lists, nested objects become aligned key/value rows. */ function JsonValue({ value }: { value: unknown }) { if (typeof value === "string") { return (
{value}
); } if (Array.isArray(value)) { return (
{value.map((item, i) => (
•
))}
); } if (value !== null && typeof value === "object") { const record = value as Record; return (
{Object.keys(record).map((key) => (
{key}
))}
); } return {String(value)}; } /** The subagent's assignment, rendered as markdown. Exported for SSR tests. */ export function TaskBlock({ task }: { task: string }) { const { t } = useI18n(); if (!task) return null; return (
{t("subagentTranscript.taskLabel")}
{task}
); } /** The subagent's final output (`.md`). Exported for SSR tests. */ export function CompletionBlock({ completion, truncated }: { completion: string | null; truncated: boolean }) { const { t } = useI18n(); let parsed: Record | null = null; if (completion) { try { const candidate = JSON.parse(completion) as unknown; if (candidate !== null && typeof candidate === "object" && !Array.isArray(candidate)) { parsed = candidate as Record; } } catch { parsed = null; } } const keys = parsed ? Object.keys(parsed) : []; const singleText = parsed && keys.length === 1 && typeof parsed[keys[0]] === "string" ? parsed[keys[0]] as string : null; return (
{t("subagentTranscript.resultLabel")} {truncated && {t("subagentTranscript.completionTruncated")}}
{singleText ? (
{singleText}
) : parsed ? (
) : completion ? (
{completion}
) : (
{t("subagentTranscript.noCompletion")}
)}
); } export function SubagentTranscriptDialog({ subagent, sessionId, transcriptVersion, events, onClose }: { subagent: SubagentInfo | null; sessionId: string | null; transcriptVersion: number; events?: SubagentActivityEvent[]; onClose: () => void; }) { const { t } = useI18n(); const [detail, setDetail] = useState(null); const [completion, setCompletion] = useState(null); const [completionTruncated, setCompletionTruncated] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [transcriptOpen, setTranscriptOpen] = useState(false); const [transcriptMessages, setTranscriptMessages] = useState([]); const [transcriptNextByte, setTranscriptNextByte] = useState(0); const [transcriptLoading, setTranscriptLoading] = useState(false); const [transcriptError, setTranscriptError] = useState(null); const [transcriptExhausted, setTranscriptExhausted] = useState(false); const requestSeqRef = useRef(0); const transcriptRequestSeqRef = useRef(0); const refetchedVersionRef = useRef(0); const refetchedTranscriptVersionRef = useRef(0); const latestVersionRef = useRef(0); const versionDebounceTimerRef = useRef | null>(null); const open = subagent !== null; const fromDisk = subagent?.source === "history"; const live = !fromDisk; const fetchCompletion = useCallback(async (): Promise<{ completion: string | null; truncated: boolean }> => { if (!sessionId || !subagent?.id) throw new Error("No session"); const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagent.id)}?mode=completion`); if (res.status === 404) return { completion: null, truncated: false }; if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json() as { completion: string | null; truncated: boolean }; }, [sessionId, subagent?.id]); // Full transcript page (RPC registry first, disk fallback) — mirrors the // get_subagent_messages response shape so both sources are interchangeable. const fetchTranscriptPage = useCallback(async (startByte: number, preferDisk: boolean): Promise => { if (!sessionId || !subagent?.id) throw new Error("No session"); if (preferDisk) { const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagent.id)}?fromByte=${startByte}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json() as SubagentMessagesPage; } return await sendAgentCommand(sessionId, { type: "get_subagent_messages", subagentId: subagent.id, sessionFile: subagent.sessionFile, fromByte: startByte, }); }, [sessionId, subagent?.id, subagent?.sessionFile]); const loadTranscriptPage = useCallback(async (startByte: number) => { if (!sessionId || !subagent?.id) return; // Own sequence ref: the completion fetch and the transcript pager must not // invalidate each other (a shared ref would wedge `loading` forever). const seq = ++transcriptRequestSeqRef.current; setTranscriptLoading(true); setTranscriptError(null); try { let page: SubagentMessagesPage; try { page = await fetchTranscriptPage(startByte, fromDisk); } catch (rpcError) { // The RPC registry only knows subagents of the current process; a // live entry whose session restarted falls back to the disk reader. if (fromDisk || !subagent.id) throw rpcError; page = await fetchTranscriptPage(startByte, true); } if (seq !== transcriptRequestSeqRef.current) return; if (page.reset) { setTranscriptMessages(page.messages); } else { setTranscriptMessages((prev) => [...prev, ...page.messages]); } setTranscriptNextByte(page.nextByte); const complete = typeof page.totalBytes === "number" ? page.nextByte >= page.totalBytes : page.messages.length === 0; setTranscriptExhausted(complete || page.nextByte <= page.fromByte); } catch (e) { if (seq !== transcriptRequestSeqRef.current) return; setTranscriptError(e instanceof Error ? e.message : String(e)); } finally { if (seq === transcriptRequestSeqRef.current) setTranscriptLoading(false); } }, [sessionId, subagent?.id, fromDisk, fetchTranscriptPage]); const load = useCallback(async () => { if (!sessionId || !subagent?.id) return; const seq = ++requestSeqRef.current; setLoading(true); setError(null); try { const found = await fetchCompletion(); // Live snapshots enrich the header (resolved model etc.) but carry no // settled output — the on-disk `.md` is the completion source. if (found.completion === null && live) { const result = await sendAgentCommand<{ subagents?: SubagentSnapshotLike[] }>(sessionId, { type: "get_subagents" }); const snap = (result.subagents ?? []).find((s) => s.id === subagent?.id); if (seq !== requestSeqRef.current) return; if (snap) setDetail(snap); } if (seq !== requestSeqRef.current) return; setCompletion(found.completion); setCompletionTruncated(found.truncated); } catch (e) { if (seq !== requestSeqRef.current) return; // A failed refetch would otherwise leave the version consumed and the // completion stale until close/reopen — re-arm so the next version bump // (or this one, via latestVersionRef) retries. refetchedVersionRef.current = 0; setError(e instanceof Error ? e.message : String(e)); } finally { if (seq === requestSeqRef.current) setLoading(false); } }, [sessionId, subagent?.id, live, fetchCompletion]); // Load the completion whenever the dialog opens for a subagent. useEffect(() => { if (!open || !sessionId) return; requestSeqRef.current += 1; transcriptRequestSeqRef.current += 1; setCompletion(null); setCompletionTruncated(false); setError(null); setDetail(null); setTranscriptOpen(false); setTranscriptMessages([]); setTranscriptNextByte(0); setTranscriptExhausted(false); setTranscriptError(null); // The bumped seq invalidates an in-flight request whose finally will skip // clearing this — reset it here or the next open shows Loading forever. setTranscriptLoading(false); // transcriptVersion is a PER-SUBAGENT counter: a coincidental value from // the previous subagent would make the done-guards below skip the next // subagent's debounced refetch, so zero the bookkeeping on every open/switch. refetchedVersionRef.current = 0; refetchedTranscriptVersionRef.current = 0; latestVersionRef.current = 0; void load(); }, [open, sessionId, load]); // Live child events mean the final output may have just landed — refetch the // completion and (when the transcript is open) append its next page. Bumps // are DEBOUNCED so streaming batches coalesce into one fetch, and the latest // version is never dropped: a bump that arrives mid-throttle re-arms the // timer, and the already-processed guard prevents same-version loops. useEffect(() => { if (!open || !sessionId || transcriptVersion === 0 || loading) return; const completionDone = transcriptVersion === refetchedVersionRef.current && transcriptVersion === latestVersionRef.current; const transcriptDone = transcriptVersion === refetchedTranscriptVersionRef.current; if (completionDone && transcriptDone) return; latestVersionRef.current = transcriptVersion; if (versionDebounceTimerRef.current) clearTimeout(versionDebounceTimerRef.current); versionDebounceTimerRef.current = setTimeout(() => { versionDebounceTimerRef.current = null; // Completion fetch: consume the version only when actually fired. if (refetchedVersionRef.current !== latestVersionRef.current) { refetchedVersionRef.current = latestVersionRef.current; void load(); } // Open transcript append: if an older page is still in flight, DO NOT // consume the version — when transcriptLoading flips false this effect // re-runs and pages the latest (a consumed version would leave the // open transcript stale forever with no Load-more button). if (transcriptOpen && !transcriptLoading && refetchedTranscriptVersionRef.current !== latestVersionRef.current) { refetchedTranscriptVersionRef.current = latestVersionRef.current; void loadTranscriptPage(transcriptNextByte); } }, 600); return () => { if (versionDebounceTimerRef.current) clearTimeout(versionDebounceTimerRef.current); }; }, [open, sessionId, transcriptVersion, loading, transcriptOpen, transcriptLoading, load, loadTranscriptPage, transcriptNextByte]); const agent = detail?.agent ?? subagent?.agent ?? ""; const description = detail?.description ?? subagent?.description ?? ""; const task = detail?.task ?? subagent?.task ?? subagent?.assignment ?? ""; const progress = subagent?.progress; const historyTokens = formatTokens(progress?.tokens); const historyMeta = subagent?.source === "history" ? [ historyTokens ? t("chatWindow.tokensUnit", { count: historyTokens }) : null, formatCost(progress?.cost), formatDuration(progress?.durationMs), progress?.resolvedModel ? progress.resolvedModel.replace(/:.*$/, "") : null, ].filter(Boolean).join(" · ") : null; const outcomeError = subagent?.source === "history" ? subagent?.result?.abortReason ?? subagent?.result?.error : undefined; const recentEvents = live && !completion && events && events.length > 0 ? events.slice(-4) : null; return ( { if (!next) onClose(); }}> {subagent && ( <>
{agent} {description && (
{description}
)}
{detail?.sessionFile ?? subagent.sessionFile ?? subagent.id}
{historyMeta && (
{historyMeta}
)} {outcomeError && (
{outcomeError}
)}
×
{recentEvents && (
{recentEvents.map((event, i) => (
{event.kind === "tool" ? "·" : event.kind === "notice" ? "!" : "»"} {event.label}
))}
)} {error ? (
{error}
) : (
{loading &&
{t("subagentTranscript.loading")}
} {transcriptOpen && (
{transcriptError && transcriptMessages.length > 0 && (
{transcriptError}
)} {transcriptMessages.length === 0 && !transcriptLoading ? (
{transcriptError ?? t("subagentTranscript.noMessages")}
) : ( transcriptMessages.map((message, i) => ) )} {transcriptLoading &&
{t("subagentTranscript.loading")}
} {!transcriptExhausted && !transcriptLoading && ( )}
)}
)}
)}
); }