diff options
Diffstat (limited to 'makima/frontend/src/components/mesh/TaskTree.tsx')
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskTree.tsx | 390 |
1 files changed, 390 insertions, 0 deletions
diff --git a/makima/frontend/src/components/mesh/TaskTree.tsx b/makima/frontend/src/components/mesh/TaskTree.tsx new file mode 100644 index 0000000..46ae78d --- /dev/null +++ b/makima/frontend/src/components/mesh/TaskTree.tsx @@ -0,0 +1,390 @@ +import { useState, useCallback } from "react"; +import type { TaskSummary, TaskStatus } from "../../lib/api"; + +interface TaskTreeProps { + tasks: TaskSummary[]; + supervisorTaskId: string | null; + onSelect: (taskId: string) => void; + onStartSupervisor?: () => void; + loading?: boolean; + fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>; +} + +interface TreeNodeProps { + task: TaskSummary; + isSupervisorTask: boolean; + onSelect: (taskId: string) => void; + depth: number; + fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>; +} + +function getStatusColor(status: TaskStatus): string { + switch (status) { + case "pending": + return "text-[#9bc3ff]"; + case "running": + return "text-green-400"; + case "paused": + return "text-yellow-400"; + case "blocked": + return "text-orange-400"; + case "done": + return "text-emerald-400"; + case "failed": + return "text-red-400"; + case "merged": + return "text-purple-400"; + default: + return "text-[#9bc3ff]"; + } +} + +function getStatusIcon(status: TaskStatus): string { + switch (status) { + case "pending": + return "○"; + case "running": + return "◉"; + case "paused": + return "◎"; + case "blocked": + return "◈"; + case "done": + return "●"; + case "failed": + return "✕"; + case "merged": + return "◆"; + default: + return "○"; + } +} + +function TreeNode({ task, isSupervisorTask, onSelect, depth, fetchSubtasks }: TreeNodeProps) { + const [expanded, setExpanded] = useState(isSupervisorTask); // Supervisor expanded by default + const [children, setChildren] = useState<TaskSummary[] | null>(null); + const [loadingChildren, setLoadingChildren] = useState(false); + + const hasSubtasks = task.subtaskCount > 0; + + const handleToggle = useCallback(async () => { + if (!hasSubtasks) return; + + if (expanded) { + setExpanded(false); + } else { + if (!children && fetchSubtasks) { + setLoadingChildren(true); + try { + const subtasks = await fetchSubtasks(task.id); + setChildren(subtasks); + } catch (err) { + console.error("Failed to fetch subtasks:", err); + } finally { + setLoadingChildren(false); + } + } + setExpanded(true); + } + }, [expanded, children, hasSubtasks, task.id, fetchSubtasks]); + + const indent = depth * 16; + + return ( + <div className="select-none"> + <div + className={`flex items-center gap-2 py-2 px-2 hover:bg-[rgba(117,170,252,0.05)] transition-colors cursor-pointer group ${ + isSupervisorTask ? "bg-[rgba(117,170,252,0.08)] border-l-2 border-[#75aafc]" : "" + }`} + style={{ paddingLeft: `${indent + 8}px` }} + > + {/* Expand/Collapse button */} + <button + onClick={handleToggle} + className={`w-4 h-4 flex items-center justify-center font-mono text-[10px] ${ + hasSubtasks + ? "text-[#75aafc] hover:text-[#9bc3ff]" + : "text-transparent cursor-default" + }`} + disabled={!hasSubtasks} + > + {loadingChildren ? ( + <span className="animate-spin">...</span> + ) : hasSubtasks ? ( + expanded ? "▼" : "▶" + ) : ( + "" + )} + </button> + + {/* Supervisor badge or status icon */} + {isSupervisorTask ? ( + <span className="px-1.5 py-0.5 font-mono text-[9px] bg-[#0f3c78] text-[#75aafc] border border-[#3f6fb3] uppercase"> + Supervisor + </span> + ) : ( + <span + className={`font-mono text-xs ${getStatusColor(task.status)}`} + title={task.status} + > + {getStatusIcon(task.status)} + </span> + )} + + {/* Task name - clickable */} + <button + onClick={() => onSelect(task.id)} + className={`flex-1 text-left font-mono text-sm transition-colors truncate ${ + isSupervisorTask + ? "text-[#9bc3ff] hover:text-white font-medium" + : "text-[#dbe7ff] hover:text-white" + }`} + > + {task.name} + </button> + + {/* Status for supervisor */} + {isSupervisorTask && ( + <span + className={`font-mono text-[10px] ${getStatusColor(task.status)}`} + > + {task.status} + </span> + )} + + {/* Subtask count badge */} + {hasSubtasks && !isSupervisorTask && ( + <span className="font-mono text-[9px] text-[#555] group-hover:text-[#75aafc]"> + {task.subtaskCount} sub + </span> + )} + + {/* Priority indicator */} + {task.priority > 0 && ( + <span className="font-mono text-[9px] text-orange-400"> + P{task.priority} + </span> + )} + </div> + + {/* Children */} + {expanded && children && children.length > 0 && ( + <div className="border-l border-[rgba(117,170,252,0.15)]" style={{ marginLeft: `${indent + 16}px` }}> + {children.map((child) => ( + <TreeNode + key={child.id} + task={child} + isSupervisorTask={false} + onSelect={onSelect} + depth={depth + 1} + fetchSubtasks={fetchSubtasks} + /> + ))} + </div> + )} + </div> + ); +} + +// Stats summary component +export interface TaskTreeStats { + total: number; + pending: number; + running: number; + paused: number; + blocked: number; + done: number; + failed: number; + merged: number; +} + +export function calculateTreeStats(tasks: TaskSummary[]): TaskTreeStats { + const stats: TaskTreeStats = { + total: tasks.length, + pending: 0, + running: 0, + paused: 0, + blocked: 0, + done: 0, + failed: 0, + merged: 0, + }; + + for (const task of tasks) { + // Skip supervisor task in stats + if (task.isSupervisor) continue; + + switch (task.status) { + case "pending": + stats.pending++; + break; + case "running": + stats.running++; + break; + case "paused": + stats.paused++; + break; + case "blocked": + stats.blocked++; + break; + case "done": + stats.done++; + break; + case "failed": + stats.failed++; + break; + case "merged": + stats.merged++; + break; + } + } + + // Adjust total to exclude supervisor + stats.total = tasks.filter(t => !t.isSupervisor).length; + + return stats; +} + +// Progress bar for task tree +export function TaskTreeProgressBar({ stats }: { stats: TaskTreeStats }) { + if (stats.total === 0) return null; + + const completedPercent = ((stats.done + stats.merged) / stats.total) * 100; + const runningPercent = (stats.running / stats.total) * 100; + const failedPercent = (stats.failed / stats.total) * 100; + + return ( + <div className="space-y-2"> + {/* Progress bar */} + <div className="h-2 bg-[rgba(117,170,252,0.1)] rounded overflow-hidden flex"> + <div + className="bg-emerald-400 transition-all" + style={{ width: `${completedPercent}%` }} + title={`Completed: ${stats.done + stats.merged}`} + /> + <div + className="bg-green-400 transition-all" + style={{ width: `${runningPercent}%` }} + title={`Running: ${stats.running}`} + /> + <div + className="bg-red-400 transition-all" + style={{ width: `${failedPercent}%` }} + title={`Failed: ${stats.failed}`} + /> + </div> + + {/* Summary */} + <div className="flex items-center justify-between font-mono text-[10px]"> + <span className="text-[#555]"> + {stats.done + stats.merged} / {stats.total} completed + </span> + {stats.running > 0 && ( + <span className="text-green-400">{stats.running} running</span> + )} + {stats.failed > 0 && ( + <span className="text-red-400">{stats.failed} failed</span> + )} + </div> + </div> + ); +} + +export function TaskTree({ + tasks, + supervisorTaskId, + onSelect, + onStartSupervisor, + loading = false, + fetchSubtasks, +}: TaskTreeProps) { + if (loading) { + return ( + <div className="p-4 text-center font-mono text-xs text-[#555]"> + Loading tasks... + </div> + ); + } + + // Separate supervisor from other tasks + const supervisorTask = tasks.find(t => t.id === supervisorTaskId || t.isSupervisor); + const workerTasks = tasks.filter(t => t.id !== supervisorTaskId && !t.isSupervisor && !t.parentTaskId); + + // Calculate stats for worker tasks + const stats = calculateTreeStats(tasks); + + return ( + <div className="space-y-4"> + {/* Supervisor Section */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <h3 className="font-mono text-xs text-[#75aafc] uppercase"> + Contract Supervisor + </h3> + {supervisorTask && supervisorTask.status === "pending" && onStartSupervisor && ( + <button + onClick={onStartSupervisor} + className="px-2 py-1 font-mono text-[10px] text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors" + > + Start Supervisor + </button> + )} + </div> + + {supervisorTask ? ( + <TreeNode + task={supervisorTask} + isSupervisorTask={true} + onSelect={onSelect} + depth={0} + fetchSubtasks={fetchSubtasks} + /> + ) : ( + <div className="p-3 border border-dashed border-[rgba(117,170,252,0.2)] text-center"> + <p className="font-mono text-xs text-[#555]"> + No supervisor task found + </p> + </div> + )} + </div> + + {/* Progress Section */} + {stats.total > 0 && ( + <div className="space-y-2"> + <h3 className="font-mono text-xs text-[#75aafc] uppercase"> + Task Progress + </h3> + <TaskTreeProgressBar stats={stats} /> + </div> + )} + + {/* Worker Tasks Section */} + {workerTasks.length > 0 && ( + <div className="space-y-2"> + <h3 className="font-mono text-xs text-[#75aafc] uppercase"> + Worker Tasks ({workerTasks.length}) + </h3> + <div className="divide-y divide-[rgba(117,170,252,0.1)]"> + {workerTasks.map((task) => ( + <TreeNode + key={task.id} + task={task} + isSupervisorTask={false} + onSelect={onSelect} + depth={0} + fetchSubtasks={fetchSubtasks} + /> + ))} + </div> + </div> + )} + + {/* Empty State */} + {workerTasks.length === 0 && !supervisorTask && ( + <div className="p-4 text-center font-mono text-xs text-[#555]"> + No tasks in this contract + </div> + )} + </div> + ); +} |
