summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/mesh/SubtaskTree.tsx
blob: 176b7a79b3a990ead942fb0e6299a52ddc30b3d4 (plain) (tree)








































































































































































































































































































                                                                                                                                
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>
  );
}