From 2ee09745c12f0762ab99fadb00cdc0c363cb027b Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Mon, 26 Jan 2026 19:15:03 +0000
Subject: [WIP] Heartbeat checkpoint - 2026-01-26 19:15:03 UTC

---
 .../src/components/mesh/GitActionsPanel.tsx        | 181 +++++++++++++++++++++
 1 file changed, 181 insertions(+)
 create mode 100644 makima/frontend/src/components/mesh/GitActionsPanel.tsx

(limited to 'makima/frontend/src/components/mesh/GitActionsPanel.tsx')

diff --git a/makima/frontend/src/components/mesh/GitActionsPanel.tsx b/makima/frontend/src/components/mesh/GitActionsPanel.tsx
new file mode 100644
index 0000000..be2e06d
--- /dev/null
+++ b/makima/frontend/src/components/mesh/GitActionsPanel.tsx
@@ -0,0 +1,181 @@
+import { useState, useCallback } from "react";
+import { exportTaskPatch, pushTaskBranch, createTaskPR, type ExportPatchResponse } from "../../lib/api";
+
+interface GitActionsPanelProps {
+  taskId: string;
+  isLocalOnly: boolean;
+  taskStatus: string;
+}
+
+interface ToastMessage {
+  type: "success" | "error" | "info";
+  message: string;
+}
+
+export function GitActionsPanel({
+  taskId,
+  isLocalOnly,
+  taskStatus,
+}: GitActionsPanelProps) {
+  const [isExporting, setIsExporting] = useState(false);
+  const [isPushing, setIsPushing] = useState(false);
+  const [isCreatingPR, setIsCreatingPR] = useState(false);
+  const [toast, setToast] = useState<ToastMessage | null>(null);
+  const [exportedPatch, setExportedPatch] = useState<ExportPatchResponse | null>(null);
+
+  // Only show for completed tasks
+  if (taskStatus !== "done") return null;
+
+  const showToast = (type: ToastMessage["type"], message: string) => {
+    setToast({ type, message });
+    setTimeout(() => setToast(null), 5000);
+  };
+
+  const handleExportPatch = useCallback(async () => {
+    setIsExporting(true);
+    try {
+      const result = await exportTaskPatch(taskId);
+      setExportedPatch(result);
+      showToast("success", `Patch generated: ${result.fileName}`);
+    } catch (e) {
+      showToast("error", e instanceof Error ? e.message : "Failed to generate patch");
+    } finally {
+      setIsExporting(false);
+    }
+  }, [taskId]);
+
+  const handlePushBranch = useCallback(async () => {
+    if (isLocalOnly) {
+      showToast("error", "Push disabled in local-only mode");
+      return;
+    }
+    setIsPushing(true);
+    try {
+      const result = await pushTaskBranch(taskId);
+      showToast("success", `Branch pushed: ${result.branchName}`);
+    } catch (e) {
+      showToast("error", e instanceof Error ? e.message : "Failed to push branch");
+    } finally {
+      setIsPushing(false);
+    }
+  }, [taskId, isLocalOnly]);
+
+  const handleCreatePR = useCallback(async () => {
+    if (isLocalOnly) {
+      showToast("error", "PR creation disabled in local-only mode");
+      return;
+    }
+    setIsCreatingPR(true);
+    try {
+      const result = await createTaskPR(taskId);
+      if (result.prUrl) {
+        showToast("success", `PR created: ${result.prUrl}`);
+        // Open PR in new tab
+        window.open(result.prUrl, "_blank", "noopener,noreferrer");
+      } else {
+        showToast("success", "PR creation initiated");
+      }
+    } catch (e) {
+      showToast("error", e instanceof Error ? e.message : "Failed to create PR");
+    } finally {
+      setIsCreatingPR(false);
+    }
+  }, [taskId, isLocalOnly]);
+
+  return (
+    <div className="space-y-3">
+      {/* Section Header */}
+      <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+        Git Actions
+      </div>
+
+      {/* Local-only mode alert */}
+      {isLocalOnly && (
+        <div className="bg-yellow-400/10 border border-yellow-400/30 px-3 py-2 font-mono text-xs text-yellow-400 flex items-center gap-2">
+          <span className="w-1.5 h-1.5 bg-yellow-400 rounded-full" />
+          Contract is in local-only mode. Push/PR actions disabled.
+        </div>
+      )}
+
+      {/* Toast notification */}
+      {toast && (
+        <div
+          className={`px-3 py-2 font-mono text-xs border ${
+            toast.type === "success"
+              ? "bg-green-400/10 border-green-400/30 text-green-400"
+              : toast.type === "error"
+              ? "bg-red-400/10 border-red-400/30 text-red-400"
+              : "bg-blue-400/10 border-blue-400/30 text-blue-400"
+          }`}
+        >
+          {toast.message}
+        </div>
+      )}
+
+      {/* Action buttons */}
+      <div className="flex flex-wrap gap-2">
+        <button
+          onClick={handleExportPatch}
+          disabled={isExporting}
+          className="px-3 py-1.5 font-mono text-xs text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+        >
+          {isExporting ? "Generating..." : "Generate Patch"}
+        </button>
+
+        <button
+          onClick={handlePushBranch}
+          disabled={isPushing || isLocalOnly}
+          className={`px-3 py-1.5 font-mono text-xs border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
+            isLocalOnly
+              ? "text-[#555] border-[rgba(117,170,252,0.15)] cursor-not-allowed"
+              : "text-cyan-400 border-cyan-400/30 hover:border-cyan-400/50 hover:bg-cyan-400/10"
+          }`}
+          title={isLocalOnly ? "Disabled in local-only mode" : "Push changes to remote branch"}
+        >
+          {isPushing ? "Pushing..." : "Push Remote Branch"}
+        </button>
+
+        <button
+          onClick={handleCreatePR}
+          disabled={isCreatingPR || isLocalOnly}
+          className={`px-3 py-1.5 font-mono text-xs border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
+            isLocalOnly
+              ? "text-[#555] border-[rgba(117,170,252,0.15)] cursor-not-allowed"
+              : "text-green-400 border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10"
+          }`}
+          title={isLocalOnly ? "Disabled in local-only mode" : "Create pull request"}
+        >
+          {isCreatingPR ? "Creating PR..." : "Create PR"}
+        </button>
+      </div>
+
+      {/* Export result info */}
+      {exportedPatch && (
+        <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]">Exported Patch</div>
+          <div className="font-mono text-xs text-[#75aafc] break-all">
+            <span className="text-[#555]">File:</span> {exportedPatch.fileName}
+          </div>
+          {exportedPatch.filePath && (
+            <div className="font-mono text-xs text-[#75aafc] break-all">
+              <span className="text-[#555]">Path:</span> {exportedPatch.filePath}
+            </div>
+          )}
+          {exportedPatch.patchSize && (
+            <div className="font-mono text-xs text-[#75aafc]">
+              <span className="text-[#555]">Size:</span> {formatBytes(exportedPatch.patchSize)}
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+function formatBytes(bytes: number): string {
+  if (bytes === 0) return "0 B";
+  const k = 1024;
+  const sizes = ["B", "KB", "MB", "GB"];
+  const i = Math.floor(Math.log(bytes) / Math.log(k));
+  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
+}
-- 
cgit v1.2.3

