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