diff --git a/apps/server/src/handlers/task/index.ts b/apps/server/src/handlers/task/index.ts index 27f64b2..9dc87cf 100644 --- a/apps/server/src/handlers/task/index.ts +++ b/apps/server/src/handlers/task/index.ts @@ -9,6 +9,7 @@ export * from "./task.delete.handler"; // NEW delete handler export * from "./task.context.handler"; // NEW context generation export * from "./task.decompose.handler"; // NEW decompose handler export * from "./task.create_project.handler"; // NEW project creation +export * from "./task.get_project.handler"; // NEW get project with hierarchy // Task attachment handlers - key-value store for tasks export * from "./task.create_attachment.handler"; diff --git a/apps/server/src/handlers/task/task.create_project.handler.ts b/apps/server/src/handlers/task/task.create_project.handler.ts index 18d22b1..585e83c 100644 --- a/apps/server/src/handlers/task/task.create_project.handler.ts +++ b/apps/server/src/handlers/task/task.create_project.handler.ts @@ -248,7 +248,7 @@ export class TaskCreateProjectHandler { // Map subtask dependencies to real task IDs const realDependencies = subtask.dependencies - .map(depId => subtaskMapping[depId]) + .map((depId: string) => subtaskMapping[depId]) .filter(Boolean); // Remove any unmapped dependencies if (realDependencies.length > 0) { @@ -285,7 +285,7 @@ export class TaskCreateProjectHandler { const subtaskTaskId = subtaskMapping[subtask.id]; if (subtaskTaskId) { dependencyMapping[subtaskTaskId] = subtask.dependencies - .map(depId => subtaskMapping[depId]) + .map((depId: string) => subtaskMapping[depId]) .filter(Boolean); } } diff --git a/apps/server/src/handlers/task/task.get_project.handler.ts b/apps/server/src/handlers/task/task.get_project.handler.ts new file mode 100644 index 0000000..7949b58 --- /dev/null +++ b/apps/server/src/handlers/task/task.get_project.handler.ts @@ -0,0 +1,207 @@ +import { EventHandler, Instrumented, Resilient } from "@/core/decorator"; +import type { EventContext } from "@/core/context"; +import { taskGetProjectInput, taskGetProjectOutput } from "@/schemas/task.schema"; +import type { TaskGetProjectInput, TaskGetProjectOutput } from "@/schemas/task.schema"; +import { getRedis } from "@/core/redis"; +import { registry } from "@/core/registry"; + +@EventHandler({ + event: "task.get_project", + inputSchema: taskGetProjectInput, + outputSchema: taskGetProjectOutput, + persist: false, + rateLimit: 30, + description: "Fetch project-structured tasks with hierarchy and attachments" +}) +export class TaskGetProjectHandler { + @Instrumented(3600) // Cache for 1 hour + @Resilient({ + rateLimit: { limit: 30, windowMs: 60000 }, + timeout: 10000, + circuitBreaker: { threshold: 5, timeout: 30000 } + }) + async handle(input: TaskGetProjectInput, ctx: EventContext): Promise { + const redis = getRedis(); + let projectId = input.projectId; + let parentTaskId = input.taskId; + + // If only taskId provided, get projectId from task metadata + if (!projectId && parentTaskId) { + const taskData = await ctx.prisma.task.findUnique({ + where: { id: parentTaskId } + }); + if (!taskData?.metadata) { + throw new Error(`Task ${parentTaskId} not found or has no metadata`); + } + const metadata = taskData.metadata as any; + projectId = metadata.projectId; + if (!projectId) { + throw new Error(`Task ${parentTaskId} is not a project task`); + } + } + + // If only projectId provided, get parentTaskId from Redis + if (projectId && !parentTaskId) { + const projectKey = `cb:project:${projectId}`; + const redisTaskId = await redis.pub.hget(projectKey, "parentTaskId"); + if (redisTaskId) { + parentTaskId = redisTaskId; + } + if (!parentTaskId) { + // Fallback to PostgreSQL + const projectTask = await ctx.prisma.task.findFirst({ + where: { + metadata: { + path: ["projectId"], + equals: projectId + } + } + }); + if (!projectTask) { + throw new Error(`Project ${projectId} not found`); + } + parentTaskId = projectTask.id; + } + } + + // Fetch parent task with attachments + const parentTask = await ctx.prisma.task.findUnique({ + where: { id: parentTaskId }, + include: { + attachments: { + orderBy: { createdAt: "desc" } + } + } + }); + + if (!parentTask) { + throw new Error(`Parent task ${parentTaskId} not found`); + } + + // Fetch all subtasks for this project + const subtasks = await ctx.prisma.task.findMany({ + where: { + metadata: { + path: ["projectId"], + equals: projectId + } + }, + include: { + attachments: { + where: { + key: { + startsWith: "subtask_context" + } + }, + orderBy: { createdAt: "desc" } + } + }, + orderBy: { createdAt: "asc" } + }); + + // Filter out the parent task from subtasks + const actualSubtasks = subtasks.filter(t => t.id !== parentTaskId); + + // Get project metadata from Redis cache or attachments + const projectKey = `cb:project:${projectId}`; + let projectMetadata: any = {}; + + const redisData = await redis.pub.hgetall(projectKey); + if (redisData && Object.keys(redisData).length > 0) { + projectMetadata = { + description: redisData.description || "", + status: redisData.status || "unknown", + constraints: redisData.constraints ? JSON.parse(redisData.constraints) : [], + requirements: redisData.requirements ? JSON.parse(redisData.requirements) : [], + estimatedMinutes: redisData.estimatedMinutes ? parseInt(redisData.estimatedMinutes) : undefined, + createdAt: redisData.createdAt || new Date().toISOString(), + createdBy: redisData.createdBy || ctx.instanceId + }; + } else { + // Fallback to attachments + const projectAttachment = parentTask.attachments.find(a => + a.key.startsWith("project_") && a.type === "json" + ); + if (projectAttachment?.value) { + const attachmentData = projectAttachment.value as any; + projectMetadata = { + description: attachmentData.description || "", + status: attachmentData.status || "unknown", + constraints: attachmentData.constraints || [], + requirements: attachmentData.requirements || [], + estimatedMinutes: attachmentData.estimatedMinutes, + createdAt: attachmentData.createdAt || parentTask.createdAt.toISOString(), + createdBy: attachmentData.createdBy || ctx.instanceId + }; + } + } + + // Get decomposition data for strategy and complexity + const decompositionAttachment = parentTask.attachments.find(a => + a.key.startsWith("decomposition_") && a.type === "json" + ); + if (decompositionAttachment?.value) { + const decomposition = decompositionAttachment.value as any; + projectMetadata.strategy = decomposition.strategy; + projectMetadata.totalComplexity = decomposition.totalComplexity; + } + + // Calculate stats + const stats = { + totalTasks: actualSubtasks.length + 1, // Include parent + pendingTasks: actualSubtasks.filter(t => t.status === "pending").length, + inProgressTasks: actualSubtasks.filter(t => t.status === "in_progress").length, + completedTasks: actualSubtasks.filter(t => t.status === "completed").length, + failedTasks: actualSubtasks.filter(t => t.status === "failed").length + }; + + // Add parent task status to stats + if (parentTask.status === "pending") stats.pendingTasks++; + else if (parentTask.status === "in_progress") stats.inProgressTasks++; + else if (parentTask.status === "completed") stats.completedTasks++; + else if (parentTask.status === "failed") stats.failedTasks++; + + // Format response + return { + projectId: projectId!, + parentTask: { + id: parentTask.id, + text: parentTask.text, + status: parentTask.status as any, + priority: parentTask.priority, + createdAt: parentTask.createdAt.toISOString(), + updatedAt: parentTask.updatedAt.toISOString(), + metadata: parentTask.metadata as any, + attachments: parentTask.attachments.map(a => ({ + key: a.key, + type: a.type as any, + value: a.value, + createdAt: a.createdAt.toISOString() + })) + }, + subtasks: actualSubtasks.map(task => { + const metadata = task.metadata as any || {}; + return { + id: task.id, + text: task.text, + status: task.status as any, + priority: task.priority, + specialist: metadata.specialist, + complexity: metadata.complexity, + estimatedMinutes: metadata.estimatedMinutes, + dependencies: metadata.dependencies || [], + createdAt: task.createdAt.toISOString(), + updatedAt: task.updatedAt.toISOString(), + attachments: task.attachments.map(a => ({ + key: a.key, + type: a.type as any, + value: a.value, + createdAt: a.createdAt.toISOString() + })) + }; + }), + projectMetadata, + stats + }; + } +} \ No newline at end of file diff --git a/apps/server/src/schemas/task.schema.ts b/apps/server/src/schemas/task.schema.ts index 0dd1c8f..5a85163 100644 --- a/apps/server/src/schemas/task.schema.ts +++ b/apps/server/src/schemas/task.schema.ts @@ -362,4 +362,71 @@ export const taskCreateProjectOutput = z.object({ }); export type TaskCreateProjectInput = z.infer; -export type TaskCreateProjectOutput = z.infer; \ No newline at end of file +export type TaskCreateProjectOutput = z.infer; + +// task.get_project +export const taskGetProjectInput = z.object({ + projectId: z.string().min(1).optional(), + taskId: z.string().min(1).optional(), +}).refine(data => data.projectId || data.taskId, { + message: "Either 'projectId' or 'taskId' must be provided", + path: ["projectId"], +}); + +export const taskGetProjectOutput = z.object({ + projectId: z.string(), + parentTask: z.object({ + id: z.string(), + text: z.string(), + status: TaskStatus, + priority: z.number(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + metadata: z.record(z.string(), z.unknown()).nullable(), + attachments: z.array(z.object({ + key: z.string(), + type: AttachmentType, + value: z.unknown().optional(), + createdAt: z.string() + })).optional(), + }), + subtasks: z.array(z.object({ + id: z.string(), + text: z.string(), + status: TaskStatus, + priority: z.number(), + specialist: z.string().optional(), + complexity: z.number().optional(), + estimatedMinutes: z.number().optional(), + dependencies: z.array(z.string()).optional(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + attachments: z.array(z.object({ + key: z.string(), + type: AttachmentType, + value: z.unknown().optional(), + createdAt: z.string() + })).optional(), + })), + projectMetadata: z.object({ + description: z.string(), + status: z.string(), + constraints: z.array(z.string()).optional(), + requirements: z.array(z.string()).optional(), + estimatedMinutes: z.number().optional(), + strategy: z.enum(["parallel", "sequential", "mixed"]).optional(), + totalComplexity: z.number().optional(), + createdAt: z.string().datetime(), + createdBy: z.string().optional(), + }), + stats: z.object({ + totalTasks: z.number(), + pendingTasks: z.number(), + inProgressTasks: z.number(), + completedTasks: z.number(), + failedTasks: z.number(), + }) +}); + +export type TaskGetProjectInput = z.infer; +export type TaskGetProjectOutput = z.infer; \ No newline at end of file diff --git a/apps/web/src/components/ClaudeBenchLayout.tsx b/apps/web/src/components/ClaudeBenchLayout.tsx index 8f140cc..bbf4b41 100644 --- a/apps/web/src/components/ClaudeBenchLayout.tsx +++ b/apps/web/src/components/ClaudeBenchLayout.tsx @@ -11,6 +11,7 @@ import { IconRobot, IconBroadcast, IconBook, + IconFolders, } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { ModeToggle } from "@/components/mode-toggle"; @@ -38,6 +39,13 @@ export function ClaudeBenchLayout({ children }: ClaudeBenchLayoutProps) { ), }, + { + label: "Projects", + href: "/projects", + icon: ( + + ), + }, { label: "Events", href: "/events", diff --git a/apps/web/src/components/ProjectCard.tsx b/apps/web/src/components/ProjectCard.tsx new file mode 100644 index 0000000..aeaf689 --- /dev/null +++ b/apps/web/src/components/ProjectCard.tsx @@ -0,0 +1,342 @@ +import { cn } from "@/lib/utils"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Folder, + MoreVertical, + CheckCircle, + Clock, + AlertCircle, + Users, + Layers, + Calendar, + Target, + Paperclip, + PlayCircle, + Eye, +} from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; + +export interface ProjectData { + id: string; + text: string; + status: "pending" | "in_progress" | "completed" | "failed"; + priority: number; + createdAt: string; + updatedAt?: string; + metadata?: { + type?: string; + projectId?: string; + constraints?: string[]; + requirements?: string[]; + estimatedMinutes?: number; + sessionId?: string; + }; + stats?: { + totalTasks: number; + pendingTasks: number; + inProgressTasks: number; + completedTasks: number; + failedTasks: number; + }; + attachmentCount?: number; +} + +interface ProjectCardProps { + project: ProjectData; + onClick?: (project: ProjectData) => void; + onViewDetails?: (projectId: string) => void; + onDecompose?: (projectId: string) => void; + onGenerateContext?: (projectId: string) => void; + className?: string; +} + +export function ProjectCard({ + project, + onClick, + onViewDetails, + onDecompose, + onGenerateContext, + className, +}: ProjectCardProps) { + // Calculate progress percentage + const progress = project.stats + ? ((project.stats.completedTasks / project.stats.totalTasks) * 100) || 0 + : 0; + + // Priority color mapping + const getPriorityColor = (priority: number) => { + if (priority >= 80) return "text-red-500 border-red-500"; + if (priority >= 60) return "text-orange-500 border-orange-500"; + if (priority >= 40) return "text-yellow-500 border-yellow-500"; + return "text-green-500 border-green-500"; + }; + + // Status icon and color + const getStatusIcon = () => { + switch (project.status) { + case "completed": + return ; + case "in_progress": + return ; + case "failed": + return ; + default: + return ; + } + }; + + const getStatusColor = () => { + switch (project.status) { + case "completed": + return "bg-green-500/10 text-green-700 border-green-200"; + case "in_progress": + return "bg-blue-500/10 text-blue-700 border-blue-200"; + case "failed": + return "bg-red-500/10 text-red-700 border-red-200"; + default: + return "bg-gray-500/10 text-gray-700 border-gray-200"; + } + }; + + // Clean project text (remove [Project] prefix if present) + const projectTitle = project.text.replace(/^\[Project\]\s*/i, ""); + + return ( + onClick?.(project)} + > + +
+
+ +
+ + {projectTitle} + +
+ + {getStatusIcon()} + + {project.status.replace("_", " ")} + + + + + + + + {project.priority} + + + Priority Score + + +
+
+
+ + + + + + Project Actions + + { + e.stopPropagation(); + onViewDetails?.(project.metadata?.projectId || project.id); + }} + > + + View Details + + {project.status === "pending" && ( + { + e.stopPropagation(); + onDecompose?.(project.id); + }} + > + + Decompose Tasks + + )} + { + e.stopPropagation(); + onGenerateContext?.(project.id); + }} + > + + Generate Context + + + +
+
+ + {/* Progress Section */} + {project.stats && ( +
+
+ Progress + {Math.round(progress)}% +
+ +
+ + + +
+
+ {project.stats.completedTasks} +
+ + Completed Tasks + + + + + +
+
+ {project.stats.inProgressTasks} +
+ + In Progress + + + + + +
+
+ {project.stats.pendingTasks} +
+ + Pending Tasks + + + {project.stats.failedTasks > 0 && ( + + + +
+
+ {project.stats.failedTasks} +
+ + Failed Tasks + + + )} +
+
+ )} + + {/* Metadata Section */} +
+ {project.stats && ( + + + +
+ + {project.stats.totalTasks} +
+
+ Total Tasks +
+
+ )} + {project.attachmentCount !== undefined && project.attachmentCount > 0 && ( + + + +
+ + {project.attachmentCount} +
+
+ Attachments +
+
+ )} + {project.metadata?.estimatedMinutes && ( + + + +
+ + {project.metadata.estimatedMinutes}m +
+
+ Estimated Time +
+
+ )} + + + +
+ + {formatDistanceToNow(new Date(project.createdAt), { addSuffix: true })} +
+
+ Created +
+
+
+ + {/* Constraints/Requirements Pills */} + {(project.metadata?.constraints?.length || project.metadata?.requirements?.length) ? ( +
+ {project.metadata.constraints?.slice(0, 2).map((constraint, idx) => ( + + {constraint.length > 20 ? `${constraint.slice(0, 20)}...` : constraint} + + ))} + {((project.metadata.constraints?.length || 0) + (project.metadata.requirements?.length || 0)) > 2 && ( + + +{(project.metadata.constraints?.length || 0) + (project.metadata.requirements?.length || 0) - 2} more + + )} +
+ ) : null} + + + ); +} \ No newline at end of file diff --git a/apps/web/src/components/ProjectCreationDialog.tsx b/apps/web/src/components/ProjectCreationDialog.tsx new file mode 100644 index 0000000..550a3ba --- /dev/null +++ b/apps/web/src/components/ProjectCreationDialog.tsx @@ -0,0 +1,347 @@ +import { useState } from "react"; +import { useCreateProject } from "@/hooks/useProjects"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Slider } from "@/components/ui/slider"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { X, Plus, AlertCircle, Loader2, FolderPlus } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface ProjectCreationDialogProps { + isOpen: boolean; + onClose: () => void; + onSuccess?: (projectId: string, taskId: string) => void; +} + +export function ProjectCreationDialog({ + isOpen, + onClose, + onSuccess, +}: ProjectCreationDialogProps) { + // Form state + const [projectDescription, setProjectDescription] = useState(""); + const [priority, setPriority] = useState(75); + const [constraints, setConstraints] = useState([]); + const [requirements, setRequirements] = useState([]); + const [currentConstraint, setCurrentConstraint] = useState(""); + const [currentRequirement, setCurrentRequirement] = useState(""); + const [estimatedComplexity, setEstimatedComplexity] = useState("medium"); + + // API hook + const { createProjectAsync, isLoading, error, reset } = useCreateProject(); + + // Handlers + const handleAddConstraint = () => { + if (currentConstraint.trim()) { + setConstraints([...constraints, currentConstraint.trim()]); + setCurrentConstraint(""); + } + }; + + const handleRemoveConstraint = (index: number) => { + setConstraints(constraints.filter((_, i) => i !== index)); + }; + + const handleAddRequirement = () => { + if (currentRequirement.trim()) { + setRequirements([...requirements, currentRequirement.trim()]); + setCurrentRequirement(""); + } + }; + + const handleRemoveRequirement = (index: number) => { + setRequirements(requirements.filter((_, i) => i !== index)); + }; + + const handleSubmit = async () => { + if (!projectDescription.trim()) { + return; + } + + try { + const result = await createProjectAsync({ + project: projectDescription, + priority, + constraints: constraints.length > 0 ? constraints : undefined, + requirements: requirements.length > 0 ? requirements : undefined, + metadata: { + estimatedComplexity, + }, + }); + + // Success - call callback and close + onSuccess?.(result.projectId, result.taskId); + handleClose(); + } catch (err) { + // Error is handled by the hook's toast notification + console.error("Failed to create project:", err); + } + }; + + const handleClose = () => { + // Reset form + setProjectDescription(""); + setPriority(75); + setConstraints([]); + setRequirements([]); + setCurrentConstraint(""); + setCurrentRequirement(""); + setEstimatedComplexity("medium"); + reset(); + onClose(); + }; + + // Priority color helper + const getPriorityColor = (value: number) => { + if (value >= 80) return "text-red-500"; + if (value >= 60) return "text-orange-500"; + if (value >= 40) return "text-yellow-500"; + return "text-green-500"; + }; + + // Priority label helper + const getPriorityLabel = (value: number) => { + if (value >= 80) return "Critical"; + if (value >= 60) return "High"; + if (value >= 40) return "Medium"; + return "Low"; + }; + + return ( + + + + + + Create New Project + + + Create a new project that will be automatically decomposed into subtasks by specialist agents. + + + +
+ {/* Project Description */} +
+ +