feat(ui): handle issue document edit conflicts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-14 06:59:20 -05:00
parent abb70ca5c5
commit 924762c073

View File

@@ -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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<DraftState | null>(null);
const [documentConflict, setDocumentConflict] = useState<DocumentConflictState | null>(null);
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(issue.id));
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(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<HTMLDivElement>) => {
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({
<div className="space-y-3">
{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 && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-amber-200">Out of date</p>
<p className="text-xs text-muted-foreground">
This document changed while you were editing. Your local draft is preserved and autosave is paused.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
setDocumentConflict((current) =>
current?.key === doc.key
? { ...current, showRemote: !current.showRemote }
: current,
)
}
>
{activeConflict.showRemote ? "Hide remote" : "Review remote"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setDocumentConflict((current) =>
current?.key === doc.key
? { ...current, showRemote: false }
: current,
)
}
>
Keep my draft
</Button>
<Button
variant="outline"
size="sm"
onClick={() => reloadDocumentFromServer(doc.key)}
>
Reload remote
</Button>
<Button
size="sm"
onClick={() => void overwriteDocumentFromDraft(doc.key)}
disabled={upsertDocument.isPending}
>
{upsertDocument.isPending ? "Saving..." : "Overwrite remote"}
</Button>
</div>
</div>
{activeConflict.showRemote && (
<div className="mt-3 rounded-md border border-border/70 bg-background/60 p-3">
<div className="mb-2 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>Remote revision {activeConflict.serverDocument.latestRevisionNumber}</span>
<span></span>
<span>updated {relativeTime(activeConflict.serverDocument.updatedAt)}</span>
</div>
{!isPlanKey(doc.key) && activeConflict.serverDocument.title ? (
<p className="mb-2 text-sm font-medium">{activeConflict.serverDocument.title}</p>
) : null}
{renderBody(activeConflict.serverDocument.body, "text-[14px] leading-7")}
</div>
)}
</div>
)}
{activeDraft && !isPlanKey(doc.key) && (
<Input
value={activeDraft.title}
@@ -602,17 +756,25 @@ export function IssueDocumentsSection({
<div className="flex min-h-4 items-center justify-end px-1">
<span
className={`text-[11px] transition-opacity duration-150 ${
autosaveState === "error" ? "text-destructive" : "text-muted-foreground"
activeConflict
? "text-amber-300"
: autosaveState === "error"
? "text-destructive"
: "text-muted-foreground"
} ${activeDraft ? "opacity-100" : "opacity-0"}`}
>
{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"
: ""
: ""
: ""}
</span>
</div>