import { useState, type ReactNode } from 'react';
import {
Activity,
AlertCircle,
CheckCircle2,
ChevronDown,
ChevronRight,
Loader2,
Pencil,
Search,
Terminal,
Wrench,
type LucideIcon,
} from 'lucide-react';
export type ProcessTraceMetric = {
key: string;
label: string;
};
export type ProcessTraceStep = {
id?: string;
title?: string;
detail?: string;
state?: string;
severity?: string;
phase?: string;
toolName?: string;
};
type ProcessTraceProps = {
label: string;
collapsedDetail?: string;
statusLabel?: string;
status?: string;
metrics?: ProcessTraceMetric[];
steps?: ProcessTraceStep[];
children?: ReactNode;
defaultExpanded?: boolean;
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
live?: boolean;
className?: string;
};
export function ProcessRunHeader({
label,
className = '',
}: {
label: string;
className?: string;
}) {
return (
<div
role="status"
aria-live="polite"
className={`mb-3 border-b border-neutral-200/70 pb-1.5 text-[14px] leading-relaxed text-neutral-500 dark:border-neutral-800/80 dark:text-neutral-400 ${className}`}
>
<span className="tabular-nums">{label}</span>
</div>
);
}
export function ProcessLiveStatus({
step,
children,
compact = false,
defaultExpanded = false,
expanded: controlledExpanded,
onExpandedChange,
className = '',
}: {
step: ProcessTraceStep;
children?: ReactNode;
compact?: boolean;
defaultExpanded?: boolean;
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
className?: string;
}) {
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(defaultExpanded);
const expanded = controlledExpanded ?? uncontrolledExpanded;
const setExpanded = (nextExpanded: boolean | ((value: boolean) => boolean)) => {
const resolvedExpanded = typeof nextExpanded === 'function'
? nextExpanded(expanded)
: nextExpanded;
if (controlledExpanded === undefined) {
setUncontrolledExpanded(resolvedExpanded);
}
onExpandedChange?.(resolvedExpanded);
};
const Icon = getStepIcon(step);
const title = step.title || step.toolName || 'Working';
const isRunning = step.state !== 'failed' && step.state !== 'completed' && step.state !== 'cancelled';
const hasDetails = Boolean(children);
const statusContent = (
<>
<Icon
className={`mt-[0.28rem] h-3.5 w-3.5 shrink-0 ${getStepIconClass(step)} ${
Icon === Loader2 && isRunning ? 'animate-spin' : ''
}`}
strokeWidth={1.8}
/>
<div className="min-w-0">
<div className="truncate">{title}</div>
{step.detail ? (
<div className="truncate text-[12px] leading-5 text-neutral-400/80 dark:text-neutral-500/80">
{step.detail}
</div>
) : null}
</div>
{hasDetails ? (
expanded ? (
<ChevronDown className="mt-1 h-4 w-4 shrink-0 text-neutral-400 dark:text-neutral-500" strokeWidth={1.8} />
) : (
<ChevronRight className="mt-1 h-4 w-4 shrink-0 text-neutral-400 dark:text-neutral-500" strokeWidth={1.8} />
)
) : null}
</>
);
return (
<div
role="status"
aria-live="polite"
className={`process-live-status ${compact ? 'py-0' : 'pb-1'} text-[14px] leading-relaxed text-neutral-400 dark:text-neutral-500 ${className}`}
>
{hasDetails ? (
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
className={`group inline-flex min-w-0 max-w-full items-start gap-2 text-left transition hover:text-neutral-600 dark:hover:text-neutral-300 ${
isRunning ? 'animate-pulse' : ''
}`}
>
{statusContent}
</button>
) : (
<div className={`inline-flex min-w-0 max-w-full items-start gap-2 ${isRunning ? 'animate-pulse' : ''}`}>
{statusContent}
</div>
)}
{expanded && hasDetails ? (
<div className="mt-1.5 space-y-1.5 pl-5">
{children}
</div>
) : null}
</div>
);
}
function getStepIcon(step: ProcessTraceStep): LucideIcon {
const haystack = `${step.phase || ''} ${step.toolName || ''} ${step.title || ''}`.toLowerCase();
if (step.state === 'failed' || step.severity === 'error' || step.severity === 'warning') {
return AlertCircle;
}
if (step.phase === 'rag' || /search|grep|glob|find|检索|搜索/.test(haystack)) {
return Search;
}
if (/edit|write|patch|update|create|modify|修改|编辑|写入|创建/.test(haystack)) {
return Pencil;
}
if (/bash|shell|terminal|command|exec|run|命令|运行/.test(haystack)) {
return Terminal;
}
if (step.phase === 'tool' || step.phase === 'subtask' || step.toolName) {
return Wrench;
}
if (step.state === 'completed') {
return CheckCircle2;
}
if (step.state === 'running') {
return Loader2;
}
return Activity;
}
function getStepIconClass(step: ProcessTraceStep): string {
if (step.state === 'failed' || step.severity === 'error') {
return 'text-amber-600 dark:text-amber-400';
}
if (step.severity === 'warning') {
return 'text-amber-500 dark:text-amber-400';
}
if (step.state === 'running') {
return 'text-neutral-400 dark:text-neutral-500';
}
return 'text-neutral-400 dark:text-neutral-500';
}
function ProcessTraceLine({ step }: { step: ProcessTraceStep }) {
const Icon = getStepIcon(step);
const isRunning = step.state === 'running';
const title = step.title || step.toolName || 'Step';
return (
<div
className={`inline-flex min-w-0 max-w-full items-start gap-2 text-[14px] leading-relaxed text-neutral-400 dark:text-neutral-500 ${
isRunning ? 'animate-pulse' : ''
}`}
>
<Icon
className={`mt-[0.28rem] h-3.5 w-3.5 shrink-0 ${getStepIconClass(step)} ${
Icon === Loader2 && isRunning ? 'animate-spin' : ''
}`}
strokeWidth={1.9}
/>
<div className="min-w-0">
<div className="truncate">{title}</div>
{step.detail ? (
<div className="truncate text-[12px] leading-5 text-neutral-400/80 dark:text-neutral-500/80">
{step.detail}
</div>
) : null}
</div>
</div>
);
}
export function ProcessTrace({
label,
collapsedDetail,
statusLabel,
status = 'completed',
metrics = [],
steps = [],
children,
defaultExpanded = false,
expanded: controlledExpanded,
onExpandedChange,
live = false,
className = '',
}: ProcessTraceProps) {
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(defaultExpanded);
const expanded = controlledExpanded ?? uncontrolledExpanded;
const setExpanded = (nextExpanded: boolean | ((value: boolean) => boolean)) => {
const resolvedExpanded = typeof nextExpanded === 'function'
? nextExpanded(expanded)
: nextExpanded;
if (controlledExpanded === undefined) {
setUncontrolledExpanded(resolvedExpanded);
}
onExpandedChange?.(resolvedExpanded);
};
const hasDetails = Boolean(statusLabel) || metrics.length > 0 || steps.length > 0 || Boolean(children);
const visibleCollapsedDetail = !expanded && collapsedDetail;
const statusStep: ProcessTraceStep | null =
statusLabel || metrics.length > 0
? {
id: 'process-status',
title: statusLabel,
detail: metrics.map((metric) => metric.label).join(', '),
state: status,
}
: null;
const summaryIconStep = steps[0] || statusStep || { title: label, state: status };
const SummaryIcon = getStepIcon(summaryIconStep);
const isRunning = status === 'running';
return (
<div
role={live ? 'status' : undefined}
aria-live={live ? 'polite' : undefined}
className={`process-trace py-0 ${className}`}
>
<button
type="button"
aria-expanded={hasDetails ? expanded : undefined}
onClick={() => {
if (hasDetails) {
setExpanded((value) => !value);
}
}}
disabled={!hasDetails}
className={`group inline-flex min-w-0 max-w-full items-center gap-2 text-left text-[14px] leading-relaxed text-neutral-400 transition hover:text-neutral-600 disabled:cursor-default disabled:hover:text-neutral-400 dark:text-neutral-500 dark:hover:text-neutral-300 dark:disabled:hover:text-neutral-500 ${
isRunning ? 'animate-pulse' : ''
}`}
>
<SummaryIcon
className={`h-3.5 w-3.5 shrink-0 ${getStepIconClass(summaryIconStep)} ${
SummaryIcon === Loader2 && isRunning ? 'animate-spin' : ''
}`}
strokeWidth={1.8}
/>
<span className="min-w-0 truncate tabular-nums">{label}</span>
{visibleCollapsedDetail ? (
<span className="min-w-0 shrink truncate text-neutral-400/75 dark:text-neutral-500/75">
{visibleCollapsedDetail}
</span>
) : null}
{hasDetails ? (
expanded ? (
<ChevronDown className="h-4 w-4 shrink-0 text-neutral-400 transition group-hover:text-neutral-500 dark:text-neutral-500 dark:group-hover:text-neutral-300" strokeWidth={1.8} />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-neutral-400 transition group-hover:text-neutral-500 dark:text-neutral-500 dark:group-hover:text-neutral-300" strokeWidth={1.8} />
)
) : null}
</button>
{expanded ? (
<div className="mt-1.5 space-y-1.5 pl-5">
{statusStep ? <ProcessTraceLine step={statusStep} /> : null}
{steps.map((step, index) => (
<ProcessTraceLine key={step.id || `${step.title || 'process-step'}-${index}`} step={step} />
))}
{children ? <div className="space-y-1.5 pt-0.5">{children}</div> : null}
</div>
) : null}
</div>
);
}