(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) : ""));
@@ -4808,7 +4818,7 @@ const IssueChatComposer = forwardRef<
}
useEffect(() => {
- if (!draftKey) return;
+ if (!draftKey || (draftRecovery?.key === draftKey && !draftRecovery.persisted)) return;
setBody(loadDraft(draftKey));
setComposerAttachments(
loadDraftAttachments(draftKey).map((item) => ({
@@ -4827,47 +4837,51 @@ const IssueChatComposer = forwardRef<
const { attemptId } = uncertainSubmission;
const reconciled = reconciledSubmissionRef.current;
if (reconciled && reconciled.draftKey === draftKey && reconciled.attemptId === attemptId) return;
- let nextDraft = uncertainSubmission.nextDraftOffset === undefined
+ const nextDraft = uncertainSubmission.nextDraftOffset === undefined
? "" : bodyRef.current.slice(uncertainSubmission.nextDraftOffset);
- let storedAttachments: ComposerAttachmentItem[] | null = null;
+ const submittedIds = uncertainSubmission.submittedAttachmentIds;
+ let nextAttachments = submittedIds
+ ? composerAttachmentsRef.current.filter(item => !item.attachmentId || !submittedIds.includes(item.attachmentId))
+ : [];
if (draftKey) {
const retained = loadDraftSubmission(draftKey);
- if (retained && retained.attemptId !== attemptId) {
- // Another attempt owns storage. Adopt its snapshot without settling it.
- setUncertainSubmission(retained);
- const storedDraft = loadDraft(draftKey);
- setBody(storedDraft);
- bodyRef.current = storedDraft;
- setComposerAttachments(loadDraftAttachments(draftKey).map(item => ({
- ...item, size: item.size ?? 0, id: `receipt:${item.attachmentId}`, status: "attached",
- })));
- return;
- }
- if (!settleDraftSubmission(draftKey, attemptId, nextDraft)) {
- const storedDraft = loadDraftIfAvailable(draftKey);
- if (storedDraft !== null && storedDraft !== bodyRef.current) {
- // Another tab already settled this receipt. Its newer draft wins.
- nextDraft = storedDraft;
- storedAttachments = loadDraftAttachments(draftKey).map(item => ({
- ...item, size: item.size ?? 0, id: `receipt:${item.attachmentId}`, status: "attached",
- }));
+ // 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;
}
- // With unavailable storage, the confirmed in-memory attempt still settles.
+ 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 => storedAttachments ?? (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
)
@@ -4884,14 +4898,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]);
@@ -4899,7 +4913,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);
@@ -5382,6 +5396,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("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 66b97a39ab1..35c82b56ce5 100644
--- a/ui/src/lib/composer-draft.ts
+++ b/ui/src/lib/composer-draft.ts
@@ -30,6 +30,27 @@ export function loadDraftIfAvailable(draftKey: string): string | 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 mayWriteDraft(draftKey: string, attemptId?: string) {
const pending = loadDraftSubmission(draftKey);
return !pending || pending.attemptId === attemptId;