diff options
| author | soryu <soryu@soryu.co> | 2026-01-06 04:08:11 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-11 03:01:13 +0000 |
| commit | 8b17a175c3e7e27b789812eba4e3cd760beadb10 (patch) | |
| tree | 7864dcaa2fa9db47fdfd4e8bfdb0b1dde832aa33 /makima/frontend/src/components/mesh/TaskDetail.tsx | |
| parent | f79c416c58557d2f946aa5332989afdfa8c021cd (diff) | |
| download | soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip | |
Initial Control system
Diffstat (limited to 'makima/frontend/src/components/mesh/TaskDetail.tsx')
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskDetail.tsx | 886 |
1 files changed, 886 insertions, 0 deletions
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<void>; + onAutoMerge?: () => Promise<void>; + fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>; +} + +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<CompletionAction>( + (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<string | null>(null); + // Track interrupt dropdown state + const [showInterruptMenu, setShowInterruptMenu] = useState(false); + // Track retry completion action state + const [isRetryingCompletion, setIsRetryingCompletion] = useState(false); + const [retryError, setRetryError] = useState<string | null>(null); + // Suggested directories from daemon + const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]); + // Track clone worktree state + const [isCloning, setIsCloning] = useState(false); + const [cloneError, setCloneError] = useState<string | null>(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 ( + <div className="panel h-full flex items-center justify-center"> + <div className="font-mono text-[#9bc3ff] text-sm">Loading task...</div> + </div> + ); + } + + return ( + <div className="panel h-full flex flex-col overflow-hidden"> + {/* Header */} + <div className="flex items-center justify-between p-4 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)] shrink-0"> + <div className="flex items-center gap-3"> + <button + onClick={onBack} + className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + < Back + </button> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + TASK// + </div> + </div> + <div className="flex items-center gap-2"> + {isEditing ? ( + <> + <button + onClick={handleCancel} + className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase" + > + Cancel + </button> + <button + onClick={handleSave} + className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 transition-colors uppercase" + > + Save + </button> + </> + ) : ( + <> + {(task.status === "pending" || task.status === "failed") && ( + <button + onClick={() => onStart(task.id)} + className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 transition-colors uppercase" + > + Start + </button> + )} + {isTaskRunning && ( + <div className="relative"> + <button + onClick={() => setShowInterruptMenu(!showInterruptMenu)} + className="px-3 py-1 font-mono text-xs text-orange-400 border border-orange-400/30 hover:border-orange-400/50 hover:bg-orange-400/10 transition-colors uppercase flex items-center gap-1" + > + <span className="w-1.5 h-1.5 bg-orange-400 rounded-full animate-pulse" /> + Interrupt + </button> + {showInterruptMenu && ( + <> + {/* Backdrop to close menu on click outside */} + <div + className="fixed inset-0 z-40" + onClick={() => setShowInterruptMenu(false)} + /> + <div className="absolute right-0 top-full mt-1 z-50 bg-[#0a1525] border border-[rgba(117,170,252,0.35)] shadow-lg"> + <button + onClick={() => { + onRestart(task.id); + setShowInterruptMenu(false); + }} + className="block w-full px-4 py-2 font-mono text-xs text-left text-yellow-400 hover:bg-yellow-400/10 transition-colors whitespace-nowrap" + > + Restart Task + </button> + <button + onClick={() => { + onStop(task.id); + setShowInterruptMenu(false); + }} + className="block w-full px-4 py-2 font-mono text-xs text-left text-red-400 hover:bg-red-400/10 transition-colors whitespace-nowrap" + > + Cancel Task + </button> + </div> + </> + )} + </div> + )} + {isTaskTerminal && ( + <button + onClick={() => onContinue(task.id)} + className="px-3 py-1 font-mono text-xs text-cyan-400 border border-cyan-400/30 hover:border-cyan-400/50 hover:bg-cyan-400/10 transition-colors uppercase flex items-center gap-1" + > + <span className="w-1.5 h-1.5 bg-cyan-400 rounded-full" /> + Continue + </button> + )} + <button + onClick={() => setIsEditing(true)} + className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase" + > + Edit + </button> + <button + onClick={() => onDelete(task.id)} + className="px-3 py-1 font-mono text-xs text-red-400 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase" + > + Delete + </button> + </> + )} + </div> + </div> + + {/* Content */} + <div className="flex-1 overflow-y-auto p-4 space-y-4"> + {/* Task Info */} + <div className="space-y-3"> + {isEditing ? ( + <> + <input + type="text" + value={editName} + onChange={(e) => 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" + /> + <textarea + value={editDescription} + onChange={(e) => setEditDescription(e.target.value)} + className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3] min-h-[60px] resize-y" + placeholder="Description (optional)" + /> + </> + ) : ( + <> + <h2 className="font-mono text-lg text-[#dbe7ff]">{task.name}</h2> + {task.description && ( + <p className="font-mono text-sm text-[#9bc3ff]">{task.description}</p> + )} + </> + )} + + {/* Status badges */} + <div className="flex flex-wrap gap-2"> + <span + className={`px-2 py-0.5 font-mono text-xs uppercase ${getStatusColor( + task.status as TaskStatus + )} ${getStatusBgColor(task.status as TaskStatus)} border border-current/20`} + > + {task.status} + </span> + {/* 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"> + Orchestrator + </span> + )} + {/* Depth indicator for subtasks */} + {task.depth > 0 && ( + <span className="px-2 py-0.5 font-mono text-xs text-cyan-400 bg-cyan-400/10 border border-cyan-400/20"> + Depth: {task.depth} + </span> + )} + {task.priority > 0 && ( + <span className="px-2 py-0.5 font-mono text-xs text-orange-400 bg-orange-400/10 border border-orange-400/20"> + Priority: {task.priority} + </span> + )} + {task.mergeMode && ( + <span className="px-2 py-0.5 font-mono text-xs text-purple-400 bg-purple-400/10 border border-purple-400/20"> + Merge: {task.mergeMode} + </span> + )} + </div> + + {/* Metadata */} + <div className="flex flex-wrap gap-4 font-mono text-[10px] text-[#75aafc]"> + <span>Created: {formatDate(task.createdAt)}</span> + {task.startedAt && <span>Started: {formatDate(task.startedAt)}</span>} + {task.completedAt && <span>Completed: {formatDate(task.completedAt)}</span>} + <span>Version: {task.version}</span> + </div> + </div> + + {/* Plan */} + <div className="space-y-2"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Plan + </div> + {isEditing ? ( + <textarea + value={editPlan} + onChange={(e) => setEditPlan(e.target.value)} + className="w-full bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3] min-h-[200px] resize-y" + placeholder="Enter the plan/instructions for this task..." + /> + ) : ( + <pre className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 font-mono text-sm text-[#dbe7ff] whitespace-pre-wrap overflow-x-auto"> + {task.plan} + </pre> + )} + </div> + + {/* Progress Summary */} + {task.progressSummary && ( + <div className="space-y-2"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Progress + </div> + <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 font-mono text-sm text-[#9bc3ff]"> + {task.progressSummary} + </div> + </div> + )} + + {/* Last Output */} + {task.lastOutput && ( + <div className="space-y-2"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Last Output + </div> + <pre className="bg-[rgba(0,0,0,0.3)] border border-[rgba(117,170,252,0.15)] p-3 font-mono text-xs text-[#75aafc] whitespace-pre-wrap overflow-x-auto max-h-[200px] overflow-y-auto"> + {task.lastOutput} + </pre> + </div> + )} + + {/* Error Message */} + {task.errorMessage && ( + <div className="space-y-2"> + <div className="font-mono text-xs text-red-400 tracking-wide uppercase"> + Error + </div> + <div className="bg-red-400/5 border border-red-400/30 p-3 font-mono text-sm text-red-400"> + {task.errorMessage} + </div> + </div> + )} + + {/* Repository Info */} + {(task.repositoryUrl || task.baseBranch || task.targetBranch) && ( + <div className="space-y-2"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Repository + </div> + <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-1"> + {task.repositoryUrl && ( + <div className="font-mono text-xs text-[#75aafc]"> + <span className="text-[#555]">URL:</span> {task.repositoryUrl} + </div> + )} + {task.baseBranch && ( + <div className="font-mono text-xs text-[#75aafc]"> + <span className="text-[#555]">Base:</span> {task.baseBranch} + </div> + )} + {task.targetBranch && ( + <div className="font-mono text-xs text-[#75aafc]"> + <span className="text-[#555]">Target:</span> {task.targetBranch} + </div> + )} + {task.prUrl && ( + <div className="font-mono text-xs text-[#75aafc]"> + <span className="text-[#555]">PR:</span>{" "} + <a + href={task.prUrl} + target="_blank" + rel="noopener noreferrer" + className="text-[#9bc3ff] hover:underline" + > + {task.prUrl} + </a> + </div> + )} + </div> + </div> + )} + + {/* Completion Action Settings */} + <div className="space-y-2"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Completion Actions + </div> + {isEditing ? ( + <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-3"> + <div className="space-y-1"> + <label className="font-mono text-xs text-[#555]">Action on Completion</label> + <select + value={editCompletionAction} + onChange={(e) => setEditCompletionAction(e.target.value as CompletionAction)} + className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]" + > + <option value="none">None (keep in worktree)</option> + <option value="branch">Create branch in target repo</option> + <option value="merge">Auto-merge to target branch</option> + <option value="pr">Create Pull Request</option> + </select> + </div> + {editCompletionAction !== "none" && ( + <div className="space-y-1"> + <label className="font-mono text-xs text-[#555]">Target Repository Path</label> + <DirectoryInput + value={editTargetRepoPath} + onChange={setEditTargetRepoPath} + suggestions={suggestedDirectories} + placeholder="/path/to/your/local/repo" + repoUrl={task.repositoryUrl} + /> + <p className="font-mono text-[10px] text-[#555]"> + Path to your local repository where the branch will be pushed/merged. + </p> + </div> + )} + </div> + ) : ( + <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-1"> + <div className="font-mono text-xs text-[#75aafc]"> + <span className="text-[#555]">Action:</span>{" "} + {task.completionAction === "none" || !task.completionAction + ? "None (keep in worktree)" + : task.completionAction === "branch" + ? "Create branch in target repo" + : task.completionAction === "merge" + ? "Auto-merge to target branch" + : task.completionAction === "pr" + ? "Create Pull Request" + : task.completionAction} + </div> + {task.targetRepoPath && ( + <div className="font-mono text-xs text-[#75aafc]"> + <span className="text-[#555]">Target Repo:</span> {task.targetRepoPath} + </div> + )} + </div> + )} + </div> + + {/* Metadata Info */} + {(task.daemonId || task.containerId || task.overlayPath) && ( + <div className="space-y-2"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Metadata + </div> + <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-1"> + {task.daemonId && ( + <div className="font-mono text-xs text-[#75aafc]"> + <span className="text-[#555]">Daemon:</span> {task.daemonId} + </div> + )} + {task.containerId && ( + <div className="font-mono text-xs text-[#75aafc]"> + <span className="text-[#555]">Container:</span> {task.containerId} + </div> + )} + {task.overlayPath && ( + <div className="font-mono text-xs text-[#75aafc]"> + <span className="text-[#555]">Overlay:</span> {task.overlayPath} + </div> + )} + </div> + </div> + )} + + {/* Subtasks */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Subtasks ({task.subtasks.length}) + </div> + {task.subtasks.length > 0 && ( + <button + onClick={() => setUseTreeView(!useTreeView)} + className="font-mono text-[9px] text-[#555] hover:text-[#75aafc]" + > + {useTreeView ? "List" : "Tree"} + </button> + )} + </div> + {/* Disable adding subtasks at max depth (2 = sub-subtask, cannot have children) */} + {task.depth < 2 ? ( + <button + onClick={onCreateSubtask} + className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase" + > + + Add Subtask + </button> + ) : ( + <span className="px-2 py-1 font-mono text-[10px] text-[#555] border border-[#333]" title="Maximum depth reached"> + Max depth + </span> + )} + </div> + + {/* Progress bar for subtasks */} + {task.subtasks.length > 0 && ( + <SubtaskProgressBar stats={subtaskStats} /> + )} + + {task.subtasks.length === 0 ? ( + <div className="text-[#555] font-mono text-xs py-4 text-center"> + No subtasks yet + </div> + ) : useTreeView ? ( + <div className="border border-[rgba(117,170,252,0.15)]"> + <SubtaskTree + subtasks={task.subtasks} + onSelect={onSelectSubtask} + fetchSubtasks={fetchSubtasks} + /> + </div> + ) : ( + <div className="divide-y divide-[rgba(117,170,252,0.15)] border border-[rgba(117,170,252,0.15)]"> + {task.subtasks.map((subtask: TaskSummary) => { + const isRunning = subtask.status === "running" || subtask.status === "initializing" || subtask.status === "starting"; + const isViewingOutput = viewingSubtaskId === subtask.id; + const isExpanded = expandedSubtaskId === subtask.id; + + // Different highlight colors: green for running, subtle blue for others + const outputHighlightBg = isRunning ? "bg-green-400/10" : "bg-[rgba(117,170,252,0.08)]"; + const outputHighlightBorder = isRunning ? "border-l-green-400" : "border-l-[#75aafc]"; + const outputLabelColor = isRunning ? "text-green-400" : "text-[#75aafc]"; + + return ( + <div key={subtask.id}> + {/* Subtask header - clickable to view output */} + <div + className={`w-full p-3 text-left hover:bg-[rgba(117,170,252,0.05)] transition-colors cursor-pointer ${ + isExpanded && !isViewingOutput ? "bg-[rgba(117,170,252,0.08)]" : "" + } ${isViewingOutput ? `${outputHighlightBg} border-l-2 ${outputHighlightBorder}` : ""}`} + onClick={() => handleSubtaskClick(subtask)} + > + <div className="flex items-center gap-2 mb-1"> + <span className="text-[#555] text-xs"> + {isViewingOutput ? "[*]" : (isExpanded ? "[-]" : "[+]")} + </span> + <span className="font-mono text-sm text-[#dbe7ff]"> + {subtask.name} + </span> + <span + className={`px-1.5 py-0.5 font-mono text-[9px] uppercase ${getStatusColor( + subtask.status + )} ${getStatusBgColor(subtask.status)} border border-current/20`} + > + {subtask.status} + </span> + {subtask.subtaskCount > 0 && ( + <span className="font-mono text-[9px] text-[#555]"> + +{subtask.subtaskCount} + </span> + )} + {isViewingOutput && ( + <span className={`font-mono text-[9px] ${outputLabelColor} ml-auto`}> + {isRunning ? "viewing live output" : "viewing output"} + </span> + )} + {/* Expand/edit button - always available */} + <button + onClick={(e) => { + e.stopPropagation(); + handleSubtaskToggle(subtask.id); + }} + className={`ml-auto px-1.5 py-0.5 font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors ${ + isViewingOutput ? "ml-2" : "" + }`} + title="Expand details" + > + {isExpanded ? "-" : "+"} + </button> + </div> + {subtask.progressSummary && !isExpanded && !isViewingOutput && ( + <p className="font-mono text-xs text-[#75aafc] line-clamp-1 pl-6"> + {subtask.progressSummary} + </p> + )} + </div> + {/* Inline subtask editor */} + {isExpanded && ( + <InlineSubtaskEditor + subtaskId={subtask.id} + onClose={() => setExpandedSubtaskId(null)} + onUpdated={handleSubtaskUpdated} + onNavigate={onSelectSubtask} + /> + )} + </div> + ); + })} + </div> + )} + </div> + + {/* Action buttons for completed tasks */} + {(task.status === "done" || task.status === "merged" || task.status === "failed") && ( + <div className="space-y-2 pt-4 border-t border-[rgba(117,170,252,0.2)]"> + <div className="flex flex-wrap gap-2"> + {onRequestDiff && ( + <button + onClick={() => { + onRequestDiff(); + setShowDiff(true); + }} + className="px-3 py-1.5 font-mono text-xs text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + View Diff + </button> + )} + {canCreatePR && ( + <button + onClick={() => setShowPRPreview(true)} + className="px-3 py-1.5 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 transition-colors" + > + Create PR + </button> + )} + {/* Retry completion action button */} + {canRetryCompletion && ( + <button + onClick={handleRetryCompletion} + disabled={isRetryingCompletion} + className="px-3 py-1.5 font-mono text-xs text-cyan-400 border border-cyan-400/30 hover:border-cyan-400/50 hover:bg-cyan-400/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + {isRetryingCompletion + ? "Retrying..." + : task.completionAction === "branch" + ? "Push Branch" + : task.completionAction === "merge" + ? "Merge to Target" + : task.completionAction === "pr" + ? "Create PR" + : "Run Completion Action"} + </button> + )} + {/* Show hint if completion action needs configuration */} + {!canRetryCompletion && ( + <span className="px-3 py-1.5 font-mono text-xs text-[#555] italic"> + {!task.completionAction || task.completionAction === "none" + ? "Set completion action to enable" + : !task.targetRepoPath + ? "Set target repo path to enable" + : ""} + </span> + )} + </div> + {/* Retry error message */} + {retryError && ( + <div className="font-mono text-xs text-red-400 bg-red-400/10 px-3 py-2 border border-red-400/30"> + {retryError} + </div> + )} + </div> + )} + + {/* Clone Worktree Section */} + {canCloneWorktree && ( + <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-2"> + <div className="font-mono text-xs text-[#555]">Clone Worktree to Directory</div> + <div className="flex gap-2 items-start"> + <DirectoryInput + value={cloneTargetDir} + onChange={setCloneTargetDir} + suggestions={suggestedDirectories} + placeholder="/path/to/clone" + repoUrl={task.repositoryUrl} + className="flex-1" + /> + <button + onClick={handleCloneWorktree} + disabled={isCloning || !cloneTargetDir.trim()} + className="px-3 py-2 font-mono text-xs text-purple-400 border border-purple-400/30 hover:border-purple-400/50 hover:bg-purple-400/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shrink-0" + > + {isCloning ? "Cloning..." : "Clone"} + </button> + </div> + <p className="font-mono text-[10px] text-[#555]"> + Clone the worktree (git repo) to a new directory. Useful for moving completed work outside ~/.makima. + </p> + {cloneError && ( + <div className="font-mono text-xs text-red-400 bg-red-400/10 px-3 py-2 border border-red-400/30"> + {cloneError} + </div> + )} + </div> + )} + </div> + + {/* Overlay Diff Modal */} + {showDiff && overlayDiff !== undefined && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> + <div className="max-w-4xl w-full max-h-[80vh]"> + <OverlayDiffViewer + diff={overlayDiff} + changedFiles={changedFiles} + onClose={() => setShowDiff(false)} + title={`Changes in ${task.name}`} + /> + </div> + </div> + )} + + {/* PR Preview Modal */} + {showPRPreview && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> + <div className="max-w-3xl w-full"> + <PRPreview + task={task} + diff={overlayDiff} + changedFiles={changedFiles} + onCreatePR={onCreatePR} + onAutoMerge={task.mergeMode === "auto" ? onAutoMerge : undefined} + onClose={() => setShowPRPreview(false)} + /> + </div> + </div> + )} + </div> + ); +} |
