mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
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:
1 parent
b4aaac47ab
commit
f1888c5f3d
4 files changed
+74
-2
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` };
|
||||
|
||||
@@ -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. */
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user