From 391927026539d42e39ed1f435ccb2b7c3c08b53a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 28 Jul 2026 14:45:03 -0700 Subject: [PATCH 1/4] fix(web): stashed prompts now survive switching providers The prompt stash was bucketed per provider instance and re-applied the stashed model selection on restore, so stashing, switching providers, and restoring silently hid the stash and dragged the old model back. The stash is now one global queue carrying only text + images; restore leaves the current provider/model selection untouched. Legacy v1 payloads are purged at startup. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/chat/ChatComposer.tsx | 63 ++----- .../components/chat/ComposerStashBadge.tsx | 3 +- .../src/components/chat/ComposerStashMenu.tsx | 22 +-- apps/web/src/promptStashStore.test.ts | 124 ++++--------- apps/web/src/promptStashStore.ts | 169 +++++++----------- 5 files changed, 114 insertions(+), 267 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index fa9a75dd323..ae61c300028 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,7 +2067,6 @@ 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 @@ -2109,8 +2078,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) createdAt: new Date().toISOString(), prompt, attachments: [], - providerInstanceId: stashScopeInstanceId, - modelSelection: noProviderAvailable ? null : selectedModelSelection, droppedImageNames: [], unreadableImageNames: [], pendingImageCount: images.length, @@ -2144,7 +2111,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 +2143,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, @@ -2216,13 +2183,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerDraftTarget, composerImagesRef, finalizeStashEntryImages, - noProviderAvailable, promptRef, pulseStashBadge, - selectedModelSelection, stashEntryToQueue, - stashProviderLabel, - stashScopeInstanceId, ]); const toggleStashMenu = useCallback(() => { @@ -2832,8 +2795,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..dfcf2948eb5 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,37 @@ 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"]); + const entries = usePromptStashStore.getState().entries; + expect(entries.map((entry) => entry.id)).toEqual(["second", "first"]); }); - it("scopes queues by provider instance, including the unscoped bucket", () => { + it("evicts the oldest entry past the cap and returns it", () => { 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"]); - }); - - it("evicts the oldest entry past the per-queue 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", () => { 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"]); - }); - - // 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, - }); - const store = usePromptStashStore.getState(); - expect(() => store.takeEntry("__proto__", "missing")).not.toThrow(); - expect(store.takeEntry("__proto__", "missing").entry).toBeNull(); + 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 +143,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 +151,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 +165,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..4f3a81ac1de 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,25 +171,25 @@ 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; @@ -218,14 +197,11 @@ interface PromptStashStoreState { 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 +209,6 @@ interface PromptStashStoreState { * tell the user their images did not make it. */ finalizeEntryImages: ( - scopeKey: string, entryId: string, images: { attachments: ReadonlyArray; @@ -244,58 +219,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 }; } - set(() => ({ queuesByScopeKey: next })); + set(() => ({ entries: nextEntries })); return { evicted, 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 +265,13 @@ 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(); + baseStashStorage.removeItem(LEGACY_PROMPT_STASH_STORAGE_KEY); + 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 +279,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() ?? [] }); } From 6569fe614a826fee1b0a01413ab0e49724c27d64 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 28 Jul 2026 14:50:13 -0700 Subject: [PATCH 2/4] fix(web): guard legacy stash purge against storage errors Co-Authored-By: Claude Fable 5 --- apps/web/src/promptStashStore.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index 4f3a81ac1de..6291c9de092 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -265,7 +265,12 @@ 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. { - baseStashStorage.removeItem(LEGACY_PROMPT_STASH_STORAGE_KEY); + 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({ entries: persisted }); From b6aa8ecd356700d67141045327df58c67391435a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 28 Jul 2026 14:59:26 -0700 Subject: [PATCH 3/4] fix(web): distinguish memory-only stash writes from failed ones With localStorage blocked, the store's in-memory fallback accepts the write but reported it identically to a quota rejection, so the composer showed a false error while the entry sat visible in the stash. stashEntry now returns written and durable separately; the composer clears on any successful write and warns when it is session-only. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/chat/ChatComposer.tsx | 24 ++++++++++++++----- apps/web/src/promptStashStore.test.ts | 13 ++++++++++ apps/web/src/promptStashStore.ts | 11 ++++++--- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ae61c300028..20ca0a628af 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2073,7 +2073,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // 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, @@ -2083,11 +2083,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) 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", @@ -2097,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 diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index dfcf2948eb5..20894713d1d 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -114,6 +114,19 @@ describe("promptStashStore", () => { expect(entries[0]?.id).toBe("overflow"); }); + // 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(); + 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", + ]); + }); + it("takeEntry removes and returns the entry; second take returns null", () => { const store = usePromptStashStore.getState(); store.stashEntry(makeEntry({ id: "keep" })); diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index 6291c9de092..d7c541e7a94 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -193,7 +193,12 @@ interface PromptStashStoreState { */ 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; }; /** @@ -228,10 +233,10 @@ export const usePromptStashStore = create()((set, get) => // 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(() => ({ entries: nextEntries })); - return { evicted, durable }; + return { evicted, written: true, durable }; }, takeEntry: (entryId) => { const entries = get().entries; From f3198b2da4a1647db11df61ede04f03c35d1c4ec Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 28 Jul 2026 15:01:33 -0700 Subject: [PATCH 4/4] fix(web): skip storage-rejection toast for memory-only image saves Co-Authored-By: Claude Fable 5 --- apps/web/src/components/chat/ChatComposer.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 20ca0a628af..5575916dd9a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2164,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",