Agent 执行轨迹
NewAgent 执行轨迹组件族:发丝竖轨上按类型着装的步骤节点、带脉冲环与节奏扫光的进行中步骤、原始输出展开、并行工具组,以及可嵌套展开的子 Agent 迷你轨迹。
"use client";
import { FileSearch, ListChecks, RotateCcw, Search, Wrench } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { type ComponentType, useEffect, useState } from "react";
import {
Trace,
TraceCostBadge,
TraceGroup,
TraceStep,
TraceSubagent,
TraceSummary,
} from "@/components/motion/agent-trace";
import { cn } from "@/lib/utils";
const TOTAL_STEPS = 7;
/** Fixed reveal delays (ms) for steps 1..7 — a 600ms cadence, no timers based on Date.now/Math.random. */
const REVEAL_DELAYS = [0, 600, 1200, 1800, 2400, 3000, 3600];
/** How long the live "Writing summary…" step stays active before settling in place. */
const WRITING_DURATION = 2400;
const RAW_JSON = `{
"query": "release checklist",
"matches": 3,
"files": [
"CHANGELOG.md",
"docs/release.md",
"docs/checklist.md"
]
}`;
interface GroupRowProps {
icon: ComponentType<{ className?: string }>;
label: string;
meta: string;
slow?: boolean;
}
/** Simplified row inside a `TraceGroup` — 14px icon, label, right-floating meta. */
function GroupRow({ icon: Icon, label, meta, slow }: GroupRowProps) {
return (
<div className="flex items-center gap-2 py-0.5 text-sm text-foreground/80">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{label}</span>
<span
className={cn(
"ml-auto shrink-0 text-xs tabular-nums text-muted-foreground",
slow && "text-[#e25507] dark:text-[#ff8549]",
)}
>
{meta}
</span>
</div>
);
}
/**
* Scripted playback of an agent execution trace — plan, tool call with an
* expandable raw-JSON result, a nested sub-agent delegation, a parallel
* tool group, a live step that settles in place once "done", a final
* "Task complete" step and a trailing run-summary row. Steps mount on a
* fixed 600ms cadence; "Replay" restarts the script. `useReducedMotion()`
* skips the staged reveal and renders every step (plus the summary) in its
* settled end state instead.
*/
export function AgentTracePreview() {
const reduce = useReducedMotion() ?? false;
const [playKey, setPlayKey] = useState(0);
const [visibleCount, setVisibleCount] = useState(reduce ? TOTAL_STEPS : 0);
const [writingDone, setWritingDone] = useState(reduce);
const [subagentOpen, setSubagentOpen] = useState(true);
// biome-ignore lint/correctness/useExhaustiveDependencies: `playKey` is a deliberate restart trigger for the "Replay" button — its value is never read, only its identity change re-runs the script from the top.
useEffect(() => {
setSubagentOpen(true);
if (reduce) {
setVisibleCount(TOTAL_STEPS);
setWritingDone(true);
return;
}
setVisibleCount(0);
setWritingDone(false);
const timers = REVEAL_DELAYS.map((delay, index) =>
setTimeout(() => setVisibleCount(index + 1), delay),
);
return () => {
for (const id of timers) clearTimeout(id);
};
}, [reduce, playKey]);
useEffect(() => {
if (reduce || visibleCount < 5) return;
const id = setTimeout(() => setWritingDone(true), WRITING_DURATION);
return () => clearTimeout(id);
}, [reduce, visibleCount]);
return (
<div className="relative mx-auto w-full max-w-xl rounded-xl border border-border bg-background p-5">
<button
type="button"
onClick={() => setPlayKey((key) => key + 1)}
className="absolute top-3 right-3 flex h-7 items-center gap-1.5 rounded-lg px-2 text-muted-foreground text-xs transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10"
>
<RotateCcw className="h-3.5 w-3.5" />
Replay
</button>
<Trace className="pt-1">
{visibleCount >= 1 ? <TraceStep kind="plan" label="Plan the release checklist" /> : null}
{visibleCount >= 2 ? (
<TraceStep
kind="tool"
icon={<Search className="h-[13px] w-[13px]" />}
label="Search docs"
detail="3 files matched"
meta={
<span className="flex items-center gap-1.5">
<span>0.4s</span>
<TraceCostBadge tokens={1240} cost={0.003} />
</span>
}
>
<div className="whitespace-pre-wrap rounded-lg bg-black/[0.04] p-2 font-mono text-xs dark:bg-white/5">
{RAW_JSON}
</div>
</TraceStep>
) : null}
{visibleCount >= 3 ? (
<TraceSubagent
label="Delegate: verify build"
open={subagentOpen}
onOpenChange={setSubagentOpen}
>
<TraceStep kind="tool" label="Run unit tests" meta="1.8s" />
<TraceStep kind="done" label="All green" />
</TraceSubagent>
) : null}
{visibleCount >= 4 ? (
<TraceGroup label="Running 3 tools in parallel">
<GroupRow icon={FileSearch} label="Fetch schema" meta="0.6s" />
<GroupRow icon={Wrench} label="Lint" meta="2.4s" slow />
<GroupRow icon={ListChecks} label="Typecheck" meta="1.1s" />
</TraceGroup>
) : null}
{visibleCount >= 5 ? (
<TraceStep
kind={writingDone ? "done" : "tool"}
active={!writingDone}
label={writingDone ? "Wrote summary" : "Writing summary…"}
/>
) : null}
{visibleCount >= 6 ? <TraceStep kind="done" label="Task complete" /> : null}
{visibleCount >= 7 ? (
<TraceSummary duration="12.8s" tokens={48200} cost={0.089} />
) : null}
</Trace>
</div>
);
}
"use client";
// ui-lab-ten.vercel.app/components/blocks/agent-trace
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 (
<div
className={cn(
"relative flex flex-col gap-0",
"before:absolute before:top-3 before:bottom-3 before:left-[11px] before:w-px before:bg-[var(--wb-border)]",
className,
)}
>
{children}
</div>
);
}
export type TraceStepKind = "plan" | "tool" | "reflection" | "done";
const KIND_ICON: Record<TraceStepKind, ComponentType<{ className?: string }>> = {
plan: Sparkles,
tool: Wrench,
reflection: RefreshCw,
done: Check,
};
const KIND_NODE_STYLE: Record<TraceStepKind, string> = {
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 <span className={cn("text-muted-foreground", className)}>{children}</span>;
}
return (
<motion.span
style={{
backgroundImage:
"linear-gradient(90deg, var(--wb-shimmer-dim) 0%, var(--wb-shimmer-dim) 40%, var(--wb-shimmer-bright) 50%, var(--wb-shimmer-dim) 60%, var(--wb-shimmer-dim) 100%)",
backgroundSize: "200% 100%",
}}
animate={{ backgroundPosition: ["100% 0%", "-100% 0%"] }}
transition={{ duration: 1, repeat: Infinity, repeatDelay: 2, ease: "linear" }}
className={cn(
"bg-clip-text text-transparent",
className,
)}
>
{children}
</motion.span>
);
}
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<HTMLDivElement>(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 (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0.15 : 0.25, ease: EASE_OUT }}
className={cn("relative flex gap-3 py-2", className)}
>
<span
className={cn(
"z-10 flex h-[23px] w-[23px] shrink-0 items-center justify-center rounded-full border bg-[var(--wb-surface)]",
KIND_NODE_STYLE[kind],
)}
>
{active && !reduce ? (
<motion.span
aria-hidden
className="absolute h-[23px] w-[23px] rounded-full border border-[var(--wb-accent)]"
initial={{ scale: 1, opacity: 0.5 }}
animate={{ scale: 1.5, opacity: 0 }}
transition={{ duration: 1.6, repeat: Infinity, ease: EASE_OUT }}
/>
) : null}
<span className="relative z-10 flex items-center justify-center">
{icon ?? <Icon className="h-[13px] w-[13px]" />}
</span>
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{active ? (
<TraceShimmerText className="text-sm">{label}</TraceShimmerText>
) : (
<span className="text-sm text-foreground">{label}</span>
)}
{hasRaw ? (
<button
type="button"
aria-expanded={open}
onClick={toggle}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
View raw
</button>
) : null}
{meta !== undefined && meta !== null ? (
<span className="ml-auto shrink-0 text-xs tabular-nums text-muted-foreground">{meta}</span>
) : null}
</div>
{detail !== undefined && detail !== null ? (
<div className="text-[13px] text-muted-foreground">{detail}</div>
) : null}
{hasRaw ? (
<motion.div
initial={false}
animate={reduce ? undefined : { height: open ? contentHeight : 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className={cn("overflow-hidden", reduce && (open ? "h-auto" : "h-0"))}
>
<div ref={contentRef} className="pt-1.5">
{children}
</div>
</motion.div>
) : null}
</div>
</motion.div>
);
}
/** <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 (
<span
className={cn(
"inline-flex h-[18px] items-center gap-1 rounded-full bg-[var(--wb-inset-strong)] px-1.5 text-[10px] tabular-nums text-muted-foreground",
className,
)}
>
{fragments.join(" · ")}
</span>
);
}
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 (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0.15 : 0.25, ease: EASE_OUT }}
className={cn("relative flex flex-col py-2", className)}
>
<div className="ml-9 text-[13px] text-muted-foreground">{label}</div>
<div className="ml-9 mt-1 flex flex-col gap-1 border-[var(--wb-border)] border-l border-dashed pl-3">
{children}
</div>
</motion.div>
);
}
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<HTMLDivElement>(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 (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0.15 : 0.25, ease: EASE_OUT }}
className={cn("relative flex flex-col py-2", className)}
>
<button
type="button"
aria-expanded={open}
onClick={toggle}
className="ml-9 inline-flex items-center gap-1 self-start rounded-lg px-1 py-0.5 text-sm text-foreground transition-colors hover:bg-[var(--wb-hover)]"
>
{label}
<motion.span
aria-hidden
className="flex text-muted-foreground/60"
animate={reduce ? undefined : { rotate: open ? 90 : 0 }}
style={reduce ? { transform: open ? "rotate(90deg)" : "rotate(0deg)" } : undefined}
transition={reduce ? undefined : { duration: 0.2, ease: EASE_OUT }}
>
<ChevronRight className="h-3.5 w-3.5" />
</motion.span>
</button>
<motion.div
initial={false}
animate={reduce ? undefined : { height: open ? contentHeight : 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className={cn("overflow-hidden", reduce && (open ? "h-auto" : "h-0"))}
>
<div ref={contentRef} className="ml-9 border-[var(--wb-border)] border-l pl-4">
<Trace>{children}</Trace>
</div>
</motion.div>
</motion.div>
);
}
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 (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0.15 : 0.25, ease: EASE_OUT }}
className={cn(
"ml-9 mt-1 flex items-center gap-3 border-[var(--wb-border)] border-t-[0.5px] pt-2 text-xs text-muted-foreground",
className,
)}
>
{duration !== undefined && duration !== null ? (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{duration}
</span>
) : null}
{tokens !== undefined ? <span>{formatTraceTokenCount(tokens)} tokens</span> : null}
{cost !== undefined ? <span>{formatTraceCost(cost)}</span> : null}
{children}
</motion.div>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// ui-lab-ten.vercel.app/components/blocks/agent-trace
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 (
<div
className={cn(
"relative flex flex-col gap-0",
"before:absolute before:top-3 before:bottom-3 before:left-[11px] before:w-px before:bg-[var(--wb-border)]",
className,
)}
>
{children}
</div>
);
}
export type TraceStepKind = "plan" | "tool" | "reflection" | "done";
const KIND_ICON: Record<TraceStepKind, ComponentType<{ className?: string }>> = {
plan: Sparkles,
tool: Wrench,
reflection: RefreshCw,
done: Check,
};
const KIND_NODE_STYLE: Record<TraceStepKind, string> = {
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 <span className={cn("text-muted-foreground", className)}>{children}</span>;
}
return (
<motion.span
style={{
backgroundImage:
"linear-gradient(90deg, var(--wb-shimmer-dim) 0%, var(--wb-shimmer-dim) 40%, var(--wb-shimmer-bright) 50%, var(--wb-shimmer-dim) 60%, var(--wb-shimmer-dim) 100%)",
backgroundSize: "200% 100%",
}}
animate={{ backgroundPosition: ["100% 0%", "-100% 0%"] }}
transition={{ duration: 1, repeat: Infinity, repeatDelay: 2, ease: "linear" }}
className={cn(
"bg-clip-text text-transparent",
className,
)}
>
{children}
</motion.span>
);
}
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<HTMLDivElement>(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 (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0.15 : 0.25, ease: EASE_OUT }}
className={cn("relative flex gap-3 py-2", className)}
>
<span
className={cn(
"z-10 flex h-[23px] w-[23px] shrink-0 items-center justify-center rounded-full border bg-[var(--wb-surface)]",
KIND_NODE_STYLE[kind],
)}
>
{active && !reduce ? (
<motion.span
aria-hidden
className="absolute h-[23px] w-[23px] rounded-full border border-[var(--wb-accent)]"
initial={{ scale: 1, opacity: 0.5 }}
animate={{ scale: 1.5, opacity: 0 }}
transition={{ duration: 1.6, repeat: Infinity, ease: EASE_OUT }}
/>
) : null}
<span className="relative z-10 flex items-center justify-center">
{icon ?? <Icon className="h-[13px] w-[13px]" />}
</span>
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{active ? (
<TraceShimmerText className="text-sm">{label}</TraceShimmerText>
) : (
<span className="text-sm text-foreground">{label}</span>
)}
{hasRaw ? (
<button
type="button"
aria-expanded={open}
onClick={toggle}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
View raw
</button>
) : null}
{meta !== undefined && meta !== null ? (
<span className="ml-auto shrink-0 text-xs tabular-nums text-muted-foreground">{meta}</span>
) : null}
</div>
{detail !== undefined && detail !== null ? (
<div className="text-[13px] text-muted-foreground">{detail}</div>
) : null}
{hasRaw ? (
<motion.div
initial={false}
animate={reduce ? undefined : { height: open ? contentHeight : 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className={cn("overflow-hidden", reduce && (open ? "h-auto" : "h-0"))}
>
<div ref={contentRef} className="pt-1.5">
{children}
</div>
</motion.div>
) : null}
</div>
</motion.div>
);
}
/** <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 (
<span
className={cn(
"inline-flex h-[18px] items-center gap-1 rounded-full bg-[var(--wb-inset-strong)] px-1.5 text-[10px] tabular-nums text-muted-foreground",
className,
)}
>
{fragments.join(" · ")}
</span>
);
}
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 (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0.15 : 0.25, ease: EASE_OUT }}
className={cn("relative flex flex-col py-2", className)}
>
<div className="ml-9 text-[13px] text-muted-foreground">{label}</div>
<div className="ml-9 mt-1 flex flex-col gap-1 border-[var(--wb-border)] border-l border-dashed pl-3">
{children}
</div>
</motion.div>
);
}
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<HTMLDivElement>(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 (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0.15 : 0.25, ease: EASE_OUT }}
className={cn("relative flex flex-col py-2", className)}
>
<button
type="button"
aria-expanded={open}
onClick={toggle}
className="ml-9 inline-flex items-center gap-1 self-start rounded-lg px-1 py-0.5 text-sm text-foreground transition-colors hover:bg-[var(--wb-hover)]"
>
{label}
<motion.span
aria-hidden
className="flex text-muted-foreground/60"
animate={reduce ? undefined : { rotate: open ? 90 : 0 }}
style={reduce ? { transform: open ? "rotate(90deg)" : "rotate(0deg)" } : undefined}
transition={reduce ? undefined : { duration: 0.2, ease: EASE_OUT }}
>
<ChevronRight className="h-3.5 w-3.5" />
</motion.span>
</button>
<motion.div
initial={false}
animate={reduce ? undefined : { height: open ? contentHeight : 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className={cn("overflow-hidden", reduce && (open ? "h-auto" : "h-0"))}
>
<div ref={contentRef} className="ml-9 border-[var(--wb-border)] border-l pl-4">
<Trace>{children}</Trace>
</div>
</motion.div>
</motion.div>
);
}
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 (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0.15 : 0.25, ease: EASE_OUT }}
className={cn(
"ml-9 mt-1 flex items-center gap-3 border-[var(--wb-border)] border-t-[0.5px] pt-2 text-xs text-muted-foreground",
className,
)}
>
{duration !== undefined && duration !== null ? (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{duration}
</span>
) : null}
{tokens !== undefined ? <span>{formatTraceTokenCount(tokens)} tokens</span> : null}
{cost !== undefined ? <span>{formatTraceCost(cost)}</span> : null}
{children}
</motion.div>
);
}
API 参考
Trace
className?string—TraceStep
kind?"done" | "plan" | "tool" | "reflection"Drives the icon-node border style and the default icon;
toolicon?ReactNode13px icon overriding the `kind` default (Sparkles/Wrench/RefreshCw/Check).
—labelReactNode—detail?ReactNodeSecond line under the label.
—meta?ReactNodeRight-floating slot, e.g. elapsed time — pass a colored span for a slow-running highlight.
—active?booleanLive state: pulses the icon ring and shimmers the label.
falseopen?booleanControlled "View raw" open state — only meaningful when `children` is passed.
—onOpenChange?((next: boolean) => void)—children?ReactNodeRaw content revealed by "View raw", e.g. a mono JSON block styled by the caller.
—className?string—TraceCostBadge
tokens?number—cost?number—className?string—TraceGroup
labelReactNodeHeader line, e.g. "Running 3 tools in parallel".
—className?string—TraceSubagent
labelReactNode—open?booleanControlled expand state.
—onOpenChange?((next: boolean) => void)—children?ReactNodeNested `TraceStep`s — rendered inside a mini `Trace` of their own.
—className?string—TraceSummary
duration?ReactNode—tokens?number—cost?number—className?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.