From 2d15dcf3687d39eda5b7028db3df6e1f2eb78ebe Mon Sep 17 00:00:00 2001 From: Lukas May Date: Wed, 4 Mar 2026 08:01:48 +0100 Subject: [PATCH] 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 --- apps/web/src/components/ExecutionTab.tsx | 3 - apps/web/src/components/TaskDetailModal.tsx | 158 ------------ .../components/execution/PhaseDetailPanel.tsx | 13 +- .../src/components/execution/TaskGraph.tsx | 105 +++++--- .../src/components/execution/TaskModal.tsx | 34 --- .../components/execution/TaskSlideOver.tsx | 244 ++++++++++++++++++ apps/web/src/components/execution/index.ts | 1 - .../src/components/pipeline/PipelineTab.tsx | 18 +- apps/web/src/lib/category.ts | 24 ++ 9 files changed, 364 insertions(+), 236 deletions(-) delete mode 100644 apps/web/src/components/TaskDetailModal.tsx delete mode 100644 apps/web/src/components/execution/TaskModal.tsx create mode 100644 apps/web/src/components/execution/TaskSlideOver.tsx create mode 100644 apps/web/src/lib/category.ts diff --git a/apps/web/src/components/ExecutionTab.tsx b/apps/web/src/components/ExecutionTab.tsx index 51118fe..8927735 100644 --- a/apps/web/src/components/ExecutionTab.tsx +++ b/apps/web/src/components/ExecutionTab.tsx @@ -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} /> - ); } @@ -255,7 +253,6 @@ export function ExecutionTab({ - ); } diff --git a/apps/web/src/components/TaskDetailModal.tsx b/apps/web/src/components/TaskDetailModal.tsx deleted file mode 100644 index 9922e07..0000000 --- a/apps/web/src/components/TaskDetailModal.tsx +++ /dev/null @@ -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 ( - { if (!open) onClose(); }}> - - - {task?.name ?? "Task"} - Task details and dependencies - - - {task && ( -
- {/* Metadata grid */} -
-
- Status -
- -
-
-
- Priority -

{task.priority}

-
-
- Phase -

{phaseName}

-
-
- Type -

{task.type}

-
-
- Agent -

- {agentName ?? "Unassigned"} -

-
-
- - {/* Description */} -
-

Description

-

- {task.description ?? "No description"} -

-
- - {/* Dependencies */} -
-

Dependencies

- {dependencies.length === 0 ? ( -

- No dependencies -

- ) : ( -
    - {dependencies.map((dep) => ( -
  • - {dep.name} - -
  • - ))} -
- )} -
- - {/* Dependents (Blocks) */} -
-

Blocks

- {dependents.length === 0 ? ( -

None

- ) : ( -
    - {dependents.map((dep) => ( -
  • - {dep.name} - -
  • - ))} -
- )} -
-
- )} - - - - - -
-
- ); -} diff --git a/apps/web/src/components/execution/PhaseDetailPanel.tsx b/apps/web/src/components/execution/PhaseDetailPanel.tsx index b9add77..0c9a26b 100644 --- a/apps/web/src/components/execution/PhaseDetailPanel.tsx +++ b/apps/web/src/components/execution/PhaseDetailPanel.tsx @@ -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>(); + const countMap = new Map(); 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 ( +
{/* Header */}
@@ -411,12 +416,16 @@ export function PhaseDetailPanel({ deleteTask.mutate({ id })} /> )}
+ +
); } diff --git a/apps/web/src/components/execution/TaskGraph.tsx b/apps/web/src/components/execution/TaskGraph.tsx index 40ec706..d7357e0 100644 --- a/apps/web/src/components/execution/TaskGraph.tsx +++ b/apps/web/src/components/execution/TaskGraph.tsx @@ -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; + /** Agent name per task (empty Map when not wired up) */ + agentNameMap: Map; onClickTask: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; } @@ -41,6 +47,8 @@ const railDotClasses: Record = { export function TaskGraph({ tasks, taskDepsRaw, + blockedByCountMap, + agentNameMap, onClickTask, onDeleteTask, }: TaskGraphProps) { @@ -96,7 +104,8 @@ export function TaskGraph({ onClickTask(task.id)} onDelete={ onDeleteTask @@ -110,7 +119,8 @@ export function TaskGraph({ ) : ( 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 (
- {/* Rail dot */} + {/* Rail dot — aligned to first text line */}
- {/* Task name */} - - {task.name} - + {/* Two-line content */} +
+ {/* Line 1: name + status + delete */} +
+ + {task.name} + + + {onDelete && ( + + )} +
- {/* Status badge */} - - - {/* Delete button */} - {onDelete && ( - - )} + {/* Line 2: category + priority + agent + blocked count */} +
+ + {catConfig.label} + + {priorityColor && ( + + {task.priority} + + )} + {agentName && ( + + {agentName} + + )} + {blockedByCount > 0 && ( + + blocked by {blockedByCount} + + )} +
+
); } diff --git a/apps/web/src/components/execution/TaskModal.tsx b/apps/web/src/components/execution/TaskModal.tsx deleted file mode 100644 index d6c235a..0000000 --- a/apps/web/src/components/execution/TaskModal.tsx +++ /dev/null @@ -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 ( - - ); -} \ No newline at end of file diff --git a/apps/web/src/components/execution/TaskSlideOver.tsx b/apps/web/src/components/execution/TaskSlideOver.tsx new file mode 100644 index 0000000..4b5a6ce --- /dev/null +++ b/apps/web/src/components/execution/TaskSlideOver.tsx @@ -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 ( + + {selectedEntry && task && ( + <> + {/* Backdrop */} + + + {/* Panel */} + + {/* Header */} +
+
+

+ {task.name} +

+

+ {selectedEntry.phaseName} +

+
+ +
+ + {/* Content */} +
+ {/* Metadata grid */} +
+ + + + + + + + + + + {task.type} + + + + {selectedEntry.agentName ?? "Unassigned"} + + +
+ + {/* Description */} +
+

+ {task.description ?? "No description"} +

+
+ + {/* Dependencies */} +
+ {dependencies.length === 0 ? ( +

None

+ ) : ( +
    + {dependencies.map((dep) => ( +
  • + + + {dep.name} + +
  • + ))} +
+ )} +
+ + {/* Blocks */} +
+ {dependents.length === 0 ? ( +

None

+ ) : ( +
    + {dependents.map((dep) => ( +
  • + + + {dep.name} + +
  • + ))} +
+ )} +
+
+ + {/* Footer */} +
+ + +
+
+ + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +function MetaField({ + label, + span, + children, +}: { + label: string; + span?: number; + children: React.ReactNode; +}) { + return ( +
+ {label} +
{children}
+
+ ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function CategoryBadge({ category }: { category: string }) { + const config = getCategoryConfig(category); + return ( + + {config.label} + + ); +} + +function PriorityText({ priority }: { priority: string }) { + const color = + priority === "high" + ? "text-status-error-fg" + : priority === "medium" + ? "text-status-warning-fg" + : "text-muted-foreground"; + return ( + {priority} + ); +} diff --git a/apps/web/src/components/execution/index.ts b/apps/web/src/components/execution/index.ts index 4dc770b..27e31e0 100644 --- a/apps/web/src/components/execution/index.ts +++ b/apps/web/src/components/execution/index.ts @@ -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"; diff --git a/apps/web/src/components/pipeline/PipelineTab.tsx b/apps/web/src/components/pipeline/PipelineTab.tsx index 07681c0..45b8ab9 100644 --- a/apps/web/src/components/pipeline/PipelineTab.tsx +++ b/apps/web/src/components/pipeline/PipelineTab.tsx @@ -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 ( - - +
+ + +
); } @@ -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] ?? []; diff --git a/apps/web/src/lib/category.ts b/apps/web/src/lib/category.ts new file mode 100644 index 0000000..f898569 --- /dev/null +++ b/apps/web/src/lib/category.ts @@ -0,0 +1,24 @@ +import type { StatusVariant } from "@/components/StatusDot"; + +interface CategoryConfig { + label: string; + variant: StatusVariant; +} + +const categoryMap: Record = { + 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; +}