feat: Replace task modal with slide-over panel and enrich task nodes

- Add category color mapping utility (lib/category.ts)
- Enhance TaskNode to 2-line layout with category badge, priority, blocked count
- Create TaskSlideOver panel that animates in from right within phase detail
- Remove centered TaskModal/TaskDetailModal in favor of contextual slide-over
- Update PipelineTab to also use TaskSlideOver
This commit is contained in:
Lukas May
2026-03-04 08:01:48 +01:00
parent 5a5d48aecc
commit 2d15dcf368
9 changed files with 364 additions and 236 deletions

View File

@@ -6,7 +6,6 @@ import {
ExecutionProvider,
PhaseActions,
PlanSection,
TaskModal,
type PhaseData,
} from "@/components/execution";
import { PhaseGraph } from "@/components/execution/PhaseGraph";
@@ -180,7 +179,6 @@ export function ExecutionTab({
phases={sortedPhases}
onAddPhase={handleStartAdd}
/>
<TaskModal />
</ExecutionProvider>
);
}
@@ -255,7 +253,6 @@ export function ExecutionTab({
</div>
</div>
<TaskModal />
</ExecutionProvider>
);
}

View File

@@ -1,158 +0,0 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusDot } from "@/components/StatusDot";
import type { SerializedTask } from "@/components/TaskRow";
interface DependencyInfo {
name: string;
status: string;
}
interface TaskDetailModalProps {
task: SerializedTask | null;
phaseName: string;
agentName: string | null;
dependencies: DependencyInfo[];
dependents: DependencyInfo[];
onClose: () => void;
onQueueTask: (taskId: string) => void;
onStopTask: (taskId: string) => void;
}
export function TaskDetailModal({
task,
phaseName,
agentName,
dependencies,
dependents,
onClose,
onQueueTask,
onStopTask,
}: TaskDetailModalProps) {
const allDependenciesComplete =
dependencies.length === 0 ||
dependencies.every((d) => d.status === "completed");
const canQueue = task !== null && task.status === "pending" && allDependenciesComplete;
const canStop = task !== null && task.status === "in_progress";
return (
<Dialog open={task !== null} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{task?.name ?? "Task"}</DialogTitle>
<DialogDescription>Task details and dependencies</DialogDescription>
</DialogHeader>
{task && (
<div className="space-y-4">
{/* Metadata grid */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">Status</span>
<div className="mt-1">
<StatusBadge status={task.status} />
</div>
</div>
<div>
<span className="text-muted-foreground">Priority</span>
<p className="mt-1 font-medium capitalize">{task.priority}</p>
</div>
<div>
<span className="text-muted-foreground">Phase</span>
<p className="mt-1 font-medium">{phaseName}</p>
</div>
<div>
<span className="text-muted-foreground">Type</span>
<p className="mt-1 font-medium">{task.type}</p>
</div>
<div className="col-span-2">
<span className="text-muted-foreground">Agent</span>
<p className="mt-1 font-medium">
{agentName ?? "Unassigned"}
</p>
</div>
</div>
{/* Description */}
<div>
<h4 className="mb-1 text-sm font-medium">Description</h4>
<p className="text-sm text-muted-foreground">
{task.description ?? "No description"}
</p>
</div>
{/* Dependencies */}
<div>
<h4 className="mb-1 text-sm font-medium">Dependencies</h4>
{dependencies.length === 0 ? (
<p className="text-sm text-muted-foreground">
No dependencies
</p>
) : (
<ul className="space-y-1">
{dependencies.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<span>{dep.name}</span>
<StatusDot status={dep.status} size="md" />
</li>
))}
</ul>
)}
</div>
{/* Dependents (Blocks) */}
<div>
<h4 className="mb-1 text-sm font-medium">Blocks</h4>
{dependents.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1">
{dependents.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<span>{dep.name}</span>
<StatusDot status={dep.status} size="md" />
</li>
))}
</ul>
)}
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
size="sm"
disabled={!canQueue}
onClick={() => task && onQueueTask(task.id)}
>
Queue Task
</Button>
<Button
variant="destructive"
size="sm"
disabled={!canStop}
onClick={() => task && onStopTask(task.id)}
>
Stop Task
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -7,6 +7,7 @@ import { mapEntityStatus } from "@/components/StatusDot";
import { PhaseNumberBadge } from "@/components/DependencyChip";
import { type SerializedTask } from "@/components/TaskRow";
import { TaskGraph } from "./TaskGraph";
import { TaskSlideOver } from "./TaskSlideOver";
import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor";
import { ChangeSetBanner } from "@/components/ChangeSetBanner";
import { Skeleton } from "@/components/Skeleton";
@@ -124,8 +125,9 @@ export function PhaseDetailPanel({
// Task-level dependencies
const taskDepsQuery = trpc.listPhaseTaskDependencies.useQuery({ phaseId: phase.id });
const taskDepsMap = useMemo(() => {
const { taskDepsMap, blockedByCountMap } = useMemo(() => {
const map = new Map<string, Array<{ name: string; status: string }>>();
const countMap = new Map<string, number>();
const edges = taskDepsQuery.data ?? [];
for (const edge of edges) {
const blockers = edge.dependsOn
@@ -135,8 +137,10 @@ export function PhaseDetailPanel({
})
.filter(Boolean) as Array<{ name: string; status: string }>;
if (blockers.length > 0) map.set(edge.taskId, blockers);
const incomplete = blockers.filter((b) => b.status !== "completed").length;
if (incomplete > 0) countMap.set(edge.taskId, incomplete);
}
return map;
return { taskDepsMap: map, blockedByCountMap: countMap };
}, [taskDepsQuery.data, tasks]);
// Resolve dependency IDs to phase objects
@@ -206,6 +210,7 @@ export function PhaseDetailPanel({
detailAgent?.status === "idle" && !!latestChangeSet;
return (
<div className="relative overflow-hidden">
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
@@ -411,12 +416,16 @@ export function PhaseDetailPanel({
<TaskGraph
tasks={tasks}
taskDepsRaw={taskDepsQuery.data ?? []}
blockedByCountMap={blockedByCountMap}
agentNameMap={new Map()}
onClickTask={setSelectedTaskId}
onDeleteTask={(id) => deleteTask.mutate({ id })}
/>
)}
</div>
</div>
<TaskSlideOver />
</div>
);
}

View File

@@ -4,8 +4,10 @@ import {
groupPhasesByDependencyLevel,
type DependencyEdge,
} from "@codewalk-district/shared";
import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/StatusBadge";
import { mapEntityStatus, type StatusVariant } from "@/components/StatusDot";
import { getCategoryConfig } from "@/lib/category";
import { cn } from "@/lib/utils";
import type { SerializedTask } from "@/components/TaskRow";
@@ -17,6 +19,10 @@ interface TaskGraphProps {
tasks: SerializedTask[];
/** Raw edges: { taskId, dependsOn: string[] } */
taskDepsRaw: Array<{ taskId: string; dependsOn: string[] }>;
/** Count of incomplete dependencies per task */
blockedByCountMap: Map<string, number>;
/** Agent name per task (empty Map when not wired up) */
agentNameMap: Map<string, string>;
onClickTask: (taskId: string) => void;
onDeleteTask?: (taskId: string) => void;
}
@@ -41,6 +47,8 @@ const railDotClasses: Record<StatusVariant, string> = {
export function TaskGraph({
tasks,
taskDepsRaw,
blockedByCountMap,
agentNameMap,
onClickTask,
onDeleteTask,
}: TaskGraphProps) {
@@ -96,7 +104,8 @@ export function TaskGraph({
<TaskNode
key={task.id}
task={task}
blockedByCount={blockedByCountMap.get(task.id) ?? 0}
agentName={agentNameMap.get(task.id)}
onClick={() => onClickTask(task.id)}
onDelete={
onDeleteTask
@@ -110,7 +119,8 @@ export function TaskGraph({
) : (
<TaskNode
task={col.phases[0]}
blockedByCount={blockedByCountMap.get(col.phases[0].id) ?? 0}
agentName={agentNameMap.get(col.phases[0].id)}
onClick={() => onClickTask(col.phases[0].id)}
onDelete={
onDeleteTask
@@ -132,57 +142,92 @@ export function TaskGraph({
function TaskNode({
task,
blockedByCount,
agentName,
onClick,
onDelete,
}: {
task: SerializedTask;
blockedByCount: number;
agentName?: string;
onClick: () => void;
onDelete?: () => void;
}) {
const variant = mapEntityStatus(task.status);
const catConfig = getCategoryConfig(task.category);
const priorityColor =
task.priority === "high"
? "text-status-error-fg"
: task.priority === "medium"
? "text-status-warning-fg"
: null;
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",
"group flex w-full cursor-pointer gap-2 rounded-md px-2 py-1.5 text-left transition-all hover:bg-accent/50",
)}
onClick={onClick}
>
{/* Rail dot */}
{/* Rail dot — aligned to first text line */}
<div
className={cn(
"h-2 w-2 shrink-0 rounded-full transition-all",
"mt-[5px] 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>
{/* Two-line content */}
<div className="min-w-0 flex-1">
{/* Line 1: name + status + delete */}
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-[13px]">
{task.name}
</span>
<StatusBadge
status={task.status}
className="shrink-0 text-[9px]"
/>
{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>
{/* 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>
)}
{/* Line 2: category + priority + agent + blocked count */}
<div className="mt-0.5 flex items-center gap-1.5">
<Badge variant={catConfig.variant} size="xs">
{catConfig.label}
</Badge>
{priorityColor && (
<span className={cn("text-[10px] font-medium capitalize", priorityColor)}>
{task.priority}
</span>
)}
{agentName && (
<span className="truncate text-[10px] text-muted-foreground">
{agentName}
</span>
)}
{blockedByCount > 0 && (
<span className="text-[10px] text-muted-foreground">
blocked by {blockedByCount}
</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,34 +0,0 @@
import { useCallback } from "react";
import { TaskDetailModal } from "@/components/TaskDetailModal";
import { useExecutionContext } from "./ExecutionContext";
import { trpc } from "@/lib/trpc";
export function TaskModal() {
const { selectedEntry, setSelectedTaskId } = useExecutionContext();
const queueTaskMutation = trpc.queueTask.useMutation();
const handleQueueTask = useCallback(
(taskId: string) => {
queueTaskMutation.mutate({ taskId });
setSelectedTaskId(null);
},
[queueTaskMutation, setSelectedTaskId],
);
const handleClose = useCallback(() => {
setSelectedTaskId(null);
}, [setSelectedTaskId]);
return (
<TaskDetailModal
task={selectedEntry?.task ?? null}
phaseName={selectedEntry?.phaseName ?? ""}
agentName={selectedEntry?.agentName ?? null}
dependencies={selectedEntry?.blockedBy ?? []}
dependents={selectedEntry?.dependents ?? []}
onClose={handleClose}
onQueueTask={handleQueueTask}
onStopTask={handleClose}
/>
);
}

View File

@@ -0,0 +1,244 @@
import { useCallback, useEffect } from "react";
import { motion, AnimatePresence } from "motion/react";
import { X, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusDot } from "@/components/StatusDot";
import { getCategoryConfig } from "@/lib/category";
import { useExecutionContext } from "./ExecutionContext";
import { trpc } from "@/lib/trpc";
import { cn } from "@/lib/utils";
export function TaskSlideOver() {
const { selectedEntry, setSelectedTaskId } = useExecutionContext();
const queueTaskMutation = trpc.queueTask.useMutation();
const deleteTaskMutation = trpc.deleteTask.useMutation();
const close = useCallback(() => setSelectedTaskId(null), [setSelectedTaskId]);
// Escape key closes
useEffect(() => {
if (!selectedEntry) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [selectedEntry, close]);
const task = selectedEntry?.task ?? null;
const dependencies = selectedEntry?.blockedBy ?? [];
const dependents = selectedEntry?.dependents ?? [];
const allDepsComplete =
dependencies.length === 0 ||
dependencies.every((d) => d.status === "completed");
const canQueue = task !== null && task.status === "pending" && allDepsComplete;
return (
<AnimatePresence>
{selectedEntry && task && (
<>
{/* Backdrop */}
<motion.div
className="absolute inset-0 z-10 bg-background/60 backdrop-blur-[2px]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={close}
/>
{/* Panel */}
<motion.div
className="absolute inset-y-0 right-0 z-20 flex w-full max-w-md flex-col border-l border-border bg-background shadow-xl"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ duration: 0.25, ease: [0, 0, 0.2, 1] }}
>
{/* Header */}
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold leading-snug">
{task.name}
</h3>
<p className="mt-0.5 text-xs text-muted-foreground">
{selectedEntry.phaseName}
</p>
</div>
<button
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={close}
>
<X className="h-4 w-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{/* Metadata grid */}
<div className="grid grid-cols-2 gap-3 text-sm">
<MetaField label="Status">
<StatusBadge status={task.status} />
</MetaField>
<MetaField label="Category">
<CategoryBadge category={task.category} />
</MetaField>
<MetaField label="Priority">
<PriorityText priority={task.priority} />
</MetaField>
<MetaField label="Type">
<span className="font-medium">{task.type}</span>
</MetaField>
<MetaField label="Agent" span={2}>
<span className="font-medium">
{selectedEntry.agentName ?? "Unassigned"}
</span>
</MetaField>
</div>
{/* Description */}
<Section title="Description">
<p className="text-sm text-muted-foreground">
{task.description ?? "No description"}
</p>
</Section>
{/* Dependencies */}
<Section title="Blocked By">
{dependencies.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1.5">
{dependencies.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate">
{dep.name}
</span>
</li>
))}
</ul>
)}
</Section>
{/* Blocks */}
<Section title="Blocks">
{dependents.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1.5">
{dependents.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate">
{dep.name}
</span>
</li>
))}
</ul>
)}
</Section>
</div>
{/* Footer */}
<div className="flex items-center gap-2 border-t border-border px-5 py-3">
<Button
variant="outline"
size="sm"
disabled={!canQueue}
onClick={() => {
queueTaskMutation.mutate({ taskId: task.id });
close();
}}
>
Queue Task
</Button>
<Button
variant="destructive"
size="sm"
className="gap-1.5"
onClick={(e) => {
if (
e.shiftKey ||
window.confirm(`Delete "${task.name}"?`)
) {
deleteTaskMutation.mutate({ id: task.id });
close();
}
}}
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------
function MetaField({
label,
span,
children,
}: {
label: string;
span?: number;
children: React.ReactNode;
}) {
return (
<div className={span === 2 ? "col-span-2" : undefined}>
<span className="text-xs text-muted-foreground">{label}</span>
<div className="mt-1">{children}</div>
</div>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div>
<h4 className="mb-1.5 text-sm font-medium">{title}</h4>
{children}
</div>
);
}
function CategoryBadge({ category }: { category: string }) {
const config = getCategoryConfig(category);
return (
<Badge variant={config.variant} size="xs">
{config.label}
</Badge>
);
}
function PriorityText({ priority }: { priority: string }) {
const color =
priority === "high"
? "text-status-error-fg"
: priority === "medium"
? "text-status-warning-fg"
: "text-muted-foreground";
return (
<span className={cn("font-medium capitalize", color)}>{priority}</span>
);
}

View File

@@ -3,5 +3,4 @@ export { PlanSection } from "./PlanSection";
export { PhaseActions } from "./PhaseActions";
export { PhaseGraph } from "./PhaseGraph";
export { PhaseDetailPanel, PhaseDetailEmpty } from "./PhaseDetailPanel";
export { TaskModal } from "./TaskModal";
export type { TaskCounts, FlatTaskEntry, PhaseData } from "./ExecutionContext";

View File

@@ -9,11 +9,11 @@ import {
import {
ExecutionProvider,
useExecutionContext,
TaskModal,
PlanSection,
type PhaseData,
type FlatTaskEntry,
} from "@/components/execution";
import { TaskSlideOver } from "@/components/execution/TaskSlideOver";
import type { SerializedTask } from "@/components/TaskRow";
import { PipelineGraph } from "./PipelineGraph";
import type { DetailAgentInfo } from "./PipelinePhaseGroup";
@@ -27,12 +27,14 @@ interface PipelineTabProps {
export function PipelineTab({ initiativeId, phases, phasesLoading }: PipelineTabProps) {
return (
<ExecutionProvider>
<PipelineTabInner
initiativeId={initiativeId}
phases={phases}
phasesLoading={phasesLoading}
/>
<TaskModal />
<div className="relative overflow-hidden">
<PipelineTabInner
initiativeId={initiativeId}
phases={phases}
phasesLoading={phasesLoading}
/>
<TaskSlideOver />
</div>
</ExecutionProvider>
);
}
@@ -119,7 +121,7 @@ function PipelineTabInner({ initiativeId, phases, phasesLoading }: PipelineTabPr
const queueAll = trpc.queueAllPhases.useMutation();
// Register tasks with ExecutionContext for TaskModal
// Register tasks with ExecutionContext for TaskSlideOver
useEffect(() => {
for (const phase of phases) {
const phaseTasks = tasksByPhase[phase.id] ?? [];

View File

@@ -0,0 +1,24 @@
import type { StatusVariant } from "@/components/StatusDot";
interface CategoryConfig {
label: string;
variant: StatusVariant;
}
const categoryMap: Record<string, CategoryConfig> = {
execute: { label: "Execute", variant: "active" },
research: { label: "Research", variant: "neutral" },
discuss: { label: "Discuss", variant: "warning" },
plan: { label: "Plan", variant: "urgent" },
detail: { label: "Detail", variant: "urgent" },
refine: { label: "Refine", variant: "warning" },
verify: { label: "Verify", variant: "success" },
merge: { label: "Merge", variant: "success" },
review: { label: "Review", variant: "error" },
};
const fallback: CategoryConfig = { label: "Unknown", variant: "neutral" };
export function getCategoryConfig(category: string): CategoryConfig {
return categoryMap[category] ?? fallback;
}