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:
Lukas May
2026-03-04 07:20:44 +01:00
parent 9f88d5b433
commit af092ba16a
2 changed files with 214 additions and 17 deletions

View File

@@ -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>

View 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 &middot; {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>
);
}