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.
This commit is contained in:
@@ -5,7 +5,8 @@ import { trpc } from "@/lib/trpc";
|
|||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
import { mapEntityStatus } from "@/components/StatusDot";
|
import { mapEntityStatus } from "@/components/StatusDot";
|
||||||
import { PhaseNumberBadge } from "@/components/DependencyChip";
|
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 { PhaseContentEditor } from "@/components/editor/PhaseContentEditor";
|
||||||
import { ChangeSetBanner } from "@/components/ChangeSetBanner";
|
import { ChangeSetBanner } from "@/components/ChangeSetBanner";
|
||||||
import { Skeleton } from "@/components/Skeleton";
|
import { Skeleton } from "@/components/Skeleton";
|
||||||
@@ -16,7 +17,6 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { sortByPriorityAndQueueTime } from "@codewalk-district/shared";
|
|
||||||
import { useExecutionContext, type FlatTaskEntry } from "./ExecutionContext";
|
import { useExecutionContext, type FlatTaskEntry } from "./ExecutionContext";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -196,7 +196,6 @@ export function PhaseDetailPanel({
|
|||||||
|
|
||||||
const isPendingReview = phase.status === "pending_review";
|
const isPendingReview = phase.status === "pending_review";
|
||||||
|
|
||||||
const sortedTasks = sortByPriorityAndQueueTime(tasks);
|
|
||||||
const hasTasks = tasks.length > 0;
|
const hasTasks = tasks.length > 0;
|
||||||
const isDetailRunning =
|
const isDetailRunning =
|
||||||
detailAgent?.status === "running" ||
|
detailAgent?.status === "running" ||
|
||||||
@@ -406,22 +405,15 @@ export function PhaseDetailPanel({
|
|||||||
<Skeleton key={i} className="h-8 w-full" />
|
<Skeleton key={i} className="h-8 w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : sortedTasks.length === 0 ? (
|
) : tasks.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No tasks yet</p>
|
<p className="text-sm text-muted-foreground">No tasks yet</p>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<TaskGraph
|
||||||
{sortedTasks.map((task, idx) => (
|
tasks={tasks}
|
||||||
<TaskRow
|
taskDepsRaw={taskDepsQuery.data ?? []}
|
||||||
key={task.id}
|
onClickTask={setSelectedTaskId}
|
||||||
task={task}
|
onDeleteTask={(id) => deleteTask.mutate({ id })}
|
||||||
agentName={null}
|
/>
|
||||||
blockedBy={taskDepsMap.get(task.id) ?? []}
|
|
||||||
isLast={idx === sortedTasks.length - 1}
|
|
||||||
onClick={() => setSelectedTaskId(task.id)}
|
|
||||||
onDelete={() => deleteTask.mutate({ id: task.id })}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
205
apps/web/src/components/execution/TaskGraph.tsx
Normal file
205
apps/web/src/components/execution/TaskGraph.tsx
Normal file
@@ -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<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",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 (
|
||||||
|
<div className="relative">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={col.depth}
|
||||||
|
style={{
|
||||||
|
animation: "graph-layer-enter 0.25s ease-out both",
|
||||||
|
animationDelay: `${colIdx * 30}ms`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isFirst && (
|
||||||
|
<TaskLayerConnector completed={prevAllCompleted} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isParallel ? (
|
||||||
|
<div className="relative rounded-lg border border-dashed border-border/60 px-0.5 pb-0.5 pt-3">
|
||||||
|
<span className="absolute -top-2 left-2 rounded-sm bg-background px-1.5 py-px text-[8px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/50">
|
||||||
|
Parallel · {col.phases.length}
|
||||||
|
</span>
|
||||||
|
<div className="space-y-px">
|
||||||
|
{col.phases.map((task) => (
|
||||||
|
<TaskNode
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
|
||||||
|
onClick={() => onClickTask(task.id)}
|
||||||
|
onDelete={
|
||||||
|
onDeleteTask
|
||||||
|
? () => onDeleteTask(task.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TaskNode
|
||||||
|
task={col.phases[0]}
|
||||||
|
|
||||||
|
onClick={() => onClickTask(col.phases[0].id)}
|
||||||
|
onDelete={
|
||||||
|
onDeleteTask
|
||||||
|
? () => onDeleteTask(col.phases[0].id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TaskNode — single task in the execution graph
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function TaskNode({
|
||||||
|
task,
|
||||||
|
onClick,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
task: SerializedTask;
|
||||||
|
onClick: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
}) {
|
||||||
|
const variant = mapEntityStatus(task.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left transition-all hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{/* Rail dot */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2 w-2 shrink-0 rounded-full transition-all",
|
||||||
|
railDotClasses[variant],
|
||||||
|
variant === "active" && "animate-status-pulse",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Task name */}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[13px]">
|
||||||
|
{task.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Status badge */}
|
||||||
|
<StatusBadge
|
||||||
|
status={task.status}
|
||||||
|
className="shrink-0 text-[9px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete button */}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
|
||||||
|
title="Delete task (Shift+click to skip confirmation)"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.shiftKey || window.confirm(`Delete "${task.name}"?`)) {
|
||||||
|
onDelete();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TaskLayerConnector
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function TaskLayerConnector({ completed }: { completed: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-px">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2.5 w-[2px] rounded-full",
|
||||||
|
completed ? "bg-status-success-dot/50" : "bg-border/70",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user