summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/mesh/TaskTree.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/mesh/TaskTree.tsx')
-rw-r--r--makima/frontend/src/components/mesh/TaskTree.tsx390
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>
+ );
+}