From 8b17a175c3e7e27b789812eba4e3cd760beadb10 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Tue, 6 Jan 2026 04:08:11 +0000
Subject: Initial Control system

---
 .../frontend/src/components/mesh/SubtaskTree.tsx   | 297 +++++++++++++++++++++
 1 file changed, 297 insertions(+)
 create mode 100644 makima/frontend/src/components/mesh/SubtaskTree.tsx

(limited to 'makima/frontend/src/components/mesh/SubtaskTree.tsx')

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>
+  );
+}
-- 
cgit v1.2.3

