Session timeline with restore points: pick a checkpoint, preview the files that will revert, and rewind code or code and chat while HEAD glides back.
Set your access key from API keys as NEXTUI_TOKEN in .env.local and add the @nextui namespace below to components.json once. Then every item installs by name. Each install counts as a copy on a free account.
$pnpm dlx shadcn@latest add @nextui/checkpoint-rewind
1{2"registries": {3"@nextui": {4"url": "https://nextui.dev/r/{name}.json",5"headers": { "Authorization": "Bearer ${NEXTUI_TOKEN}" }6}7}8}
1"use client";23import { CheckpointRewind, type Checkpoint } from "@/components/nextui/checkpoint-rewind";45export function SessionCheckpoints({ checkpoints }: { checkpoints: Checkpoint[] }) {6return (7<CheckpointRewind8checkpoints={checkpoints}9onRestore={(id, mode) => restoreSession(id, { includeChat: mode === "all" })}10/>11);12}
| Prop | Type | Default | Description |
|---|---|---|---|
| checkpoints | Checkpoint[] | – | Restore points, oldest first, with id, label, time, kind (prompt, edit, auto) and changed files |
| headId | string | – | Checkpoint the workspace sits on. Defaults to the newest |
| defaultMode | "code" | "all" | "code" | Restore code only, or code and conversation |
| onRestore | (id: string, mode: "code" | "all") => void | – | Called when Restore is pressed |
| autoPlay | boolean | false | Loop the scripted demo; it stops on interaction and pauses offscreen |
| className | string | – | Extra classes on the wrapper |
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { Bookmark, Check, History, MessageSquare, Pencil, RotateCcw, Undo2 } from "lucide-react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type CheckpointFile = { path: string; add: number; del: number };
export type Checkpoint = {
id: string;
label: string;
time: string;
/** prompt = user turn, edit = agent changed files, auto = snapshot taken before a risky step. */
kind: "prompt" | "edit" | "auto";
files?: CheckpointFile[];
};
export type RestoreMode = "code" | "all";
export type CheckpointRewindProps = {
/** Oldest first. */
checkpoints?: Checkpoint[];
/** Checkpoint the workspace currently sits on. Defaults to the newest. */
headId?: string;
defaultMode?: RestoreMode;
onRestore?: (id: string, mode: RestoreMode) => void;
/** Loop a scripted new-checkpoint → pick → rewind demo. */
autoPlay?: boolean;
className?: string;
};
const BASE: Checkpoint[] = [
{ id: "c1", label: "Session start", time: "10:02", kind: "prompt" },
{ id: "c2", label: "Before auth refactor", time: "10:14", kind: "auto", files: [{ path: "src/auth/session.ts", add: 12, del: 3 }] },
{
id: "c3",
label: "Add refresh token rotation",
time: "10:21",
kind: "edit",
files: [
{ path: "src/auth/refresh.ts", add: 48, del: 6 },
{ path: "src/auth/session.ts", add: 9, del: 14 },
],
},
{
id: "c4",
label: "Update API middleware",
time: "10:27",
kind: "edit",
files: [
{ path: "src/middleware.ts", add: 22, del: 11 },
{ path: "tests/auth.test.ts", add: 31, del: 0 },
],
},
];
const NEW: Checkpoint = {
id: "c5",
label: "Move cookies to httpOnly",
time: "10:33",
kind: "edit",
files: [
{ path: "src/auth/cookies.ts", add: 17, del: 9 },
{ path: "src/middleware.ts", add: 4, del: 2 },
],
};
const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 10000;
const STEP = 300;
const KIND_ICON = { prompt: MessageSquare, edit: Pencil, auto: Bookmark } as const;
function useDemoClock(enabled: boolean, loop: number) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el || !enabled) return;
const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, [enabled]);
useEffect(() => {
if (!enabled || !visible) return;
const id = setInterval(() => setElapsed((e) => e + TICK), TICK);
return () => clearInterval(id);
}, [enabled, visible]);
return { ref, t: elapsed % loop };
}
type Phase = "idle" | "pressing" | "rewinding" | "restored";
type View = { list: Checkpoint[]; head: string; selected: string | null; mode: RestoreMode; phase: Phase; rewoundFrom: string | null };
function demoView(t: number): View {
const list = t >= 900 ? [...BASE, NEW] : BASE;
const top = list[list.length - 1].id;
let head = top;
let phase: Phase = "idle";
if (t >= 4300 && t < 4600) phase = "pressing";
if (t >= 4600) {
const steps = Math.floor((t - 4600) / STEP) + 1;
head = ["c4", "c3", "c2"][Math.min(2, steps - 1)];
phase = t >= 4600 + STEP * 3 ? "restored" : "rewinding";
}
return {
list,
head,
selected: t >= 2200 ? "c2" : null,
mode: t >= 3400 ? "all" : "code",
phase,
rewoundFrom: t >= 4600 ? "c5" : null,
};
}
function changesSince(list: Checkpoint[], fromId: string, toId: string) {
const from = list.findIndex((c) => c.id === fromId);
const to = list.findIndex((c) => c.id === toId);
const map = new Map<string, CheckpointFile>();
for (const c of list.slice(from + 1, to + 1)) {
for (const f of c.files ?? []) {
const prev = map.get(f.path);
map.set(f.path, prev ? { path: f.path, add: prev.add + f.add, del: prev.del + f.del } : { ...f });
}
}
return [...map.values()].sort((a, b) => b.add + b.del - (a.add + a.del));
}
/** Session timeline with restore points: pick a checkpoint, see what changed since, and rewind code (or code and chat). */
export function CheckpointRewind({ checkpoints, headId, defaultMode = "code", onRestore, autoPlay = false, className }: CheckpointRewindProps) {
const reduce = useReducedMotion() ?? false;
const uid = useId();
const [touched, setTouched] = useState(false);
const demo = autoPlay && !reduce && !touched && !checkpoints;
const { ref, t } = useDemoClock(demo, LOOP);
const initialList = checkpoints ?? [...BASE, NEW];
const [view, setView] = useState<View>(() => ({
list: initialList,
head: headId ?? initialList[initialList.length - 1].id,
selected: null,
mode: defaultMode,
phase: "idle",
rewoundFrom: null,
}));
// Sync the scripted frame during render (no effect → no extra commit per tick).
const [syncedT, setSyncedT] = useState(-1);
if (demo && t !== syncedT) {
setSyncedT(t);
setView(demoView(t));
}
const { list, head, selected, mode, phase, rewoundFrom } = view;
const headIdx = list.findIndex((c) => c.id === head);
const rewoundIdx = rewoundFrom ? list.findIndex((c) => c.id === rewoundFrom) : -1;
const target = selected ?? null;
const diffTo = rewoundFrom ?? list[list.length - 1].id;
const files = target ? changesSince(list, target, diffTo) : [];
const totals = files.reduce((s, f) => ({ add: s.add + f.add, del: s.del + f.del }), { add: 0, del: 0 });
const busy = phase === "pressing" || phase === "rewinding";
// Interactive rewind: walk HEAD down one checkpoint at a time.
useEffect(() => {
if (demo || phase !== "rewinding" || !target) return;
const id = setTimeout(() => {
setView((v) => {
const hi = v.list.findIndex((c) => c.id === v.head);
const ti = v.list.findIndex((c) => c.id === target);
if (hi - 1 <= ti) return { ...v, head: target, phase: "restored" };
return { ...v, head: v.list[hi - 1].id };
});
}, reduce ? 0 : STEP);
return () => clearTimeout(id);
}, [demo, phase, head, target, reduce]);
const interact = (fn: (v: View) => View) => {
setTouched(true);
setView((v) => fn(v));
};
const restore = () => {
if (!target || target === head || busy) return;
onRestore?.(target, mode);
interact((v) => ({ ...v, phase: "rewinding", rewoundFrom: v.rewoundFrom ?? v.head }));
};
const undo = () => interact((v) => ({ ...v, head: v.rewoundFrom ?? v.head, phase: "idle", rewoundFrom: null, selected: null }));
const shown = [...list].reverse();
const targetLabel = list.find((c) => c.id === target)?.label;
return (
<div ref={ref} className={cn("w-full max-w-md text-sm", className)}>
<div className="overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-18px_rgb(0_0_0/0.25)]">
<div className="flex items-center gap-3 bg-gradient-to-b from-muted/50 to-transparent px-4 pt-4 pb-3">
<span className="relative grid size-9 shrink-0 place-items-center rounded-xl border bg-background shadow-sm">
<History className={cn("size-4", phase === "rewinding" && !reduce && "animate-[cr-ccw_0.9s_linear_infinite]")} aria-hidden />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">Checkpoints</p>
<p className="truncate text-[11px] text-muted-foreground">Restore points for this session</p>
</div>
<span className="shrink-0 rounded-full border bg-background px-2 py-0.5 font-mono text-[10px] text-muted-foreground tabular-nums">
{list.length} saved
</span>
</div>
{/* Timeline, newest first */}
<LayoutGroup id={uid}>
<ol className="relative px-2 pb-1" aria-label="Checkpoints">
<span className="absolute top-5 bottom-5 left-[25px] w-0 border-l border-dashed border-foreground/20" aria-hidden />
<motion.span
aria-hidden
className="absolute bottom-5 left-[24.5px] w-[2px] rounded-full bg-gradient-to-b from-sky-500 via-sky-500/60 to-foreground/15"
initial={false}
animate={{ top: 20 + (list.length - 1 - headIdx) * 40 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 300, damping: 32 }}
/>
<AnimatePresence initial={false}>
{shown.map((c) => {
const idx = list.findIndex((x) => x.id === c.id);
const isHead = c.id === head;
const rewound = rewoundIdx >= 0 && idx > headIdx && idx <= rewoundIdx;
const isSel = c.id === selected;
const Icon = KIND_ICON[c.kind];
const delta = (c.files ?? []).reduce((s, f) => ({ add: s.add + f.add, del: s.del + f.del }), { add: 0, del: 0 });
return (
<motion.li
key={c.id}
layout={reduce ? false : "position"}
initial={reduce ? false : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={reduce ? undefined : { opacity: 0, height: 0 }}
transition={{ duration: 0.4, ease: EASE }}
>
<button
type="button"
disabled={busy || rewound}
aria-pressed={isSel}
onClick={() => interact((v) => ({ ...v, selected: v.selected === c.id || c.id === v.head ? null : c.id, phase: v.phase === "restored" ? "idle" : v.phase }))}
className={cn(
"relative flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-xl px-2 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default",
isSel ? "bg-muted" : "hover:bg-muted/50",
)}
>
<span className="relative grid size-6 shrink-0 place-items-center">
{isHead && (
<motion.span
layoutId="head-ring"
transition={{ type: "spring", stiffness: 380, damping: 30 }}
className="absolute -inset-1 rounded-full bg-sky-500/15 ring-2 ring-sky-500/70"
/>
)}
<span
className={cn(
"relative grid size-6 place-items-center rounded-full border transition-colors duration-300",
isHead ? "border-foreground bg-foreground text-background" : rewound ? "border-dashed bg-background text-muted-foreground/50" : "bg-background text-muted-foreground",
isSel && !isHead && "border-sky-500 text-sky-600 dark:text-sky-400",
)}
>
<Icon className="size-3" aria-hidden />
</span>
</span>
<span className={cn("min-w-0 flex-1 transition-opacity duration-300", rewound && "opacity-45")}>
<span className={cn("block truncate text-xs font-medium", rewound && "line-through decoration-foreground/40")}>{c.label}</span>
<span className="block font-mono text-[10px] text-muted-foreground">
{c.time}
{c.files?.length ? (
<>
{" · "}
{c.files.length} file{c.files.length > 1 ? "s" : ""} <span className="text-emerald-600 dark:text-emerald-400">+{delta.add}</span>{" "}
<span className="text-rose-600 dark:text-rose-400">−{delta.del}</span>
</>
) : (
" · no file changes"
)}
</span>
</span>
{isHead ? (
<motion.span
layoutId="head-pill"
transition={{ type: "spring", stiffness: 380, damping: 30 }}
className="shrink-0 rounded-full bg-foreground px-1.5 py-px font-mono text-[9px] font-semibold tracking-wider text-background"
>
HEAD
</motion.span>
) : rewound ? (
<span className="shrink-0 font-mono text-[9px] tracking-wider text-muted-foreground uppercase">rewound</span>
) : c.kind === "auto" ? (
<span className="shrink-0 rounded-full border px-1.5 py-px text-[9px] text-muted-foreground">auto</span>
) : null}
</button>
</motion.li>
);
})}
</AnimatePresence>
</ol>
</LayoutGroup>
{/* Changes since the picked checkpoint */}
<AnimatePresence initial={false}>
{target && phase !== "restored" && (
<motion.div
key="diff"
initial={reduce ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? undefined : { height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: EASE }}
className="overflow-hidden"
>
<div className="mx-3 mb-2 rounded-xl border bg-background px-3 py-2">
<p className="mb-1.5 flex items-center justify-between text-[11px] text-muted-foreground">
<span>Will revert {files.length} file{files.length === 1 ? "" : "s"}</span>
<span className="font-mono">
<span className="text-emerald-600 dark:text-emerald-400">+{totals.add}</span> <span className="text-rose-600 dark:text-rose-400">−{totals.del}</span>
</span>
</p>
<ul className="space-y-1">
{files.slice(0, 3).map((f, i) => {
const max = Math.max(...files.map((x) => x.add + x.del), 1);
return (
<motion.li
key={f.path}
initial={reduce ? false : { opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.08 + i * 0.06, duration: 0.3, ease: EASE }}
className="flex items-center gap-2 font-mono text-[10.5px]"
>
<span className="min-w-0 flex-1 truncate">{f.path}</span>
<span className="flex h-1.5 w-16 shrink-0 overflow-hidden rounded-full bg-muted">
<motion.span className="h-full bg-emerald-500" initial={reduce ? false : { width: 0 }} animate={{ width: `${(f.add / max) * 100}%` }} transition={{ delay: 0.15 + i * 0.06, duration: 0.5, ease: EASE }} />
<motion.span className="h-full bg-rose-500" initial={reduce ? false : { width: 0 }} animate={{ width: `${(f.del / max) * 100}%` }} transition={{ delay: 0.2 + i * 0.06, duration: 0.5, ease: EASE }} />
</span>
</motion.li>
);
})}
</ul>
{files.length > 3 && <p className="mt-1 text-[10px] text-muted-foreground">+{files.length - 3} more</p>}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Footer */}
<div className="flex h-12 items-center gap-2 border-t px-3">
<AnimatePresence mode="popLayout" initial={false}>
{phase === "restored" ? (
<motion.div
key="done"
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -8 }}
transition={{ duration: 0.3, ease: EASE }}
className="flex min-w-0 flex-1 items-center gap-2"
role="status"
>
<motion.span
initial={reduce ? false : { scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 600, damping: 18, delay: 0.05 }}
className="grid size-5 shrink-0 place-items-center rounded-full bg-emerald-500 text-white"
>
<Check className="size-3" strokeWidth={3} aria-hidden />
</motion.span>
<span className="min-w-0 flex-1 truncate text-xs">
Restored to <span className="font-medium">{targetLabel}</span>
<span className="text-muted-foreground"> · {mode === "all" ? "code + chat" : "code only"}</span>
</span>
<button
type="button"
onClick={undo}
className="inline-flex h-7 shrink-0 cursor-pointer items-center gap-1 rounded-lg px-2 text-[11px] text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<Undo2 className="size-3" aria-hidden />
Undo
</button>
</motion.div>
) : (
<motion.div
key="controls"
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -8 }}
transition={{ duration: 0.3, ease: EASE }}
className="flex min-w-0 flex-1 items-center gap-2"
>
<div role="radiogroup" aria-label="Restore mode" className="relative flex rounded-lg bg-muted p-0.5 text-[11px]">
{(["code", "all"] as const).map((m) => (
<button
key={m}
type="button"
role="radio"
aria-checked={mode === m}
disabled={busy}
onClick={() => interact((v) => ({ ...v, mode: m }))}
className={cn("relative h-6 cursor-pointer rounded-md px-2 outline-none focus-visible:ring-2 focus-visible:ring-ring", mode === m ? "text-foreground" : "text-muted-foreground")}
>
{mode === m && <motion.span layoutId={`${uid}-mode`} className="absolute inset-0 rounded-md bg-background shadow-sm" transition={{ type: "spring", stiffness: 500, damping: 34 }} />}
<span className="relative">{m === "code" ? "Code" : "Code + chat"}</span>
</button>
))}
</div>
<span className="flex-1" />
<motion.button
type="button"
onClick={restore}
disabled={!target || busy}
animate={{ scale: phase === "pressing" ? 0.94 : 1 }}
transition={{ type: "spring", stiffness: 500, damping: 26 }}
className="inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg bg-foreground px-3 text-xs font-medium text-background outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40"
>
<RotateCcw className={cn("size-3.5", phase === "rewinding" && !reduce && "animate-[cr-ccw_0.6s_linear_infinite]")} aria-hidden />
{phase === "rewinding" ? "Rewinding…" : "Restore"}
</motion.button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<style>{`@keyframes cr-ccw{to{transform:rotate(-360deg)}}`}</style>
</div>
);
}
export default function CheckpointRewindDemo() {
return (
<div className="flex h-[420px] w-full items-start justify-center pt-2">
<CheckpointRewind autoPlay />
</div>
);
}