From 4c211a0369fd7452e2a2a9d63e371777ae2b0854 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 7 Nov 2026 18:56:51 +0000 Subject: [PATCH 1/4] fix(web): typing a draft in one tab no longer erases drafts written in another tab Each tab's debounced composer-draft write serialized only its own drafts over the shared localStorage key, so a draft saved in one tab was erased as soon as another tab saved. Now the debounced write reads storage, replaces only the entries this tab changed since its last sync, and skips the write when it changed nothing. Entries are one per record key and one per other field, built from whatever the persisted state holds, and compared as JSON values so key order never reads as an edit. A storage listener reads current storage (never the event's newValue) and adopts entries that storage changed and this tab did not, keeping File handles, decoded images and images not yet persisted. createDeferredStorage now takes a write callback, so the read, merge, write and baseline update run in one function. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/composerDraftStore.test.ts | 160 +++++++++++- apps/web/src/composerDraftStore.ts | 310 ++++++++++++++++++++++-- apps/web/src/lib/storage.ts | 9 +- 3 files changed, 446 insertions(+), 33 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index cd0e74d231aac..5f32cfb42a4ea 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -721,6 +721,153 @@ describe("composerDraftStore context persistence", () => { expect(draft?.previewAnnotations).toEqual([annotation]); } }); + + // Each tab is a fresh module instance sharing one localStorage. A browser sends a write's + // storage event only to the other tabs, whenever it gets to it. + const openTabs = () => { + const storage = createMockStorage(); + vi.stubGlobal("localStorage", storage); + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + const read = () => storage.getItem(COMPOSER_DRAFT_STORAGE_KEY); + const openTab = async () => { + const page = new EventTarget(); + vi.stubGlobal("window", page); + vi.resetModules(); + const store = (await import("./composerDraftStore")).useComposerDraftStore; + return { + store, + write: () => page.dispatchEvent(new Event("beforeunload")), + receive: (newValue = read(), key: string | null = COMPOSER_DRAFT_STORAGE_KEY) => + page.dispatchEvent( + Object.assign(new Event("storage"), { key, newValue, storageArea: storage }), + ), + prompts: () => + Object.fromEntries( + Object.entries(store.getState().draftsByThreadKey).map(([key, draft]) => [ + key, + draft.prompt, + ]), + ), + }; + }; + return { storage, read, openTab }; + }; + const crossTabRef = (id: string) => + scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make(`cross-tab-${id}`)); + const crossTabKey = (id: string) => scopedThreadKey(crossTabRef(id)); + + it("keeps drafts written in other tabs and does not restore drafts they cleared", async () => { + const { storage, read, openTab } = openTabs(); + const tabA = await openTab(); + const tabB = await openTab(); + + tabB.store.getState().setPrompt(crossTabRef("x"), "from tab B"); + tabB.store.getState().setStickyModelSelection(modelSelection(CODEX_DRIVER, "gpt-5.4")); + tabB.write(); + const tabBWrite = read(); + tabA.store.getState().setPrompt(crossTabRef("y"), "from tab A"); + tabA.write(); + const both = { [crossTabKey("x")]: "from tab B", [crossTabKey("y")]: "from tab A" }; + expect((await openTab()).prompts()).toEqual(both); + // The late event still carries tab B's write, which predates tab A's. + tabA.receive(tabBWrite); + tabB.receive(); + expect(tabA.prompts()).toEqual(both); + expect(tabA.store.getState().stickyActiveProvider).toBe(CODEX_INSTANCE); + expect(tabA.store.getState().stickyModelSelectionByProvider).toEqual( + tabB.store.getState().stickyModelSelectionByProvider, + ); + const settled = read(); + tabA.write(); + tabB.write(); + expect(read()).toBe(settled); + + tabA.store.getState().setPrompt(crossTabRef("x"), ""); + tabA.write(); + tabB.store.getState().setPrompt(crossTabRef("z"), "from tab B again"); + tabB.write(); + tabA.receive(); + tabB.receive(); + const expected = { [crossTabKey("y")]: "from tab A", [crossTabKey("z")]: "from tab B again" }; + expect((await openTab()).prompts()).toEqual(expected); + expect(tabA.prompts()).toEqual(expected); + expect(tabB.prompts()).toEqual(expected); + + // localStorage.clear() reports a null key. + storage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); + tabA.receive(null, null); + expect(tabA.prompts()).toEqual({}); + expect(tabA.store.getState().stickyActiveProvider).toBeNull(); + tabA.write(); + expect(read()).toBeNull(); + }); + + it("does not overwrite another tab's edit with an edit undone before the write", async () => { + const { storage, openTab } = openTabs(); + const tabA = await openTab(); + const tabB = await openTab(); + + tabB.store.getState().setPrompt(crossTabRef("z"), "from tab B"); + tabB.write(); + tabA.receive(); + tabB.store.getState().setPrompt(crossTabRef("z"), "edited in tab B"); + tabB.write(); + tabA.store.getState().setPrompt(crossTabRef("z"), "undone in tab A"); + tabA.receive(); + tabA.store.getState().setPrompt(crossTabRef("z"), "from tab B"); + tabA.store.getState().setPrompt(crossTabRef("w"), "from tab A"); + tabA.write(); + expect((await openTab()).prompts()).toEqual({ + [crossTabKey("z")]: "edited in tab B", + [crossTabKey("w")]: "from tab A", + }); + + // Storage from a newer build is neither adopted nor merged into: this tab's write replaces it. + const newerBuild = { [crossTabKey("v")]: { prompt: "from a newer build", attachments: [] } }; + storage.setItem( + COMPOSER_DRAFT_STORAGE_KEY, + JSON.stringify({ + version: Number.MAX_SAFE_INTEGER, + state: { draftsByThreadKey: newerBuild }, + }), + ); + tabA.receive(); + expect(tabA.prompts()[crossTabKey("v")]).toBeUndefined(); + tabA.store.getState().setPrompt(crossTabRef("y"), "from tab A"); + tabA.write(); + expect((await openTab()).prompts()).toEqual(tabA.prompts()); + }); + + it("does not take a draft thread rebuilt in another key order as an edit", async () => { + const { openTab } = openTabs(); + const tabA = await openTab(); + const tabB = await openTab(); + const draftId = DraftId.make("cross-tab-draft"); + + tabA.store + .getState() + .setProjectDraftThreadId( + scopeProjectRef(TEST_ENVIRONMENT_ID, ProjectId.make("cross-tab-project")), + draftId, + { environmentSelection: "auto", loadBalancedEnvironmentId: TEST_ENVIRONMENT_ID }, + ); + tabA.write(); + tabB.receive(); + tabA.store.getState().setDraftThreadContext(draftId, { branch: "feature" }); + tabA.write(); + // Rebuilds tab B's copy with the same content in another key order. + tabB.store.getState().setDraftThreadContext(draftId, { branch: "scratch" }); + tabB.store + .getState() + .setDraftThreadContext(draftId, { branch: null, environmentSelection: "auto" }); + tabB.store.getState().setPrompt(crossTabRef("w"), "unrelated"); + tabB.write(); + tabB.receive(); + expect((await openTab()).store.getState().getDraftThread(draftId)?.branch).toBe("feature"); + expect(tabB.store.getState().getDraftThread(draftId)?.branch).toBe("feature"); + }); }); describe("composerDraftStore thread contexts", () => { @@ -2236,11 +2383,8 @@ function createMockStorage() { } describe("createDeferredStorage", () => { - const serialize = vi.fn((value: string) => `s:${value}`); - beforeEach(() => { vi.useFakeTimers(); - serialize.mockClear(); }); afterEach(() => { @@ -2249,29 +2393,27 @@ describe("createDeferredStorage", () => { it("removeItem cancels a pending setItem write", () => { const base = createMockStorage(); - const storage = createDeferredStorage(base, serialize); + const storage = createDeferredStorage(base, base.setItem); storage.setItem("key", "v1"); storage.removeItem("key"); vi.advanceTimersByTime(300); - expect(serialize).not.toHaveBeenCalled(); expect(base.setItem).not.toHaveBeenCalled(); expect(base.removeItem).toHaveBeenCalledWith("key"); }); - it("flush serializes and writes the pending value immediately", () => { + it("flush writes the pending value immediately", () => { const base = createMockStorage(); - const storage = createDeferredStorage(base, serialize); + const storage = createDeferredStorage(base, base.setItem); storage.setItem("key", "v1"); expect(base.setItem).not.toHaveBeenCalled(); storage.flush(); - expect(base.setItem).toHaveBeenCalledWith("key", "s:v1"); + expect(base.setItem).toHaveBeenCalledWith("key", "v1"); vi.advanceTimersByTime(300); - expect(serialize).toHaveBeenCalledTimes(1); expect(base.setItem).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index aef9e88a38572..644afb8f99a39 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -37,6 +37,7 @@ import { } from "@t3tools/client-runtime/environment"; import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; +import * as Predicate from "effect/Predicate"; import * as Effect from "effect/Effect"; import { DeepMutable } from "effect/Types"; import { @@ -124,35 +125,307 @@ type ComposerPersistState = | { capturedState: ComposerDraftStoreState } | PersistedComposerDraftStoreState; -const composerDebouncedStorage = createDeferredStorage>( - typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(), - (value) => - JSON.stringify({ - state: - "capturedState" in value.state - ? partializeComposerDraftStoreState(value.state.capturedState) - : value.state, - version: value.version, - }), +const composerDraftLocalStorage = + typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(); + +function readComposerDraftText(): string | null { + const text = composerDraftLocalStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY); + return typeof text === "string" ? text : null; +} + +/** Persisted values by entry: one per record key and one per other field. */ +type ComposerDraftEntries = ReadonlyMap; + +const entryId = (field: string, key?: string) => + key === undefined ? field : `${field}\u0000${key}`; + +function entryIdParts(id: string): [field: string, key?: string] { + const separator = id.indexOf("\u0000"); + return separator === -1 ? [id] : [id.slice(0, separator), id.slice(separator + 1)]; +} + +function entriesOf(state: Readonly>): ComposerDraftEntries { + const entries = new Map(); + for (const [field, value] of Object.entries(state)) { + const items = Predicate.isObject(value) ? Object.entries(value) : [[undefined, value] as const]; + for (const [key, item] of items) { + if (item !== undefined) entries.set(entryId(field, key), item); + } + } + return entries; +} + +/** The state `entries` describe, keeping each record field of `shape` even when it is empty. */ +function persistedStateOf(entries: ComposerDraftEntries, shape: Readonly>) { + const state: Record = {}; + for (const [field, value] of Object.entries(shape)) { + if (Predicate.isObject(value)) state[field] = {}; + } + for (const [id, value] of entries) { + const [field, key] = entryIdParts(id); + const record = state[field]; + if (key === undefined) state[field] = value; + else if (Predicate.isObject(record)) record[key] = value; + else state[field] = { [key]: value }; + } + return state; +} + +/** Equality of JSON values; key order is ignored. */ +function jsonEquals(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b)) + return a.length === b.length && a.every((item, index) => jsonEquals(item, b[index])); + if (!Predicate.isObject(a) || !Predicate.isObject(b)) return false; + const keys = Object.keys(a); + return ( + keys.length === Object.keys(b).length && + keys.every((key) => Object.hasOwn(b, key) && jsonEquals(a[key], b[key])) + ); +} + +function changedEntryIds(next: ComposerDraftEntries, base: ComposerDraftEntries): string[] { + return [...new Set([...next.keys(), ...base.keys()])].filter( + (id) => !jsonEquals(next.get(id), base.get(id)), + ); +} + +function withEntries( + base: ComposerDraftEntries, + ids: Iterable, + source: ComposerDraftEntries, +): ComposerDraftEntries { + const next = new Map(base); + for (const id of ids) { + const value = source.get(id); + if (value === undefined) next.delete(id); + else next.set(id, value); + } + return next; +} + +/** `null` when storage holds nothing this build can read, so nothing is adopted or merged. */ +function storedStateOf(value: unknown): PersistedComposerDraftStoreState | null { + if (value === null) return EMPTY_PERSISTED_DRAFT_STORE_STATE; + if (!Predicate.isObject(value) || !Predicate.isObject(value.state)) return null; + const version = + typeof value.version === "number" ? value.version : COMPOSER_DRAFT_STORAGE_VERSION; + if (version > COMPOSER_DRAFT_STORAGE_VERSION) return null; + return version < COMPOSER_DRAFT_STORAGE_VERSION + ? migratePersistedComposerDraftStoreState(value.state) + : normalizeCurrentPersistedComposerDraftStoreState(value.state); +} + +function readStoredComposerDrafts(text: string | null): PersistedComposerDraftStoreState | null { + try { + return storedStateOf(text === null ? null : JSON.parse(text)); + } catch { + return null; + } +} + +/** What this tab knew about storage when it last wrote, read or adopted it. */ +interface ComposerDraftSync { + /** Per entry: this tab's partialized value and storage's value at its last sync. */ + readonly local: ComposerDraftEntries; + readonly stored: ComposerDraftEntries; + /** The storage text last written or read, its entries, and whether memory adopted them. */ + readonly text?: string | null; + readonly textEntries?: ComposerDraftEntries | null; + readonly adopted?: boolean; +} +const UNSYNCED: ComposerDraftSync = { local: new Map(), stored: new Map() }; +let sync = UNSYNCED; + +/** Writes only the entries this tab changed since its last sync over what storage holds now. */ +function writeComposerDrafts(name: string, value: StorageValue): void { + if (!("capturedState" in value.state)) { + composerDraftLocalStorage.setItem(name, JSON.stringify(value)); + return; + } + const partialized = partializeComposerDraftStoreState(value.state.capturedState); + const local = entriesOf(partialized); + const changed = changedEntryIds(local, sync.local); + if (changed.length === 0) return; + const text = readComposerDraftText(); + const parsed = sync.text === text ? undefined : readStoredComposerDrafts(text); + const stored = parsed === undefined ? sync.textEntries : parsed && entriesOf(parsed); + // Unreadable or newer storage: overwrite it with this tab's state. + const merged = stored ? withEntries(stored, changed, local) : local; + const nextText = JSON.stringify({ + state: persistedStateOf(merged, partialized), + version: value.version, + }); + composerDraftLocalStorage.setItem(name, nextText); + sync = { + local, + stored: stored ? withEntries(sync.stored, changed, local) : local, + text: nextText, + textEntries: merged, + adopted: false, + }; +} + +const composerDebouncedStorage = createDeferredStorage( + composerDraftLocalStorage, + writeComposerDrafts, COMPOSER_PERSIST_DEBOUNCE_MS, ); const composerPersistStorage: PersistStorage = { - getItem: (name) => { - const raw = composerDebouncedStorage.getItem(name); - if (typeof raw !== "string") { - return null; - } - return JSON.parse(raw) as StorageValue; + getItem: () => { + const text = readComposerDraftText(); + const value = text === null ? null : (JSON.parse(text) as StorageValue); + const stored = storedStateOf(value); + const seen = stored && entriesOf(stored); + // onRehydrateStorage sets `local` once the state is hydrated. + sync = { ...UNSYNCED, stored: seen ?? new Map(), text, textEntries: seen, adopted: true }; + return value; }, setItem: (name, value) => composerDebouncedStorage.setItem(name, value), - removeItem: (name) => composerDebouncedStorage.removeItem(name), + removeItem: (name) => { + composerDebouncedStorage.removeItem(name); + sync = UNSYNCED; + }, }; +function adoptStoredRecord>>>( + ids: ReadonlySet, + field: keyof PersistedComposerDraftStoreState, + stored: Readonly>> | undefined, + memory: M, + hydrate: (stored: P | undefined, current: H | undefined) => H | undefined, +): M | Record { + const keys = [...ids] + .map(entryIdParts) + .flatMap(([idField, key]) => (idField === field && key !== undefined ? [key] : [])); + if (keys.length === 0) return memory; + const next: Record = {}; + for (const [key, value] of Object.entries(memory)) { + if (value !== undefined) next[key] = value; + } + for (const key of keys) { + const value = hydrate(stored?.[key], memory[key]); + if (value === undefined) delete next[key]; + else next[key] = value; + } + return next; +} + +/** Keeps `File` handles, decoded images, and images not yet persisted, in their current order. */ +function adoptStoredDraft( + stored: PersistedComposerThreadDraftState | undefined, + current: ComposerThreadDraftState | undefined, +): ComposerThreadDraftState | undefined { + const attachments = stored?.attachments ?? []; + const storedIds = new Set(attachments.map((attachment) => attachment.id)); + const persistedIds = new Set(current?.persistedAttachments.map((attachment) => attachment.id)); + // An image is in persistedAttachments only once serializeComposerImages has finished. + const keptImages = + current?.images.filter((image) => storedIds.has(image.id) || !persistedIds.has(image.id)) ?? []; + const keptIds = new Set(keptImages.map((image) => image.id)); + const currentFiles = new Map(current?.files.map((file) => [file.id, file.file])); + const adopted = stored + ? toHydratedThreadDraft({ ...stored, attachments: [] }) + : createEmptyThreadDraft(); + const next: ComposerThreadDraftState = { + ...adopted, + images: [ + ...keptImages, + ...hydrateImagesFromPersisted( + attachments.filter((attachment) => !keptIds.has(attachment.id)), + ), + ], + nonPersistedImageIds: current?.nonPersistedImageIds ?? [], + persistedAttachments: [...attachments], + files: adopted.files.map((file) => ({ ...file, file: currentFiles.get(file.id) ?? file.file })), + }; + for (const image of current?.images ?? []) { + if (!keptIds.has(image.id)) { + revokeObjectPreviewUrl(image.previewUrl); + } + } + return shouldRemoveDraft(next) ? undefined : next; +} + +/** Takes in every entry another tab changed in storage that this tab has not changed itself. */ +function adoptStoredComposerDrafts(): void { + const text = readComposerDraftText(); + if (sync.adopted && sync.text === text) return; + const stored = readStoredComposerDrafts(text); + const entries = stored && entriesOf(stored); + const fromStorage = entries ? changedEntryIds(entries, sync.stored) : []; + // Shares unchanged entries with the baseline instead of keeping a second parsed copy. + const seen = entries && withEntries(sync.stored, fromStorage, entries); + sync = { ...sync, text, textEntries: seen, adopted: true }; + if (!stored || !seen || fromStorage.length === 0) return; + const state = composerDraftStore.getState(); + const local = entriesOf(partializeComposerDraftStoreState(state)); + const ids = new Set(fromStorage.filter((id) => jsonEquals(local.get(id), sync.local.get(id)))); + if (ids.size === 0) return; + const keep = (stored: T | undefined) => stored; + composerDraftStore.setState({ + draftThreadsByThreadKey: adoptStoredRecord( + ids, + "draftThreadsByThreadKey", + stored.draftThreadsByThreadKey, + state.draftThreadsByThreadKey, + (value) => value && toHydratedDraftThreadState(value), + ), + logicalProjectDraftThreadKeyByLogicalProjectKey: adoptStoredRecord( + ids, + "logicalProjectDraftThreadKeyByLogicalProjectKey", + stored.logicalProjectDraftThreadKeyByLogicalProjectKey, + state.logicalProjectDraftThreadKeyByLogicalProjectKey, + keep, + ), + stickyModelSelectionByProvider: adoptStoredRecord( + ids, + "stickyModelSelectionByProvider", + stored.stickyModelSelectionByProvider, + state.stickyModelSelectionByProvider, + keep, + ), + stickyOptionsByModelByProvider: adoptStoredRecord( + ids, + "stickyOptionsByModelByProvider", + stored.stickyOptionsByModelByProvider, + state.stickyOptionsByModelByProvider, + (value): Partial>> | undefined => value, + ), + ...(ids.has(entryId("stickyActiveProvider")) && { + stickyActiveProvider: stored.stickyActiveProvider ?? null, + }), + // Last: adopting a draft revokes the preview URLs it replaces. + draftsByThreadKey: adoptStoredRecord( + ids, + "draftsByThreadKey", + stored.draftsByThreadKey, + state.draftsByThreadKey, + adoptStoredDraft, + ), + }); + const adoptedLocal = entriesOf(partializeComposerDraftStoreState(composerDraftStore.getState())); + sync = { + ...sync, + local: withEntries(sync.local, ids, adoptedLocal), + stored: withEntries(sync.stored, ids, seen), + }; +} + if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener("beforeunload", () => { composerDebouncedStorage.flush(); }); + // The event's newValue can predate this tab's own later write, so read storage instead. + window.addEventListener("storage", (event) => { + if ( + event.storageArea === composerDraftLocalStorage && + (event.key === COMPOSER_DRAFT_STORAGE_KEY || event.key === null) + ) { + adoptStoredComposerDrafts(); + } + }); } export const PersistedComposerImageAttachment = Schema.Struct({ @@ -4237,6 +4510,9 @@ const composerDraftStore = create()( stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null, }; }, + onRehydrateStorage: () => (state) => { + if (state) sync = { ...sync, local: entriesOf(partializeComposerDraftStoreState(state)) }; + }, }, ), ); diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts index 948b8f427f971..cbed51230f7a1 100644 --- a/apps/web/src/lib/storage.ts +++ b/apps/web/src/lib/storage.ts @@ -44,16 +44,11 @@ export function resolveStorage(storage: Partial | null | undefined export function createDeferredStorage( baseStorage: Partial | null | undefined, - serialize: (value: TValue) => string, + write: (name: string, value: TValue) => void, debounceMs: number = 300, ): DeferredStorage { const resolvedStorage = resolveStorage(baseStorage); - const debouncedSetItem = new Debouncer( - (name: string, value: TValue) => { - resolvedStorage.setItem(name, serialize(value)); - }, - { wait: debounceMs }, - ); + const debouncedSetItem = new Debouncer(write, { wait: debounceMs }); return { getItem: (name) => resolvedStorage.getItem(name), From 06923a7f1208aa8d245ab2e126405de364cd7d21 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 00:55:10 +0000 Subject: [PATCH 2/4] fix(web): reconcile deferred remote composer drafts --- apps/web/src/composerDraftStore.test.ts | 38 +++++++++++++++++++++++++ apps/web/src/composerDraftStore.ts | 34 ++++++++++++++-------- 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 5f32cfb42a4ea..320b7d43f75cb 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -840,6 +840,44 @@ describe("composerDraftStore context persistence", () => { expect((await openTab()).prompts()).toEqual(tabA.prompts()); }); + it("adopts a remote edit after a local edit is undone", async () => { + const { openTab } = openTabs(); + const tabA = await openTab(); + const tabB = await openTab(); + const ref = crossTabRef("deferred-edit"); + + tabA.store.getState().setPrompt(ref, "baseline"); + tabA.write(); + tabA.store.getState().setPrompt(ref, "local edit"); + tabB.store.getState().setPrompt(ref, "remote edit"); + tabB.write(); + tabA.receive(); + tabA.store.getState().setPrompt(ref, "baseline"); + tabA.write(); + + expect(tabA.prompts()[crossTabKey("deferred-edit")]).toBe("remote edit"); + }); + + it("adopts a remote deletion after a local edit is undone", async () => { + const { openTab } = openTabs(); + const tabA = await openTab(); + const tabB = await openTab(); + const ref = crossTabRef("deferred-delete"); + const key = crossTabKey("deferred-delete"); + + tabB.store.getState().setPrompt(ref, "remote baseline"); + tabB.write(); + tabA.receive(); + tabA.store.getState().setPrompt(ref, "local edit"); + tabB.store.getState().setPrompt(ref, ""); + tabB.write(); + tabA.receive(); + tabA.store.getState().setPrompt(ref, "remote baseline"); + tabA.write(); + + expect(tabA.prompts()[key]).toBeUndefined(); + }); + it("does not take a draft thread rebuilt in another key order as an edit", async () => { const { openTab } = openTabs(); const tabA = await openTab(); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 644afb8f99a39..c64d5e397ec22 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -229,10 +229,9 @@ interface ComposerDraftSync { /** Per entry: this tab's partialized value and storage's value at its last sync. */ readonly local: ComposerDraftEntries; readonly stored: ComposerDraftEntries; - /** The storage text last written or read, its entries, and whether memory adopted them. */ + /** The storage text last written or read and its entries. */ readonly text?: string | null; readonly textEntries?: ComposerDraftEntries | null; - readonly adopted?: boolean; } const UNSYNCED: ComposerDraftSync = { local: new Map(), stored: new Map() }; let sync = UNSYNCED; @@ -243,9 +242,18 @@ function writeComposerDrafts(name: string, value: StorageValue + jsonEquals(local.get(id), sync.local.get(id)), + ); + if (deferred.length > 0) { + adoptStoredComposerDrafts(); + partialized = partializeComposerDraftStoreState(composerDraftStore.getState()); + local = entriesOf(partialized); + changed = changedEntryIds(local, sync.local); + } if (changed.length === 0) return; const text = readComposerDraftText(); const parsed = sync.text === text ? undefined : readStoredComposerDrafts(text); @@ -262,7 +270,6 @@ function writeComposerDrafts(name: string, value: StorageValue = { const stored = storedStateOf(value); const seen = stored && entriesOf(stored); // onRehydrateStorage sets `local` once the state is hydrated. - sync = { ...UNSYNCED, stored: seen ?? new Map(), text, textEntries: seen, adopted: true }; + sync = { ...UNSYNCED, stored: seen ?? new Map(), text, textEntries: seen }; return value; }, setItem: (name, value) => composerDebouncedStorage.setItem(name, value), @@ -351,18 +358,21 @@ function adoptStoredDraft( /** Takes in every entry another tab changed in storage that this tab has not changed itself. */ function adoptStoredComposerDrafts(): void { const text = readComposerDraftText(); - if (sync.adopted && sync.text === text) return; const stored = readStoredComposerDrafts(text); const entries = stored && entriesOf(stored); const fromStorage = entries ? changedEntryIds(entries, sync.stored) : []; // Shares unchanged entries with the baseline instead of keeping a second parsed copy. const seen = entries && withEntries(sync.stored, fromStorage, entries); - sync = { ...sync, text, textEntries: seen, adopted: true }; - if (!stored || !seen || fromStorage.length === 0) return; + sync = { ...sync, text, textEntries: seen }; const state = composerDraftStore.getState(); const local = entriesOf(partializeComposerDraftStoreState(state)); - const ids = new Set(fromStorage.filter((id) => jsonEquals(local.get(id), sync.local.get(id)))); - if (ids.size === 0) return; + const deferred = changedEntryIds(seen ?? sync.stored, sync.local); + const ids = new Set( + [...new Set([...fromStorage, ...deferred])].filter((id) => + jsonEquals(local.get(id), sync.local.get(id)), + ), + ); + if (!stored || !seen || ids.size === 0) return; const keep = (stored: T | undefined) => stored; composerDraftStore.setState({ draftThreadsByThreadKey: adoptStoredRecord( From afaf6f4f0f1d4f8856dce9018a57d5f920cff67b Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 00:59:05 +0000 Subject: [PATCH 3/4] test(web): reset the composer draft store's storage record between snap-shot tests The cross-tab draft change keeps a module-level record of what the store last wrote and skips writing entries that match it. The snap-shot tests cleared localStorage behind the store's back, so a later test that rebuilt the same draft wrote nothing and the delivery check found no saved attachment. Clear through the store's persist API so both reset together. Co-Authored-By: Claude Opus 5.5 --- .../src/components/desktop/SnapShotCoordinator.browser.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts b/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts index a504d429185af..41dffdf77f519 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts +++ b/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts @@ -51,6 +51,8 @@ const projectRef = scopeProjectRef(environmentId, ProjectId.make("snap-shot-proj function setup(): Disposable { storage.clear(); vi.stubGlobal("localStorage", storage); + // Resets the store's record of what it last wrote along with the storage it wrote to. + void useComposerDraftStore.persist.clearStorage(); useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {}, From 7dc1e2baa121d476ca79edd452a9b7e1718b2cdf Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 01:59:36 +0000 Subject: [PATCH 4/4] fix(web): a draft thread with unsaved changes survives another tab discarding it When another tab discarded a draft thread while this tab still held an unwritten edit or an image not yet saved, adoption removed the thread but kept the draft. The route lost its draft thread, the composer unmounted, and the draft was stored without its thread or, with only the image, lost. Adoption now keeps a draft thread whose draft this tab keeps, and drops it from the baseline so the next write puts the thread back. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/composerDraftStore.test.ts | 47 ++++++++++++++++++++++++- apps/web/src/composerDraftStore.ts | 27 ++++++++------ 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 320b7d43f75cb..404c2663f1f3a 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -732,7 +732,7 @@ describe("composerDraftStore context persistence", () => { }); const read = () => storage.getItem(COMPOSER_DRAFT_STORAGE_KEY); const openTab = async () => { - const page = new EventTarget(); + const page = Object.assign(new EventTarget(), { localStorage: storage }); vi.stubGlobal("window", page); vi.resetModules(); const store = (await import("./composerDraftStore")).useComposerDraftStore; @@ -906,6 +906,51 @@ describe("composerDraftStore context persistence", () => { expect((await openTab()).store.getState().getDraftThread(draftId)?.branch).toBe("feature"); expect(tabB.store.getState().getDraftThread(draftId)?.branch).toBe("feature"); }); + + it("keeps a draft thread with unsaved changes that another tab discards", async () => { + const { openTab } = openTabs(); + const tabA = await openTab(); + const tabB = await openTab(); + const imageDraft = DraftId.make("cross-tab-image-draft"); + const promptDraft = DraftId.make("cross-tab-prompt-draft"); + for (const draftId of [imageDraft, promptDraft]) { + tabA.store + .getState() + .setProjectDraftThreadId( + scopeProjectRef(TEST_ENVIRONMENT_ID, ProjectId.make(`${draftId}-project`)), + draftId, + { environmentSelection: "auto", loadBalancedEnvironmentId: TEST_ENVIRONMENT_ID }, + ); + } + tabA.write(); + tabB.receive(); + // The image is unsaved until its serialization finishes, the prompt until the next write. + const image = makeImage({ id: "cross-tab-image", previewUrl: "blob:cross-tab" }); + tabA.store.getState().addImage(imageDraft, image); + tabA.store.getState().setPrompt(promptDraft, "unwritten"); + tabB.store.getState().clearDraftThread(imageDraft); + tabB.store.getState().clearDraftThread(promptDraft); + tabB.write(); + tabA.receive(); + expect(tabA.store.getState().getDraftSession(imageDraft)).not.toBeNull(); + expect(tabA.store.getState().getDraftSession(promptDraft)).not.toBeNull(); + + await tabA.store.getState().syncPersistedAttachments(imageDraft, [ + { + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: "data:image/png;base64,AQEBAQ==", + }, + ]); + tabA.write(); + const reopened = (await openTab()).store.getState(); + expect(reopened.getDraftSession(imageDraft)).not.toBeNull(); + expect(reopened.getComposerDraft(imageDraft)?.images.map(({ id }) => id)).toEqual([image.id]); + expect(reopened.getDraftSession(promptDraft)).not.toBeNull(); + expect(reopened.getComposerDraft(promptDraft)?.prompt).toBe("unwritten"); + }); }); describe("composerDraftStore thread contexts", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index c64d5e397ec22..afa189e756453 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -374,6 +374,20 @@ function adoptStoredComposerDrafts(): void { ); if (!stored || !seen || ids.size === 0) return; const keep = (stored: T | undefined) => stored; + const draftsByThreadKey = adoptStoredRecord( + ids, + "draftsByThreadKey", + stored.draftsByThreadKey, + state.draftsByThreadKey, + adoptStoredDraft, + ); + // A draft this tab keeps (an unwritten edit or an unsaved image) keeps its draft thread, and + // the thread leaves the baseline so the next write puts it back. + const keptThreadIds = Object.keys(draftsByThreadKey) + .filter((key) => stored.draftThreadsByThreadKey[key] === undefined) + .map((key) => entryId("draftThreadsByThreadKey", key)) + .filter((id) => ids.has(id)); + for (const id of keptThreadIds) ids.delete(id); composerDraftStore.setState({ draftThreadsByThreadKey: adoptStoredRecord( ids, @@ -406,20 +420,13 @@ function adoptStoredComposerDrafts(): void { ...(ids.has(entryId("stickyActiveProvider")) && { stickyActiveProvider: stored.stickyActiveProvider ?? null, }), - // Last: adopting a draft revokes the preview URLs it replaces. - draftsByThreadKey: adoptStoredRecord( - ids, - "draftsByThreadKey", - stored.draftsByThreadKey, - state.draftsByThreadKey, - adoptStoredDraft, - ), + draftsByThreadKey, }); const adoptedLocal = entriesOf(partializeComposerDraftStoreState(composerDraftStore.getState())); sync = { ...sync, - local: withEntries(sync.local, ids, adoptedLocal), - stored: withEntries(sync.stored, ids, seen), + local: withEntries(withEntries(sync.local, ids, adoptedLocal), keptThreadIds, seen), + stored: withEntries(sync.stored, [...ids, ...keptThreadIds], seen), }; }