diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index 0b6bcf79a4fa..f27414d96dec 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, @@ -42,6 +42,61 @@ 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(); + type Granted = (lock: { name: string } | null) => unknown; + vi.stubGlobal("navigator", { + locks: { + 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({ name })) + .finally(() => held.delete(name)); + }, + }, + }); +} + +async function importTab() { + vi.resetModules(); + return await import("./promptStashStore"); +} + function resetPromptStashStore() { usePromptStashStore.setState({ entries: [] }); writePromptStashStorageForTest(""); @@ -126,6 +181,52 @@ 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", + ]); + }); + + 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(); @@ -212,19 +313,329 @@ describe("promptStashStore", () => { expect(attached).toBe(false); }); - it("settles a pending count left behind by a crashed or closed session", () => { - writePromptStashStorageForTest( - JSON.stringify({ - version: 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); + + usePromptStashStore.getState().stashEntry(makeEntry({ id: "later" })); + expect(usePromptStashStore.getState().entries[1]).toEqual(entry); + } finally { + vi.unstubAllGlobals(); + } + }); + + 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: 1 }, + { id: "encoding", attachments: [encodedImage], pendingImageCount: 0 }, + ], + }, + }); + 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(); + } + }); + + 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); + // Fires the encode after settlement has returned, as a finishing tab's finalize 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: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }], + 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("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", {}); + const encoding = { + ...makeEntry({ id: "encoding" }), + createdAt: "9999-01-01T00:00:00.000Z", + pendingImageCount: 1, + }; + 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], + unreadableImageNames: [], + pendingImageCount: 0, + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + 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: [], + }; - const entry = usePromptStashStore.getState().entries[0]; - expect(entry?.pendingImageCount).toBe(0); - expect(entry?.unreadableImageNames).toHaveLength(2); + 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", () => { diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index 099353cad284..3436ce8591e3 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -36,25 +36,125 @@ type PersistedPromptStashState = typeof PersistedPromptStashState.Type; const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPromptStashState); -// Encoders cannot survive reload; clear orphaned counts so entries remain restorable. -function clearOrphanedPendingImages( - entries: ReadonlyArray, -): ReadonlyArray { - return entries.map((entry) => { - if (!entry.pendingImageCount) return entry; - const lostCount = entry.pendingImageCount; - return { - ...entry, - pendingImageCount: 0, - unreadableImageNames: [ - ...(entry.unreadableImageNames ?? []), - ...Array.from( - { length: lostCount }, - (_, index) => `image ${index + 1} (not saved before reload)`, - ), - ], - }; - }); +/** 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); +} + +/** + * 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; + +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), + ); +} + +/** Runs `critical` holding every named lock, or not at all when anyone holds or awaits one. */ +function withEveryLockIfAvailable( + locks: LockManager, + names: ReadonlyArray, + critical: () => T, +): Promise { + const [name, ...rest] = names; + 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; +const settledPendingEntryIds = new Set(); + +/** + * Only the stashing tab's encode clears `pendingImageCount`, so an entry nobody encodes would + * 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); + if (pendingIds.size === 0) return; + const locks = encodeLocks(); + if (!locks) { + const orphanIds = orphanedPendingEntryIds(loaded); + const latest = orphanIds.size === 0 ? null : readPersistedEntries(); + if (latest) settlePendingImages(latest, orphanIds); + return; + } + 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( + latest: ReadonlyArray, + orphanIds: ReadonlySet, +): void { + 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( @@ -89,6 +189,51 @@ 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); + } + 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. function persistEntries(entries: ReadonlyArray): { written: boolean; @@ -99,7 +244,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 }; @@ -116,12 +263,21 @@ 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 visibleEntries(readPersistedEntries() ?? current); +} + interface PromptStashStoreState { entries: ReadonlyArray; stashEntry: (entry: PromptStashEntry) => { @@ -136,38 +292,39 @@ interface PromptStashStoreState { /** attached=false means the entry disappeared while encoding, so images could not be saved. */ finalizeEntryImages: ( entryId: string, - images: { - attachments: ReadonlyArray; - droppedImageNames: ReadonlyArray; - unreadableImageNames: ReadonlyArray; - }, + images: StashEntryImages, ) => { attached: boolean; durable: boolean }; } 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; + // 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); // Failed writes must not duplicate the intact composer; eviction also commits only on success. if (!written) { + releaseEncodeLock(entry.id); return { evicted: null, written: false, durable: false }; } - set(() => ({ entries: nextEntries })); + set(() => ({ entries: visibleEntries(nextEntries) })); return { evicted, written: true, durable }; }, takeEntry: (entryId) => { - const entries = get().entries; - const entry = entries.find((candidate) => candidate.id === entryId) ?? null; - if (!entry) return { entry: null, durable: true }; + const entries = latestEntries(get().entries); + 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) => { - const entries = get().entries; + releaseEncodeLock(entryId); + 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 }; @@ -179,23 +336,44 @@ 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 }; }, })); +/** 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 = readPersistedEntries(); - 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) => { + if (event.key !== PROMPT_STASH_STORAGE_KEY && event.key !== null) return; + usePromptStashStore.setState({ entries: visibleEntries(readPersistedEntries() ?? []) }); + }); } } -export function writePromptStashStorageForTest(raw: string): void { +export function writePromptStashStorageForTest(raw: string): Promise { baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw); - usePromptStashStore.setState({ entries: readPersistedEntries() ?? [] }); + return loadPersistedEntries(); }