diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index fa9a75dd323..5575916dd9a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -57,10 +57,8 @@ import { useEffectiveComposerModelState, } from "../../composerDraftStore"; import { - EMPTY_PROMPT_STASH_QUEUE, - MAX_STASH_ENTRIES_PER_QUEUE, + MAX_STASH_ENTRIES, partitionStashAttachments, - promptStashScopeKey, usePromptStashStore, type PromptStashEntry, } from "../../promptStashStore"; @@ -733,7 +731,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const clearComposerDraftPromptAndImages = useComposerDraftStore( (store) => store.clearComposerPromptAndImages, ); - const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); @@ -1888,23 +1885,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Prompt stash (⌘S) // ------------------------------------------------------------------ - const stashScopeInstanceId = noProviderAvailable ? null : selectedInstanceId; - const stashScope = promptStashScopeKey(stashScopeInstanceId); - const stashQueue = usePromptStashStore( - (state) => state.queuesByScopeKey[stashScope] ?? EMPTY_PROMPT_STASH_QUEUE, - ); - const stashOtherScopesCount = usePromptStashStore((state) => - Object.entries(state.queuesByScopeKey).reduce( - (total, [key, queue]) => (key === stashScope ? total : total + queue.length), - 0, - ), - ); + // One global queue. Stashed prompts carry only text + images so they can be + // restored into any thread or provider — stash, switch, restore is the + // whole point. + const stashQueue = usePromptStashStore((state) => state.entries); const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry); const takeStashEntry = usePromptStashStore((state) => state.takeEntry); const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages); - const stashProviderLabel = noProviderAvailable - ? "No provider" - : getProviderDisplayName(providerStatuses, selectedProvider); useEffect(() => { return () => { @@ -1930,10 +1917,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const restoreStashEntry = useCallback( (entry: PromptStashEntry) => { // Remove first so a double activation (click + Enter) can't restore twice. - const { entry: taken, durable } = takeStashEntry( - promptStashScopeKey(entry.providerInstanceId), - entry.id, - ); + const { entry: taken, durable } = takeStashEntry(entry.id); if (!taken) return; if (!durable) { toastManager.add({ @@ -1996,21 +1980,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } } - const restorableSelection = - entry.modelSelection && - providerInstanceEntries.some( - (candidate) => - candidate.instanceId === entry.modelSelection?.instanceId && - candidate.enabled && - candidate.isAvailable, - ) - ? entry.modelSelection - : null; - if (restorableSelection) { - setComposerDraftModelSelection(composerDraftTarget, restorableSelection, { - replaceOptions: true, - }); - } + // Deliberately no model/provider restore: the stash exists to carry a + // prompt across threads and providers, so whatever the composer has + // selected right now stays selected. // Each cause gets its own sentence so "too large" is never blamed for a // file that actually failed to decode, or for one the composer simply @@ -2052,8 +2024,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerDraftTarget, composerImagesRef, promptRef, - providerInstanceEntries, - setComposerDraftModelSelection, setComposerDraftPrompt, takeStashEntry, ], @@ -2061,7 +2031,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const deleteStashEntry = useCallback( (entry: PromptStashEntry) => { - const { durable } = takeStashEntry(promptStashScopeKey(entry.providerInstanceId), entry.id); + const { durable } = takeStashEntry(entry.id); if (!durable) { toastManager.add({ type: "warning", @@ -2097,30 +2067,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const stashTarget = composerDraftTarget; const entryId = randomUUID(); - const scopeKey = promptStashScopeKey(stashScopeInstanceId); try { // Persist the text-only entry *first*, then clear. Ordering matters in // both directions: writing before clearing means a crash or closed tab // mid-encode still leaves the prompt recoverable, while clearing before // the async image work means edits typed during encoding are not wiped. // Images are appended to the stored entry as they finish encoding. - const { evicted, durable } = stashEntryToQueue({ + const { evicted, written, durable } = stashEntryToQueue({ id: entryId, createdAt: new Date().toISOString(), prompt, attachments: [], - providerInstanceId: stashScopeInstanceId, - modelSelection: noProviderAvailable ? null : selectedModelSelection, droppedImageNames: [], unreadableImageNames: [], pendingImageCount: images.length, }); - // Clearing the composer is only safe once the entry is durable. If the - // write was rejected (quota, blocked storage) the store has already - // rolled itself back, so leave the composer untouched rather than - // making it the second casualty of a reload. - if (!durable) { + // Clearing the composer is only safe once the write actually landed. + // If it was rejected (quota) the store has already rolled itself back, + // so leave the composer untouched rather than making it the second + // casualty of a reload. + if (!written) { toastManager.add({ type: "error", title: "Could not stash this prompt", @@ -2130,6 +2097,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); return; } + // Written but only into the in-memory fallback (localStorage blocked): + // the entry is visible and restorable this session, so proceed with the + // clear, but say it won't survive a reload. + if (!durable) { + toastManager.add({ + type: "warning", + title: "Stashed prompt will not survive a reload", + description: + "Browser storage is unavailable, so this stash is kept in memory only for this session.", + data: { hideCopyButton: true }, + }); + } // Only the prompt and images are cleared — terminal/element contexts, // preview annotations, and review comments are not stashable, so @@ -2144,7 +2123,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) toastManager.add({ type: "warning", title: "Oldest stashed prompt discarded", - description: `The ${stashProviderLabel} stash holds ${MAX_STASH_ENTRIES_PER_QUEUE} prompts; the oldest was removed to make room.`, + description: `The stash holds ${MAX_STASH_ENTRIES} prompts; the oldest was removed to make room.`, data: { hideCopyButton: true }, }); } @@ -2176,7 +2155,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } const { kept, droppedNames } = partitionStashAttachments(candidateAttachments); - const { attached, durable: imagesDurable } = finalizeStashEntryImages(scopeKey, entryId, { + const { attached, durable: imagesDurable } = finalizeStashEntryImages(entryId, { attachments: kept, droppedImageNames: [...oversizedImageNames, ...droppedNames], unreadableImageNames, @@ -2185,8 +2164,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // The second phase can be rejected on its own: the text-only entry // fit, but adding image payloads pushed past the quota. Disk would // then still hold the phase-one entry with pendingImageCount set, - // which reads as an orphan after reload — so say so now. - if (!imagesDurable && images.length > 0) { + // which reads as an orphan after reload — so say so now. Gated on the + // entry write having been durable: on the in-memory fallback nothing + // is ever durable, and the session-only warning already covered it. + if (!imagesDurable && durable && images.length > 0) { toastManager.add({ type: "warning", title: "Stashed images were not saved", @@ -2216,13 +2197,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerDraftTarget, composerImagesRef, finalizeStashEntryImages, - noProviderAvailable, promptRef, pulseStashBadge, - selectedModelSelection, stashEntryToQueue, - stashProviderLabel, - stashScopeInstanceId, ]); const toggleStashMenu = useCallback(() => { @@ -2832,8 +2809,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsStashMenuOpen(false)} diff --git a/apps/web/src/components/chat/ComposerStashBadge.tsx b/apps/web/src/components/chat/ComposerStashBadge.tsx index cb2d98339c4..79ed301a5d5 100644 --- a/apps/web/src/components/chat/ComposerStashBadge.tsx +++ b/apps/web/src/components/chat/ComposerStashBadge.tsx @@ -5,8 +5,7 @@ import { cn } from "~/lib/utils"; /** * Bookmark pill perched on the composer's top-right shoulder. Shows the - * current method's stash count and doubles as the click target for opening - * the stash menu. + * stash count and doubles as the click target for opening the stash menu. * * On save the badge gives one quiet acknowledgement: it lifts to full * opacity and the count ticks over. `pulseKey` changes per stash, remounting diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index 22950074307..bfc2d88ba76 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -24,16 +24,13 @@ function stashEntrySnippet(entry: PromptStashEntry): string { } /** - * Popover listing the current connection method's stashed prompts. - * Keyboard-first: opened by ⌘S on an empty composer, navigated with - * arrows, restored with Enter, dismissed with Escape. The listener runs - * capture-phase on window so it wins over the Lexical editor's handlers - * while the menu is open. + * Popover listing the stashed prompts. Keyboard-first: opened by ⌘S on an + * empty composer, navigated with arrows, restored with Enter, dismissed + * with Escape. The listener runs capture-phase on window so it wins over + * the Lexical editor's handlers while the menu is open. */ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { entries: ReadonlyArray; - providerLabel: string; - otherScopesCount: number; onRestore: (entry: PromptStashEntry) => void; onDelete: (entry: PromptStashEntry) => void; onClose: () => void; @@ -99,12 +96,11 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { {entries.length === 0 ? (

