"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) => (
))}
);
}
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 (
);
}