diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 82268181708..62e1e686d11 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -15,6 +15,7 @@ import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Agent } from "@paperclipai/shared"; import { CommentSubmissionUnknownError } from "../lib/comment-submit-result"; +import { loadDraft, preserveDraftInTab, settleDraftSubmission } from "../lib/composer-draft"; import { IssueAssigneePausedNotice, IssueChatThread, @@ -379,6 +380,7 @@ describe("IssueChatThread", () => { document.body.appendChild(container); window.scrollTo = vi.fn(); localStorage.clear(); + sessionStorage.clear(); }); afterEach(() => { @@ -3276,6 +3278,234 @@ describe("IssueChatThread", () => { act(() => root.unmount()); }); + it("settles a restored submission only once through StrictMode effect replay", () => { + const key = "strict-restored-submission"; + const attemptId = "aaf8228f-0be7-45ae-a104-6fbe0af6f1d3"; + const submitted = "One text-only save interrupted by reload."; + const nextDraft = "A newer draft written while delivery was pending."; + localStorage.setItem(key, `${submitted}\n\n${nextDraft}`); + localStorage.setItem(`${key}:submission:v1`, JSON.stringify({ + version: 1, + draftKey: key, + attemptId, + reviewed: false, + nextDraftOffset: submitted.length + 2, + submittedAttachmentIds: [], + })); + const root = createRoot(container); + try { + act(() => root.render( + + + {}} + draftKey={key} + enableLiveTranscriptPolling={false} + /> + + , + )); + expect(container.querySelector( + 'textarea[aria-label="Issue chat editor"]', + )?.value).toBe(nextDraft); + expect(localStorage.getItem(key)).toBe(nextDraft); + expect(localStorage.getItem(`${key}:submission:v1`)).toBeNull(); + expect(container.textContent).not.toContain("We couldn’t confirm"); + } finally { + act(() => root.unmount()); + } + expect(localStorage.getItem(key)).toBe(nextDraft); + }); + + it.each(["submission write", "all storage"])( + "unlocks a confirmed in-memory submission after failed %s", + async (failure) => { + const key = "unavailable-submission-storage"; + const originalSetItem = localStorage.setItem.bind(localStorage); + const write = vi.spyOn(localStorage, "setItem").mockImplementation((key, value) => { + if (failure === "all storage" || key.endsWith(":submission:v1")) throw new Error("Storage unavailable"); + originalSetItem(key, value); + }); + const read = failure === "all storage" + ? vi.spyOn(localStorage, "getItem").mockImplementation(() => { throw new Error("Storage unavailable"); }) + : null; + let rejectSend!: (error: Error) => void; + const onAdd = vi.fn().mockReturnValueOnce(new Promise((_, reject) => { rejectSend = reject; })).mockResolvedValue(undefined); + const root = createRoot(container); + const element = (attemptId?: string) => ( + + + + ); + const editor = () => container.querySelector('textarea[aria-label="Issue chat editor"]')!; + const type = (value: string) => act(() => { + Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!.call(editor(), value); + editor().dispatchEvent(new Event("input", { bubbles: true })); + }); + const send = () => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Send") as HTMLButtonElement; + try { + await act(async () => root.render(element())); + type("Earlier message"); + await act(async () => send().click()); + const attemptId = onAdd.mock.calls[0]![4] as string; + type("The full next draft"); + await act(async () => rejectSend(new CommentSubmissionUnknownError())); + expect(container.textContent).toContain("We couldn’t confirm"); + await act(async () => root.render(element(attemptId))); + expect(editor().value).toBe("The full next draft"); + expect(container.textContent).not.toContain("We couldn’t confirm"); + expect(send().disabled).toBe(false); + await act(async () => send().click()); + expect(onAdd.mock.calls[1]![0]).toBe("The full next draft"); + } finally { + await act(async () => root.unmount()); + write.mockRestore(); + read?.mockRestore(); + } + }, + ); + + it.each(["newer local text", "foreign pending receipt", "matching text with new attachments", "matching full text with new attachments", "own receipt with newer stored text", "unavailable recovery storage"])( + "preserves both tabs through settlement and reload: %s", + async (scenario) => { + const key = `cross-tab-preserved-${scenario}`; + const attemptId = "aaf8228f-0be7-45ae-a104-6fbe0af6f1d3"; + const newerId = "baf8228f-0be7-45ae-a104-6fbe0af6f1d3"; + const attachmentId = "caf8228f-0be7-45ae-a104-6fbe0af6f1d3"; + const localText = "This tab's newer unsent draft"; + const otherText = scenario === "matching text with new attachments" ? localText : scenario === "matching full text with new attachments" ? `Earlier message\n\n${localText}` : "The other tab's unsent draft"; + localStorage.setItem(key, `Earlier message\n\n${localText}`); + localStorage.setItem(`${key}:submission:v1`, JSON.stringify({ version: 1, draftKey: key, attemptId, reviewed: false, nextDraftOffset: 17, submittedAttachmentIds: [] })); + const element = (confirmed: boolean, foreignConfirmed = false) => ( + + {}} draftKey={key} enableLiveTranscriptPolling={false} + /> + + ); + let root = createRoot(container); + const storagePrototype = Object.getPrototypeOf(sessionStorage) as Storage; + const originalStorageWrite = storagePrototype.setItem; + const recoveryWrite = scenario === "unavailable recovery storage" + ? vi.spyOn(storagePrototype, "setItem").mockImplementation(function (this: Storage, key: string, value: string) { + if (this === sessionStorage) throw new Error("Storage full"); + return originalStorageWrite.call(this, key, value); + }) + : null; + try { + await act(async () => root.render(element(false))); + if (scenario === "own receipt with newer stored text") localStorage.setItem(key, `Earlier message\n\n${otherText}`); + else expect(settleDraftSubmission(key, attemptId, otherText)).toBe(true); + if (scenario === "foreign pending receipt") localStorage.setItem(`${key}:submission:v1`, JSON.stringify({ version: 1, draftKey: key, attemptId: newerId, reviewed: false, nextDraftOffset: 0, submittedAttachmentIds: [] })); + localStorage.setItem(`${key}:attachments:v1`, JSON.stringify({ version: 1, draftKey: key, attachments: [{ attachmentId, name: "another-tab.txt", inline: false, contentPath: `/api/attachments/${attachmentId}/content` }] })); + await act(async () => root.render(element(true))); + const editor = () => container.querySelector('textarea[aria-label="Issue chat editor"]')!; + expect(editor().value).toBe(localText); + expect(container.textContent).not.toContain("We couldn’t confirm"); + if (scenario === "unavailable recovery storage") expect(container.textContent).toContain("copy your text before leaving"); + if (scenario.includes("with new attachments")) expect(container.textContent).toContain("another-tab.txt"); + if (scenario === "foreign pending receipt") { + localStorage.setItem(key, "The owning tab kept typing after the first receipt"); + await act(async () => root.render(element(true, true))); + expect(localStorage.getItem(`${key}:submission:v1`)).toContain(newerId); + } else expect(localStorage.getItem(`${key}:submission:v1`)).toBeNull(); + const storedText = localStorage.getItem(key); + expect(storedText).toBe(scenario === "foreign pending receipt" ? "The owning tab kept typing after the first receipt" : otherText); + expect(localStorage.getItem(`${key}:attachments:v1`)).toContain(attachmentId); + await act(async () => root.unmount()); + expect(localStorage.getItem(key)).toBe(storedText); + if (scenario === "unavailable recovery storage") return; + root = createRoot(container); + await act(async () => root.render(element(true, true))); + expect(editor().value).toBe(localText); + if (scenario.includes("with new attachments")) expect(container.textContent).toContain("another-tab.txt"); + expect(localStorage.getItem(key)).toBe(storedText); + expect(localStorage.getItem(`${key}:attachments:v1`)).toContain(attachmentId); + } finally { + if (container.childNodes.length) await act(async () => root.unmount()); + recoveryWrite?.mockRestore(); + } + }, + ); + + it.each(["ordinary", "recovered"])("preserves %s drafts when the same composer switches A to B to A before debounce", async (kind) => { + vi.useFakeTimers(); + const a = `navigation-a-${kind}`; + const b = `navigation-b-${kind}`; + const keys = kind === "recovered" + ? [preserveDraftInTab(a, "Draft A", []).key, preserveDraftInTab(b, "Draft B", []).key] + : [a, b]; + if (kind === "ordinary") { localStorage.setItem(a, "Draft A"); localStorage.setItem(b, "Draft B"); } + const root = createRoot(container); + const element = (draftKey: string) => ( + {}} draftKey={draftKey} enableLiveTranscriptPolling={false} /> + ); + const editor = () => container.querySelector('textarea[aria-label="Issue chat editor"]')!; + const type = (value: string) => act(() => { + Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!.call(editor(), value); + editor().dispatchEvent(new Event("input", { bubbles: true })); + }); + try { + await act(async () => root.render(element(a))); + expect(editor().value).toBe("Draft A"); + type("Draft A typed just now"); + await act(async () => root.render(element(b))); + expect(editor().value).toBe("Draft B"); + expect(loadDraft(keys[0]!)).toBe("Draft A typed just now"); + type("Draft B typed just now"); + await act(async () => root.render(element(a))); + expect(editor().value).toBe("Draft A typed just now"); + expect(loadDraft(keys[1]!)).toBe("Draft B typed just now"); + } finally { await act(async () => root.unmount()); } + }); + + it("returns to the shared draft after finishing a recovery and leaving the task", async () => { + const a = "finished-recovery-a"; + const b = "finished-recovery-b"; + localStorage.setItem(a, "The other tab's preserved draft"); + preserveDraftInTab(a, "My recovered message", []); + const onAdd = vi.fn().mockResolvedValue(undefined); + const root = createRoot(container); + const element = (draftKey: string) => ( + + ); + const editor = () => container.querySelector('textarea[aria-label="Issue chat editor"]')!; + try { + await act(async () => root.render(element(a))); + expect(editor().value).toBe("My recovered message"); + await act(async () => (Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Send") as HTMLButtonElement).click()); + expect(onAdd.mock.calls[0]![0]).toBe("My recovered message"); + expect(editor().value).toBe(""); + await act(async () => root.render(element(b))); + await act(async () => root.render(element(a))); + expect(editor().value).toBe("The other tab's preserved draft"); + expect(container.textContent).not.toContain("This draft is kept separately"); + } finally { await act(async () => root.unmount()); } + }); + it("stores and restores the composer draft per issue key", () => { vi.useFakeTimers(); const root = createRoot(container); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index cd1c4d5b0e4..1b7f8418e72 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -59,6 +59,9 @@ import { useOptionalToastActions } from "../context/ToastContext"; import { copyTextToClipboard } from "../lib/clipboard"; import { loadDraft, + loadDraftIfAvailable, + loadDraftRecoveryKey, + preserveDraftInTab, saveDraft, clearDraft, loadDraftAttachments, @@ -4652,7 +4655,7 @@ const IssueChatComposer = forwardRef< stopScope = "leaf", onImageUpload, onAttachImage, - draftKey, + draftKey: sharedDraftKey, enableReassign = false, reassignOptions = [], currentAssigneeValue = "", @@ -4672,6 +4675,17 @@ const IssueChatComposer = forwardRef< forwardedRef, ) { const stopControl = useComposerStop(onStop, stopPending); + const restoredRecovery = useMemo(() => { + const key = sharedDraftKey && loadDraftRecoveryKey(sharedDraftKey); + return key ? { sourceKey: sharedDraftKey!, key, persisted: true } : null; + }, [sharedDraftKey]); + const [newRecovery, setDraftRecovery] = useState<{ sourceKey: string; key: string; persisted: boolean } | null>(null); + const draftRecovery = newRecovery?.sourceKey === sharedDraftKey ? newRecovery : restoredRecovery; + const draftKey = draftRecovery?.key ?? sharedDraftKey; + // Keep the active buffer through send completion. Re-entering a task may + // return to its shared draft after an empty recovery has been retired. + useEffect(() => setDraftRecovery(restoredRecovery), [sharedDraftKey, restoredRecovery]); + const retiredDraftKeyRef = useRef(undefined); // Initialize before StrictMode's mount cleanup can flush an empty value over // the stored draft. The effect below handles subsequent task-key changes. const [body, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : "")); @@ -4691,6 +4705,7 @@ const IssueChatComposer = forwardRef< }, [draftKey]); const bodyRef = useRef(body); bodyRef.current = body; + const reconciledSubmissionRef = useRef<{ draftKey: string | undefined; attemptId: string } | null>(null); const pendingDraftRef = useRef<{ draftKey: string; attemptId: string; @@ -4806,7 +4821,7 @@ const IssueChatComposer = forwardRef< } useEffect(() => { - if (!draftKey) return; + if (!draftKey || (draftRecovery?.key === draftKey && !draftRecovery.persisted)) return; setBody(loadDraft(draftKey)); setComposerAttachments( loadDraftAttachments(draftKey).map((item) => ({ @@ -4822,21 +4837,54 @@ const IssueChatComposer = forwardRef< // Text equality is not delivery proof: users may intentionally repeat text. useEffect(() => { if (!uncertainSubmission || !confirmedSubmissionIds.has(uncertainSubmission.attemptId)) return; + const { attemptId } = uncertainSubmission; + const reconciled = reconciledSubmissionRef.current; + if (reconciled && reconciled.draftKey === draftKey && reconciled.attemptId === attemptId) return; const nextDraft = uncertainSubmission.nextDraftOffset === undefined ? "" : bodyRef.current.slice(uncertainSubmission.nextDraftOffset); - if (draftKey) settleDraftSubmission(draftKey, uncertainSubmission.attemptId, nextDraft); + const submittedIds = uncertainSubmission.submittedAttachmentIds; + let nextAttachments = submittedIds + ? composerAttachmentsRef.current.filter(item => !item.attachmentId || !submittedIds.includes(item.attachmentId)) + : []; + if (draftKey) { + const retained = loadDraftSubmission(draftKey); + // Settle storage from its own snapshot, never from this tab's stale copy. + // A different retained attempt is owned by its original tab. + if (retained?.attemptId === attemptId) settleDraftSubmission(draftKey, attemptId); + const storedDraft = loadDraftIfAvailable(draftKey); + const storedAttachments = loadDraftAttachments(draftKey).filter(item => !submittedIds?.includes(item.attachmentId)); + const foreignAttempt = retained && retained.attemptId !== attemptId; + const differentAttachments = JSON.stringify(nextAttachments.map(item => item.attachmentId).sort()) !== + JSON.stringify(storedAttachments.map(item => item.attachmentId).sort()); + if (storedDraft !== null && (foreignAttempt || !retained || storedDraft !== nextDraft || differentAttachments)) { + // Text has no cross-tab ordering. Preserve each buffer separately. When + // both snapshots describe the same text, retain all attachment receipts. + if (!foreignAttempt && (storedDraft === nextDraft || storedDraft === bodyRef.current)) { + const ids = new Set(nextAttachments.map(item => item.attachmentId)); + const combined = [...nextAttachments, ...storedAttachments.filter(item => !ids.has(item.attachmentId)).map(item => ({ + ...item, size: item.size ?? 0, id: `receipt:${item.attachmentId}`, status: "attached" as const, + }))]; + // Each draft is limited to 20 receipts. If their union exceeds that, + // keep the local selection here and the other selection in shared storage. + if (combined.length <= 20) nextAttachments = combined; + } + const recovered = preserveDraftInTab(sharedDraftKey!, nextDraft, nextAttachments); + // Fence old effect cleanups before switching keys or updating bodyRef. + retiredDraftKeyRef.current = draftKey; + setDraftRecovery({ sourceKey: sharedDraftKey!, ...recovered }); + } + // With unavailable storage, the confirmed in-memory attempt still settles. + } + reconciledSubmissionRef.current = { draftKey, attemptId }; setUncertainSubmission(null); setBody(nextDraft); bodyRef.current = nextDraft; - const submittedIds = uncertainSubmission.submittedAttachmentIds; - setComposerAttachments(current => submittedIds - ? current.filter(item => !item.attachmentId || !submittedIds.includes(item.attachmentId)) - : []); - }, [confirmedSubmissionIds, draftKey, uncertainSubmission]); + setComposerAttachments(nextAttachments); + }, [confirmedSubmissionIds, draftKey, sharedDraftKey, uncertainSubmission]); useEffect(() => { if ( - !draftKey || + !draftKey || retiredDraftKeyRef.current === draftKey || submitting || composerAttachments !== composerAttachmentsRef.current ) @@ -4853,14 +4901,14 @@ const IssueChatComposer = forwardRef< if (!draftKey || submitting) return; if (draftTimer.current) clearTimeout(draftTimer.current); draftTimer.current = setTimeout(() => { - saveDraft(draftKey, body); + if (retiredDraftKeyRef.current !== draftKey) saveDraft(draftKey, body); }, DRAFT_DEBOUNCE_MS); }, [body, draftKey, submitting]); useEffect(() => { return () => { if (draftTimer.current) clearTimeout(draftTimer.current); - if (draftKey && !submittingRef.current) + if (draftKey && retiredDraftKeyRef.current !== draftKey && !submittingRef.current) saveDraft(draftKey, bodyRef.current); }; }, [draftKey]); @@ -4868,7 +4916,7 @@ const IssueChatComposer = forwardRef< useEffect(() => { if (!draftKey) return; const flushDraft = () => { - if (!submittingRef.current) saveDraft(draftKey, bodyRef.current); + if (retiredDraftKeyRef.current !== draftKey && !submittingRef.current) saveDraft(draftKey, bodyRef.current); }; window.addEventListener("beforeunload", flushDraft); return () => window.removeEventListener("beforeunload", flushDraft); @@ -5351,6 +5399,12 @@ const IssueChatComposer = forwardRef< ) : null} + {draftRecovery && draftRecovery.sourceKey === sharedDraftKey ? ( +

+ Another tab changed the saved draft. This draft is kept separately in this tab. + {!draftRecovery.persisted ? " Browser storage is unavailable; copy your text before leaving." : " It will be restored if you reload this tab."} +

+ ) : null} {uncertainSubmission ? (
{ contentPath: `/api/attachments/${id}/content`, }; beforeEach(() => localStorage.clear()); + it("persists a tab recovery without touching shared text, receipts, or pending submission", () => { + sessionStorage.clear(); + saveDraft(key, "Other tab text"); + saveDraftAttachments(key, [receipt]); + saveDraftSubmission(key, { attemptId: id, reviewed: false }); + const fork = preserveDraftInTab(key, "This tab text", [receipt]); + expect(fork.persisted).toBe(true); + expect(loadDraftRecoveryKey(key)).toBe(fork.key); + expect(loadDraft(fork.key)).toBe("This tab text"); + expect(loadDraftAttachments(fork.key)).toEqual([receipt]); + expect(loadDraftSubmission(fork.key)).toBeNull(); + expect(loadDraft(key)).toBe("Other tab text"); + expect(loadDraftAttachments(key)).toEqual([receipt]); + expect(loadDraftSubmission(key)?.attemptId).toBe(id); + clearDraft(fork.key); + expect(loadDraft(fork.key)).toBe(""); + expect(loadDraft(key)).toBe("Other tab text"); + expect(loadDraftSubmission(key)?.attemptId).toBe(id); + }); + + it("retires a finished recovery but keeps or restores its mapping for unsent work", () => { + sessionStorage.clear(); + saveDraft(key, "Shared draft stays available"); + const fork = preserveDraftInTab(key, "Recovered message", []); + saveDraftSubmission(fork.key, { attemptId: id, reviewed: false }); + expect(settleDraftSubmission(fork.key, id)).toBe(true); + expect(loadDraftRecoveryKey(key)).toBeNull(); + expect(loadDraft(key)).toBe("Shared draft stays available"); + + saveDraft(fork.key, "Next recovered draft"); + expect(loadDraftRecoveryKey(key)).toBe(fork.key); + saveDraftSubmission(fork.key, { attemptId: id, reviewed: false, nextDraftOffset: 0, submittedAttachmentIds: [] }); + expect(settleDraftSubmission(fork.key, id)).toBe(true); + expect(loadDraftRecoveryKey(key)).toBe(fork.key); + expect(loadDraft(fork.key)).toBe("Next recovered draft"); + clearDraft(fork.key); + expect(loadDraftRecoveryKey(key)).toBeNull(); + saveDraftAttachments(fork.key, [receipt]); + expect(loadDraftRecoveryKey(key)).toBe(fork.key); + expect(loadDraftAttachments(fork.key)).toEqual([receipt]); + }); + it("settles only submitted text and attachments while preserving the next draft", () => { const nextId = "aaf8228f-0be7-45ae-a104-6fbe0af6f1d3"; const nextReceipt = { ...receipt, attachmentId: nextId, contentPath: `/api/attachments/${nextId}/content` }; diff --git a/ui/src/lib/composer-draft.ts b/ui/src/lib/composer-draft.ts index 1c28b721aa9..2ca07566def 100644 --- a/ui/src/lib/composer-draft.ts +++ b/ui/src/lib/composer-draft.ts @@ -18,10 +18,47 @@ function draftStorage(draftKey: string): Storage { } export function loadDraft(draftKey: string): string { + return loadDraftIfAvailable(draftKey) ?? ""; +} + +/** Distinguish an empty stored draft from unavailable browser storage. */ +export function loadDraftIfAvailable(draftKey: string): string | null { try { return draftStorage(draftKey).getItem(draftKey) ?? ""; } catch { - return ""; + return null; + } +} + +/** A conflicting tab keeps its own recoverable buffer without replacing the + * shared draft or acquiring another tab's pending receipt. */ +export function loadDraftRecoveryKey(draftKey: string): string | null { + const key = `paperclip:agent-chat-draft:recovered:${draftKey}`; + try { + return sessionStorage.getItem(`${key}:recovery:v1`) === draftKey ? key : null; + } catch { return null; } +} + +export function preserveDraftInTab(draftKey: string, body: string, attachments: unknown): { key: string; persisted: boolean } { + const key = `paperclip:agent-chat-draft:recovered:${draftKey}`; + try { + sessionStorage.setItem(key, body); + sessionStorage.setItem(`${key}:attachments:v1`, JSON.stringify({ version: 1, draftKey: key, attachments: draftAttachments(attachments) })); + sessionStorage.setItem(`${key}:recovery:v1`, draftKey); + return { key, persisted: true }; + } catch { + return { key, persisted: false }; + } +} + +function syncDraftRecoveryMarker(draftKey: string) { + const prefix = "paperclip:agent-chat-draft:recovered:"; + if (!draftKey.startsWith(prefix)) return; + const marker = `${draftKey}:recovery:v1`; + if (loadDraft(draftKey).trim() || loadDraftAttachments(draftKey).length || loadDraftSubmission(draftKey)) { + sessionStorage.setItem(marker, draftKey.slice(prefix.length)); + } else { + sessionStorage.removeItem(marker); } } @@ -38,6 +75,7 @@ export function saveDraft(draftKey: string, value: string, attemptId?: string) { } else { draftStorage(draftKey).removeItem(draftKey); } + syncDraftRecoveryMarker(draftKey); } catch { // Ignore browser storage failures. } @@ -49,6 +87,7 @@ export function clearDraft(draftKey: string, attemptId?: string) { draftStorage(draftKey).removeItem(draftKey); draftStorage(draftKey).removeItem(`${draftKey}:attachments:v1`); draftStorage(draftKey).removeItem(`${draftKey}:submission:v1`); + syncDraftRecoveryMarker(draftKey); } catch { // Ignore browser storage failures. } @@ -101,6 +140,7 @@ export function saveDraftSubmission( `${draftKey}:submission:v1`, JSON.stringify({ version: 1, draftKey, ...submission }), ); + syncDraftRecoveryMarker(draftKey); } catch { /* The composer also retains the fence in memory. */ } @@ -108,8 +148,10 @@ export function saveDraftSubmission( export function clearDraftSubmission(draftKey: string, attemptId: string) { try { - if (loadDraftSubmission(draftKey)?.attemptId === attemptId) + if (loadDraftSubmission(draftKey)?.attemptId === attemptId) { draftStorage(draftKey).removeItem(`${draftKey}:submission:v1`); + syncDraftRecoveryMarker(draftKey); + } } catch { /* Disabled browser storage is supported in memory. */ } @@ -212,6 +254,7 @@ export function saveDraftAttachments(draftKey: string, attachments: unknown, att JSON.stringify({ version: 1, draftKey, attachments: selected }), ); else draftStorage(draftKey).removeItem(`${draftKey}:attachments:v1`); + syncDraftRecoveryMarker(draftKey); } catch { /* Disabled/full browser storage must not break the composer. */ }