summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/mesh/PRPreview.tsx
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-06 04:08:11 +0000
committersoryu <soryu@soryu.co>2026-01-11 03:01:13 +0000
commit8b17a175c3e7e27b789812eba4e3cd760beadb10 (patch)
tree7864dcaa2fa9db47fdfd4e8bfdb0b1dde832aa33 /makima/frontend/src/components/mesh/PRPreview.tsx
parentf79c416c58557d2f946aa5332989afdfa8c021cd (diff)
downloadsoryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz
soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip
Initial Control system
Diffstat (limited to 'makima/frontend/src/components/mesh/PRPreview.tsx')
-rw-r--r--makima/frontend/src/components/mesh/PRPreview.tsx314
1 files changed, 314 insertions, 0 deletions
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>
+ );
+}