Files
claudia/src/components/TimelineNavigator.tsx
2025-06-19 19:24:01 +05:30

583 lines
20 KiB
TypeScript

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<TimelineNavigatorProps> = ({
sessionId,
projectId,
projectPath,
currentMessageIndex,
onCheckpointSelect,
onFork,
refreshVersion = 0,
className
}) => {
const [timeline, setTimeline] = useState<SessionTimeline | null>(null);
const [selectedCheckpoint, setSelectedCheckpoint] = useState<Checkpoint | null>(null);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(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<string | null>(null);
const [diff, setDiff] = useState<CheckpointDiff | null>(null);
const [compareCheckpoint, setCompareCheckpoint] = useState<Checkpoint | null>(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 (
<div key={node.checkpoint.id} className="relative">
{/* Connection line */}
{depth > 0 && (
<div
className="absolute left-0 top-0 w-6 h-6 border-l-2 border-b-2 border-muted-foreground/30"
style={{
left: `${(depth - 1) * 24}px`,
borderBottomLeftRadius: '8px'
}}
/>
)}
{/* Node content */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: depth * 0.05 }}
className={cn(
"flex items-start gap-2 py-2",
depth > 0 && "ml-6"
)}
style={{ paddingLeft: `${depth * 24}px` }}
>
{/* Expand/collapse button */}
{hasChildren && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 -ml-1"
onClick={() => toggleNodeExpansion(node.checkpoint.id)}
>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</Button>
)}
{/* Checkpoint card */}
<Card
className={cn(
"flex-1 cursor-pointer transition-all hover:shadow-md",
isCurrent && "border-primary ring-2 ring-primary/20",
isSelected && "border-blue-500 bg-blue-500/5",
!hasChildren && "ml-5"
)}
onClick={() => setSelectedCheckpoint(node.checkpoint)}
>
<CardContent className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{isCurrent && (
<Badge variant="default" className="text-xs">Current</Badge>
)}
<span className="text-xs font-mono text-muted-foreground">
{node.checkpoint.id.slice(0, 8)}
</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(node.checkpoint.timestamp), { addSuffix: true })}
</span>
</div>
{node.checkpoint.description && (
<p className="text-sm font-medium mb-1">{node.checkpoint.description}</p>
)}
<p className="text-xs text-muted-foreground line-clamp-2">
{node.checkpoint.metadata.userPrompt || "No prompt"}
</p>
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Hash className="h-3 w-3" />
{node.checkpoint.metadata.totalTokens.toLocaleString()} tokens
</span>
<span className="flex items-center gap-1">
<FileCode className="h-3 w-3" />
{node.checkpoint.metadata.fileChanges} files
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
handleRestoreCheckpoint(node.checkpoint);
}}
>
<RotateCcw className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Restore to this checkpoint</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
handleFork(node.checkpoint);
}}
>
<GitFork className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Fork from this checkpoint</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
handleCompare(node.checkpoint);
}}
>
<Diff className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Compare with another checkpoint</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* Children */}
{isExpanded && hasChildren && (
<div className="relative">
{/* Vertical line for children */}
{node.children.length > 1 && (
<div
className="absolute top-0 bottom-0 w-0.5 bg-muted-foreground/30"
style={{ left: `${(depth + 1) * 24 - 1}px` }}
/>
)}
{node.children.map((child) =>
renderTimelineNode(child, depth + 1)
)}
</div>
)}
</div>
);
};
return (
<div className={cn("space-y-4", className)}>
{/* Experimental Feature Warning */}
<div className="rounded-lg border border-yellow-500/50 bg-yellow-500/10 p-3">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-yellow-600 mt-0.5" />
<div className="text-xs">
<p className="font-medium text-yellow-600">Experimental Feature</p>
<p className="text-yellow-600/80">
Checkpointing may affect directory structure or cause data loss. Use with caution.
</p>
</div>
</div>
</div>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitBranch className="h-5 w-5 text-muted-foreground" />
<h3 className="text-sm font-medium">Timeline</h3>
{timeline && (
<Badge variant="outline" className="text-xs">
{timeline.totalCheckpoints} checkpoints
</Badge>
)}
</div>
<Button
size="sm"
variant="default"
onClick={() => setShowCreateDialog(true)}
disabled={isLoading}
>
<Save className="h-3 w-3 mr-1" />
Checkpoint
</Button>
</div>
{/* Error display */}
{error && (
<div className="flex items-center gap-2 text-xs text-destructive">
<AlertCircle className="h-3 w-3" />
{error}
</div>
)}
{/* Timeline tree */}
{timeline?.rootNode ? (
<div className="relative overflow-x-auto">
{renderTimelineNode(timeline.rootNode)}
</div>
) : (
<div className="text-center py-8 text-sm text-muted-foreground">
{isLoading ? "Loading timeline..." : "No checkpoints yet"}
</div>
)}
{/* Create checkpoint dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Checkpoint</DialogTitle>
<DialogDescription>
Save the current state of your session with an optional description.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="description">Description (optional)</Label>
<Input
id="description"
placeholder="e.g., Before major refactoring"
value={checkpointDescription}
onChange={(e) => setCheckpointDescription(e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter" && !isLoading) {
handleCreateCheckpoint();
}
}}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateDialog(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleCreateCheckpoint}
disabled={isLoading}
>
Create Checkpoint
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Diff dialog */}
<Dialog open={showDiffDialog} onOpenChange={setShowDiffDialog}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Checkpoint Comparison</DialogTitle>
<DialogDescription>
Changes between "{selectedCheckpoint?.description || selectedCheckpoint?.id.slice(0, 8)}"
and "{compareCheckpoint?.description || compareCheckpoint?.id.slice(0, 8)}"
</DialogDescription>
</DialogHeader>
{diff && (
<div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto">
{/* Summary */}
<div className="grid grid-cols-3 gap-4">
<Card>
<CardContent className="p-3">
<div className="text-xs text-muted-foreground">Modified Files</div>
<div className="text-2xl font-bold">{diff.modifiedFiles.length}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<div className="text-xs text-muted-foreground">Added Files</div>
<div className="text-2xl font-bold text-green-600">{diff.addedFiles.length}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<div className="text-xs text-muted-foreground">Deleted Files</div>
<div className="text-2xl font-bold text-red-600">{diff.deletedFiles.length}</div>
</CardContent>
</Card>
</div>
{/* Token delta */}
<div className="flex items-center justify-center">
<Badge variant={diff.tokenDelta > 0 ? "default" : "secondary"}>
{diff.tokenDelta > 0 ? "+" : ""}{diff.tokenDelta.toLocaleString()} tokens
</Badge>
</div>
{/* File lists */}
{diff.modifiedFiles.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2">Modified Files</h4>
<div className="space-y-1">
{diff.modifiedFiles.map((file) => (
<div key={file.path} className="flex items-center justify-between text-xs">
<span className="font-mono">{file.path}</span>
<div className="flex items-center gap-2 text-xs">
<span className="text-green-600">+{file.additions}</span>
<span className="text-red-600">-{file.deletions}</span>
</div>
</div>
))}
</div>
</div>
)}
{diff.addedFiles.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2">Added Files</h4>
<div className="space-y-1">
{diff.addedFiles.map((file) => (
<div key={file} className="text-xs font-mono text-green-600">
+ {file}
</div>
))}
</div>
</div>
)}
{diff.deletedFiles.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2">Deleted Files</h4>
<div className="space-y-1">
{diff.deletedFiles.map((file) => (
<div key={file} className="text-xs font-mono text-red-600">
- {file}
</div>
))}
</div>
</div>
)}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowDiffDialog(false);
setDiff(null);
setCompareCheckpoint(null);
}}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};