- Nothing stashed for this method yet. Press ⌘S with a prompt in the composer to stash - it. + Nothing stashed yet. Press ⌘S with a prompt in the composer to stash it.

) : ( entries.map((entry) => ( @@ -173,12 +169,6 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { )) )}
- {props.otherScopesCount > 0 ? ( -

- {props.otherScopesCount} more stashed under other connection methods — switch provider - to see them. -

- ) : null} diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index 228e1e35714..20894713d1d 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -1,26 +1,19 @@ -import { ProviderInstanceId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { removeLocalStorageItem } from "./hooks/useLocalStorage"; import { - MAX_STASH_ENTRIES_PER_QUEUE, + MAX_STASH_ENTRIES, PROMPT_STASH_STORAGE_KEY, MAX_STASH_ENTRY_ATTACHMENT_CHARS, - PROMPT_STASH_UNSCOPED_KEY, partitionStashAttachments, - promptStashScopeKey, usePromptStashStore, writePromptStashStorageForTest, type PromptStashEntry, } from "./promptStashStore"; -const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent"); -const CODEX_INSTANCE = ProviderInstanceId.make("codex"); - function makeEntry(input: { id: string; - providerInstanceId?: ProviderInstanceId | null; prompt?: string; attachmentChars?: number; }): PromptStashEntry { @@ -40,35 +33,16 @@ function makeEntry(input: { }, ] : [], - providerInstanceId: - input.providerInstanceId === undefined ? CLAUDE_AGENT_INSTANCE : input.providerInstanceId, - modelSelection: null, droppedImageNames: [], }; } function resetPromptStashStore() { - usePromptStashStore.setState({ queuesByScopeKey: {} }); + usePromptStashStore.setState({ entries: [] }); writePromptStashStorageForTest(""); removeLocalStorageItem(PROMPT_STASH_STORAGE_KEY); } -describe("promptStashScopeKey", () => { - it("maps a provider instance to its own bucket and null to the unscoped bucket", () => { - expect(promptStashScopeKey(CLAUDE_AGENT_INSTANCE)).toBe("provider:claudeAgent"); - expect(promptStashScopeKey(null)).toBe(PROMPT_STASH_UNSCOPED_KEY); - expect(promptStashScopeKey(undefined)).toBe(PROMPT_STASH_UNSCOPED_KEY); - }); - - // Provider slugs must match /^[a-zA-Z][a-zA-Z0-9_-]*$/, so no real instance - // id can equal the unscoped sentinel. The namespace prefix makes that - // structural rather than incidental. - it("namespaces provider keys so they can never equal the unscoped sentinel", () => { - expect(promptStashScopeKey(CLAUDE_AGENT_INSTANCE)).not.toBe(PROMPT_STASH_UNSCOPED_KEY); - expect(promptStashScopeKey(CODEX_INSTANCE).startsWith("provider:")).toBe(true); - }); -}); - describe("partitionStashAttachments", () => { it("keeps attachments within the budget and reports dropped names in order", () => { const small = { @@ -124,74 +98,50 @@ describe("promptStashStore", () => { const store = usePromptStashStore.getState(); store.stashEntry(makeEntry({ id: "first" })); store.stashEntry(makeEntry({ id: "second" })); - const queue = - usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ?? - []; - expect(queue.map((entry) => entry.id)).toEqual(["second", "first"]); - }); - - it("scopes queues by provider instance, including the unscoped bucket", () => { - const store = usePromptStashStore.getState(); - store.stashEntry(makeEntry({ id: "claude" })); - store.stashEntry(makeEntry({ id: "codex", providerInstanceId: CODEX_INSTANCE })); - store.stashEntry(makeEntry({ id: "none", providerInstanceId: null })); - const queues = usePromptStashStore.getState().queuesByScopeKey; - expect(queues[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)]?.map((entry) => entry.id)).toEqual([ - "claude", - ]); - expect(queues[promptStashScopeKey(CODEX_INSTANCE)]?.map((entry) => entry.id)).toEqual([ - "codex", - ]); - expect(queues[PROMPT_STASH_UNSCOPED_KEY]?.map((entry) => entry.id)).toEqual(["none"]); + const entries = usePromptStashStore.getState().entries; + expect(entries.map((entry) => entry.id)).toEqual(["second", "first"]); }); - it("evicts the oldest entry past the per-queue cap and returns it", () => { + it("evicts the oldest entry past the cap and returns it", () => { const store = usePromptStashStore.getState(); - for (let index = 0; index < MAX_STASH_ENTRIES_PER_QUEUE; index += 1) { + for (let index = 0; index < MAX_STASH_ENTRIES; index += 1) { expect(store.stashEntry(makeEntry({ id: `entry-${index}` })).evicted).toBeNull(); } const { evicted } = store.stashEntry(makeEntry({ id: "overflow" })); expect(evicted?.id).toBe("entry-0"); - const queue = - usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ?? - []; - expect(queue).toHaveLength(MAX_STASH_ENTRIES_PER_QUEUE); - expect(queue[0]?.id).toBe("overflow"); + const entries = usePromptStashStore.getState().entries; + expect(entries).toHaveLength(MAX_STASH_ENTRIES); + expect(entries[0]?.id).toBe("overflow"); }); - it("takeEntry removes and returns the entry; second take returns null", () => { + // This test environment has no `localStorage`, so the store runs on its + // in-memory fallback — the exact "kept for this session, gone on reload" + // case the composer must distinguish from an outright write failure. + it("distinguishes a memory-only write (written, not durable) from a failed one", () => { const store = usePromptStashStore.getState(); - store.stashEntry(makeEntry({ id: "keep" })); - store.stashEntry(makeEntry({ id: "take" })); - expect(store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "take").entry?.id).toBe( - "take", - ); - expect(store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "take").entry).toBeNull(); - const queue = - usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ?? - []; - expect(queue.map((entry) => entry.id)).toEqual(["keep"]); + const result = store.stashEntry(makeEntry({ id: "memory-only" })); + expect(result.written).toBe(true); + expect(result.durable).toBe(false); + expect(usePromptStashStore.getState().entries.map((entry) => entry.id)).toEqual([ + "memory-only", + ]); }); - // Queue keys are persisted as plain strings, so a hand-edited or corrupted - // localStorage payload can carry a literal `__proto__` key that survives - // JSON.parse as an own property. An unguarded lookup would resolve to - // Object.prototype and throw "not iterable" on spread. - it("tolerates a __proto__ scope key rehydrated from storage", () => { - usePromptStashStore.setState({ - queuesByScopeKey: JSON.parse('{"__proto__":[]}') as Record, - }); + it("takeEntry removes and returns the entry; second take returns null", () => { const store = usePromptStashStore.getState(); - expect(() => store.takeEntry("__proto__", "missing")).not.toThrow(); - expect(store.takeEntry("__proto__", "missing").entry).toBeNull(); + store.stashEntry(makeEntry({ id: "keep" })); + store.stashEntry(makeEntry({ id: "take" })); + expect(store.takeEntry("take").entry?.id).toBe("take"); + expect(store.takeEntry("take").entry).toBeNull(); + const entries = usePromptStashStore.getState().entries; + expect(entries.map((entry) => entry.id)).toEqual(["keep"]); }); it("finalizeEntryImages attaches images and clears the pending count", () => { const store = usePromptStashStore.getState(); - const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); store.stashEntry({ ...makeEntry({ id: "pending" }), pendingImageCount: 2 }); - const { attached } = store.finalizeEntryImages(scopeKey, "pending", { + const { attached } = store.finalizeEntryImages("pending", { attachments: [ { id: "img-1", @@ -206,7 +156,7 @@ describe("promptStashStore", () => { }); expect(attached).toBe(true); - const entry = usePromptStashStore.getState().queuesByScopeKey[scopeKey]?.[0]; + const entry = usePromptStashStore.getState().entries[0]; expect(entry?.attachments).toHaveLength(1); expect(entry?.droppedImageNames).toEqual(["big.png"]); expect(entry?.pendingImageCount).toBe(0); @@ -214,12 +164,11 @@ describe("promptStashStore", () => { it("finalizeEntryImages reports false when the entry was already taken", () => { const store = usePromptStashStore.getState(); - const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 }); // Restored (or deleted) while its images were still encoding. - store.takeEntry(scopeKey, "racing"); + store.takeEntry("racing"); - const { attached } = store.finalizeEntryImages(scopeKey, "racing", { + const { attached } = store.finalizeEntryImages("racing", { attachments: [], droppedImageNames: [], unreadableImageNames: [], @@ -229,31 +178,31 @@ describe("promptStashStore", () => { }); it("settles a pending count left behind by a crashed or closed session", () => { - const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); writePromptStashStorageForTest( JSON.stringify({ - version: 1, + version: 2, state: { - queuesByScopeKey: { - [scopeKey]: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }], - }, + entries: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }], }, }), ); // Hydration must settle the stale count, or the entry would stay stuck // showing "saving…" with images that no longer exist anywhere. - const entry = usePromptStashStore.getState().queuesByScopeKey[scopeKey]?.[0]; + const entry = usePromptStashStore.getState().entries[0]; expect(entry?.pendingImageCount).toBe(0); expect(entry?.unreadableImageNames).toHaveLength(2); }); - it("drops the scope key entirely when its queue empties", () => { - const store = usePromptStashStore.getState(); - store.stashEntry(makeEntry({ id: "only" })); - store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "only"); - expect( - usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)], - ).toBeUndefined(); + it("ignores an unreadable v1 payload seeded under the current key", () => { + // The v1 shape (per-provider queues) does not decode as v2; hydration + // must fall back to an empty stash rather than throw. + writePromptStashStorageForTest( + JSON.stringify({ + version: 1, + state: { queuesByScopeKey: { "provider:claudeAgent": [] } }, + }), + ); + expect(usePromptStashStore.getState().entries).toEqual([]); }); }); diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index e4340935b77..d7c541e7a94 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -1,24 +1,20 @@ -import { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { create } from "zustand"; import { PersistedComposerImageAttachment } from "./composerDraftStore"; import { createMemoryStorage, type StateStorage } from "./lib/storage"; -export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1"; -const PROMPT_STASH_STORAGE_VERSION = 1; - +export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v2"; /** - * Queue bucket for prompts stashed while no provider instance is selected. - * - * Provider-scoped keys are prefixed (see `promptStashScopeKey`), so this - * sentinel can never collide with a provider literally named `__none__`. + * v1 bucketed entries into per-provider-instance queues and stored a model + * selection with each prompt. The stash is provider-agnostic now, so the old + * payload is deleted at startup rather than migrated — left behind it would + * silently hold megabytes of the origin's ~5MB localStorage quota forever. */ -export const PROMPT_STASH_UNSCOPED_KEY = "__none__"; -/** Namespace applied to provider-derived keys to keep them collision-proof. */ -const PROVIDER_SCOPE_PREFIX = "provider:"; +const LEGACY_PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1"; +const PROMPT_STASH_STORAGE_VERSION = 2; -export const MAX_STASH_ENTRIES_PER_QUEUE = 20; +export const MAX_STASH_ENTRIES = 20; /** * Budget for an entry's serialized attachment payload. localStorage is a * ~5MB origin-wide quota shared with the composer draft store, so oversized @@ -30,13 +26,17 @@ export const MAX_STASH_ENTRIES_PER_QUEUE = 20; */ export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000; +/** + * A stashed prompt carries only what every provider can accept: text and + * image attachments. Deliberately no provider instance or model selection — + * the point of stashing is to move a prompt into a different thread or + * provider, so restoring must never drag the old model choice along. + */ const StashEntrySchema = Schema.Struct({ id: Schema.String, createdAt: Schema.String, prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), - providerInstanceId: Schema.NullOr(ProviderInstanceId), - modelSelection: Schema.NullOr(ModelSelection), /** Names of images that exceeded the attachment budget and were not saved. */ droppedImageNames: Schema.Array(Schema.String), /** @@ -57,7 +57,7 @@ const StashEntrySchema = Schema.Struct({ export type PromptStashEntry = typeof StashEntrySchema.Type; const PersistedPromptStashState = Schema.Struct({ - queuesByScopeKey: Schema.Record(Schema.String, Schema.Array(StashEntrySchema)), + entries: Schema.Array(StashEntrySchema), }); type PersistedPromptStashState = typeof PersistedPromptStashState.Type; @@ -74,44 +74,23 @@ const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPrompt * recorded as unreadable to keep the prompt itself restorable. */ function clearOrphanedPendingImages( - queues: Record>, -): Record> { - const next: Record> = {}; - for (const [scopeKey, queue] of Object.entries(queues)) { - next[scopeKey] = queue.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)`, - ), - ], - }; - }); - } - return next; -} - -/** Maps the composer's active provider instance to a stash queue bucket. */ -export function promptStashScopeKey(instanceId: ProviderInstanceId | null | undefined): string { - return instanceId ? `${PROVIDER_SCOPE_PREFIX}${instanceId}` : PROMPT_STASH_UNSCOPED_KEY; -} - -/** - * Reads a queue without inheriting from `Object.prototype`. Scope keys derive - * from user-authored provider slugs, so a key like `__proto__` must not - * resolve to the prototype chain. - */ -function readQueue( - queues: Record>, - scopeKey: string, + entries: ReadonlyArray, ): ReadonlyArray { - return Object.hasOwn(queues, scopeKey) ? (queues[scopeKey] ?? []) : []; + 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)`, + ), + ], + }; + }); } /** @@ -163,7 +142,7 @@ function resolveBaseStorage(): { storage: StateStorage; durable: boolean } { const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStorage(); /** - * Persists the queues, immediately rather than debounced. Stashing is a + * Persists the queue, immediately rather than debounced. Stashing is a * deliberate, infrequent keystroke — not a per-character autosave — so there * is nothing to coalesce, and the caller clears the composer on the strength * of this write landing, which a debounce timer cannot honestly report. @@ -171,7 +150,7 @@ const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStor * Returns whether the write will survive a reload: false on a quota rejection * or when only the in-memory fallback is available. */ -function persistQueues(queues: Record>): { +function persistEntries(entries: ReadonlyArray): { /** The write succeeded (possibly only into the in-memory fallback). */ written: boolean; /** The write will survive a reload. */ @@ -182,7 +161,7 @@ function persistQueues(queues: Record>): PROMPT_STASH_STORAGE_KEY, JSON.stringify({ version: PROMPT_STASH_STORAGE_VERSION, - state: { queuesByScopeKey: queues }, + state: { entries }, }), ); return { written: true, durable: storageIsDurable }; @@ -192,40 +171,42 @@ function persistQueues(queues: Record>): } } -/** Reads the persisted queues, settling stale pending counts. */ -function readPersistedQueues(): Record> | null { +/** Reads the persisted queue, settling stale pending counts. */ +function readPersistedEntries(): ReadonlyArray | null { try { const raw = baseStashStorage.getItem(PROMPT_STASH_STORAGE_KEY); if (typeof raw !== "string" || raw.length === 0) return null; const parsed: unknown = JSON.parse(raw); const state = (parsed as { state?: unknown } | null)?.state; if (!state) return null; - return clearOrphanedPendingImages(decodePersistedPromptStashState(state).queuesByScopeKey); + return clearOrphanedPendingImages(decodePersistedPromptStashState(state).entries); } catch { return null; } } interface PromptStashStoreState { - queuesByScopeKey: Record>; + entries: ReadonlyArray; /** - * Prepends an entry to its scope's queue, evicting the oldest entry past - * the per-queue cap. Returns the evicted entry (for messaging) if any. + * Prepends an entry to the queue, evicting the oldest entry past the cap. + * Returns the evicted entry (for messaging) if any. */ stashEntry: (entry: PromptStashEntry) => { evicted: PromptStashEntry | null; - /** False when the write did not reach durable storage; nothing was kept. */ + /** False when the write failed outright (e.g. quota); nothing was kept. */ + written: boolean; + /** + * False when the write will not survive a reload: either it failed, or it + * landed only in the in-memory fallback because localStorage is blocked. + */ durable: boolean; }; /** - * Removes and returns an entry from a scope's queue (restore + delete). + * Removes and returns an entry from the queue (restore + delete). * `durable` is false when the removal could not be persisted, meaning a * reload would resurrect the entry. */ - takeEntry: ( - scopeKey: string, - entryId: string, - ) => { entry: PromptStashEntry | null; durable: boolean }; + takeEntry: (entryId: string) => { entry: PromptStashEntry | null; durable: boolean }; /** * Attaches the encoded images to an entry written earlier by `stashEntry`, * clearing its pending count. Returns attached=false when the entry is gone @@ -233,7 +214,6 @@ interface PromptStashStoreState { * tell the user their images did not make it. */ finalizeEntryImages: ( - scopeKey: string, entryId: string, images: { attachments: ReadonlyArray; @@ -244,58 +224,45 @@ interface PromptStashStoreState { } export const usePromptStashStore = create()((set, get) => ({ - queuesByScopeKey: {}, + entries: [], stashEntry: (entry) => { - const scopeKey = promptStashScopeKey(entry.providerInstanceId); - const queues = get().queuesByScopeKey; - const nextQueue = [entry, ...readQueue(queues, scopeKey)]; - const evicted = - nextQueue.length > MAX_STASH_ENTRIES_PER_QUEUE ? (nextQueue.pop() ?? null) : null; - const next = { ...queues, [scopeKey]: nextQueue }; - const { written, durable } = persistQueues(next); + const nextEntries = [entry, ...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 // keeps the composer intact on failure, so a stashed copy would // duplicate the prompt. Eviction likewise only sticks on success. if (!written) { - return { evicted: null, durable: false }; + return { evicted: null, written: false, durable: false }; } - set(() => ({ queuesByScopeKey: next })); - return { evicted, durable }; + set(() => ({ entries: nextEntries })); + return { evicted, written: true, durable }; }, - takeEntry: (scopeKey, entryId) => { - const queues = get().queuesByScopeKey; - const queue = readQueue(queues, scopeKey); - const entry = queue.find((candidate) => candidate.id === entryId) ?? null; + takeEntry: (entryId) => { + const entries = get().entries; + const entry = entries.find((candidate) => candidate.id === entryId) ?? null; if (!entry) return { entry: null, durable: true }; - const nextQueue = queue.filter((candidate) => candidate.id !== entryId); - const next = { ...queues }; - if (nextQueue.length === 0) { - delete next[scopeKey]; - } else { - next[scopeKey] = nextQueue; - } - const { durable } = persistQueues(next); - set(() => ({ queuesByScopeKey: next })); + const nextEntries = entries.filter((candidate) => candidate.id !== entryId); + const { durable } = persistEntries(nextEntries); + set(() => ({ entries: nextEntries })); return { entry, durable }; }, - finalizeEntryImages: (scopeKey, entryId, images) => { - const queues = get().queuesByScopeKey; - const queue = readQueue(queues, scopeKey); - const index = queue.findIndex((candidate) => candidate.id === entryId); - const existing = index === -1 ? undefined : queue[index]; + finalizeEntryImages: (entryId, images) => { + const entries = get().entries; + const index = entries.findIndex((candidate) => candidate.id === entryId); + const existing = index === -1 ? undefined : entries[index]; // Restored or deleted mid-encode: nothing to attach to. if (!existing) return { attached: false, durable: true }; - const nextQueue = [...queue]; - nextQueue[index] = { + const nextEntries = [...entries]; + nextEntries[index] = { ...existing, attachments: images.attachments, droppedImageNames: images.droppedImageNames, unreadableImageNames: images.unreadableImageNames, pendingImageCount: 0, }; - const next = { ...queues, [scopeKey]: nextQueue }; - const { durable } = persistQueues(next); - set(() => ({ queuesByScopeKey: next })); + const { durable } = persistEntries(nextEntries); + set(() => ({ entries: nextEntries })); return { attached: true, durable }; }, })); @@ -303,14 +270,18 @@ export const usePromptStashStore = create()((set, get) => // Hydrate once at startup. Like the app's other persisted stores, tabs are // last-write-wins: no cross-tab merging or storage-event syncing. { - const persisted = readPersistedQueues(); + try { + baseStashStorage.removeItem(LEGACY_PROMPT_STASH_STORAGE_KEY); + } catch { + // Purging the v1 payload is best-effort; a storage policy that rejects + // the delete must not take down module init. + } + const persisted = readPersistedEntries(); if (persisted) { - usePromptStashStore.setState({ queuesByScopeKey: persisted }); + usePromptStashStore.setState({ entries: persisted }); } } -export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray = []; - /** * Test seam: seeds the persisted payload through the same storage the store * reads and rehydrates, without needing a real `localStorage` global. @@ -318,5 +289,5 @@ export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray = []; */ export function writePromptStashStorageForTest(raw: string): void { baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw); - usePromptStashStore.setState({ queuesByScopeKey: readPersistedQueues() ?? {} }); + usePromptStashStore.setState({ entries: readPersistedEntries() ?? [] }); }