From 927fc7b0c5e06b5421ed9a2d84f94a893d51261d Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 08:15:23 -0500 Subject: [PATCH 1/8] fix(web): stashing in one tab no longer erases another tab's stash Each stash change now starts from what storage holds instead of the tab's in-memory copy, and a storage listener keeps other tabs' lists current. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/promptStashStore.test.ts | 21 ++++++++++++ apps/web/src/promptStashStore.ts | 46 +++++++++++++++++++++------ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index 0b6bcf79a4fa..35d5ecd19baf 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -126,6 +126,24 @@ describe("promptStashStore", () => { expect(entries[0]?.id).toBe("overflow"); }); + it("keeps an entry another tab stashed after this tab loaded", () => { + writePromptStashStorageForTest( + JSON.stringify({ + version: 2, + state: { entries: [makeEntry({ id: "other-tab" })] }, + }), + ); + // This tab loaded before the other tab's write, so its list is still empty. + usePromptStashStore.setState({ entries: [] }); + + usePromptStashStore.getState().stashEntry(makeEntry({ id: "this-tab" })); + + expect(usePromptStashStore.getState().entries.map((entry) => entry.id)).toEqual([ + "this-tab", + "other-tab", + ]); + }); + // regression: #4787 stashed prompts were lost when switching providers it("distinguishes a memory-only write (written, not durable) from a failed one", () => { const store = usePromptStashStore.getState(); @@ -225,6 +243,9 @@ describe("promptStashStore", () => { const entry = usePromptStashStore.getState().entries[0]; expect(entry?.pendingImageCount).toBe(0); expect(entry?.unreadableImageNames).toHaveLength(2); + + usePromptStashStore.getState().stashEntry(makeEntry({ id: "later" })); + expect(usePromptStashStore.getState().entries[1]).toEqual(entry); }); it("ignores an unreadable v1 payload seeded under the current key", () => { diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index 660a691b5aa7..98f23e58566f 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -42,10 +42,11 @@ const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPrompt /** * `pendingImageCount` only has meaning within the session that wrote it: the - * encode loop that would clear it does not survive a reload. Any entry that - * comes back from storage still pending was orphaned by a closed tab or a - * crash mid-encode, so the count is settled here — otherwise the entry would - * be stuck showing "saving…" and refuse to restore forever. + * encode loop that would clear it does not survive a reload. An entry still + * pending when a tab loads was orphaned by a closed tab or a crash mid-encode, + * so the count is settled then — otherwise the entry would be stuck showing + * "saving…" and refuse to restore forever. If another tab is in fact still + * encoding it, that tab's finalize overwrites the settled fields. */ function clearOrphanedPendingImages( entries: ReadonlyArray, @@ -135,12 +136,30 @@ function readPersistedEntries(): ReadonlyArray | null { const parsed: unknown = JSON.parse(raw); const state = (parsed as { state?: unknown } | null)?.state; if (!state) return null; - return clearOrphanedPendingImages(decodePersistedPromptStashState(state).entries); + return decodePersistedPromptStashState(state).entries; } catch { return null; } } +/** + * Every tab of the origin shares one stash. A change starts from what storage + * holds now rather than this tab's copy, so writing it back cannot erase an + * entry another tab stashed since this tab last read. + */ +function latestEntries(current: ReadonlyArray): ReadonlyArray { + return readPersistedEntries() ?? current; +} + +function loadPersistedEntries(): ReadonlyArray | null { + const persisted = readPersistedEntries(); + if (!persisted?.some((entry) => entry.pendingImageCount)) return persisted; + const settled = clearOrphanedPendingImages(persisted); + // Later changes start from storage, so the settled counts must land there too. + persistEntries(settled); + return settled; +} + interface PromptStashStoreState { entries: ReadonlyArray; stashEntry: (entry: PromptStashEntry) => { @@ -176,7 +195,7 @@ interface PromptStashStoreState { export const usePromptStashStore = create()((set, get) => ({ entries: [], stashEntry: (entry) => { - const nextEntries = [entry, ...get().entries]; + const nextEntries = [entry, ...latestEntries(get().entries)]; const evicted = nextEntries.length > MAX_STASH_ENTRIES ? (nextEntries.pop() ?? null) : null; const { written, durable } = persistEntries(nextEntries); // A rejected write must not leave the entry visible either: the caller @@ -189,7 +208,7 @@ export const usePromptStashStore = create()((set, get) => return { evicted, written: true, durable }; }, takeEntry: (entryId) => { - const entries = get().entries; + const entries = latestEntries(get().entries); const entry = entries.find((candidate) => candidate.id === entryId) ?? null; if (!entry) return { entry: null, durable: true }; const nextEntries = entries.filter((candidate) => candidate.id !== entryId); @@ -198,7 +217,7 @@ export const usePromptStashStore = create()((set, get) => return { entry, durable }; }, finalizeEntryImages: (entryId, images) => { - const entries = get().entries; + const entries = latestEntries(get().entries); const index = entries.findIndex((candidate) => candidate.id === entryId); const existing = index === -1 ? undefined : entries[index]; if (!existing) return { attached: false, durable: true }; @@ -220,13 +239,20 @@ export const usePromptStashStore = create()((set, get) => try { baseStashStorage.removeItem(LEGACY_PROMPT_STASH_STORAGE_KEY); } catch {} - const persisted = readPersistedEntries(); + const persisted = loadPersistedEntries(); if (persisted) { usePromptStashStore.setState({ entries: persisted }); } + // Fires only for writes from other tabs, keeping this tab's list current. + if (storageIsDurable && typeof window !== "undefined") { + window.addEventListener("storage", (event) => { + if (event.key !== PROMPT_STASH_STORAGE_KEY && event.key !== null) return; + usePromptStashStore.setState({ entries: readPersistedEntries() ?? [] }); + }); + } } export function writePromptStashStorageForTest(raw: string): void { baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw); - usePromptStashStore.setState({ entries: readPersistedEntries() ?? [] }); + usePromptStashStore.setState({ entries: loadPersistedEntries() ?? [] }); } From cdfb7984ae287e842e659274336e5352f001256e Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 09:03:12 -0500 Subject: [PATCH 2/8] fix(web): stashed images storage rejected stay restorable in the same tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When browser storage rejects an entry's images, the stash now writes the entry without them, listing them as dropped, and keeps the images in this tab until it reloads. Restoring in the same session returns the images, and a later stash or another tab's write no longer brings back "saving…". Co-Authored-By: Claude Opus 5.5 --- apps/web/src/promptStashStore.test.ts | 76 +++++++++++++++++++++++++- apps/web/src/promptStashStore.ts | 77 ++++++++++++++++++++++----- 2 files changed, 139 insertions(+), 14 deletions(-) diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index 35d5ecd19baf..848284a928fc 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { collectAssistantCitations, @@ -248,6 +248,80 @@ describe("promptStashStore", () => { expect(usePromptStashStore.getState().entries[1]).toEqual(entry); }); + it("keeps images storage rejected for this session and settles the stored entry", async () => { + // A localStorage that rejects large writes, as a full origin quota does. + const stored = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => stored.get(key) ?? null, + setItem: (key: string, value: string) => { + if (value.length > 2_000) { + throw new DOMException("Quota exceeded", "QuotaExceededError"); + } + stored.set(key, value); + }, + removeItem: (key: string) => { + stored.delete(key); + }, + }); + vi.resetModules(); + try { + const { usePromptStashStore: store } = await import("./promptStashStore"); + const { stashEntry, finalizeEntryImages, takeEntry } = store.getState(); + const bigImage = { + id: "big-img", + name: "big.png", + mimeType: "image/png", + sizeBytes: 5_000, + dataUrl: "x".repeat(5_000), + }; + const rejectedImages = { + attachments: [bigImage], + droppedImageNames: [], + unreadableImageNames: [], + }; + + stashEntry({ ...makeEntry({ id: "rejected" }), pendingImageCount: 1 }); + expect(finalizeEntryImages("rejected", rejectedImages)).toEqual({ + attached: true, + durable: false, + }); + + // After a reload, and in other tabs, the entry reports the image missing. + expect(JSON.parse(stored.get(PROMPT_STASH_STORAGE_KEY) ?? "null")).toMatchObject({ + state: { + entries: [ + { + id: "rejected", + attachments: [], + droppedImageNames: ["big.png"], + pendingImageCount: 0, + }, + ], + }, + }); + + // A later write re-reads storage, yet this tab still holds the image. + stashEntry(makeEntry({ id: "later" })); + expect(store.getState().entries[1]).toMatchObject({ + id: "rejected", + attachments: [{ name: "big.png" }], + pendingImageCount: 0, + }); + expect(takeEntry("rejected").entry?.attachments).toEqual([bigImage]); + + // An entry another tab already restored stays gone. + stashEntry({ ...makeEntry({ id: "restored-elsewhere" }), pendingImageCount: 1 }); + finalizeEntryImages("restored-elsewhere", rejectedImages); + stored.set( + PROMPT_STASH_STORAGE_KEY, + JSON.stringify({ version: 2, state: { entries: [makeEntry({ id: "later" })] } }), + ); + expect(takeEntry("restored-elsewhere").entry).toBeNull(); + } finally { + vi.unstubAllGlobals(); + } + }); + it("ignores an unreadable v1 payload seeded under the current key", () => { writePromptStashStorageForTest( JSON.stringify({ diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index 98f23e58566f..911298a2fbeb 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -104,6 +104,48 @@ function resolveBaseStorage(): { storage: StateStorage; durable: boolean } { const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStorage(); +interface StashEntryImages { + attachments: ReadonlyArray; + droppedImageNames: ReadonlyArray; + unreadableImageNames: ReadonlyArray; +} + +/** + * Finalized images storage refused (quota), by entry id. Storage gets the + * entry with those images listed as dropped, so a reload or another tab + * reports them missing; this tab keeps them until it reloads, as the stash + * toast promises. + */ +const unsavedImagesByEntryId = new Map(); + +function withUnsavedImages(entry: PromptStashEntry): PromptStashEntry { + const images = unsavedImagesByEntryId.get(entry.id); + return images ? { ...entry, ...images, pendingImageCount: 0 } : entry; +} + +function withoutUnsavedImages(entry: PromptStashEntry): PromptStashEntry { + const images = unsavedImagesByEntryId.get(entry.id); + if (!images) return entry; + return { + ...entry, + attachments: [], + droppedImageNames: [ + ...images.droppedImageNames, + ...images.attachments.map((attachment) => attachment.name), + ], + unreadableImageNames: images.unreadableImageNames, + pendingImageCount: 0, + }; +} + +/** Storage's entries as this tab shows them, forgetting images of entries now gone. */ +function visibleEntries(entries: ReadonlyArray): ReadonlyArray { + for (const entryId of unsavedImagesByEntryId.keys()) { + if (!entries.some((entry) => entry.id === entryId)) unsavedImagesByEntryId.delete(entryId); + } + return unsavedImagesByEntryId.size === 0 ? entries : entries.map(withUnsavedImages); +} + /** * Persists the queue, immediately rather than debounced. Stashing is a * deliberate, infrequent keystroke — not a per-character autosave — so there @@ -119,7 +161,9 @@ function persistEntries(entries: ReadonlyArray): { PROMPT_STASH_STORAGE_KEY, JSON.stringify({ version: PROMPT_STASH_STORAGE_VERSION, - state: { entries }, + state: { + entries: unsavedImagesByEntryId.size === 0 ? entries : entries.map(withoutUnsavedImages), + }, }), ); return { written: true, durable: storageIsDurable }; @@ -184,11 +228,7 @@ interface PromptStashStoreState { */ finalizeEntryImages: ( entryId: string, - images: { - attachments: ReadonlyArray; - droppedImageNames: ReadonlyArray; - unreadableImageNames: ReadonlyArray; - }, + images: StashEntryImages, ) => { attached: boolean; durable: boolean }; } @@ -204,16 +244,17 @@ export const usePromptStashStore = create()((set, get) => if (!written) { return { evicted: null, written: false, durable: false }; } - set(() => ({ entries: nextEntries })); + set(() => ({ entries: visibleEntries(nextEntries) })); return { evicted, written: true, durable }; }, takeEntry: (entryId) => { const entries = latestEntries(get().entries); - const entry = entries.find((candidate) => candidate.id === entryId) ?? null; - if (!entry) return { entry: null, durable: true }; + const stored = entries.find((candidate) => candidate.id === entryId); + if (!stored) return { entry: null, durable: true }; + const entry = withUnsavedImages(stored); const nextEntries = entries.filter((candidate) => candidate.id !== entryId); const { durable } = persistEntries(nextEntries); - set(() => ({ entries: nextEntries })); + set(() => ({ entries: visibleEntries(nextEntries) })); return { entry, durable }; }, finalizeEntryImages: (entryId, images) => { @@ -229,8 +270,18 @@ export const usePromptStashStore = create()((set, get) => unreadableImageNames: images.unreadableImageNames, pendingImageCount: 0, }; - const { durable } = persistEntries(nextEntries); - set(() => ({ entries: nextEntries })); + const { written, durable } = persistEntries(nextEntries); + if (!written) { + // The smaller write without the images settles the stored entry, so it + // never shows "saving…" again with no encoder left to finish it. + unsavedImagesByEntryId.set(entryId, { + attachments: images.attachments, + droppedImageNames: images.droppedImageNames, + unreadableImageNames: images.unreadableImageNames, + }); + persistEntries(nextEntries); + } + set(() => ({ entries: visibleEntries(nextEntries) })); return { attached: true, durable }; }, })); @@ -247,7 +298,7 @@ export const usePromptStashStore = create()((set, get) => if (storageIsDurable && typeof window !== "undefined") { window.addEventListener("storage", (event) => { if (event.key !== PROMPT_STASH_STORAGE_KEY && event.key !== null) return; - usePromptStashStore.setState({ entries: readPersistedEntries() ?? [] }); + usePromptStashStore.setState({ entries: visibleEntries(readPersistedEntries() ?? []) }); }); } } From a4bf9a84d19321f557710c97eac685317ab03778 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 22:27:15 +0000 Subject: [PATCH 3/8] fix(web): opening another tab no longer drops images still saving to the stash A tab that loaded settled every pending stash entry in shared storage, including one another tab was still encoding images for. That entry could then be restored without its images, and the encoding tab's finalize found nothing to attach to. Loading now settles a pending entry only once it is older than a generous encode window (or its timestamp is unreadable), and writes storage only when something actually settled. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/promptStashStore.test.ts | 36 +++++++++++++++++++++++++ apps/web/src/promptStashStore.ts | 39 +++++++++++++++++++-------- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index 848284a928fc..ce8b4938d78e 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -248,6 +248,42 @@ describe("promptStashStore", () => { expect(usePromptStashStore.getState().entries[1]).toEqual(entry); }); + it("keeps the pending count of an entry another tab may still be encoding", () => { + // A far-future createdAt is unambiguously within any encode window, without reading the clock. + const encoding = { + ...makeEntry({ id: "encoding" }), + createdAt: "9999-01-01T00:00:00.000Z", + pendingImageCount: 1, + }; + writePromptStashStorageForTest(JSON.stringify({ version: 2, state: { entries: [encoding] } })); + expect(usePromptStashStore.getState().entries).toEqual([encoding]); + + // Storage still holds the pending entry, not a settled copy. + const store = usePromptStashStore.getState(); + store.stashEntry(makeEntry({ id: "later" })); + expect(usePromptStashStore.getState().entries[1]).toEqual(encoding); + + const image = { + id: "img-encoded", + name: "encoded.webp", + mimeType: "image/webp", + sizeBytes: 12, + dataUrl: "data:image/webp;base64,BBBB", + }; + expect( + store.finalizeEntryImages("encoding", { + attachments: [image], + droppedImageNames: [], + unreadableImageNames: [], + }).attached, + ).toBe(true); + expect(store.takeEntry("encoding").entry).toMatchObject({ + attachments: [image], + unreadableImageNames: [], + pendingImageCount: 0, + }); + }); + it("keeps images storage rejected for this session and settles the stored entry", async () => { // A localStorage that rejects large writes, as a full origin quota does. const stored = new Map(); diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index 911298a2fbeb..69d3fba49698 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -41,18 +41,34 @@ type PersistedPromptStashState = typeof PersistedPromptStashState.Type; const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPromptStashState); /** - * `pendingImageCount` only has meaning within the session that wrote it: the - * encode loop that would clear it does not survive a reload. An entry still - * pending when a tab loads was orphaned by a closed tab or a crash mid-encode, - * so the count is settled then — otherwise the entry would be stuck showing - * "saving…" and refuse to restore forever. If another tab is in fact still - * encoding it, that tab's finalize overwrites the settled fields. + * How long a stash's image encode may plausibly run. `compressImageForStash` + * re-encodes images one at a time, up to the composer's attachment limit, with + * several quality and scale passes each, so this sits well past the worst case. */ -function clearOrphanedPendingImages( +const PENDING_IMAGE_ENCODE_WINDOW_MS = 15 * 60 * 1000; + +/** + * `pendingImageCount` is cleared only by the encode loop of the tab that + * stashed the entry, and that loop does not survive a reload. An entry still + * pending past the encode window was orphaned by a closed tab or a crash + * mid-encode, so a loading tab settles it — otherwise it would be stuck + * showing "saving…" and refuse to restore forever. A younger entry may still + * be encoding in another open tab; settling it would let it restore without + * its images and make that tab's finalize find nothing to attach to. + * Returns null when nothing settled. + */ +function settleOrphanedPendingImages( entries: ReadonlyArray, -): ReadonlyArray { - return entries.map((entry) => { + now: number, +): ReadonlyArray | null { + let settledAny = false; + const settled = entries.map((entry) => { if (!entry.pendingImageCount) return entry; + const startedAt = Date.parse(entry.createdAt); + if (!Number.isNaN(startedAt) && now - startedAt <= PENDING_IMAGE_ENCODE_WINDOW_MS) { + return entry; + } + settledAny = true; const lostCount = entry.pendingImageCount; return { ...entry, @@ -66,6 +82,7 @@ function clearOrphanedPendingImages( ], }; }); + return settledAny ? settled : null; } export function partitionStashAttachments( @@ -197,8 +214,8 @@ function latestEntries(current: ReadonlyArray): ReadonlyArray< function loadPersistedEntries(): ReadonlyArray | null { const persisted = readPersistedEntries(); - if (!persisted?.some((entry) => entry.pendingImageCount)) return persisted; - const settled = clearOrphanedPendingImages(persisted); + const settled = persisted && settleOrphanedPendingImages(persisted, Date.now()); + if (!settled) return persisted; // Later changes start from storage, so the settled counts must land there too. persistEntries(settled); return settled; From 00f412681cbd16b3206f3ccb7dc46d0a9a3667ef Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 22:41:58 +0000 Subject: [PATCH 4/8] fix(web): a tab suspended mid-encode keeps its stashed images Another tab loading settled pending stash entries by age, so a tab suspended past the window lost its images, and the settle wrote back the whole snapshot it read, overwriting a finalize that landed in between. The stashing tab now holds a Web Lock per entry until its encode finalizes. A loading tab settles only entries whose lock nobody holds, against a fresh read of storage. Without Web Locks the age window stays. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/promptStashStore.test.ts | 158 +++++++++++++++++++++++--- apps/web/src/promptStashStore.ts | 116 +++++++++++++------ 2 files changed, 222 insertions(+), 52 deletions(-) diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index ce8b4938d78e..db3a0fb8e8d0 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -42,6 +42,55 @@ function makeEntry(input: { }; } +const encodedImage = { + id: "img-encoded", + name: "encoded.webp", + mimeType: "image/webp", + sizeBytes: 12, + dataUrl: "data:image/webp;base64,BBBB", +}; + +/** A localStorage every tab imported after this call shares, as tabs of one origin do. */ +function stubSharedStorage() { + const shared = { stored: new Map(), afterNextRead: null as (() => void) | null }; + vi.stubGlobal("localStorage", { + getItem: (key: string) => { + const value = shared.stored.get(key) ?? null; + const afterRead = shared.afterNextRead; + shared.afterNextRead = null; + afterRead?.(); + return value; + }, + setItem: (key: string, value: string) => { + shared.stored.set(key, value); + }, + removeItem: (key: string) => { + shared.stored.delete(key); + }, + }); + return shared; +} + +function stubLockManager() { + const held = new Set(); + vi.stubGlobal("navigator", { + locks: { + request: (name: string, callback: () => Promise) => { + held.add(name); + return Promise.resolve() + .then(callback) + .finally(() => held.delete(name)); + }, + query: async () => ({ held: [...held].map((name) => ({ name })), pending: [] }), + }, + }); +} + +async function importTab() { + vi.resetModules(); + return await import("./promptStashStore"); +} + function resetPromptStashStore() { usePromptStashStore.setState({ entries: [] }); writePromptStashStorageForTest(""); @@ -230,26 +279,105 @@ describe("promptStashStore", () => { expect(attached).toBe(false); }); - it("settles a pending count left behind by a crashed or closed session", () => { - writePromptStashStorageForTest( - JSON.stringify({ - version: 2, - state: { - entries: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }], - }, - }), - ); + it("settles a pending count left behind by a crashed or closed session", async () => { + stubLockManager(); + try { + await writePromptStashStorageForTest( + JSON.stringify({ + version: 2, + state: { + entries: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }], + }, + }), + ); + + const entry = usePromptStashStore.getState().entries[0]; + expect(entry?.pendingImageCount).toBe(0); + expect(entry?.unreadableImageNames).toHaveLength(2); - const entry = usePromptStashStore.getState().entries[0]; - expect(entry?.pendingImageCount).toBe(0); - expect(entry?.unreadableImageNames).toHaveLength(2); + usePromptStashStore.getState().stashEntry(makeEntry({ id: "later" })); + expect(usePromptStashStore.getState().entries[1]).toEqual(entry); + } finally { + vi.unstubAllGlobals(); + } + }); - usePromptStashStore.getState().stashEntry(makeEntry({ id: "later" })); - expect(usePromptStashStore.getState().entries[1]).toEqual(entry); + it("keeps an entry pending however old while the tab encoding it is alive", async () => { + const shared = stubSharedStorage(); + stubLockManager(); + try { + const encodingTab = (await importTab()).usePromptStashStore; + const loadingTab = await importTab(); + // A suspended tab can resume its encode long after it stashed. + encodingTab.getState().stashEntry({ + ...makeEntry({ id: "suspended" }), + createdAt: "2000-01-01T00:00:00.000Z", + pendingImageCount: 1, + }); + + await loadingTab.writePromptStashStorageForTest( + shared.stored.get(PROMPT_STASH_STORAGE_KEY) ?? "", + ); + expect(loadingTab.usePromptStashStore.getState().entries[0]?.pendingImageCount).toBe(1); + + expect( + encodingTab.getState().finalizeEntryImages("suspended", { + attachments: [encodedImage], + droppedImageNames: [], + unreadableImageNames: [], + }).attached, + ).toBe(true); + expect(loadingTab.usePromptStashStore.getState().takeEntry("suspended").entry).toMatchObject({ + attachments: [encodedImage], + unreadableImageNames: [], + pendingImageCount: 0, + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("settling an orphan keeps images another tab finalized meanwhile", async () => { + const shared = stubSharedStorage(); + stubLockManager(); + try { + const tab = await importTab(); + const orphan = { ...makeEntry({ id: "orphan" }), pendingImageCount: 1 }; + const encoding = { ...makeEntry({ id: "encoding" }), pendingImageCount: 1 }; + shared.afterNextRead = () => { + shared.stored.set( + PROMPT_STASH_STORAGE_KEY, + JSON.stringify({ + version: 2, + state: { + entries: [orphan, { ...encoding, attachments: [encodedImage], pendingImageCount: 0 }], + }, + }), + ); + }; + + await tab.writePromptStashStorageForTest( + JSON.stringify({ version: 2, state: { entries: [orphan, encoding] } }), + ); + + expect(JSON.parse(shared.stored.get(PROMPT_STASH_STORAGE_KEY) ?? "null")).toMatchObject({ + state: { + entries: [ + { id: "orphan", pendingImageCount: 0, unreadableImageNames: [expect.any(String)] }, + { id: "encoding", attachments: [encodedImage], pendingImageCount: 0 }, + ], + }, + }); + expect(tab.usePromptStashStore.getState().takeEntry("encoding").entry?.attachments).toEqual([ + encodedImage, + ]); + } finally { + vi.unstubAllGlobals(); + } }); it("keeps the pending count of an entry another tab may still be encoding", () => { - // A far-future createdAt is unambiguously within any encode window, without reading the clock. + // Without Web Locks the age window decides; a far-future createdAt is inside it, clock unread. const encoding = { ...makeEntry({ id: "encoding" }), createdAt: "9999-01-01T00:00:00.000Z", diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index 69d3fba49698..be59176d9fcd 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -40,34 +40,75 @@ type PersistedPromptStashState = typeof PersistedPromptStashState.Type; const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPromptStashState); +/** Held by the stashing tab until its encode finalizes; a closed or crashed tab drops it. */ +const encodeLockName = (entryId: string) => `t3code:prompt-stash:encode:${entryId}`; +const encodeLockReleases = new Map void>(); + +// Typed as always present, but missing in older browsers and non-secure contexts. +function encodeLocks(): LockManager | undefined { + return typeof navigator === "undefined" ? undefined : navigator.locks; +} + +function holdEncodeLock(entryId: string): void { + const locks = encodeLocks(); + if (!locks) return; + const released = new Promise((resolve) => encodeLockReleases.set(entryId, resolve)); + void locks.request(encodeLockName(entryId), () => released).catch(() => {}); +} + +function releaseEncodeLock(entryId: string): void { + encodeLockReleases.get(entryId)?.(); + encodeLockReleases.delete(entryId); +} + /** - * How long a stash's image encode may plausibly run. `compressImageForStash` - * re-encodes images one at a time, up to the composer's attachment limit, with - * several quality and scale passes each, so this sits well past the worst case. + * Without Web Locks (older browsers, plain-http origins), ownership is unknowable, so only + * entries older than any plausible encode settle; a tab suspended past it can still lose them. */ const PENDING_IMAGE_ENCODE_WINDOW_MS = 15 * 60 * 1000; +async function orphanedPendingEntryIds( + entries: ReadonlyArray, +): Promise> { + const pending = entries.filter( + (entry) => entry.pendingImageCount && !encodeLockReleases.has(entry.id), + ); + if (pending.length === 0) return new Set(); + const snapshot = await encodeLocks() + ?.query() + .catch(() => undefined); + if (!snapshot) { + const now = Date.now(); + return new Set( + pending + .filter((entry) => { + const startedAt = Date.parse(entry.createdAt); + return Number.isNaN(startedAt) || now - startedAt > PENDING_IMAGE_ENCODE_WINDOW_MS; + }) + .map((entry) => entry.id), + ); + } + const owned = new Set( + [...(snapshot.held ?? []), ...(snapshot.pending ?? [])].map((lock) => lock.name), + ); + return new Set( + pending.filter((entry) => !owned.has(encodeLockName(entry.id))).map((entry) => entry.id), + ); +} + /** - * `pendingImageCount` is cleared only by the encode loop of the tab that - * stashed the entry, and that loop does not survive a reload. An entry still - * pending past the encode window was orphaned by a closed tab or a crash - * mid-encode, so a loading tab settles it — otherwise it would be stuck - * showing "saving…" and refuse to restore forever. A younger entry may still - * be encoding in another open tab; settling it would let it restore without - * its images and make that tab's finalize find nothing to attach to. - * Returns null when nothing settled. + * Only the stashing tab's encode clears `pendingImageCount`, so an entry whose encode lock + * nobody holds would show "saving…" forever. Settles those against a fresh read, since + * another tab may have written while the lock query was in flight. */ -function settleOrphanedPendingImages( - entries: ReadonlyArray, - now: number, -): ReadonlyArray | null { +async function settleOrphanedPendingImages(loaded: ReadonlyArray): Promise { + const orphanIds = await orphanedPendingEntryIds(loaded); + if (orphanIds.size === 0) return; + const latest = readPersistedEntries(); + if (!latest) return; let settledAny = false; - const settled = entries.map((entry) => { - if (!entry.pendingImageCount) return entry; - const startedAt = Date.parse(entry.createdAt); - if (!Number.isNaN(startedAt) && now - startedAt <= PENDING_IMAGE_ENCODE_WINDOW_MS) { - return entry; - } + const settled = latest.map((entry) => { + if (!entry.pendingImageCount || !orphanIds.has(entry.id)) return entry; settledAny = true; const lostCount = entry.pendingImageCount; return { @@ -82,7 +123,9 @@ function settleOrphanedPendingImages( ], }; }); - return settledAny ? settled : null; + if (!settledAny) return; + persistEntries(settled); + usePromptStashStore.setState({ entries: visibleEntries(settled) }); } export function partitionStashAttachments( @@ -212,15 +255,6 @@ function latestEntries(current: ReadonlyArray): ReadonlyArray< return readPersistedEntries() ?? current; } -function loadPersistedEntries(): ReadonlyArray | null { - const persisted = readPersistedEntries(); - const settled = persisted && settleOrphanedPendingImages(persisted, Date.now()); - if (!settled) return persisted; - // Later changes start from storage, so the settled counts must land there too. - persistEntries(settled); - return settled; -} - interface PromptStashStoreState { entries: ReadonlyArray; stashEntry: (entry: PromptStashEntry) => { @@ -254,11 +288,14 @@ export const usePromptStashStore = create()((set, get) => stashEntry: (entry) => { const nextEntries = [entry, ...latestEntries(get().entries)]; const evicted = nextEntries.length > MAX_STASH_ENTRIES ? (nextEntries.pop() ?? null) : null; + // Requested before the write, so a tab that reads the pending entry can see its owner. + if (entry.pendingImageCount) holdEncodeLock(entry.id); const { written, durable } = persistEntries(nextEntries); // A rejected write must not leave the entry visible either: the caller // keeps the composer intact on failure, so a stashed copy would // duplicate the prompt. Eviction likewise only sticks on success. if (!written) { + releaseEncodeLock(entry.id); return { evicted: null, written: false, durable: false }; } set(() => ({ entries: visibleEntries(nextEntries) })); @@ -275,6 +312,7 @@ export const usePromptStashStore = create()((set, get) => return { entry, durable }; }, finalizeEntryImages: (entryId, images) => { + releaseEncodeLock(entryId); const entries = latestEntries(get().entries); const index = entries.findIndex((candidate) => candidate.id === entryId); const existing = index === -1 ? undefined : entries[index]; @@ -303,14 +341,18 @@ export const usePromptStashStore = create()((set, get) => }, })); +/** Shows storage as it is now; orphaned pending counts settle once ownership is known. */ +function loadPersistedEntries(): Promise { + const persisted = readPersistedEntries(); + usePromptStashStore.setState({ entries: persisted ?? [] }); + return persisted ? settleOrphanedPendingImages(persisted) : Promise.resolve(); +} + { try { baseStashStorage.removeItem(LEGACY_PROMPT_STASH_STORAGE_KEY); } catch {} - const persisted = loadPersistedEntries(); - if (persisted) { - usePromptStashStore.setState({ entries: persisted }); - } + void loadPersistedEntries(); // Fires only for writes from other tabs, keeping this tab's list current. if (storageIsDurable && typeof window !== "undefined") { window.addEventListener("storage", (event) => { @@ -320,7 +362,7 @@ export const usePromptStashStore = create()((set, get) => } } -export function writePromptStashStorageForTest(raw: string): void { +export function writePromptStashStorageForTest(raw: string): Promise { baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw); - usePromptStashStore.setState({ entries: loadPersistedEntries() ?? [] }); + return loadPersistedEntries(); } From 463d82e34aa21870638a8126a779054da70d4582 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 30 Oct 2026 23:05:01 +0000 Subject: [PATCH 5/8] fix(web): settling stashed images no longer overwrites a finishing encode Settlement read the stash, then wrote it back without coordinating with other tabs, so an encode finalizing in between lost its images. It now holds every pending entry's encode lock (ifAvailable) across its re-read and write, and settles nothing while any encoder is still live. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/promptStashStore.test.ts | 124 ++++++++++++++++++++------ apps/web/src/promptStashStore.ts | 73 ++++++++------- 2 files changed, 138 insertions(+), 59 deletions(-) diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index db3a0fb8e8d0..67b1954713fc 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -73,15 +73,21 @@ function stubSharedStorage() { function stubLockManager() { const held = new Set(); + type Granted = (lock: { name: string } | null) => unknown; vi.stubGlobal("navigator", { locks: { - request: (name: string, callback: () => Promise) => { + request: (name: string, ...args: [Granted] | [{ ifAvailable?: boolean }, Granted]) => { + const [options, callback] = args.length === 1 ? [{}, args[0]] : args; + // Grants at once or, with ifAvailable, reports the lock taken; never queues. + if (held.has(name)) { + if (options.ifAvailable) return Promise.resolve().then(() => callback(null)); + return Promise.reject(new Error(`fake LockManager cannot queue ${name}`)); + } held.add(name); return Promise.resolve() - .then(callback) + .then(() => callback({ name })) .finally(() => held.delete(name)); }, - query: async () => ({ held: [...held].map((name) => ({ name })), pending: [] }), }, }); } @@ -376,40 +382,100 @@ describe("promptStashStore", () => { } }); - it("keeps the pending count of an entry another tab may still be encoding", () => { + it("settling waits out a live encode so its finalize keeps the images", async () => { + const shared = stubSharedStorage(); + stubLockManager(); + try { + const encodingTab = (await importTab()).usePromptStashStore; + const loadingTab = await importTab(); + const orphan = { ...makeEntry({ id: "orphan" }), pendingImageCount: 1 }; + shared.stored.set( + PROMPT_STASH_STORAGE_KEY, + JSON.stringify({ version: 2, state: { entries: [orphan] } }), + ); + encodingTab.getState().stashEntry({ ...makeEntry({ id: "encoding" }), pendingImageCount: 1 }); + const stashed = shared.stored.get(PROMPT_STASH_STORAGE_KEY) ?? ""; + + let finalized: { attached: boolean } | undefined; + const settling = loadingTab.writePromptStashStorageForTest(stashed); + // The encode finishes at settlement's next storage read, as a concurrent tab's can. + shared.afterNextRead = () => { + finalized = encodingTab.getState().finalizeEntryImages("encoding", { + attachments: [encodedImage], + droppedImageNames: [], + unreadableImageNames: [], + }); + }; + await settling; + // Fires the finalize now if settlement never read storage again. + localStorage.getItem(PROMPT_STASH_STORAGE_KEY); + + expect(finalized?.attached).toBe(true); + expect(JSON.parse(shared.stored.get(PROMPT_STASH_STORAGE_KEY) ?? "null")).toMatchObject({ + state: { + entries: [ + { id: "encoding", attachments: [encodedImage], pendingImageCount: 0 }, + { id: "orphan", pendingImageCount: 1 }, + ], + }, + }); + expect(loadingTab.usePromptStashStore.getState().takeEntry("encoding").entry).toMatchObject({ + attachments: [encodedImage], + pendingImageCount: 0, + }); + + await loadingTab.writePromptStashStorageForTest( + shared.stored.get(PROMPT_STASH_STORAGE_KEY) ?? "", + ); + expect(loadingTab.usePromptStashStore.getState().entries).toMatchObject([ + { id: "orphan", pendingImageCount: 0, unreadableImageNames: [expect.any(String)] }, + ]); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("keeps the pending count of an entry another tab may still be encoding", async () => { // Without Web Locks the age window decides; a far-future createdAt is inside it, clock unread. + vi.stubGlobal("navigator", {}); const encoding = { ...makeEntry({ id: "encoding" }), createdAt: "9999-01-01T00:00:00.000Z", pendingImageCount: 1, }; - writePromptStashStorageForTest(JSON.stringify({ version: 2, state: { entries: [encoding] } })); - expect(usePromptStashStore.getState().entries).toEqual([encoding]); - - // Storage still holds the pending entry, not a settled copy. - const store = usePromptStashStore.getState(); - store.stashEntry(makeEntry({ id: "later" })); - expect(usePromptStashStore.getState().entries[1]).toEqual(encoding); - - const image = { - id: "img-encoded", - name: "encoded.webp", - mimeType: "image/webp", - sizeBytes: 12, - dataUrl: "data:image/webp;base64,BBBB", - }; - expect( - store.finalizeEntryImages("encoding", { + await writePromptStashStorageForTest( + JSON.stringify({ version: 2, state: { entries: [encoding] } }), + ); + try { + expect(usePromptStashStore.getState().entries).toEqual([encoding]); + + // Storage still holds the pending entry, not a settled copy. + const store = usePromptStashStore.getState(); + store.stashEntry(makeEntry({ id: "later" })); + expect(usePromptStashStore.getState().entries[1]).toEqual(encoding); + + const image = { + id: "img-encoded", + name: "encoded.webp", + mimeType: "image/webp", + sizeBytes: 12, + dataUrl: "data:image/webp;base64,BBBB", + }; + expect( + store.finalizeEntryImages("encoding", { + attachments: [image], + droppedImageNames: [], + unreadableImageNames: [], + }).attached, + ).toBe(true); + expect(store.takeEntry("encoding").entry).toMatchObject({ attachments: [image], - droppedImageNames: [], unreadableImageNames: [], - }).attached, - ).toBe(true); - expect(store.takeEntry("encoding").entry).toMatchObject({ - attachments: [image], - unreadableImageNames: [], - pendingImageCount: 0, - }); + pendingImageCount: 0, + }); + } finally { + vi.unstubAllGlobals(); + } }); it("keeps images storage rejected for this session and settles the stored entry", async () => { diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index be59176d9fcd..a127ae345a6c 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -67,42 +67,55 @@ function releaseEncodeLock(entryId: string): void { */ const PENDING_IMAGE_ENCODE_WINDOW_MS = 15 * 60 * 1000; -async function orphanedPendingEntryIds( - entries: ReadonlyArray, -): Promise> { - const pending = entries.filter( - (entry) => entry.pendingImageCount && !encodeLockReleases.has(entry.id), +function orphanedPendingEntryIds(entries: ReadonlyArray): ReadonlySet { + const now = Date.now(); + return new Set( + entries + .filter((entry) => { + if (!entry.pendingImageCount) return false; + const startedAt = Date.parse(entry.createdAt); + return Number.isNaN(startedAt) || now - startedAt > PENDING_IMAGE_ENCODE_WINDOW_MS; + }) + .map((entry) => entry.id), ); - if (pending.length === 0) return new Set(); - const snapshot = await encodeLocks() - ?.query() - .catch(() => undefined); - if (!snapshot) { - const now = Date.now(); - return new Set( - pending - .filter((entry) => { - const startedAt = Date.parse(entry.createdAt); - return Number.isNaN(startedAt) || now - startedAt > PENDING_IMAGE_ENCODE_WINDOW_MS; - }) - .map((entry) => entry.id), - ); +} + +/** Runs `critical` holding every named lock, or not at all when anyone holds or awaits one. */ +function withEveryLockIfAvailable( + locks: LockManager, + names: ReadonlyArray, + critical: () => void, +): Promise { + const [name, ...rest] = names; + if (name === undefined) { + critical(); + return Promise.resolve(); } - const owned = new Set( - [...(snapshot.held ?? []), ...(snapshot.pending ?? [])].map((lock) => lock.name), - ); - return new Set( - pending.filter((entry) => !owned.has(encodeLockName(entry.id))).map((entry) => entry.id), + return locks.request(name, { ifAvailable: true }, (lock) => + lock ? withEveryLockIfAvailable(locks, rest, critical) : undefined, ); } /** - * Only the stashing tab's encode clears `pendingImageCount`, so an entry whose encode lock - * nobody holds would show "saving…" forever. Settles those against a fresh read, since - * another tab may have written while the lock query was in flight. + * Only the stashing tab's encode clears `pendingImageCount`, so an entry nobody encodes would + * show "saving…" forever. Settling holds every pending entry's encode lock, which its encoder + * frees only after finalizing, so no finalize lands between this read and write. */ -async function settleOrphanedPendingImages(loaded: ReadonlyArray): Promise { - const orphanIds = await orphanedPendingEntryIds(loaded); +function settleOrphanedPendingImages(loaded: ReadonlyArray): Promise { + const pendingIds = loaded.filter((entry) => entry.pendingImageCount).map((entry) => entry.id); + if (pendingIds.length === 0) return Promise.resolve(); + const locks = encodeLocks(); + if (!locks) { + settlePendingImages(orphanedPendingEntryIds(loaded)); + return Promise.resolve(); + } + // Any live encoder, this tab's included, defers settling to a later load. + return withEveryLockIfAvailable(locks, pendingIds.map(encodeLockName), () => + settlePendingImages(new Set(pendingIds)), + ).catch(() => {}); +} + +function settlePendingImages(orphanIds: ReadonlySet): void { if (orphanIds.size === 0) return; const latest = readPersistedEntries(); if (!latest) return; @@ -288,7 +301,7 @@ export const usePromptStashStore = create()((set, get) => stashEntry: (entry) => { const nextEntries = [entry, ...latestEntries(get().entries)]; const evicted = nextEntries.length > MAX_STASH_ENTRIES ? (nextEntries.pop() ?? null) : null; - // Requested before the write, so a tab that reads the pending entry can see its owner. + // Requested before the write, so the owner is normally registered before another tab reads the entry. if (entry.pendingImageCount) holdEncodeLock(entry.id); const { written, durable } = persistEntries(nextEntries); // A rejected write must not leave the entry visible either: the caller From 6394439318df11cfc0b6df7f892db5eab2af4e7f Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 00:29:34 +0000 Subject: [PATCH 6/8] fix(web): settling stashed images no longer overwrites an entry stashed mid-settle Settlement took encode locks only for the entries pending when the tab loaded. An entry another tab stashed while those locks were being taken showed up in settlement's re-read without its lock, and a finalize landing between that re-read and the write was overwritten (images lost, entry stuck "saving"). Settlement now writes only while it holds the encode lock of every entry pending in its final storage read. If the re-read under the locks shows a pending entry it does not hold, it releases everything and retries with that entry included, up to three attempts; a held lock or exhausted attempts mean no write this load. The lockless fallback is unchanged. Residual: the final re-read and persist run as one synchronous block, the pattern every write in this module uses. A real finalize always follows at least one awaited encode step while its encoder holds the lock, so it cannot land inside that block. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/promptStashStore.test.ts | 49 +++++++++++++++++++- apps/web/src/promptStashStore.ts | 64 ++++++++++++++++++--------- 2 files changed, 90 insertions(+), 23 deletions(-) diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index 67b1954713fc..f3d173032bcf 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -398,7 +398,7 @@ describe("promptStashStore", () => { let finalized: { attached: boolean } | undefined; const settling = loadingTab.writePromptStashStorageForTest(stashed); - // The encode finishes at settlement's next storage read, as a concurrent tab's can. + // Fires the encode after settlement has returned, as a finishing tab's finalize can. shared.afterNextRead = () => { finalized = encodingTab.getState().finalizeEntryImages("encoding", { attachments: [encodedImage], @@ -435,6 +435,53 @@ describe("promptStashStore", () => { } }); + it("settling keeps images of an entry stashed while it acquired locks", async () => { + const shared = stubSharedStorage(); + stubLockManager(); + try { + const encodingTab = (await importTab()).usePromptStashStore; + const loadingTab = await importTab(); + const orphan = { ...makeEntry({ id: "orphan" }), pendingImageCount: 1 }; + + const settling = loadingTab.writePromptStashStorageForTest( + JSON.stringify({ version: 2, state: { entries: [orphan] } }), + ); + encodingTab.getState().stashEntry({ ...makeEntry({ id: "late" }), pendingImageCount: 1 }); + let finalized: { attached: boolean } | undefined; + shared.afterNextRead = () => { + finalized = encodingTab.getState().finalizeEntryImages("late", { + attachments: [encodedImage], + droppedImageNames: [], + unreadableImageNames: [], + }); + }; + await settling; + + expect(finalized?.attached).toBe(true); + expect(JSON.parse(shared.stored.get(PROMPT_STASH_STORAGE_KEY) ?? "null")).toMatchObject({ + state: { + entries: [ + { id: "late", attachments: [encodedImage], pendingImageCount: 0 }, + { id: "orphan" }, + ], + }, + }); + expect(loadingTab.usePromptStashStore.getState().takeEntry("late").entry).toMatchObject({ + attachments: [encodedImage], + pendingImageCount: 0, + }); + + await loadingTab.writePromptStashStorageForTest( + shared.stored.get(PROMPT_STASH_STORAGE_KEY) ?? "", + ); + expect(loadingTab.usePromptStashStore.getState().entries).toMatchObject([ + { id: "orphan", pendingImageCount: 0, unreadableImageNames: [expect.any(String)] }, + ]); + } finally { + vi.unstubAllGlobals(); + } + }); + it("keeps the pending count of an entry another tab may still be encoding", async () => { // Without Web Locks the age window decides; a far-future createdAt is inside it, clock unread. vi.stubGlobal("navigator", {}); diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index a127ae345a6c..47269665d5c3 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -81,44 +81,64 @@ function orphanedPendingEntryIds(entries: ReadonlyArray): Read } /** Runs `critical` holding every named lock, or not at all when anyone holds or awaits one. */ -function withEveryLockIfAvailable( +function withEveryLockIfAvailable( locks: LockManager, names: ReadonlyArray, - critical: () => void, -): Promise { + critical: () => T, +): Promise { const [name, ...rest] = names; - if (name === undefined) { - critical(); - return Promise.resolve(); - } + if (name === undefined) return Promise.resolve(critical()); return locks.request(name, { ifAvailable: true }, (lock) => lock ? withEveryLockIfAvailable(locks, rest, critical) : undefined, ); } +const pendingEntryIds = (entries: ReadonlyArray) => + new Set(entries.filter((entry) => entry.pendingImageCount).map((entry) => entry.id)); + +const SETTLE_LOCK_ATTEMPTS = 3; + /** * Only the stashing tab's encode clears `pendingImageCount`, so an entry nobody encodes would - * show "saving…" forever. Settling holds every pending entry's encode lock, which its encoder - * frees only after finalizing, so no finalize lands between this read and write. + * show "saving…" forever. Settling writes only while holding the encode lock of every entry + * pending in its final read, and an encoder frees its lock only after finalizing. */ -function settleOrphanedPendingImages(loaded: ReadonlyArray): Promise { - const pendingIds = loaded.filter((entry) => entry.pendingImageCount).map((entry) => entry.id); - if (pendingIds.length === 0) return Promise.resolve(); +async function settleOrphanedPendingImages(loaded: ReadonlyArray) { + let pendingIds = pendingEntryIds(loaded); + if (pendingIds.size === 0) return; const locks = encodeLocks(); if (!locks) { - settlePendingImages(orphanedPendingEntryIds(loaded)); - return Promise.resolve(); + const orphanIds = orphanedPendingEntryIds(loaded); + const latest = orphanIds.size === 0 ? null : readPersistedEntries(); + if (latest) settlePendingImages(latest, orphanIds); + return; } - // Any live encoder, this tab's included, defers settling to a later load. - return withEveryLockIfAvailable(locks, pendingIds.map(encodeLockName), () => - settlePendingImages(new Set(pendingIds)), - ).catch(() => {}); + try { + for (let attempt = 0; attempt < SETTLE_LOCK_ATTEMPTS; attempt += 1) { + const heldIds = pendingIds; + // Any live encoder, this tab's included, defers settling to a later load. + const settled = await withEveryLockIfAvailable( + locks, + [...heldIds].map(encodeLockName), + () => { + const latest = readPersistedEntries(); + if (!latest) return true; + pendingIds = pendingEntryIds(latest); + // An entry stashed while this tab acquired its locks needs its own lock first. + if ([...pendingIds].some((id) => !heldIds.has(id))) return false; + settlePendingImages(latest, heldIds); + return true; + }, + ); + if (settled !== false) return; + } + } catch {} } -function settlePendingImages(orphanIds: ReadonlySet): void { - if (orphanIds.size === 0) return; - const latest = readPersistedEntries(); - if (!latest) return; +function settlePendingImages( + latest: ReadonlyArray, + orphanIds: ReadonlySet, +): void { let settledAny = false; const settled = latest.map((entry) => { if (!entry.pendingImageCount || !orphanIds.has(entry.id)) return entry; From 3bee846df581dfc1ad1a26a378d19307ce6ff260 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 05:51:13 +0000 Subject: [PATCH 7/8] fix(web): keep orphan stash settlement local to the tab --- apps/web/src/promptStashStore.test.ts | 13 ++++++- apps/web/src/promptStashStore.ts | 51 ++++++++++++++------------- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index f3d173032bcf..241c4043e346 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -369,7 +369,7 @@ describe("promptStashStore", () => { expect(JSON.parse(shared.stored.get(PROMPT_STASH_STORAGE_KEY) ?? "null")).toMatchObject({ state: { entries: [ - { id: "orphan", pendingImageCount: 0, unreadableImageNames: [expect.any(String)] }, + { id: "orphan", pendingImageCount: 1 }, { id: "encoding", attachments: [encodedImage], pendingImageCount: 0 }, ], }, @@ -377,6 +377,17 @@ describe("promptStashStore", () => { expect(tab.usePromptStashStore.getState().takeEntry("encoding").entry?.attachments).toEqual([ encodedImage, ]); + + const settling = tab.writePromptStashStorageForTest( + JSON.stringify({ version: 2, state: { entries: [orphan] } }), + ); + shared.afterNextRead = () => { + tab.usePromptStashStore.getState().stashEntry(makeEntry({ id: "new-text" })); + }; + await settling; + expect(tab.usePromptStashStore.getState().takeEntry("new-text").entry?.prompt).toBe( + "prompt new-text", + ); } finally { vi.unstubAllGlobals(); } diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index 23a300088b65..3436ce8591e3 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -93,11 +93,12 @@ const pendingEntryIds = (entries: ReadonlyArray) => new Set(entries.filter((entry) => entry.pendingImageCount).map((entry) => entry.id)); const SETTLE_LOCK_ATTEMPTS = 3; +const settledPendingEntryIds = new Set(); /** * Only the stashing tab's encode clears `pendingImageCount`, so an entry nobody encodes would - * show "saving…" forever. Settling writes only while holding the encode lock of every entry - * pending in its final read, and an encoder frees its lock only after finalizing. + * show "saving…" forever. Check ownership under encode locks, then settle locally; + * writing a startup snapshot could overwrite a prompt another tab just stashed. */ async function settleOrphanedPendingImages(loaded: ReadonlyArray) { let pendingIds = pendingEntryIds(loaded); @@ -135,26 +136,25 @@ function settlePendingImages( latest: ReadonlyArray, orphanIds: ReadonlySet, ): void { - let settledAny = false; - const settled = latest.map((entry) => { - if (!entry.pendingImageCount || !orphanIds.has(entry.id)) return entry; - settledAny = true; - const lostCount = entry.pendingImageCount; - return { - ...entry, - pendingImageCount: 0, - unreadableImageNames: [ - ...(entry.unreadableImageNames ?? []), - ...Array.from( - { length: lostCount }, - (_, index) => `image ${index + 1} (not saved before reload)`, - ), - ], - }; - }); - if (!settledAny) return; - persistEntries(settled); - usePromptStashStore.setState({ entries: visibleEntries(settled) }); + for (const entry of latest) { + if (entry.pendingImageCount && orphanIds.has(entry.id)) settledPendingEntryIds.add(entry.id); + } + usePromptStashStore.setState({ entries: visibleEntries(readPersistedEntries() ?? latest) }); +} + +function withSettledPendingImages(entry: PromptStashEntry): PromptStashEntry { + if (!entry.pendingImageCount || !settledPendingEntryIds.has(entry.id)) return entry; + return { + ...entry, + pendingImageCount: 0, + unreadableImageNames: [ + ...(entry.unreadableImageNames ?? []), + ...Array.from( + { length: entry.pendingImageCount }, + (_, index) => `image ${index + 1} (not saved before reload)`, + ), + ], + }; } export function partitionStashAttachments( @@ -228,7 +228,10 @@ function visibleEntries(entries: ReadonlyArray): ReadonlyArray for (const entryId of unsavedImagesByEntryId.keys()) { if (!entries.some((entry) => entry.id === entryId)) unsavedImagesByEntryId.delete(entryId); } - return unsavedImagesByEntryId.size === 0 ? entries : entries.map(withUnsavedImages); + for (const entryId of settledPendingEntryIds) { + if (!entries.some((entry) => entry.id === entryId)) settledPendingEntryIds.delete(entryId); + } + return entries.map((entry) => withUnsavedImages(withSettledPendingImages(entry))); } // Clear the composer only after a durable write; debounce cannot confirm durability. @@ -272,7 +275,7 @@ function readPersistedEntries(): ReadonlyArray | null { * entry another tab stashed since this tab last read. */ function latestEntries(current: ReadonlyArray): ReadonlyArray { - return readPersistedEntries() ?? current; + return visibleEntries(readPersistedEntries() ?? current); } interface PromptStashStoreState { From 709781310348dc74b9dea9e647038600c1167b99 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 09:43:12 +0000 Subject: [PATCH 8/8] test(web): cover the prompt stash cross-tab storage listener A matching key or a null key (another tab's clear()) refreshes the list; an unrelated key leaves it alone. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/promptStashStore.test.ts | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index 241c4043e346..f27414d96dec 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -199,6 +199,34 @@ describe("promptStashStore", () => { ]); }); + it("refreshes on another tab's stash write or clear, ignoring other keys", async () => { + const shared = stubSharedStorage(); + const tabWindow = new EventTarget(); + vi.stubGlobal("window", tabWindow); + try { + const tab = (await importTab()).usePromptStashStore; + const dispatchStorage = (key: string | null) => + tabWindow.dispatchEvent(Object.assign(new Event("storage"), { key })); + shared.stored.set( + PROMPT_STASH_STORAGE_KEY, + JSON.stringify({ version: 2, state: { entries: [makeEntry({ id: "other-tab" })] } }), + ); + + dispatchStorage("t3code:unrelated"); + expect(tab.getState().entries).toEqual([]); + + dispatchStorage(PROMPT_STASH_STORAGE_KEY); + expect(tab.getState().entries.map((entry) => entry.id)).toEqual(["other-tab"]); + + // localStorage.clear() in another tab reports a null key. + shared.stored.clear(); + dispatchStorage(null); + expect(tab.getState().entries).toEqual([]); + } finally { + vi.unstubAllGlobals(); + } + }); + // regression: #4787 stashed prompts were lost when switching providers it("distinguishes a memory-only write (written, not durable) from a failed one", () => { const store = usePromptStashStore.getState();