diff options
Diffstat (limited to 'makima/frontend/src/components/mesh/SubtaskTree.tsx')
| -rw-r--r-- | makima/frontend/src/components/mesh/SubtaskTree.tsx | 297 |
1 files changed, 297 insertions, 0 deletions
diff --git a/makima/frontend/src/components/mesh/SubtaskTree.tsx b/makima/frontend/src/components/mesh/SubtaskTree.tsx new file mode 100644 index 0000000..176b7a7 --- /dev/null +++ b/makima/frontend/src/components/mesh/SubtaskTree.tsx @@ -0,0 +1,297 @@ +import { useState, useCallback } from "react"; +import type { TaskSummary, TaskStatus } from "../../lib/api"; + +interface SubtaskTreeProps { + subtasks: TaskSummary[]; + onSelect: (taskId: string) => void; + depth?: number; + loading?: boolean; + fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>; +} + +interface TreeNodeProps { + task: TaskSummary; + 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, onSelect, depth, fetchSubtasks }: TreeNodeProps) { + const [expanded, setExpanded] = useState(false); + 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-1.5 px-2 hover:bg-[rgba(117,170,252,0.05)] transition-colors cursor-pointer group" + 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> + + {/* Status icon */} + <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 text-[#dbe7ff] hover:text-white transition-colors truncate" + > + {task.name} + </button> + + {/* Subtask count badge */} + {hasSubtasks && ( + <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} + onSelect={onSelect} + depth={depth + 1} + fetchSubtasks={fetchSubtasks} + /> + ))} + </div> + )} + </div> + ); +} + +export function SubtaskTree({ + subtasks, + onSelect, + depth = 0, + loading = false, + fetchSubtasks, +}: SubtaskTreeProps) { + if (loading) { + return ( + <div className="p-4 text-center font-mono text-xs text-[#555]"> + Loading subtasks... + </div> + ); + } + + if (subtasks.length === 0) { + return ( + <div className="p-4 text-center font-mono text-xs text-[#555]"> + No subtasks + </div> + ); + } + + return ( + <div className="divide-y divide-[rgba(117,170,252,0.1)]"> + {subtasks.map((task) => ( + <TreeNode + key={task.id} + task={task} + onSelect={onSelect} + depth={depth} + fetchSubtasks={fetchSubtasks} + /> + ))} + </div> + ); +} + +// Aggregated status summary for a task tree +export interface TaskTreeStats { + total: number; + pending: number; + running: number; + paused: number; + blocked: number; + done: number; + failed: number; + merged: number; +} + +export function calculateTreeStats(subtasks: TaskSummary[]): TaskTreeStats { + const stats: TaskTreeStats = { + total: subtasks.length, + pending: 0, + running: 0, + paused: 0, + blocked: 0, + done: 0, + failed: 0, + merged: 0, + }; + + for (const task of subtasks) { + 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; + } + } + + return stats; +} + +// Visual summary bar +export function SubtaskProgressBar({ stats }: { stats: TaskTreeStats }) { + if (stats.total === 0) return null; + + const segments = [ + { count: stats.merged, color: "bg-purple-400", label: "Merged" }, + { count: stats.done, color: "bg-emerald-400", label: "Done" }, + { count: stats.running, color: "bg-green-400", label: "Running" }, + { count: stats.paused, color: "bg-yellow-400", label: "Paused" }, + { count: stats.blocked, color: "bg-orange-400", label: "Blocked" }, + { count: stats.pending, color: "bg-[#75aafc]", label: "Pending" }, + { count: stats.failed, color: "bg-red-400", label: "Failed" }, + ].filter((s) => s.count > 0); + + return ( + <div className="space-y-1"> + {/* Progress bar */} + <div className="h-2 flex overflow-hidden rounded-sm"> + {segments.map((segment, i) => ( + <div + key={i} + className={`${segment.color} transition-all`} + style={{ width: `${(segment.count / stats.total) * 100}%` }} + title={`${segment.label}: ${segment.count}`} + /> + ))} + </div> + + {/* Legend */} + <div className="flex flex-wrap gap-3 font-mono text-[9px]"> + {segments.map((segment, i) => ( + <div key={i} className="flex items-center gap-1"> + <div className={`w-2 h-2 ${segment.color} rounded-sm`} /> + <span className="text-[#75aafc]"> + {segment.label}: {segment.count} + </span> + </div> + ))} + </div> + </div> + ); +} |
