summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-16 12:23:49 +0000
committersoryu <soryu@soryu.co>2026-01-16 12:23:49 +0000
commit205ab8a223ddf6591a3e8bfc9108506502977c11 (patch)
treed768063acff233dbeea223d7b6ea69d7e3038300 /makima/frontend/src/components
parent05931d19bc0c161d0177c3f983d0cd903d5e8ae3 (diff)
downloadsoryu-205ab8a223ddf6591a3e8bfc9108506502977c11.tar.gz
soryu-205ab8a223ddf6591a3e8bfc9108506502977c11.zip
Fixup: use default api.makima.jp URL and fix default branch detection
Also add checkpointing/history
Diffstat (limited to 'makima/frontend/src/components')
-rw-r--r--makima/frontend/src/components/NavStrip.tsx1
-rw-r--r--makima/frontend/src/components/contracts/RepositoryPanel.tsx2
-rw-r--r--makima/frontend/src/components/history/CheckpointCard.tsx284
-rw-r--r--makima/frontend/src/components/history/CheckpointList.tsx51
-rw-r--r--makima/frontend/src/components/history/ConversationMessage.tsx147
-rw-r--r--makima/frontend/src/components/history/ConversationView.tsx114
-rw-r--r--makima/frontend/src/components/history/HistoryFilters.tsx84
-rw-r--r--makima/frontend/src/components/history/ResumeControls.tsx306
-rw-r--r--makima/frontend/src/components/history/TimelineEventCard.tsx139
-rw-r--r--makima/frontend/src/components/history/TimelineList.tsx80
-rw-r--r--makima/frontend/src/components/history/index.ts8
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";