From af092ba16afe02a13de8154b6d476eca3b1eae56 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Wed, 4 Mar 2026 07:20:44 +0100 Subject: [PATCH] feat: Add task execution graph within phase detail panel Tasks are now grouped by dependency depth using the same groupPhasesByDependencyLevel utility. Parallel tasks are wrapped in dashed containers, sequential layers connected by status-aware lines. Replaces the flat TaskRow list and DependencyIndicator callout bars. --- .../components/execution/PhaseDetailPanel.tsx | 26 +-- .../src/components/execution/TaskGraph.tsx | 205 ++++++++++++++++++ 2 files changed, 214 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/execution/TaskGraph.tsx diff --git a/apps/web/src/components/execution/PhaseDetailPanel.tsx b/apps/web/src/components/execution/PhaseDetailPanel.tsx index 99f3995..b9add77 100644 --- a/apps/web/src/components/execution/PhaseDetailPanel.tsx +++ b/apps/web/src/components/execution/PhaseDetailPanel.tsx @@ -5,7 +5,8 @@ import { trpc } from "@/lib/trpc"; import { StatusBadge } from "@/components/StatusBadge"; import { mapEntityStatus } from "@/components/StatusDot"; import { PhaseNumberBadge } from "@/components/DependencyChip"; -import { TaskRow, type SerializedTask } from "@/components/TaskRow"; +import { type SerializedTask } from "@/components/TaskRow"; +import { TaskGraph } from "./TaskGraph"; import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor"; import { ChangeSetBanner } from "@/components/ChangeSetBanner"; import { Skeleton } from "@/components/Skeleton"; @@ -16,7 +17,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { sortByPriorityAndQueueTime } from "@codewalk-district/shared"; import { useExecutionContext, type FlatTaskEntry } from "./ExecutionContext"; import { cn } from "@/lib/utils"; @@ -196,7 +196,6 @@ export function PhaseDetailPanel({ const isPendingReview = phase.status === "pending_review"; - const sortedTasks = sortByPriorityAndQueueTime(tasks); const hasTasks = tasks.length > 0; const isDetailRunning = detailAgent?.status === "running" || @@ -406,22 +405,15 @@ export function PhaseDetailPanel({ ))} - ) : sortedTasks.length === 0 ? ( + ) : tasks.length === 0 ? (

No tasks yet

) : ( -
- {sortedTasks.map((task, idx) => ( - setSelectedTaskId(task.id)} - onDelete={() => deleteTask.mutate({ id: task.id })} - /> - ))} -
+ deleteTask.mutate({ id })} + /> )} diff --git a/apps/web/src/components/execution/TaskGraph.tsx b/apps/web/src/components/execution/TaskGraph.tsx new file mode 100644 index 0000000..40ec706 --- /dev/null +++ b/apps/web/src/components/execution/TaskGraph.tsx @@ -0,0 +1,205 @@ +import { useMemo } from "react"; +import { X } 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 { SerializedTask } from "@/components/TaskRow"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface TaskGraphProps { + tasks: SerializedTask[]; + /** Raw edges: { taskId, dependsOn: string[] } */ + taskDepsRaw: Array<{ taskId: string; dependsOn: string[] }>; + onClickTask: (taskId: string) => void; + onDeleteTask?: (taskId: string) => void; +} + +// --------------------------------------------------------------------------- +// 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", +}; + +// --------------------------------------------------------------------------- +// TaskGraph — vertical execution graph for tasks within a phase +// --------------------------------------------------------------------------- + +export function TaskGraph({ + tasks, + taskDepsRaw, + onClickTask, + onDeleteTask, +}: TaskGraphProps) { + // Flatten task dep edges to DependencyEdge format for reuse of groupPhasesByDependencyLevel + const columns = useMemo(() => { + const edges: DependencyEdge[] = []; + for (const raw of taskDepsRaw) { + for (const depId of raw.dependsOn) { + edges.push({ phaseId: raw.taskId, dependsOnPhaseId: depId }); + } + } + // groupPhasesByDependencyLevel accepts anything with { id, createdAt } + return groupPhasesByDependencyLevel(tasks, edges); + }, [tasks, taskDepsRaw]); + + if (columns.length === 0) return null; + + return ( +
+ {columns.map((col, colIdx) => { + const isParallel = col.phases.length > 1; + const isFirst = colIdx === 0; + + const prevAllCompleted = + colIdx > 0 && + columns + .slice(0, colIdx) + .every((c) => + c.phases.every( + (t) => mapEntityStatus(t.status) === "success", + ), + ); + + return ( +
+ {!isFirst && ( + + )} + + {isParallel ? ( +
+ + Parallel · {col.phases.length} + +
+ {col.phases.map((task) => ( + onClickTask(task.id)} + onDelete={ + onDeleteTask + ? () => onDeleteTask(task.id) + : undefined + } + /> + ))} +
+
+ ) : ( + onClickTask(col.phases[0].id)} + onDelete={ + onDeleteTask + ? () => onDeleteTask(col.phases[0].id) + : undefined + } + /> + )} +
+ ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// TaskNode — single task in the execution graph +// --------------------------------------------------------------------------- + +function TaskNode({ + task, + onClick, + onDelete, +}: { + task: SerializedTask; + onClick: () => void; + onDelete?: () => void; +}) { + const variant = mapEntityStatus(task.status); + + return ( +
+ {/* Rail dot */} +
+ + {/* Task name */} + + {task.name} + + + {/* Status badge */} + + + {/* Delete button */} + {onDelete && ( + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// TaskLayerConnector +// --------------------------------------------------------------------------- + +function TaskLayerConnector({ completed }: { completed: boolean }) { + return ( +
+
+
+ ); +}