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.
248 lines
7.9 KiB
TypeScript
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 · {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>
|
|
);
|
|
}
|