From 8b17a175c3e7e27b789812eba4e3cd760beadb10 Mon Sep 17 00:00:00 2001
From: soryu <soryu@soryu.co>
Date: Tue, 6 Jan 2026 04:08:11 +0000
Subject: Initial Control system

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

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

diff --git a/makima/frontend/src/components/mesh/PRPreview.tsx b/makima/frontend/src/components/mesh/PRPreview.tsx
new file mode 100644
index 0000000..fc202b0
--- /dev/null
+++ b/makima/frontend/src/components/mesh/PRPreview.tsx
@@ -0,0 +1,314 @@
+import { useState, useMemo } from "react";
+import type { TaskWithSubtasks, TaskSummary } from "../../lib/api";
+import { OverlayDiffViewer } from "./OverlayDiffViewer";
+
+interface PRPreviewProps {
+  task: TaskWithSubtasks;
+  diff?: string;
+  changedFiles?: string[];
+  loading?: boolean;
+  onCreatePR?: (title: string, body: string, draft: boolean) => Promise<void>;
+  onAutoMerge?: () => Promise<void>;
+  onClose: () => void;
+}
+
+interface PRFormData {
+  title: string;
+  body: string;
+  isDraft: boolean;
+}
+
+function generatePRTitle(task: TaskWithSubtasks): string {
+  // Generate a PR title based on the task name
+  const prefix = task.parentTaskId ? "feat" : "feat";
+  return `${prefix}: ${task.name}`;
+}
+
+function generatePRBody(task: TaskWithSubtasks, changedFiles?: string[]): string {
+  const sections: string[] = [];
+
+  // Summary
+  sections.push("## Summary\n");
+  if (task.description) {
+    sections.push(task.description + "\n");
+  } else {
+    sections.push("_Add a brief description of the changes..._\n");
+  }
+
+  // Plan/Implementation details
+  sections.push("\n## Implementation\n");
+  if (task.plan) {
+    // Truncate if too long
+    const planPreview = task.plan.length > 500
+      ? task.plan.substring(0, 500) + "..."
+      : task.plan;
+    sections.push("```\n" + planPreview + "\n```\n");
+  }
+
+  // Subtasks summary
+  if (task.subtasks.length > 0) {
+    sections.push("\n## Subtasks\n");
+    task.subtasks.forEach((subtask: TaskSummary) => {
+      const emoji = subtask.status === "done" || subtask.status === "merged" ? "+" :
+                    subtask.status === "running" ? "~" : "-";
+      sections.push(`- [${emoji === "+" ? "x" : " "}] ${subtask.name} (${subtask.status})\n`);
+    });
+  }
+
+  // Changed files
+  if (changedFiles && changedFiles.length > 0) {
+    sections.push("\n## Changed Files\n");
+    changedFiles.slice(0, 20).forEach((file) => {
+      sections.push(`- \`${file}\`\n`);
+    });
+    if (changedFiles.length > 20) {
+      sections.push(`\n_...and ${changedFiles.length - 20} more files_\n`);
+    }
+  }
+
+  // Test plan
+  sections.push("\n## Test Plan\n");
+  sections.push("- [ ] Manual testing completed\n");
+  sections.push("- [ ] Unit tests added/updated\n");
+  sections.push("- [ ] Integration tests passing\n");
+
+  // Footer
+  sections.push("\n---\n");
+  sections.push("_Generated by makima mesh orchestrator_\n");
+
+  return sections.join("");
+}
+
+export function PRPreview({
+  task,
+  diff = "",
+  changedFiles = [],
+  loading = false,
+  onCreatePR,
+  onAutoMerge,
+  onClose,
+}: PRPreviewProps) {
+  const [showDiff, setShowDiff] = useState(false);
+  const [creating, setCreating] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  const [formData, setFormData] = useState<PRFormData>(() => ({
+    title: generatePRTitle(task),
+    body: generatePRBody(task, changedFiles),
+    isDraft: false,
+  }));
+
+  const handleCreatePR = async () => {
+    if (!onCreatePR || creating) return;
+
+    setCreating(true);
+    setError(null);
+
+    try {
+      await onCreatePR(formData.title, formData.body, formData.isDraft);
+      onClose();
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "Failed to create PR");
+    } finally {
+      setCreating(false);
+    }
+  };
+
+  const handleAutoMerge = async () => {
+    if (!onAutoMerge || creating) return;
+
+    if (!confirm("Are you sure you want to auto-merge this task directly to the target branch?")) {
+      return;
+    }
+
+    setCreating(true);
+    setError(null);
+
+    try {
+      await onAutoMerge();
+      onClose();
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "Failed to auto-merge");
+    } finally {
+      setCreating(false);
+    }
+  };
+
+  // Calculate stats
+  const stats = useMemo(() => {
+    const completedSubtasks = task.subtasks.filter(
+      (s) => s.status === "done" || s.status === "merged"
+    ).length;
+    return {
+      filesChanged: changedFiles.length,
+      subtasksCompleted: completedSubtasks,
+      subtasksTotal: task.subtasks.length,
+      isReady: completedSubtasks === task.subtasks.length || task.subtasks.length === 0,
+    };
+  }, [task.subtasks, changedFiles]);
+
+  return (
+    <div className="panel flex flex-col max-h-[80vh] overflow-hidden">
+      {/* Header */}
+      <div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.2)] shrink-0">
+        <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+          Create Pull Request
+        </div>
+        <button
+          onClick={onClose}
+          className="font-mono text-xs text-[#555] hover:text-[#9bc3ff]"
+        >
+          Cancel
+        </button>
+      </div>
+
+      {/* Content */}
+      <div className="flex-1 overflow-y-auto p-4 space-y-4">
+        {/* Status badges */}
+        <div className="flex flex-wrap gap-2">
+          <span className="px-2 py-0.5 font-mono text-[10px] text-[#75aafc] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.2)]">
+            {task.baseBranch || "main"} → {task.targetBranch || task.baseBranch || "main"}
+          </span>
+          <span className="px-2 py-0.5 font-mono text-[10px] text-[#9bc3ff] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.2)]">
+            {stats.filesChanged} files changed
+          </span>
+          {task.subtasks.length > 0 && (
+            <span
+              className={`px-2 py-0.5 font-mono text-[10px] border ${
+                stats.isReady
+                  ? "text-green-400 bg-green-400/10 border-green-400/20"
+                  : "text-yellow-400 bg-yellow-400/10 border-yellow-400/20"
+              }`}
+            >
+              {stats.subtasksCompleted}/{stats.subtasksTotal} subtasks complete
+            </span>
+          )}
+        </div>
+
+        {/* Warning if subtasks not complete */}
+        {!stats.isReady && (
+          <div className="bg-yellow-400/10 border border-yellow-400/30 p-3 font-mono text-xs text-yellow-400">
+            Some subtasks are not yet complete. Consider waiting before creating the PR.
+          </div>
+        )}
+
+        {/* Error message */}
+        {error && (
+          <div className="bg-red-400/10 border border-red-400/30 p-3 font-mono text-xs text-red-400">
+            {error}
+          </div>
+        )}
+
+        {/* PR Title */}
+        <div className="space-y-2">
+          <label className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+            Title
+          </label>
+          <input
+            type="text"
+            value={formData.title}
+            onChange={(e) => setFormData({ ...formData, title: e.target.value })}
+            className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]"
+            placeholder="PR title"
+            disabled={creating}
+          />
+        </div>
+
+        {/* PR Body */}
+        <div className="space-y-2">
+          <div className="flex items-center justify-between">
+            <label className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+              Description
+            </label>
+            <button
+              onClick={() => setFormData({
+                ...formData,
+                body: generatePRBody(task, changedFiles),
+              })}
+              className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]"
+            >
+              Regenerate
+            </button>
+          </div>
+          <textarea
+            value={formData.body}
+            onChange={(e) => setFormData({ ...formData, body: e.target.value })}
+            className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-xs px-3 py-2 outline-none focus:border-[#3f6fb3] min-h-[200px] resize-y"
+            placeholder="PR description (markdown)"
+            disabled={creating}
+          />
+        </div>
+
+        {/* Options */}
+        <div className="flex items-center gap-4">
+          <label className="flex items-center gap-2 cursor-pointer">
+            <input
+              type="checkbox"
+              checked={formData.isDraft}
+              onChange={(e) => setFormData({ ...formData, isDraft: e.target.checked })}
+              className="w-4 h-4 accent-[#75aafc]"
+              disabled={creating}
+            />
+            <span className="font-mono text-xs text-[#9bc3ff]">Create as draft</span>
+          </label>
+        </div>
+
+        {/* Diff preview toggle */}
+        <div className="border-t border-[rgba(117,170,252,0.2)] pt-4">
+          <button
+            onClick={() => setShowDiff(!showDiff)}
+            className="flex items-center gap-2 font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff]"
+          >
+            <span>{showDiff ? "▼" : "▶"}</span>
+            <span>
+              {showDiff ? "Hide" : "Show"} diff preview ({stats.filesChanged} files)
+            </span>
+          </button>
+        </div>
+
+        {/* Inline diff viewer */}
+        {showDiff && (
+          <div className="border border-[rgba(117,170,252,0.2)]">
+            <OverlayDiffViewer
+              diff={diff}
+              changedFiles={changedFiles}
+              loading={loading}
+              title="Changes to be merged"
+            />
+          </div>
+        )}
+      </div>
+
+      {/* Footer actions */}
+      <div className="flex items-center justify-between p-4 border-t border-[rgba(117,170,252,0.2)] shrink-0">
+        <div className="font-mono text-[10px] text-[#555]">
+          {task.repositoryUrl && (
+            <span className="truncate max-w-[200px] inline-block align-middle">
+              {task.repositoryUrl}
+            </span>
+          )}
+        </div>
+        <div className="flex items-center gap-2">
+          {task.mergeMode === "auto" && onAutoMerge && (
+            <button
+              onClick={handleAutoMerge}
+              disabled={creating || !stats.isReady}
+              className="px-4 py-2 font-mono text-xs text-yellow-400 border border-yellow-400/30 hover:border-yellow-400/50 hover:bg-yellow-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+            >
+              {creating ? "..." : "Auto-Merge"}
+            </button>
+          )}
+          {onCreatePR && (
+            <button
+              onClick={handleCreatePR}
+              disabled={creating || !formData.title.trim()}
+              className="px-4 py-2 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+            >
+              {creating ? "Creating..." : formData.isDraft ? "Create Draft PR" : "Create PR"}
+            </button>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
-- 
cgit v1.2.3

