History

fix(web): stashed prompts now survive switching providers (#4787)

Theo Browne committed 3 months ago

Commit f2d2fb21 parent 8829e2f5 files · +162 −278Browse files

Unified · Split

5 files
  1. apps/web/src/components/chat/ChatComposer.tsx +34 −59
  2. apps/web/src/components/chat/ComposerStashBadge.tsx +1 −2
  3. apps/web/src/components/chat/ComposerStashMenu.tsx +6 −16
  4. apps/web/src/promptStashStore.test.ts +43 −94
  5. apps/web/src/promptStashStore.ts +78 −107
apps/web/src/components/chat/ChatComposer.tsx+34−59
@@ -57,10 +57,8 @@ import { · 56 unmodified lines
5757 useEffectiveComposerModelState,
5858 } from "../../composerDraftStore";
5959 import {
60− EMPTY_PROMPT_STASH_QUEUE,
61− MAX_STASH_ENTRIES_PER_QUEUE,
60+ MAX_STASH_ENTRIES,
6261 partitionStashAttachments,
63− promptStashScopeKey,
6462 usePromptStashStore,
6563 type PromptStashEntry,
6664 } from "../../promptStashStore";
@@ -733,7 +731,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 666 unmodified lines
733731 const clearComposerDraftPromptAndImages = useComposerDraftStore(
734732 (store) => store.clearComposerPromptAndImages,
735733 );
736− const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection);
737734 const syncComposerDraftPersistedAttachments = useComposerDraftStore(
738735 (store) => store.syncPersistedAttachments,
739736 );
@@ -1888,23 +1885,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 1148 unmodified lines
18881885 // ------------------------------------------------------------------
18891886 // Prompt stash (⌘S)
18901887 // ------------------------------------------------------------------
1891− const stashScopeInstanceId = noProviderAvailable ? null : selectedInstanceId;
1892− const stashScope = promptStashScopeKey(stashScopeInstanceId);
1893− const stashQueue = usePromptStashStore(
1894− (state) => state.queuesByScopeKey[stashScope] ?? EMPTY_PROMPT_STASH_QUEUE,
1895− );
1896− const stashOtherScopesCount = usePromptStashStore((state) =>
1897− Object.entries(state.queuesByScopeKey).reduce(
1898− (total, [key, queue]) => (key === stashScope ? total : total + queue.length),
1899− 0,
1900− ),
1901− );
1888+ // One global queue. Stashed prompts carry only text + images so they can be
1889+ // restored into any thread or provider — stash, switch, restore is the
1890+ // whole point.
1891+ const stashQueue = usePromptStashStore((state) => state.entries);
19021892 const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry);
19031893 const takeStashEntry = usePromptStashStore((state) => state.takeEntry);
19041894 const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages);
1905− const stashProviderLabel = noProviderAvailable
1906− ? "No provider"
1907− : getProviderDisplayName(providerStatuses, selectedProvider);
19081895
19091896 useEffect(() => {
19101897 return () => {
@@ -1930,10 +1917,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 19 unmodified lines
19301917 const restoreStashEntry = useCallback(
19311918 (entry: PromptStashEntry) => {
19321919 // Remove first so a double activation (click + Enter) can't restore twice.
1933− const { entry: taken, durable } = takeStashEntry(
1934− promptStashScopeKey(entry.providerInstanceId),
1935− entry.id,
1936− );
1920+ const { entry: taken, durable } = takeStashEntry(entry.id);
19371921 if (!taken) return;
19381922 if (!durable) {
19391923 toastManager.add({
@@ -1996,21 +1980,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 56 unmodified lines
19961980 }
19971981 }
19981982
1999− const restorableSelection =
2000− entry.modelSelection &&
2001− providerInstanceEntries.some(
2002− (candidate) =>
2003− candidate.instanceId === entry.modelSelection?.instanceId &&
2004− candidate.enabled &&
2005− candidate.isAvailable,
2006− )
2007− ? entry.modelSelection
2008− : null;
2009− if (restorableSelection) {
2010− setComposerDraftModelSelection(composerDraftTarget, restorableSelection, {
2011− replaceOptions: true,
2012− });
2013− }
1983+ // Deliberately no model/provider restore: the stash exists to carry a
1984+ // prompt across threads and providers, so whatever the composer has
1985+ // selected right now stays selected.
20141986
20151987 // Each cause gets its own sentence so "too large" is never blamed for a
20161988 // file that actually failed to decode, or for one the composer simply
@@ -2052,8 +2024,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 35 unmodified lines
20522024 composerDraftTarget,
20532025 composerImagesRef,
20542026 promptRef,
2055− providerInstanceEntries,
2056− setComposerDraftModelSelection,
20572027 setComposerDraftPrompt,
20582028 takeStashEntry,
20592029 ],
@@ -2061,7 +2031,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 1 unmodified line
20612031
20622032 const deleteStashEntry = useCallback(
20632033 (entry: PromptStashEntry) => {
2064− const { durable } = takeStashEntry(promptStashScopeKey(entry.providerInstanceId), entry.id);
2034+ const { durable } = takeStashEntry(entry.id);
20652035 if (!durable) {
20662036 toastManager.add({
20672037 type: "warning",
@@ -2097,30 +2067,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 29 unmodified lines
20972067
20982068 const stashTarget = composerDraftTarget;
20992069 const entryId = randomUUID();
2100− const scopeKey = promptStashScopeKey(stashScopeInstanceId);
21012070 try {
21022071 // Persist the text-only entry *first*, then clear. Ordering matters in
21032072 // both directions: writing before clearing means a crash or closed tab
21042073 // mid-encode still leaves the prompt recoverable, while clearing before
21052074 // the async image work means edits typed during encoding are not wiped.
21062075 // Images are appended to the stored entry as they finish encoding.
2107− const { evicted, durable } = stashEntryToQueue({
2076+ const { evicted, written, durable } = stashEntryToQueue({
21082077 id: entryId,
21092078 createdAt: new Date().toISOString(),
21102079 prompt,
21112080 attachments: [],
2112− providerInstanceId: stashScopeInstanceId,
2113− modelSelection: noProviderAvailable ? null : selectedModelSelection,
21142081 droppedImageNames: [],
21152082 unreadableImageNames: [],
21162083 pendingImageCount: images.length,
21172084 });
21182085
2119− // Clearing the composer is only safe once the entry is durable. If the
2120− // write was rejected (quota, blocked storage) the store has already
2121− // rolled itself back, so leave the composer untouched rather than
2122− // making it the second casualty of a reload.
2123− if (!durable) {
2086+ // Clearing the composer is only safe once the write actually landed.
2087+ // If it was rejected (quota) the store has already rolled itself back,
2088+ // so leave the composer untouched rather than making it the second
2089+ // casualty of a reload.
2090+ if (!written) {
21242091 toastManager.add({
21252092 type: "error",
21262093 title: "Could not stash this prompt",
@@ -2130,6 +2097,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 3 unmodified lines
21302097 });
21312098 return;
21322099 }
2100+ // Written but only into the in-memory fallback (localStorage blocked):
2101+ // the entry is visible and restorable this session, so proceed with the
2102+ // clear, but say it won't survive a reload.
2103+ if (!durable) {
2104+ toastManager.add({
2105+ type: "warning",
2106+ title: "Stashed prompt will not survive a reload",
2107+ description:
2108+ "Browser storage is unavailable, so this stash is kept in memory only for this session.",
2109+ data: { hideCopyButton: true },
2110+ });
2111+ }
21332112
21342113 // Only the prompt and images are cleared — terminal/element contexts,
21352114 // preview annotations, and review comments are not stashable, so
@@ -2144,7 +2123,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 8 unmodified lines
21442123 toastManager.add({
21452124 type: "warning",
21462125 title: "Oldest stashed prompt discarded",
2147− description: `The ${stashProviderLabel} stash holds ${MAX_STASH_ENTRIES_PER_QUEUE} prompts; the oldest was removed to make room.`,
2126+ description: `The stash holds ${MAX_STASH_ENTRIES} prompts; the oldest was removed to make room.`,
21482127 data: { hideCopyButton: true },
21492128 });
21502129 }
@@ -2176,7 +2155,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 25 unmodified lines
21762155 }
21772156 const { kept, droppedNames } = partitionStashAttachments(candidateAttachments);
21782157
2179− const { attached, durable: imagesDurable } = finalizeStashEntryImages(scopeKey, entryId, {
2158+ const { attached, durable: imagesDurable } = finalizeStashEntryImages(entryId, {
21802159 attachments: kept,
21812160 droppedImageNames: [...oversizedImageNames, ...droppedNames],
21822161 unreadableImageNames,
@@ -2185,8 +2164,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 2 unmodified lines
21852164 // The second phase can be rejected on its own: the text-only entry
21862165 // fit, but adding image payloads pushed past the quota. Disk would
21872166 // then still hold the phase-one entry with pendingImageCount set,
2188− // which reads as an orphan after reload — so say so now.
2189− if (!imagesDurable && images.length > 0) {
2167+ // which reads as an orphan after reload — so say so now. Gated on the
2168+ // entry write having been durable: on the in-memory fallback nothing
2169+ // is ever durable, and the session-only warning already covered it.
2170+ if (!imagesDurable && durable && images.length > 0) {
21902171 toastManager.add({
21912172 type: "warning",
21922173 title: "Stashed images were not saved",
@@ -2216,13 +2197,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 23 unmodified lines
22162197 composerDraftTarget,
22172198 composerImagesRef,
22182199 finalizeStashEntryImages,
2219− noProviderAvailable,
22202200 promptRef,
22212201 pulseStashBadge,
2222− selectedModelSelection,
22232202 stashEntryToQueue,
2224− stashProviderLabel,
2225− stashScopeInstanceId,
22262203 ]);
22272204
22282205 const toggleStashMenu = useCallback(() => {
@@ -2832,8 +2809,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 603 unmodified lines
28322809 <ComposerCommandMenuLayer anchor={composerMenuAnchor}>
28332810 <ComposerStashMenu
28342811 entries={stashQueue}
2835− providerLabel={stashProviderLabel}
2836− otherScopesCount={stashOtherScopesCount}
28372812 onRestore={restoreStashEntry}
28382813 onDelete={deleteStashEntry}
28392814 onClose={() => setIsStashMenuOpen(false)}
apps/web/src/components/chat/ComposerStashBadge.tsx+1−2
@@ -5,8 +5,7 @@ import { cn } from "~/lib/utils"; · 4 unmodified lines
55
66 /**
77 * Bookmark pill perched on the composer's top-right shoulder. Shows the
8− * current method's stash count and doubles as the click target for opening
9− * the stash menu.
8+ * stash count and doubles as the click target for opening the stash menu.
109 *
1110 * On save the badge gives one quiet acknowledgement: it lifts to full
1211 * opacity and the count ticks over. `pulseKey` changes per stash, remounting
apps/web/src/components/chat/ComposerStashMenu.tsx+6−16
@@ -24,16 +24,13 @@ function stashEntrySnippet(entry: PromptStashEntry): string { · 23 unmodified lines
2424 }
2525
2626 /**
27− * Popover listing the current connection method's stashed prompts.
28− * Keyboard-first: opened by ⌘S on an empty composer, navigated with
29− * arrows, restored with Enter, dismissed with Escape. The listener runs
30− * capture-phase on window so it wins over the Lexical editor's handlers
31− * while the menu is open.
27+ * Popover listing the stashed prompts. Keyboard-first: opened by ⌘S on an
28+ * empty composer, navigated with arrows, restored with Enter, dismissed
29+ * with Escape. The listener runs capture-phase on window so it wins over
30+ * the Lexical editor's handlers while the menu is open.
3231 */
3332 export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
3433 entries: ReadonlyArray<PromptStashEntry>;
35− providerLabel: string;
36− otherScopesCount: number;
3734 onRestore: (entry: PromptStashEntry) => void;
3835 onDelete: (entry: PromptStashEntry) => void;
3936 onClose: () => void;
@@ -99,12 +96,11 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { · 59 unmodified lines
9996 <CommandGroup>
10097 <CommandGroupLabel className="flex items-center gap-1.5 px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/55">
10198 <BookmarkIcon className="size-3" aria-hidden="true" />
102− Stashed prompts — {props.providerLabel}
99+ Stashed prompts
103100 </CommandGroupLabel>
104101 {entries.length === 0 ? (
105102 <p className="px-3 pb-3 pt-1 text-muted-foreground/70 text-xs">
106− Nothing stashed for this method yet. Press ⌘S with a prompt in the composer to stash
107− it.
103+ Nothing stashed yet. Press ⌘S with a prompt in the composer to stash it.
108104 </p>
109105 ) : (
110106 entries.map((entry) => (
@@ -173,12 +169,6 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { · 62 unmodified lines
173169 ))
174170 )}
175171 </CommandGroup>
176− {props.otherScopesCount > 0 ? (
177− <p className="border-t border-border/50 px-3 py-2 text-[11px] text-muted-foreground/60">
178− {props.otherScopesCount} more stashed under other connection methods — switch provider
179− to see them.
180− </p>
181− ) : null}
182172 </CommandList>
183173 </div>
184174 </Command>
apps/web/src/promptStashStore.test.ts+43−94
@@ -1,26 +1,19 @@
1−import { ProviderInstanceId } from "@t3tools/contracts";
21 import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
32
43 import { removeLocalStorageItem } from "./hooks/useLocalStorage";
54
65 import {
7− MAX_STASH_ENTRIES_PER_QUEUE,
6+ MAX_STASH_ENTRIES,
87 PROMPT_STASH_STORAGE_KEY,
98 MAX_STASH_ENTRY_ATTACHMENT_CHARS,
10− PROMPT_STASH_UNSCOPED_KEY,
119 partitionStashAttachments,
12− promptStashScopeKey,
1310 usePromptStashStore,
1411 writePromptStashStorageForTest,
1512 type PromptStashEntry,
1613 } from "./promptStashStore";
1714
18−const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent");
19−const CODEX_INSTANCE = ProviderInstanceId.make("codex");
20−
2115 function makeEntry(input: {
2216 id: string;
23− providerInstanceId?: ProviderInstanceId | null;
2417 prompt?: string;
2518 attachmentChars?: number;
2619 }): PromptStashEntry {
@@ -40,35 +33,16 @@ function makeEntry(input: { · 13 unmodified lines
4033 },
4134 ]
4235 : [],
43− providerInstanceId:
44− input.providerInstanceId === undefined ? CLAUDE_AGENT_INSTANCE : input.providerInstanceId,
45− modelSelection: null,
4636 droppedImageNames: [],
4737 };
4838 }
4939
5040 function resetPromptStashStore() {
51− usePromptStashStore.setState({ queuesByScopeKey: {} });
41+ usePromptStashStore.setState({ entries: [] });
5242 writePromptStashStorageForTest("");
5343 removeLocalStorageItem(PROMPT_STASH_STORAGE_KEY);
5444 }
5545
56−describe("promptStashScopeKey", () => {
57− it("maps a provider instance to its own bucket and null to the unscoped bucket", () => {
58− expect(promptStashScopeKey(CLAUDE_AGENT_INSTANCE)).toBe("provider:claudeAgent");
59− expect(promptStashScopeKey(null)).toBe(PROMPT_STASH_UNSCOPED_KEY);
60− expect(promptStashScopeKey(undefined)).toBe(PROMPT_STASH_UNSCOPED_KEY);
61− });
62−
63− // Provider slugs must match /^[a-zA-Z][a-zA-Z0-9_-]*$/, so no real instance
64− // id can equal the unscoped sentinel. The namespace prefix makes that
65− // structural rather than incidental.
66− it("namespaces provider keys so they can never equal the unscoped sentinel", () => {
67− expect(promptStashScopeKey(CLAUDE_AGENT_INSTANCE)).not.toBe(PROMPT_STASH_UNSCOPED_KEY);
68− expect(promptStashScopeKey(CODEX_INSTANCE).startsWith("provider:")).toBe(true);
69− });
70−});
71−
7246 describe("partitionStashAttachments", () => {
7347 it("keeps attachments within the budget and reports dropped names in order", () => {
7448 const small = {
@@ -124,74 +98,50 @@ describe("promptStashStore", () => { · 49 unmodified lines
12498 const store = usePromptStashStore.getState();
12599 store.stashEntry(makeEntry({ id: "first" }));
126100 store.stashEntry(makeEntry({ id: "second" }));
127− const queue =
128− usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ??
129− [];
130− expect(queue.map((entry) => entry.id)).toEqual(["second", "first"]);
131− });
132−
133− it("scopes queues by provider instance, including the unscoped bucket", () => {
134− const store = usePromptStashStore.getState();
135− store.stashEntry(makeEntry({ id: "claude" }));
136− store.stashEntry(makeEntry({ id: "codex", providerInstanceId: CODEX_INSTANCE }));
137− store.stashEntry(makeEntry({ id: "none", providerInstanceId: null }));
138− const queues = usePromptStashStore.getState().queuesByScopeKey;
139− expect(queues[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)]?.map((entry) => entry.id)).toEqual([
140− "claude",
141− ]);
142− expect(queues[promptStashScopeKey(CODEX_INSTANCE)]?.map((entry) => entry.id)).toEqual([
143− "codex",
144− ]);
145− expect(queues[PROMPT_STASH_UNSCOPED_KEY]?.map((entry) => entry.id)).toEqual(["none"]);
101+ const entries = usePromptStashStore.getState().entries;
102+ expect(entries.map((entry) => entry.id)).toEqual(["second", "first"]);
146103 });
147104
148− it("evicts the oldest entry past the per-queue cap and returns it", () => {
105+ it("evicts the oldest entry past the cap and returns it", () => {
149106 const store = usePromptStashStore.getState();
150− for (let index = 0; index < MAX_STASH_ENTRIES_PER_QUEUE; index += 1) {
107+ for (let index = 0; index < MAX_STASH_ENTRIES; index += 1) {
151108 expect(store.stashEntry(makeEntry({ id: `entry-${index}` })).evicted).toBeNull();
152109 }
153110 const { evicted } = store.stashEntry(makeEntry({ id: "overflow" }));
154111 expect(evicted?.id).toBe("entry-0");
155− const queue =
156− usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ??
157− [];
158− expect(queue).toHaveLength(MAX_STASH_ENTRIES_PER_QUEUE);
159− expect(queue[0]?.id).toBe("overflow");
112+ const entries = usePromptStashStore.getState().entries;
113+ expect(entries).toHaveLength(MAX_STASH_ENTRIES);
114+ expect(entries[0]?.id).toBe("overflow");
160115 });
161116
162− it("takeEntry removes and returns the entry; second take returns null", () => {
117+ // This test environment has no `localStorage`, so the store runs on its
118+ // in-memory fallback — the exact "kept for this session, gone on reload"
119+ // case the composer must distinguish from an outright write failure.
120+ it("distinguishes a memory-only write (written, not durable) from a failed one", () => {
163121 const store = usePromptStashStore.getState();
164− store.stashEntry(makeEntry({ id: "keep" }));
165− store.stashEntry(makeEntry({ id: "take" }));
166− expect(store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "take").entry?.id).toBe(
167− "take",
168− );
169− expect(store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "take").entry).toBeNull();
170− const queue =
171− usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ??
172− [];
173− expect(queue.map((entry) => entry.id)).toEqual(["keep"]);
122+ const result = store.stashEntry(makeEntry({ id: "memory-only" }));
123+ expect(result.written).toBe(true);
124+ expect(result.durable).toBe(false);
125+ expect(usePromptStashStore.getState().entries.map((entry) => entry.id)).toEqual([
126+ "memory-only",
127+ ]);
174128 });
175129
176− // Queue keys are persisted as plain strings, so a hand-edited or corrupted
177− // localStorage payload can carry a literal `__proto__` key that survives
178− // JSON.parse as an own property. An unguarded lookup would resolve to
179− // Object.prototype and throw "not iterable" on spread.
180− it("tolerates a __proto__ scope key rehydrated from storage", () => {
181− usePromptStashStore.setState({
182− queuesByScopeKey: JSON.parse('{"__proto__":[]}') as Record<string, PromptStashEntry[]>,
183− });
130+ it("takeEntry removes and returns the entry; second take returns null", () => {
184131 const store = usePromptStashStore.getState();
185− expect(() => store.takeEntry("__proto__", "missing")).not.toThrow();
186− expect(store.takeEntry("__proto__", "missing").entry).toBeNull();
132+ store.stashEntry(makeEntry({ id: "keep" }));
133+ store.stashEntry(makeEntry({ id: "take" }));
134+ expect(store.takeEntry("take").entry?.id).toBe("take");
135+ expect(store.takeEntry("take").entry).toBeNull();
136+ const entries = usePromptStashStore.getState().entries;
137+ expect(entries.map((entry) => entry.id)).toEqual(["keep"]);
187138 });
188139
189140 it("finalizeEntryImages attaches images and clears the pending count", () => {
190141 const store = usePromptStashStore.getState();
191− const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE);
192142 store.stashEntry({ ...makeEntry({ id: "pending" }), pendingImageCount: 2 });
193143
194− const { attached } = store.finalizeEntryImages(scopeKey, "pending", {
144+ const { attached } = store.finalizeEntryImages("pending", {
195145 attachments: [
196146 {
197147 id: "img-1",
@@ -206,7 +156,7 @@ describe("promptStashStore", () => { · 8 unmodified lines
206156 });
207157
208158 expect(attached).toBe(true);
209− const entry = usePromptStashStore.getState().queuesByScopeKey[scopeKey]?.[0];
159+ const entry = usePromptStashStore.getState().entries[0];
210160 expect(entry?.attachments).toHaveLength(1);
211161 expect(entry?.droppedImageNames).toEqual(["big.png"]);
212162 expect(entry?.pendingImageCount).toBe(0);
@@ -214,12 +164,11 @@ describe("promptStashStore", () => { · 1 unmodified line
214164
215165 it("finalizeEntryImages reports false when the entry was already taken", () => {
216166 const store = usePromptStashStore.getState();
217− const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE);
218167 store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 });
219168 // Restored (or deleted) while its images were still encoding.
220− store.takeEntry(scopeKey, "racing");
169+ store.takeEntry("racing");
221170
222− const { attached } = store.finalizeEntryImages(scopeKey, "racing", {
171+ const { attached } = store.finalizeEntryImages("racing", {
223172 attachments: [],
224173 droppedImageNames: [],
225174 unreadableImageNames: [],
@@ -229,31 +178,31 @@ describe("promptStashStore", () => { · 3 unmodified lines
229178 });
230179
231180 it("settles a pending count left behind by a crashed or closed session", () => {
232− const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE);
233181 writePromptStashStorageForTest(
234182 JSON.stringify({
235− version: 1,
183+ version: 2,
236184 state: {
237− queuesByScopeKey: {
238− [scopeKey]: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }],
239− },
185+ entries: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }],
240186 },
241187 }),
242188 );
243189
244190 // Hydration must settle the stale count, or the entry would stay stuck
245191 // showing "saving…" with images that no longer exist anywhere.
246− const entry = usePromptStashStore.getState().queuesByScopeKey[scopeKey]?.[0];
192+ const entry = usePromptStashStore.getState().entries[0];
247193 expect(entry?.pendingImageCount).toBe(0);
248194 expect(entry?.unreadableImageNames).toHaveLength(2);
249195 });
250196
251− it("drops the scope key entirely when its queue empties", () => {
252− const store = usePromptStashStore.getState();
253− store.stashEntry(makeEntry({ id: "only" }));
254− store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "only");
255− expect(
256− usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)],
257− ).toBeUndefined();
197+ it("ignores an unreadable v1 payload seeded under the current key", () => {
198+ // The v1 shape (per-provider queues) does not decode as v2; hydration
199+ // must fall back to an empty stash rather than throw.
200+ writePromptStashStorageForTest(
201+ JSON.stringify({
202+ version: 1,
203+ state: { queuesByScopeKey: { "provider:claudeAgent": [] } },
204+ }),
205+ );
206+ expect(usePromptStashStore.getState().entries).toEqual([]);
258207 });
259208 });
apps/web/src/promptStashStore.ts+78−107
@@ -1,24 +1,20 @@
1−import { ModelSelection, ProviderInstanceId } from "@t3tools/contracts";
21 import * as Schema from "effect/Schema";
32 import { create } from "zustand";
43
54 import { PersistedComposerImageAttachment } from "./composerDraftStore";
65 import { createMemoryStorage, type StateStorage } from "./lib/storage";
76
8−export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1";
9−const PROMPT_STASH_STORAGE_VERSION = 1;
10−
7+export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v2";
118 /**
12− * Queue bucket for prompts stashed while no provider instance is selected.
13− *
14− * Provider-scoped keys are prefixed (see `promptStashScopeKey`), so this
15− * sentinel can never collide with a provider literally named `__none__`.
9+ * v1 bucketed entries into per-provider-instance queues and stored a model
10+ * selection with each prompt. The stash is provider-agnostic now, so the old
11+ * payload is deleted at startup rather than migrated — left behind it would
12+ * silently hold megabytes of the origin's ~5MB localStorage quota forever.
1613 */
17−export const PROMPT_STASH_UNSCOPED_KEY = "__none__";
18−/** Namespace applied to provider-derived keys to keep them collision-proof. */
19−const PROVIDER_SCOPE_PREFIX = "provider:";
14+const LEGACY_PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1";
15+const PROMPT_STASH_STORAGE_VERSION = 2;
2016
21−export const MAX_STASH_ENTRIES_PER_QUEUE = 20;
17+export const MAX_STASH_ENTRIES = 20;
2218 /**
2319 * Budget for an entry's serialized attachment payload. localStorage is a
2420 * ~5MB origin-wide quota shared with the composer draft store, so oversized
@@ -30,13 +26,17 @@ export const MAX_STASH_ENTRIES_PER_QUEUE = 20; · 5 unmodified lines
3026 */
3127 export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000;
3228
29+/**
30+ * A stashed prompt carries only what every provider can accept: text and
31+ * image attachments. Deliberately no provider instance or model selection —
32+ * the point of stashing is to move a prompt into a different thread or
33+ * provider, so restoring must never drag the old model choice along.
34+ */
3335 const StashEntrySchema = Schema.Struct({
3436 id: Schema.String,
3537 createdAt: Schema.String,
3638 prompt: Schema.String,
3739 attachments: Schema.Array(PersistedComposerImageAttachment),
38− providerInstanceId: Schema.NullOr(ProviderInstanceId),
39− modelSelection: Schema.NullOr(ModelSelection),
4040 /** Names of images that exceeded the attachment budget and were not saved. */
4141 droppedImageNames: Schema.Array(Schema.String),
4242 /**
@@ -57,7 +57,7 @@ const StashEntrySchema = Schema.Struct({ · 14 unmodified lines
5757 export type PromptStashEntry = typeof StashEntrySchema.Type;
5858
5959 const PersistedPromptStashState = Schema.Struct({
60− queuesByScopeKey: Schema.Record(Schema.String, Schema.Array(StashEntrySchema)),
60+ entries: Schema.Array(StashEntrySchema),
6161 });
6262 type PersistedPromptStashState = typeof PersistedPromptStashState.Type;
6363
@@ -74,44 +74,23 @@ const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPrompt · 10 unmodified lines
7474 * recorded as unreadable to keep the prompt itself restorable.
7575 */
7676 function clearOrphanedPendingImages(
77− queues: Record<string, ReadonlyArray<PromptStashEntry>>,
78−): Record<string, ReadonlyArray<PromptStashEntry>> {
79− const next: Record<string, ReadonlyArray<PromptStashEntry>> = {};
80− for (const [scopeKey, queue] of Object.entries(queues)) {
81− next[scopeKey] = queue.map((entry) => {
82− if (!entry.pendingImageCount) return entry;
83− const lostCount = entry.pendingImageCount;
84− return {
85− ...entry,
86− pendingImageCount: 0,
87− unreadableImageNames: [
88− ...(entry.unreadableImageNames ?? []),
89− ...Array.from(
90− { length: lostCount },
91− (_, index) => `image ${index + 1} (not saved before reload)`,
92− ),
93− ],
94− };
95− });
96− }
97− return next;
98−}
99−
100−/** Maps the composer's active provider instance to a stash queue bucket. */
101−export function promptStashScopeKey(instanceId: ProviderInstanceId | null | undefined): string {
102− return instanceId ? `${PROVIDER_SCOPE_PREFIX}${instanceId}` : PROMPT_STASH_UNSCOPED_KEY;
103−}
104−
105−/**
106− * Reads a queue without inheriting from `Object.prototype`. Scope keys derive
107− * from user-authored provider slugs, so a key like `__proto__` must not
108− * resolve to the prototype chain.
109− */
110−function readQueue(
111− queues: Record<string, ReadonlyArray<PromptStashEntry>>,
112− scopeKey: string,
77+ entries: ReadonlyArray<PromptStashEntry>,
11378 ): ReadonlyArray<PromptStashEntry> {
114− return Object.hasOwn(queues, scopeKey) ? (queues[scopeKey] ?? []) : [];
79+ return entries.map((entry) => {
80+ if (!entry.pendingImageCount) return entry;
81+ const lostCount = entry.pendingImageCount;
82+ return {
83+ ...entry,
84+ pendingImageCount: 0,
85+ unreadableImageNames: [
86+ ...(entry.unreadableImageNames ?? []),
87+ ...Array.from(
88+ { length: lostCount },
89+ (_, index) => `image ${index + 1} (not saved before reload)`,
90+ ),
91+ ],
92+ };
93+ });
11594 }
11695
11796 /**
@@ -163,7 +142,7 @@ function resolveBaseStorage(): { storage: StateStorage; durable: boolean } { · 45 unmodified lines
163142 const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStorage();
164143
165144 /**
166− * Persists the queues, immediately rather than debounced. Stashing is a
145+ * Persists the queue, immediately rather than debounced. Stashing is a
167146 * deliberate, infrequent keystroke — not a per-character autosave — so there
168147 * is nothing to coalesce, and the caller clears the composer on the strength
169148 * of this write landing, which a debounce timer cannot honestly report.
@@ -171,7 +150,7 @@ const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStor · 1 unmodified line
171150 * Returns whether the write will survive a reload: false on a quota rejection
172151 * or when only the in-memory fallback is available.
173152 */
174−function persistQueues(queues: Record<string, ReadonlyArray<PromptStashEntry>>): {
153+function persistEntries(entries: ReadonlyArray<PromptStashEntry>): {
175154 /** The write succeeded (possibly only into the in-memory fallback). */
176155 written: boolean;
177156 /** The write will survive a reload. */
@@ -182,7 +161,7 @@ function persistQueues(queues: Record<string, ReadonlyArray<PromptStashEntry>>): · 4 unmodified lines
182161 PROMPT_STASH_STORAGE_KEY,
183162 JSON.stringify({
184163 version: PROMPT_STASH_STORAGE_VERSION,
185− state: { queuesByScopeKey: queues },
164+ state: { entries },
186165 }),
187166 );
188167 return { written: true, durable: storageIsDurable };
@@ -192,40 +171,42 @@ function persistQueues(queues: Record<string, ReadonlyArray<PromptStashEntry>>): · 3 unmodified lines
192171 }
193172 }
194173
195−/** Reads the persisted queues, settling stale pending counts. */
196−function readPersistedQueues(): Record<string, ReadonlyArray<PromptStashEntry>> | null {
174+/** Reads the persisted queue, settling stale pending counts. */
175+function readPersistedEntries(): ReadonlyArray<PromptStashEntry> | null {
197176 try {
198177 const raw = baseStashStorage.getItem(PROMPT_STASH_STORAGE_KEY);
199178 if (typeof raw !== "string" || raw.length === 0) return null;
200179 const parsed: unknown = JSON.parse(raw);
201180 const state = (parsed as { state?: unknown } | null)?.state;
202181 if (!state) return null;
203− return clearOrphanedPendingImages(decodePersistedPromptStashState(state).queuesByScopeKey);
182+ return clearOrphanedPendingImages(decodePersistedPromptStashState(state).entries);
204183 } catch {
205184 return null;
206185 }
207186 }
208187
209188 interface PromptStashStoreState {
210− queuesByScopeKey: Record<string, ReadonlyArray<PromptStashEntry>>;
189+ entries: ReadonlyArray<PromptStashEntry>;
211190 /**
212− * Prepends an entry to its scope's queue, evicting the oldest entry past
213− * the per-queue cap. Returns the evicted entry (for messaging) if any.
191+ * Prepends an entry to the queue, evicting the oldest entry past the cap.
192+ * Returns the evicted entry (for messaging) if any.
214193 */
215194 stashEntry: (entry: PromptStashEntry) => {
216195 evicted: PromptStashEntry | null;
217− /** False when the write did not reach durable storage; nothing was kept. */
196+ /** False when the write failed outright (e.g. quota); nothing was kept. */
197+ written: boolean;
198+ /**
199+ * False when the write will not survive a reload: either it failed, or it
200+ * landed only in the in-memory fallback because localStorage is blocked.
201+ */
218202 durable: boolean;
219203 };
220204 /**
221− * Removes and returns an entry from a scope's queue (restore + delete).
205+ * Removes and returns an entry from the queue (restore + delete).
222206 * `durable` is false when the removal could not be persisted, meaning a
223207 * reload would resurrect the entry.
224208 */
225− takeEntry: (
226− scopeKey: string,
227− entryId: string,
228− ) => { entry: PromptStashEntry | null; durable: boolean };
209+ takeEntry: (entryId: string) => { entry: PromptStashEntry | null; durable: boolean };
229210 /**
230211 * Attaches the encoded images to an entry written earlier by `stashEntry`,
231212 * clearing its pending count. Returns attached=false when the entry is gone
@@ -233,7 +214,6 @@ interface PromptStashStoreState { · 1 unmodified line
233214 * tell the user their images did not make it.
234215 */
235216 finalizeEntryImages: (
236− scopeKey: string,
237217 entryId: string,
238218 images: {
239219 attachments: ReadonlyArray<PersistedComposerImageAttachment>;
@@ -244,58 +224,45 @@ interface PromptStashStoreState { · 4 unmodified lines
244224 }
245225
246226 export const usePromptStashStore = create<PromptStashStoreState>()((set, get) => ({
247− queuesByScopeKey: {},
227+ entries: [],
248228 stashEntry: (entry) => {
249− const scopeKey = promptStashScopeKey(entry.providerInstanceId);
250− const queues = get().queuesByScopeKey;
251− const nextQueue = [entry, ...readQueue(queues, scopeKey)];
252− const evicted =
253− nextQueue.length > MAX_STASH_ENTRIES_PER_QUEUE ? (nextQueue.pop() ?? null) : null;
254− const next = { ...queues, [scopeKey]: nextQueue };
255− const { written, durable } = persistQueues(next);
229+ const nextEntries = [entry, ...get().entries];
230+ const evicted = nextEntries.length > MAX_STASH_ENTRIES ? (nextEntries.pop() ?? null) : null;
231+ const { written, durable } = persistEntries(nextEntries);
256232 // A rejected write must not leave the entry visible either: the caller
257233 // keeps the composer intact on failure, so a stashed copy would
258234 // duplicate the prompt. Eviction likewise only sticks on success.
259235 if (!written) {
260− return { evicted: null, durable: false };
236+ return { evicted: null, written: false, durable: false };
261237 }
262− set(() => ({ queuesByScopeKey: next }));
263− return { evicted, durable };
238+ set(() => ({ entries: nextEntries }));
239+ return { evicted, written: true, durable };
264240 },
265− takeEntry: (scopeKey, entryId) => {
266− const queues = get().queuesByScopeKey;
267− const queue = readQueue(queues, scopeKey);
268− const entry = queue.find((candidate) => candidate.id === entryId) ?? null;
241+ takeEntry: (entryId) => {
242+ const entries = get().entries;
243+ const entry = entries.find((candidate) => candidate.id === entryId) ?? null;
269244 if (!entry) return { entry: null, durable: true };
270− const nextQueue = queue.filter((candidate) => candidate.id !== entryId);
271− const next = { ...queues };
272− if (nextQueue.length === 0) {
273− delete next[scopeKey];
274− } else {
275− next[scopeKey] = nextQueue;
276− }
277− const { durable } = persistQueues(next);
278− set(() => ({ queuesByScopeKey: next }));
245+ const nextEntries = entries.filter((candidate) => candidate.id !== entryId);
246+ const { durable } = persistEntries(nextEntries);
247+ set(() => ({ entries: nextEntries }));
279248 return { entry, durable };
280249 },
281− finalizeEntryImages: (scopeKey, entryId, images) => {
282− const queues = get().queuesByScopeKey;
283− const queue = readQueue(queues, scopeKey);
284− const index = queue.findIndex((candidate) => candidate.id === entryId);
285− const existing = index === -1 ? undefined : queue[index];
250+ finalizeEntryImages: (entryId, images) => {
251+ const entries = get().entries;
252+ const index = entries.findIndex((candidate) => candidate.id === entryId);
253+ const existing = index === -1 ? undefined : entries[index];
286254 // Restored or deleted mid-encode: nothing to attach to.
287255 if (!existing) return { attached: false, durable: true };
288− const nextQueue = [...queue];
289− nextQueue[index] = {
256+ const nextEntries = [...entries];
257+ nextEntries[index] = {
290258 ...existing,
291259 attachments: images.attachments,
292260 droppedImageNames: images.droppedImageNames,
293261 unreadableImageNames: images.unreadableImageNames,
294262 pendingImageCount: 0,
295263 };
296− const next = { ...queues, [scopeKey]: nextQueue };
297− const { durable } = persistQueues(next);
298− set(() => ({ queuesByScopeKey: next }));
264+ const { durable } = persistEntries(nextEntries);
265+ set(() => ({ entries: nextEntries }));
299266 return { attached: true, durable };
300267 },
301268 }));
@@ -303,14 +270,18 @@ export const usePromptStashStore = create<PromptStashStoreState>()((set, get) => · 1 unmodified line
303270 // Hydrate once at startup. Like the app's other persisted stores, tabs are
304271 // last-write-wins: no cross-tab merging or storage-event syncing.
305272 {
306− const persisted = readPersistedQueues();
273+ try {
274+ baseStashStorage.removeItem(LEGACY_PROMPT_STASH_STORAGE_KEY);
275+ } catch {
276+ // Purging the v1 payload is best-effort; a storage policy that rejects
277+ // the delete must not take down module init.
278+ }
279+ const persisted = readPersistedEntries();
307280 if (persisted) {
308− usePromptStashStore.setState({ queuesByScopeKey: persisted });
281+ usePromptStashStore.setState({ entries: persisted });
309282 }
310283 }
311284
312−export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray<PromptStashEntry> = [];
313−
314285 /**
315286 * Test seam: seeds the persisted payload through the same storage the store
316287 * reads and rehydrates, without needing a real `localStorage` global.
@@ -318,5 +289,5 @@ export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray<PromptStashEntry> = []; · 1 unmodified line
318289 */
319290 export function writePromptStashStorageForTest(raw: string): void {
320291 baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw);
321− usePromptStashStore.setState({ queuesByScopeKey: readPersistedQueues() ?? {} });
292+ usePromptStashStore.setState({ entries: readPersistedEntries() ?? [] });
322293 }