diff options
Diffstat (limited to 'makima/frontend/src/components/history/CheckpointCard.tsx')
| -rw-r--r-- | makima/frontend/src/components/history/CheckpointCard.tsx | 284 |
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> + )} + </> + ); +} |
