diff options
Diffstat (limited to 'makima/frontend/src/components')
11 files changed, 1215 insertions, 1 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 48abe09..7e12c75 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -14,6 +14,7 @@ const NAV_LINKS: NavLink[] = [ { label: "Contracts", href: "/contracts", requiresAuth: true }, { label: "Board", href: "/workflow", requiresAuth: true }, { label: "Mesh", href: "/mesh", requiresAuth: true }, + { label: "History", href: "/history", requiresAuth: true }, ]; export function NavStrip() { diff --git a/makima/frontend/src/components/contracts/RepositoryPanel.tsx b/makima/frontend/src/components/contracts/RepositoryPanel.tsx index e314140..15741a8 100644 --- a/makima/frontend/src/components/contracts/RepositoryPanel.tsx +++ b/makima/frontend/src/components/contracts/RepositoryPanel.tsx @@ -226,7 +226,7 @@ export function RepositoryPanel({ </span> </div> <div className="text-[10px] text-[#556677] truncate"> - {suggestion.repositoryUrl || suggestion.localPath} + {addMode === "local" ? suggestion.localPath : suggestion.repositoryUrl} </div> </button> ))} 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> + )} + </> + ); +} diff --git a/makima/frontend/src/components/history/CheckpointList.tsx b/makima/frontend/src/components/history/CheckpointList.tsx new file mode 100644 index 0000000..a12c155 --- /dev/null +++ b/makima/frontend/src/components/history/CheckpointList.tsx @@ -0,0 +1,51 @@ +import type { TaskCheckpoint } from "../../lib/api"; +import { CheckpointCard } from "./CheckpointCard"; + +interface CheckpointListProps { + checkpoints: TaskCheckpoint[]; + taskId: string; + onActionComplete: () => void; +} + +export function CheckpointList({ checkpoints, taskId, onActionComplete }: CheckpointListProps) { + // Sort checkpoints by number descending (most recent first) + const sortedCheckpoints = [...checkpoints].sort( + (a, b) => b.checkpointNumber - a.checkpointNumber + ); + + return ( + <div className="flex flex-col h-full"> + {/* Header */} + <div className="shrink-0 p-3 border-b border-[rgba(117,170,252,0.1)] bg-[rgba(0,0,0,0.2)]"> + <div className="flex items-center justify-between"> + <div className="font-mono text-xs text-[#9bc3ff]"> + {checkpoints.length} checkpoint{checkpoints.length !== 1 ? "s" : ""} + </div> + <div className="font-mono text-[10px] text-[#556677]"> + Fork or resume from any checkpoint + </div> + </div> + </div> + + {/* Checkpoint list */} + <div className="flex-1 overflow-y-auto"> + {sortedCheckpoints.length === 0 ? ( + <div className="flex items-center justify-center h-32"> + <div className="font-mono text-[#7788aa] text-xs">No checkpoints</div> + </div> + ) : ( + <div className="divide-y divide-[rgba(117,170,252,0.1)]"> + {sortedCheckpoints.map((checkpoint) => ( + <CheckpointCard + key={checkpoint.id} + checkpoint={checkpoint} + taskId={taskId} + onActionComplete={onActionComplete} + /> + ))} + </div> + )} + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/history/ConversationMessage.tsx b/makima/frontend/src/components/history/ConversationMessage.tsx new file mode 100644 index 0000000..43c0ed0 --- /dev/null +++ b/makima/frontend/src/components/history/ConversationMessage.tsx @@ -0,0 +1,147 @@ +import { useState } from "react"; +import type { ConversationMessage as ConversationMessageType } from "../../lib/api"; + +interface ConversationMessageProps { + message: ConversationMessageType; +} + +// Get role styling +function getRoleStyle(role: string) { + switch (role.toLowerCase()) { + case "user": + return { label: "User", color: "text-[#9bc3ff]", bg: "bg-[rgba(155,195,255,0.1)]" }; + case "assistant": + return { label: "Assistant", color: "text-emerald-400", bg: "bg-[rgba(52,211,153,0.1)]" }; + case "system": + return { label: "System", color: "text-yellow-400", bg: "bg-[rgba(250,204,21,0.1)]" }; + case "tool": + return { label: "Tool", color: "text-purple-400", bg: "bg-[rgba(192,132,252,0.1)]" }; + default: + return { label: role, color: "text-[#7788aa]", bg: "bg-[rgba(119,136,170,0.1)]" }; + } +} + +// Format JSON for display +function formatJson(data: unknown): string { + try { + return JSON.stringify(data, null, 2); + } catch { + return String(data); + } +} + +export function ConversationMessage({ message }: ConversationMessageProps) { + const [showToolDetails, setShowToolDetails] = useState(false); + const { label, color, bg } = getRoleStyle(message.role); + + const hasToolInfo = message.toolName || message.toolCalls?.length; + + return ( + <div className={`p-3 ${bg} border-l-2 border-transparent hover:border-[rgba(117,170,252,0.3)]`}> + {/* Header */} + <div className="flex items-center justify-between mb-2"> + <div className="flex items-center gap-2"> + <span className={`font-mono text-[10px] uppercase ${color}`}>{label}</span> + {message.toolName && ( + <span className="font-mono text-[9px] text-purple-400 px-1.5 py-0.5 border border-[rgba(192,132,252,0.3)]"> + {message.toolName} + </span> + )} + </div> + <div className="flex items-center gap-3"> + {message.tokenCount && ( + <span className="font-mono text-[9px] text-[#556677]"> + {message.tokenCount.toLocaleString()} tokens + </span> + )} + {message.costUsd !== undefined && message.costUsd > 0 && ( + <span className="font-mono text-[9px] text-[#556677]"> + ${message.costUsd.toFixed(4)} + </span> + )} + <span className="font-mono text-[9px] text-[#556677]"> + {new Date(message.timestamp).toLocaleTimeString()} + </span> + </div> + </div> + + {/* Content */} + <div className="font-mono text-xs text-[#dbe7ff] whitespace-pre-wrap break-words"> + {message.content} + </div> + + {/* Tool calls */} + {hasToolInfo && ( + <div className="mt-2"> + <button + onClick={() => setShowToolDetails(!showToolDetails)} + className="font-mono text-[9px] text-purple-400 hover:text-purple-300 uppercase flex items-center gap-1" + > + <svg + className={`w-3 h-3 transition-transform ${showToolDetails ? "rotate-90" : ""}`} + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> + </svg> + {message.toolCalls?.length + ? `${message.toolCalls.length} tool call${message.toolCalls.length > 1 ? "s" : ""}` + : "Tool details"} + </button> + + {showToolDetails && ( + <div className="mt-2 space-y-2"> + {/* Tool input */} + {message.toolInput && ( + <div className="p-2 bg-[rgba(0,0,0,0.3)] border border-[rgba(192,132,252,0.2)]"> + <div className="font-mono text-[9px] text-purple-400 uppercase mb-1">Input</div> + <pre className="font-mono text-[10px] text-[#9bc3ff] overflow-x-auto"> + {formatJson(message.toolInput)} + </pre> + </div> + )} + + {/* Tool result */} + {message.toolResult && ( + <div + className={`p-2 border ${ + message.isError + ? "bg-[rgba(239,68,68,0.1)] border-[rgba(239,68,68,0.3)]" + : "bg-[rgba(0,0,0,0.3)] border-[rgba(192,132,252,0.2)]" + }`} + > + <div + className={`font-mono text-[9px] uppercase mb-1 ${ + message.isError ? "text-red-400" : "text-purple-400" + }`} + > + {message.isError ? "Error" : "Result"} + </div> + <pre className="font-mono text-[10px] text-[#9bc3ff] overflow-x-auto max-h-48 overflow-y-auto"> + {message.toolResult} + </pre> + </div> + )} + + {/* Multiple tool calls */} + {message.toolCalls?.map((call, i) => ( + <div + key={call.id || i} + className="p-2 bg-[rgba(0,0,0,0.3)] border border-[rgba(192,132,252,0.2)]" + > + <div className="font-mono text-[9px] text-purple-400 uppercase mb-1"> + {call.name} + </div> + <pre className="font-mono text-[10px] text-[#9bc3ff] overflow-x-auto"> + {formatJson(call.input)} + </pre> + </div> + ))} + </div> + )} + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/components/history/ConversationView.tsx b/makima/frontend/src/components/history/ConversationView.tsx new file mode 100644 index 0000000..e3d1110 --- /dev/null +++ b/makima/frontend/src/components/history/ConversationView.tsx @@ -0,0 +1,114 @@ +import type { + TaskConversationResponse, + SupervisorConversationResponse, +} from "../../lib/api"; +import { ConversationMessage } from "./ConversationMessage"; + +interface ConversationViewProps { + conversation: TaskConversationResponse | SupervisorConversationResponse; +} + +// Type guard for task conversation +function isTaskConversation( + conv: TaskConversationResponse | SupervisorConversationResponse +): conv is TaskConversationResponse { + return "taskId" in conv; +} + +// Type guard for supervisor conversation +function isSupervisorConversation( + conv: TaskConversationResponse | SupervisorConversationResponse +): conv is SupervisorConversationResponse { + return "supervisorTaskId" in conv; +} + +export function ConversationView({ conversation }: ConversationViewProps) { + const messages = conversation.messages; + + return ( + <div className="flex flex-col h-full"> + {/* Header info */} + <div className="shrink-0 p-3 border-b border-[rgba(117,170,252,0.1)] bg-[rgba(0,0,0,0.2)]"> + <div className="flex items-center justify-between"> + <div> + {isTaskConversation(conversation) ? ( + <div className="font-mono text-xs text-[#9bc3ff]"> + {conversation.taskName} + <span + className={`ml-2 text-[9px] uppercase px-1.5 py-0.5 border ${ + conversation.status === "done" + ? "text-emerald-400 border-emerald-400/30" + : conversation.status === "running" + ? "text-green-400 border-green-400/30" + : conversation.status === "failed" + ? "text-red-400 border-red-400/30" + : "text-[#7788aa] border-[rgba(117,170,252,0.25)]" + }`} + > + {conversation.status} + </span> + </div> + ) : isSupervisorConversation(conversation) ? ( + <div className="font-mono text-xs text-[#9bc3ff]"> + Supervisor + <span className="ml-2 text-[9px] uppercase text-cyan-400 px-1.5 py-0.5 border border-cyan-400/30"> + {conversation.phase} + </span> + </div> + ) : null} + </div> + + <div className="flex items-center gap-4 font-mono text-[10px] text-[#556677]"> + <span>{messages.length} messages</span> + {isTaskConversation(conversation) && conversation.totalTokens && ( + <span>{conversation.totalTokens.toLocaleString()} tokens</span> + )} + {isTaskConversation(conversation) && + conversation.totalCost !== null && + conversation.totalCost > 0 && ( + <span>${conversation.totalCost.toFixed(4)}</span> + )} + </div> + </div> + + {/* Spawned tasks (supervisor only) */} + {isSupervisorConversation(conversation) && conversation.spawnedTasks.length > 0 && ( + <div className="mt-2 flex flex-wrap gap-2"> + <span className="font-mono text-[9px] text-[#7788aa] uppercase">Spawned:</span> + {conversation.spawnedTasks.map((task) => ( + <span + key={task.taskId} + className={`font-mono text-[9px] px-1.5 py-0.5 border ${ + task.status === "done" + ? "text-emerald-400 border-emerald-400/30" + : task.status === "running" + ? "text-green-400 border-green-400/30" + : task.status === "failed" + ? "text-red-400 border-red-400/30" + : "text-[#7788aa] border-[rgba(117,170,252,0.25)]" + }`} + > + {task.taskName} + </span> + ))} + </div> + )} + </div> + + {/* Messages */} + <div className="flex-1 overflow-y-auto"> + {messages.length === 0 ? ( + <div className="flex items-center justify-center h-32"> + <div className="font-mono text-[#7788aa] text-xs">No messages</div> + </div> + ) : ( + <div className="divide-y divide-[rgba(117,170,252,0.05)]"> + {messages.map((message, index) => ( + <ConversationMessage key={message.id || index} message={message} /> + ))} + </div> + )} + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/history/HistoryFilters.tsx b/makima/frontend/src/components/history/HistoryFilters.tsx new file mode 100644 index 0000000..a1a4945 --- /dev/null +++ b/makima/frontend/src/components/history/HistoryFilters.tsx @@ -0,0 +1,84 @@ +import type { ContractSummary } from "../../lib/api"; + +interface HistoryFiltersProps { + contracts: ContractSummary[]; + selectedContractId: string | null; + onContractChange: (contractId: string | null) => void; + dateFrom: string; + dateTo: string; + onDateFromChange: (date: string) => void; + onDateToChange: (date: string) => void; + totalCount: number; +} + +export function HistoryFilters({ + contracts, + selectedContractId, + onContractChange, + dateFrom, + dateTo, + onDateFromChange, + onDateToChange, + totalCount, +}: HistoryFiltersProps) { + return ( + <div className="shrink-0 flex items-center gap-4 p-3 border border-[rgba(117,170,252,0.15)] bg-[rgba(0,0,0,0.2)]"> + {/* Contract filter */} + <div className="flex items-center gap-2"> + <label className="font-mono text-[10px] text-[#7788aa] uppercase">Contract</label> + <select + value={selectedContractId || ""} + onChange={(e) => onContractChange(e.target.value || null)} + className="font-mono text-xs text-[#9bc3ff] bg-[#0a1525] border border-[rgba(117,170,252,0.25)] px-2 py-1 focus:border-[#3f6fb3] focus:outline-none min-w-[150px]" + > + <option value="">All Contracts</option> + {contracts.map((contract) => ( + <option key={contract.id} value={contract.id}> + {contract.name} + </option> + ))} + </select> + </div> + + {/* Date range */} + <div className="flex items-center gap-2"> + <label className="font-mono text-[10px] text-[#7788aa] uppercase">From</label> + <input + type="date" + value={dateFrom} + onChange={(e) => onDateFromChange(e.target.value)} + className="font-mono text-xs text-[#9bc3ff] bg-[#0a1525] border border-[rgba(117,170,252,0.25)] px-2 py-1 focus:border-[#3f6fb3] focus:outline-none" + /> + </div> + + <div className="flex items-center gap-2"> + <label className="font-mono text-[10px] text-[#7788aa] uppercase">To</label> + <input + type="date" + value={dateTo} + onChange={(e) => onDateToChange(e.target.value)} + className="font-mono text-xs text-[#9bc3ff] bg-[#0a1525] border border-[rgba(117,170,252,0.25)] px-2 py-1 focus:border-[#3f6fb3] focus:outline-none" + /> + </div> + + {/* Clear filters */} + {(selectedContractId || dateFrom || dateTo) && ( + <button + onClick={() => { + onContractChange(null); + onDateFromChange(""); + onDateToChange(""); + }} + className="font-mono text-[10px] text-[#7788aa] hover:text-[#9bc3ff] uppercase transition-colors" + > + Clear Filters + </button> + )} + + {/* Result count */} + <div className="ml-auto font-mono text-[10px] text-[#556677]"> + {totalCount} event{totalCount !== 1 ? "s" : ""} + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/history/ResumeControls.tsx b/makima/frontend/src/components/history/ResumeControls.tsx new file mode 100644 index 0000000..23493f0 --- /dev/null +++ b/makima/frontend/src/components/history/ResumeControls.tsx @@ -0,0 +1,306 @@ +import { useState } from "react"; +import type { TaskCheckpoint } from "../../lib/api"; +import { rewindTask, resumeSupervisor, rewindSupervisorConversation } from "../../lib/api"; + +interface ResumeControlsProps { + taskId: string; + contractId: string | null; + checkpoints: TaskCheckpoint[]; + onActionComplete: () => void; +} + +export function ResumeControls({ + taskId, + contractId, + checkpoints, + onActionComplete, +}: ResumeControlsProps) { + const [showRewindDialog, setShowRewindDialog] = useState(false); + const [showSupervisorDialog, setShowSupervisorDialog] = useState(false); + const [selectedCheckpoint, setSelectedCheckpoint] = useState<string>(""); + const [preserveMode, setPreserveMode] = useState<"discard" | "create_branch">("create_branch"); + const [branchName, setBranchName] = useState(""); + const [resumeMode, setResumeMode] = useState<"continue" | "restart_phase">("continue"); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + const handleRewindTask = async () => { + if (!selectedCheckpoint) { + setError("Select a checkpoint"); + return; + } + + setIsLoading(true); + setError(null); + try { + await rewindTask(taskId, { + checkpointId: selectedCheckpoint, + preserveMode, + branchName: preserveMode === "create_branch" ? branchName || undefined : undefined, + }); + setShowRewindDialog(false); + onActionComplete(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to rewind task"); + } finally { + setIsLoading(false); + } + }; + + const handleResumeSupervisor = async () => { + if (!contractId) return; + + setIsLoading(true); + setError(null); + try { + await resumeSupervisor(contractId, { + resumeMode, + }); + setShowSupervisorDialog(false); + onActionComplete(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to resume supervisor"); + } finally { + setIsLoading(false); + } + }; + + const handleRewindConversation = async () => { + if (!contractId) return; + + setIsLoading(true); + setError(null); + try { + await rewindSupervisorConversation(contractId, { + byMessageCount: 1, // Rewind by 1 message + }); + onActionComplete(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to rewind conversation"); + } finally { + setIsLoading(false); + } + }; + + return ( + <> + <div className="shrink-0 p-3 border-t border-[rgba(117,170,252,0.15)] bg-[rgba(0,0,0,0.2)] flex items-center gap-2"> + {/* Task controls */} + {checkpoints.length > 0 && ( + <button + onClick={() => setShowRewindDialog(true)} + className="px-3 py-1.5 font-mono text-[10px] uppercase text-yellow-400 border border-yellow-400/30 hover:bg-yellow-400/10 transition-colors" + > + Rewind Code + </button> + )} + + {/* Supervisor controls */} + {contractId && ( + <> + <button + onClick={() => setShowSupervisorDialog(true)} + 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 Supervisor + </button> + <button + onClick={handleRewindConversation} + disabled={isLoading} + className="px-3 py-1.5 font-mono text-[10px] uppercase text-orange-400 border border-orange-400/30 hover:bg-orange-400/10 transition-colors disabled:opacity-50" + > + Undo Last Message + </button> + </> + )} + </div> + + {/* Rewind Task Dialog */} + {showRewindDialog && ( + <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]"> + Rewind Task Code + </h2> + <button + onClick={() => setShowRewindDialog(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"> + Checkpoint + </label> + <select + value={selectedCheckpoint} + onChange={(e) => setSelectedCheckpoint(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" + > + <option value="">Select checkpoint...</option> + {checkpoints.map((cp) => ( + <option key={cp.id} value={cp.id}> + #{cp.checkpointNumber} - {cp.message || cp.commitSha.slice(0, 7)} + </option> + ))} + </select> + </div> + + <div> + <label className="block font-mono text-[10px] text-[#7788aa] uppercase mb-1"> + Preserve Current Code + </label> + <div className="flex gap-4"> + <label className="flex items-center gap-2 cursor-pointer"> + <input + type="radio" + name="preserveMode" + checked={preserveMode === "create_branch"} + onChange={() => setPreserveMode("create_branch")} + className="text-[#3f6fb3]" + /> + <span className="font-mono text-xs text-[#9bc3ff]">Create branch</span> + </label> + <label className="flex items-center gap-2 cursor-pointer"> + <input + type="radio" + name="preserveMode" + checked={preserveMode === "discard"} + onChange={() => setPreserveMode("discard")} + className="text-[#3f6fb3]" + /> + <span className="font-mono text-xs text-[#9bc3ff]">Discard</span> + </label> + </div> + </div> + + {preserveMode === "create_branch" && ( + <div> + <label className="block font-mono text-[10px] text-[#7788aa] uppercase mb-1"> + Branch Name (optional) + </label> + <input + type="text" + value={branchName} + onChange={(e) => setBranchName(e.target.value)} + placeholder="Auto-generated if empty" + 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 className="flex justify-end gap-2"> + <button + onClick={() => setShowRewindDialog(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={handleRewindTask} + disabled={isLoading || !selectedCheckpoint} + className="px-4 py-2 font-mono text-xs uppercase text-white bg-yellow-600 border border-yellow-500 hover:bg-yellow-500 transition-colors disabled:opacity-50" + > + {isLoading ? "Rewinding..." : "Rewind"} + </button> + </div> + </div> + </div> + </div> + )} + + {/* Resume Supervisor Dialog */} + {showSupervisorDialog && ( + <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 Supervisor + </h2> + <button + onClick={() => setShowSupervisorDialog(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-2"> + Resume Mode + </label> + <div className="space-y-2"> + <label className="flex items-start gap-2 cursor-pointer p-2 border border-[rgba(117,170,252,0.15)] hover:border-[rgba(117,170,252,0.25)] transition-colors"> + <input + type="radio" + name="resumeMode" + checked={resumeMode === "continue"} + onChange={() => setResumeMode("continue")} + className="mt-0.5 text-[#3f6fb3]" + /> + <div> + <span className="font-mono text-xs text-[#9bc3ff]">Continue</span> + <p className="font-mono text-[10px] text-[#7788aa] mt-0.5"> + Resume with existing conversation context + </p> + </div> + </label> + <label className="flex items-start gap-2 cursor-pointer p-2 border border-[rgba(117,170,252,0.15)] hover:border-[rgba(117,170,252,0.25)] transition-colors"> + <input + type="radio" + name="resumeMode" + checked={resumeMode === "restart_phase"} + onChange={() => setResumeMode("restart_phase")} + className="mt-0.5 text-[#3f6fb3]" + /> + <div> + <span className="font-mono text-xs text-[#9bc3ff]">Restart Phase</span> + <p className="font-mono text-[10px] text-[#7788aa] mt-0.5"> + Clear conversation but keep phase progress + </p> + </div> + </label> + </div> + </div> + + <div className="flex justify-end gap-2"> + <button + onClick={() => setShowSupervisorDialog(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={handleResumeSupervisor} + 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 ? "Resuming..." : "Resume"} + </button> + </div> + </div> + </div> + </div> + )} + </> + ); +} diff --git a/makima/frontend/src/components/history/TimelineEventCard.tsx b/makima/frontend/src/components/history/TimelineEventCard.tsx new file mode 100644 index 0000000..f48466f --- /dev/null +++ b/makima/frontend/src/components/history/TimelineEventCard.tsx @@ -0,0 +1,139 @@ +import type { HistoryEvent } from "../../lib/api"; + +interface TimelineEventCardProps { + event: HistoryEvent; + isSelected: boolean; + onClick: () => void; +} + +// Get icon and color based on event type +function getEventStyle(eventType: string, eventSubtype: string | null) { + const type = eventType.toLowerCase(); + const subtype = eventSubtype?.toLowerCase(); + + if (type === "task") { + if (subtype === "created") return { icon: "+", color: "text-[#9bc3ff]" }; + if (subtype === "started") return { icon: "\u25B6", color: "text-green-400" }; + if (subtype === "completed") return { icon: "\u2713", color: "text-emerald-400" }; + if (subtype === "failed") return { icon: "\u2717", color: "text-red-400" }; + if (subtype === "stopped") return { icon: "\u25A0", color: "text-yellow-400" }; + return { icon: "\u2022", color: "text-[#9bc3ff]" }; + } + + if (type === "checkpoint") { + return { icon: "\u2691", color: "text-purple-400" }; + } + + if (type === "phase") { + return { icon: "\u21B3", color: "text-cyan-400" }; + } + + if (type === "chat") { + return { icon: "\u2709", color: "text-[#75aafc]" }; + } + + if (type === "contract") { + return { icon: "\u2606", color: "text-[#9bc3ff]" }; + } + + if (type === "file") { + return { icon: "\u2630", color: "text-[#7788aa]" }; + } + + return { icon: "\u2022", color: "text-[#7788aa]" }; +} + +// Format relative time +function formatRelativeTime(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + + if (diffSec < 60) return "just now"; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHour < 24) return `${diffHour}h ago`; + if (diffDay < 7) return `${diffDay}d ago`; + + return date.toLocaleDateString(); +} + +// Extract a preview from event data +function getEventPreview(event: HistoryEvent): string { + const data = event.eventData as Record<string, unknown>; + + // Task events + if (data.taskName) return String(data.taskName); + if (data.name) return String(data.name); + + // Chat events + if (data.message) { + const msg = String(data.message); + return msg.length > 50 ? msg.slice(0, 50) + "..." : msg; + } + + // Checkpoint events + if (data.checkpointMessage) return String(data.checkpointMessage); + if (data.commitSha) return `Commit ${String(data.commitSha).slice(0, 7)}`; + + // Phase events + if (data.phase) return `Phase: ${data.phase}`; + + // Contract events + if (data.contractName) return String(data.contractName); + + return ""; +} + +export function TimelineEventCard({ event, isSelected, onClick }: TimelineEventCardProps) { + const { icon, color } = getEventStyle(event.eventType, event.eventSubtype); + const preview = getEventPreview(event); + + return ( + <button + onClick={onClick} + className={`w-full text-left p-3 transition-colors ${ + isSelected + ? "bg-[rgba(63,111,179,0.2)] border-l-2 border-[#3f6fb3]" + : "hover:bg-[rgba(117,170,252,0.05)] border-l-2 border-transparent" + }`} + > + <div className="flex items-start gap-3"> + {/* Icon */} + <div className={`font-mono text-sm ${color}`}>{icon}</div> + + {/* Content */} + <div className="flex-1 min-w-0"> + <div className="flex items-center justify-between gap-2"> + <div className="font-mono text-xs text-[#9bc3ff] uppercase truncate"> + {event.eventType} + {event.eventSubtype && ( + <span className="text-[#7788aa]"> / {event.eventSubtype}</span> + )} + </div> + <div className="font-mono text-[10px] text-[#556677] shrink-0"> + {formatRelativeTime(event.createdAt)} + </div> + </div> + + {preview && ( + <div className="font-mono text-[10px] text-[#7788aa] mt-1 truncate"> + {preview} + </div> + )} + + {event.phase && ( + <div className="mt-1"> + <span className="font-mono text-[9px] text-[#75aafc] uppercase px-1.5 py-0.5 border border-[rgba(117,170,252,0.25)]"> + {event.phase} + </span> + </div> + )} + </div> + </div> + </button> + ); +} diff --git a/makima/frontend/src/components/history/TimelineList.tsx b/makima/frontend/src/components/history/TimelineList.tsx new file mode 100644 index 0000000..b0348c0 --- /dev/null +++ b/makima/frontend/src/components/history/TimelineList.tsx @@ -0,0 +1,80 @@ +import type { HistoryEvent } from "../../lib/api"; +import { TimelineEventCard } from "./TimelineEventCard"; + +interface TimelineListProps { + events: HistoryEvent[]; + loading: boolean; + error: string | null; + selectedEvent: HistoryEvent | null; + onSelectEvent: (event: HistoryEvent) => void; + onRefresh: () => void; +} + +export function TimelineList({ + events, + loading, + error, + selectedEvent, + onSelectEvent, + onRefresh, +}: TimelineListProps) { + return ( + <div className="panel flex flex-col h-full"> + {/* Header */} + <div className="shrink-0 p-3 border-b border-[rgba(117,170,252,0.15)] flex items-center justify-between"> + <h2 className="font-mono text-xs text-[#9bc3ff] uppercase tracking-wide"> + Timeline + </h2> + <button + onClick={onRefresh} + disabled={loading} + className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors disabled:opacity-50" + title="Refresh timeline" + > + <svg + className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" + /> + </svg> + </button> + </div> + + {/* Content */} + <div className="flex-1 overflow-y-auto"> + {loading && events.length === 0 ? ( + <div className="flex items-center justify-center h-32"> + <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> + </div> + ) : error ? ( + <div className="p-4"> + <div className="font-mono text-red-400 text-xs mb-2">Error loading timeline</div> + <div className="font-mono text-[#7788aa] text-[10px]">{error}</div> + </div> + ) : events.length === 0 ? ( + <div className="flex items-center justify-center h-32"> + <div className="font-mono text-[#7788aa] text-xs">No events found</div> + </div> + ) : ( + <div className="divide-y divide-[rgba(117,170,252,0.1)]"> + {events.map((event) => ( + <TimelineEventCard + key={event.id} + event={event} + isSelected={selectedEvent?.id === event.id} + onClick={() => onSelectEvent(event)} + /> + ))} + </div> + )} + </div> + </div> + ); +} diff --git a/makima/frontend/src/components/history/index.ts b/makima/frontend/src/components/history/index.ts new file mode 100644 index 0000000..5b66ea2 --- /dev/null +++ b/makima/frontend/src/components/history/index.ts @@ -0,0 +1,8 @@ +export { TimelineList } from "./TimelineList"; +export { TimelineEventCard } from "./TimelineEventCard"; +export { HistoryFilters } from "./HistoryFilters"; +export { ConversationView } from "./ConversationView"; +export { ConversationMessage } from "./ConversationMessage"; +export { CheckpointList } from "./CheckpointList"; +export { CheckpointCard } from "./CheckpointCard"; +export { ResumeControls } from "./ResumeControls"; |
