diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index c3f0a9d783f..44de75192af 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -12,6 +12,7 @@ import { useLocation } from "@/lib/router"; import { ApiError } from "../api/client"; import { issuesApi } from "../api/issues"; import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator"; +import { deriveDocumentRevisionState } from "../lib/document-revisions"; import { queryKeys } from "../lib/queryKeys"; import { cn, relativeTime } from "../lib/utils"; import { MarkdownBody } from "./MarkdownBody"; @@ -787,7 +788,9 @@ export function IssueDocumentsSection({ const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null; const activeConflict = documentConflict?.key === doc.key ? documentConflict : null; const isFolded = foldedDocumentKeys.includes(doc.key); - const revisionHistory = getDocumentRevisions(doc.key); + const revisionState = deriveDocumentRevisionState(doc, getDocumentRevisions(doc.key)); + const revisionHistory = revisionState.revisions; + const currentRevision = revisionState.currentRevision; const selectedRevisionId = selectedRevisionIds[doc.key] ?? null; const selectedHistoricalRevision = selectedRevisionId ? revisionHistory.find((revision) => revision.id === selectedRevisionId) ?? null @@ -795,10 +798,10 @@ export function IssueDocumentsSection({ const isHistoricalPreview = Boolean(selectedHistoricalRevision); const displayedTitle = selectedHistoricalRevision ? selectedHistoricalRevision.title ?? "" - : activeDraft?.title ?? doc.title ?? ""; - const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? doc.body; - const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? doc.latestRevisionNumber; - const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? doc.updatedAt; + : activeDraft?.title ?? currentRevision.title ?? ""; + const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? currentRevision.body; + const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? currentRevision.revisionNumber; + const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? currentRevision.createdAt; const showTitle = !isPlanKey(doc.key) && !!displayedTitle.trim() && !titlesMatchKey(displayedTitle, doc.key); const canVoteOnDocument = Boolean(doc.latestRevisionId && doc.updatedByAgentId && !doc.updatedByUserId && onVote); @@ -848,9 +851,9 @@ export function IssueDocumentsSection({ {revisionMenuOpenKey === doc.key && isFetchingDocumentRevisions && revisionHistory.length === 0 ? ( Loading revisions... ) : revisionHistory.length > 0 ? ( - + {revisionHistory.map((revision) => { - const isCurrentRevision = revision.id === doc.latestRevisionId; + const isCurrentRevision = revision.id === currentRevision.id; return ( = {}): IssueDocument { + return { + id: "document-1", + companyId: "company-1", + issueId: "issue-1", + key: "plan", + title: "Plan", + format: "markdown", + body: "# Current plan", + latestRevisionId: "revision-2", + latestRevisionNumber: 2, + createdByAgentId: "agent-1", + createdByUserId: null, + updatedByAgentId: "agent-1", + updatedByUserId: null, + createdAt: new Date("2026-04-10T15:00:00.000Z"), + updatedAt: new Date("2026-04-10T16:00:00.000Z"), + ...overrides, + }; +} + +function createRevision(overrides: Partial = {}): DocumentRevision { + return { + id: "revision-1", + companyId: "company-1", + documentId: "document-1", + issueId: "issue-1", + key: "plan", + revisionNumber: 1, + title: "Plan", + format: "markdown", + body: "# Revision body", + changeSummary: null, + createdByAgentId: "agent-1", + createdByUserId: null, + createdAt: new Date("2026-04-10T15:00:00.000Z"), + ...overrides, + }; +} + +describe("deriveDocumentRevisionState", () => { + it("sorts fetched revisions newest-first even when the API payload is out of order", () => { + const state = deriveDocumentRevisionState(createDocument(), [ + createRevision({ id: "revision-1", revisionNumber: 1, createdAt: new Date("2026-04-10T15:00:00.000Z") }), + createRevision({ id: "revision-2", revisionNumber: 2, body: "# Current plan", createdAt: new Date("2026-04-10T16:00:00.000Z") }), + ]); + + expect(state.currentRevision.id).toBe("revision-2"); + expect(state.revisions.map((revision) => revision.id)).toEqual(["revision-2", "revision-1"]); + }); + + it("keeps the latest document revision current when the revision history cache is stale", () => { + const state = deriveDocumentRevisionState(createDocument(), [ + createRevision({ id: "revision-1", revisionNumber: 1, body: "# Original plan" }), + ]); + + expect(state.currentRevision.id).toBe("revision-2"); + expect(state.currentRevision.body).toBe("# Current plan"); + expect(state.revisions.map((revision) => revision.id)).toEqual(["revision-2", "revision-1"]); + }); + + it("trusts the fetched revision history when it is newer than the document summary cache", () => { + const staleDocument = createDocument({ + body: "# Original plan", + latestRevisionId: "revision-1", + latestRevisionNumber: 1, + updatedAt: new Date("2026-04-10T15:00:00.000Z"), + }); + + const state = deriveDocumentRevisionState(staleDocument, [ + createRevision({ id: "revision-2", revisionNumber: 2, body: "# Current plan", createdAt: new Date("2026-04-10T16:00:00.000Z") }), + createRevision({ id: "revision-1", revisionNumber: 1, body: "# Original plan", createdAt: new Date("2026-04-10T15:00:00.000Z") }), + ]); + + expect(state.currentRevision.id).toBe("revision-2"); + expect(state.currentRevision.body).toBe("# Current plan"); + expect(state.revisions.map((revision) => revision.id)).toEqual(["revision-2", "revision-1"]); + }); +}); diff --git a/ui/src/lib/document-revisions.ts b/ui/src/lib/document-revisions.ts new file mode 100644 index 00000000000..5759d4af300 --- /dev/null +++ b/ui/src/lib/document-revisions.ts @@ -0,0 +1,72 @@ +import type { DocumentRevision, IssueDocument } from "@paperclipai/shared"; + +type DocumentRevisionState = { + currentRevision: DocumentRevision; + revisions: DocumentRevision[]; +}; + +function toTimestamp(value: Date | string | null | undefined) { + if (!value) return 0; + const timestamp = new Date(value).getTime(); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +function sortRevisionsDescending(revisions: DocumentRevision[]) { + return [...revisions].sort((a, b) => { + if (a.revisionNumber !== b.revisionNumber) { + return b.revisionNumber - a.revisionNumber; + } + const createdAtDelta = toTimestamp(b.createdAt) - toTimestamp(a.createdAt); + if (createdAtDelta !== 0) return createdAtDelta; + return b.id.localeCompare(a.id); + }); +} + +function createCurrentRevisionSnapshot(document: IssueDocument): DocumentRevision { + return { + id: document.latestRevisionId ?? `${document.id}-latest`, + companyId: document.companyId, + documentId: document.id, + issueId: document.issueId, + key: document.key, + revisionNumber: document.latestRevisionNumber, + title: document.title, + format: document.format, + body: document.body, + changeSummary: null, + createdByAgentId: document.updatedByAgentId ?? document.createdByAgentId, + createdByUserId: document.updatedByUserId ?? document.createdByUserId, + createdAt: document.updatedAt, + }; +} + +export function deriveDocumentRevisionState( + document: IssueDocument, + revisions: DocumentRevision[], +): DocumentRevisionState { + const sortedRevisions = sortRevisionsDescending(revisions); + const currentSnapshot = createCurrentRevisionSnapshot(document); + const highestFetchedRevision = sortedRevisions[0] ?? null; + const documentAppearsStale = Boolean( + highestFetchedRevision && highestFetchedRevision.revisionNumber > document.latestRevisionNumber, + ); + + const currentRevision = documentAppearsStale + ? highestFetchedRevision! + : sortedRevisions.find((revision) => revision.id === document.latestRevisionId) ?? currentSnapshot; + + const revisionsWithCurrent = sortRevisionsDescending([currentRevision, ...sortedRevisions]); + const dedupedRevisions: DocumentRevision[] = []; + const seenRevisionIds = new Set(); + + for (const revision of revisionsWithCurrent) { + if (seenRevisionIds.has(revision.id)) continue; + seenRevisionIds.add(revision.id); + dedupedRevisions.push(revision); + } + + return { + currentRevision, + revisions: dedupedRevisions, + }; +}