From f49aaa39a32661b54c109ba002d24cbdf73f4ea3 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
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 +++++++++++++++++----
 1 file changed, 122 insertions(+), 23 deletions(-)

(limited to 'makima/frontend/src/components/directives/TaskSlideOutPanel.tsx')

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<TaskOutputEvent[]>([]);
   const [isStreaming, setIsStreaming] = useState(false);
   const [loadingHistory, setLoadingHistory] = useState(false);
+  const [showDiff, setShowDiff] = useState(false);
+  const [diffContent, setDiffContent] = useState<string>("");
+  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 */}
         <div className="flex items-center justify-between px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.25)] shrink-0">
           <div className="flex items-center gap-2 min-w-0 flex-1">
+            {showingDiff && (
+              <button
+                type="button"
+                onClick={() => {
+                  setSelectedFileDiff(null);
+                  setSelectedFilePath(null);
+                  setDiffLoading(false);
+                }}
+                className="text-[#75aafc] hover:text-white font-mono text-xs transition-colors shrink-0 mr-1"
+                title="Back to worktree view"
+              >
+                &larr;
+              </button>
+            )}
             <span className="text-[10px] font-mono text-[#75aafc] uppercase tracking-wide shrink-0">
-              Task
+              {showingDiff ? "Diff" : "Task"}
             </span>
             <span className="text-[12px] font-mono text-white truncate">
-              {taskName || taskId}
+              {showingDiff ? (selectedFilePath || "Loading...") : (taskName || taskId)}
             </span>
-            {isStreaming && (
+            {!showingDiff && isStreaming && (
               <span className="flex items-center gap-1 px-1.5 py-0.5 bg-green-400/10 border border-green-400/30 shrink-0">
                 <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
                 <span className="text-green-400 font-mono text-[9px] uppercase">
@@ -140,40 +190,89 @@ export function TaskSlideOutPanel({
               </span>
             )}
           </div>
+          <button
+            onClick={async () => {
+              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);
+              }
+            }}
+            className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff] transition-colors px-1.5 py-0.5 border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] shrink-0"
+          >
+            {diffLoading ? "Loading..." : "View Diff"}
+          </button>
           <button
             type="button"
             onClick={onClose}
             className="text-[#7788aa] hover:text-white font-mono text-sm transition-colors ml-2 shrink-0 w-6 h-6 flex items-center justify-center"
           >
-            ✕
+            &#x2715;
           </button>
         </div>
 
         {/* Content */}
         <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
-          {/* Task Output section (~60% height) */}
-          <div className="flex-[3] min-h-0 flex flex-col border-b border-[rgba(117,170,252,0.15)]">
-            {loadingHistory ? (
-              <div className="flex-1 flex items-center justify-center">
-                <span className="font-mono text-xs text-[#555] animate-pulse">
-                  Loading output...
-                </span>
-              </div>
-            ) : (
-              <TaskOutput
-                entries={entries}
-                isStreaming={isStreaming}
-                taskId={taskId}
+          {showingDiff ? (
+            /* Diff view replaces the worktree panel content */
+            <div className="flex-1 min-h-0 overflow-y-auto">
+              <OverlayDiffViewer
+                diff={selectedFileDiff || ""}
+                loading={diffLoading}
+                onClose={() => {
+                  setSelectedFileDiff(null);
+                  setSelectedFilePath(null);
+                  setDiffLoading(false);
+                }}
+                title={selectedFilePath ? `Diff: ${selectedFilePath}` : "File Diff"}
               />
-            )}
-          </div>
+            </div>
+          ) : (
+            <>
+              {/* Task Output section (~60% height) */}
+              <div className="flex-[3] min-h-0 flex flex-col border-b border-[rgba(117,170,252,0.15)]">
+                {loadingHistory ? (
+                  <div className="flex-1 flex items-center justify-center">
+                    <span className="font-mono text-xs text-[#555] animate-pulse">
+                      Loading output...
+                    </span>
+                  </div>
+                ) : (
+                  <TaskOutput
+                    entries={entries}
+                    isStreaming={isStreaming}
+                    taskId={taskId}
+                  />
+                )}
+              </div>
 
           {/* Worktree Changes section (~40% height) */}
           <div className="flex-[2] min-h-0 overflow-y-auto">
-            {taskId && <WorktreeFilesPanel taskId={taskId} />}
+            {taskId && <WorktreeFilesPanel taskId={taskId} onFileClick={handleFileClick} />}
           </div>
         </div>
       </div>
+
+      {/* Diff modal */}
+      {showDiff && (
+        <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
+          <div className="max-w-4xl w-full max-h-[80vh]">
+            <OverlayDiffViewer
+              diff={diffContent}
+              onClose={() => setShowDiff(false)}
+              title={`Changes in ${taskName || taskId}`}
+            />
+          </div>
+        </div>
+      )}
     </>
   );
 }
-- 
cgit v1.2.3

