"use client"; /* Traces: conversations (chat sessions) grouped by end user, their user<->AI turns, and a drill-in to the per-turn span waterfall (tool + LLM request/response). */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../icons"; import { Avatar, StatusPill } from "../primitives"; import { api, Conversation, ConversationDetail, Facets, openSSE, Span, Turn } from "@/lib/api"; import { fmtUSD, buildNodeLabels, NODE_META, type NodeLabel } from "@/lib/data"; // Conversations are paged in on scroll (newest-activity first) so the Traces view never // pulls a project's entire history in one shot. const PAGE = 20; // Friendly labels for the raw run source. const SOURCE_LABEL: Record = { playground: "Playground", api: "API", embed: "Embed", assistant: "Forge Assistant", channel_email: "Email", webhook: "Webhook", schedule: "Schedule", app_event: "App event", }; const srcLabel = (s: string) => SOURCE_LABEL[s] || s || "—"; const fmtWhen = (iso?: string | null) => (iso ? iso.slice(0, 16).replace("T", " ") : ""); // Trigger a client-side file download of `data` as pretty JSON (used by the Traces export). function downloadJSON(filename: string, data: unknown) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } export function TracesScreen({ project }: { project: any }) { const [convos, setConvos] = useState([]); const [facets, setFacets] = useState({ actors: [], sources: [] }); const [actor, setActor] = useState(""); const [source, setSource] = useState(""); const [status, setStatus] = useState(""); const [searchInput, setSearchInput] = useState(""); const [search, setSearch] = useState(""); const [sel, setSel] = useState(null); const [detail, setDetail] = useState(null); const [nextOffset, setNextOffset] = useState(0); const [hasMore, setHasMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const listRef = useRef(null); // Distinct actors for the filter dropdown. A separate call because the paged list below // only holds one 20-row window - the full actor set can't be derived from it. useEffect(() => { if (project?.id) api.conversationFacets(project.id).then(setFacets).catch(() => {}); }, [project?.id]); // Debounce the search box so we don't fire a request per keystroke. useEffect(() => { const t = setTimeout(() => setSearch(searchInput.trim()), 350); return () => clearTimeout(t); }, [searchInput]); // First page - and a reload whenever the project or a filter changes. useEffect(() => { if (!project?.id) { setConvos([]); setHasMore(false); setNextOffset(0); return; } let live = true; api.listConversations(project.id, { actor: actor || undefined, source: source || undefined, status: status || undefined, search: search || undefined, limit: PAGE, offset: 0 }) .then((c) => { if (!live) return; setConvos(c); setNextOffset(c.length); setHasMore(c.length === PAGE); setSel((cur) => (cur && c.some((x) => x.thread_id === cur) ? cur : c[0]?.thread_id ?? null)); if (listRef.current) listRef.current.scrollTop = 0; }) .catch(() => { if (live) { setConvos([]); setHasMore(false); setNextOffset(0); } }); return () => { live = false; }; }, [project?.id, actor, source, status, search]); const loadMore = useCallback(async () => { if (!project?.id || loadingMore || !hasMore) return; setLoadingMore(true); try { const next = await api.listConversations(project.id, { actor: actor || undefined, source: source || undefined, status: status || undefined, search: search || undefined, limit: PAGE, offset: nextOffset }); // De-dupe by thread_id in case the scan window shifted between pages. setConvos((prev) => { const seen = new Set(prev.map((x) => x.thread_id)); return [...prev, ...next.filter((x) => !seen.has(x.thread_id))]; }); setNextOffset((o) => o + next.length); setHasMore(next.length === PAGE); } catch { /* keep what we have */ } finally { setLoadingMore(false); } }, [project?.id, actor, source, status, search, nextOffset, hasMore, loadingMore]); const onListScroll = () => { const el = listRef.current; if (el && el.scrollHeight - el.scrollTop - el.clientHeight < 120) loadMore(); }; useEffect(() => { if (project?.id && sel) api.getConversation(project.id, sel).then(setDetail).catch(() => setDetail(null)); else setDetail(null); }, [project?.id, sel]); const purge = async () => { const days = window.prompt("Delete conversations older than how many days? (admin only)", "30"); if (days == null) return; const n = parseInt(days, 10); if (!Number.isFinite(n) || n < 0) return; try { const { removed } = await api.purgeConversations(project.id, n); window.alert(`Removed ${removed} conversation turn(s) older than ${n} days.`); // Reset back to the first page after a purge. const first = await api.listConversations(project.id, { actor: actor || undefined, source: source || undefined, status: status || undefined, search: search || undefined, limit: PAGE, offset: 0 }); setConvos(first); setNextOffset(first.length); setHasMore(first.length === PAGE); } catch { window.alert("Purge failed — this action requires an admin role."); } }; // Export the loaded conversation summaries as JSON (respects the active filters/search). const exportConvos = () => { if (!convos.length) return; downloadJSON(`conversations-${project?.slug || project?.id || "export"}.json`, convos); }; return ( // alignItems:stretch - .row centers children, which gives the list its content height.
{/* conversations list + filters */}
Conversations
setSearchInput(e.target.value)} placeholder="Search messages…" className="input" style={{ width: "100%", fontSize: 13 }} />
{[["", "All"], ["success", "Success"], ["error", "Error"]].map(([v, label]) => ( ))}
{convos.length === 0 &&
No conversations yet. Run a workflow in the Playground or from your app.
} {convos.map((c) => ( ))} {loadingMore &&
Loading more…
} {hasMore && !loadingMore && ( )}
{/* transcript */}
{detail ? :
Select a conversation to see its messages.
}
); } /* One user message can produce several Trace rows under the SAME run_id: a HITL pause writes an `interrupted` trace, then the resume writes a `done` trace (both carry the same user_message, since run.input is unchanged). Grouping by run_id folds those segments back into one turn, so a single message reads as one turn that paused - not two. Separate submissions get fresh run_ids and stay distinct. Traces arrive oldest-first (started_at asc), so the last segment is final. */ type GroupedTurn = { runId: string; segments: Turn[]; userMessage: string | null; aiResponse: string | null; status: string; error: string | null; latencyMs: number; tokens: number; costUsd: number; paused: boolean; }; function groupTurns(turns: Turn[]): GroupedTurn[] { const byRun = new Map(); const order: string[] = []; for (const t of turns) { let g = byRun.get(t.run_id); if (!g) { g = { runId: t.run_id, segments: [], userMessage: null, aiResponse: null, status: t.status, error: null, latencyMs: 0, tokens: 0, costUsd: 0, paused: false }; byRun.set(t.run_id, g); order.push(t.run_id); } g.segments.push(t); g.latencyMs += t.latency_ms || 0; g.tokens += t.total_tokens || 0; g.costUsd += t.total_cost_usd || 0; g.userMessage = g.userMessage ?? (t.user_message || null); if (t.ai_response) g.aiResponse = t.ai_response; // last non-empty wins (the final segment) g.status = t.status; // last segment = the run's final status g.error = t.error ?? null; if (t.status === "interrupted") g.paused = true; } return order.map((r) => byRun.get(r)!); } function ConversationView({ project, detail }: { project: any; detail: ConversationDetail }) { const c = detail.conversation; const [openTurn, setOpenTurn] = useState(null); const [traces, setTraces] = useState>({}); const [rerunning, setRerunning] = useState(null); // Resolve the workflow's node ids to their friendly (canvas) names so the span tree reads like // the graph the operator built (e.g. "support_supervisor", not "supervisor"). Best-effort: if the // workflow was deleted the map is empty and spans fall back to their raw id (still traceable). const [nodeLabels, setNodeLabels] = useState>({}); useEffect(() => { if (!project?.id || !c.workflow_id) { setNodeLabels({}); return; } let live = true; api.getWorkflow(project.id, c.workflow_id) .then((wf) => { if (live) setNodeLabels(buildNodeLabels(wf)); }) .catch(() => { if (live) setNodeLabels({}); }); return () => { live = false; }; }, [project?.id, c.workflow_id]); // Keyed by run_id (a group), not trace_id: a paused run has an interrupt + a resume trace, and // expanding the turn lazy-loads the spans for every segment so the waterfall shows the whole run. const toggle = async (group: GroupedTurn) => { if (openTurn === group.runId) { setOpenTurn(null); return; } setOpenTurn(group.runId); for (const seg of group.segments) { if (traces[seg.trace_id]) continue; try { const d = await api.getTrace(project.id, seg.trace_id); setTraces((t) => ({ ...t, [seg.trace_id]: { spans: d.spans } })); } catch { /* ignore */ } } }; const rerun = async (runId: string) => { if (!c.workflow_id || rerunning) return; setRerunning(runId); try { const run = await api.rerunRun(project.id, c.workflow_id, runId); let outcome = "completed"; await openSSE(api.runStreamUrl(project.id, c.workflow_id, run.id), (frame) => { if (frame.event === "error") outcome = "failed"; else if (frame.event === "interrupt") outcome = "paused for input"; }); window.alert(`Re-run ${outcome}. Open its new conversation from the list.`); } catch (error) { window.alert(error instanceof Error ? `Re-run failed: ${error.message}` : "Re-run failed."); } finally { setRerunning(null); } }; return (
{/* high-level rollup */}
{c.actor}
{srcLabel(c.source)} · {c.turns} turn{c.turns === 1 ? "" : "s"} · started {fmtWhen(c.started_at)}
{/* transcript */} {groupTurns(detail.turns).map((group) => (
{group.userMessage && (
{group.userMessage}
)}
traces[s.trace_id]?.spans)} nodeLabels={nodeLabels} onToggle={() => toggle(group)} onRerun={() => rerun(group.runId)} rerunning={rerunning === group.runId} canRerun={!!c.workflow_id} />
))}
); } function AITurn({ group, open, segmentSpans, nodeLabels, onToggle, onRerun, rerunning, canRerun }: { group: GroupedTurn; open: boolean; segmentSpans: (Span[] | undefined)[]; nodeLabels: Record; onToggle: () => void; onRerun: () => void; rerunning: boolean; canRerun: boolean; }) { const errored = group.status === "error" || !!group.error; const awaiting = group.status === "interrupted"; // still paused, not yet resumed const multi = group.segments.length > 1; const placeholder = errored ? "(no response — this turn errored)" : awaiting ? "(paused — awaiting approval)" : "(no text response)"; return (
{group.error &&
{group.error}
} {open && (
{group.segments.map((seg, i) => { const spans = segmentSpans[i]; return (
{/* Label each segment only when a pause split the run into more than one. */} {multi && (
{seg.status === "interrupted" ? "Paused for approval" : i > 0 ? "Resumed" : "Started"} · {seg.latency_ms}ms
)} {spans ? :
Loading trace…
}
); })}
)}
); } function hasDetail(s: Span): boolean { return s.input != null || s.output != null || !!s.error; } // A colored dot per span kind so the tree scans quickly. Node-level (chain) spans borrow their // node type's canvas color; the rest are keyed by kind. Mirrors the canvas IOType palette. function spanColor(s: Span, labels: Record): string { const t = labels[s.name]?.type; if (t && NODE_META[t]?.color) return NODE_META[t].color; const byKind: Record = { llm: "var(--accent)", subagent: "var(--accent)", agent: "var(--accent)", tool: "var(--io-json)", retriever: "var(--io-vector)", embedding: "var(--io-vector)", }; return byKind[s.kind] || "var(--fg-2)"; } // A span's two-line label: the friendly (canvas) NAME on top, and a mono sub-line carrying the // raw node id (when it differs from the name, so a big trace stays traceable) + kind + model. function spanLabel(s: Span, labels: Record): { primary: string; sub: string } { const hit = labels[s.name]; const primary = hit?.label || s.name; const idPart = hit && hit.label !== s.name ? s.name : null; // show the id only if it adds info const modelPart = s.model && !s.name.includes(s.model) ? s.model : null; const sub = [idPart, s.kind, modelPart].filter(Boolean).join(" · "); return { primary, sub }; } // The per-turn span tree (graph nodes -> their model/tool/parser/subagent spans), rendered like an // IDE / DevTools element tree: fold arrows, indent guide-lines, and a click-to-open I/O panel per // span. Nesting comes straight from `parent_span_id`; the node id is resolved to its canvas name. function SpanWaterfall({ spans, nodeLabels }: { spans: Span[]; nodeLabels: Record }) { const [openDetail, setOpenDetail] = useState>({}); const [collapsed, setCollapsed] = useState>({}); const maxLatency = useMemo(() => Math.max(1, ...spans.map((s) => s.latency_ms)), [spans]); // Build the parent -> children index and the root list (a span whose parent isn't in this set is // a root). Insertion order is preserved, so children stay in the order they were recorded. const { roots, byParent } = useMemo(() => { const ids = new Set(spans.map((s) => s.id)); const byParent: Record = {}; const roots: Span[] = []; for (const s of spans) { if (s.parent_span_id && ids.has(s.parent_span_id)) (byParent[s.parent_span_id] ||= []).push(s); else roots.push(s); } return { roots, byParent }; }, [spans]); const allWithKids = useMemo(() => Object.keys(byParent), [byParent]); const anyCollapsed = allWithKids.some((id) => collapsed[id]); const toggleAll = () => setCollapsed(anyCollapsed ? {} : Object.fromEntries(allWithKids.map((id) => [id, true]))); const renderNode = (s: Span, depth: number) => { const kids = byParent[s.id] || []; const hasKids = kids.length > 0; const isCollapsed = !!collapsed[s.id]; const expandable = hasDetail(s); const isOpen = !!openDetail[s.id]; const { primary, sub } = spanLabel(s, nodeLabels); const dot = spanColor(s, nodeLabels); return (
setOpenDetail((o) => ({ ...o, [s.id]: !o[s.id] })) : undefined} style={{ padding: "7px 12px", borderBottom: "1px solid var(--line)", cursor: expandable ? "pointer" : "default", background: isOpen ? "var(--bg-3)" : "transparent" }} > {/* fold control (or a leaf dot) */} {hasKids ? ( ) : ( )} {/* label (indents with depth; shrinks naturally) */}
{hasKids && } {primary} {expandable && }
{sub &&
{sub}
}
{/* right-aligned metrics: a slim latency bar + tokens + cost (aligned regardless of depth) */}
{s.latency_ms}ms {(s.input_tokens + s.output_tokens) > 0 ? `${s.input_tokens + s.output_tokens} tok` : ""} {s.cost_usd > 0 ? fmtUSD(s.cost_usd) : ""} {s.error && error}
{isOpen && } {/* children: a nested block with a left guide-line, like a code/HTML tree */} {hasKids && !isCollapsed && (
{kids.map((k) => renderNode(k, depth + 1))}
)}
); }; return (
{spans.length === 0 ? (
No spans recorded.
) : ( <>
{spans.length} span{spans.length === 1 ? "" : "s"} {allWithKids.length > 0 && ( )}
{roots.map((r) => renderNode(r, 0))}
)}
); } function Metric({ label, value }: { label: string; value: string }) { return
{value}{label}
; } // Is this a framed REST request/response envelope (vs a generic tool's raw args/return)? const isRestReq = (v: any) => v && typeof v === "object" && "method" in v && "url" in v; const isRestRes = (v: any) => v && typeof v === "object" && ("status" in v || "final_url" in v); const nonEmpty = (v: any) => v != null && !(typeof v === "object" && Object.keys(v).length === 0) && v !== ""; function Code({ value }: { value: any }) { const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); return
{text}
; } function Row({ label, value }: { label: string; value: any }) { if (!nonEmpty(value)) return null; return
{label}
; } function SpanDetail({ span }: { span: Span }) { const inp = span.input, out = span.output; return (
{isRestReq(inp) ? ( <>
Request
{inp.method}{inp.url}
) : ( )} {isRestRes(out) ? (
Response {out.status != null && = 400 ? "pill pill-err" : "pill"} style={{ height: 18 }}>{out.status}} {out.latency_ms != null && {out.latency_ms}ms}
{out.final_url && out.final_url !== inp?.url &&
→ {out.final_url}
} {out.error && }
) : ( )} {span.error &&
Error
}
); }