diff options
| author | soryu <soryu@soryu.co> | 2026-01-16 12:23:49 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-16 12:23:49 +0000 |
| commit | 205ab8a223ddf6591a3e8bfc9108506502977c11 (patch) | |
| tree | d768063acff233dbeea223d7b6ea69d7e3038300 | |
| parent | 05931d19bc0c161d0177c3f983d0cd903d5e8ae3 (diff) | |
| download | soryu-205ab8a223ddf6591a3e8bfc9108506502977c11.tar.gz soryu-205ab8a223ddf6591a3e8bfc9108506502977c11.zip | |
Fixup: use default api.makima.jp URL and fix default branch detection
Also add checkpointing/history
25 files changed, 2071 insertions, 19 deletions
diff --git a/makima/docs/CLI.md b/makima/docs/CLI.md index 5246a30..0d4e499 100644 --- a/makima/docs/CLI.md +++ b/makima/docs/CLI.md @@ -140,7 +140,7 @@ All supervisor subcommands accept these common options: | Option | Environment Variable | Default | Description | |--------|---------------------|---------|-------------| -| `--api-url <URL>` | `MAKIMA_API_URL` | `http://localhost:8080` | API URL | +| `--api-url <URL>` | `MAKIMA_API_URL` | `https://api.makima.jp` | API URL | | `--api-key <KEY>` | `MAKIMA_API_KEY` | - | API key for authentication | | `--contract-id <UUID>` | `MAKIMA_CONTRACT_ID` | - | Contract ID | | `--task-id <UUID>` | `MAKIMA_TASK_ID` | - | Current task ID (optional) | @@ -314,7 +314,7 @@ All contract subcommands accept these common options: | Option | Environment Variable | Default | Description | |--------|---------------------|---------|-------------| -| `--api-url <URL>` | `MAKIMA_API_URL` | `http://localhost:8080` | API URL | +| `--api-url <URL>` | `MAKIMA_API_URL` | `https://api.makima.jp` | API URL | | `--api-key <KEY>` | `MAKIMA_API_KEY` | - | API key for authentication | | `--contract-id <UUID>` | `MAKIMA_CONTRACT_ID` | - | Contract ID | | `--task-id <UUID>` | `MAKIMA_TASK_ID` | - | Current task ID (optional) | 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"; diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 1e62732..9c56f6b 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -2093,3 +2093,402 @@ export async function createAdhocTask( } return res.json(); } + +// ============================================================================= +// History Types +// ============================================================================= + +/** History event from the timeline */ +export interface HistoryEvent { + id: string; + ownerId: string; + contractId: string | null; + taskId: string | null; + eventType: string; + eventSubtype: string | null; + phase: string | null; + eventData: Record<string, unknown>; + createdAt: string; +} + +/** Response for contract history endpoint */ +export interface ContractHistoryResponse { + contractId: string; + entries: HistoryEvent[]; + totalCount: number; + cursor: string | null; +} + +/** Tool call info in conversation messages */ +export interface ToolCallInfo { + id: string; + name: string; + input: Record<string, unknown>; +} + +/** Conversation message with optional tool calls */ +export interface ConversationMessage { + id: string; + role: string; + content: string; + timestamp: string; + toolCalls?: ToolCallInfo[]; + toolName?: string; + toolInput?: Record<string, unknown>; + toolResult?: string; + isError?: boolean; + tokenCount?: number; + costUsd?: number; +} + +/** Reference to a spawned task in supervisor conversation */ +export interface TaskReference { + taskId: string; + taskName: string; + status: string; + createdAt: string; + completedAt: string | null; +} + +/** Response for supervisor conversation endpoint */ +export interface SupervisorConversationResponse { + contractId: string; + supervisorTaskId: string; + phase: string; + lastActivity: string; + pendingTaskIds: string[]; + messages: ConversationMessage[]; + spawnedTasks: TaskReference[]; +} + +/** Response for task conversation endpoint */ +export interface TaskConversationResponse { + taskId: string; + taskName: string; + status: string; + messages: ConversationMessage[]; + totalTokens: number | null; + totalCost: number | null; +} + +/** Query filters for timeline endpoint */ +export interface TimelineQueryFilters { + contractId?: string; + taskId?: string; + includeSubtasks?: boolean; + from?: string; + to?: string; + limit?: number; +} + +/** Query filters for contract history endpoint */ +export interface HistoryQueryFilters { + phase?: string; + eventTypes?: string; + from?: string; + to?: string; + limit?: number; +} + +/** Task checkpoint */ +export interface TaskCheckpoint { + id: string; + taskId: string; + checkpointNumber: number; + commitSha: string; + branchName: string; + message: string; + filesChanged: Array<{ path: string; action: string }>; + linesAdded: number; + linesRemoved: number; + createdAt: string; +} + +// ============================================================================= +// History API Functions +// ============================================================================= + +/** + * Get contract history timeline. + */ +export async function getContractHistory( + contractId: string, + filters?: HistoryQueryFilters +): Promise<ContractHistoryResponse> { + const params = new URLSearchParams(); + if (filters?.phase) params.append("phase", filters.phase); + if (filters?.eventTypes) params.append("event_types", filters.eventTypes); + if (filters?.from) params.append("from", filters.from); + if (filters?.to) params.append("to", filters.to); + if (filters?.limit) params.append("limit", filters.limit.toString()); + + const query = params.toString(); + const url = `${API_BASE}/api/v1/contracts/${contractId}/history${query ? `?${query}` : ""}`; + + const res = await authFetch(url); + if (!res.ok) { + throw new Error(`Failed to get contract history: ${res.statusText}`); + } + return res.json(); +} + +/** + * Get supervisor conversation history. + */ +export async function getSupervisorConversation( + contractId: string +): Promise<SupervisorConversationResponse> { + const res = await authFetch( + `${API_BASE}/api/v1/contracts/${contractId}/supervisor/conversation` + ); + if (!res.ok) { + throw new Error(`Failed to get supervisor conversation: ${res.statusText}`); + } + return res.json(); +} + +/** + * Get task conversation history. + */ +export async function getTaskConversation( + taskId: string, + options?: { includeToolCalls?: boolean; includeToolResults?: boolean; limit?: number } +): Promise<TaskConversationResponse> { + const params = new URLSearchParams(); + if (options?.includeToolCalls !== undefined) + params.append("include_tool_calls", options.includeToolCalls.toString()); + if (options?.includeToolResults !== undefined) + params.append("include_tool_results", options.includeToolResults.toString()); + if (options?.limit) params.append("limit", options.limit.toString()); + + const query = params.toString(); + const url = `${API_BASE}/api/v1/mesh/tasks/${taskId}/conversation${query ? `?${query}` : ""}`; + + const res = await authFetch(url); + if (!res.ok) { + throw new Error(`Failed to get task conversation: ${res.statusText}`); + } + return res.json(); +} + +/** + * Get unified timeline for current user. + */ +export async function getTimeline( + filters?: TimelineQueryFilters +): Promise<ContractHistoryResponse> { + const params = new URLSearchParams(); + if (filters?.contractId) params.append("contract_id", filters.contractId); + if (filters?.taskId) params.append("task_id", filters.taskId); + if (filters?.includeSubtasks !== undefined) + params.append("include_subtasks", filters.includeSubtasks.toString()); + if (filters?.from) params.append("from", filters.from); + if (filters?.to) params.append("to", filters.to); + if (filters?.limit) params.append("limit", filters.limit.toString()); + + const query = params.toString(); + const url = `${API_BASE}/api/v1/timeline${query ? `?${query}` : ""}`; + + const res = await authFetch(url); + if (!res.ok) { + throw new Error(`Failed to get timeline: ${res.statusText}`); + } + return res.json(); +} + +/** + * Get task checkpoints. + */ +export async function getTaskCheckpoints(taskId: string): Promise<TaskCheckpoint[]> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/checkpoints`); + if (!res.ok) { + throw new Error(`Failed to get task checkpoints: ${res.statusText}`); + } + return res.json(); +} + +// ============================================================================= +// Resume/Rewind/Fork Types +// ============================================================================= + +/** Request to rewind a task to a checkpoint */ +export interface RewindTaskRequest { + checkpointId?: string; + checkpointSha?: string; + preserveMode: "discard" | "create_branch" | "stash"; + branchName?: string; +} + +/** Response from task rewind */ +export interface RewindTaskResponse { + taskId: string; + rewindedTo: { + checkpointNumber: number; + sha: string; + message: string; + }; + preservedAs?: { + stateType: string; + reference: string; + }; +} + +/** Request to fork a task from a checkpoint */ +export interface ForkTaskRequest { + forkFromType: "checkpoint" | "timestamp" | "message_id"; + forkFromValue: string; + newTaskName: string; + newTaskPlan: string; + includeConversation?: boolean; + createBranch?: boolean; + branchName?: string; +} + +/** Response from task fork */ +export interface ForkTaskResponse { + newTaskId: string; + sourceTaskId: string; + forkPoint: { + forkType: string; + checkpoint?: TaskCheckpoint; + timestamp: string; + }; + branchName?: string; + conversationIncluded: boolean; + messageCount?: number; +} + +/** Request to resume supervisor */ +export interface ResumeSupervisorRequest { + resumeMode: "continue" | "restart_phase" | "from_checkpoint"; + checkpointId?: string; + additionalContext?: string; +} + +/** Response from supervisor resume */ +export interface ResumeSupervisorResponse { + supervisorTaskId: string; + daemonId: string | null; + contractId: string; + phase: string; + status: string; + conversationMessageCount: number; +} + +/** Request to rewind supervisor conversation */ +export interface RewindConversationRequest { + toMessageId?: string; + byMessageCount?: number; + rewindCode?: boolean; +} + +/** Response from conversation rewind */ +export interface RewindConversationResponse { + contractId: string; + messagesRemoved: number; + newMessageCount: number; +} + +// ============================================================================= +// Resume/Rewind/Fork API Functions +// ============================================================================= + +/** + * Rewind a task to a checkpoint. + */ +export async function rewindTask( + taskId: string, + request: RewindTaskRequest +): Promise<RewindTaskResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/rewind`, { + method: "POST", + body: JSON.stringify(request), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to rewind task: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** + * Fork a task from a checkpoint. + */ +export async function forkTask( + taskId: string, + request: ForkTaskRequest +): Promise<ForkTaskResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/fork`, { + method: "POST", + body: JSON.stringify(request), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to fork task: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** + * Resume a supervisor. + */ +export async function resumeSupervisor( + contractId: string, + request: ResumeSupervisorRequest +): Promise<ResumeSupervisorResponse> { + const res = await authFetch( + `${API_BASE}/api/v1/contracts/${contractId}/supervisor/resume`, + { + method: "POST", + body: JSON.stringify(request), + } + ); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to resume supervisor: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** + * Rewind supervisor conversation. + */ +export async function rewindSupervisorConversation( + contractId: string, + request: RewindConversationRequest +): Promise<RewindConversationResponse> { + const res = await authFetch( + `${API_BASE}/api/v1/contracts/${contractId}/supervisor/conversation/rewind`, + { + method: "POST", + body: JSON.stringify(request), + } + ); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to rewind conversation: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** + * Resume task from a checkpoint. + */ +export async function resumeFromCheckpoint( + taskId: string, + checkpointId: string, + request: { taskName?: string; plan: string; includeConversation?: boolean } +): Promise<{ taskId: string }> { + const res = await authFetch( + `${API_BASE}/api/v1/mesh/tasks/${taskId}/checkpoints/${checkpointId}/resume`, + { + method: "POST", + body: JSON.stringify(request), + } + ); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to resume from checkpoint: ${errorText || res.statusText}`); + } + return res.json(); +} diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index 5d389fc..5fd6a4e 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -13,6 +13,7 @@ import FilesPage from "./routes/files"; import ContractsPage from "./routes/contracts"; import WorkflowPage from "./routes/workflow"; import MeshPage from "./routes/mesh"; +import HistoryPage from "./routes/history"; import LoginPage from "./routes/login"; import SettingsPage from "./routes/settings"; @@ -91,6 +92,22 @@ createRoot(document.getElementById("root")!).render( } /> <Route + path="/history" + element={ + <ProtectedRoute> + <HistoryPage /> + </ProtectedRoute> + } + /> + <Route + path="/history/:id" + element={ + <ProtectedRoute> + <HistoryPage /> + </ProtectedRoute> + } + /> + <Route path="/settings" element={ <ProtectedRoute> diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx index 8ed4ab5..cd385f9 100644 --- a/makima/frontend/src/routes/contracts.tsx +++ b/makima/frontend/src/routes/contracts.tsx @@ -612,7 +612,7 @@ function ContractsPageContent() { </span> </div> <div className="text-[10px] text-[#556677] truncate"> - {suggestion.repositoryUrl || suggestion.localPath} + {repoType === "local" ? suggestion.localPath : suggestion.repositoryUrl} </div> </button> ))} diff --git a/makima/frontend/src/routes/history.tsx b/makima/frontend/src/routes/history.tsx new file mode 100644 index 0000000..fc88f0e --- /dev/null +++ b/makima/frontend/src/routes/history.tsx @@ -0,0 +1,325 @@ +import { useState, useCallback, useEffect } from "react"; +import { useParams, useNavigate } from "react-router"; +import { Masthead } from "../components/Masthead"; +import { TimelineList } from "../components/history/TimelineList"; +import { ConversationView } from "../components/history/ConversationView"; +import { CheckpointList } from "../components/history/CheckpointList"; +import { HistoryFilters } from "../components/history/HistoryFilters"; +import { ResumeControls } from "../components/history/ResumeControls"; +import { useAuth } from "../contexts/AuthContext"; +import type { + HistoryEvent, + TaskConversationResponse, + SupervisorConversationResponse, + TaskCheckpoint, + ContractSummary, +} from "../lib/api"; +import { + getTimeline, + getTaskConversation, + getSupervisorConversation, + getTaskCheckpoints, + listContracts, +} from "../lib/api"; + +// Detail view modes +type DetailMode = "conversation" | "checkpoints"; + +export default function HistoryPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); + + // Timeline state + const [events, setEvents] = useState<HistoryEvent[]>([]); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + // Filters + const [contracts, setContracts] = useState<ContractSummary[]>([]); + const [selectedContractId, setSelectedContractId] = useState<string | null>(null); + const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null); + const [dateFrom, setDateFrom] = useState<string>(""); + const [dateTo, setDateTo] = useState<string>(""); + + // Selected event and detail + const [selectedEvent, setSelectedEvent] = useState<HistoryEvent | null>(null); + const [detailMode, setDetailMode] = useState<DetailMode>("conversation"); + const [conversation, setConversation] = useState< + TaskConversationResponse | SupervisorConversationResponse | null + >(null); + const [checkpoints, setCheckpoints] = useState<TaskCheckpoint[]>([]); + const [detailLoading, setDetailLoading] = useState(false); + + // Redirect to login if not authenticated + useEffect(() => { + if (!authLoading && isAuthConfigured && !isAuthenticated) { + navigate("/login"); + } + }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); + + // Load contracts for filter dropdown + useEffect(() => { + async function loadContracts() { + try { + const response = await listContracts(); + setContracts(response.contracts); + } catch (e) { + console.error("Failed to load contracts:", e); + } + } + if (isAuthenticated || !isAuthConfigured) { + loadContracts(); + } + }, [isAuthenticated, isAuthConfigured]); + + // Load timeline + const loadTimeline = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await getTimeline({ + contractId: selectedContractId || undefined, + taskId: selectedTaskId || undefined, + from: dateFrom || undefined, + to: dateTo || undefined, + limit: 100, + }); + setEvents(response.entries); + setTotalCount(response.totalCount); + } catch (e) { + console.error("Failed to load timeline:", e); + setError(e instanceof Error ? e.message : "Failed to load timeline"); + } finally { + setLoading(false); + } + }, [selectedContractId, selectedTaskId, dateFrom, dateTo]); + + // Load timeline on mount and filter change + useEffect(() => { + if (isAuthenticated || !isAuthConfigured) { + loadTimeline(); + } + }, [loadTimeline, isAuthenticated, isAuthConfigured]); + + // Load detail when event selected + const handleSelectEvent = useCallback(async (event: HistoryEvent) => { + setSelectedEvent(event); + setDetailLoading(true); + + try { + // Determine if this is a task or supervisor event + if (event.taskId) { + // Load task conversation and checkpoints + const [conv, cps] = await Promise.all([ + getTaskConversation(event.taskId, { + includeToolCalls: true, + includeToolResults: true, + }), + getTaskCheckpoints(event.taskId).catch(() => []), + ]); + setConversation(conv); + setCheckpoints(cps); + } else if (event.contractId) { + // Load supervisor conversation + const conv = await getSupervisorConversation(event.contractId); + setConversation(conv); + setCheckpoints([]); + } + } catch (e) { + console.error("Failed to load event details:", e); + } finally { + setDetailLoading(false); + } + }, []); + + // Handle URL param for direct navigation + useEffect(() => { + if (id && events.length > 0) { + const event = events.find((e) => e.taskId === id || e.contractId === id); + if (event && event !== selectedEvent) { + handleSelectEvent(event); + } + } + }, [id, events, selectedEvent, handleSelectEvent]); + + // Clear selection + const handleClearSelection = useCallback(() => { + setSelectedEvent(null); + setConversation(null); + setCheckpoints([]); + navigate("/history"); + }, [navigate]); + + // Handle filter changes + const handleContractChange = useCallback((contractId: string | null) => { + setSelectedContractId(contractId); + setSelectedTaskId(null); // Reset task filter when contract changes + }, []); + + // Handle actions completed + const handleActionComplete = useCallback(() => { + // Refresh timeline and detail after action + loadTimeline(); + if (selectedEvent?.taskId) { + handleSelectEvent(selectedEvent); + } + }, [loadTimeline, selectedEvent, handleSelectEvent]); + + if (authLoading) { + return ( + <div className="relative z-10 min-h-screen bg-[#0a1628] flex flex-col"> + <Masthead /> + <div className="flex-1 flex items-center justify-center"> + <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> + </div> + </div> + ); + } + + return ( + <div className="relative z-10 min-h-screen bg-[#0a1628] flex flex-col"> + <Masthead /> + + <main className="flex-1 flex flex-col overflow-hidden p-4 gap-4"> + {/* Filters */} + <HistoryFilters + contracts={contracts} + selectedContractId={selectedContractId} + onContractChange={handleContractChange} + dateFrom={dateFrom} + dateTo={dateTo} + onDateFromChange={setDateFrom} + onDateToChange={setDateTo} + totalCount={totalCount} + /> + + {/* Main content area */} + <div className="flex-1 flex gap-4 min-h-0 overflow-hidden"> + {/* Timeline list */} + <div className="w-1/3 min-w-[300px] max-w-[400px] flex flex-col"> + <TimelineList + events={events} + loading={loading} + error={error} + selectedEvent={selectedEvent} + onSelectEvent={handleSelectEvent} + onRefresh={loadTimeline} + /> + </div> + + {/* Detail panel */} + <div className="flex-1 flex flex-col min-h-0 overflow-hidden panel"> + {selectedEvent ? ( + <> + {/* Detail header */} + <div className="shrink-0 p-3 border-b border-[rgba(117,170,252,0.15)] flex items-center justify-between"> + <div className="flex items-center gap-4"> + <button + onClick={handleClearSelection} + 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="M15 19l-7-7 7-7" + /> + </svg> + </button> + <div> + <div className="font-mono text-xs text-[#9bc3ff] uppercase"> + {selectedEvent.eventType} + {selectedEvent.eventSubtype && ` / ${selectedEvent.eventSubtype}`} + </div> + <div className="font-mono text-[10px] text-[#7788aa]"> + {new Date(selectedEvent.createdAt).toLocaleString()} + </div> + </div> + </div> + + {/* Mode toggle (only if task has checkpoints) */} + {checkpoints.length > 0 && ( + <div className="flex gap-1"> + <button + onClick={() => setDetailMode("conversation")} + className={`px-3 py-1 font-mono text-[10px] uppercase border transition-colors ${ + detailMode === "conversation" + ? "border-[#3f6fb3] text-[#9bc3ff] bg-[rgba(63,111,179,0.2)]" + : "border-[rgba(117,170,252,0.25)] text-[#7788aa] hover:border-[rgba(117,170,252,0.35)]" + }`} + > + Conversation + </button> + <button + onClick={() => setDetailMode("checkpoints")} + className={`px-3 py-1 font-mono text-[10px] uppercase border transition-colors ${ + detailMode === "checkpoints" + ? "border-[#3f6fb3] text-[#9bc3ff] bg-[rgba(63,111,179,0.2)]" + : "border-[rgba(117,170,252,0.25)] text-[#7788aa] hover:border-[rgba(117,170,252,0.35)]" + }`} + > + Checkpoints ({checkpoints.length}) + </button> + </div> + )} + </div> + + {/* Detail content */} + <div className="flex-1 overflow-auto"> + {detailLoading ? ( + <div className="flex items-center justify-center h-full"> + <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> + </div> + ) : detailMode === "conversation" && conversation ? ( + <ConversationView conversation={conversation} /> + ) : detailMode === "checkpoints" && checkpoints.length > 0 ? ( + <CheckpointList + checkpoints={checkpoints} + taskId={selectedEvent.taskId!} + onActionComplete={handleActionComplete} + /> + ) : ( + <div className="flex items-center justify-center h-full"> + <div className="font-mono text-[#7788aa] text-xs"> + No {detailMode} data available + </div> + </div> + )} + </div> + + {/* Resume controls */} + {selectedEvent.taskId && ( + <ResumeControls + taskId={selectedEvent.taskId} + contractId={selectedEvent.contractId} + checkpoints={checkpoints} + onActionComplete={handleActionComplete} + /> + )} + </> + ) : ( + <div className="flex items-center justify-center h-full"> + <div className="text-center"> + <div className="font-mono text-[#7788aa] text-sm mb-2"> + Select an event to view details + </div> + <div className="font-mono text-[#556677] text-xs"> + View conversation history, checkpoints, and more + </div> + </div> + </div> + )} + </div> + </div> + </main> + </div> + ); +} diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo index 7af14b5..33deafa 100644 --- a/makima/frontend/tsconfig.tsbuildinfo +++ b/makima/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contracts.tsx","./src/routes/files.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contracts.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index 9c9ac77..47e627b 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -168,6 +168,12 @@ async fn run_daemon( } else { None }; + // Derive HTTP API URL from WebSocket server URL (wss://... -> https://...) + let api_url = config + .server + .url + .replace("wss://", "https://") + .replace("ws://", "http://"); let task_config = TaskConfig { max_concurrent_tasks: config.process.max_concurrent_tasks, worktree_base_dir: config.worktree.base_dir.clone(), @@ -178,6 +184,7 @@ async fn run_daemon( enable_permissions: config.process.enable_permissions, disable_verbose: config.process.disable_verbose, bubblewrap: bubblewrap_config, + api_url, }; // Create task manager diff --git a/makima/src/daemon/api/client.rs b/makima/src/daemon/api/client.rs index b27d606..2318d5a 100644 --- a/makima/src/daemon/api/client.rs +++ b/makima/src/daemon/api/client.rs @@ -42,7 +42,9 @@ impl ApiClient { let url = format!("{}{}", self.base_url, path); let response = self.client .get(&url) + // Send both headers - server will try tool key first, then API key .header("X-Makima-Tool-Key", &self.api_key) + .header("X-Makima-API-Key", &self.api_key) .send() .await?; @@ -58,7 +60,9 @@ impl ApiClient { let url = format!("{}{}", self.base_url, path); let response = self.client .post(&url) + // Send both headers - server will try tool key first, then API key .header("X-Makima-Tool-Key", &self.api_key) + .header("X-Makima-API-Key", &self.api_key) .header("Content-Type", "application/json") .json(body) .send() @@ -72,7 +76,9 @@ impl ApiClient { let url = format!("{}{}", self.base_url, path); let response = self.client .post(&url) + // Send both headers - server will try tool key first, then API key .header("X-Makima-Tool-Key", &self.api_key) + .header("X-Makima-API-Key", &self.api_key) .send() .await?; @@ -88,7 +94,9 @@ impl ApiClient { let url = format!("{}{}", self.base_url, path); let response = self.client .put(&url) + // Send both headers - server will try tool key first, then API key .header("X-Makima-Tool-Key", &self.api_key) + .header("X-Makima-API-Key", &self.api_key) .header("Content-Type", "application/json") .json(body) .send() diff --git a/makima/src/daemon/cli/contract.rs b/makima/src/daemon/cli/contract.rs index 5fef5ec..a443b85 100644 --- a/makima/src/daemon/cli/contract.rs +++ b/makima/src/daemon/cli/contract.rs @@ -7,7 +7,7 @@ use uuid::Uuid; #[derive(Args, Debug, Clone)] pub struct ContractArgs { /// API URL - #[arg(long, env = "MAKIMA_API_URL", default_value = "http://localhost:8080", global = true)] + #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp", global = true)] pub api_url: String, /// API key for authentication diff --git a/makima/src/daemon/cli/supervisor.rs b/makima/src/daemon/cli/supervisor.rs index ba4fb2b..db30cf1 100644 --- a/makima/src/daemon/cli/supervisor.rs +++ b/makima/src/daemon/cli/supervisor.rs @@ -7,7 +7,7 @@ use uuid::Uuid; #[derive(Args, Debug, Clone)] pub struct SupervisorArgs { /// API URL - #[arg(long, env = "MAKIMA_API_URL", default_value = "http://localhost:8080")] + #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp")] pub api_url: String, /// API key for authentication diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs index 5491934..fccebc5 100644 --- a/makima/src/daemon/task/manager.rs +++ b/makima/src/daemon/task/manager.rs @@ -978,6 +978,8 @@ pub struct TaskConfig { pub disable_verbose: bool, /// Bubblewrap sandbox configuration. pub bubblewrap: Option<crate::daemon::config::BubblewrapConfig>, + /// API URL for spawned tasks (HTTP endpoint for makima CLI). + pub api_url: String, } impl Default for TaskConfig { @@ -992,6 +994,7 @@ impl Default for TaskConfig { enable_permissions: false, disable_verbose: false, bubblewrap: None, + api_url: "https://api.makima.jp".to_string(), } } } @@ -1583,6 +1586,7 @@ impl TaskManager { active_pids: self.active_pids.clone(), git_user_email: self.git_user_email.clone(), git_user_name: self.git_user_name.clone(), + api_url: self.config.api_url.clone(), } } @@ -2877,6 +2881,7 @@ struct TaskManagerInner { active_pids: Arc<RwLock<HashMap<Uuid, u32>>>, git_user_email: Arc<RwLock<Option<String>>>, git_user_name: Arc<RwLock<Option<String>>>, + api_url: String, } impl TaskManagerInner { @@ -3196,8 +3201,7 @@ impl TaskManagerInner { // Set up environment variables for makima CLI let mut env = HashMap::new(); - // TODO: Make API URL configurable - env.insert("MAKIMA_API_URL".to_string(), "http://localhost:8080".to_string()); + env.insert("MAKIMA_API_URL".to_string(), self.api_url.clone()); env.insert("MAKIMA_API_KEY".to_string(), tool_key.clone()); env.insert("MAKIMA_TASK_ID".to_string(), task_id.to_string()); // Supervisor needs contract ID for its tools @@ -3207,7 +3211,7 @@ impl TaskManagerInner { tracing::info!( task_id = %task_id, - api_url = "http://localhost:8080", + api_url = %self.api_url, tool_key_preview = &tool_key[..8.min(tool_key.len())], "Set supervisor environment variables" ); @@ -3252,14 +3256,13 @@ impl TaskManagerInner { // Set up environment variables for makima CLI let mut env = HashMap::new(); - // TODO: Make API URL configurable - env.insert("MAKIMA_API_URL".to_string(), "http://localhost:8080".to_string()); + env.insert("MAKIMA_API_URL".to_string(), self.api_url.clone()); env.insert("MAKIMA_API_KEY".to_string(), tool_key.clone()); env.insert("MAKIMA_TASK_ID".to_string(), task_id.to_string()); tracing::info!( task_id = %task_id, - api_url = "http://localhost:8080", + api_url = %self.api_url, tool_key_preview = &tool_key[..8.min(tool_key.len())], "Set orchestrator environment variables" ); @@ -3313,7 +3316,7 @@ impl TaskManagerInner { tracing::warn!(task_id = %task_id, "Failed to register contract tool key"); } - env.insert("MAKIMA_API_URL".to_string(), "http://localhost:8080".to_string()); + env.insert("MAKIMA_API_URL".to_string(), self.api_url.clone()); env.insert("MAKIMA_API_KEY".to_string(), tool_key); env.insert("MAKIMA_TASK_ID".to_string(), task_id.to_string()); } @@ -4126,6 +4129,7 @@ impl Clone for TaskManagerInner { active_pids: self.active_pids.clone(), git_user_email: self.git_user_email.clone(), git_user_name: self.git_user_name.clone(), + api_url: self.api_url.clone(), } } } diff --git a/makima/src/daemon/worktree/manager.rs b/makima/src/daemon/worktree/manager.rs index ff0e9e7..d370828 100644 --- a/makima/src/daemon/worktree/manager.rs +++ b/makima/src/daemon/worktree/manager.rs @@ -128,8 +128,30 @@ impl WorktreeManager { /// Detect the default branch of a repository. /// Tries to find HEAD's target, falling back to common branch names. + /// Works for both regular and bare repositories. pub async fn detect_default_branch(&self, repo_path: &Path) -> Result<String, WorktreeError> { - // Try to get the branch that HEAD points to + tracing::debug!("Detecting default branch for repo: {}", repo_path.display()); + + // First, try to read HEAD directly (works for bare repos) + // In bare repos, HEAD is a symbolic ref to the default branch + let output = Command::new("git") + .args(["symbolic-ref", "HEAD", "--short"]) + .current_dir(repo_path) + .output() + .await?; + + if output.status.success() { + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !branch.is_empty() { + tracing::debug!("Detected default branch from HEAD: {}", branch); + return Ok(branch); + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::debug!("symbolic-ref HEAD failed: {}", stderr.trim()); + } + + // Try to get the branch that origin/HEAD points to (for regular clones) let output = Command::new("git") .args(["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]) .current_dir(repo_path) @@ -141,11 +163,12 @@ impl WorktreeManager { // Remove "origin/" prefix if present let branch = branch.strip_prefix("origin/").unwrap_or(&branch).to_string(); if !branch.is_empty() { + tracing::debug!("Detected default branch from origin/HEAD: {}", branch); return Ok(branch); } } - // Try common branch names + // Try common branch names in refs/heads (works for bare and regular repos) for branch in ["main", "master", "develop", "trunk"] { let output = Command::new("git") .args(["rev-parse", "--verify", &format!("refs/heads/{}", branch)]) @@ -154,11 +177,12 @@ impl WorktreeManager { .await?; if output.status.success() { + tracing::debug!("Detected default branch from refs/heads: {}", branch); return Ok(branch.to_string()); } } - // Fall back to getting the current branch + // Fall back to getting the current branch (for regular repos) let output = Command::new("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(repo_path) @@ -168,12 +192,41 @@ impl WorktreeManager { if output.status.success() { let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !branch.is_empty() && branch != "HEAD" { + tracing::debug!("Detected default branch from rev-parse: {}", branch); + return Ok(branch); + } + } + + // Final fallback: list all branches and pick the first one + let output = Command::new("git") + .args(["for-each-ref", "--format=%(refname:short)", "refs/heads/", "--count=1"]) + .current_dir(repo_path) + .output() + .await?; + + if output.status.success() { + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !branch.is_empty() { + tracing::warn!("Using first available branch as fallback: {}", branch); return Ok(branch); } } + // Log what branches exist for debugging + let output = Command::new("git") + .args(["for-each-ref", "--format=%(refname)", "refs/"]) + .current_dir(repo_path) + .output() + .await?; + + let available_refs = String::from_utf8_lossy(&output.stdout); + tracing::error!( + "Could not detect default branch. Available refs:\n{}", + available_refs + ); + Err(WorktreeError::GitCommand( - "Could not detect default branch".to_string(), + format!("Could not detect default branch. Check if the repository at {} has any branches.", repo_path.display()), )) } diff --git a/makima/src/server/handlers/repository_history.rs b/makima/src/server/handlers/repository_history.rs index c788d84..9c309c0 100644 --- a/makima/src/server/handlers/repository_history.rs +++ b/makima/src/server/handlers/repository_history.rs @@ -97,6 +97,14 @@ pub async fn get_repository_suggestions( let limit = params.limit.unwrap_or(10).min(50); // Cap at 50 for safety + tracing::debug!( + owner_id = %auth.owner_id, + source_type = ?params.source_type, + query = ?params.query, + limit = limit, + "Fetching repository suggestions" + ); + match repository::get_repository_suggestions( pool, auth.owner_id, @@ -107,6 +115,17 @@ pub async fn get_repository_suggestions( .await { Ok(entries) => { + // Debug log to help diagnose filtering issues + for entry in &entries { + tracing::debug!( + id = %entry.id, + name = %entry.name, + source_type = %entry.source_type, + has_url = entry.repository_url.is_some(), + has_path = entry.local_path.is_some(), + "Repository suggestion entry" + ); + } let total = entries.len() as i64; Json(RepositoryHistoryListResponse { entries, total }).into_response() } diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index e244a08..a4cb3d1 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contracts, file_ws, files, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contracts, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, templates, transcript_analysis, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -107,6 +107,7 @@ pub fn make_router(state: SharedState) -> Router { // Checkpoint endpoints .route("/mesh/tasks/{id}/checkpoint", post(mesh_supervisor::create_checkpoint)) .route("/mesh/tasks/{id}/checkpoints", get(mesh_supervisor::list_checkpoints)) + .route("/mesh/tasks/{id}/conversation", get(history::get_task_conversation)) // Resume and rewind endpoints .route("/mesh/tasks/{id}/rewind", post(mesh::rewind_task)) .route("/mesh/tasks/{id}/fork", post(mesh::fork_task)) @@ -166,6 +167,9 @@ pub fn make_router(state: SharedState) -> Router { // Contract supervisor resume endpoints .route("/contracts/{id}/supervisor/resume", post(mesh_supervisor::resume_supervisor)) .route("/contracts/{id}/supervisor/conversation/rewind", post(mesh_supervisor::rewind_conversation)) + // History endpoints + .route("/contracts/{id}/history", get(history::get_contract_history)) + .route("/contracts/{id}/supervisor/conversation", get(history::get_supervisor_conversation)) // Contract daemon endpoints (for tasks to interact with contracts) .route("/contracts/{id}/daemon/status", get(contract_daemon::get_contract_status)) .route("/contracts/{id}/daemon/checklist", get(contract_daemon::get_contract_checklist)) @@ -198,6 +202,8 @@ pub fn make_router(state: SharedState) -> Router { "/contracts/{id}/tasks/{task_id}", post(contracts::add_task_to_contract).delete(contracts::remove_task_from_contract), ) + // Timeline endpoint (unified history for user) + .route("/timeline", get(history::get_timeline)) // Template endpoints .route("/templates", get(templates::list_templates)) .route("/templates/{id}", get(templates::get_template)) |
