Files
Codewalkers/apps/web/src/components/execution/PhaseGraph.tsx
Lukas May 9f88d5b433 feat: Replace flat phase sidebar with vertical execution graph
Phases are now grouped by dependency depth using groupPhasesByDependencyLevel.
Single-phase layers render as compact nodes, multi-phase layers are wrapped in
a dashed "PARALLEL" container. Connectors between layers turn green when prior
layers are all completed. Staggered entrance animation per layer.
2026-03-04 05:44:23 +01:00

248 lines
7.9 KiB
TypeScript

import { useMemo } from "react";
import { Loader2 } from "lucide-react";
import {
groupPhasesByDependencyLevel,
type DependencyEdge,
} from "@codewalk-district/shared";
import { StatusBadge } from "@/components/StatusBadge";
import { mapEntityStatus, type StatusVariant } from "@/components/StatusDot";
import { cn } from "@/lib/utils";
import type { PhaseData } from "./ExecutionContext";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface PhaseGraphProps {
phases: PhaseData[];
dependencyEdges: DependencyEdge[];
taskCountsByPhase: Record<string, { complete: number; total: number }>;
activePhaseId: string | null;
onSelectPhase: (id: string) => void;
detailAgentByPhase: Map<string, { status: string }>;
allDisplayIndices: Map<string, number>;
}
// ---------------------------------------------------------------------------
// Styling maps
// ---------------------------------------------------------------------------
const railDotClasses: Record<StatusVariant, string> = {
active: "bg-status-active-dot",
success: "bg-status-success-dot",
warning: "bg-status-warning-dot",
error: "bg-status-error-dot",
neutral: "bg-muted-foreground/30",
urgent: "bg-status-urgent-dot",
};
const selectedRingClasses: Record<StatusVariant, string> = {
active: "ring-status-active-dot/40",
success: "ring-status-success-dot/40",
warning: "ring-status-warning-dot/40",
error: "ring-status-error-dot/40",
neutral: "ring-border",
urgent: "ring-status-urgent-dot/40",
};
// ---------------------------------------------------------------------------
// PhaseGraph — vertical execution graph ordered by dependency depth
// ---------------------------------------------------------------------------
export function PhaseGraph({
phases,
dependencyEdges,
taskCountsByPhase,
activePhaseId,
onSelectPhase,
detailAgentByPhase,
allDisplayIndices,
}: PhaseGraphProps) {
const columns = useMemo(
() => groupPhasesByDependencyLevel(phases, dependencyEdges),
[phases, dependencyEdges],
);
if (columns.length === 0) return null;
return (
<div className="relative">
{columns.map((col, colIdx) => {
const isParallel = col.phases.length > 1;
const isFirst = colIdx === 0;
// Connector coloring: green if all prior layers completed
const prevAllCompleted =
colIdx > 0 &&
columns
.slice(0, colIdx)
.every((c) =>
c.phases.every(
(p) => mapEntityStatus(p.status) === "success",
),
);
return (
<div
key={col.depth}
style={{
animation: "graph-layer-enter 0.25s ease-out both",
animationDelay: `${colIdx * 40}ms`,
}}
>
{/* Connector line between layers */}
{!isFirst && (
<LayerConnector completed={prevAllCompleted} />
)}
{isParallel ? (
<div className="relative rounded-lg border border-dashed border-border/60 px-1 pb-1 pt-3.5">
<span className="absolute -top-2.5 left-2.5 rounded-sm bg-background px-1.5 py-px text-[9px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/50">
Parallel &middot; {col.phases.length}
</span>
<div className="space-y-0.5">
{col.phases.map((phase) => (
<GraphNode
key={phase.id}
phase={phase}
taskCount={
taskCountsByPhase[phase.id] ?? {
complete: 0,
total: 0,
}
}
isSelected={phase.id === activePhaseId}
onClick={() => onSelectPhase(phase.id)}
detailAgent={
detailAgentByPhase.get(phase.id) ?? null
}
displayIndex={
allDisplayIndices.get(phase.id) ?? 0
}
/>
))}
</div>
</div>
) : (
<GraphNode
phase={col.phases[0]}
taskCount={
taskCountsByPhase[col.phases[0].id] ?? {
complete: 0,
total: 0,
}
}
isSelected={col.phases[0].id === activePhaseId}
onClick={() => onSelectPhase(col.phases[0].id)}
detailAgent={
detailAgentByPhase.get(col.phases[0].id) ?? null
}
displayIndex={
allDisplayIndices.get(col.phases[0].id) ?? 0
}
/>
)}
</div>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// GraphNode — single phase in the execution graph
// ---------------------------------------------------------------------------
function GraphNode({
phase,
taskCount,
isSelected,
onClick,
detailAgent,
displayIndex,
}: {
phase: PhaseData;
taskCount: { complete: number; total: number };
isSelected: boolean;
onClick: () => void;
detailAgent: { status: string } | null;
displayIndex: number;
}) {
const variant = mapEntityStatus(phase.status);
const isDetailing =
detailAgent?.status === "running" ||
detailAgent?.status === "waiting_for_input";
const detailDone = detailAgent?.status === "idle";
return (
<button
className={cn(
"group flex w-full items-start gap-2.5 rounded-md px-2 py-2 text-left transition-all",
isSelected
? cn("bg-accent shadow-xs ring-1", selectedRingClasses[variant])
: "hover:bg-accent/40",
)}
onClick={onClick}
>
{/* Rail dot */}
<div
className={cn(
"mt-[5px] h-2.5 w-2.5 shrink-0 rounded-full transition-all",
railDotClasses[variant],
variant === "active" && "animate-status-pulse",
isSelected && "scale-125",
)}
/>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] font-bold leading-none text-muted-foreground/60">
{displayIndex}
</span>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium leading-tight">
{phase.name}
</span>
<StatusBadge
status={phase.status}
className="shrink-0 text-[9px]"
/>
</div>
<div className="mt-0.5 pl-[18px] text-[11px] text-muted-foreground">
{isDetailing ? (
<span className="flex items-center gap-1 text-status-active-fg">
<Loader2 className="h-3 w-3 animate-spin" />
Detailing
</span>
) : detailDone && taskCount.total === 0 ? (
<span className="text-status-warning-fg">Review changes</span>
) : taskCount.total === 0 ? (
<span>No tasks</span>
) : (
<span>
{taskCount.complete}/{taskCount.total} tasks
</span>
)}
</div>
</div>
</button>
);
}
// ---------------------------------------------------------------------------
// LayerConnector — vertical line between dependency layers
// ---------------------------------------------------------------------------
function LayerConnector({ completed }: { completed: boolean }) {
return (
<div className="flex justify-center py-px">
<div
className={cn(
"h-3.5 w-[2px] rounded-full",
completed ? "bg-status-success-dot/50" : "bg-border/70",
)}
/>
</div>
);
}