"use client"; import { ArrowRightIcon, CheckIcon, DatabaseIcon, FileCodeIcon, HistoryIcon, LockIcon, MailIcon, MessageSquareIcon, RocketIcon, SlidersHorizontalIcon, Undo2Icon, } from "lucide-react"; import { AnimatePresence, MotionConfig, motion, useReducedMotion, useSpring, useTransform } from "motion/react"; import { type KeyboardEvent, type PointerEvent, type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; export type RewindKind = "file" | "database" | "setting" | "message" | "email" | "deploy"; export interface RewindChange { id: string; kind: RewindKind; title: string; target: string; before: string; after: string; at: string; irreversible?: string; } export interface RewindProps { changes: RewindChange[]; agent?: string; task?: string; defaultAt?: number; onRewind?: (changes: RewindChange[]) => void | Promise; onUndo?: (changes: RewindChange[]) => void | Promise; className?: string; } const kindIcons: Record = { file: FileCodeIcon, database: DatabaseIcon, setting: SlidersHorizontalIcon, message: MessageSquareIcon, email: MailIcon, deploy: RocketIcon, }; const ease = [0.23, 1, 0.32, 1] as const; const swap = { duration: 0.16, ease }; const press = "transition-[scale,box-shadow,background-color] duration-150 ease-out active:scale-[0.97]"; const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); type Phase = { name: "idle" } | { name: "confirm" } | { name: "working" } | { name: "done"; batch: RewindChange[]; to: string }; export function Rewind({ changes, agent = "Agent", task, defaultAt, onRewind, onUndo, className }: RewindProps) { const reduce = useReducedMotion(); const [gone, setGone] = useState>(() => new Set()); const live = changes.filter((c) => !gone.has(c.id)); const n = live.length; let lockAt = -1; live.forEach((c, i) => { if (c.irreversible) lockAt = i; }); const minSlot = lockAt + 1; const [slot, setSlot] = useState(() => clamp(defaultAt ?? n, minSlot, n)); const [phase, setPhase] = useState({ name: "idle" }); const [hover, setHover] = useState(null); const [bump, setBump] = useState(0); const track = useRef(null); const handle = useRef(null); const [width, setWidth] = useState(0); const dragging = useRef(false); const x = useSpring(0, { stiffness: 700, damping: 48, mass: 0.6 }); const hatch = useTransform(x, (v) => `inset(0 0 0 ${v}px)`); const blockedRef = useRef(false); const at = (k: number) => ((k + 0.5) / (n + 1)) * width; const markAt = (i: number) => ((i + 1) / (n + 1)) * width; useLayoutEffect(() => { const el = track.current; if (!el) return; const ro = new ResizeObserver(() => setWidth(el.clientWidth)); ro.observe(el); setWidth(el.clientWidth); return () => ro.disconnect(); }, []); const placed = useRef(false); useLayoutEffect(() => { if (!width || dragging.current) return; const target = ((slot + 0.5) / (n + 1)) * width; if (!placed.current || reduce) x.jump(target); else x.set(target); placed.current = true; }, [slot, width, n, x, reduce]); const pushBack = () => { setBump((b) => b + 1); if (reduce) return; const home = at(minSlot); x.set(home - 10); window.setTimeout(() => !dragging.current && x.set(home), 90); }; const busy = phase.name === "working"; const move = (k: number) => { if (busy) return; if (k < minSlot) { if (slot !== minSlot) setSlot(minSlot); pushBack(); return; } setSlot(clamp(k, minSlot, n)); if (phase.name === "confirm" || phase.name === "done") setPhase({ name: "idle" }); }; const fromPointer = (e: PointerEvent) => { const rect = track.current?.getBoundingClientRect(); if (!rect || !width) return; const px = e.clientX - rect.left; const raw = Math.round((px / width) * (n + 1) - 0.5); const k = clamp(raw, 0, n); if (k < minSlot) { if (slot !== minSlot) setSlot(minSlot); if (phase.name !== "idle") setPhase({ name: "idle" }); const home = at(minSlot); x.set(reduce ? home : home - Math.min(14, Math.max(0, home - px) * 0.25)); if (!blockedRef.current) setBump((b) => b + 1); blockedRef.current = true; return; } blockedRef.current = false; if (k !== slot) move(k); x.set(at(k)); }; const onKeyDown = (e: KeyboardEvent) => { const keys: Record = { ArrowLeft: slot - 1, ArrowDown: slot - 1, ArrowRight: slot + 1, ArrowUp: slot + 1, Home: minSlot, End: n }; const k = keys[e.key]; if (k === undefined) return; e.preventDefault(); if (k > n) return; move(k); }; const revert = live.slice(slot); const label = (k: number) => (k === n ? "Now" : k === 0 ? "Start" : live[k - 1].at); const locked = lockAt >= 0 && slot === minSlot; const commit = async () => { const batch = revert; const to = label(slot); setPhase({ name: "working" }); try { await onRewind?.(batch); setGone((g) => new Set([...g, ...batch.map((c) => c.id)])); setPhase({ name: "done", batch, to }); handle.current?.focus(); } catch { setPhase({ name: "confirm" }); } }; const undo = async (batch: RewindChange[]) => { setPhase({ name: "working" }); try { await onUndo?.(batch); setGone((g) => { const next = new Set(g); for (const c of batch) next.delete(c.id); return next; }); } finally { setPhase({ name: "idle" }); } }; const prevN = useRef(n); useEffect(() => { if (prevN.current !== n) setSlot(n); prevN.current = n; }, [n]); let hint: { key: string; tone?: string; node: ReactNode }; if (n === 0) hint = { key: "empty", node: "Nothing to rewind yet." }; else if (locked && slot < n) hint = { key: `lock-${live[lockAt].id}`, tone: "text-warning-foreground", node: ( <> Can’t go back past “{live[lockAt].title}”. {live[lockAt].irreversible} ), }; else if (slot < n) hint = { key: "preview", node: ( Rewinding to {label(slot)} undoes {revert.length === 1 ? "1 change" : `${revert.length} changes`}. ), }; else if (lockAt >= 0 && lockAt === n - 1) hint = { key: "stuck", tone: "text-warning-foreground", node: ( <> The last change can’t be taken back, so there’s nothing to rewind. ), }; else hint = { key: "idle", node: "Drag the playhead back to see what a rewind would undo." }; return (

Rewind {agent}

{task &&

{task}

}
{n === 1 ? "1 change" : `${n} changes`}
0 && !busy ? "cursor-ew-resize" : "pointer-events-none")} onLostPointerCapture={() => { dragging.current = false; x.set(at(slot)); }} onPointerDown={(e) => { if (e.button !== 0 || !n) return; dragging.current = true; e.currentTarget.setPointerCapture(e.pointerId); fromPointer(e); handle.current?.focus({ preventScroll: true }); }} onPointerMove={(e) => dragging.current && fromPointer(e)} onPointerUp={(e) => { dragging.current = false; e.currentTarget.releasePointerCapture(e.pointerId); x.set(at(slot)); }} ref={track} >
{width > 0 && n > 0 && ( <> {minSlot > 0 && (
)} )} {width > 0 && live.map((c, i) => { const undone = i >= slot; const Icon = c.irreversible ? LockIcon : null; return ( {Icon ? ( ) : ( )} ); })} {width > 0 && n > 0 && (
{label(slot)}
)}
{live[0]?.at ?? ""} Now
0 ? { x: [0, -3, 3, -2, 0] } : undefined} className="flex gap-1.5" key={bump} transition={{ duration: 0.28, ease: "easeOut" }} > {hint.node}
    {live.map((c, i) => { const undone = i >= slot; const KindIcon = kindIcons[c.kind]; const pickable = i >= minSlot && !busy; return ( ); })}
); }