"use client"; import { Check, ChevronRight, Clock, RefreshCw, Sparkles, Wrench } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { type ComponentType, type ReactNode, useLayoutEffect, useRef, useState } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface TraceProps { className?: string; children?: ReactNode; } /** * Activity-rail container for an agent's execution trace — a hairline * strung through the center of every step's icon node, top and bottom * insets leaving breathing room before the first and after the last step. * Purely a positioning shell: `TraceStep`, `TraceGroup` and `TraceSubagent` * line their own markers up against it, so the rail visually threads * through whatever mix of step kinds is mounted. */ export function Trace({ className, children }: TraceProps) { return (
{children}
); } export type TraceStepKind = "plan" | "tool" | "reflection" | "done"; const KIND_ICON: Record> = { plan: Sparkles, tool: Wrench, reflection: RefreshCw, done: Check, }; const KIND_NODE_STYLE: Record = { plan: "border-[var(--wb-border-strong)] text-muted-foreground", tool: "border-[var(--wb-border-strong)] text-muted-foreground", reflection: "border-dashed border-[var(--wb-border-strong)] text-muted-foreground", done: "border-[var(--wb-success-border)]/40 text-[var(--wb-success)]", }; interface TraceShimmerTextProps { className?: string; children?: ReactNode; } /** * Cadenced shimmer for a step's live label — a 1s sweep followed by a 2s * rest, matching the pulse-of-activity read the thread block's shimmer * uses. Deliberately reimplemented here rather than imported, so this block * distributes as one self-contained unit. Falls back to a plain muted span * under `useReducedMotion()`. */ function TraceShimmerText({ className, children }: TraceShimmerTextProps) { const reduce = useReducedMotion() ?? false; if (reduce) { return {children}; } return ( {children} ); } export interface TraceStepProps { /** Drives the icon-node border style and the default icon; @default "tool" */ kind?: TraceStepKind; /** 13px icon overriding the `kind` default (Sparkles/Wrench/RefreshCw/Check). */ icon?: ReactNode; label: ReactNode; /** Second line under the label. */ detail?: ReactNode; /** Right-floating slot, e.g. elapsed time — pass a colored span for a slow-running highlight. */ meta?: ReactNode; /** Live state: pulses the icon ring and shimmers the label. */ active?: boolean; /** Controlled "View raw" open state — only meaningful when `children` is passed. */ open?: boolean; onOpenChange?: (next: boolean) => void; /** Raw content revealed by "View raw", e.g. a mono JSON block styled by the caller. */ children?: ReactNode; className?: string; } /** * One row on the activity rail. Renders an icon node on the rail (styled by * `kind`), a label (shimmering while `active`), an optional detail line and * a right-floating `meta` slot. When `children` is passed, a "View raw" * button appends to the label row and toggles a height-measured region * below — controlled (`open`/`onOpenChange`) or uncontrolled, matching the * `ThreadCollapse` idiom (`ResizeObserver` + 0 ↔ measured height tween, * instant under `useReducedMotion()`). Mounts with the same opacity + slide * entrance as `ThreadItem`; callers drive when a step mounts to script the * trace's pacing. */ export function TraceStep({ kind = "tool", icon, label, detail, meta, active = false, open: openProp, onOpenChange, children, className, }: TraceStepProps) { const reduce = useReducedMotion() ?? false; const [openState, setOpenState] = useState(false); const open = openProp ?? openState; const contentRef = useRef(null); const [contentHeight, setContentHeight] = useState(0); const hasRaw = children !== undefined && children !== null; const Icon = KIND_ICON[kind]; const toggle = () => { const next = !open; setOpenState(next); onOpenChange?.(next); }; useLayoutEffect(() => { const node = contentRef.current; if (!node) return; const update = () => setContentHeight(node.offsetHeight); update(); const observer = new ResizeObserver(update); observer.observe(node); return () => observer.disconnect(); }, []); return ( {active && !reduce ? ( ) : null} {icon ?? }
{active ? ( {label} ) : ( {label} )} {hasRaw ? ( ) : null} {meta !== undefined && meta !== null ? ( {meta} ) : null}
{detail !== undefined && detail !== null ? (
{detail}
) : null} {hasRaw ? (
{children}
) : null}
); } /** <0.01 keeps 4 decimal places, otherwise 3 — trailing zeros are trimmed either way (e.g. 0.003 → "$0.003", not "$0.0030"). */ function formatTraceCost(cost: number): string { const decimals = cost < 0.01 ? 4 : 3; const trimmed = cost.toFixed(decimals).replace(/0+$/, "").replace(/\.$/, ""); return `$${trimmed}`; } /** K/M abbreviation for a raw token count; values under 1000 render as-is. */ function formatTraceTokenCount(count: number): string { if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; if (count >= 1000) return `${(count / 1000).toFixed(1)}K`; return `${count}`; } export interface TraceCostBadgeProps { tokens?: number; cost?: number; className?: string; } /** * Compact usage pill sized for a `TraceStep`'s `meta` slot — token count * and/or cost, joined by a middle dot when both are passed. Deliberately * reimplements its own K/M and price formatting rather than importing the * thread block's, keeping this block self-contained. */ export function TraceCostBadge({ tokens, cost, className }: TraceCostBadgeProps) { const fragments: string[] = []; if (tokens !== undefined) fragments.push(`${formatTraceTokenCount(tokens)} tok`); if (cost !== undefined) fragments.push(formatTraceCost(cost)); if (fragments.length === 0) return null; return ( {fragments.join(" · ")} ); } export interface TraceGroupProps { /** Header line, e.g. "Running 3 tools in parallel". */ label: ReactNode; children?: ReactNode; className?: string; } /** * Parallel-execution group — a muted header line followed by a * dashed-ruled sub-list, indented to align under a `TraceStep`'s label * column. `children` are typically simplified rows the caller composes * (icon + text + meta) rather than full `TraceStep`s. Mounts as one entrance * unit, matching the other rail rows. */ export function TraceGroup({ label, children, className }: TraceGroupProps) { const reduce = useReducedMotion() ?? false; return (
{label}
{children}
); } export interface TraceSubagentProps { label: ReactNode; /** Controlled expand state. */ open?: boolean; onOpenChange?: (next: boolean) => void; /** Nested `TraceStep`s — rendered inside a mini `Trace` of their own. */ children?: ReactNode; className?: string; } /** * Nested sub-agent trace — a clickable header (chevron rotates on toggle) * that reveals its own indented `Trace` of steps below, styled like * `ThreadTurnHeader`'s hover pill. Height-measured expand/collapse mirrors * `ThreadCollapse` (`ResizeObserver`, 0 ↔ measured, `EASE_OUT` 0.25s, * instant under `useReducedMotion()`). Works controlled or uncontrolled. */ export function TraceSubagent({ label, open: openProp, onOpenChange, children, className, }: TraceSubagentProps) { const reduce = useReducedMotion() ?? false; const [openState, setOpenState] = useState(false); const open = openProp ?? openState; const contentRef = useRef(null); const [contentHeight, setContentHeight] = useState(0); const toggle = () => { const next = !open; setOpenState(next); onOpenChange?.(next); }; useLayoutEffect(() => { const node = contentRef.current; if (!node) return; const update = () => setContentHeight(node.offsetHeight); update(); const observer = new ResizeObserver(update); observer.observe(node); return () => observer.disconnect(); }, []); return (
{children}
); } export interface TraceSummaryProps { duration?: ReactNode; tokens?: number; cost?: number; className?: string; children?: ReactNode; } /** * Run-summary tail row appended below a trace's last step — total duration, * token count and cost, ruled off from the steps above. Mounts with the * same opacity + slide entrance as `TraceStep` (`EASE_OUT`, 0.25s), reduced * to an opacity-only fade under `useReducedMotion()`. */ export function TraceSummary({ duration, tokens, cost, className, children }: TraceSummaryProps) { const reduce = useReducedMotion() ?? false; return ( {duration !== undefined && duration !== null ? ( {duration} ) : null} {tokens !== undefined ? {formatTraceTokenCount(tokens)} tokens : null} {cost !== undefined ? {formatTraceCost(cost)} : null} {children} ); }