From 8b17a175c3e7e27b789812eba4e3cd760beadb10 Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 6 Jan 2026 04:08:11 +0000 Subject: Initial Control system --- makima/frontend/src/components/mesh/TaskDetail.tsx | 886 +++++++++++++++++++++ 1 file changed, 886 insertions(+) create mode 100644 makima/frontend/src/components/mesh/TaskDetail.tsx (limited to 'makima/frontend/src/components/mesh/TaskDetail.tsx') diff --git a/makima/frontend/src/components/mesh/TaskDetail.tsx b/makima/frontend/src/components/mesh/TaskDetail.tsx new file mode 100644 index 0000000..be4fb80 --- /dev/null +++ b/makima/frontend/src/components/mesh/TaskDetail.tsx @@ -0,0 +1,886 @@ +import { useState, useCallback, useMemo, useEffect } from "react"; +import type { TaskWithSubtasks, TaskStatus, TaskSummary, CompletionAction, DaemonDirectory } from "../../lib/api"; +import { retryCompletionAction, getDaemonDirectories, cloneWorktree } from "../../lib/api"; +import { SubtaskTree, SubtaskProgressBar, calculateTreeStats } from "./SubtaskTree"; +import { OverlayDiffViewer } from "./OverlayDiffViewer"; +import { PRPreview } from "./PRPreview"; +import { InlineSubtaskEditor } from "./InlineSubtaskEditor"; +import { DirectoryInput } from "./DirectoryInput"; + +interface TaskDetailProps { + task: TaskWithSubtasks; + loading: boolean; + onBack: () => void; + onSave: (taskId: string, name: string, description: string, plan: string, targetRepoPath?: string, completionAction?: CompletionAction) => void; + onDelete: (taskId: string) => void; + onStart: (taskId: string) => void; + onStop: (taskId: string) => void; + onRestart: (taskId: string) => void; + onContinue: (taskId: string) => void; + onSelectSubtask: (taskId: string) => void; + onCreateSubtask: () => void; + /** Toggle viewing a subtask's output (for running subtasks) */ + onToggleSubtaskOutput?: (subtaskId: string, subtaskName: string) => void; + /** Which subtask's output is currently being viewed */ + viewingSubtaskId?: string | null; + // Optional advanced features + overlayDiff?: string; + changedFiles?: string[]; + onRequestDiff?: () => void; + onCreatePR?: (title: string, body: string, draft: boolean) => Promise; + onAutoMerge?: () => Promise; + fetchSubtasks?: (taskId: string) => Promise; +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function getStatusColor(status: TaskStatus): string { + switch (status) { + case "pending": + return "text-[#9bc3ff]"; + case "initializing": + case "starting": + return "text-cyan-400"; + 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 getStatusBgColor(status: TaskStatus): string { + switch (status) { + case "pending": + return "bg-[rgba(117,170,252,0.1)]"; + case "initializing": + case "starting": + return "bg-cyan-400/10"; + case "running": + return "bg-green-400/10"; + case "paused": + return "bg-yellow-400/10"; + case "blocked": + return "bg-orange-400/10"; + case "done": + return "bg-emerald-400/10"; + case "failed": + return "bg-red-400/10"; + case "merged": + return "bg-purple-400/10"; + default: + return "bg-[rgba(117,170,252,0.1)]"; + } +} + +export function TaskDetail({ + task, + loading, + onBack, + onSave, + onDelete, + onStart, + onStop, + onRestart, + onContinue, + onSelectSubtask, + onCreateSubtask, + onToggleSubtaskOutput, + viewingSubtaskId, + overlayDiff, + changedFiles, + onRequestDiff, + onCreatePR, + onAutoMerge, + fetchSubtasks, +}: TaskDetailProps) { + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(task.name); + const [editDescription, setEditDescription] = useState(task.description || ""); + const [editPlan, setEditPlan] = useState(task.plan); + const [editTargetRepoPath, setEditTargetRepoPath] = useState(task.targetRepoPath || ""); + const [editCompletionAction, setEditCompletionAction] = useState( + (task.completionAction as CompletionAction) || "none" + ); + const [showDiff, setShowDiff] = useState(false); + const [showPRPreview, setShowPRPreview] = useState(false); + const [useTreeView, setUseTreeView] = useState(false); + // Track which subtask is expanded for inline editing + const [expandedSubtaskId, setExpandedSubtaskId] = useState(null); + // Track interrupt dropdown state + const [showInterruptMenu, setShowInterruptMenu] = useState(false); + // Track retry completion action state + const [isRetryingCompletion, setIsRetryingCompletion] = useState(false); + const [retryError, setRetryError] = useState(null); + // Suggested directories from daemon + const [suggestedDirectories, setSuggestedDirectories] = useState([]); + // Track clone worktree state + const [isCloning, setIsCloning] = useState(false); + const [cloneError, setCloneError] = useState(null); + const [cloneTargetDir, setCloneTargetDir] = useState(""); + + // Check if task is running + const isTaskRunning = task.status === "running" || task.status === "initializing" || task.status === "starting"; + // Check if task is in a terminal state (can be continued/reopened) + const isTaskTerminal = task.status === "done" || task.status === "failed" || task.status === "merged"; + + // Calculate subtask statistics + const subtaskStats = useMemo( + () => calculateTreeStats(task.subtasks), + [task.subtasks] + ); + + // Check if task can create PR + const canCreatePR = useMemo(() => { + return ( + (task.status === "done" || task.status === "merged") && + task.repositoryUrl && + (onCreatePR || onAutoMerge) + ); + }, [task.status, task.repositoryUrl, onCreatePR, onAutoMerge]); + + // Check if task can retry completion action + const canRetryCompletion = useMemo(() => { + return ( + (task.status === "done" || task.status === "failed" || task.status === "merged") && + task.completionAction && + task.completionAction !== "none" && + task.targetRepoPath + // Note: overlayPath may be null in server DB even if worktree exists on daemon + // The daemon will scan for the worktree by task ID + ); + }, [task.status, task.completionAction, task.targetRepoPath]); + + // Handler for retrying completion action + const handleRetryCompletion = useCallback(async () => { + setIsRetryingCompletion(true); + setRetryError(null); + try { + await retryCompletionAction(task.id); + // Success - the result will be shown in task output + } catch (e) { + setRetryError(e instanceof Error ? e.message : "Failed to retry completion action"); + } finally { + setIsRetryingCompletion(false); + } + }, [task.id]); + + // Check if task can clone worktree + const canCloneWorktree = useMemo(() => { + return ( + (task.status === "done" || task.status === "failed" || task.status === "merged") + ); + }, [task.status]); + + // Handler for cloning worktree + const handleCloneWorktree = useCallback(async () => { + if (!cloneTargetDir.trim()) { + setCloneError("Please enter a target directory"); + return; + } + setIsCloning(true); + setCloneError(null); + try { + await cloneWorktree(task.id, cloneTargetDir); + // Success - the result will be shown in task output + setCloneTargetDir(""); // Clear input on success + } catch (e) { + setCloneError(e instanceof Error ? e.message : "Failed to clone worktree"); + } finally { + setIsCloning(false); + } + }, [task.id, cloneTargetDir]); + + // Fetch suggested directories when entering edit mode or when clone section is visible + useEffect(() => { + if (isEditing || canCloneWorktree) { + getDaemonDirectories() + .then((res) => setSuggestedDirectories(res.directories)) + .catch(() => setSuggestedDirectories([])); + } + }, [isEditing, canCloneWorktree]); + + const handleSave = useCallback(() => { + onSave( + task.id, + editName, + editDescription, + editPlan, + editTargetRepoPath || undefined, + editCompletionAction + ); + setIsEditing(false); + }, [task.id, editName, editDescription, editPlan, editTargetRepoPath, editCompletionAction, onSave]); + + const handleCancel = useCallback(() => { + setEditName(task.name); + setEditDescription(task.description || ""); + setEditPlan(task.plan); + setEditTargetRepoPath(task.targetRepoPath || ""); + setEditCompletionAction((task.completionAction as CompletionAction) || "none"); + setIsEditing(false); + }, [task]); + + // Toggle subtask expansion for inline editing + const handleSubtaskToggle = useCallback((subtaskId: string) => { + setExpandedSubtaskId((prev) => (prev === subtaskId ? null : subtaskId)); + }, []); + + // Handle subtask click - toggle output view for any task status + const handleSubtaskClick = useCallback( + (subtask: TaskSummary) => { + if (onToggleSubtaskOutput) { + // Toggle viewing this subtask's output (works for any status) + onToggleSubtaskOutput(subtask.id, subtask.name); + } else { + // Fallback to expand/collapse if output viewing not available + handleSubtaskToggle(subtask.id); + } + }, + [onToggleSubtaskOutput, handleSubtaskToggle] + ); + + // Called when inline subtask editor saves changes + const handleSubtaskUpdated = useCallback(() => { + // Re-fetch the parent task to refresh subtask list + // This will trigger from the parent component when task updates + }, []); + + if (loading) { + return ( +
+
Loading task...
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+ TASK// +
+
+
+ {isEditing ? ( + <> + + + + ) : ( + <> + {(task.status === "pending" || task.status === "failed") && ( + + )} + {isTaskRunning && ( +
+ + {showInterruptMenu && ( + <> + {/* Backdrop to close menu on click outside */} +
setShowInterruptMenu(false)} + /> +
+ + +
+ + )} +
+ )} + {isTaskTerminal && ( + + )} + + + + )} +
+
+ + {/* Content */} +
+ {/* Task Info */} +
+ {isEditing ? ( + <> + setEditName(e.target.value)} + className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-lg px-3 py-2 outline-none focus:border-[#3f6fb3]" + placeholder="Task name" + /> +