DocumentHistoryView.tsx8.3 KBView on GitHub 'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useTRPC } from '@/providers/query-provider';
import { base64ToUint8Array, getProvider } from '@/modules/documents/yjs';
import { HistoryDiffView, type HistoryDiffMode } from './HistoryDiffView';
import { HistorySidebar, type HistorySidebarSession } from './HistorySidebar';
import { useDocumentHistory } from './useDocumentHistory';
import { useVersionDiff } from './useVersionDiff';
export interface DocumentHistoryViewProps {
documentId: string;
onClose: () => void;
}
/**
* Read-only history mode for a document, rendered as a modal over the
* editor. Mounts on demand and unmounts on close so the live editor and the
* history queries are mutually exclusive (lazy contract). The sidebar
* timeline drives selection; the main pane shows the selected version with
* inline word-level diffs vs the previous session; the Restore button
* commits a new system-origin save and closes the modal.
*
* Sizing:
* - the main reading column is capped at 100ch (matches the live editor
* `max-w-[100ch]` so the diff renders at the same measure)
* - the sidebar is a fixed 18rem
* - the dialog itself caps at `100ch + 18rem` total
*/
export function DocumentHistoryView({ documentId, onClose }: DocumentHistoryViewProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const { data, isLoading } = useDocumentHistory(documentId, true);
// Backend returns sessions in from_seq ascending order. The sidebar shows
// newest at top; selection logic works on the ascending list so the
// "previous session" lookup is `idx - 1`.
const sessionsAsc: HistorySidebarSession[] = useMemo(
() => (data?.sessions ?? []) as HistorySidebarSession[],
[data?.sessions],
);
const [selectedSeq, setSelectedSeq] = useState<number | null>(null);
const [diffMode, setDiffMode] = useState<HistoryDiffMode>('diff');
useEffect(() => {
if (selectedSeq != null) return;
if (sessionsAsc.length === 0) return;
setSelectedSeq(sessionsAsc[sessionsAsc.length - 1].toSeq);
}, [sessionsAsc, selectedSeq]);
const selectedIdx = useMemo(
() => sessionsAsc.findIndex((s) => s.toSeq === selectedSeq),
[sessionsAsc, selectedSeq],
);
const selected = selectedIdx >= 0 ? sessionsAsc[selectedIdx] : null;
const previousSeq = selectedIdx > 0 ? sessionsAsc[selectedIdx - 1].toSeq : null;
const diff = useVersionDiff(documentId, selected?.toSeq ?? null, previousSeq);
const restoreMutation = useMutation({
...trpc.documents.restoreVersion.mutationOptions(),
onSuccess: (result) => {
// A REPLACING restore (a table) must be pushed into the live provider here, not left to a
// refetch. Invalidating `getDoc` only re-seeds on a fresh mount, and the provider stays
// mounted behind this modal — so the Y.Doc would keep the pre-restore state while the
// server holds the replacement. The next cell edit then flushes a delta computed against a
// state vector from a DISJOINT history, the server merges the two, and the document ends up
// holding both copies of every row. `applyServerReplace` discards local state instead,
// which is the only correct response to a new clientID and clock space.
const replacement = (result as { replaced?: boolean; fullState?: string } | undefined)
?.fullState;
if (replacement) {
const provider = getProvider(documentId);
provider?.applyServerReplace(base64ToUint8Array(replacement));
}
void queryClient.invalidateQueries(
trpc.documents.history.queryOptions({ documentId }),
);
void queryClient.invalidateQueries(
// Editor's getDoc cache — force a refetch when the user returns to
// editing so they see the restored content even if SSE hasn't
// delivered yet.
trpc.documents.getDoc.queryOptions({ documentId }),
);
onClose();
},
});
const sessionsDesc = useMemo(() => [...sessionsAsc].reverse(), [sessionsAsc]);
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent
showCloseButton={false}
className="sm:max-w-[calc(100ch+18rem)] h-[85vh] max-h-[85vh] gap-0 overflow-hidden p-0"
>
<DialogTitle className="sr-only">Version history</DialogTitle>
<div className="flex h-full min-h-0 flex-1" data-testid="document-history-view">
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
className="text-xs text-muted-foreground hover:text-foreground"
data-testid="history-back"
>
← Back to editing
</button>
<div className="flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5">
<button
type="button"
onClick={() => setDiffMode('diff')}
data-testid="history-mode-diff"
aria-pressed={diffMode === 'diff'}
className={cn(
'rounded px-2 py-0.5 text-xs',
diffMode === 'diff'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
Diff
</button>
<button
type="button"
onClick={() => setDiffMode('formatted')}
data-testid="history-mode-formatted"
aria-pressed={diffMode === 'formatted'}
className={cn(
'rounded px-2 py-0.5 text-xs',
diffMode === 'formatted'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
Formatted
</button>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{selected ? (
<span>
Viewing v{selected.toSeq} ·{' '}
{selected.origin === 'human'
? 'human'
: selected.origin === 'agent'
? selected.actorLabel ?? 'agent'
: selected.actorLabel ?? 'system'}
</span>
) : isLoading ? (
<span>Loading history…</span>
) : null}
<button
type="button"
onClick={() =>
selected &&
restoreMutation.mutate({ documentId, seq: selected.toSeq })
}
disabled={!selected || restoreMutation.isPending}
data-testid="history-restore"
className="rounded bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{restoreMutation.isPending
? 'Restoring…'
: selected
? `Restore to v${selected.toSeq}`
: 'Restore'}
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-[100ch] px-6 py-6">
<HistoryDiffView
markdown={diff.selectedMarkdown}
previousMarkdown={diff.previousMarkdown}
mode={diffMode}
isLoading={diff.isLoading}
/>
</div>
</div>
</div>
<HistorySidebar
sessions={sessionsDesc}
selectedSeq={selectedSeq}
onSelect={setSelectedSeq}
/>
</div>
</DialogContent>
</Dialog>
);
}