"use client"; import * as React from "react"; import { CheckIcon, ChevronDown, ChevronRight, CrosshairIcon, Flag, MessageSquare, PaperclipIcon, ShieldAlertIcon, Terminal, UserIcon, Wrench, XIcon, } from "lucide-react"; import { toast } from "sonner"; import { api } from "@/lib/api"; import { Markdown } from "@/components/markdown"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import type { Activity } from "@/lib/types"; // ---- per-agent lane color (planner + work#1/#2/#3 …) --------------------------- const workerColors = [ "bg-sky-600", "bg-violet-600", "bg-teal-600", "bg-pink-600", "bg-orange-600", ]; function workerColor(name: string): string { if (name === "planner") return "bg-amber-600"; // the intent generator, distinct if (name === "mainagent") return "bg-primary"; const m = /#(\d+)/.exec(name); const i = m ? (parseInt(m[1], 10) - 1) % workerColors.length : 0; return workerColors[Math.max(0, i)]; } const chip = (worker: string) => "mt-0.5 shrink-0 rounded px-1 text-[9px] font-medium text-white " + workerColor(worker); // useInView latches true when the ref'd element first comes within `rootMargin` of // the enclosing scroll viewport. Blocks that always show their full body (user // bubbles, answers) use it to defer fetching that body until they're about to be // seen — so opening a long thread doesn't fire a detail request for every off- // screen step. Observes the ScrollArea viewport (falls back to eager load when // IntersectionObserver is unavailable, e.g. SSR). function useInView(rootMargin = "400px"): [React.RefObject, boolean] { const ref = React.useRef(null); const [inView, setInView] = React.useState(false); React.useEffect(() => { if (inView) return; // latch: once seen, stop observing const el = ref.current; if (!el) return; if (typeof IntersectionObserver === "undefined") { setInView(true); return; } const root = el.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null; const ob = new IntersectionObserver( (entries) => { if (entries.some((e) => e.isIntersecting)) setInView(true); }, { root, rootMargin }, ); ob.observe(el); return () => ob.disconnect(); }, [inView, rootMargin]); return [ref, inView]; } // A tool group pairs a tool_use with its matching tool_result (by tool_use_id); // a run of consecutive conversational steps (text/thinking/result) from the same // agent is one "message". type Group = | { type: "user"; key: number; step: Activity; intent?: boolean } | { type: "answer"; key: number; step: Activity } | { type: "round"; key: number; label: string } | { type: "tool"; key: number; worker: string; use?: Activity; result?: Activity } | { type: "msg"; key: number; worker: string; steps: Activity[] } | { type: "intercept"; key: number; step: Activity }; function groupSteps(steps: Activity[], chat: boolean): Group[] { const out: Group[] = []; const byToolId = new Map>(); for (const s of steps) { if (s.kind === "usage") continue; // live token-usage marker — not a rendered step if (s.kind === "round") { out.push({ type: "round", key: s.seq, label: s.summary || "新一轮" }); // planner round boundary continue; } if (s.kind === "intercept_request") { out.push({ type: "intercept", key: s.seq, step: s }); continue; } if (s.kind === "user" || s.kind === "intent") { // human turn OR the LLM-generated intent leading a worker session — both are // right-aligned bubbles; `intent` swaps the avatar to a non-human icon. out.push({ type: "user", key: s.seq, step: s, intent: s.kind === "intent" }); continue; } // In a chat (main agent) the assistant's text/result IS the answer — render it // full (markdown), never collapsed. thinking still folds into a compact block. if (s.kind === "result" || (chat && s.kind === "text")) { out.push({ type: "answer", key: s.seq, step: s }); continue; } if (s.kind === "tool_use") { const g: Extract = { type: "tool", key: s.seq, worker: s.worker, use: s }; if (s.tool_use_id) byToolId.set(s.tool_use_id, g); out.push(g); continue; } if (s.kind === "tool_result") { // bind to its tool_use by id (NOT adjacency — tools can run in parallel) const g = s.tool_use_id ? byToolId.get(s.tool_use_id) : undefined; if (g && !g.result) g.result = s; else out.push({ type: "tool", key: s.seq, worker: s.worker, result: s }); // orphan result continue; } const last = out[out.length - 1]; if (last && last.type === "msg" && last.worker === s.worker) last.steps.push(s); else out.push({ type: "msg", key: s.seq, worker: s.worker, steps: [s] }); } return out; } const kindLabel = (k: string) => (k === "thinking" ? "推理" : k === "result" ? "总结" : "说明"); function ActivityTime({ ts }: { ts: string }) { const date = new Date(ts); if (!ts || Number.isNaN(date.getTime())) return null; return ( ); } // toolInputText renders a tool_use input for display. For Bash it pulls the shell // command out of the raw input JSON ({"command":…,"description":…}) so the UI shows // the command itself instead of JSON; other tools fall back to the raw text. function toolInputText(tool: string, raw: string): string { if (tool !== "Bash") return raw; // full input (expanded detail): parse and pull the command out. try { const o = JSON.parse(raw); if (o && typeof o.command === "string") return o.command; } catch { // collapsed-row summaries are truncated to ~200 chars by the backend, so the // JSON tail is cut off and JSON.parse fails — fall through and extract the // "command" field by hand, tolerating the missing closing quote. } const m = raw.match(/"command"\s*:\s*"((?:\\.|[^"\\])*)/); if (m) { try { // re-wrap the captured body and parse to unescape \n, \", \\, etc. return JSON.parse('"' + m[1] + '"'); } catch { // truncated mid-escape — unescape the common sequences best-effort. return m[1].replace(/\\(["\\/nrt])/g, (_s, c) => c === "n" ? "\n" : c === "r" ? "\r" : c === "t" ? "\t" : c, ); } } return raw; } // InterceptCard renders an inline intercept_request approval card. The pending_id // is extracted from the summary (format: "工具 X 请求审批 (#N)") so buttons are // available immediately without waiting for the detail load. function InterceptCard({ step, getDetail, }: { step: Activity; getDetail: (seq: number) => Promise; }) { // extract pending_id from summary: "工具 Bash 请求审批 (#42)" const pendingId = React.useMemo(() => { const m = /\(#(\d+)\)/.exec(step.summary); return m ? parseInt(m[1], 10) : null; }, [step.summary]); const toolName = React.useMemo(() => { const m = /工具\s+(\S+)\s+请求/.exec(step.summary); return m ? m[1] : step.summary; }, [step.summary]); const [detail, setDetail] = React.useState | null>(null); const [decided, setDecided] = React.useState<"allowed" | "denied" | "timeout" | null>(null); const [deciding, setDeciding] = React.useState(false); // Load persisted detail JSON + check the real current status from the backend // so that a page refresh shows the already-decided state instead of re-offering buttons. React.useEffect(() => { let live = true; getDetail(step.seq) .then((raw) => { if (!live || !raw) return; try { setDetail(JSON.parse(raw)); } catch { /* ignore */ } }) .catch(() => {/* ignore */}); return () => { live = false; }; }, [step.seq, getDetail]); React.useEffect(() => { if (!pendingId) return; let live = true; api.interceptGetOne(pendingId) .then((p) => { if (!live) return; if (p.status !== "pending") setDecided(p.status as "allowed" | "denied" | "timeout"); }) .catch(() => {/* ignore */}); return () => { live = false; }; }, [pendingId]); async function decide(decision: "allowed" | "denied") { if (step.inherited || !pendingId || deciding) return; setDeciding(true); try { await api.interceptDecide(pendingId, decision); setDecided(decision); toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行"); } catch (e) { toast.error((e as Error).message); } finally { setDeciding(false); } } const inputStr = detail?.input ? JSON.stringify(detail.input).slice(0, 200) : null; return (
审批请求 {toolName} {pendingId && ( #{pendingId} )}
{inputStr && (

{inputStr}

)}
{step.inherited ? ( 历史记录 · 只读 ) : decided ? ( {decided === "allowed" ? "已允许" : decided === "timeout" ? "已超时" : "已拒绝"} ) : (
)}
); } // ToolBlock renders ONE tool call: the command and its result bound into a single // row (collapsed shows the command + a status/result preview; expand shows the // full input AND output together). Lazy-loads both details on expand. function ToolBlock({ group, getDetail, showWorker, }: { group: Extract; getDetail: (seq: number) => Promise; showWorker?: boolean; }) { const [open, setOpen] = React.useState(false); const [detail, setDetail] = React.useState(null); // what we last loaded, keyed by the underlying step seqs. When the tool result // arrives after we expanded mid-run (command only), this key changes and the // effect below re-fetches — so the output shows up instead of being cached out. const loadedKey = React.useRef(null); const { use, result } = group; const toolName = use?.tool || result?.tool || "工具"; const ToolIcon = toolName === "Bash" ? Terminal : Wrench; const running = !result; const ok = !!result && !result.is_error; const statusTone = running ? "text-muted-foreground" : ok ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"; const rawCmd = use && use.summary.startsWith(toolName) ? use.summary.slice(toolName.length).trimStart() : use?.summary ?? ""; const cmd = toolInputText(toolName, rawCmd); // status only — the full result lives behind the expand (【输出】), not previewed inline const statusText = running ? "执行中…" : ok ? "✓" : "✕ 失败"; // key over the seqs we'd load; changes when the result (or command) arrives. const detailKey = `${use?.seq ?? ""}:${result?.seq ?? ""}`; React.useEffect(() => { if (!open || loadedKey.current === detailKey) return; let live = true; const segs: { label: string; seq: number }[] = []; if (use) segs.push({ label: "命令", seq: use.seq }); if (result) segs.push({ label: "输出" + (result.is_error ? " ✕" : " ✓"), seq: result.seq }); Promise.all( segs.map((x) => getDetail(x.seq) .then((d) => d || "(空)") .catch(() => "(加载失败)"), ), ).then((parts) => { if (!live) return; setDetail( segs .map((x, i) => `【${x.label}】\n${x.label === "命令" ? toolInputText(toolName, parts[i]) : parts[i]}`) .join("\n\n"), ); loadedKey.current = detailKey; }); return () => { live = false; }; }, [open, detailKey, use, result, getDetail]); function toggle() { setOpen((o) => !o); } return (
{open && (
          {detail ?? "加载中…"}
        
)}
); } // MessageBlock renders a coalesced agent message (merged streaming text/thinking/ // result fragments into one block) so the conversation reads as messages, not rows. function MessageBlock({ group, getDetail, showWorker, }: { group: Extract; getDetail: (seq: number) => Promise; showWorker?: boolean; }) { const [open, setOpen] = React.useState(false); const [detail, setDetail] = React.useState(null); // like ToolBlock: keyed by the group's step seqs so streamed steps arriving // after an early expand re-fetch instead of being cached out. const loadedKey = React.useRef(null); const speak = group.steps.filter((s) => s.kind !== "thinking"); const hasThinking = group.steps.some((s) => s.kind === "thinking"); const isError = group.steps.some((s) => s.is_error); const body = (speak.length ? speak : group.steps).map((s) => s.summary).join(" ") || "…"; const Icon = isError ? Flag : MessageSquare; const tone = isError ? "text-red-600 dark:text-red-400" : "text-foreground"; const detailKey = group.steps.map((s) => s.seq).join(","); React.useEffect(() => { if (!open || loadedKey.current === detailKey) return; let live = true; Promise.all( group.steps.map((s) => getDetail(s.seq) .then((d) => d || s.summary) .catch(() => s.summary), ), ).then((parts) => { if (!live) return; setDetail(group.steps.map((s, i) => `【${kindLabel(s.kind)}】\n${parts[i]}`).join("\n\n")); loadedKey.current = detailKey; }); return () => { live = false; }; }, [open, detailKey, group.steps, getDetail]); function toggle() { setOpen((o) => !o); } return (
{open && (
          {detail ?? "加载中…"}
        
)}
); } // UserRow renders a right-aligned chat bubble: either a human turn (the message you // sent the main agent) or, in a worker session, the LLM-generated intent that leads // it (intent=true) — same bubble, but a target icon instead of the human avatar. // summary is a truncated first line, so the full message is pulled from the detail // and shown in full (bubble is whitespace-pre-wrap, so long/multi-line text wraps). // fmtBytes renders a human file size for attachment chips. function fmtBytes(n: number): string { if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`; if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB`; return `${n} B`; } type MsgAttachment = { name: string; path: string; size: number }; // parseUserBody splits a user turn's body into text + attachments. The backend stores // Detail as JSON {text, attachments} when files were uploaded, else plain text — so we // parse defensively and fall back to treating the whole body as text. function parseUserBody(body: string): { text: string; attachments: MsgAttachment[] } { if (body.startsWith("{")) { try { const p = JSON.parse(body); if (p && Array.isArray(p.attachments)) { return { text: typeof p.text === "string" ? p.text : "", attachments: p.attachments }; } } catch { /* plain text that merely starts with "{" */ } } return { text: body, attachments: [] }; } function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean; getDetail: (seq: number) => Promise }) { const Icon = intent ? CrosshairIcon : UserIcon; const [ref, inView] = useInView(); // Optimistic echoes carry their detail inline; persisted rows lazy-load it on scroll. const inline = step.detail && step.detail.length > 0 ? step.detail : null; const [full, setFull] = React.useState(inline); React.useEffect(() => { if (inline) return; // already have the body (optimistic echo) if (!inView) return; // fetch the full message only when the bubble nears view let live = true; getDetail(step.seq) .then((d) => { if (live) setFull(d || step.summary); }) .catch(() => { if (live) setFull(step.summary); }); return () => { live = false; }; }, [inView, step.seq, getDetail, step.summary, inline]); const { text, attachments } = parseUserBody(full ?? step.summary); return (
{attachments.length > 0 && (
{attachments.map((a) => (
{a.name} {fmtBytes(a.size)}
))}
)} {text && (
{text}
)}
); } // AnswerBlock renders the agent's FINAL answer (kind="result") in full — never // collapsed. The summary is a truncated first line, so the full text is pulled // from the detail and shown inline. function AnswerBlock({ step, getDetail }: { step: Activity; getDetail: (seq: number) => Promise }) { const [ref, inView] = useInView(); const [full, setFull] = React.useState(null); React.useEffect(() => { if (!inView) return; // fetch the full answer only when it nears view let live = true; getDetail(step.seq) .then((d) => { if (live) setFull(d || step.summary); }) .catch(() => { if (live) setFull(step.summary); }); return () => { live = false; }; }, [inView, step.seq, getDetail, step.summary]); return (
{step.is_error ? ( {full ?? step.summary} ) : ( )}
); } // ExecView renders an agent execution replay (planner / worker / main agent) in // the compact, grouped, expand-to-detail format — thinking, tool calls/results, // and (for the main agent) the human turns. Worker lane chips show only when the // view actually mixes agents. function ExecView({ activity, taskId, chat, fetchDetail, }: { activity: Activity[]; taskId?: string; chat?: boolean; fetchDetail?: (seq: number) => Promise; }) { const showWorker = new Set(activity.map((a) => a.worker)).size > 1; // default detail fetcher: the task-scoped activity endpoint. The chat page passes // its own (conversation-scoped) fetcher instead. const getDetail = React.useCallback( (seq: number) => fetchDetail ? fetchDetail(seq) : api.activityDetail(seq, taskId).then((r) => r.detail ?? ""), [fetchDetail, taskId], ); return (
{groupSteps(activity, !!chat).map((g) => g.type === "round" ? (
{g.label}
) : g.type === "user" ? ( ) : g.type === "answer" ? ( ) : g.type === "tool" ? ( ) : g.type === "intercept" ? ( ) : ( ), )}
); } // Transcript renders a session's activity as a compact grouped execution replay: // human turns, thinking, tool calls (command+result paired), and messages — for // the main agent (interactive) and worker/planner (read-only) alike. export function Transcript({ activity, live, taskId, chat, fetchDetail, }: { activity: Activity[]; live?: boolean; taskId?: string; chat?: boolean; fetchDetail?: (seq: number) => Promise; }) { return (
{live && (
实时流式中…
)}
); }