From f49aaa39a32661b54c109ba002d24cbdf73f4ea3 Mon Sep 17 00:00:00 2001 From: soryu Date: Mon, 9 Mar 2026 17:20:52 +0000 Subject: feat: worktree diff/commit endpoints and frontend diff viewing (#88) * feat: soryu-co/soryu - makima: Fix worktree info failing when origin ref is missing * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Add worktree commit endpoint and diff endpoint for regular users * feat: soryu-co/soryu - makima: Add frontend diff viewing with clickable worktree files --- .../components/directives/TaskSlideOutPanel.tsx | 145 +++++++++++++++++---- .../src/components/mesh/GitActionsPanel.tsx | 40 +++++- makima/frontend/src/components/mesh/TaskDetail.tsx | 41 +++++- .../src/components/mesh/WorktreeFilesPanel.tsx | 7 +- makima/frontend/src/lib/api.ts | 24 ++++ makima/frontend/src/routes/mesh.tsx | 23 +++- 6 files changed, 251 insertions(+), 29 deletions(-) (limited to 'makima/frontend') diff --git a/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx b/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx index 29fce23..176728c 100644 --- a/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx +++ b/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx @@ -3,7 +3,8 @@ import { useTaskSubscription } from "../../hooks/useTaskSubscription"; import type { TaskOutputEvent } from "../../hooks/useTaskSubscription"; import { TaskOutput } from "../mesh/TaskOutput"; import { WorktreeFilesPanel } from "../mesh/WorktreeFilesPanel"; -import { getTaskOutput } from "../../lib/api"; +import { OverlayDiffViewer } from "../mesh/OverlayDiffViewer"; +import { getTaskOutput, getTaskDiff } from "../../lib/api"; interface TaskSlideOutPanelProps { taskId: string; @@ -21,21 +22,37 @@ export function TaskSlideOutPanel({ const [entries, setEntries] = useState([]); const [isStreaming, setIsStreaming] = useState(false); const [loadingHistory, setLoadingHistory] = useState(false); + const [showDiff, setShowDiff] = useState(false); + const [diffContent, setDiffContent] = useState(""); + const [diffLoading, setDiffLoading] = useState(false); // Escape key handler useEffect(() => { const handleEsc = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + // If diff is showing, close diff first; otherwise close panel + if (e.key === "Escape") { + if (selectedFileDiff !== null || diffLoading) { + setSelectedFileDiff(null); + setSelectedFilePath(null); + setDiffLoading(false); + } else { + onClose(); + } + } }; if (isOpen) document.addEventListener("keydown", handleEsc); return () => document.removeEventListener("keydown", handleEsc); - }, [isOpen, onClose]); + }, [isOpen, onClose, selectedFileDiff, diffLoading]); // Load historical output when panel opens with a taskId useEffect(() => { if (!isOpen || !taskId) { setEntries([]); setIsStreaming(false); + // Reset diff state when panel closes + setSelectedFileDiff(null); + setSelectedFilePath(null); + setDiffLoading(false); return; } @@ -98,6 +115,23 @@ export function TaskSlideOutPanel({ [] ); + // Handle file click to show diff + const handleFileClick = useCallback(async (_filePath: string) => { + if (!taskId) return; + setDiffLoading(true); + try { + const result = await getTaskDiff(taskId); + if (result.success && result.diff) { + setDiffContent(result.diff); + setShowDiff(true); + } + } catch (e) { + console.error("Failed to get diff:", e); + } finally { + setDiffLoading(false); + } + }, [taskId]); + // Subscribe to live output useTaskSubscription({ taskId: isOpen ? taskId : null, @@ -106,6 +140,8 @@ export function TaskSlideOutPanel({ onUpdate: handleUpdate, }); + const showingDiff = selectedFileDiff !== null || diffLoading; + return ( <> {/* Backdrop overlay */} @@ -125,13 +161,27 @@ export function TaskSlideOutPanel({ {/* Header */}
+ {showingDiff && ( + + )} - Task + {showingDiff ? "Diff" : "Task"} - {taskName || taskId} + {showingDiff ? (selectedFilePath || "Loading...") : (taskName || taskId)} - {isStreaming && ( + {!showingDiff && isStreaming && ( @@ -140,40 +190,89 @@ export function TaskSlideOutPanel({ )}
+
{/* Content */}
- {/* Task Output section (~60% height) */} -
- {loadingHistory ? ( -
- - Loading output... - -
- ) : ( - + { + setSelectedFileDiff(null); + setSelectedFilePath(null); + setDiffLoading(false); + }} + title={selectedFilePath ? `Diff: ${selectedFilePath}` : "File Diff"} /> - )} -
+
+ ) : ( + <> + {/* Task Output section (~60% height) */} +
+ {loadingHistory ? ( +
+ + Loading output... + +
+ ) : ( + + )} +
{/* Worktree Changes section (~40% height) */}
- {taskId && } + {taskId && }
+ + {/* Diff modal */} + {showDiff && ( +
+
+ setShowDiff(false)} + title={`Changes in ${taskName || taskId}`} + /> +
+
+ )} ); } diff --git a/makima/frontend/src/components/mesh/GitActionsPanel.tsx b/makima/frontend/src/components/mesh/GitActionsPanel.tsx index be2e06d..ff1dc7d 100644 --- a/makima/frontend/src/components/mesh/GitActionsPanel.tsx +++ b/makima/frontend/src/components/mesh/GitActionsPanel.tsx @@ -1,5 +1,5 @@ import { useState, useCallback } from "react"; -import { exportTaskPatch, pushTaskBranch, createTaskPR, type ExportPatchResponse } from "../../lib/api"; +import { exportTaskPatch, pushTaskBranch, createTaskPR, commitWorktree, type ExportPatchResponse } from "../../lib/api"; interface GitActionsPanelProps { taskId: string; @@ -20,6 +20,8 @@ export function GitActionsPanel({ const [isExporting, setIsExporting] = useState(false); const [isPushing, setIsPushing] = useState(false); const [isCreatingPR, setIsCreatingPR] = useState(false); + const [isCommitting, setIsCommitting] = useState(false); + const [commitMessage, setCommitMessage] = useState(""); const [toast, setToast] = useState(null); const [exportedPatch, setExportedPatch] = useState(null); @@ -82,6 +84,23 @@ export function GitActionsPanel({ } }, [taskId, isLocalOnly]); + const handleCommit = useCallback(async () => { + setIsCommitting(true); + try { + const result = await commitWorktree(taskId, commitMessage || undefined); + if (result.success) { + showToast("success", `Committed: ${result.commitSha?.substring(0, 8) || "done"}`); + setCommitMessage(""); + } else { + showToast("error", result.error || "Failed to commit"); + } + } catch (e) { + showToast("error", e instanceof Error ? e.message : "Failed to commit"); + } finally { + setIsCommitting(false); + } + }, [taskId, commitMessage]); + return (
{/* Section Header */} @@ -112,6 +131,25 @@ export function GitActionsPanel({
)} + {/* Commit section */} +
+ setCommitMessage(e.target.value)} + placeholder="Commit message (optional)" + className="flex-1 px-2 py-1.5 font-mono text-xs bg-transparent border border-[rgba(117,170,252,0.2)] text-[#dbe7ff] placeholder:text-[#555] focus:border-[#75aafc] focus:outline-none" + onKeyDown={(e) => { if (e.key === "Enter") handleCommit(); }} + /> + +
+ {/* Action buttons */}
)} + {/* Worktree File Diff Modal */} + {(worktreeFileDiff !== null || worktreeFileDiffLoading) && ( +
+
+ { + setWorktreeFileDiff(null); + setWorktreeFileDiffPath(null); + setWorktreeFileDiffLoading(false); + }} + title={worktreeFileDiffPath ? `Diff: ${worktreeFileDiffPath}` : "File Diff"} + /> +
+
+ )} + {/* PR Preview Modal */} {showPRPreview && (
diff --git a/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx b/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx index b529588..bb3361d 100644 --- a/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx +++ b/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx @@ -4,6 +4,7 @@ import { getWorktreeInfo } from "../../lib/api"; interface WorktreeFilesPanelProps { taskId: string; + onFileClick?: (filePath: string) => void; } /** Get status badge styling based on file status */ @@ -35,7 +36,7 @@ function getStatusStyle(status: string): { color: string; bgColor: string; label } } -export function WorktreeFilesPanel({ taskId }: WorktreeFilesPanelProps) { +export function WorktreeFilesPanel({ taskId, onFileClick }: WorktreeFilesPanelProps) { const [worktreeInfo, setWorktreeInfo] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -116,6 +117,7 @@ export function WorktreeFilesPanel({ taskId }: WorktreeFilesPanelProps) { const { stats, files } = worktreeInfo; const displayFiles = expanded ? files : files.slice(0, 10); + const isClickable = !!onFileClick; return (
@@ -152,7 +154,8 @@ export function WorktreeFilesPanel({ taskId }: WorktreeFilesPanelProps) { return (
onFileClick?.(file.path)} > {/* Status badge */} { return res.json(); } +/** Get the diff for a task's worktree changes */ +export async function getTaskDiff(taskId: string): Promise<{ taskId: string; success: boolean; diff: string | null; error: string | null }> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/diff`); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to get task diff: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** Commit changes in a task's worktree */ +export async function commitWorktree(taskId: string, message?: string): Promise<{ taskId: string; success: boolean; commitSha: string | null; error: string | null }> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/worktree-commit`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message }), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to commit worktree: ${errorText || res.statusText}`); + } + return res.json(); +} + // ============================================================================= // Patch Types and Functions // ============================================================================= diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx index 67129f9..f210227 100644 --- a/makima/frontend/src/routes/mesh.tsx +++ b/makima/frontend/src/routes/mesh.tsx @@ -9,7 +9,7 @@ import { ContractCompleteQuestion } from "../components/mesh/ContractCompleteQue import { useTasks } from "../hooks/useTasks"; import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription"; import type { TaskWithSubtasks, MeshChatContext, ContractSummary, ContractWithRelations, DaemonDirectory, TaskSummary, RepositoryHistoryEntry } from "../lib/api"; -import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories, continueTask as continueTaskApi, resumeSupervisor, branchTask, getRepositorySuggestions } from "../lib/api"; +import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories, continueTask as continueTaskApi, resumeSupervisor, branchTask, getRepositorySuggestions, getTaskDiff } from "../lib/api"; import { DirectoryInput } from "../components/mesh/DirectoryInput"; import { useAuth } from "../contexts/AuthContext"; import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext"; @@ -136,6 +136,8 @@ export default function MeshPage() { const [viewingSubtaskName, setViewingSubtaskName] = useState(null); // For supervisor tasks: all tasks in the contract (excluding the supervisor itself) const [contractTasks, setContractTasks] = useState([]); + // Overlay diff content for viewing worktree changes + const [overlayDiff, setOverlayDiff] = useState(undefined); // View mode for the split panel layout const [viewMode, setViewMode] = useState("split"); // Width of the task panel as a percentage (0-100) @@ -297,6 +299,7 @@ export default function MeshPage() { useEffect(() => { setViewingSubtaskId(null); setViewingSubtaskName(null); + setOverlayDiff(undefined); }, [id]); // Toggle viewing a subtask's output (for running subtasks) @@ -315,6 +318,22 @@ export default function MeshPage() { [viewingSubtaskId] ); + // Request diff for the current task + const handleRequestDiff = useCallback(async () => { + if (!id) return; + try { + const result = await getTaskDiff(id); + if (result.success && result.diff) { + setOverlayDiff(result.diff); + } else { + setOverlayDiff(result.error || "Failed to get diff"); + } + } catch (e) { + console.error("Failed to get diff:", e); + setOverlayDiff(e instanceof Error ? e.message : "Failed to get diff"); + } + }, [id]); + // Load task detail when URL has an id useEffect(() => { if (id) { @@ -810,6 +829,8 @@ export default function MeshPage() { onViewContract={(contractId) => navigate(`/contracts/${contractId}`)} onBranch={handleBranch} contractTasks={taskDetail.isSupervisor ? contractTasks : undefined} + overlayDiff={overlayDiff} + onRequestDiff={handleRequestDiff} />
)} -- cgit v1.2.3