summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/mesh/SubtaskTree.tsx
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-06 04:08:11 +0000
committersoryu <soryu@soryu.co>2026-01-11 03:01:13 +0000
commit8b17a175c3e7e27b789812eba4e3cd760beadb10 (patch)
tree7864dcaa2fa9db47fdfd4e8bfdb0b1dde832aa33 /makima/frontend/src/components/mesh/SubtaskTree.tsx
parentf79c416c58557d2f946aa5332989afdfa8c021cd (diff)
downloadsoryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz
soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip
Initial Control system
Diffstat (limited to 'makima/frontend/src/components/mesh/SubtaskTree.tsx')
-rw-r--r--makima/frontend/src/components/mesh/SubtaskTree.tsx297
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>
+ );
+}