"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 ?? }
);
}
/** <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}
);
}