diff options
| author | soryu <soryu@soryu.co> | 2026-01-11 05:52:14 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-15 00:21:16 +0000 |
| commit | 87044a747b47bd83249d61a45842c7f7b2eae56d (patch) | |
| tree | ef2000ce79ffcc2723ef841acef5aa1deb1d5378 /makima/frontend/src/components/mesh | |
| parent | 077820c4167c168072d217a1b01df840463a12a8 (diff) | |
| download | soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.tar.gz soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.zip | |
Contract system
Diffstat (limited to 'makima/frontend/src/components/mesh')
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskDetail.tsx | 12 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskList.tsx | 215 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskOutput.tsx | 96 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskTree.tsx | 390 |
4 files changed, 659 insertions, 54 deletions
diff --git a/makima/frontend/src/components/mesh/TaskDetail.tsx b/makima/frontend/src/components/mesh/TaskDetail.tsx index be4fb80..967b1d1 100644 --- a/makima/frontend/src/components/mesh/TaskDetail.tsx +++ b/makima/frontend/src/components/mesh/TaskDetail.tsx @@ -23,6 +23,8 @@ interface TaskDetailProps { onToggleSubtaskOutput?: (subtaskId: string, subtaskName: string) => void; /** Which subtask's output is currently being viewed */ viewingSubtaskId?: string | null; + /** Navigate to view the contract */ + onViewContract?: (contractId: string) => void; // Optional advanced features overlayDiff?: string; changedFiles?: string[]; @@ -105,6 +107,7 @@ export function TaskDetail({ onCreateSubtask, onToggleSubtaskOutput, viewingSubtaskId, + onViewContract, overlayDiff, changedFiles, onRequestDiff, @@ -417,6 +420,15 @@ export function TaskDetail({ > {task.status} </span> + {/* Contract badge - clickable to view contract */} + {task.contractId && onViewContract && ( + <button + onClick={() => onViewContract(task.contractId!)} + className="px-2 py-0.5 font-mono text-xs text-blue-400 bg-blue-400/10 border border-blue-400/20 hover:bg-blue-400/20 transition-colors" + > + Contract + </button> + )} {/* Orchestrator badge for depth 0 tasks with subtasks */} {task.depth === 0 && task.subtasks.length > 0 && ( <span className="px-2 py-0.5 font-mono text-xs text-purple-400 bg-purple-400/10 border border-purple-400/20"> diff --git a/makima/frontend/src/components/mesh/TaskList.tsx b/makima/frontend/src/components/mesh/TaskList.tsx index a37e564..d013782 100644 --- a/makima/frontend/src/components/mesh/TaskList.tsx +++ b/makima/frontend/src/components/mesh/TaskList.tsx @@ -1,4 +1,5 @@ -import type { TaskSummary, TaskStatus } from "../../lib/api"; +import { useMemo } from "react"; +import type { TaskSummary, TaskStatus, ContractPhase } from "../../lib/api"; interface TaskListProps { tasks: TaskSummary[]; @@ -8,6 +9,13 @@ interface TaskListProps { onCreate: () => void; } +interface GroupedTasks { + contractId: string | null; + contractName: string | null; + contractPhase: ContractPhase | null; + tasks: TaskSummary[]; +} + function formatDate(dateStr: string): string { const date = new Date(dateStr); return date.toLocaleDateString("en-US", { @@ -61,6 +69,17 @@ function getStatusBgColor(status: TaskStatus): string { } } +function getPhaseColor(phase: ContractPhase | null): string { + switch (phase) { + case "research": return "text-blue-400"; + case "specify": return "text-cyan-400"; + case "plan": return "text-yellow-400"; + case "execute": return "text-green-400"; + case "review": return "text-purple-400"; + default: return "text-[#8b949e]"; + } +} + export function TaskList({ tasks, loading, @@ -68,6 +87,53 @@ export function TaskList({ onDelete, onCreate, }: TaskListProps) { + // Group tasks by contract + const groupedTasks = useMemo(() => { + // Separate root tasks (no parent) from subtasks + const rootTasks = tasks.filter((t) => !t.parentTaskId); + + // Group by contractId + const groups = new Map<string | null, GroupedTasks>(); + + for (const task of rootTasks) { + const key = task.contractId; + if (!groups.has(key)) { + groups.set(key, { + contractId: task.contractId, + contractName: task.contractName, + contractPhase: task.contractPhase, + tasks: [], + }); + } + groups.get(key)!.tasks.push(task); + } + + // Sort tasks within each group: supervisors first, then by status (running first), then by date + for (const group of groups.values()) { + group.tasks.sort((a, b) => { + // Supervisors always first + if (a.isSupervisor && !b.isSupervisor) return -1; + if (!a.isSupervisor && b.isSupervisor) return 1; + // Running tasks next + if (a.status === "running" && b.status !== "running") return -1; + if (a.status !== "running" && b.status === "running") return 1; + // Then by date (newest first) + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + } + + // Sort: contracts first (alphabetically by name), then orphan tasks + const sorted = Array.from(groups.values()).sort((a, b) => { + // Orphan tasks (no contract) go last + if (!a.contractId && b.contractId) return 1; + if (a.contractId && !b.contractId) return -1; + // Sort by contract name + return (a.contractName || "").localeCompare(b.contractName || ""); + }); + + return sorted; + }, [tasks]); + if (loading) { return ( <div className="panel h-full flex items-center justify-center"> @@ -76,8 +142,7 @@ export function TaskList({ ); } - // Separate root tasks (no parent) from subtasks - const rootTasks = tasks.filter((t) => !t.parentTaskId); + const totalTasks = groupedTasks.reduce((sum, g) => sum + g.tasks.length, 0); return ( <div className="panel h-full flex flex-col"> @@ -94,65 +159,107 @@ export function TaskList({ </div> <div className="flex-1 overflow-y-auto"> - {rootTasks.length === 0 ? ( + {totalTasks === 0 ? ( <div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8"> No tasks yet. Create one to start orchestrating Claude Code instances. </div> ) : ( - <div className="divide-y divide-[rgba(117,170,252,0.15)]"> - {rootTasks.map((task) => ( - <div - key={task.id} - className="p-4 hover:bg-[rgba(117,170,252,0.05)] transition-colors" - > - <div className="flex items-start justify-between gap-4"> - <button - onClick={() => onSelect(task.id)} - className="flex-1 text-left" - > - <div className="flex items-center gap-2 mb-1"> - <h3 className="font-mono text-sm text-[#dbe7ff]"> - {task.name} - </h3> - <span - className={`px-2 py-0.5 font-mono text-[10px] uppercase ${getStatusColor( - task.status - )} ${getStatusBgColor(task.status)} border border-current/20`} - > - {task.status} + <div> + {groupedTasks.map((group) => ( + <div key={group.contractId || "orphan"}> + {/* Contract header */} + <div className="sticky top-0 bg-[#0d1117] border-b border-[rgba(117,170,252,0.25)] px-4 py-2 flex items-center gap-2"> + {group.contractId ? ( + <> + <span className="font-mono text-xs text-[#dbe7ff] font-medium"> + {group.contractName} </span> - {task.depth === 0 && task.subtaskCount > 0 && ( - <span className="px-2 py-0.5 font-mono text-[10px] text-purple-400 bg-purple-400/10 border border-purple-400/20"> - Orchestrator + {group.contractPhase && ( + <span className={`font-mono text-[10px] ${getPhaseColor(group.contractPhase)}`}> + [{group.contractPhase}] </span> )} - {task.priority > 0 && ( - <span className="px-2 py-0.5 font-mono text-[10px] text-orange-400 bg-orange-400/10 border border-orange-400/20"> - P{task.priority} - </span> - )} - </div> - {task.progressSummary && ( - <p className="font-mono text-xs text-[#9bc3ff] mb-2 line-clamp-2"> - {task.progressSummary} - </p> - )} - <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]"> - {task.subtaskCount > 0 && ( - <span>{task.subtaskCount} subtasks</span> - )} - <span>{formatDate(task.createdAt)}</span> + <span className="font-mono text-[10px] text-[#8b949e]"> + ({group.tasks.length}) + </span> + </> + ) : ( + <span className="font-mono text-xs text-[#8b949e] italic"> + Unassigned Tasks ({group.tasks.length}) + </span> + )} + </div> + + {/* Tasks in this group */} + <div className="divide-y divide-[rgba(117,170,252,0.15)]"> + {group.tasks.map((task) => ( + <div + key={task.id} + className={`p-4 hover:bg-[rgba(117,170,252,0.05)] transition-colors ${ + task.isSupervisor + ? "bg-[rgba(117,170,252,0.08)] border-l-2 border-[#75aafc]" + : "" + }`} + > + <div className="flex items-start justify-between gap-4"> + <button + onClick={() => onSelect(task.id)} + className="flex-1 text-left" + > + <div className="flex items-center gap-2 mb-1"> + <h3 className="font-mono text-sm text-[#dbe7ff]"> + {task.name} + </h3> + <span + className={`px-2 py-0.5 font-mono text-[10px] uppercase ${getStatusColor( + task.status + )} ${getStatusBgColor(task.status)} border border-current/20`} + > + {task.status} + </span> + {task.isSupervisor && ( + <span className="px-2 py-0.5 font-mono text-[10px] text-[#75aafc] bg-[#0f3c78] border border-[#3f6fb3] uppercase"> + Supervisor + </span> + )} + {!task.isSupervisor && task.depth === 0 && task.subtaskCount > 0 && ( + <span className="px-2 py-0.5 font-mono text-[10px] text-purple-400 bg-purple-400/10 border border-purple-400/20"> + Orchestrator + </span> + )} + {task.priority > 0 && ( + <span className="px-2 py-0.5 font-mono text-[10px] text-orange-400 bg-orange-400/10 border border-orange-400/20"> + P{task.priority} + </span> + )} + </div> + {task.progressSummary && ( + <p className="font-mono text-xs text-[#9bc3ff] mb-2 line-clamp-2"> + {task.progressSummary} + </p> + )} + <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]"> + {task.subtaskCount > 0 && ( + <span>{task.subtaskCount} subtasks</span> + )} + <span>{formatDate(task.createdAt)}</span> + </div> + </button> + {/* Supervisor tasks cannot be deleted directly - they are deleted with the contract */} + {!task.isSupervisor && ( + <button + onClick={(e) => { + e.stopPropagation(); + onDelete(task.id); + }} + className="px-2 py-1 font-mono text-[10px] text-red-400 hover:bg-red-400/10 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase" + > + Delete + </button> + )} + </div> </div> - </button> - <button - onClick={(e) => { - e.stopPropagation(); - onDelete(task.id); - }} - className="px-2 py-1 font-mono text-[10px] text-red-400 hover:bg-red-400/10 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase" - > - Delete - </button> + ))} </div> </div> ))} diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx index 10de225..cb0eba3 100644 --- a/makima/frontend/src/components/mesh/TaskOutput.tsx +++ b/makima/frontend/src/components/mesh/TaskOutput.tsx @@ -275,7 +275,103 @@ function OutputEntryRenderer({ entry }: { entry: TaskOutputEvent }) { </div> ); + case "auth_required": + return <AuthRequiredEntry entry={entry} />; + default: return null; } } + +function AuthRequiredEntry({ entry }: { entry: TaskOutputEvent }) { + const [authCode, setAuthCode] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState<string | null>(null); + + const loginUrl = entry.toolInput?.loginUrl as string | undefined; + const hostname = entry.toolInput?.hostname as string | undefined; + // Get taskId from entry or fallback to toolInput (for robustness) + const taskId = entry.taskId || (entry.toolInput?.taskId as string | undefined); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!authCode.trim() || !taskId) return; + + setSubmitting(true); + setError(null); + + try { + // Send the auth code to the task via the message endpoint + await sendTaskMessage(taskId, `AUTH_CODE:${authCode.trim()}`); + setSubmitted(true); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to submit code"); + } finally { + setSubmitting(false); + } + }; + + if (submitted) { + return ( + <div className="bg-green-900/30 border border-green-500/50 rounded p-3 my-2"> + <div className="flex items-center gap-2 text-green-400 font-semibold"> + <span>✓</span> + <span>Authentication code submitted</span> + </div> + <p className="text-green-200/80 text-sm mt-1"> + Waiting for authentication to complete... + </p> + </div> + ); + } + + return ( + <div className="bg-amber-900/30 border border-amber-500/50 rounded p-3 my-2"> + <div className="flex items-center gap-2 text-amber-400 font-semibold mb-2"> + <span>🔐</span> + <span>Authentication Required{hostname ? ` (${hostname})` : ""}</span> + </div> + <p className="text-amber-200/80 text-sm mb-3"> + The daemon's OAuth token has expired. Click the button to login, then paste the code below: + </p> + + <div className="flex flex-col gap-3"> + {loginUrl ? ( + <a + href={loginUrl} + target="_blank" + rel="noopener noreferrer" + className="inline-block bg-amber-500 hover:bg-amber-400 text-black font-medium px-4 py-2 rounded transition-colors text-center" + > + 1. Login to Claude + </a> + ) : ( + <p className="text-red-400 text-sm">Login URL not available</p> + )} + + <form onSubmit={handleSubmit} className="flex gap-2"> + <input + type="text" + value={authCode} + onChange={(e) => setAuthCode(e.target.value)} + placeholder="2. Paste authentication code here" + className="flex-1 bg-[#0a1525] border border-amber-500/30 rounded px-3 py-2 text-amber-100 placeholder-amber-500/50 focus:outline-none focus:border-amber-400" + disabled={submitting} + /> + <button + type="submit" + disabled={submitting || !authCode.trim()} + className="bg-amber-500 hover:bg-amber-400 disabled:bg-amber-700 disabled:cursor-not-allowed text-black font-medium px-4 py-2 rounded transition-colors" + > + {submitting ? "..." : "Submit"} + </button> + </form> + + {error && ( + <p className="text-red-400 text-sm">{error}</p> + )} + </div> + </div> + ); +} 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> + ); +} |
