From bbbaab80baca6b152ce2edf68a971f29f189cd97 Mon Sep 17 00:00:00 2001 From: soryu Date: Mon, 26 Jan 2026 20:32:07 +0000 Subject: Add WorktreeFilesPanel and PatchesListPanel components --- .../src/components/mesh/PatchesListPanel.tsx | 236 +++++++++++++++++++++ makima/frontend/src/components/mesh/TaskDetail.tsx | 12 ++ .../src/components/mesh/WorktreeFilesPanel.tsx | 197 +++++++++++++++++ makima/frontend/src/lib/api.ts | 103 +++++++++ 4 files changed, 548 insertions(+) create mode 100644 makima/frontend/src/components/mesh/PatchesListPanel.tsx create mode 100644 makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx diff --git a/makima/frontend/src/components/mesh/PatchesListPanel.tsx b/makima/frontend/src/components/mesh/PatchesListPanel.tsx new file mode 100644 index 0000000..b63d7e7 --- /dev/null +++ b/makima/frontend/src/components/mesh/PatchesListPanel.tsx @@ -0,0 +1,236 @@ +import { useState, useEffect, useCallback } from "react"; +import type { PatchSummary } from "../../lib/api"; +import { listTaskPatches } from "../../lib/api"; + +interface PatchesListPanelProps { + taskId: string; + contractId: string; +} + +/** Format a date for display */ +function formatDate(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** Copy text to clipboard and show feedback */ +async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fallback for older browsers + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.left = "-999999px"; + textArea.style.top = "-999999px"; + document.body.appendChild(textArea); + textArea.select(); + try { + document.execCommand("copy"); + return true; + } catch { + return false; + } finally { + document.body.removeChild(textArea); + } + } +} + +export function PatchesListPanel({ taskId, contractId }: PatchesListPanelProps) { + const [patches, setPatches] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [copiedPatchId, setCopiedPatchId] = useState(null); + const [expandedPatchId, setExpandedPatchId] = useState(null); + + const fetchPatches = useCallback(async () => { + try { + setLoading(true); + setError(null); + const patchList = await listTaskPatches(taskId, contractId); + setPatches(patchList); + } catch (e) { + console.error("Failed to fetch patches:", e); + setError(e instanceof Error ? e.message : "Failed to fetch patches"); + } finally { + setLoading(false); + } + }, [taskId, contractId]); + + useEffect(() => { + fetchPatches(); + }, [fetchPatches]); + + const handleCopyApplyCommand = useCallback(async (patch: PatchSummary) => { + // Generate the apply command for this patch + const command = `makima patch apply ${patch.id}`; + const success = await copyToClipboard(command); + if (success) { + setCopiedPatchId(patch.id); + setTimeout(() => setCopiedPatchId(null), 2000); + } + }, []); + + const handleViewPatch = useCallback((patchId: string) => { + setExpandedPatchId(expandedPatchId === patchId ? null : patchId); + }, [expandedPatchId]); + + if (loading) { + return ( +
+
+ Exported Patches +
+
+ Loading patches... +
+
+ ); + } + + if (error) { + return ( +
+
+
+ Exported Patches +
+ +
+
+ {error} +
+
+ ); + } + + if (patches.length === 0) { + return ( +
+
+
+ Exported Patches +
+ +
+
+ No patches exported yet +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ Exported Patches +
+
+ + {patches.length} patch{patches.length !== 1 ? "es" : ""} + + +
+
+ + {/* Patch list */} +
+ {patches.map((patch) => ( +
+ {/* Patch header */} +
+
+
+ {patch.name} +
+
+ + {formatDate(patch.createdAt)} + + + {patch.filesCount} file{patch.filesCount !== 1 ? "s" : ""} + + +{patch.linesAdded} + -{patch.linesRemoved} +
+
+
+ + {/* Action buttons */} +
+ + +
+ + {/* Expanded patch content */} + {expandedPatchId === patch.id && patch.description && ( +
+
+                  {patch.description}
+                
+
+ )} + + {/* Expanded - show files if available */} + {expandedPatchId === patch.id && patch.files && patch.files.length > 0 && ( +
+
Changed files:
+
+ {patch.files.map((file, idx) => ( +
+ {file} +
+ ))} +
+
+ )} +
+ ))} +
+
+ ); +} diff --git a/makima/frontend/src/components/mesh/TaskDetail.tsx b/makima/frontend/src/components/mesh/TaskDetail.tsx index 2822b6d..fdcc58b 100644 --- a/makima/frontend/src/components/mesh/TaskDetail.tsx +++ b/makima/frontend/src/components/mesh/TaskDetail.tsx @@ -8,6 +8,8 @@ import { InlineSubtaskEditor } from "./InlineSubtaskEditor"; import { DirectoryInput } from "./DirectoryInput"; import { BranchTaskModal } from "./BranchTaskModal"; import { GitActionsPanel } from "./GitActionsPanel"; +import { WorktreeFilesPanel } from "./WorktreeFilesPanel"; +import { PatchesListPanel } from "./PatchesListPanel"; interface TaskDetailProps { task: TaskWithSubtasks; @@ -892,6 +894,16 @@ export function TaskDetail({ taskStatus={task.status} /> )} + + {/* Worktree Files Panel - show changed files in the worktree */} + {(task.status === "done" || task.status === "failed" || task.status === "merged" || task.status === "running") && ( + + )} + + {/* Patches List Panel - show exported patches for this task */} + {task.contractId && (task.status === "done" || task.status === "failed" || task.status === "merged") && ( + + )} {/* Overlay Diff Modal */} diff --git a/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx b/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx new file mode 100644 index 0000000..b529588 --- /dev/null +++ b/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx @@ -0,0 +1,197 @@ +import { useState, useEffect, useCallback } from "react"; +import type { WorktreeInfo } from "../../lib/api"; +import { getWorktreeInfo } from "../../lib/api"; + +interface WorktreeFilesPanelProps { + taskId: string; +} + +/** Get status badge styling based on file status */ +function getStatusStyle(status: string): { color: string; bgColor: string; label: string } { + switch (status) { + case "M": + case "modified": + return { color: "text-yellow-400", bgColor: "bg-yellow-400/10", label: "M" }; + case "A": + case "added": + return { color: "text-green-400", bgColor: "bg-green-400/10", label: "A" }; + case "D": + case "deleted": + return { color: "text-red-400", bgColor: "bg-red-400/10", label: "D" }; + case "R": + case "renamed": + return { color: "text-cyan-400", bgColor: "bg-cyan-400/10", label: "R" }; + case "C": + case "copied": + return { color: "text-purple-400", bgColor: "bg-purple-400/10", label: "C" }; + case "U": + case "unmerged": + return { color: "text-orange-400", bgColor: "bg-orange-400/10", label: "U" }; + case "?": + case "untracked": + return { color: "text-[#555]", bgColor: "bg-[#555]/10", label: "?" }; + default: + return { color: "text-[#9bc3ff]", bgColor: "bg-[rgba(117,170,252,0.1)]", label: status.charAt(0).toUpperCase() }; + } +} + +export function WorktreeFilesPanel({ taskId }: WorktreeFilesPanelProps) { + const [worktreeInfo, setWorktreeInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState(false); + + const fetchWorktreeInfo = useCallback(async () => { + try { + setLoading(true); + setError(null); + const info = await getWorktreeInfo(taskId); + setWorktreeInfo(info); + } catch (e) { + console.error("Failed to fetch worktree info:", e); + setError(e instanceof Error ? e.message : "Failed to fetch worktree info"); + } finally { + setLoading(false); + } + }, [taskId]); + + useEffect(() => { + fetchWorktreeInfo(); + }, [fetchWorktreeInfo]); + + if (loading) { + return ( +
+
+ Worktree Changes +
+
+ Loading worktree info... +
+
+ ); + } + + if (error) { + return ( +
+
+
+ Worktree Changes +
+ +
+
+ {error} +
+
+ ); + } + + if (!worktreeInfo || worktreeInfo.files.length === 0) { + return ( +
+
+
+ Worktree Changes +
+ +
+
+ No changes in worktree +
+
+ ); + } + + const { stats, files } = worktreeInfo; + const displayFiles = expanded ? files : files.slice(0, 10); + + return ( +
+ {/* Header */} +
+
+ Worktree Changes +
+
+ {/* Stats */} +
+ + {stats.filesChanged} file{stats.filesChanged !== 1 ? "s" : ""} + + +{stats.insertions} + -{stats.deletions} +
+ +
+
+ + {/* File list */} +
+ {displayFiles.map((file) => { + const statusStyle = getStatusStyle(file.status); + return ( +
+ {/* Status badge */} + + {statusStyle.label} + + + {/* File path */} + + {file.path} + + + {/* Line stats */} +
+ {file.linesAdded > 0 && ( + +{file.linesAdded} + )} + {file.linesRemoved > 0 && ( + -{file.linesRemoved} + )} +
+
+ ); + })} +
+ + {/* Show more/less button */} + {files.length > 10 && ( +
+ +
+ )} +
+ ); +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index c3b2a2a..f15eec0 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -2816,3 +2816,106 @@ export function getSupervisorStatus( export async function dismissTask(taskId: string): Promise { return updateTask(taskId, { hidden: true }); } + +// ============================================================================= +// Worktree Info Types and Functions +// ============================================================================= + +/** File status in the worktree (git status) */ +export type FileStatus = "M" | "A" | "D" | "R" | "C" | "U" | "?" | "modified" | "added" | "deleted" | "renamed" | "copied" | "unmerged" | "untracked"; + +/** A single changed file in the worktree */ +export interface WorktreeFile { + /** File path relative to worktree root */ + path: string; + /** Git status code (M=modified, A=added, D=deleted, R=renamed, C=copied, U=unmerged, ?=untracked) */ + status: FileStatus; + /** Lines added (0 if deleted or unavailable) */ + linesAdded: number; + /** Lines removed (0 if added or unavailable) */ + linesRemoved: number; +} + +/** Statistics about worktree changes */ +export interface WorktreeStats { + /** Number of files changed */ + filesChanged: number; + /** Total lines inserted */ + insertions: number; + /** Total lines deleted */ + deletions: number; +} + +/** Worktree information for a task */ +export interface WorktreeInfo { + /** Task ID */ + taskId: string; + /** Path to the worktree directory */ + worktreePath: string | null; + /** Whether the worktree exists on the daemon */ + exists: boolean; + /** Aggregate statistics */ + stats: WorktreeStats; + /** Changed files list */ + files: WorktreeFile[]; + /** Current branch name */ + branch: string | null; + /** Current HEAD commit SHA */ + headSha: string | null; +} + +/** + * Get worktree information for a task. + * Returns changed files, stats, and metadata about the worktree. + */ +export async function getWorktreeInfo(taskId: string): Promise { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/worktree-info`); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to get worktree info: ${errorText || res.statusText}`); + } + return res.json(); +} + +// ============================================================================= +// Patch Types and Functions +// ============================================================================= + +/** Summary of a patch file (contract file of type "patch") */ +export interface PatchSummary { + /** Patch/file ID */ + id: string; + /** Patch name */ + name: string; + /** Optional description */ + description: string | null; + /** Task ID this patch was created from */ + taskId: string | null; + /** Contract ID */ + contractId: string; + /** Number of files in the patch */ + filesCount: number; + /** Total lines added */ + linesAdded: number; + /** Total lines removed */ + linesRemoved: number; + /** List of file paths in the patch (if available) */ + files: string[] | null; + /** When the patch was created */ + createdAt: string; + /** When the patch was last updated */ + updatedAt: string; +} + +/** + * List patches for a task. + * Returns contract files of type "patch" associated with the task. + */ +export async function listTaskPatches(taskId: string, contractId: string): Promise { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/patches?contractId=${contractId}`); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to list patches: ${errorText || res.statusText}`); + } + return res.json(); +} -- cgit v1.2.3