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.
This commit is contained in:
@@ -9,7 +9,7 @@ import {
|
||||
TaskModal,
|
||||
type PhaseData,
|
||||
} from "@/components/execution";
|
||||
import { PhaseSidebarItem, type PhaseDependencyInfo } from "@/components/execution/PhaseSidebarItem";
|
||||
import { PhaseGraph } from "@/components/execution/PhaseGraph";
|
||||
import {
|
||||
PhaseDetailPanel,
|
||||
PhaseDetailEmpty,
|
||||
@@ -39,22 +39,6 @@ export function ExecutionTab({
|
||||
[phases, dependencyEdges],
|
||||
);
|
||||
|
||||
// Build dependency info map from bulk edges (includes status for visual indicators)
|
||||
const depInfoByPhase = useMemo(() => {
|
||||
const map = new Map<string, PhaseDependencyInfo[]>();
|
||||
const phaseIndex = new Map(sortedPhases.map((p, i) => [p.id, i + 1]));
|
||||
const phaseStatus = new Map(sortedPhases.map((p) => [p.id, p.status]));
|
||||
for (const edge of dependencyEdges) {
|
||||
const depIdx = phaseIndex.get(edge.dependsOnPhaseId);
|
||||
const depStatus = phaseStatus.get(edge.dependsOnPhaseId);
|
||||
if (!depIdx || !depStatus) continue;
|
||||
const existing = map.get(edge.phaseId) ?? [];
|
||||
existing.push({ displayIndex: depIdx, status: depStatus });
|
||||
map.set(edge.phaseId, existing);
|
||||
}
|
||||
return map;
|
||||
}, [dependencyEdges, sortedPhases]);
|
||||
|
||||
// Detail agent tracking: map phaseId → most recent active detail agent
|
||||
const agentsQuery = trpc.listAgents.useQuery();
|
||||
const allAgents = agentsQuery.data ?? [];
|
||||
@@ -228,21 +212,16 @@ export function ExecutionTab({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5 pt-2">
|
||||
{sortedPhases.map((phase, index) => (
|
||||
<PhaseSidebarItem
|
||||
key={phase.id}
|
||||
phase={phase}
|
||||
displayIndex={index + 1}
|
||||
taskCount={
|
||||
taskCountsByPhase[phase.id] ?? { complete: 0, total: 0 }
|
||||
}
|
||||
dependencies={depInfoByPhase.get(phase.id) ?? []}
|
||||
isSelected={phase.id === activePhaseId}
|
||||
onClick={() => setSelectedPhaseId(phase.id)}
|
||||
detailAgent={detailAgentByPhase.get(phase.id) ?? null}
|
||||
/>
|
||||
))}
|
||||
<div className="pt-2">
|
||||
<PhaseGraph
|
||||
phases={sortedPhases}
|
||||
dependencyEdges={dependencyEdges}
|
||||
taskCountsByPhase={taskCountsByPhase}
|
||||
activePhaseId={activePhaseId}
|
||||
onSelectPhase={setSelectedPhaseId}
|
||||
detailAgentByPhase={detailAgentByPhase}
|
||||
allDisplayIndices={allDisplayIndices}
|
||||
/>
|
||||
{isAddingPhase && (
|
||||
<NewPhaseEntry
|
||||
number={nextNumber}
|
||||
|
||||
247
apps/web/src/components/execution/PhaseGraph.tsx
Normal file
247
apps/web/src/components/execution/PhaseGraph.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export { ExecutionProvider, useExecutionContext } from "./ExecutionContext";
|
||||
export { PlanSection } from "./PlanSection";
|
||||
export { PhaseActions } from "./PhaseActions";
|
||||
export { PhaseSidebarItem } from "./PhaseSidebarItem";
|
||||
export { PhaseGraph } from "./PhaseGraph";
|
||||
export { PhaseDetailPanel, PhaseDetailEmpty } from "./PhaseDetailPanel";
|
||||
export { TaskModal } from "./TaskModal";
|
||||
export type { TaskCounts, FlatTaskEntry, PhaseData } from "./ExecutionContext";
|
||||
|
||||
@@ -279,6 +279,11 @@
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes graph-layer-enter {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Global focus-visible styles */
|
||||
*:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
|
||||
Reference in New Issue
Block a user