From 9f88d5b433d42d686e5ae3c40c19a6ba3a1a7b4c Mon Sep 17 00:00:00 2001 From: Lukas May Date: Wed, 4 Mar 2026 05:44:23 +0100 Subject: [PATCH] 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. --- apps/web/src/components/ExecutionTab.tsx | 43 +-- .../src/components/execution/PhaseGraph.tsx | 247 ++++++++++++++++++ apps/web/src/components/execution/index.ts | 2 +- apps/web/src/index.css | 5 + 4 files changed, 264 insertions(+), 33 deletions(-) create mode 100644 apps/web/src/components/execution/PhaseGraph.tsx diff --git a/apps/web/src/components/ExecutionTab.tsx b/apps/web/src/components/ExecutionTab.tsx index bedd933..51118fe 100644 --- a/apps/web/src/components/ExecutionTab.tsx +++ b/apps/web/src/components/ExecutionTab.tsx @@ -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(); - 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({ ))} ) : ( -
- {sortedPhases.map((phase, index) => ( - setSelectedPhaseId(phase.id)} - detailAgent={detailAgentByPhase.get(phase.id) ?? null} - /> - ))} +
+ {isAddingPhase && ( ; + activePhaseId: string | null; + onSelectPhase: (id: string) => void; + detailAgentByPhase: Map; + allDisplayIndices: Map; +} + +// --------------------------------------------------------------------------- +// Styling maps +// --------------------------------------------------------------------------- + +const railDotClasses: Record = { + 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 = { + 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 ( +
+ {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 ( +
+ {/* Connector line between layers */} + {!isFirst && ( + + )} + + {isParallel ? ( +
+ + Parallel · {col.phases.length} + +
+ {col.phases.map((phase) => ( + onSelectPhase(phase.id)} + detailAgent={ + detailAgentByPhase.get(phase.id) ?? null + } + displayIndex={ + allDisplayIndices.get(phase.id) ?? 0 + } + /> + ))} +
+
+ ) : ( + onSelectPhase(col.phases[0].id)} + detailAgent={ + detailAgentByPhase.get(col.phases[0].id) ?? null + } + displayIndex={ + allDisplayIndices.get(col.phases[0].id) ?? 0 + } + /> + )} +
+ ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( + + ); +} + +// --------------------------------------------------------------------------- +// LayerConnector — vertical line between dependency layers +// --------------------------------------------------------------------------- + +function LayerConnector({ completed }: { completed: boolean }) { + return ( +
+
+
+ ); +} diff --git a/apps/web/src/components/execution/index.ts b/apps/web/src/components/execution/index.ts index ecfc58c..4dc770b 100644 --- a/apps/web/src/components/execution/index.ts +++ b/apps/web/src/components/execution/index.ts @@ -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"; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 4301c2f..8a40ac7 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -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));