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,
|
ExecutionProvider,
|
||||||
PhaseActions,
|
PhaseActions,
|
||||||
PlanSection,
|
PlanSection,
|
||||||
TaskModal,
|
|
||||||
type PhaseData,
|
type PhaseData,
|
||||||
} from "@/components/execution";
|
} from "@/components/execution";
|
||||||
import { PhaseGraph } from "@/components/execution/PhaseGraph";
|
import { PhaseGraph } from "@/components/execution/PhaseGraph";
|
||||||
@@ -180,7 +179,6 @@ export function ExecutionTab({
|
|||||||
phases={sortedPhases}
|
phases={sortedPhases}
|
||||||
onAddPhase={handleStartAdd}
|
onAddPhase={handleStartAdd}
|
||||||
/>
|
/>
|
||||||
<TaskModal />
|
|
||||||
</ExecutionProvider>
|
</ExecutionProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -255,7 +253,6 @@ export function ExecutionTab({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TaskModal />
|
|
||||||
</ExecutionProvider>
|
</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 { PhaseNumberBadge } from "@/components/DependencyChip";
|
||||||
import { type SerializedTask } from "@/components/TaskRow";
|
import { type SerializedTask } from "@/components/TaskRow";
|
||||||
import { TaskGraph } from "./TaskGraph";
|
import { TaskGraph } from "./TaskGraph";
|
||||||
|
import { TaskSlideOver } from "./TaskSlideOver";
|
||||||
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";
|
||||||
@@ -124,8 +125,9 @@ export function PhaseDetailPanel({
|
|||||||
|
|
||||||
// Task-level dependencies
|
// Task-level dependencies
|
||||||
const taskDepsQuery = trpc.listPhaseTaskDependencies.useQuery({ phaseId: phase.id });
|
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 map = new Map<string, Array<{ name: string; status: string }>>();
|
||||||
|
const countMap = new Map<string, number>();
|
||||||
const edges = taskDepsQuery.data ?? [];
|
const edges = taskDepsQuery.data ?? [];
|
||||||
for (const edge of edges) {
|
for (const edge of edges) {
|
||||||
const blockers = edge.dependsOn
|
const blockers = edge.dependsOn
|
||||||
@@ -135,8 +137,10 @@ export function PhaseDetailPanel({
|
|||||||
})
|
})
|
||||||
.filter(Boolean) as Array<{ name: string; status: string }>;
|
.filter(Boolean) as Array<{ name: string; status: string }>;
|
||||||
if (blockers.length > 0) map.set(edge.taskId, blockers);
|
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]);
|
}, [taskDepsQuery.data, tasks]);
|
||||||
|
|
||||||
// Resolve dependency IDs to phase objects
|
// Resolve dependency IDs to phase objects
|
||||||
@@ -206,6 +210,7 @@ export function PhaseDetailPanel({
|
|||||||
detailAgent?.status === "idle" && !!latestChangeSet;
|
detailAgent?.status === "idle" && !!latestChangeSet;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="relative overflow-hidden">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -411,12 +416,16 @@ export function PhaseDetailPanel({
|
|||||||
<TaskGraph
|
<TaskGraph
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
taskDepsRaw={taskDepsQuery.data ?? []}
|
taskDepsRaw={taskDepsQuery.data ?? []}
|
||||||
|
blockedByCountMap={blockedByCountMap}
|
||||||
|
agentNameMap={new Map()}
|
||||||
onClickTask={setSelectedTaskId}
|
onClickTask={setSelectedTaskId}
|
||||||
onDeleteTask={(id) => deleteTask.mutate({ id })}
|
onDeleteTask={(id) => deleteTask.mutate({ id })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<TaskSlideOver />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
groupPhasesByDependencyLevel,
|
groupPhasesByDependencyLevel,
|
||||||
type DependencyEdge,
|
type DependencyEdge,
|
||||||
} from "@codewalk-district/shared";
|
} from "@codewalk-district/shared";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
import { mapEntityStatus, type StatusVariant } from "@/components/StatusDot";
|
import { mapEntityStatus, type StatusVariant } from "@/components/StatusDot";
|
||||||
|
import { getCategoryConfig } from "@/lib/category";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { SerializedTask } from "@/components/TaskRow";
|
import type { SerializedTask } from "@/components/TaskRow";
|
||||||
|
|
||||||
@@ -17,6 +19,10 @@ interface TaskGraphProps {
|
|||||||
tasks: SerializedTask[];
|
tasks: SerializedTask[];
|
||||||
/** Raw edges: { taskId, dependsOn: string[] } */
|
/** Raw edges: { taskId, dependsOn: string[] } */
|
||||||
taskDepsRaw: Array<{ taskId: string; 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;
|
onClickTask: (taskId: string) => void;
|
||||||
onDeleteTask?: (taskId: string) => void;
|
onDeleteTask?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
@@ -41,6 +47,8 @@ const railDotClasses: Record<StatusVariant, string> = {
|
|||||||
export function TaskGraph({
|
export function TaskGraph({
|
||||||
tasks,
|
tasks,
|
||||||
taskDepsRaw,
|
taskDepsRaw,
|
||||||
|
blockedByCountMap,
|
||||||
|
agentNameMap,
|
||||||
onClickTask,
|
onClickTask,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
}: TaskGraphProps) {
|
}: TaskGraphProps) {
|
||||||
@@ -96,7 +104,8 @@ export function TaskGraph({
|
|||||||
<TaskNode
|
<TaskNode
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
|
blockedByCount={blockedByCountMap.get(task.id) ?? 0}
|
||||||
|
agentName={agentNameMap.get(task.id)}
|
||||||
onClick={() => onClickTask(task.id)}
|
onClick={() => onClickTask(task.id)}
|
||||||
onDelete={
|
onDelete={
|
||||||
onDeleteTask
|
onDeleteTask
|
||||||
@@ -110,7 +119,8 @@ export function TaskGraph({
|
|||||||
) : (
|
) : (
|
||||||
<TaskNode
|
<TaskNode
|
||||||
task={col.phases[0]}
|
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)}
|
onClick={() => onClickTask(col.phases[0].id)}
|
||||||
onDelete={
|
onDelete={
|
||||||
onDeleteTask
|
onDeleteTask
|
||||||
@@ -132,57 +142,92 @@ export function TaskGraph({
|
|||||||
|
|
||||||
function TaskNode({
|
function TaskNode({
|
||||||
task,
|
task,
|
||||||
|
blockedByCount,
|
||||||
|
agentName,
|
||||||
onClick,
|
onClick,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
task: SerializedTask;
|
task: SerializedTask;
|
||||||
|
blockedByCount: number;
|
||||||
|
agentName?: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const variant = mapEntityStatus(task.status);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{/* Rail dot */}
|
{/* Rail dot — aligned to first text line */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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],
|
railDotClasses[variant],
|
||||||
variant === "active" && "animate-status-pulse",
|
variant === "active" && "animate-status-pulse",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Task name */}
|
{/* Two-line content */}
|
||||||
<span className="min-w-0 flex-1 truncate text-[13px]">
|
<div className="min-w-0 flex-1">
|
||||||
{task.name}
|
{/* Line 1: name + status + delete */}
|
||||||
</span>
|
<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 */}
|
{/* Line 2: category + priority + agent + blocked count */}
|
||||||
<StatusBadge
|
<div className="mt-0.5 flex items-center gap-1.5">
|
||||||
status={task.status}
|
<Badge variant={catConfig.variant} size="xs">
|
||||||
className="shrink-0 text-[9px]"
|
{catConfig.label}
|
||||||
/>
|
</Badge>
|
||||||
|
{priorityColor && (
|
||||||
{/* Delete button */}
|
<span className={cn("text-[10px] font-medium capitalize", priorityColor)}>
|
||||||
{onDelete && (
|
{task.priority}
|
||||||
<button
|
</span>
|
||||||
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)"
|
{agentName && (
|
||||||
onClick={(e) => {
|
<span className="truncate text-[10px] text-muted-foreground">
|
||||||
e.stopPropagation();
|
{agentName}
|
||||||
if (e.shiftKey || window.confirm(`Delete "${task.name}"?`)) {
|
</span>
|
||||||
onDelete();
|
)}
|
||||||
}
|
{blockedByCount > 0 && (
|
||||||
}}
|
<span className="text-[10px] text-muted-foreground">
|
||||||
>
|
blocked by {blockedByCount}
|
||||||
<X className="h-3.5 w-3.5" />
|
</span>
|
||||||
</button>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
</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 { PhaseActions } from "./PhaseActions";
|
||||||
export { PhaseGraph } from "./PhaseGraph";
|
export { PhaseGraph } from "./PhaseGraph";
|
||||||
export { PhaseDetailPanel, PhaseDetailEmpty } from "./PhaseDetailPanel";
|
export { PhaseDetailPanel, PhaseDetailEmpty } from "./PhaseDetailPanel";
|
||||||
export { TaskModal } from "./TaskModal";
|
|
||||||
export type { TaskCounts, FlatTaskEntry, PhaseData } from "./ExecutionContext";
|
export type { TaskCounts, FlatTaskEntry, PhaseData } from "./ExecutionContext";
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
ExecutionProvider,
|
ExecutionProvider,
|
||||||
useExecutionContext,
|
useExecutionContext,
|
||||||
TaskModal,
|
|
||||||
PlanSection,
|
PlanSection,
|
||||||
type PhaseData,
|
type PhaseData,
|
||||||
type FlatTaskEntry,
|
type FlatTaskEntry,
|
||||||
} from "@/components/execution";
|
} from "@/components/execution";
|
||||||
|
import { TaskSlideOver } from "@/components/execution/TaskSlideOver";
|
||||||
import type { SerializedTask } from "@/components/TaskRow";
|
import type { SerializedTask } from "@/components/TaskRow";
|
||||||
import { PipelineGraph } from "./PipelineGraph";
|
import { PipelineGraph } from "./PipelineGraph";
|
||||||
import type { DetailAgentInfo } from "./PipelinePhaseGroup";
|
import type { DetailAgentInfo } from "./PipelinePhaseGroup";
|
||||||
@@ -27,12 +27,14 @@ interface PipelineTabProps {
|
|||||||
export function PipelineTab({ initiativeId, phases, phasesLoading }: PipelineTabProps) {
|
export function PipelineTab({ initiativeId, phases, phasesLoading }: PipelineTabProps) {
|
||||||
return (
|
return (
|
||||||
<ExecutionProvider>
|
<ExecutionProvider>
|
||||||
<PipelineTabInner
|
<div className="relative overflow-hidden">
|
||||||
initiativeId={initiativeId}
|
<PipelineTabInner
|
||||||
phases={phases}
|
initiativeId={initiativeId}
|
||||||
phasesLoading={phasesLoading}
|
phases={phases}
|
||||||
/>
|
phasesLoading={phasesLoading}
|
||||||
<TaskModal />
|
/>
|
||||||
|
<TaskSlideOver />
|
||||||
|
</div>
|
||||||
</ExecutionProvider>
|
</ExecutionProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -119,7 +121,7 @@ function PipelineTabInner({ initiativeId, phases, phasesLoading }: PipelineTabPr
|
|||||||
|
|
||||||
const queueAll = trpc.queueAllPhases.useMutation();
|
const queueAll = trpc.queueAllPhases.useMutation();
|
||||||
|
|
||||||
// Register tasks with ExecutionContext for TaskModal
|
// Register tasks with ExecutionContext for TaskSlideOver
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
for (const phase of phases) {
|
for (const phase of phases) {
|
||||||
const phaseTasks = tasksByPhase[phase.id] ?? [];
|
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