summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--makima/frontend/src/components/mesh/PatchesListPanel.tsx236
-rw-r--r--makima/frontend/src/components/mesh/TaskDetail.tsx12
-rw-r--r--makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx197
-rw-r--r--makima/frontend/src/lib/api.ts103
4 files changed, 548 insertions, 0 deletions
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<boolean> {
+ 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<PatchSummary[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+ const [copiedPatchId, setCopiedPatchId] = useState<string | null>(null);
+ const [expandedPatchId, setExpandedPatchId] = useState<string | null>(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 (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase mb-2">
+ Exported Patches
+ </div>
+ <div className="font-mono text-xs text-[#555] animate-pulse">
+ Loading patches...
+ </div>
+ </div>
+ );
+ }
+
+ if (error) {
+ return (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3">
+ <div className="flex items-center justify-between mb-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Exported Patches
+ </div>
+ <button
+ onClick={fetchPatches}
+ className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ Retry
+ </button>
+ </div>
+ <div className="font-mono text-xs text-red-400">
+ {error}
+ </div>
+ </div>
+ );
+ }
+
+ if (patches.length === 0) {
+ return (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3">
+ <div className="flex items-center justify-between mb-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Exported Patches
+ </div>
+ <button
+ onClick={fetchPatches}
+ className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ Refresh
+ </button>
+ </div>
+ <div className="font-mono text-xs text-[#555]">
+ No patches exported yet
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)]">
+ {/* Header */}
+ <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.1)]">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Exported Patches
+ </div>
+ <div className="flex items-center gap-2">
+ <span className="font-mono text-[10px] text-[#75aafc]">
+ {patches.length} patch{patches.length !== 1 ? "es" : ""}
+ </span>
+ <button
+ onClick={fetchPatches}
+ className="font-mono text-[10px] text-[#555] hover:text-[#75aafc] transition-colors"
+ title="Refresh"
+ >
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+ </svg>
+ </button>
+ </div>
+ </div>
+
+ {/* Patch list */}
+ <div className="divide-y divide-[rgba(117,170,252,0.1)]">
+ {patches.map((patch) => (
+ <div
+ key={patch.id}
+ className="p-3"
+ >
+ {/* Patch header */}
+ <div className="flex items-start justify-between gap-2 mb-2">
+ <div className="flex-1 min-w-0">
+ <div className="font-mono text-sm text-[#dbe7ff] truncate" title={patch.name}>
+ {patch.name}
+ </div>
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1 font-mono text-[10px] text-[#555]">
+ <span title={new Date(patch.createdAt).toLocaleString()}>
+ {formatDate(patch.createdAt)}
+ </span>
+ <span>
+ {patch.filesCount} file{patch.filesCount !== 1 ? "s" : ""}
+ </span>
+ <span className="text-green-400">+{patch.linesAdded}</span>
+ <span className="text-red-400">-{patch.linesRemoved}</span>
+ </div>
+ </div>
+ </div>
+
+ {/* Action buttons */}
+ <div className="flex flex-wrap gap-2">
+ <button
+ onClick={() => handleViewPatch(patch.id)}
+ className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors"
+ >
+ {expandedPatchId === patch.id ? "Hide" : "View"}
+ </button>
+ <button
+ onClick={() => handleCopyApplyCommand(patch)}
+ className={`px-2 py-1 font-mono text-[10px] border transition-colors ${
+ copiedPatchId === patch.id
+ ? "text-green-400 border-green-400/30 bg-green-400/10"
+ : "text-[#9bc3ff] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)]"
+ }`}
+ >
+ {copiedPatchId === patch.id ? "Copied!" : "Copy Apply Command"}
+ </button>
+ </div>
+
+ {/* Expanded patch content */}
+ {expandedPatchId === patch.id && patch.description && (
+ <div className="mt-3 p-2 bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.1)]">
+ <pre className="font-mono text-[10px] text-[#75aafc] whitespace-pre-wrap overflow-x-auto">
+ {patch.description}
+ </pre>
+ </div>
+ )}
+
+ {/* Expanded - show files if available */}
+ {expandedPatchId === patch.id && patch.files && patch.files.length > 0 && (
+ <div className="mt-2">
+ <div className="font-mono text-[10px] text-[#555] mb-1">Changed files:</div>
+ <div className="space-y-0.5">
+ {patch.files.map((file, idx) => (
+ <div key={idx} className="font-mono text-[10px] text-[#75aafc] pl-2">
+ {file}
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
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") && (
+ <WorktreeFilesPanel taskId={task.id} />
+ )}
+
+ {/* Patches List Panel - show exported patches for this task */}
+ {task.contractId && (task.status === "done" || task.status === "failed" || task.status === "merged") && (
+ <PatchesListPanel taskId={task.id} contractId={task.contractId} />
+ )}
</div>
{/* 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<WorktreeInfo | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(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 (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase mb-2">
+ Worktree Changes
+ </div>
+ <div className="font-mono text-xs text-[#555] animate-pulse">
+ Loading worktree info...
+ </div>
+ </div>
+ );
+ }
+
+ if (error) {
+ return (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3">
+ <div className="flex items-center justify-between mb-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Worktree Changes
+ </div>
+ <button
+ onClick={fetchWorktreeInfo}
+ className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ Retry
+ </button>
+ </div>
+ <div className="font-mono text-xs text-red-400">
+ {error}
+ </div>
+ </div>
+ );
+ }
+
+ if (!worktreeInfo || worktreeInfo.files.length === 0) {
+ return (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3">
+ <div className="flex items-center justify-between mb-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Worktree Changes
+ </div>
+ <button
+ onClick={fetchWorktreeInfo}
+ className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ Refresh
+ </button>
+ </div>
+ <div className="font-mono text-xs text-[#555]">
+ No changes in worktree
+ </div>
+ </div>
+ );
+ }
+
+ const { stats, files } = worktreeInfo;
+ const displayFiles = expanded ? files : files.slice(0, 10);
+
+ return (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)]">
+ {/* Header */}
+ <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.1)]">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Worktree Changes
+ </div>
+ <div className="flex items-center gap-3">
+ {/* Stats */}
+ <div className="flex items-center gap-2 font-mono text-[10px]">
+ <span className="text-[#75aafc]">
+ {stats.filesChanged} file{stats.filesChanged !== 1 ? "s" : ""}
+ </span>
+ <span className="text-green-400">+{stats.insertions}</span>
+ <span className="text-red-400">-{stats.deletions}</span>
+ </div>
+ <button
+ onClick={fetchWorktreeInfo}
+ className="font-mono text-[10px] text-[#555] hover:text-[#75aafc] transition-colors"
+ title="Refresh"
+ >
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+ </svg>
+ </button>
+ </div>
+ </div>
+
+ {/* File list */}
+ <div className="divide-y divide-[rgba(117,170,252,0.05)]">
+ {displayFiles.map((file) => {
+ const statusStyle = getStatusStyle(file.status);
+ return (
+ <div
+ key={file.path}
+ className="flex items-center gap-2 px-3 py-1.5 hover:bg-[rgba(117,170,252,0.03)]"
+ >
+ {/* Status badge */}
+ <span
+ className={`w-5 h-5 flex items-center justify-center font-mono text-[10px] font-medium ${statusStyle.color} ${statusStyle.bgColor} border border-current/20`}
+ title={file.status}
+ >
+ {statusStyle.label}
+ </span>
+
+ {/* File path */}
+ <span className="flex-1 font-mono text-xs text-[#dbe7ff] truncate" title={file.path}>
+ {file.path}
+ </span>
+
+ {/* Line stats */}
+ <div className="flex items-center gap-1.5 font-mono text-[10px] shrink-0">
+ {file.linesAdded > 0 && (
+ <span className="text-green-400">+{file.linesAdded}</span>
+ )}
+ {file.linesRemoved > 0 && (
+ <span className="text-red-400">-{file.linesRemoved}</span>
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+
+ {/* Show more/less button */}
+ {files.length > 10 && (
+ <div className="px-3 py-2 border-t border-[rgba(117,170,252,0.1)]">
+ <button
+ onClick={() => setExpanded(!expanded)}
+ className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ {expanded ? `Show less` : `Show ${files.length - 10} more files...`}
+ </button>
+ </div>
+ )}
+ </div>
+ );
+}
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<Task> {
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<WorktreeInfo> {
+ 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<PatchSummary[]> {
+ 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();
+}