fix(ui): retire finished draft recovery mappings

Keep an active composer isolated while sending, then restore the shared draft on a later visit once no recovered work remains. Re-establish the mapping for new text, attachments, or pending intent. Scope the storage-failure test to sessionStorage on every platform.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-27 23:05:49 -05:00
1 parent b4aaac47ab
commit f1888c5f3d
4 files changed
+74 -2

No files matched your search

+31 -1
View File
@@ -3405,8 +3405,13 @@ describe("IssueChatThread", () => {
</MemoryRouter>
);
let root = createRoot(container);
const storagePrototype = Object.getPrototypeOf(sessionStorage) as Storage;
const originalStorageWrite = storagePrototype.setItem;
const recoveryWrite = scenario === "unavailable recovery storage"
? vi.spyOn(Object.getPrototypeOf(sessionStorage), "setItem").mockImplementation(() => { throw new Error("Storage full"); })
? 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)));
@@ -3476,6 +3481,31 @@ describe("IssueChatThread", () => {
} 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) => (
<MemoryRouter><IssueChatThread comments={[]} linkedRuns={[]} timelineEvents={[]} liveRuns={[]}
onAdd={onAdd} draftKey={draftKey} enableLiveTranscriptPolling={false} /></MemoryRouter>
);
const editor = () => container.querySelector<HTMLTextAreaElement>('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);
+3
View File
@@ -4682,6 +4682,9 @@ const IssueChatComposer = forwardRef<
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<string | undefined>(undefined);
// Initialize before StrictMode's mount cleanup can flush an empty value over
// the stored draft. The effect below handles subsequent task-key changes.
+22
View File
@@ -45,6 +45,28 @@ describe("task draft upload receipts", () => {
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` };
+18 -1
View File
@@ -51,6 +51,17 @@ export function preserveDraftInTab(draftKey: string, body: string, attachments:
}
}
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);
}
}
function mayWriteDraft(draftKey: string, attemptId?: string) {
const pending = loadDraftSubmission(draftKey);
return !pending || pending.attemptId === attemptId;
@@ -64,6 +75,7 @@ export function saveDraft(draftKey: string, value: string, attemptId?: string) {
} else {
draftStorage(draftKey).removeItem(draftKey);
}
syncDraftRecoveryMarker(draftKey);
} catch {
// Ignore browser storage failures.
}
@@ -75,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.
}
@@ -127,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. */
}
@@ -134,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. */
}
@@ -238,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. */
}