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 | |
| parent | f79c416c58557d2f946aa5332989afdfa8c021cd (diff) | |
| download | soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip | |
Initial Control system
Diffstat (limited to 'makima/frontend/src/components/mesh')
| -rw-r--r-- | makima/frontend/src/components/mesh/DirectoryInput.tsx | 220 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx | 262 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/MergeConflictResolver.tsx | 504 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/OverlayDiffViewer.tsx | 476 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/PRPreview.tsx | 314 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/SubtaskTree.tsx | 297 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskDetail.tsx | 886 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskList.tsx | 164 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskOutput.tsx | 281 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx | 536 |
10 files changed, 3940 insertions, 0 deletions
diff --git a/makima/frontend/src/components/mesh/DirectoryInput.tsx b/makima/frontend/src/components/mesh/DirectoryInput.tsx new file mode 100644 index 0000000..e2e331e --- /dev/null +++ b/makima/frontend/src/components/mesh/DirectoryInput.tsx @@ -0,0 +1,220 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import type { DaemonDirectory } from "../../lib/api"; + +interface DirectoryInputProps { + value: string; + onChange: (value: string) => void; + suggestions: DaemonDirectory[]; + placeholder?: string; + /** Repository URL to extract repo name for home directory suggestions */ + repoUrl?: string | null; + className?: string; + disabled?: boolean; +} + +/** Extract repository name from URL */ +function extractRepoName(url: string | null | undefined): string | null { + if (!url) return null; + + // Handle various URL formats: + // https://github.com/user/repo.git -> repo + // https://github.com/user/repo -> repo + // git@github.com:user/repo.git -> repo + // /path/to/local/repo -> repo + + let name = url; + + // Remove trailing .git + if (name.endsWith(".git")) { + name = name.slice(0, -4); + } + + // Remove trailing slash + if (name.endsWith("/")) { + name = name.slice(0, -1); + } + + // Get the last path segment + const lastSlash = name.lastIndexOf("/"); + if (lastSlash !== -1) { + name = name.slice(lastSlash + 1); + } + + // Handle git@host:user/repo format + const colonIndex = name.lastIndexOf(":"); + if (colonIndex !== -1) { + const afterColon = name.slice(colonIndex + 1); + const slashIndex = afterColon.lastIndexOf("/"); + if (slashIndex !== -1) { + name = afterColon.slice(slashIndex + 1); + } else { + name = afterColon; + } + } + + return name || null; +} + +export function DirectoryInput({ + value, + onChange, + suggestions, + placeholder = "/path/to/directory", + repoUrl, + className = "", + disabled = false, +}: DirectoryInputProps) { + const [showSuggestions, setShowSuggestions] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const inputRef = useRef<HTMLInputElement>(null); + const dropdownRef = useRef<HTMLDivElement>(null); + + // Extract repo name for home directory suggestions + const repoName = extractRepoName(repoUrl); + + // Process suggestions to add repo name to home directory + const processedSuggestions = suggestions.map((dir) => { + if (dir.directoryType === "home" && repoName) { + return { + ...dir, + path: `${dir.path}/${repoName}`, + label: `${dir.label} (${repoName})`, + }; + } + return dir; + }); + + // Filter suggestions based on current input + const filteredSuggestions = processedSuggestions.filter((dir) => { + if (!value) return true; + const lowerValue = value.toLowerCase(); + return ( + dir.path.toLowerCase().includes(lowerValue) || + dir.label.toLowerCase().includes(lowerValue) + ); + }); + + // Handle clicking outside to close dropdown + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) && + inputRef.current && + !inputRef.current.contains(e.target as Node) + ) { + setShowSuggestions(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const handleFocus = useCallback(() => { + setShowSuggestions(true); + setHighlightedIndex(-1); + }, []); + + const handleBlur = useCallback(() => { + // Delay hiding to allow click on suggestion + setTimeout(() => { + setShowSuggestions(false); + }, 150); + }, []); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!showSuggestions || filteredSuggestions.length === 0) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setHighlightedIndex((prev) => + prev < filteredSuggestions.length - 1 ? prev + 1 : 0 + ); + break; + case "ArrowUp": + e.preventDefault(); + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredSuggestions.length - 1 + ); + break; + case "Enter": + e.preventDefault(); + if (highlightedIndex >= 0 && highlightedIndex < filteredSuggestions.length) { + onChange(filteredSuggestions[highlightedIndex].path); + setShowSuggestions(false); + } + break; + case "Escape": + setShowSuggestions(false); + break; + } + }, + [showSuggestions, filteredSuggestions, highlightedIndex, onChange] + ); + + const handleSelectSuggestion = useCallback( + (path: string) => { + onChange(path); + setShowSuggestions(false); + inputRef.current?.focus(); + }, + [onChange] + ); + + return ( + <div className={`relative ${className}`}> + <input + ref={inputRef} + type="text" + value={value} + onChange={(e) => onChange(e.target.value)} + onFocus={handleFocus} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled} + 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] disabled:opacity-50" + /> + + {/* Suggestions dropdown */} + {showSuggestions && filteredSuggestions.length > 0 && ( + <div + ref={dropdownRef} + className="absolute z-50 left-0 right-0 top-full mt-1 bg-[#0a1525] border border-[rgba(117,170,252,0.35)] shadow-lg max-h-48 overflow-auto" + > + {filteredSuggestions.map((dir, index) => ( + <button + key={`${dir.directoryType}-${index}`} + type="button" + onClick={() => handleSelectSuggestion(dir.path)} + onMouseEnter={() => setHighlightedIndex(index)} + className={`w-full text-left px-3 py-2 font-mono text-xs transition-colors ${ + index === highlightedIndex + ? "bg-[rgba(117,170,252,0.2)] text-[#dbe7ff]" + : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)]" + }`} + > + <div className="flex items-center justify-between gap-2"> + <div className="flex items-center gap-2"> + <span className="text-[#75aafc]">{dir.label}</span> + {dir.exists === true && ( + <span className="text-[#f0ad4e] text-[10px]" title="Directory already exists"> + (exists) + </span> + )} + </div> + {dir.hostname && ( + <span className="text-[#555] text-[10px]">({dir.hostname})</span> + )} + </div> + <div className="text-[10px] text-[#555] truncate">{dir.path}</div> + </button> + ))} + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx b/makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx new file mode 100644 index 0000000..3621b08 --- /dev/null +++ b/makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx @@ -0,0 +1,262 @@ +import { useState, useCallback, useEffect } from "react"; +import type { TaskWithSubtasks, TaskStatus } from "../../lib/api"; +import { getTask, updateTask } from "../../lib/api"; + +interface InlineSubtaskEditorProps { + subtaskId: string; + onClose: () => void; + onUpdated: () => void; + onNavigate?: (taskId: string) => void; +} + +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 getStatusBgColor(status: TaskStatus): string { + switch (status) { + case "pending": + return "bg-[rgba(117,170,252,0.1)]"; + 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 InlineSubtaskEditor({ + subtaskId, + onClose, + onUpdated, + onNavigate, +}: InlineSubtaskEditorProps) { + const [subtask, setSubtask] = useState<TaskWithSubtasks | null>(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(""); + const [editDescription, setEditDescription] = useState(""); + const [editPlan, setEditPlan] = useState(""); + + // Load subtask details + useEffect(() => { + setLoading(true); + getTask(subtaskId) + .then((task) => { + setSubtask(task); + setEditName(task.name); + setEditDescription(task.description || ""); + setEditPlan(task.plan); + }) + .catch((err) => { + console.error("Failed to load subtask:", err); + }) + .finally(() => { + setLoading(false); + }); + }, [subtaskId]); + + const handleSave = useCallback(async () => { + if (!subtask || saving) return; + setSaving(true); + try { + await updateTask(subtaskId, { + name: editName, + description: editDescription || undefined, + plan: editPlan, + version: subtask.version, + }); + // Refresh subtask + const updated = await getTask(subtaskId); + setSubtask(updated); + setIsEditing(false); + onUpdated(); + } catch (err) { + console.error("Failed to save subtask:", err); + } finally { + setSaving(false); + } + }, [subtask, subtaskId, editName, editDescription, editPlan, saving, onUpdated]); + + const handleCancel = useCallback(() => { + if (subtask) { + setEditName(subtask.name); + setEditDescription(subtask.description || ""); + setEditPlan(subtask.plan); + } + setIsEditing(false); + }, [subtask]); + + if (loading) { + return ( + <div className="p-4 bg-[rgba(0,0,0,0.2)] border-l-2 border-[#3f6fb3]"> + <div className="font-mono text-xs text-[#75aafc]">Loading subtask...</div> + </div> + ); + } + + if (!subtask) { + return ( + <div className="p-4 bg-[rgba(0,0,0,0.2)] border-l-2 border-red-400"> + <div className="font-mono text-xs text-red-400">Failed to load subtask</div> + </div> + ); + } + + return ( + <div className="bg-[rgba(0,0,0,0.2)] border-l-2 border-[#3f6fb3]"> + {/* Header */} + <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.15)]"> + <div className="flex items-center gap-2"> + <button + onClick={onClose} + className="font-mono text-[10px] text-[#555] hover:text-[#75aafc]" + > + [close] + </button> + <span + className={`px-1.5 py-0.5 font-mono text-[9px] uppercase ${getStatusColor( + subtask.status as TaskStatus + )} ${getStatusBgColor(subtask.status as TaskStatus)} border border-current/20`} + > + {subtask.status} + </span> + {onNavigate && ( + <button + onClick={() => onNavigate(subtaskId)} + className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff]" + > + [open full view] + </button> + )} + </div> + <div className="flex items-center gap-2"> + {isEditing ? ( + <> + <button + onClick={handleCancel} + disabled={saving} + className="px-2 py-0.5 font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] disabled:opacity-50" + > + Cancel + </button> + <button + onClick={handleSave} + disabled={saving} + className="px-2 py-0.5 font-mono text-[10px] text-green-400 border border-green-400/30 hover:border-green-400/50 disabled:opacity-50" + > + {saving ? "..." : "Save"} + </button> + </> + ) : ( + <button + onClick={() => setIsEditing(true)} + className="px-2 py-0.5 font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff]" + > + Edit + </button> + )} + </div> + </div> + + {/* Content */} + <div className="p-3 space-y-3"> + {/* Name */} + {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-sm px-2 py-1 outline-none focus:border-[#3f6fb3]" + placeholder="Subtask name" + /> + ) : ( + <div className="font-mono text-sm text-[#dbe7ff]">{subtask.name}</div> + )} + + {/* Description */} + {isEditing ? ( + <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-xs px-2 py-1 outline-none focus:border-[#3f6fb3] min-h-[40px] resize-y" + placeholder="Description (optional)" + /> + ) : subtask.description ? ( + <div className="font-mono text-xs text-[#75aafc]">{subtask.description}</div> + ) : null} + + {/* Plan */} + <div className="space-y-1"> + <div className="font-mono text-[10px] text-[#555] 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-xs px-2 py-1 outline-none focus:border-[#3f6fb3] min-h-[100px] resize-y" + placeholder="Plan/instructions..." + /> + ) : ( + <pre className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-2 font-mono text-xs text-[#9bc3ff] whitespace-pre-wrap max-h-[150px] overflow-y-auto"> + {subtask.plan} + </pre> + )} + </div> + + {/* Progress/Error */} + {subtask.progressSummary && ( + <div className="font-mono text-[10px] text-[#75aafc]"> + <span className="text-[#555]">Progress:</span> {subtask.progressSummary} + </div> + )} + {subtask.errorMessage && ( + <div className="font-mono text-[10px] text-red-400"> + <span className="text-red-400/50">Error:</span> {subtask.errorMessage} + </div> + )} + + {/* Nested subtasks indicator */} + {subtask.subtasks.length > 0 && ( + <div className="font-mono text-[10px] text-[#555]"> + Has {subtask.subtasks.length} subtask{subtask.subtasks.length > 1 ? "s" : ""} + {onNavigate && ( + <button + onClick={() => onNavigate(subtaskId)} + className="ml-2 text-[#75aafc] hover:text-[#9bc3ff]" + > + [view all] + </button> + )} + </div> + )} + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/mesh/MergeConflictResolver.tsx b/makima/frontend/src/components/mesh/MergeConflictResolver.tsx new file mode 100644 index 0000000..4479705 --- /dev/null +++ b/makima/frontend/src/components/mesh/MergeConflictResolver.tsx @@ -0,0 +1,504 @@ +import { useState, useMemo, useCallback } from "react"; + +interface ConflictHunk { + id: string; + filePath: string; + startLine: number; + endLine: number; + ours: string[]; // Changes from current branch + theirs: string[]; // Changes from incoming branch + base?: string[]; // Original content (if 3-way merge) + resolved?: "ours" | "theirs" | "both" | "custom"; + customResolution?: string[]; +} + +interface ConflictFile { + path: string; + hunks: ConflictHunk[]; + resolved: boolean; +} + +interface MergeConflictResolverProps { + conflicts: ConflictFile[]; + sourceBranch: string; + targetBranch: string; + loading?: boolean; + onResolve: (resolutions: Map<string, ConflictHunk[]>) => Promise<void>; + onAbort: () => void; + onAskLLM?: (hunk: ConflictHunk) => Promise<string[]>; +} + +type ResolutionChoice = "ours" | "theirs" | "both" | "custom"; + +function ConflictHunkView({ + hunk, + sourceBranch, + targetBranch, + onResolve, + onAskLLM, +}: { + hunk: ConflictHunk; + sourceBranch: string; + targetBranch: string; + onResolve: (resolution: ResolutionChoice, customLines?: string[]) => void; + onAskLLM?: () => Promise<void>; +}) { + const [showCustomEditor, setShowCustomEditor] = useState(false); + const [customText, setCustomText] = useState( + hunk.customResolution?.join("\n") || [...hunk.ours, ...hunk.theirs].join("\n") + ); + const [askingLLM, setAskingLLM] = useState(false); + + const handleAskLLM = async () => { + if (!onAskLLM || askingLLM) return; + setAskingLLM(true); + try { + await onAskLLM(); + } finally { + setAskingLLM(false); + } + }; + + const handleCustomSave = () => { + const lines = customText.split("\n"); + onResolve("custom", lines); + setShowCustomEditor(false); + }; + + const isResolved = hunk.resolved !== undefined; + + return ( + <div + className={`border ${ + isResolved + ? "border-green-400/30 bg-green-400/5" + : "border-yellow-400/30 bg-yellow-400/5" + } mb-3`} + > + {/* Hunk header */} + <div className="flex items-center justify-between px-3 py-2 border-b border-[rgba(117,170,252,0.2)]"> + <div className="font-mono text-xs text-[#75aafc]"> + Lines {hunk.startLine}-{hunk.endLine} + {isResolved && ( + <span className="ml-2 text-green-400"> + (Resolved: {hunk.resolved}) + </span> + )} + </div> + <div className="flex items-center gap-2"> + {onAskLLM && ( + <button + onClick={handleAskLLM} + disabled={askingLLM} + className="px-2 py-1 font-mono text-[10px] text-purple-400 border border-purple-400/30 hover:border-purple-400/50 disabled:opacity-50 transition-colors" + > + {askingLLM ? "..." : "Ask LLM"} + </button> + )} + <button + onClick={() => setShowCustomEditor(!showCustomEditor)} + className="px-2 py-1 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + Edit + </button> + </div> + </div> + + {/* Conflict content */} + {!showCustomEditor ? ( + <div className="grid grid-cols-2 divide-x divide-[rgba(117,170,252,0.2)]"> + {/* Ours (current branch) */} + <div className="p-2"> + <div className="flex items-center justify-between mb-2"> + <span className="font-mono text-[10px] text-[#9bc3ff] uppercase"> + {targetBranch} (ours) + </span> + <button + onClick={() => onResolve("ours")} + className={`px-2 py-0.5 font-mono text-[9px] border transition-colors ${ + hunk.resolved === "ours" + ? "text-green-400 border-green-400/50 bg-green-400/10" + : "text-[#75aafc] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]" + }`} + > + Use This + </button> + </div> + <pre className="font-mono text-xs text-red-400 bg-red-400/5 p-2 overflow-x-auto"> + {hunk.ours.map((line, i) => ( + <div key={i}> + <span className="text-[#555] select-none mr-2">-</span> + {line} + </div> + ))} + </pre> + </div> + + {/* Theirs (incoming branch) */} + <div className="p-2"> + <div className="flex items-center justify-between mb-2"> + <span className="font-mono text-[10px] text-[#9bc3ff] uppercase"> + {sourceBranch} (theirs) + </span> + <button + onClick={() => onResolve("theirs")} + className={`px-2 py-0.5 font-mono text-[9px] border transition-colors ${ + hunk.resolved === "theirs" + ? "text-green-400 border-green-400/50 bg-green-400/10" + : "text-[#75aafc] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]" + }`} + > + Use This + </button> + </div> + <pre className="font-mono text-xs text-green-400 bg-green-400/5 p-2 overflow-x-auto"> + {hunk.theirs.map((line, i) => ( + <div key={i}> + <span className="text-[#555] select-none mr-2">+</span> + {line} + </div> + ))} + </pre> + </div> + </div> + ) : ( + /* Custom editor */ + <div className="p-2"> + <div className="flex items-center justify-between mb-2"> + <span className="font-mono text-[10px] text-[#9bc3ff] uppercase"> + Custom Resolution + </span> + <div className="flex items-center gap-2"> + <button + onClick={() => setShowCustomEditor(false)} + className="px-2 py-0.5 font-mono text-[9px] text-[#555] hover:text-[#9bc3ff]" + > + Cancel + </button> + <button + onClick={handleCustomSave} + className="px-2 py-0.5 font-mono text-[9px] text-green-400 border border-green-400/30 hover:border-green-400/50" + > + Apply + </button> + </div> + </div> + <textarea + value={customText} + onChange={(e) => setCustomText(e.target.value)} + className="w-full bg-[rgba(0,0,0,0.3)] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-xs p-2 outline-none focus:border-[#3f6fb3] min-h-[100px] resize-y" + /> + </div> + )} + + {/* Both option */} + <div className="px-3 py-2 border-t border-[rgba(117,170,252,0.1)] flex justify-center"> + <button + onClick={() => onResolve("both")} + className={`px-3 py-1 font-mono text-[10px] border transition-colors ${ + hunk.resolved === "both" + ? "text-green-400 border-green-400/50 bg-green-400/10" + : "text-[#75aafc] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]" + }`} + > + Keep Both + </button> + </div> + </div> + ); +} + +function ConflictFileView({ + file, + sourceBranch, + targetBranch, + onResolveHunk, + onAskLLM, +}: { + file: ConflictFile; + sourceBranch: string; + targetBranch: string; + onResolveHunk: (hunkId: string, resolution: ResolutionChoice, customLines?: string[]) => void; + onAskLLM?: (hunk: ConflictHunk) => Promise<string[]>; +}) { + const [expanded, setExpanded] = useState(true); + const resolvedCount = file.hunks.filter((h) => h.resolved !== undefined).length; + + return ( + <div className="border border-[rgba(117,170,252,0.2)] mb-3"> + {/* File header */} + <button + onClick={() => setExpanded(!expanded)} + className="w-full flex items-center gap-2 px-3 py-2 bg-[rgba(0,0,0,0.2)] hover:bg-[rgba(0,0,0,0.3)] text-left" + > + <span className="font-mono text-[10px] text-[#555]"> + {expanded ? "▼" : "▶"} + </span> + <span + className={`px-1.5 py-0.5 font-mono text-[9px] ${ + file.resolved + ? "text-green-400 bg-green-400/10" + : "text-yellow-400 bg-yellow-400/10" + }`} + > + {file.resolved ? "RESOLVED" : "CONFLICT"} + </span> + <span className="font-mono text-sm text-[#dbe7ff] flex-1 truncate"> + {file.path} + </span> + <span className="font-mono text-[10px] text-[#555]"> + {resolvedCount}/{file.hunks.length} hunks + </span> + </button> + + {/* Hunks */} + {expanded && ( + <div className="p-3"> + {file.hunks.map((hunk) => ( + <ConflictHunkView + key={hunk.id} + hunk={hunk} + sourceBranch={sourceBranch} + targetBranch={targetBranch} + onResolve={(resolution, customLines) => + onResolveHunk(hunk.id, resolution, customLines) + } + onAskLLM={ + onAskLLM + ? async () => { + const resolution = await onAskLLM(hunk); + onResolveHunk(hunk.id, "custom", resolution); + } + : undefined + } + /> + ))} + </div> + )} + </div> + ); +} + +export function MergeConflictResolver({ + conflicts: initialConflicts, + sourceBranch, + targetBranch, + loading = false, + onResolve, + onAbort, + onAskLLM, +}: MergeConflictResolverProps) { + const [conflicts, setConflicts] = useState<ConflictFile[]>(initialConflicts); + const [resolving, setResolving] = useState(false); + const [error, setError] = useState<string | null>(null); + + // Calculate resolution stats + const stats = useMemo(() => { + const totalHunks = conflicts.reduce((sum, f) => sum + f.hunks.length, 0); + const resolvedHunks = conflicts.reduce( + (sum, f) => sum + f.hunks.filter((h) => h.resolved !== undefined).length, + 0 + ); + const resolvedFiles = conflicts.filter((f) => f.resolved).length; + return { + totalFiles: conflicts.length, + resolvedFiles, + totalHunks, + resolvedHunks, + allResolved: resolvedHunks === totalHunks, + }; + }, [conflicts]); + + // Handle resolving a single hunk + const handleResolveHunk = useCallback( + (filePath: string, hunkId: string, resolution: ResolutionChoice, customLines?: string[]) => { + setConflicts((prev) => + prev.map((file) => { + if (file.path !== filePath) return file; + + const updatedHunks = file.hunks.map((hunk) => { + if (hunk.id !== hunkId) return hunk; + return { + ...hunk, + resolved: resolution, + customResolution: customLines, + }; + }); + + const allHunksResolved = updatedHunks.every((h) => h.resolved !== undefined); + + return { + ...file, + hunks: updatedHunks, + resolved: allHunksResolved, + }; + }) + ); + }, + [] + ); + + // Resolve all hunks in a file with same choice + const handleResolveFileAll = useCallback( + (filePath: string, resolution: ResolutionChoice) => { + setConflicts((prev) => + prev.map((file) => { + if (file.path !== filePath) return file; + + const updatedHunks = file.hunks.map((hunk) => ({ + ...hunk, + resolved: resolution, + customResolution: + resolution === "both" + ? [...hunk.ours, ...hunk.theirs] + : resolution === "ours" + ? hunk.ours + : resolution === "theirs" + ? hunk.theirs + : undefined, + })); + + return { + ...file, + hunks: updatedHunks, + resolved: true, + }; + }) + ); + }, + [] + ); + + // Apply all resolutions + const handleApplyResolutions = async () => { + if (!stats.allResolved || resolving) return; + + setResolving(true); + setError(null); + + try { + const resolutionMap = new Map<string, ConflictHunk[]>(); + conflicts.forEach((file) => { + resolutionMap.set(file.path, file.hunks); + }); + await onResolve(resolutionMap); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to apply resolutions"); + } finally { + setResolving(false); + } + }; + + if (loading) { + return ( + <div className="panel p-4"> + <div className="flex items-center justify-center h-32"> + <div className="font-mono text-sm text-[#75aafc]"> + Analyzing conflicts... + </div> + </div> + </div> + ); + } + + return ( + <div className="panel flex flex-col max-h-[80vh] overflow-hidden"> + {/* Header */} + <div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.2)] shrink-0"> + <div className="flex items-center gap-3"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Merge Conflicts + </div> + <span className="px-2 py-0.5 font-mono text-[10px] text-yellow-400 bg-yellow-400/10 border border-yellow-400/20"> + {sourceBranch} → {targetBranch} + </span> + </div> + <button + onClick={onAbort} + className="font-mono text-xs text-red-400 hover:text-red-300" + > + Abort Merge + </button> + </div> + + {/* Progress */} + <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)] shrink-0"> + <div className="flex items-center justify-between mb-2"> + <span className="font-mono text-[10px] text-[#75aafc]"> + {stats.resolvedFiles}/{stats.totalFiles} files resolved + </span> + <span className="font-mono text-[10px] text-[#75aafc]"> + {stats.resolvedHunks}/{stats.totalHunks} conflicts resolved + </span> + </div> + <div className="h-1.5 bg-[rgba(117,170,252,0.1)] rounded-full overflow-hidden"> + <div + className="h-full bg-green-400 transition-all" + style={{ + width: `${(stats.resolvedHunks / stats.totalHunks) * 100}%`, + }} + /> + </div> + </div> + + {/* Error */} + {error && ( + <div className="mx-4 mt-3 bg-red-400/10 border border-red-400/30 p-3 font-mono text-xs text-red-400 shrink-0"> + {error} + </div> + )} + + {/* Conflict files */} + <div className="flex-1 overflow-y-auto p-4"> + {conflicts.map((file) => ( + <ConflictFileView + key={file.path} + file={file} + sourceBranch={sourceBranch} + targetBranch={targetBranch} + onResolveHunk={(hunkId, resolution, customLines) => + handleResolveHunk(file.path, hunkId, resolution, customLines) + } + onAskLLM={onAskLLM} + /> + ))} + </div> + + {/* Footer actions */} + <div className="flex items-center justify-between p-4 border-t border-[rgba(117,170,252,0.2)] shrink-0"> + <div className="flex items-center gap-2"> + <button + onClick={() => + conflicts.forEach((f) => handleResolveFileAll(f.path, "ours")) + } + className="px-3 py-1.5 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + Accept All Ours + </button> + <button + onClick={() => + conflicts.forEach((f) => handleResolveFileAll(f.path, "theirs")) + } + className="px-3 py-1.5 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" + > + Accept All Theirs + </button> + </div> + <button + onClick={handleApplyResolutions} + disabled={!stats.allResolved || resolving} + className="px-4 py-2 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {resolving + ? "Applying..." + : stats.allResolved + ? "Complete Merge" + : `Resolve ${stats.totalHunks - stats.resolvedHunks} Conflicts`} + </button> + </div> + </div> + ); +} + +// Export types for use in other components +export type { ConflictHunk, ConflictFile, ResolutionChoice }; diff --git a/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx b/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx new file mode 100644 index 0000000..74059a0 --- /dev/null +++ b/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx @@ -0,0 +1,476 @@ +import { useState, useMemo } from "react"; + +interface DiffLine { + type: "add" | "remove" | "context" | "header" | "hunk"; + content: string; + oldLineNumber?: number; + newLineNumber?: number; +} + +interface DiffFile { + path: string; + status: "added" | "modified" | "deleted" | "renamed"; + oldPath?: string; // For renames + additions: number; + deletions: number; + lines: DiffLine[]; +} + +interface OverlayDiffViewerProps { + diff: string; + changedFiles?: string[]; + loading?: boolean; + error?: string; + onClose?: () => void; + title?: string; +} + +function parseDiff(diffText: string): DiffFile[] { + if (!diffText.trim()) return []; + + const files: DiffFile[] = []; + const lines = diffText.split("\n"); + + let currentFile: DiffFile | null = null; + let oldLineNum = 0; + let newLineNum = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // File header (diff --git a/path b/path) + if (line.startsWith("diff --git")) { + if (currentFile) { + files.push(currentFile); + } + + // Extract paths + const match = line.match(/diff --git a\/(.+) b\/(.+)/); + const oldPath = match?.[1] || ""; + const newPath = match?.[2] || oldPath; + + currentFile = { + path: newPath, + oldPath: oldPath !== newPath ? oldPath : undefined, + status: "modified", + additions: 0, + deletions: 0, + lines: [], + }; + continue; + } + + if (!currentFile) continue; + + // New file indicator + if (line.startsWith("new file mode")) { + currentFile.status = "added"; + continue; + } + + // Deleted file indicator + if (line.startsWith("deleted file mode")) { + currentFile.status = "deleted"; + continue; + } + + // Rename indicator + if (line.startsWith("rename from") || line.startsWith("rename to")) { + currentFile.status = "renamed"; + continue; + } + + // Hunk header (@@ -1,3 +1,4 @@) + if (line.startsWith("@@")) { + const hunkMatch = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/); + if (hunkMatch) { + oldLineNum = parseInt(hunkMatch[1], 10); + newLineNum = parseInt(hunkMatch[2], 10); + } + currentFile.lines.push({ + type: "hunk", + content: line, + }); + continue; + } + + // Skip other headers (---, +++, index, etc.) + if ( + line.startsWith("---") || + line.startsWith("+++") || + line.startsWith("index ") || + line.startsWith("Binary files") + ) { + currentFile.lines.push({ + type: "header", + content: line, + }); + continue; + } + + // Diff content + if (line.startsWith("+")) { + currentFile.additions++; + currentFile.lines.push({ + type: "add", + content: line.substring(1), + newLineNumber: newLineNum++, + }); + } else if (line.startsWith("-")) { + currentFile.deletions++; + currentFile.lines.push({ + type: "remove", + content: line.substring(1), + oldLineNumber: oldLineNum++, + }); + } else if (line.startsWith(" ") || line === "") { + currentFile.lines.push({ + type: "context", + content: line.substring(1) || "", + oldLineNumber: oldLineNum++, + newLineNumber: newLineNum++, + }); + } + } + + if (currentFile) { + files.push(currentFile); + } + + return files; +} + +function DiffFileView({ file, collapsed, onToggle }: { file: DiffFile; collapsed: boolean; onToggle: () => void }) { + const statusColors: Record<DiffFile["status"], string> = { + added: "text-green-400 bg-green-400/10", + modified: "text-yellow-400 bg-yellow-400/10", + deleted: "text-red-400 bg-red-400/10", + renamed: "text-purple-400 bg-purple-400/10", + }; + + const statusLabels: Record<DiffFile["status"], string> = { + added: "A", + modified: "M", + deleted: "D", + renamed: "R", + }; + + return ( + <div className="border border-[rgba(117,170,252,0.2)] mb-2"> + {/* File header */} + <button + onClick={onToggle} + className="w-full flex items-center gap-2 px-3 py-2 bg-[rgba(0,0,0,0.2)] hover:bg-[rgba(0,0,0,0.3)] transition-colors text-left" + > + <span className="font-mono text-[10px] text-[#555]"> + {collapsed ? "▶" : "▼"} + </span> + <span + className={`px-1.5 py-0.5 font-mono text-[9px] font-bold ${statusColors[file.status]}`} + > + {statusLabels[file.status]} + </span> + <span className="font-mono text-sm text-[#dbe7ff] flex-1 truncate"> + {file.oldPath ? ( + <> + <span className="text-[#555]">{file.oldPath}</span> + <span className="text-[#75aafc] mx-1">→</span> + {file.path} + </> + ) : ( + file.path + )} + </span> + <span className="font-mono text-[10px]"> + {file.additions > 0 && ( + <span className="text-green-400 mr-2">+{file.additions}</span> + )} + {file.deletions > 0 && ( + <span className="text-red-400">-{file.deletions}</span> + )} + </span> + </button> + + {/* File content */} + {!collapsed && ( + <div className="overflow-x-auto"> + <table className="w-full font-mono text-xs"> + <tbody> + {file.lines.map((line, i) => { + if (line.type === "header" || line.type === "hunk") { + return ( + <tr + key={i} + className="bg-[rgba(117,170,252,0.05)]" + > + <td + colSpan={3} + className="px-2 py-0.5 text-[#75aafc] select-none" + > + {line.content} + </td> + </tr> + ); + } + + const bgColor = + line.type === "add" + ? "bg-green-400/10" + : line.type === "remove" + ? "bg-red-400/10" + : ""; + + const textColor = + line.type === "add" + ? "text-green-400" + : line.type === "remove" + ? "text-red-400" + : "text-[#9bc3ff]"; + + const prefix = + line.type === "add" + ? "+" + : line.type === "remove" + ? "-" + : " "; + + return ( + <tr key={i} className={bgColor}> + {/* Old line number */} + <td className="w-10 px-2 py-0 text-right text-[#555] select-none border-r border-[rgba(117,170,252,0.1)]"> + {line.type !== "add" ? line.oldLineNumber : ""} + </td> + {/* New line number */} + <td className="w-10 px-2 py-0 text-right text-[#555] select-none border-r border-[rgba(117,170,252,0.1)]"> + {line.type !== "remove" ? line.newLineNumber : ""} + </td> + {/* Content */} + <td className={`px-2 py-0 whitespace-pre ${textColor}`}> + <span className="select-none mr-1 text-[#555]"> + {prefix} + </span> + {line.content} + </td> + </tr> + ); + })} + </tbody> + </table> + </div> + )} + </div> + ); +} + +export function OverlayDiffViewer({ + diff, + changedFiles, + loading, + error, + onClose, + title = "Overlay Changes", +}: OverlayDiffViewerProps) { + const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set()); + const [showFullDiff, setShowFullDiff] = useState(true); + + const parsedFiles = useMemo(() => parseDiff(diff), [diff]); + + const toggleFile = (path: string) => { + setCollapsedFiles((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }; + + const expandAll = () => setCollapsedFiles(new Set()); + const collapseAll = () => setCollapsedFiles(new Set(parsedFiles.map((f) => f.path))); + + // Calculate totals + const totals = useMemo(() => { + return parsedFiles.reduce( + (acc, file) => ({ + additions: acc.additions + file.additions, + deletions: acc.deletions + file.deletions, + files: acc.files + 1, + }), + { additions: 0, deletions: 0, files: 0 } + ); + }, [parsedFiles]); + + if (loading) { + return ( + <div className="panel p-4"> + <div className="flex items-center justify-center h-32"> + <div className="font-mono text-sm text-[#75aafc]"> + Loading diff... + </div> + </div> + </div> + ); + } + + if (error) { + return ( + <div className="panel p-4"> + <div className="flex items-center justify-between mb-3"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + {title} + </div> + {onClose && ( + <button + onClick={onClose} + className="font-mono text-xs text-[#555] hover:text-[#9bc3ff]" + > + Close + </button> + )} + </div> + <div className="bg-red-400/10 border border-red-400/30 p-3 font-mono text-sm text-red-400"> + {error} + </div> + </div> + ); + } + + if (!diff.trim() && (!changedFiles || changedFiles.length === 0)) { + return ( + <div className="panel p-4"> + <div className="flex items-center justify-between mb-3"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + {title} + </div> + {onClose && ( + <button + onClick={onClose} + className="font-mono text-xs text-[#555] hover:text-[#9bc3ff]" + > + Close + </button> + )} + </div> + <div className="text-center py-8 font-mono text-sm text-[#555]"> + No changes detected in overlay + </div> + </div> + ); + } + + return ( + <div className="panel flex flex-col max-h-[600px]"> + {/* Header */} + <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.2)] shrink-0"> + <div className="flex items-center gap-4"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + {title} + </div> + <div className="font-mono text-[10px] text-[#75aafc]"> + {totals.files} file{totals.files !== 1 ? "s" : ""} changed + {totals.additions > 0 && ( + <span className="text-green-400 ml-2">+{totals.additions}</span> + )} + {totals.deletions > 0 && ( + <span className="text-red-400 ml-2">-{totals.deletions}</span> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <button + onClick={expandAll} + className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]" + > + Expand All + </button> + <button + onClick={collapseAll} + className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]" + > + Collapse All + </button> + <button + onClick={() => setShowFullDiff(!showFullDiff)} + className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]" + > + {showFullDiff ? "File List" : "Full Diff"} + </button> + {onClose && ( + <button + onClick={onClose} + className="font-mono text-xs text-[#555] hover:text-[#9bc3ff] ml-2" + > + Close + </button> + )} + </div> + </div> + + {/* Content */} + <div className="flex-1 overflow-y-auto p-3"> + {showFullDiff ? ( + // Full diff view + parsedFiles.length > 0 ? ( + parsedFiles.map((file) => ( + <DiffFileView + key={file.path} + file={file} + collapsed={collapsedFiles.has(file.path)} + onToggle={() => toggleFile(file.path)} + /> + )) + ) : ( + // Fallback to raw diff + <pre className="font-mono text-xs text-[#9bc3ff] whitespace-pre-wrap"> + {diff} + </pre> + ) + ) : ( + // File list view + <div className="space-y-1"> + {(changedFiles || parsedFiles.map((f) => f.path)).map((path) => { + const file = parsedFiles.find((f) => f.path === path); + return ( + <div + key={path} + className="flex items-center gap-2 px-2 py-1 hover:bg-[rgba(117,170,252,0.05)]" + > + {file && ( + <span + className={`px-1 py-0.5 font-mono text-[9px] ${ + file.status === "added" + ? "text-green-400 bg-green-400/10" + : file.status === "deleted" + ? "text-red-400 bg-red-400/10" + : file.status === "renamed" + ? "text-purple-400 bg-purple-400/10" + : "text-yellow-400 bg-yellow-400/10" + }`} + > + {file.status.charAt(0).toUpperCase()} + </span> + )} + <span className="font-mono text-sm text-[#dbe7ff] flex-1 truncate"> + {path} + </span> + {file && ( + <span className="font-mono text-[10px]"> + {file.additions > 0 && ( + <span className="text-green-400 mr-1">+{file.additions}</span> + )} + {file.deletions > 0 && ( + <span className="text-red-400">-{file.deletions}</span> + )} + </span> + )} + </div> + ); + })} + </div> + )} + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/mesh/PRPreview.tsx b/makima/frontend/src/components/mesh/PRPreview.tsx new file mode 100644 index 0000000..fc202b0 --- /dev/null +++ b/makima/frontend/src/components/mesh/PRPreview.tsx @@ -0,0 +1,314 @@ +import { useState, useMemo } from "react"; +import type { TaskWithSubtasks, TaskSummary } from "../../lib/api"; +import { OverlayDiffViewer } from "./OverlayDiffViewer"; + +interface PRPreviewProps { + task: TaskWithSubtasks; + diff?: string; + changedFiles?: string[]; + loading?: boolean; + onCreatePR?: (title: string, body: string, draft: boolean) => Promise<void>; + onAutoMerge?: () => Promise<void>; + onClose: () => void; +} + +interface PRFormData { + title: string; + body: string; + isDraft: boolean; +} + +function generatePRTitle(task: TaskWithSubtasks): string { + // Generate a PR title based on the task name + const prefix = task.parentTaskId ? "feat" : "feat"; + return `${prefix}: ${task.name}`; +} + +function generatePRBody(task: TaskWithSubtasks, changedFiles?: string[]): string { + const sections: string[] = []; + + // Summary + sections.push("## Summary\n"); + if (task.description) { + sections.push(task.description + "\n"); + } else { + sections.push("_Add a brief description of the changes..._\n"); + } + + // Plan/Implementation details + sections.push("\n## Implementation\n"); + if (task.plan) { + // Truncate if too long + const planPreview = task.plan.length > 500 + ? task.plan.substring(0, 500) + "..." + : task.plan; + sections.push("```\n" + planPreview + "\n```\n"); + } + + // Subtasks summary + if (task.subtasks.length > 0) { + sections.push("\n## Subtasks\n"); + task.subtasks.forEach((subtask: TaskSummary) => { + const emoji = subtask.status === "done" || subtask.status === "merged" ? "+" : + subtask.status === "running" ? "~" : "-"; + sections.push(`- [${emoji === "+" ? "x" : " "}] ${subtask.name} (${subtask.status})\n`); + }); + } + + // Changed files + if (changedFiles && changedFiles.length > 0) { + sections.push("\n## Changed Files\n"); + changedFiles.slice(0, 20).forEach((file) => { + sections.push(`- \`${file}\`\n`); + }); + if (changedFiles.length > 20) { + sections.push(`\n_...and ${changedFiles.length - 20} more files_\n`); + } + } + + // Test plan + sections.push("\n## Test Plan\n"); + sections.push("- [ ] Manual testing completed\n"); + sections.push("- [ ] Unit tests added/updated\n"); + sections.push("- [ ] Integration tests passing\n"); + + // Footer + sections.push("\n---\n"); + sections.push("_Generated by makima mesh orchestrator_\n"); + + return sections.join(""); +} + +export function PRPreview({ + task, + diff = "", + changedFiles = [], + loading = false, + onCreatePR, + onAutoMerge, + onClose, +}: PRPreviewProps) { + const [showDiff, setShowDiff] = useState(false); + const [creating, setCreating] = useState(false); + const [error, setError] = useState<string | null>(null); + + const [formData, setFormData] = useState<PRFormData>(() => ({ + title: generatePRTitle(task), + body: generatePRBody(task, changedFiles), + isDraft: false, + })); + + const handleCreatePR = async () => { + if (!onCreatePR || creating) return; + + setCreating(true); + setError(null); + + try { + await onCreatePR(formData.title, formData.body, formData.isDraft); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create PR"); + } finally { + setCreating(false); + } + }; + + const handleAutoMerge = async () => { + if (!onAutoMerge || creating) return; + + if (!confirm("Are you sure you want to auto-merge this task directly to the target branch?")) { + return; + } + + setCreating(true); + setError(null); + + try { + await onAutoMerge(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to auto-merge"); + } finally { + setCreating(false); + } + }; + + // Calculate stats + const stats = useMemo(() => { + const completedSubtasks = task.subtasks.filter( + (s) => s.status === "done" || s.status === "merged" + ).length; + return { + filesChanged: changedFiles.length, + subtasksCompleted: completedSubtasks, + subtasksTotal: task.subtasks.length, + isReady: completedSubtasks === task.subtasks.length || task.subtasks.length === 0, + }; + }, [task.subtasks, changedFiles]); + + return ( + <div className="panel flex flex-col max-h-[80vh] overflow-hidden"> + {/* Header */} + <div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.2)] shrink-0"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Create Pull Request + </div> + <button + onClick={onClose} + className="font-mono text-xs text-[#555] hover:text-[#9bc3ff]" + > + Cancel + </button> + </div> + + {/* Content */} + <div className="flex-1 overflow-y-auto p-4 space-y-4"> + {/* Status badges */} + <div className="flex flex-wrap gap-2"> + <span className="px-2 py-0.5 font-mono text-[10px] text-[#75aafc] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.2)]"> + {task.baseBranch || "main"} → {task.targetBranch || task.baseBranch || "main"} + </span> + <span className="px-2 py-0.5 font-mono text-[10px] text-[#9bc3ff] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.2)]"> + {stats.filesChanged} files changed + </span> + {task.subtasks.length > 0 && ( + <span + className={`px-2 py-0.5 font-mono text-[10px] border ${ + stats.isReady + ? "text-green-400 bg-green-400/10 border-green-400/20" + : "text-yellow-400 bg-yellow-400/10 border-yellow-400/20" + }`} + > + {stats.subtasksCompleted}/{stats.subtasksTotal} subtasks complete + </span> + )} + </div> + + {/* Warning if subtasks not complete */} + {!stats.isReady && ( + <div className="bg-yellow-400/10 border border-yellow-400/30 p-3 font-mono text-xs text-yellow-400"> + Some subtasks are not yet complete. Consider waiting before creating the PR. + </div> + )} + + {/* Error message */} + {error && ( + <div className="bg-red-400/10 border border-red-400/30 p-3 font-mono text-xs text-red-400"> + {error} + </div> + )} + + {/* PR Title */} + <div className="space-y-2"> + <label className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Title + </label> + <input + type="text" + value={formData.title} + onChange={(e) => setFormData({ ...formData, title: e.target.value })} + className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]" + placeholder="PR title" + disabled={creating} + /> + </div> + + {/* PR Body */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Description + </label> + <button + onClick={() => setFormData({ + ...formData, + body: generatePRBody(task, changedFiles), + })} + className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]" + > + Regenerate + </button> + </div> + <textarea + value={formData.body} + onChange={(e) => setFormData({ ...formData, body: e.target.value })} + className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-xs px-3 py-2 outline-none focus:border-[#3f6fb3] min-h-[200px] resize-y" + placeholder="PR description (markdown)" + disabled={creating} + /> + </div> + + {/* Options */} + <div className="flex items-center gap-4"> + <label className="flex items-center gap-2 cursor-pointer"> + <input + type="checkbox" + checked={formData.isDraft} + onChange={(e) => setFormData({ ...formData, isDraft: e.target.checked })} + className="w-4 h-4 accent-[#75aafc]" + disabled={creating} + /> + <span className="font-mono text-xs text-[#9bc3ff]">Create as draft</span> + </label> + </div> + + {/* Diff preview toggle */} + <div className="border-t border-[rgba(117,170,252,0.2)] pt-4"> + <button + onClick={() => setShowDiff(!showDiff)} + className="flex items-center gap-2 font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff]" + > + <span>{showDiff ? "▼" : "▶"}</span> + <span> + {showDiff ? "Hide" : "Show"} diff preview ({stats.filesChanged} files) + </span> + </button> + </div> + + {/* Inline diff viewer */} + {showDiff && ( + <div className="border border-[rgba(117,170,252,0.2)]"> + <OverlayDiffViewer + diff={diff} + changedFiles={changedFiles} + loading={loading} + title="Changes to be merged" + /> + </div> + )} + </div> + + {/* Footer actions */} + <div className="flex items-center justify-between p-4 border-t border-[rgba(117,170,252,0.2)] shrink-0"> + <div className="font-mono text-[10px] text-[#555]"> + {task.repositoryUrl && ( + <span className="truncate max-w-[200px] inline-block align-middle"> + {task.repositoryUrl} + </span> + )} + </div> + <div className="flex items-center gap-2"> + {task.mergeMode === "auto" && onAutoMerge && ( + <button + onClick={handleAutoMerge} + disabled={creating || !stats.isReady} + className="px-4 py-2 font-mono text-xs text-yellow-400 border border-yellow-400/30 hover:border-yellow-400/50 hover:bg-yellow-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {creating ? "..." : "Auto-Merge"} + </button> + )} + {onCreatePR && ( + <button + onClick={handleCreatePR} + disabled={creating || !formData.title.trim()} + className="px-4 py-2 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {creating ? "Creating..." : formData.isDraft ? "Create Draft PR" : "Create PR"} + </button> + )} + </div> + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/mesh/SubtaskTree.tsx b/makima/frontend/src/components/mesh/SubtaskTree.tsx new file mode 100644 index 0000000..176b7a7 --- /dev/null +++ b/makima/frontend/src/components/mesh/SubtaskTree.tsx @@ -0,0 +1,297 @@ +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> + ); +} 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> + ); +} diff --git a/makima/frontend/src/components/mesh/TaskList.tsx b/makima/frontend/src/components/mesh/TaskList.tsx new file mode 100644 index 0000000..a37e564 --- /dev/null +++ b/makima/frontend/src/components/mesh/TaskList.tsx @@ -0,0 +1,164 @@ +import type { TaskSummary, TaskStatus } from "../../lib/api"; + +interface TaskListProps { + tasks: TaskSummary[]; + loading: boolean; + onSelect: (id: string) => void; + onDelete: (id: string) => void; + onCreate: () => void; +} + +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 "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 "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 TaskList({ + tasks, + loading, + onSelect, + onDelete, + onCreate, +}: TaskListProps) { + if (loading) { + return ( + <div className="panel h-full flex items-center justify-center"> + <div className="font-mono text-[#9bc3ff] text-sm">Loading tasks...</div> + </div> + ); + } + + // Separate root tasks (no parent) from subtasks + const rootTasks = tasks.filter((t) => !t.parentTaskId); + + return ( + <div className="panel h-full flex flex-col"> + <div className="flex items-center justify-between p-4 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)]"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + MESH//TASKS + </div> + <button + onClick={onCreate} + className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors uppercase" + > + + New Task + </button> + </div> + + <div className="flex-1 overflow-y-auto"> + {rootTasks.length === 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} + </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 + </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> + <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> + ))} + </div> + )} + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx new file mode 100644 index 0000000..10de225 --- /dev/null +++ b/makima/frontend/src/components/mesh/TaskOutput.tsx @@ -0,0 +1,281 @@ +import { useRef, useEffect, useState, useCallback } from "react"; +import { SimpleMarkdown } from "../SimpleMarkdown"; +import type { TaskOutputEvent } from "../../hooks/useTaskSubscription"; +import { sendTaskMessage } from "../../lib/api"; + +interface TaskOutputProps { + /** Array of parsed output events from the backend */ + entries: TaskOutputEvent[]; + isStreaming: boolean; + /** Name of subtask whose output is being viewed (null = parent task) */ + viewingSubtaskName?: string | null; + /** Callback to return to parent task output */ + onClearSubtaskView?: () => void; + onClear?: () => void; + /** Task ID for sending input (if provided, shows input bar when streaming) */ + taskId?: string | null; + /** Callback when user sends input (to show it immediately in output) */ + onUserInput?: (message: string) => void; +} + +export function TaskOutput({ entries, isStreaming, viewingSubtaskName, onClearSubtaskView, onClear, taskId, onUserInput }: TaskOutputProps) { + const containerRef = useRef<HTMLDivElement>(null); + const [autoScroll, setAutoScroll] = useState(true); + const [inputValue, setInputValue] = useState(""); + const [sendingInput, setSendingInput] = useState(false); + const [inputError, setInputError] = useState<string | null>(null); + const inputRef = useRef<HTMLInputElement>(null); + + // Handle scroll to check if user has scrolled up + const handleScroll = useCallback(() => { + if (!containerRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = containerRef.current; + const isAtBottom = scrollHeight - scrollTop - clientHeight < 50; + setAutoScroll(isAtBottom); + }, []); + + // Auto-scroll when entries change + useEffect(() => { + if (autoScroll && containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, [entries, autoScroll]); + + // Handle sending input to the task + const handleSendInput = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + if (!taskId || !inputValue.trim() || sendingInput) return; + + const message = inputValue.trim(); + setSendingInput(true); + setInputError(null); + + // Show user input immediately in the output window + onUserInput?.(message); + + try { + await sendTaskMessage(taskId, message); + setInputValue(""); + inputRef.current?.focus(); + } catch (err) { + setInputError(err instanceof Error ? err.message : "Failed to send input"); + } finally { + setSendingInput(false); + } + }, [taskId, inputValue, sendingInput, onUserInput]); + + // Show input bar when task is running and has a valid taskId + const showInputBar = isStreaming && taskId; + + return ( + <div className="flex flex-col h-full"> + {/* Header */} + <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.35)] shrink-0"> + <div className="flex items-center gap-2"> + {viewingSubtaskName ? ( + <> + <button + onClick={onClearSubtaskView} + className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + < + </button> + <span className="font-mono text-xs text-green-400 tracking-wide uppercase"> + Subtask: {viewingSubtaskName} + </span> + </> + ) : ( + <span className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Output + </span> + )} + {isStreaming && ( + <span className="flex items-center gap-1.5 px-2 py-0.5 bg-green-400/10 border border-green-400/30 text-green-400 font-mono text-[10px] uppercase"> + <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" /> + Live + </span> + )} + </div> + <div className="flex items-center gap-2"> + {!autoScroll && ( + <button + onClick={() => { + setAutoScroll(true); + if (containerRef.current) { + containerRef.current.scrollTop = + containerRef.current.scrollHeight; + } + }} + 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" + > + Resume Scroll + </button> + )} + {onClear && entries.length > 0 && ( + <button + onClick={onClear} + 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" + > + Clear + </button> + )} + </div> + </div> + + {/* Output area */} + <div + ref={containerRef} + onScroll={handleScroll} + className="flex-1 overflow-auto bg-[#0a0f18] p-3 font-mono text-xs min-h-0" + > + {entries.length === 0 ? ( + <div className="text-[#555] italic"> + {isStreaming ? "Waiting for output..." : "No output yet"} + </div> + ) : ( + <div className="space-y-3"> + {entries.map((entry, idx) => ( + <OutputEntryRenderer key={idx} entry={entry} /> + ))} + {isStreaming && ( + <span className="inline-block w-2 h-4 bg-[#9bc3ff] animate-pulse" /> + )} + </div> + )} + </div> + + {/* Input bar for sending messages to running tasks */} + {showInputBar && ( + <div className="shrink-0 border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]"> + {inputError && ( + <div className="px-3 py-1 bg-red-900/20 text-red-400 text-xs font-mono"> + {inputError} + </div> + )} + <form onSubmit={handleSendInput} className="flex items-center gap-2 px-3 py-2"> + <span className="text-green-400 font-mono text-sm">></span> + <input + ref={inputRef} + type="text" + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} + placeholder={sendingInput ? "Sending..." : "Send input to Claude..."} + disabled={sendingInput} + className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]" + /> + <button + type="submit" + disabled={sendingInput || !inputValue.trim()} + 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 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {sendingInput ? "..." : "Send"} + </button> + </form> + </div> + )} + </div> + ); +} + +function OutputEntryRenderer({ entry }: { entry: TaskOutputEvent }) { + const [expanded, setExpanded] = useState(false); + + switch (entry.messageType) { + case "user_input": + return ( + <div className="pl-2 border-l-2 border-cyan-400/50"> + <div className="flex items-center gap-2"> + <span className="text-cyan-400 text-[10px] uppercase tracking-wide">You:</span> + </div> + <div className="text-cyan-300 mt-1">{entry.content}</div> + </div> + ); + + case "system": + return ( + <div className="text-[#555] text-[10px] uppercase tracking-wide"> + {entry.content} + </div> + ); + + case "assistant": + return ( + <div className="pl-2 border-l-2 border-[#3f6fb3]"> + <SimpleMarkdown content={entry.content} className="text-[#9bc3ff]" /> + </div> + ); + + case "tool_use": + return ( + <div className="space-y-1"> + <div className="flex items-center gap-2"> + <span className="text-yellow-500">*</span> + <span className="text-[#75aafc]">{entry.toolName || "unknown"}</span> + {entry.toolInput && Object.keys(entry.toolInput).length > 0 && ( + <button + onClick={() => setExpanded(!expanded)} + className="text-[#555] hover:text-[#9bc3ff] text-[10px]" + > + {expanded ? "[-]" : "[+]"} + </button> + )} + </div> + {expanded && entry.toolInput && ( + <pre className="ml-4 text-[10px] text-[#555] bg-[#0a1525] p-2 overflow-x-auto"> + {JSON.stringify(entry.toolInput, null, 2)} + </pre> + )} + </div> + ); + + case "tool_result": + if (!entry.content) return null; + return ( + <div className="ml-4 text-[10px]"> + <span className={entry.isError ? "text-red-400" : "text-green-500"}> + {entry.isError ? "x" : "+"} + </span>{" "} + <span className="text-[#555]"> + {entry.content.split("\n")[0]} + {entry.content.includes("\n") && "..."} + </span> + </div> + ); + + case "result": + return ( + <div className="border-t border-[rgba(117,170,252,0.2)] pt-2 mt-2"> + <div className="text-green-500 font-semibold mb-1">Result:</div> + <SimpleMarkdown content={entry.content} className="text-[#9bc3ff]" /> + {(entry.costUsd !== undefined || entry.durationMs !== undefined) && ( + <div className="text-[10px] text-[#555] mt-2"> + {entry.durationMs !== undefined && ( + <span>Duration: {(entry.durationMs / 1000).toFixed(1)}s</span> + )} + {entry.costUsd !== undefined && entry.durationMs !== undefined && " | "} + {entry.costUsd !== undefined && ( + <span>Cost: ${entry.costUsd.toFixed(4)}</span> + )} + </div> + )} + </div> + ); + + case "error": + return ( + <div className="text-red-400 pl-2 border-l-2 border-red-400/50"> + {entry.content} + </div> + ); + + case "raw": + return ( + <div className="text-[#555] text-[10px]"> + {entry.content} + </div> + ); + + default: + return null; + } +} diff --git a/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx b/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx new file mode 100644 index 0000000..5caa3c4 --- /dev/null +++ b/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx @@ -0,0 +1,536 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import { + type LlmModel, + type UserQuestion, + type UserAnswer, + type MeshChatContext, +} from "../../lib/api"; +import { useMeshChatHistory } from "../../hooks/useMeshChatHistory"; +import { SimpleMarkdown } from "../SimpleMarkdown"; + +interface UnifiedMeshChatInputProps { + context: MeshChatContext; + onUpdate?: () => void; +} + +const MODEL_OPTIONS: { value: LlmModel; label: string }[] = [ + { value: "claude-opus", label: "Claude Opus" }, + { value: "claude-sonnet", label: "Claude Sonnet" }, + { value: "groq", label: "Groq Kimi" }, +]; + +const DEFAULT_MODEL: LlmModel = "claude-opus"; + +// LocalStorage keys +const STORAGE_KEY_MODEL = "makima-mesh-chat-model"; +const STORAGE_KEY_CMD_HISTORY = "makima-mesh-chat-cmd-history"; +const MAX_CMD_HISTORY = 100; + +function loadModel(): LlmModel { + try { + const modelStr = localStorage.getItem(STORAGE_KEY_MODEL); + return (modelStr as LlmModel) || DEFAULT_MODEL; + } catch { + return DEFAULT_MODEL; + } +} + +function saveModel(model: LlmModel): void { + try { + localStorage.setItem(STORAGE_KEY_MODEL, model); + } catch { + // Ignore storage errors + } +} + +function loadCommandHistory(): string[] { + try { + const historyJson = localStorage.getItem(STORAGE_KEY_CMD_HISTORY); + return historyJson ? JSON.parse(historyJson) : []; + } catch { + return []; + } +} + +function saveCommandHistory(history: string[]): void { + try { + localStorage.setItem( + STORAGE_KEY_CMD_HISTORY, + JSON.stringify(history.slice(-MAX_CMD_HISTORY)) + ); + } catch { + // Ignore storage errors + } +} + +function getPlaceholder(context: MeshChatContext): string { + switch (context.type) { + case "mesh": + return "Create task, list tasks, check status..."; + case "task": + return "Create subtask, run task, check status..."; + case "subtask": + return "Update plan, check siblings, merge..."; + default: + return "Ask anything..."; + } +} + +function getContextLabel(context: MeshChatContext): string { + switch (context.type) { + case "mesh": + return "mesh"; + case "task": + return `task:${context.taskId?.slice(0, 8)}`; + case "subtask": + return `subtask:${context.taskId?.slice(0, 8)}`; + default: + return "chat"; + } +} + +export function UnifiedMeshChatInput({ + context, + onUpdate, +}: UnifiedMeshChatInputProps) { + const { + messages, + loading: historyLoading, + error: historyError, + sending, + clearHistory, + sendMessage, + } = useMeshChatHistory(); + + const [input, setInput] = useState(""); + const [expanded, setExpanded] = useState(false); + const [model, setModel] = useState<LlmModel>(DEFAULT_MODEL); + + // Pending questions state + const [pendingQuestions, setPendingQuestions] = useState< + UserQuestion[] | null + >(null); + const [userAnswers, setUserAnswers] = useState<Map<string, string[]>>( + new Map() + ); + const [customInputs, setCustomInputs] = useState<Map<string, string>>( + new Map() + ); + + // Command history for arrow key navigation + const [commandHistory, setCommandHistory] = useState<string[]>([]); + const [historyIndex, setHistoryIndex] = useState(-1); + const [savedInput, setSavedInput] = useState(""); + + const inputRef = useRef<HTMLInputElement>(null); + const messagesRef = useRef<HTMLDivElement>(null); + + // Load model preference on mount + useEffect(() => { + setModel(loadModel()); + setCommandHistory(loadCommandHistory()); + }, []); + + // Expand when messages exist + useEffect(() => { + if (messages.length > 0) { + setExpanded(true); + } + }, [messages.length]); + + // Auto-scroll to bottom when messages change + useEffect(() => { + if (messagesRef.current) { + messagesRef.current.scrollTop = messagesRef.current.scrollHeight; + } + }, [messages]); + + // Handle model change + const handleModelChange = useCallback((newModel: LlmModel) => { + setModel(newModel); + saveModel(newModel); + }, []); + + // Handle keyboard navigation for command history + const handleKeyDown = useCallback( + (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === "ArrowUp") { + e.preventDefault(); + if (commandHistory.length === 0) return; + + if (historyIndex === -1) { + setSavedInput(input); + setHistoryIndex(commandHistory.length - 1); + setInput(commandHistory[commandHistory.length - 1]); + } else if (historyIndex > 0) { + setHistoryIndex(historyIndex - 1); + setInput(commandHistory[historyIndex - 1]); + } + } else if (e.key === "ArrowDown") { + e.preventDefault(); + if (historyIndex === -1) return; + + if (historyIndex < commandHistory.length - 1) { + setHistoryIndex(historyIndex + 1); + setInput(commandHistory[historyIndex + 1]); + } else { + setHistoryIndex(-1); + setInput(savedInput); + } + } + }, + [commandHistory, historyIndex, input, savedInput] + ); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!input.trim() || sending) return; + + const userMessage = input.trim(); + + // Update command history + const newHistory = + commandHistory[commandHistory.length - 1] !== userMessage + ? [...commandHistory, userMessage] + : commandHistory; + setCommandHistory(newHistory); + saveCommandHistory(newHistory); + + // Reset navigation state + setHistoryIndex(-1); + setSavedInput(""); + + setInput(""); + setExpanded(true); + + // Send message via hook (uses DB-persisted history) + const response = await sendMessage(userMessage, context, model); + + if (response) { + // Handle pending questions + if (response.pendingQuestions?.length) { + setPendingQuestions(response.pendingQuestions); + const initialAnswers = new Map<string, string[]>(); + response.pendingQuestions.forEach((q) => { + initialAnswers.set(q.id, []); + }); + setUserAnswers(initialAnswers); + setCustomInputs(new Map()); + } + + // Notify parent that something may have been updated + // Always refresh when tool calls were made (state may have changed) + if (response.toolCalls && response.toolCalls.length > 0) { + onUpdate?.(); + } + } + + inputRef.current?.focus(); + }, + [input, sending, context, model, sendMessage, onUpdate, commandHistory] + ); + + // Handle option selection for a question + const handleOptionToggle = useCallback( + (questionId: string, option: string, allowMultiple: boolean) => { + setUserAnswers((prev) => { + const newMap = new Map(prev); + const currentAnswers = newMap.get(questionId) || []; + + if (allowMultiple) { + if (currentAnswers.includes(option)) { + newMap.set( + questionId, + currentAnswers.filter((a) => a !== option) + ); + } else { + newMap.set(questionId, [...currentAnswers, option]); + } + } else { + newMap.set(questionId, [option]); + } + + return newMap; + }); + }, + [] + ); + + // Handle custom input change + const handleCustomInputChange = useCallback( + (questionId: string, value: string) => { + setCustomInputs((prev) => { + const newMap = new Map(prev); + newMap.set(questionId, value); + return newMap; + }); + }, + [] + ); + + // Submit answers to questions + const handleSubmitAnswers = useCallback(async () => { + if (!pendingQuestions || sending) return; + + // Build answers array + const answers: UserAnswer[] = pendingQuestions.map((q) => { + const selectedOptions = userAnswers.get(q.id) || []; + const customInput = customInputs.get(q.id)?.trim(); + const finalAnswers = customInput + ? [...selectedOptions, customInput] + : selectedOptions; + + return { + id: q.id, + answers: finalAnswers, + }; + }); + + // Format answers as a message + const answerText = answers + .map((a) => { + const question = pendingQuestions.find((q) => q.id === a.id); + return `${question?.question || a.id}: ${a.answers.join(", ")}`; + }) + .join("\n"); + + // Clear pending questions + setPendingQuestions(null); + setUserAnswers(new Map()); + setCustomInputs(new Map()); + + // Send answers as the next message + const response = await sendMessage(answerText, context, model); + + if (response) { + // Handle more pending questions + if (response.pendingQuestions?.length) { + setPendingQuestions(response.pendingQuestions); + const initialAnswers = new Map<string, string[]>(); + response.pendingQuestions.forEach((q) => { + initialAnswers.set(q.id, []); + }); + setUserAnswers(initialAnswers); + setCustomInputs(new Map()); + } + + // Notify parent that something may have been updated + if (response.toolCalls && response.toolCalls.length > 0) { + onUpdate?.(); + } + } + }, [ + pendingQuestions, + userAnswers, + customInputs, + sending, + context, + model, + sendMessage, + onUpdate, + ]); + + // Cancel answering questions + const handleCancelQuestions = useCallback(() => { + setPendingQuestions(null); + setUserAnswers(new Map()); + setCustomInputs(new Map()); + }, []); + + const handleClearHistory = useCallback(async () => { + await clearHistory(); + setPendingQuestions(null); + setUserAnswers(new Map()); + setCustomInputs(new Map()); + }, [clearHistory]); + + const loading = sending || historyLoading; + + return ( + <div className="border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]"> + {/* Error Display */} + {historyError && ( + <div className="px-3 py-2 bg-red-900/20 text-red-400 text-xs font-mono"> + {historyError} + </div> + )} + + {/* Messages Panel (expandable) */} + {expanded && messages.length > 0 && ( + <div + ref={messagesRef} + className="max-h-48 overflow-y-auto p-3 space-y-2 border-b border-[rgba(117,170,252,0.2)]" + > + {messages.map((msg) => ( + <div key={msg.id} className="font-mono text-xs"> + {msg.role === "user" && ( + <div className="flex gap-2"> + <span className="text-[#9bc3ff]">></span> + <span className="text-white/80 whitespace-pre-wrap"> + {msg.content} + </span> + {msg.contextType !== "mesh" && ( + <span className="text-[#555] text-[10px]"> + [{msg.contextType}] + </span> + )} + </div> + )} + {msg.role === "assistant" && ( + <div className="pl-4 space-y-1"> + <SimpleMarkdown + content={msg.content} + className="text-[#75aafc]" + /> + {msg.toolCalls && msg.toolCalls.length > 0 && ( + <div className="text-[#555] text-[10px] space-y-0.5"> + {msg.toolCalls.map((tc, i) => ( + <div key={i}> + <span + className={ + tc.result.success + ? "text-green-500" + : "text-red-400" + } + > + {tc.result.success ? "+" : "x"} + </span>{" "} + {tc.name}: {tc.result.message} + </div> + ))} + </div> + )} + </div> + )} + {msg.role === "error" && ( + <div className="pl-4 text-red-400">{msg.content}</div> + )} + </div> + ))} + </div> + )} + + {/* Pending Questions UI */} + {pendingQuestions && pendingQuestions.length > 0 && ( + <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3"> + <div className="text-[#9bc3ff] font-mono text-xs uppercase tracking-wide"> + Questions from AI + </div> + {pendingQuestions.map((q) => ( + <div key={q.id} className="space-y-2"> + <div className="text-white/90 font-mono text-sm"> + {q.question} + </div> + <div className="flex flex-wrap gap-2"> + {q.options.map((option) => { + const isSelected = (userAnswers.get(q.id) || []).includes( + option + ); + return ( + <button + key={option} + type="button" + onClick={() => + handleOptionToggle(q.id, option, q.allowMultiple) + } + className={`px-2 py-1 font-mono text-xs border transition-colors ${ + isSelected + ? "bg-[#3f6fb3] border-[#75aafc] text-white" + : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-[#3f6fb3]" + }`} + > + {q.allowMultiple && ( + <span className="mr-1">{isSelected ? "+" : "-"}</span> + )} + {option} + </button> + ); + })} + </div> + {q.allowCustom && ( + <input + type="text" + value={customInputs.get(q.id) || ""} + onChange={(e) => handleCustomInputChange(q.id, e.target.value)} + placeholder="Or type a custom answer..." + className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] placeholder-[#555]" + /> + )} + </div> + ))} + <div className="flex gap-2 pt-2"> + <button + type="button" + onClick={handleSubmitAnswers} + disabled={loading} + className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {loading ? "..." : "Submit Answers"} + </button> + <button + type="button" + onClick={handleCancelQuestions} + disabled={loading} + className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors" + > + Cancel + </button> + </div> + </div> + )} + + {/* Input Bar */} + <form onSubmit={handleSubmit} className="flex items-center gap-2 p-3"> + <select + value={model} + onChange={(e) => handleModelChange(e.target.value as LlmModel)} + disabled={loading || !!pendingQuestions} + className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs px-2 py-1 rounded-none outline-none focus:border-[#3f6fb3] disabled:opacity-50" + > + {MODEL_OPTIONS.map((opt) => ( + <option key={opt.value} value={opt.value}> + {opt.label} + </option> + ))} + </select> + <span className="text-[#555] font-mono text-[10px]"> + [{getContextLabel(context)}] + </span> + <span className="text-[#9bc3ff] font-mono text-sm">></span> + <input + ref={inputRef} + type="text" + value={input} + onChange={(e) => setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={ + loading + ? "Processing..." + : pendingQuestions + ? "Answer questions above first..." + : getPlaceholder(context) + } + disabled={loading || !!pendingQuestions} + className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]" + /> + {messages.length > 0 && ( + <button + type="button" + onClick={handleClearHistory} + className="text-[#555] hover:text-[#9bc3ff] font-mono text-xs transition-colors" + > + clear + </button> + )} + <button + type="submit" + disabled={loading || !input.trim() || !!pendingQuestions} + className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {loading ? "..." : "Send"} + </button> + </form> + </div> + ); +} |
