import { useState } from 'react';
import { CheckCircle2, ChevronDown, ChevronRight, CircleDashed, GitBranch, Link, MessageSquare, Wrench, XCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import type { TaskStep } from './task-visualization';
interface ExecutionGraphCardProps {
agentLabel: string;
steps: TaskStep[];
active: boolean;
suppressThinking?: boolean;
* When provided, the card becomes fully controlled: the parent owns the
* expand state (e.g. to persist across remounts) and toggling goes through
* `onExpandedChange`. When omitted, the card manages its own local state.
*/
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
}
const TOOL_ROW_EXTRA_INDENT_PX = 8;
function AnimatedDots({ className }: { className?: string }) {
return (
<span className={cn('flex items-center gap-0.5 leading-none text-muted-foreground', className)} aria-hidden="true">
<span className="inline-block animate-bounce [animation-delay:0ms]">.</span>
<span className="inline-block animate-bounce [animation-delay:150ms]">.</span>
<span className="inline-block animate-bounce [animation-delay:300ms]">.</span>
</span>
);
}
function GraphStatusIcon({ status }: { status: TaskStep['status'] }) {
if (status === 'completed') return <CheckCircle2 className="h-4 w-4" />;
if (status === 'error') return <XCircle className="h-4 w-4" />;
return <CircleDashed className="h-4 w-4" />;
}
function StepDetailCard({ step }: { step: TaskStep }) {
const { t } = useTranslation('chat');
const [expanded, setExpanded] = useState(false);
const hasDetail = !!step.detail;
const isNarration = step.kind === 'message';
const isTool = step.kind === 'tool';
const isThinking = step.kind === 'thinking';
const isSystem = step.kind === 'system';
const isFlatRow = isTool || isSystem;
const showRunningDots = (isTool || isThinking) && step.status === 'running';
const hideStatusText = (isTool || isSystem) && step.status === 'completed';
const detailPreview = step.detail?.replace(/\s+/g, ' ').trim();
const canExpand = hasDetail;
const displayLabel = isThinking ? t('executionGraph.thinkingLabel') : step.label;
return (
<div
className={cn(
'min-w-0 flex-1 text-muted-foreground',
isFlatRow || isNarration || isThinking
? 'px-0 py-0'
: 'rounded-xl border border-black/10 bg-white/40 px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]',
)}
>
<button
type="button"
className={cn(
'flex w-full gap-2 text-left',
isFlatRow ? 'items-center' : 'items-start',
canExpand ? 'cursor-pointer' : 'cursor-default',
)}
onClick={() => {
if (!canExpand) return;
setExpanded((value) => !value);
}}
>
<div className="min-w-0 flex-1">
{(!isNarration && !isThinking || expanded) && (
<div className="flex min-w-0 items-center gap-2">
<p className="shrink-0 text-sm font-medium text-muted-foreground">{displayLabel}</p>
{isTool && step.label === 'web_fetch' && step.url && (
<a
href={step.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="shrink-0 text-muted-foreground hover:text-foreground"
title={step.url}
>
<Link className="h-3.5 w-3.5" />
</a>
)}
{isFlatRow && detailPreview && !expanded && (
<p className="min-w-0 truncate text-xs leading-4 text-muted-foreground/80">
{detailPreview}
</p>
)}
{!hideStatusText && !showRunningDots && (
<span className="shrink-0 whitespace-nowrap rounded-full bg-black/5 px-2 py-0.5 text-2xs font-medium uppercase tracking-wide text-muted-foreground dark:bg-white/10">
{t(`taskPanel.stepStatus.${step.status}`)}
</span>
)}
{showRunningDots && (
<AnimatedDots className="text-sm" />
)}
{step.depth > 1 && (
<span className="shrink-0 whitespace-nowrap rounded-full bg-black/5 px-2 py-0.5 text-2xs font-medium uppercase tracking-wide text-muted-foreground dark:bg-white/10">
{t('executionGraph.branchLabel')}
</span>
)}
</div>
)}
{step.detail && !expanded && !isFlatRow && (
<p
className={cn(
'text-muted-foreground',
isThinking
? 'mt-0.5 text-meta leading-5 line-clamp-2'
: 'text-meta leading-6 text-muted-foreground line-clamp-2',
)}
>
{step.detail}
</p>
)}
</div>
{canExpand && (
<span className="mt-0.5 shrink-0 text-muted-foreground">
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</span>
)}
</button>
{step.detail && expanded && canExpand && isFlatRow && (() => {
// Tool inputs are typically JSON; system payloads (e.g. subagent
// session keys) are usually plain strings. Pretty-print if the
// detail parses as JSON, otherwise fall back to the raw text so
// session keys render unchanged.
let formatted = step.detail;
try {
formatted = JSON.stringify(JSON.parse(step.detail), null, 2);
} catch { /* not valid JSON */ }
return (
<div className="mt-3 rounded-lg border border-black/10 bg-black/[0.03] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
<pre
className="whitespace-pre-wrap break-words text-xs leading-5 text-muted-foreground"
>
{formatted}
</pre>
</div>
);
})()}
{step.detail && expanded && canExpand && (isNarration || isThinking) && (
<div className="mt-3 rounded-lg border border-black/10 bg-black/[0.03] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
<pre
className="whitespace-pre-wrap break-words text-xs leading-5 text-muted-foreground"
>
{step.detail}
</pre>
</div>
)}
</div>
);
}
export function ExecutionGraphCard({
agentLabel,
steps,
active,
suppressThinking = false,
expanded: controlledExpanded,
onExpandedChange,
}: ExecutionGraphCardProps) {
const { t } = useTranslation('chat');
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(active);
const [prevActive, setPrevActive] = useState(active);
if (prevActive !== active) {
setPrevActive(active);
if (controlledExpanded == null && uncontrolledExpanded !== active) {
setUncontrolledExpanded(active);
}
}
const isControlled = controlledExpanded != null;
const expanded = isControlled ? controlledExpanded : uncontrolledExpanded;
const setExpanded = (next: boolean) => {
if (!isControlled) setUncontrolledExpanded(next);
onExpandedChange?.(next);
};
const toolCount = steps.filter((step) => step.kind === 'tool').length;
const processCount = steps.length - toolCount;
const shouldShowTrailingThinking = active && !suppressThinking;
if (!expanded) {
return (
<button
type="button"
data-testid="chat-execution-graph"
data-collapsed="true"
onClick={() => setExpanded(true)}
className="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-black/5 hover:text-muted-foreground dark:hover:bg-white/5"
>
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform group-hover:translate-x-0.5" />
<span className="truncate">
{t('executionGraph.collapsedSummary', { toolCount, processCount })}
</span>
</button>
);
}
return (
<div
data-testid="chat-execution-graph"
data-collapsed="false"
className="w-full px-0 py-0 text-muted-foreground"
>
<button
type="button"
data-testid="chat-execution-graph-collapse"
onClick={() => setExpanded(false)}
className="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-black/5 hover:text-muted-foreground dark:hover:bg-white/5"
aria-label={t('executionGraph.collapseAction')}
title={t('executionGraph.collapseAction')}
>
<ChevronRight className="h-3.5 w-3.5 shrink-0 rotate-90" />
<span className="truncate">{t('executionGraph.title')}</span>
</button>
<div className="mt-0 px-0 py-0">
<div className="mt-0.5 flex items-center gap-0.5" style={{ marginLeft: `${TOOL_ROW_EXTRA_INDENT_PX}px` }}>
<div className="flex w-6 shrink-0 justify-center">
<div className="flex h-6 w-6 items-center justify-center text-muted-foreground">
<GitBranch className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</div>
</div>
<div className="min-w-0 flex-1">
<span className="truncate text-sm font-medium text-muted-foreground">
{t('executionGraph.agentRun', { agent: agentLabel })}
</span>
</div>
</div>
{steps.map((step) => {
const alignedIndentOffset = (
step.kind === 'tool'
|| step.kind === 'message'
|| step.kind === 'thinking'
) ? TOOL_ROW_EXTRA_INDENT_PX : 0;
const rowMarginLeft = (Math.max(step.depth - 1, 0) * 24) + alignedIndentOffset;
return (
<div key={step.id} className="mt-0.5">
<div
className="pl-3"
style={{ marginLeft: `${rowMarginLeft}px` }}
>
<div className="ml-3 h-1 w-px bg-border" />
</div>
<div
className="flex items-start gap-0.5"
data-testid="chat-execution-step"
style={{ marginLeft: `${rowMarginLeft}px` }}
>
<div className="flex w-6 shrink-0 justify-center">
<div className="relative flex items-center justify-center">
{step.depth > 1 && (
<div className="absolute -left-3 top-1/2 h-px w-3 -translate-y-1/2 bg-border" />
)}
<div
className={cn(
'flex h-6 w-6 items-center justify-center text-muted-foreground',
)}
>
{step.kind === 'thinking'
? <MessageSquare className="h-3.5 w-3.5" />
: step.kind === 'tool'
? <Wrench className="h-3.5 w-3.5" />
: step.kind === 'message'
? <MessageSquare className="h-3.5 w-3.5" />
: <GraphStatusIcon status={step.status} />}
</div>
</div>
</div>
<StepDetailCard step={step} />
</div>
</div>
)})}
{shouldShowTrailingThinking && (
<div className="mt-0.5">
<div className="pl-3" style={{ marginLeft: `${TOOL_ROW_EXTRA_INDENT_PX}px` }}>
<div className="ml-3 h-1 w-px bg-border" />
</div>
<div
className="flex items-center gap-0.5"
data-testid="chat-execution-step-thinking-trailing"
style={{ marginLeft: `${TOOL_ROW_EXTRA_INDENT_PX}px` }}
>
<div className="w-6 shrink-0" />
<div className="min-w-0 flex-1 text-sm text-muted-foreground">
<span className="font-medium">{t('executionGraph.thinkingLabel')}</span>
<AnimatedDots className="ml-1 inline-flex text-sm" />
</div>
</div>
</div>
)}
</div>
</div>
);
}