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 ( +
+
+
+ ); +}