summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/history/CheckpointCard.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/history/CheckpointCard.tsx')
-rw-r--r--makima/frontend/src/components/history/CheckpointCard.tsx284
1 files changed, 284 insertions, 0 deletions
diff --git a/makima/frontend/src/components/history/CheckpointCard.tsx b/makima/frontend/src/components/history/CheckpointCard.tsx
new file mode 100644
index 0000000..fee5bdc
--- /dev/null
+++ b/makima/frontend/src/components/history/CheckpointCard.tsx
@@ -0,0 +1,284 @@
+import { useState } from "react";
+import type { TaskCheckpoint } from "../../lib/api";
+import { forkTask, resumeFromCheckpoint } from "../../lib/api";
+
+interface CheckpointCardProps {
+ checkpoint: TaskCheckpoint;
+ taskId: string;
+ onActionComplete: () => void;
+}
+
+export function CheckpointCard({ checkpoint, taskId, onActionComplete }: CheckpointCardProps) {
+ const [showActions, setShowActions] = useState(false);
+ const [showForkDialog, setShowForkDialog] = useState(false);
+ const [showResumeDialog, setShowResumeDialog] = useState(false);
+ const [forkName, setForkName] = useState(`Fork from checkpoint ${checkpoint.checkpointNumber}`);
+ const [forkPlan, setForkPlan] = useState("");
+ const [resumePlan, setResumePlan] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const handleFork = async () => {
+ if (!forkName.trim() || !forkPlan.trim()) {
+ setError("Name and plan are required");
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+ try {
+ await forkTask(taskId, {
+ forkFromType: "checkpoint",
+ forkFromValue: String(checkpoint.checkpointNumber),
+ newTaskName: forkName,
+ newTaskPlan: forkPlan,
+ includeConversation: true,
+ });
+ setShowForkDialog(false);
+ onActionComplete();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to fork task");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleResume = async () => {
+ if (!resumePlan.trim()) {
+ setError("Plan is required");
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+ try {
+ await resumeFromCheckpoint(taskId, checkpoint.id, {
+ plan: resumePlan,
+ includeConversation: true,
+ });
+ setShowResumeDialog(false);
+ onActionComplete();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to resume from checkpoint");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <>
+ <div className="p-3 hover:bg-[rgba(117,170,252,0.05)] transition-colors">
+ <div className="flex items-start justify-between gap-4">
+ {/* Checkpoint info */}
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2">
+ <span className="font-mono text-sm text-purple-400">#{checkpoint.checkpointNumber}</span>
+ <span className="font-mono text-[10px] text-[#7788aa]">
+ {checkpoint.commitSha.slice(0, 7)}
+ </span>
+ <span className="font-mono text-[10px] text-[#556677]">
+ on {checkpoint.branchName}
+ </span>
+ </div>
+
+ {checkpoint.message && (
+ <div className="font-mono text-xs text-[#9bc3ff] mt-1">{checkpoint.message}</div>
+ )}
+
+ {/* Files changed */}
+ {checkpoint.filesChanged.length > 0 && (
+ <div className="mt-2 flex flex-wrap gap-2">
+ {checkpoint.filesChanged.slice(0, 5).map((file, i) => (
+ <span
+ key={i}
+ className={`font-mono text-[9px] px-1.5 py-0.5 border ${
+ file.action === "A"
+ ? "text-green-400 border-green-400/30"
+ : file.action === "D"
+ ? "text-red-400 border-red-400/30"
+ : "text-yellow-400 border-yellow-400/30"
+ }`}
+ >
+ {file.action} {file.path.split("/").pop()}
+ </span>
+ ))}
+ {checkpoint.filesChanged.length > 5 && (
+ <span className="font-mono text-[9px] text-[#556677]">
+ +{checkpoint.filesChanged.length - 5} more
+ </span>
+ )}
+ </div>
+ )}
+
+ {/* Stats */}
+ <div className="mt-2 flex items-center gap-4 font-mono text-[9px] text-[#556677]">
+ <span className="text-green-400">+{checkpoint.linesAdded}</span>
+ <span className="text-red-400">-{checkpoint.linesRemoved}</span>
+ <span>{new Date(checkpoint.createdAt).toLocaleString()}</span>
+ </div>
+ </div>
+
+ {/* Actions button */}
+ <button
+ onClick={() => setShowActions(!showActions)}
+ className="shrink-0 p-1 text-[#7788aa] hover:text-[#9bc3ff] transition-colors"
+ >
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {/* Actions dropdown */}
+ {showActions && (
+ <div className="mt-3 flex gap-2">
+ <button
+ onClick={() => {
+ setShowForkDialog(true);
+ setShowActions(false);
+ }}
+ className="px-3 py-1.5 font-mono text-[10px] uppercase text-purple-400 border border-purple-400/30 hover:bg-purple-400/10 transition-colors"
+ >
+ Fork from here
+ </button>
+ <button
+ onClick={() => {
+ setShowResumeDialog(true);
+ setShowActions(false);
+ }}
+ className="px-3 py-1.5 font-mono text-[10px] uppercase text-cyan-400 border border-cyan-400/30 hover:bg-cyan-400/10 transition-colors"
+ >
+ Resume from here
+ </button>
+ </div>
+ )}
+ </div>
+
+ {/* Fork dialog */}
+ {showForkDialog && (
+ <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
+ <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] max-w-lg w-full mx-4">
+ <div className="p-4 border-b border-[rgba(117,170,252,0.15)] flex justify-between items-center">
+ <h2 className="font-mono text-sm uppercase tracking-wide text-[#9bc3ff]">
+ Fork from Checkpoint #{checkpoint.checkpointNumber}
+ </h2>
+ <button
+ onClick={() => setShowForkDialog(false)}
+ className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors"
+ >
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+ <div className="p-4 space-y-4">
+ {error && (
+ <div className="p-2 bg-red-400/10 border border-red-400/30 font-mono text-xs text-red-400">
+ {error}
+ </div>
+ )}
+ <div>
+ <label className="block font-mono text-[10px] text-[#7788aa] uppercase mb-1">
+ New Task Name
+ </label>
+ <input
+ type="text"
+ value={forkName}
+ onChange={(e) => setForkName(e.target.value)}
+ className="w-full font-mono text-xs text-[#9bc3ff] bg-[#0a1525] border border-[rgba(117,170,252,0.25)] px-3 py-2 focus:border-[#3f6fb3] focus:outline-none"
+ />
+ </div>
+ <div>
+ <label className="block font-mono text-[10px] text-[#7788aa] uppercase mb-1">
+ Plan for New Task
+ </label>
+ <textarea
+ value={forkPlan}
+ onChange={(e) => setForkPlan(e.target.value)}
+ rows={4}
+ className="w-full font-mono text-xs text-[#9bc3ff] bg-[#0a1525] border border-[rgba(117,170,252,0.25)] px-3 py-2 focus:border-[#3f6fb3] focus:outline-none resize-none"
+ placeholder="Describe what this forked task should accomplish..."
+ />
+ </div>
+ <div className="flex justify-end gap-2">
+ <button
+ onClick={() => setShowForkDialog(false)}
+ className="px-4 py-2 font-mono text-xs uppercase text-[#7788aa] border border-[rgba(117,170,252,0.25)] hover:text-[#9bc3ff] transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleFork}
+ disabled={isLoading}
+ className="px-4 py-2 font-mono text-xs uppercase text-white bg-purple-600 border border-purple-500 hover:bg-purple-500 transition-colors disabled:opacity-50"
+ >
+ {isLoading ? "Creating..." : "Create Fork"}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Resume dialog */}
+ {showResumeDialog && (
+ <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
+ <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] max-w-lg w-full mx-4">
+ <div className="p-4 border-b border-[rgba(117,170,252,0.15)] flex justify-between items-center">
+ <h2 className="font-mono text-sm uppercase tracking-wide text-[#9bc3ff]">
+ Resume from Checkpoint #{checkpoint.checkpointNumber}
+ </h2>
+ <button
+ onClick={() => setShowResumeDialog(false)}
+ className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors"
+ >
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+ <div className="p-4 space-y-4">
+ {error && (
+ <div className="p-2 bg-red-400/10 border border-red-400/30 font-mono text-xs text-red-400">
+ {error}
+ </div>
+ )}
+ <div>
+ <label className="block font-mono text-[10px] text-[#7788aa] uppercase mb-1">
+ Plan for Resumed Task
+ </label>
+ <textarea
+ value={resumePlan}
+ onChange={(e) => setResumePlan(e.target.value)}
+ rows={4}
+ className="w-full font-mono text-xs text-[#9bc3ff] bg-[#0a1525] border border-[rgba(117,170,252,0.25)] px-3 py-2 focus:border-[#3f6fb3] focus:outline-none resize-none"
+ placeholder="Describe what the resumed task should do..."
+ />
+ </div>
+ <div className="flex justify-end gap-2">
+ <button
+ onClick={() => setShowResumeDialog(false)}
+ className="px-4 py-2 font-mono text-xs uppercase text-[#7788aa] border border-[rgba(117,170,252,0.25)] hover:text-[#9bc3ff] transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleResume}
+ disabled={isLoading}
+ className="px-4 py-2 font-mono text-xs uppercase text-white bg-cyan-600 border border-cyan-500 hover:bg-cyan-500 transition-colors disabled:opacity-50"
+ >
+ {isLoading ? "Creating..." : "Resume Task"}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+ </>
+ );
+}