/* ---------------------------------------------------------------------------- RevisionHistory - every saved version of a text, what each one changed, and Restore. Shared by knowledge entries, workspace instructions and organization identity. Restoring never deletes anything: it saves the old text as a new version, so it's reversible and confirms with a toast and Undo, not a dialog. Layouts: - "stacked" (default): a timeline; each version opens in place to show its changes. Fits a 520px sheet. - "split": versions on the left, the selected version's changes on the right. For pages and wide sheets. -------------------------------------------------------------------------- */ import { useId, useState, type ReactNode } from "react"; import { ChevronRightIcon, LoaderCircleIcon, RotateCcwIcon } from "lucide-react"; import { cn } from "../lib/cn"; import { Button } from "./button"; import { DiffView, failedLoadParts, type DiffFormat, type FailedLoad } from "./diff-view"; import { DisabledReason } from "./disabled-reason"; import { ErrorMessage } from "./error-message"; import { MetaChip } from "./meta-chip"; import { RelativeTime } from "./relative-time"; import { InlineDisabledReason } from "./select-menu"; import { Skeleton } from "./skeleton"; export interface Revision { id: string; /** Who saved it: a person, or "Opengeni" for an approved agent change. */ author: string; /** When, as an ISO time. Shown as "3 days ago" with the exact time on hover. */ createdAt?: string | undefined; /** Or a ready label, when there's no timestamp: "Created 18 Sep". */ createdLabel?: string | undefined; /** One line: "Added: Prefer small PRs." */ summary: string; /** The full text of this version. */ content: string; } export type RevisionHistoryLayout = "stacked" | "split"; export interface RevisionHistoryProps { /** Newest first. The first one is the current version. */ revisions: readonly Revision[]; layout?: RevisionHistoryLayout | undefined; format?: DiffFormat | undefined; /** Restores a version by saving it again as the newest. Return a promise for the saving state. */ onRestore?: ((revision: Revision) => void | Promise) | undefined; /** Why versions can't be restored here, and who can. */ restoreDisabledReason?: string | undefined; /** Which version starts open (stacked) or selected (split). Defaults to the newest. */ defaultRevisionId?: string | undefined; /** The clock for "3 days ago". */ now?: Date | undefined; /** Accessible name: "Workspace instructions history". */ label?: string | undefined; loading?: boolean | undefined; /** What happened, what to do next, and an optional retry. */ error?: FailedLoad | undefined; className?: string | undefined; } /** The text a version replaced: the one saved right before it, or nothing for the first. */ export function previousContent(revisions: readonly Revision[], index: number): string { return revisions[index + 1]?.content ?? ""; } function When({ revision, now }: { revision: Revision; now?: Date | undefined }) { if (revision.createdAt) { return ; } return {revision.createdLabel}; } function RestoreButton({ revision, onRestore, disabledReason, }: { revision: Revision; onRestore?: ((revision: Revision) => void | Promise) | undefined; disabledReason?: string | undefined; }) { const [pending, setPending] = useState(false); if (!onRestore) return null; const restore = async () => { const result = onRestore(revision); if (result && typeof (result as Promise).then === "function") { setPending(true); try { await result; } finally { setPending(false); } } }; // Not allowed: still focusable, and the reason shows on hover, focus and tap. return ( ); } /** "Current" is a fact about the version, not a health status: a quiet chip. */ function CurrentTag() { return Current; } function Stacked({ revisions, format, onRestore, restoreDisabledReason, defaultRevisionId, now, }: RevisionHistoryProps & { format: DiffFormat }) { const [open, setOpen] = useState>( () => new Set([defaultRevisionId ?? revisions[0]?.id ?? ""]), ); const baseId = useId(); const toggle = (id: string) => setOpen((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); return (
    {revisions.map((revision, index) => { const isOpen = open.has(revision.id); const current = index === 0; const last = index === revisions.length - 1; const panelId = `${baseId}-${index}`; return (
  1. {/* The timeline: a dot per version, joined by a line. */}
  2. ); })}
); } function Split({ revisions, format, onRestore, restoreDisabledReason, defaultRevisionId, now, }: RevisionHistoryProps & { format: DiffFormat }) { const [selectedId, setSelectedId] = useState(defaultRevisionId ?? revisions[0]?.id); const index = Math.max( 0, revisions.findIndex((revision) => revision.id === selectedId), ); const selected = revisions[index]; const listId = useId(); return (
{ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; event.preventDefault(); const next = Math.min( revisions.length - 1, Math.max(0, index + (event.key === "ArrowDown" ? 1 : -1)), ); setSelectedId(revisions[next]!.id); (event.currentTarget.children[next] as HTMLElement | undefined)?.focus(); }} > {revisions.map((revision, at) => { const active = at === index; return (
setSelectedId(revision.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setSelectedId(revision.id); } }} className={cn( "relative min-w-0 cursor-pointer rounded-[10px] px-3 py-2 transition-colors duration-[120ms] pointer-coarse:min-h-11", active ? "bg-surface-2" : "hover:bg-surface-2/70", )} > {active ? ( // The selected marker every list uses (ListRow, nav): 2 x 16px brand bar.
); })}
{selected ? ( {selected.author} · } actions={ index === 0 ? ( ) : ( ) } /> ) : null}
); } /** Every saved version of a text, what each changed, and Restore. */ export function RevisionHistory(props: RevisionHistoryProps) { const { layout = "stacked", format = "markdown", revisions, restoreDisabledReason, loading, error, label = "History", className, } = props; let body: ReactNode; if (loading) { body = ( ); } else if (error) { const failure = failedLoadParts(error); body = ( Try again ) : undefined } > {failure.detail} ); } else if (revisions.length === 0) { body =

No versions yet. Saving creates the first one.

; } else { body = layout === "split" ? ( ) : ( ); } return (
{body} {restoreDisabledReason && !loading && !error && revisions.length > 1 ? (
{restoreDisabledReason}
) : null}
); }