import React, { useState, useEffect } from "react"; import { motion } from "framer-motion"; import { GitBranch, Save, RotateCcw, GitFork, AlertCircle, ChevronDown, ChevronRight, Hash, FileCode, Diff } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { api, type Checkpoint, type TimelineNode, type SessionTimeline, type CheckpointDiff } from "@/lib/api"; import { cn } from "@/lib/utils"; import { formatDistanceToNow } from "date-fns"; interface TimelineNavigatorProps { sessionId: string; projectId: string; projectPath: string; currentMessageIndex: number; onCheckpointSelect: (checkpoint: Checkpoint) => void; onFork: (checkpointId: string) => void; /** * Incrementing value provided by parent to force timeline reload when checkpoints * are created elsewhere (e.g., auto-checkpoint after tool execution). */ refreshVersion?: number; className?: string; } /** * Visual timeline navigator for checkpoint management */ export const TimelineNavigator: React.FC = ({ sessionId, projectId, projectPath, currentMessageIndex, onCheckpointSelect, onFork, refreshVersion = 0, className }) => { const [timeline, setTimeline] = useState(null); const [selectedCheckpoint, setSelectedCheckpoint] = useState(null); const [expandedNodes, setExpandedNodes] = useState>(new Set()); const [showCreateDialog, setShowCreateDialog] = useState(false); const [showDiffDialog, setShowDiffDialog] = useState(false); const [checkpointDescription, setCheckpointDescription] = useState(""); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [diff, setDiff] = useState(null); const [compareCheckpoint, setCompareCheckpoint] = useState(null); // Load timeline on mount and whenever refreshVersion bumps useEffect(() => { loadTimeline(); }, [sessionId, projectId, projectPath, refreshVersion]); const loadTimeline = async () => { try { setIsLoading(true); setError(null); const timelineData = await api.getSessionTimeline(sessionId, projectId, projectPath); setTimeline(timelineData); // Auto-expand nodes with current checkpoint if (timelineData.currentCheckpointId && timelineData.rootNode) { const pathToNode = findPathToCheckpoint(timelineData.rootNode, timelineData.currentCheckpointId); setExpandedNodes(new Set(pathToNode)); } } catch (err) { console.error("Failed to load timeline:", err); setError("Failed to load timeline"); } finally { setIsLoading(false); } }; const findPathToCheckpoint = (node: TimelineNode, checkpointId: string, path: string[] = []): string[] => { if (node.checkpoint.id === checkpointId) { return path; } for (const child of node.children) { const childPath = findPathToCheckpoint(child, checkpointId, [...path, node.checkpoint.id]); if (childPath.length > path.length) { return childPath; } } return path; }; const handleCreateCheckpoint = async () => { try { setIsLoading(true); setError(null); await api.createCheckpoint( sessionId, projectId, projectPath, currentMessageIndex, checkpointDescription || undefined ); setCheckpointDescription(""); setShowCreateDialog(false); await loadTimeline(); } catch (err) { console.error("Failed to create checkpoint:", err); setError("Failed to create checkpoint"); } finally { setIsLoading(false); } }; const handleRestoreCheckpoint = async (checkpoint: Checkpoint) => { if (!confirm(`Restore to checkpoint "${checkpoint.description || checkpoint.id.slice(0, 8)}"? Current state will be saved as a new checkpoint.`)) { return; } try { setIsLoading(true); setError(null); // First create a checkpoint of current state await api.createCheckpoint( sessionId, projectId, projectPath, currentMessageIndex, "Auto-save before restore" ); // Then restore await api.restoreCheckpoint(checkpoint.id, sessionId, projectId, projectPath); await loadTimeline(); onCheckpointSelect(checkpoint); } catch (err) { console.error("Failed to restore checkpoint:", err); setError("Failed to restore checkpoint"); } finally { setIsLoading(false); } }; const handleFork = async (checkpoint: Checkpoint) => { onFork(checkpoint.id); }; const handleCompare = async (checkpoint: Checkpoint) => { if (!selectedCheckpoint) { setSelectedCheckpoint(checkpoint); return; } try { setIsLoading(true); setError(null); const diffData = await api.getCheckpointDiff( selectedCheckpoint.id, checkpoint.id, sessionId, projectId ); setDiff(diffData); setCompareCheckpoint(checkpoint); setShowDiffDialog(true); } catch (err) { console.error("Failed to get diff:", err); setError("Failed to compare checkpoints"); } finally { setIsLoading(false); } }; const toggleNodeExpansion = (nodeId: string) => { const newExpanded = new Set(expandedNodes); if (newExpanded.has(nodeId)) { newExpanded.delete(nodeId); } else { newExpanded.add(nodeId); } setExpandedNodes(newExpanded); }; const renderTimelineNode = (node: TimelineNode, depth: number = 0) => { const isExpanded = expandedNodes.has(node.checkpoint.id); const hasChildren = node.children.length > 0; const isCurrent = timeline?.currentCheckpointId === node.checkpoint.id; const isSelected = selectedCheckpoint?.id === node.checkpoint.id; return (
{/* Connection line */} {depth > 0 && (
)} {/* Node content */} 0 && "ml-6" )} style={{ paddingLeft: `${depth * 24}px` }} > {/* Expand/collapse button */} {hasChildren && ( )} {/* Checkpoint card */} setSelectedCheckpoint(node.checkpoint)} >
{isCurrent && ( Current )} {node.checkpoint.id.slice(0, 8)} {formatDistanceToNow(new Date(node.checkpoint.timestamp), { addSuffix: true })}
{node.checkpoint.description && (

{node.checkpoint.description}

)}

