From 87044a747b47bd83249d61a45842c7f7b2eae56d Mon Sep 17 00:00:00 2001 From: soryu Date: Sun, 11 Jan 2026 05:52:14 +0000 Subject: Contract system --- makima/frontend/src/components/mesh/TaskDetail.tsx | 12 + makima/frontend/src/components/mesh/TaskList.tsx | 215 +++++++++--- makima/frontend/src/components/mesh/TaskOutput.tsx | 96 +++++ makima/frontend/src/components/mesh/TaskTree.tsx | 390 +++++++++++++++++++++ 4 files changed, 659 insertions(+), 54 deletions(-) create mode 100644 makima/frontend/src/components/mesh/TaskTree.tsx (limited to 'makima/frontend/src/components/mesh') 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} + {/* Contract badge - clickable to view contract */} + {task.contractId && onViewContract && ( + + )} {/* Orchestrator badge for depth 0 tasks with subtasks */} {task.depth === 0 && task.subtasks.length > 0 && ( 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(); + + 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 (
@@ -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 (
@@ -94,65 +159,107 @@ export function TaskList({
- {rootTasks.length === 0 ? ( + {totalTasks === 0 ? (
No tasks yet. Create one to start orchestrating Claude Code instances.
) : ( -
- {rootTasks.map((task) => ( -
-
- + {/* Supervisor tasks cannot be deleted directly - they are deleted with the contract */} + {!task.isSupervisor && ( + + )} +
- - + ))}
))} 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 }) {
); + case "auth_required": + return ; + 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(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 ( +
+
+ + Authentication code submitted +
+

+ Waiting for authentication to complete... +

+
+ ); + } + + return ( +
+
+ 🔐 + Authentication Required{hostname ? ` (${hostname})` : ""} +
+

+ The daemon's OAuth token has expired. Click the button to login, then paste the code below: +

+ +
+ {loginUrl ? ( + + 1. Login to Claude + + ) : ( +

Login URL not available

+ )} + +
+ 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} + /> + +
+ + {error && ( +

{error}

+ )} +
+
+ ); +} 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; +} + +interface TreeNodeProps { + task: TaskSummary; + isSupervisorTask: boolean; + onSelect: (taskId: string) => void; + depth: number; + fetchSubtasks?: (taskId: string) => Promise; +} + +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(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 ( +
+
+ {/* Expand/Collapse button */} + + + {/* Supervisor badge or status icon */} + {isSupervisorTask ? ( + + Supervisor + + ) : ( + + {getStatusIcon(task.status)} + + )} + + {/* Task name - clickable */} + + + {/* Status for supervisor */} + {isSupervisorTask && ( + + {task.status} + + )} + + {/* Subtask count badge */} + {hasSubtasks && !isSupervisorTask && ( + + {task.subtaskCount} sub + + )} + + {/* Priority indicator */} + {task.priority > 0 && ( + + P{task.priority} + + )} +
+ + {/* Children */} + {expanded && children && children.length > 0 && ( +
+ {children.map((child) => ( + + ))} +
+ )} +
+ ); +} + +// 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 ( +
+ {/* Progress bar */} +
+
+
+
+
+ + {/* Summary */} +
+ + {stats.done + stats.merged} / {stats.total} completed + + {stats.running > 0 && ( + {stats.running} running + )} + {stats.failed > 0 && ( + {stats.failed} failed + )} +
+
+ ); +} + +export function TaskTree({ + tasks, + supervisorTaskId, + onSelect, + onStartSupervisor, + loading = false, + fetchSubtasks, +}: TaskTreeProps) { + if (loading) { + return ( +
+ Loading tasks... +
+ ); + } + + // 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 ( +
+ {/* Supervisor Section */} +
+
+

+ Contract Supervisor +

+ {supervisorTask && supervisorTask.status === "pending" && onStartSupervisor && ( + + )} +
+ + {supervisorTask ? ( + + ) : ( +
+

+ No supervisor task found +

+
+ )} +
+ + {/* Progress Section */} + {stats.total > 0 && ( +
+

+ Task Progress +

+ +
+ )} + + {/* Worker Tasks Section */} + {workerTasks.length > 0 && ( +
+

+ Worker Tasks ({workerTasks.length}) +

+
+ {workerTasks.map((task) => ( + + ))} +
+
+ )} + + {/* Empty State */} + {workerTasks.length === 0 && !supervisorTask && ( +
+ No tasks in this contract +
+ )} +
+ ); +} -- cgit v1.2.3