import { useNavigate } from "react-router";
import type { ChainStep, ContractPhase } from "../../lib/api";
import { PhaseProgressBarCompact } from "../contracts/PhaseProgressBar";
interface StepDiagramProps {
steps: ChainStep[];
}
const statusBorderColors: Record<string, string> = {
pending: "border-[#555]",
running: "border-yellow-400",
passed: "border-green-400",
failed: "border-red-400",
};
const statusDotColors: Record<string, string> = {
pending: "bg-[#555]",
running: "bg-yellow-400",
passed: "bg-green-400",
failed: "bg-red-400",
};
/**
* Assign depth to each step via topological sort.
* Steps with no dependsOn = depth 0. Steps depending only on depth-0 = depth 1. Etc.
*/
function assignDepths(steps: ChainStep[]): Map<string, number> {
const depths = new Map<string, number>();
const stepMap = new Map(steps.map((s) => [s.id, s]));
function getDepth(id: string): number {
if (depths.has(id)) return depths.get(id)!;
const step = stepMap.get(id);
if (!step || !step.dependsOn || step.dependsOn.length === 0) {
depths.set(id, 0);
return 0;
}
const maxParent = Math.max(
...step.dependsOn.map((depId) => getDepth(depId))
);
const d = maxParent + 1;
depths.set(id, d);
return d;
}
for (const step of steps) {
getDepth(step.id);
}
return depths;
}
export function StepDiagram({ steps }: StepDiagramProps) {
const navigate = useNavigate();
if (steps.length === 0) {
return (
<p className="font-mono text-xs text-[#7788aa]">No steps to display.</p>
);
}
const depths = assignDepths(steps);
const maxDepth = Math.max(...Array.from(depths.values()));
// Group steps by depth
const levels: ChainStep[][] = [];
for (let d = 0; d <= maxDepth; d++) {
levels.push(
steps
.filter((s) => depths.get(s.id) === d)
.sort((a, b) => a.orderIndex - b.orderIndex)
);
}
// Build position map for connectors
const stepPositions = new Map<string, { level: number; index: number }>();
levels.forEach((level, li) => {
level.forEach((step, si) => {
stepPositions.set(step.id, { level: li, index: si });
});
});
return (
<div className="space-y-3">
{levels.map((level, li) => (
<div key={li} className="flex items-start gap-2 flex-wrap">
{li > 0 && (
<div className="w-full flex justify-center mb-1">
<div className="w-px h-3 bg-[rgba(117,170,252,0.3)]" />
</div>
)}
{level.map((step) => {
const borderColor =
statusBorderColors[step.status] || "border-[#555]";
const dotColor = statusDotColors[step.status] || "bg-[#555]";
const summary = step.contractSummary;
const hasContract = !!step.contractId;
return (
<div
key={step.id}
className={`
border ${borderColor} bg-[rgba(0,0,0,0.2)] p-2 min-w-[180px] max-w-[220px]
${hasContract ? "cursor-pointer hover:bg-[rgba(117,170,252,0.05)]" : ""}
transition-colors
`}
onClick={() => {
if (hasContract) navigate(`/contracts/${step.contractId}`);
}}
title={hasContract ? "View contract" : undefined}
>
<div className="flex items-center gap-1.5 mb-1">
<div className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
<span className="font-mono text-[11px] text-[#dbe7ff] truncate flex-1">
{step.name}
</span>
{hasContract && (
<span className="font-mono text-[9px] text-[#75aafc] shrink-0">
→
</span>
)}
</div>
{summary && (
<>
<div className="mb-1">
<PhaseProgressBarCompact
currentPhase={summary.phase as ContractPhase}
/>
</div>
<div className="font-mono text-[9px] text-[#7788aa]">
{summary.tasksDone}/{summary.taskCount} tasks
{summary.tasksRunning > 0 && (
<span className="text-yellow-400 ml-1">
{summary.tasksRunning} running
</span>
)}
{summary.tasksFailed > 0 && (
<span className="text-red-400 ml-1">
{summary.tasksFailed} failed
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
))}
</div>
);
}