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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
244
apps/web/src/components/execution/TaskSlideOver.tsx
Normal file
244
apps/web/src/components/execution/TaskSlideOver.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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] ?? [];
|
||||
|
||||
24
apps/web/src/lib/category.ts
Normal file
24
apps/web/src/lib/category.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user