{node.checkpoint.metadata.userPrompt || "No prompt"}

{node.checkpoint.metadata.totalTokens.toLocaleString()} tokens {node.checkpoint.metadata.fileChanges} files
{/* Actions */}
Restore to this checkpoint Fork from this checkpoint Compare with another checkpoint
{/* Children */} {isExpanded && hasChildren && (
{/* Vertical line for children */} {node.children.length > 1 && (
)} {node.children.map((child) => renderTimelineNode(child, depth + 1) )}
)}
); }; return (
{/* Experimental Feature Warning */}

Experimental Feature

Checkpointing may affect directory structure or cause data loss. Use with caution.

{/* Header */}

Timeline

{timeline && ( {timeline.totalCheckpoints} checkpoints )}
{/* Error display */} {error && (
{error}
)} {/* Timeline tree */} {timeline?.rootNode ? (
{renderTimelineNode(timeline.rootNode)}
) : (
{isLoading ? "Loading timeline..." : "No checkpoints yet"}
)} {/* Create checkpoint dialog */} Create Checkpoint Save the current state of your session with an optional description.
setCheckpointDescription(e.target.value)} onKeyPress={(e) => { if (e.key === "Enter" && !isLoading) { handleCreateCheckpoint(); } }} />
{/* Diff dialog */} Checkpoint Comparison Changes between "{selectedCheckpoint?.description || selectedCheckpoint?.id.slice(0, 8)}" and "{compareCheckpoint?.description || compareCheckpoint?.id.slice(0, 8)}" {diff && (
{/* Summary */}
Modified Files
{diff.modifiedFiles.length}
Added Files
{diff.addedFiles.length}
Deleted Files
{diff.deletedFiles.length}
{/* Token delta */}
0 ? "default" : "secondary"}> {diff.tokenDelta > 0 ? "+" : ""}{diff.tokenDelta.toLocaleString()} tokens
{/* File lists */} {diff.modifiedFiles.length > 0 && (

Modified Files

{diff.modifiedFiles.map((file) => (
{file.path}
+{file.additions} -{file.deletions}
))}
)} {diff.addedFiles.length > 0 && (

Added Files

{diff.addedFiles.map((file) => (
+ {file}
))}
)} {diff.deletedFiles.length > 0 && (

Deleted Files

{diff.deletedFiles.map((file) => (
- {file}
))}
)}
)}
); };