authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-28 15:22:22-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-28 15:22:22-07:00
logf2d2fb2f2444bcabc31bf906b5b334aee0d9d79c
tree1429ff2b5dfe3923f3bfb837d58631bbc6e6bfff
parent8829e2f9b960eb31dc62ddab256d5da82c5077a6
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

5 files changed, 162 insertions(+), 278 deletions(-)

apps/web/src/components/chat/ChatComposer.tsx+34-59
......@@ -57,10 +57,8 @@ import {
5757 useEffectiveComposerModelState,
5858} from "../../composerDraftStore";
5959import {
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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";
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 {
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 */
3332export 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: {
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: {
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 @@
1import { ProviderInstanceId } from "@t3tools/contracts";
21import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
32
43import { removeLocalStorageItem } from "./hooks/useLocalStorage";
54
65import {
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
18const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent");
19const CODEX_INSTANCE = ProviderInstanceId.make("codex");
20
2115function makeEntry(input: {
2216 id: string;
23 providerInstanceId?: ProviderInstanceId | null;
2417 prompt?: string;
2518 attachmentChars?: number;
2619}): PromptStashEntry {
......@@ -40,35 +33,16 @@ function makeEntry(input: {
4033 },
4134 ]
4235 : [],
43 providerInstanceId:
44 input.providerInstanceId === undefined ? CLAUDE_AGENT_INSTANCE : input.providerInstanceId,
45 modelSelection: null,
4636 droppedImageNames: [],
4737 };
4838}
4939
5040function resetPromptStashStore() {
51 usePromptStashStore.setState({ queuesByScopeKey: {} });
41 usePromptStashStore.setState({ entries: [] });
5242 writePromptStashStorageForTest("");
5343 removeLocalStorageItem(PROMPT_STASH_STORAGE_KEY);
5444}
5545
56describe("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
7246describe("partitionStashAttachments", () => {
7347 it("keeps attachments within the budget and reports dropped names in order", () => {
7448 const small = {
......@@ -124,74 +98,50 @@ describe("promptStashStore", () => {
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", () => {
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", () => {
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", () => {
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 @@
1import { ModelSelection, ProviderInstanceId } from "@t3tools/contracts";
21import * as Schema from "effect/Schema";
32import { create } from "zustand";
43
54import { PersistedComposerImageAttachment } from "./composerDraftStore";
65import { createMemoryStorage, type StateStorage } from "./lib/storage";
76
8export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1";
9const PROMPT_STASH_STORAGE_VERSION = 1;
10
7export 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 */
17export const PROMPT_STASH_UNSCOPED_KEY = "__none__";
18/** Namespace applied to provider-derived keys to keep them collision-proof. */
19const PROVIDER_SCOPE_PREFIX = "provider:";
14const LEGACY_PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1";
15const PROMPT_STASH_STORAGE_VERSION = 2;
2016
21export const MAX_STASH_ENTRIES_PER_QUEUE = 20;
17export 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;
3026 */
3127export 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 */
3335const 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({
5757export type PromptStashEntry = typeof StashEntrySchema.Type;
5858
5959const PersistedPromptStashState = Schema.Struct({
60 queuesByScopeKey: Schema.Record(Schema.String, Schema.Array(StashEntrySchema)),
60 entries: Schema.Array(StashEntrySchema),
6161});
6262type PersistedPromptStashState = typeof PersistedPromptStashState.Type;
6363
......@@ -74,44 +74,23 @@ const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPrompt
7474 * recorded as unreadable to keep the prompt itself restorable.
7575 */
7676function 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. */
101export 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 */
110function 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 } {
163142const { 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
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 */
174function persistQueues(queues: Record<string, ReadonlyArray<PromptStashEntry>>): {
153function 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>>):
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>>):
192171 }
193172}
194173
195/** Reads the persisted queues, settling stale pending counts. */
196function readPersistedQueues(): Record<string, ReadonlyArray<PromptStashEntry>> | null {
174/** Reads the persisted queue, settling stale pending counts. */
175function 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
209188interface 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 {
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 {
244224}
245225
246226export 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) =>
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
312export 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> = [];
318289 */
319290export function writePromptStashStorageForTest(raw: string): void {
320291 baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw);
321 usePromptStashStore.setState({ queuesByScopeKey: readPersistedQueues() ?? {} });
292 usePromptStashStore.setState({ entries: readPersistedEntries() ?? [] });
322293}