(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. */
}