diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 17e48f38..0a714c56 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { Issue } from "@paperclipai/shared"; +import type { Issue, IssueDocument } from "@paperclipai/shared"; import { useLocation } from "@/lib/router"; +import { ApiError } from "../api/client"; import { issuesApi } from "../api/issues"; import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator"; import { queryKeys } from "../lib/queryKeys"; @@ -27,6 +28,12 @@ type DraftState = { isNew: boolean; }; +type DocumentConflictState = { + key: string; + serverDocument: IssueDocument; + showRemote: boolean; +}; + const DOCUMENT_AUTOSAVE_DEBOUNCE_MS = 900; const DOCUMENT_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/; const getFoldedDocumentsStorageKey = (issueId: string) => `paperclip:issue-document-folds:${issueId}`; @@ -60,6 +67,10 @@ function titlesMatchKey(title: string | null | undefined, key: string) { return (title ?? "").trim().toLowerCase() === key.trim().toLowerCase(); } +function isDocumentConflictError(error: unknown) { + return error instanceof ApiError && error.status === 409; +} + export function IssueDocumentsSection({ issue, canDeleteDocuments, @@ -78,6 +89,7 @@ export function IssueDocumentsSection({ const [confirmDeleteKey, setConfirmDeleteKey] = useState(null); const [error, setError] = useState(null); const [draft, setDraft] = useState(null); + const [documentConflict, setDocumentConflict] = useState(null); const [foldedDocumentKeys, setFoldedDocumentKeys] = useState(() => loadFoldedDocumentKeys(issue.id)); const [autosaveDocumentKey, setAutosaveDocumentKey] = useState(null); const [highlightDocumentKey, setHighlightDocumentKey] = useState(null); @@ -149,6 +161,7 @@ export function IssueDocumentsSection({ const beginNewDocument = () => { resetAutosaveState(); + setDocumentConflict(null); setDraft({ key: "", title: "", @@ -164,6 +177,7 @@ export function IssueDocumentsSection({ if (!doc) return; setFoldedDocumentKeys((current) => current.filter((entry) => entry !== key)); resetAutosaveState(); + setDocumentConflict((current) => current?.key === key ? current : null); setDraft({ key: doc.key, title: doc.title ?? "", @@ -179,18 +193,27 @@ export function IssueDocumentsSection({ clearTimeout(autosaveDebounceRef.current); } resetAutosaveState(); + setDocumentConflict(null); setDraft(null); setError(null); }; const commitDraft = useCallback(async ( currentDraft: DraftState | null, - options?: { clearAfterSave?: boolean; trackAutosave?: boolean }, + options?: { clearAfterSave?: boolean; trackAutosave?: boolean; overrideConflict?: boolean }, ) => { if (!currentDraft || upsertDocument.isPending) return false; const normalizedKey = currentDraft.key.trim().toLowerCase(); const normalizedBody = currentDraft.body.trim(); const normalizedTitle = currentDraft.title.trim(); + const activeConflict = documentConflict?.key === normalizedKey ? documentConflict : null; + + if (activeConflict && !options?.overrideConflict) { + if (options?.trackAutosave) { + resetAutosaveState(); + } + return false; + } if (!normalizedKey || !normalizedBody) { if (currentDraft.isNew) { @@ -234,8 +257,12 @@ export function IssueDocumentsSection({ key: normalizedKey, title: isPlanKey(normalizedKey) ? "" : normalizedTitle, body: currentDraft.body, + baseRevisionId: options?.overrideConflict + ? activeConflict?.serverDocument.latestRevisionId ?? currentDraft.baseRevisionId + : currentDraft.baseRevisionId, }); setError(null); + setDocumentConflict((current) => current?.key === normalizedKey ? null : current); setDraft((value) => { if (!value || value.key !== normalizedKey) return value; if (options?.clearAfterSave) return null; @@ -259,10 +286,57 @@ export function IssueDocumentsSection({ } return true; } catch (err) { + if (isDocumentConflictError(err)) { + try { + const latestDocument = await issuesApi.getDocument(issue.id, normalizedKey); + setDocumentConflict({ + key: normalizedKey, + serverDocument: latestDocument, + showRemote: true, + }); + setFoldedDocumentKeys((current) => current.filter((key) => key !== normalizedKey)); + setError(null); + resetAutosaveState(); + return false; + } catch { + setError("Document changed remotely and the latest version could not be loaded"); + return false; + } + } setError(err instanceof Error ? err.message : "Failed to save document"); return false; } - }, [invalidateIssueDocuments, resetAutosaveState, runSave, sortedDocuments, upsertDocument]); + }, [documentConflict, invalidateIssueDocuments, issue.id, resetAutosaveState, runSave, sortedDocuments, upsertDocument]); + + const reloadDocumentFromServer = useCallback((key: string) => { + if (documentConflict?.key !== key) return; + const serverDocument = documentConflict.serverDocument; + setDraft({ + key: serverDocument.key, + title: serverDocument.title ?? "", + body: serverDocument.body, + baseRevisionId: serverDocument.latestRevisionId, + isNew: false, + }); + setDocumentConflict(null); + resetAutosaveState(); + setError(null); + }, [documentConflict, resetAutosaveState]); + + const overwriteDocumentFromDraft = useCallback(async (key: string) => { + if (documentConflict?.key !== key || !draft || draft.key !== key || draft.isNew) return; + await commitDraft( + { + ...draft, + baseRevisionId: documentConflict.serverDocument.latestRevisionId, + }, + { + clearAfterSave: false, + trackAutosave: true, + overrideConflict: true, + }, + ); + }, [commitDraft, documentConflict, draft]); const handleDraftBlur = async (event: React.FocusEvent) => { if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; @@ -310,6 +384,17 @@ export function IssueDocumentsSection({ saveFoldedDocumentKeys(issue.id, foldedDocumentKeys); }, [foldedDocumentKeys, issue.id]); + useEffect(() => { + if (!documentConflict) return; + const latest = sortedDocuments.find((doc) => doc.key === documentConflict.key); + if (!latest || latest.latestRevisionId === documentConflict.serverDocument.latestRevisionId) return; + setDocumentConflict((current) => + current?.key === latest.key + ? { ...current, serverDocument: latest } + : current, + ); + }, [documentConflict, sortedDocuments]); + useEffect(() => { const hash = location.hash; if (!hash.startsWith("#document-")) return; @@ -337,6 +422,7 @@ export function IssueDocumentsSection({ useEffect(() => { if (!draft || draft.isNew) return; + if (documentConflict?.key === draft.key) return; const existing = sortedDocuments.find((doc) => doc.key === draft.key); if (!existing) return; const hasChanges = @@ -361,7 +447,7 @@ export function IssueDocumentsSection({ clearTimeout(autosaveDebounceRef.current); } }; - }, [autosaveState, commitDraft, draft, markDocumentDirty, resetAutosaveState, sortedDocuments]); + }, [autosaveState, commitDraft, documentConflict, draft, markDocumentDirty, resetAutosaveState, sortedDocuments]); const documentBodyShellClassName = "mt-3 overflow-hidden rounded-md"; const documentBodyPaddingClassName = ""; @@ -477,6 +563,7 @@ export function IssueDocumentsSection({
{sortedDocuments.map((doc) => { 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 showTitle = !isPlanKey(doc.key) && !!doc.title?.trim() && !titlesMatchKey(doc.title, doc.key); @@ -558,6 +645,73 @@ export function IssueDocumentsSection({ } }} > + {activeConflict && ( +
+
+
+

Out of date

+

+ This document changed while you were editing. Your local draft is preserved and autosave is paused. +

+
+
+ + + + +
+
+ {activeConflict.showRemote && ( +
+
+ Remote revision {activeConflict.serverDocument.latestRevisionNumber} + + updated {relativeTime(activeConflict.serverDocument.updatedAt)} +
+ {!isPlanKey(doc.key) && activeConflict.serverDocument.title ? ( +

{activeConflict.serverDocument.title}

+ ) : null} + {renderBody(activeConflict.serverDocument.body, "text-[14px] leading-7")} +
+ )} +
+ )} {activeDraft && !isPlanKey(doc.key) && ( - {activeDraft && autosaveDocumentKey === doc.key - ? autosaveState === "saving" - ? "Autosaving..." - : autosaveState === "saved" - ? "Saved" - : autosaveState === "error" - ? "Could not save" - : "" + {activeDraft + ? activeConflict + ? "Out of date" + : autosaveDocumentKey === doc.key + ? autosaveState === "saving" + ? "Autosaving..." + : autosaveState === "saved" + ? "Saved" + : autosaveState === "error" + ? "Could not save" + : "" + : "" : ""}