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 {...@@ -57,10 +57,8 @@ import {
57 useEffectiveComposerModelState,57 useEffectiveComposerModelState,
58} from "../../composerDraftStore";58} from "../../composerDraftStore";
59import {59import {
60 EMPTY_PROMPT_STASH_QUEUE,60 MAX_STASH_ENTRIES,
61 MAX_STASH_ENTRIES_PER_QUEUE,
62 partitionStashAttachments,61 partitionStashAttachments,
63 promptStashScopeKey,
64 usePromptStashStore,62 usePromptStashStore,
65 type PromptStashEntry,63 type PromptStashEntry,
66} from "../../promptStashStore";64} from "../../promptStashStore";
...@@ -733,7 +731,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -733,7 +731,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
733 const clearComposerDraftPromptAndImages = useComposerDraftStore(731 const clearComposerDraftPromptAndImages = useComposerDraftStore(
734 (store) => store.clearComposerPromptAndImages,732 (store) => store.clearComposerPromptAndImages,
735 );733 );
736 const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection);
737 const syncComposerDraftPersistedAttachments = useComposerDraftStore(734 const syncComposerDraftPersistedAttachments = useComposerDraftStore(
738 (store) => store.syncPersistedAttachments,735 (store) => store.syncPersistedAttachments,
739 );736 );
...@@ -1888,23 +1885,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1888,23 +1885,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1888 // ------------------------------------------------------------------1885 // ------------------------------------------------------------------
1889 // Prompt stash (⌘S)1886 // Prompt stash (⌘S)
1890 // ------------------------------------------------------------------1887 // ------------------------------------------------------------------
1891 const stashScopeInstanceId = noProviderAvailable ? null : selectedInstanceId;1888 // One global queue. Stashed prompts carry only text + images so they can be
1892 const stashScope = promptStashScopeKey(stashScopeInstanceId);1889 // restored into any thread or provider — stash, switch, restore is the
1893 const stashQueue = usePromptStashStore(1890 // whole point.
1894 (state) => state.queuesByScopeKey[stashScope] ?? EMPTY_PROMPT_STASH_QUEUE,1891 const stashQueue = usePromptStashStore((state) => state.entries);
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 );
1902 const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry);1892 const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry);
1903 const takeStashEntry = usePromptStashStore((state) => state.takeEntry);1893 const takeStashEntry = usePromptStashStore((state) => state.takeEntry);
1904 const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages);1894 const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages);
1905 const stashProviderLabel = noProviderAvailable
1906 ? "No provider"
1907 : getProviderDisplayName(providerStatuses, selectedProvider);
19081895
1909 useEffect(() => {1896 useEffect(() => {
1910 return () => {1897 return () => {
...@@ -1930,10 +1917,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1930,10 +1917,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1930 const restoreStashEntry = useCallback(1917 const restoreStashEntry = useCallback(
1931 (entry: PromptStashEntry) => {1918 (entry: PromptStashEntry) => {
1932 // Remove first so a double activation (click + Enter) can't restore twice.1919 // Remove first so a double activation (click + Enter) can't restore twice.
1933 const { entry: taken, durable } = takeStashEntry(1920 const { entry: taken, durable } = takeStashEntry(entry.id);
1934 promptStashScopeKey(entry.providerInstanceId),
1935 entry.id,
1936 );
1937 if (!taken) return;1921 if (!taken) return;
1938 if (!durable) {1922 if (!durable) {
1939 toastManager.add({1923 toastManager.add({
...@@ -1996,21 +1980,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1996,21 +1980,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1996 }1980 }
1997 }1981 }
19981982
1999 const restorableSelection =1983 // Deliberately no model/provider restore: the stash exists to carry a
2000 entry.modelSelection &&1984 // prompt across threads and providers, so whatever the composer has
2001 providerInstanceEntries.some(1985 // selected right now stays selected.
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 }
20141986
2015 // Each cause gets its own sentence so "too large" is never blamed for a1987 // Each cause gets its own sentence so "too large" is never blamed for a
2016 // file that actually failed to decode, or for one the composer simply1988 // file that actually failed to decode, or for one the composer simply
...@@ -2052,8 +2024,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2052,8 +2024,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2052 composerDraftTarget,2024 composerDraftTarget,
2053 composerImagesRef,2025 composerImagesRef,
2054 promptRef,2026 promptRef,
2055 providerInstanceEntries,
2056 setComposerDraftModelSelection,
2057 setComposerDraftPrompt,2027 setComposerDraftPrompt,
2058 takeStashEntry,2028 takeStashEntry,
2059 ],2029 ],
...@@ -2061,7 +2031,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2061,7 +2031,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
20612031
2062 const deleteStashEntry = useCallback(2032 const deleteStashEntry = useCallback(
2063 (entry: PromptStashEntry) => {2033 (entry: PromptStashEntry) => {
2064 const { durable } = takeStashEntry(promptStashScopeKey(entry.providerInstanceId), entry.id);2034 const { durable } = takeStashEntry(entry.id);
2065 if (!durable) {2035 if (!durable) {
2066 toastManager.add({2036 toastManager.add({
2067 type: "warning",2037 type: "warning",
...@@ -2097,30 +2067,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2097,30 +2067,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
20972067
2098 const stashTarget = composerDraftTarget;2068 const stashTarget = composerDraftTarget;
2099 const entryId = randomUUID();2069 const entryId = randomUUID();
2100 const scopeKey = promptStashScopeKey(stashScopeInstanceId);
2101 try {2070 try {
2102 // Persist the text-only entry *first*, then clear. Ordering matters in2071 // Persist the text-only entry *first*, then clear. Ordering matters in
2103 // both directions: writing before clearing means a crash or closed tab2072 // both directions: writing before clearing means a crash or closed tab
2104 // mid-encode still leaves the prompt recoverable, while clearing before2073 // mid-encode still leaves the prompt recoverable, while clearing before
2105 // the async image work means edits typed during encoding are not wiped.2074 // the async image work means edits typed during encoding are not wiped.
2106 // Images are appended to the stored entry as they finish encoding.2075 // Images are appended to the stored entry as they finish encoding.
2107 const { evicted, durable } = stashEntryToQueue({2076 const { evicted, written, durable } = stashEntryToQueue({
2108 id: entryId,2077 id: entryId,
2109 createdAt: new Date().toISOString(),2078 createdAt: new Date().toISOString(),
2110 prompt,2079 prompt,
2111 attachments: [],2080 attachments: [],
2112 providerInstanceId: stashScopeInstanceId,
2113 modelSelection: noProviderAvailable ? null : selectedModelSelection,
2114 droppedImageNames: [],2081 droppedImageNames: [],
2115 unreadableImageNames: [],2082 unreadableImageNames: [],
2116 pendingImageCount: images.length,2083 pendingImageCount: images.length,
2117 });2084 });
21182085
2119 // Clearing the composer is only safe once the entry is durable. If the2086 // Clearing the composer is only safe once the write actually landed.
2120 // write was rejected (quota, blocked storage) the store has already2087 // If it was rejected (quota) the store has already rolled itself back,
2121 // rolled itself back, so leave the composer untouched rather than2088 // so leave the composer untouched rather than making it the second
2122 // making it the second casualty of a reload.2089 // casualty of a reload.
2123 if (!durable) {2090 if (!written) {
2124 toastManager.add({2091 toastManager.add({
2125 type: "error",2092 type: "error",
2126 title: "Could not stash this prompt",2093 title: "Could not stash this prompt",
...@@ -2130,6 +2097,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2130,6 +2097,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2130 });2097 });
2131 return;2098 return;
2132 }2099 }
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
2134 // Only the prompt and images are cleared — terminal/element contexts,2113 // Only the prompt and images are cleared — terminal/element contexts,
2135 // preview annotations, and review comments are not stashable, so2114 // preview annotations, and review comments are not stashable, so
...@@ -2144,7 +2123,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2144,7 +2123,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2144 toastManager.add({2123 toastManager.add({
2145 type: "warning",2124 type: "warning",
2146 title: "Oldest stashed prompt discarded",2125 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.`,
2148 data: { hideCopyButton: true },2127 data: { hideCopyButton: true },
2149 });2128 });
2150 }2129 }
...@@ -2176,7 +2155,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2176,7 +2155,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2176 }2155 }
2177 const { kept, droppedNames } = partitionStashAttachments(candidateAttachments);2156 const { kept, droppedNames } = partitionStashAttachments(candidateAttachments);
21782157
2179 const { attached, durable: imagesDurable } = finalizeStashEntryImages(scopeKey, entryId, {2158 const { attached, durable: imagesDurable } = finalizeStashEntryImages(entryId, {
2180 attachments: kept,2159 attachments: kept,
2181 droppedImageNames: [...oversizedImageNames, ...droppedNames],2160 droppedImageNames: [...oversizedImageNames, ...droppedNames],
2182 unreadableImageNames,2161 unreadableImageNames,
...@@ -2185,8 +2164,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2185,8 +2164,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2185 // The second phase can be rejected on its own: the text-only entry2164 // The second phase can be rejected on its own: the text-only entry
2186 // fit, but adding image payloads pushed past the quota. Disk would2165 // fit, but adding image payloads pushed past the quota. Disk would
2187 // then still hold the phase-one entry with pendingImageCount set,2166 // then still hold the phase-one entry with pendingImageCount set,
2188 // which reads as an orphan after reload — so say so now.2167 // which reads as an orphan after reload — so say so now. Gated on the
2189 if (!imagesDurable && images.length > 0) {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) {
2190 toastManager.add({2171 toastManager.add({
2191 type: "warning",2172 type: "warning",
2192 title: "Stashed images were not saved",2173 title: "Stashed images were not saved",
...@@ -2216,13 +2197,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2216,13 +2197,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2216 composerDraftTarget,2197 composerDraftTarget,
2217 composerImagesRef,2198 composerImagesRef,
2218 finalizeStashEntryImages,2199 finalizeStashEntryImages,
2219 noProviderAvailable,
2220 promptRef,2200 promptRef,
2221 pulseStashBadge,2201 pulseStashBadge,
2222 selectedModelSelection,
2223 stashEntryToQueue,2202 stashEntryToQueue,
2224 stashProviderLabel,
2225 stashScopeInstanceId,
2226 ]);2203 ]);
22272204
2228 const toggleStashMenu = useCallback(() => {2205 const toggleStashMenu = useCallback(() => {
...@@ -2832,8 +2809,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2832,8 +2809,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2832 <ComposerCommandMenuLayer anchor={composerMenuAnchor}>2809 <ComposerCommandMenuLayer anchor={composerMenuAnchor}>
2833 <ComposerStashMenu2810 <ComposerStashMenu
2834 entries={stashQueue}2811 entries={stashQueue}
2835 providerLabel={stashProviderLabel}
2836 otherScopesCount={stashOtherScopesCount}
2837 onRestore={restoreStashEntry}2812 onRestore={restoreStashEntry}
2838 onDelete={deleteStashEntry}2813 onDelete={deleteStashEntry}
2839 onClose={() => setIsStashMenuOpen(false)}2814 onClose={() => setIsStashMenuOpen(false)}
apps/web/src/components/chat/ComposerStashBadge.tsx+1-2
...@@ -5,8 +5,7 @@ import { cn } from "~/lib/utils";...@@ -5,8 +5,7 @@ import { cn } from "~/lib/utils";
55
6/**6/**
7 * Bookmark pill perched on the composer's top-right shoulder. Shows the7 * 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 opening8 * stash count and doubles as the click target for opening the stash menu.
9 * the stash menu.
10 *9 *
11 * On save the badge gives one quiet acknowledgement: it lifts to full10 * On save the badge gives one quiet acknowledgement: it lifts to full
12 * opacity and the count ticks over. `pulseKey` changes per stash, remounting11 * 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 {...@@ -24,16 +24,13 @@ function stashEntrySnippet(entry: PromptStashEntry): string {
24}24}
2525
26/**26/**
27 * Popover listing the current connection method's stashed prompts.27 * Popover listing the stashed prompts. Keyboard-first: opened by ⌘S on an
28 * Keyboard-first: opened by ⌘S on an empty composer, navigated with28 * empty composer, navigated with arrows, restored with Enter, dismissed
29 * arrows, restored with Enter, dismissed with Escape. The listener runs29 * with Escape. The listener runs capture-phase on window so it wins over
30 * capture-phase on window so it wins over the Lexical editor's handlers30 * the Lexical editor's handlers while the menu is open.
31 * while the menu is open.
32 */31 */
33export const ComposerStashMenu = memo(function ComposerStashMenu(props: {32export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
34 entries: ReadonlyArray<PromptStashEntry>;33 entries: ReadonlyArray<PromptStashEntry>;
35 providerLabel: string;
36 otherScopesCount: number;
37 onRestore: (entry: PromptStashEntry) => void;34 onRestore: (entry: PromptStashEntry) => void;
38 onDelete: (entry: PromptStashEntry) => void;35 onDelete: (entry: PromptStashEntry) => void;
39 onClose: () => void;36 onClose: () => void;
...@@ -99,12 +96,11 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -99,12 +96,11 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
99 <CommandGroup>96 <CommandGroup>
100 <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">97 <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">
101 <BookmarkIcon className="size-3" aria-hidden="true" />98 <BookmarkIcon className="size-3" aria-hidden="true" />
102 Stashed prompts — {props.providerLabel}99 Stashed prompts
103 </CommandGroupLabel>100 </CommandGroupLabel>
104 {entries.length === 0 ? (101 {entries.length === 0 ? (
105 <p className="px-3 pb-3 pt-1 text-muted-foreground/70 text-xs">102 <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 stash103 Nothing stashed yet. Press ⌘S with a prompt in the composer to stash it.
107 it.
108 </p>104 </p>
109 ) : (105 ) : (
110 entries.map((entry) => (106 entries.map((entry) => (
...@@ -173,12 +169,6 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -173,12 +169,6 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
173 ))169 ))
174 )}170 )}
175 </CommandGroup>171 </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}
182 </CommandList>172 </CommandList>
183 </div>173 </div>
184 </Command>174 </Command>
apps/web/src/promptStashStore.test.ts+43-94
...@@ -1,26 +1,19 @@...@@ -1,26 +1,19 @@
1import { ProviderInstanceId } from "@t3tools/contracts";
2import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";1import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
32
4import { removeLocalStorageItem } from "./hooks/useLocalStorage";3import { removeLocalStorageItem } from "./hooks/useLocalStorage";
54
6import {5import {
7 MAX_STASH_ENTRIES_PER_QUEUE,6 MAX_STASH_ENTRIES,
8 PROMPT_STASH_STORAGE_KEY,7 PROMPT_STASH_STORAGE_KEY,
9 MAX_STASH_ENTRY_ATTACHMENT_CHARS,8 MAX_STASH_ENTRY_ATTACHMENT_CHARS,
10 PROMPT_STASH_UNSCOPED_KEY,
11 partitionStashAttachments,9 partitionStashAttachments,
12 promptStashScopeKey,
13 usePromptStashStore,10 usePromptStashStore,
14 writePromptStashStorageForTest,11 writePromptStashStorageForTest,
15 type PromptStashEntry,12 type PromptStashEntry,
16} from "./promptStashStore";13} from "./promptStashStore";
1714
18const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent");
19const CODEX_INSTANCE = ProviderInstanceId.make("codex");
20
21function makeEntry(input: {15function makeEntry(input: {
22 id: string;16 id: string;
23 providerInstanceId?: ProviderInstanceId | null;
24 prompt?: string;17 prompt?: string;
25 attachmentChars?: number;18 attachmentChars?: number;
26}): PromptStashEntry {19}): PromptStashEntry {
...@@ -40,35 +33,16 @@ function makeEntry(input: {...@@ -40,35 +33,16 @@ function makeEntry(input: {
40 },33 },
41 ]34 ]
42 : [],35 : [],
43 providerInstanceId:
44 input.providerInstanceId === undefined ? CLAUDE_AGENT_INSTANCE : input.providerInstanceId,
45 modelSelection: null,
46 droppedImageNames: [],36 droppedImageNames: [],
47 };37 };
48}38}
4939
50function resetPromptStashStore() {40function resetPromptStashStore() {
51 usePromptStashStore.setState({ queuesByScopeKey: {} });41 usePromptStashStore.setState({ entries: [] });
52 writePromptStashStorageForTest("");42 writePromptStashStorageForTest("");
53 removeLocalStorageItem(PROMPT_STASH_STORAGE_KEY);43 removeLocalStorageItem(PROMPT_STASH_STORAGE_KEY);
54}44}
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
72describe("partitionStashAttachments", () => {46describe("partitionStashAttachments", () => {
73 it("keeps attachments within the budget and reports dropped names in order", () => {47 it("keeps attachments within the budget and reports dropped names in order", () => {
74 const small = {48 const small = {
...@@ -124,74 +98,50 @@ describe("promptStashStore", () => {...@@ -124,74 +98,50 @@ describe("promptStashStore", () => {
124 const store = usePromptStashStore.getState();98 const store = usePromptStashStore.getState();
125 store.stashEntry(makeEntry({ id: "first" }));99 store.stashEntry(makeEntry({ id: "first" }));
126 store.stashEntry(makeEntry({ id: "second" }));100 store.stashEntry(makeEntry({ id: "second" }));
127 const queue =101 const entries = usePromptStashStore.getState().entries;
128 usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ??102 expect(entries.map((entry) => entry.id)).toEqual(["second", "first"]);
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"]);
146 });103 });
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", () => {
149 const store = usePromptStashStore.getState();106 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) {
151 expect(store.stashEntry(makeEntry({ id: `entry-${index}` })).evicted).toBeNull();108 expect(store.stashEntry(makeEntry({ id: `entry-${index}` })).evicted).toBeNull();
152 }109 }
153 const { evicted } = store.stashEntry(makeEntry({ id: "overflow" }));110 const { evicted } = store.stashEntry(makeEntry({ id: "overflow" }));
154 expect(evicted?.id).toBe("entry-0");111 expect(evicted?.id).toBe("entry-0");
155 const queue =112 const entries = usePromptStashStore.getState().entries;
156 usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ??113 expect(entries).toHaveLength(MAX_STASH_ENTRIES);
157 [];114 expect(entries[0]?.id).toBe("overflow");
158 expect(queue).toHaveLength(MAX_STASH_ENTRIES_PER_QUEUE);
159 expect(queue[0]?.id).toBe("overflow");
160 });115 });
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", () => {
163 const store = usePromptStashStore.getState();121 const store = usePromptStashStore.getState();
164 store.stashEntry(makeEntry({ id: "keep" }));122 const result = store.stashEntry(makeEntry({ id: "memory-only" }));
165 store.stashEntry(makeEntry({ id: "take" }));123 expect(result.written).toBe(true);
166 expect(store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "take").entry?.id).toBe(124 expect(result.durable).toBe(false);
167 "take",125 expect(usePromptStashStore.getState().entries.map((entry) => entry.id)).toEqual([
168 );126 "memory-only",
169 expect(store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "take").entry).toBeNull();127 ]);
170 const queue =
171 usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ??
172 [];
173 expect(queue.map((entry) => entry.id)).toEqual(["keep"]);
174 });128 });
175129
176 // Queue keys are persisted as plain strings, so a hand-edited or corrupted130 it("takeEntry removes and returns the entry; second take returns null", () => {
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 });
184 const store = usePromptStashStore.getState();131 const store = usePromptStashStore.getState();
185 expect(() => store.takeEntry("__proto__", "missing")).not.toThrow();132 store.stashEntry(makeEntry({ id: "keep" }));
186 expect(store.takeEntry("__proto__", "missing").entry).toBeNull();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"]);
187 });138 });
188139
189 it("finalizeEntryImages attaches images and clears the pending count", () => {140 it("finalizeEntryImages attaches images and clears the pending count", () => {
190 const store = usePromptStashStore.getState();141 const store = usePromptStashStore.getState();
191 const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE);
192 store.stashEntry({ ...makeEntry({ id: "pending" }), pendingImageCount: 2 });142 store.stashEntry({ ...makeEntry({ id: "pending" }), pendingImageCount: 2 });
193143
194 const { attached } = store.finalizeEntryImages(scopeKey, "pending", {144 const { attached } = store.finalizeEntryImages("pending", {
195 attachments: [145 attachments: [
196 {146 {
197 id: "img-1",147 id: "img-1",
...@@ -206,7 +156,7 @@ describe("promptStashStore", () => {...@@ -206,7 +156,7 @@ describe("promptStashStore", () => {
206 });156 });
207157
208 expect(attached).toBe(true);158 expect(attached).toBe(true);
209 const entry = usePromptStashStore.getState().queuesByScopeKey[scopeKey]?.[0];159 const entry = usePromptStashStore.getState().entries[0];
210 expect(entry?.attachments).toHaveLength(1);160 expect(entry?.attachments).toHaveLength(1);
211 expect(entry?.droppedImageNames).toEqual(["big.png"]);161 expect(entry?.droppedImageNames).toEqual(["big.png"]);
212 expect(entry?.pendingImageCount).toBe(0);162 expect(entry?.pendingImageCount).toBe(0);
...@@ -214,12 +164,11 @@ describe("promptStashStore", () => {...@@ -214,12 +164,11 @@ describe("promptStashStore", () => {
214164
215 it("finalizeEntryImages reports false when the entry was already taken", () => {165 it("finalizeEntryImages reports false when the entry was already taken", () => {
216 const store = usePromptStashStore.getState();166 const store = usePromptStashStore.getState();
217 const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE);
218 store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 });167 store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 });
219 // Restored (or deleted) while its images were still encoding.168 // 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", {
223 attachments: [],172 attachments: [],
224 droppedImageNames: [],173 droppedImageNames: [],
225 unreadableImageNames: [],174 unreadableImageNames: [],
...@@ -229,31 +178,31 @@ describe("promptStashStore", () => {...@@ -229,31 +178,31 @@ describe("promptStashStore", () => {
229 });178 });
230179
231 it("settles a pending count left behind by a crashed or closed session", () => {180 it("settles a pending count left behind by a crashed or closed session", () => {
232 const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE);
233 writePromptStashStorageForTest(181 writePromptStashStorageForTest(
234 JSON.stringify({182 JSON.stringify({
235 version: 1,183 version: 2,
236 state: {184 state: {
237 queuesByScopeKey: {185 entries: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }],
238 [scopeKey]: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }],
239 },
240 },186 },
241 }),187 }),
242 );188 );
243189
244 // Hydration must settle the stale count, or the entry would stay stuck190 // Hydration must settle the stale count, or the entry would stay stuck
245 // showing "saving…" with images that no longer exist anywhere.191 // showing "saving…" with images that no longer exist anywhere.
246 const entry = usePromptStashStore.getState().queuesByScopeKey[scopeKey]?.[0];192 const entry = usePromptStashStore.getState().entries[0];
247 expect(entry?.pendingImageCount).toBe(0);193 expect(entry?.pendingImageCount).toBe(0);
248 expect(entry?.unreadableImageNames).toHaveLength(2);194 expect(entry?.unreadableImageNames).toHaveLength(2);
249 });195 });
250196
251 it("drops the scope key entirely when its queue empties", () => {197 it("ignores an unreadable v1 payload seeded under the current key", () => {
252 const store = usePromptStashStore.getState();198 // The v1 shape (per-provider queues) does not decode as v2; hydration
253 store.stashEntry(makeEntry({ id: "only" }));199 // must fall back to an empty stash rather than throw.
254 store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "only");200 writePromptStashStorageForTest(
255 expect(201 JSON.stringify({
256 usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)],202 version: 1,
257 ).toBeUndefined();203 state: { queuesByScopeKey: { "provider:claudeAgent": [] } },
204 }),
205 );
206 expect(usePromptStashStore.getState().entries).toEqual([]);
258 });207 });
259});208});
apps/web/src/promptStashStore.ts+78-107
...@@ -1,24 +1,20 @@...@@ -1,24 +1,20 @@
1import { ModelSelection, ProviderInstanceId } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
3import { create } from "zustand";2import { create } from "zustand";
43
5import { PersistedComposerImageAttachment } from "./composerDraftStore";4import { PersistedComposerImageAttachment } from "./composerDraftStore";
6import { createMemoryStorage, type StateStorage } from "./lib/storage";5import { createMemoryStorage, type StateStorage } from "./lib/storage";
76
8export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1";7export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v2";
9const PROMPT_STASH_STORAGE_VERSION = 1;
10
11/**8/**
12 * Queue bucket for prompts stashed while no provider instance is selected.9 * v1 bucketed entries into per-provider-instance queues and stored a model
13 *10 * selection with each prompt. The stash is provider-agnostic now, so the old
14 * Provider-scoped keys are prefixed (see `promptStashScopeKey`), so this11 * payload is deleted at startup rather than migrated — left behind it would
15 * sentinel can never collide with a provider literally named `__none__`.12 * silently hold megabytes of the origin's ~5MB localStorage quota forever.
16 */13 */
17export const PROMPT_STASH_UNSCOPED_KEY = "__none__";14const LEGACY_PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1";
18/** Namespace applied to provider-derived keys to keep them collision-proof. */15const PROMPT_STASH_STORAGE_VERSION = 2;
19const PROVIDER_SCOPE_PREFIX = "provider:";
2016
21export const MAX_STASH_ENTRIES_PER_QUEUE = 20;17export const MAX_STASH_ENTRIES = 20;
22/**18/**
23 * Budget for an entry's serialized attachment payload. localStorage is a19 * Budget for an entry's serialized attachment payload. localStorage is a
24 * ~5MB origin-wide quota shared with the composer draft store, so oversized20 * ~5MB origin-wide quota shared with the composer draft store, so oversized
...@@ -30,13 +26,17 @@ export const MAX_STASH_ENTRIES_PER_QUEUE = 20;...@@ -30,13 +26,17 @@ export const MAX_STASH_ENTRIES_PER_QUEUE = 20;
30 */26 */
31export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000;27export 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 */
33const StashEntrySchema = Schema.Struct({35const StashEntrySchema = Schema.Struct({
34 id: Schema.String,36 id: Schema.String,
35 createdAt: Schema.String,37 createdAt: Schema.String,
36 prompt: Schema.String,38 prompt: Schema.String,
37 attachments: Schema.Array(PersistedComposerImageAttachment),39 attachments: Schema.Array(PersistedComposerImageAttachment),
38 providerInstanceId: Schema.NullOr(ProviderInstanceId),
39 modelSelection: Schema.NullOr(ModelSelection),
40 /** Names of images that exceeded the attachment budget and were not saved. */40 /** Names of images that exceeded the attachment budget and were not saved. */
41 droppedImageNames: Schema.Array(Schema.String),41 droppedImageNames: Schema.Array(Schema.String),
42 /**42 /**
...@@ -57,7 +57,7 @@ const StashEntrySchema = Schema.Struct({...@@ -57,7 +57,7 @@ const StashEntrySchema = Schema.Struct({
57export type PromptStashEntry = typeof StashEntrySchema.Type;57export type PromptStashEntry = typeof StashEntrySchema.Type;
5858
59const PersistedPromptStashState = Schema.Struct({59const PersistedPromptStashState = Schema.Struct({
60 queuesByScopeKey: Schema.Record(Schema.String, Schema.Array(StashEntrySchema)),60 entries: Schema.Array(StashEntrySchema),
61});61});
62type PersistedPromptStashState = typeof PersistedPromptStashState.Type;62type PersistedPromptStashState = typeof PersistedPromptStashState.Type;
6363
...@@ -74,44 +74,23 @@ const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPrompt...@@ -74,44 +74,23 @@ const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPrompt
74 * recorded as unreadable to keep the prompt itself restorable.74 * recorded as unreadable to keep the prompt itself restorable.
75 */75 */
76function clearOrphanedPendingImages(76function clearOrphanedPendingImages(
77 queues: Record<string, ReadonlyArray<PromptStashEntry>>,77 entries: 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,
113): ReadonlyArray<PromptStashEntry> {78): 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 });
115}94}
11695
117/**96/**
...@@ -163,7 +142,7 @@ function resolveBaseStorage(): { storage: StateStorage; durable: boolean } {...@@ -163,7 +142,7 @@ function resolveBaseStorage(): { storage: StateStorage; durable: boolean } {
163const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStorage();142const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStorage();
164143
165/**144/**
166 * Persists the queues, immediately rather than debounced. Stashing is a145 * Persists the queue, immediately rather than debounced. Stashing is a
167 * deliberate, infrequent keystroke — not a per-character autosave — so there146 * deliberate, infrequent keystroke — not a per-character autosave — so there
168 * is nothing to coalesce, and the caller clears the composer on the strength147 * is nothing to coalesce, and the caller clears the composer on the strength
169 * of this write landing, which a debounce timer cannot honestly report.148 * of this write landing, which a debounce timer cannot honestly report.
...@@ -171,7 +150,7 @@ const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStor...@@ -171,7 +150,7 @@ const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStor
171 * Returns whether the write will survive a reload: false on a quota rejection150 * Returns whether the write will survive a reload: false on a quota rejection
172 * or when only the in-memory fallback is available.151 * or when only the in-memory fallback is available.
173 */152 */
174function persistQueues(queues: Record<string, ReadonlyArray<PromptStashEntry>>): {153function persistEntries(entries: ReadonlyArray<PromptStashEntry>): {
175 /** The write succeeded (possibly only into the in-memory fallback). */154 /** The write succeeded (possibly only into the in-memory fallback). */
176 written: boolean;155 written: boolean;
177 /** The write will survive a reload. */156 /** The write will survive a reload. */
...@@ -182,7 +161,7 @@ function persistQueues(queues: Record<string, ReadonlyArray<PromptStashEntry>>):...@@ -182,7 +161,7 @@ function persistQueues(queues: Record<string, ReadonlyArray<PromptStashEntry>>):
182 PROMPT_STASH_STORAGE_KEY,161 PROMPT_STASH_STORAGE_KEY,
183 JSON.stringify({162 JSON.stringify({
184 version: PROMPT_STASH_STORAGE_VERSION,163 version: PROMPT_STASH_STORAGE_VERSION,
185 state: { queuesByScopeKey: queues },164 state: { entries },
186 }),165 }),
187 );166 );
188 return { written: true, durable: storageIsDurable };167 return { written: true, durable: storageIsDurable };
...@@ -192,40 +171,42 @@ function persistQueues(queues: Record<string, ReadonlyArray<PromptStashEntry>>):...@@ -192,40 +171,42 @@ function persistQueues(queues: Record<string, ReadonlyArray<PromptStashEntry>>):
192 }171 }
193}172}
194173
195/** Reads the persisted queues, settling stale pending counts. */174/** Reads the persisted queue, settling stale pending counts. */
196function readPersistedQueues(): Record<string, ReadonlyArray<PromptStashEntry>> | null {175function readPersistedEntries(): ReadonlyArray<PromptStashEntry> | null {
197 try {176 try {
198 const raw = baseStashStorage.getItem(PROMPT_STASH_STORAGE_KEY);177 const raw = baseStashStorage.getItem(PROMPT_STASH_STORAGE_KEY);
199 if (typeof raw !== "string" || raw.length === 0) return null;178 if (typeof raw !== "string" || raw.length === 0) return null;
200 const parsed: unknown = JSON.parse(raw);179 const parsed: unknown = JSON.parse(raw);
201 const state = (parsed as { state?: unknown } | null)?.state;180 const state = (parsed as { state?: unknown } | null)?.state;
202 if (!state) return null;181 if (!state) return null;
203 return clearOrphanedPendingImages(decodePersistedPromptStashState(state).queuesByScopeKey);182 return clearOrphanedPendingImages(decodePersistedPromptStashState(state).entries);
204 } catch {183 } catch {
205 return null;184 return null;
206 }185 }
207}186}
208187
209interface PromptStashStoreState {188interface PromptStashStoreState {
210 queuesByScopeKey: Record<string, ReadonlyArray<PromptStashEntry>>;189 entries: ReadonlyArray<PromptStashEntry>;
211 /**190 /**
212 * Prepends an entry to its scope's queue, evicting the oldest entry past191 * Prepends an entry to the queue, evicting the oldest entry past the cap.
213 * the per-queue cap. Returns the evicted entry (for messaging) if any.192 * Returns the evicted entry (for messaging) if any.
214 */193 */
215 stashEntry: (entry: PromptStashEntry) => {194 stashEntry: (entry: PromptStashEntry) => {
216 evicted: PromptStashEntry | null;195 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 */
218 durable: boolean;202 durable: boolean;
219 };203 };
220 /**204 /**
221 * Removes and returns an entry from a scope's queue (restore + delete).205 * Removes and returns an entry from the queue (restore + delete).
222 * `durable` is false when the removal could not be persisted, meaning a206 * `durable` is false when the removal could not be persisted, meaning a
223 * reload would resurrect the entry.207 * reload would resurrect the entry.
224 */208 */
225 takeEntry: (209 takeEntry: (entryId: string) => { entry: PromptStashEntry | null; durable: boolean };
226 scopeKey: string,
227 entryId: string,
228 ) => { entry: PromptStashEntry | null; durable: boolean };
229 /**210 /**
230 * Attaches the encoded images to an entry written earlier by `stashEntry`,211 * Attaches the encoded images to an entry written earlier by `stashEntry`,
231 * clearing its pending count. Returns attached=false when the entry is gone212 * clearing its pending count. Returns attached=false when the entry is gone
...@@ -233,7 +214,6 @@ interface PromptStashStoreState {...@@ -233,7 +214,6 @@ interface PromptStashStoreState {
233 * tell the user their images did not make it.214 * tell the user their images did not make it.
234 */215 */
235 finalizeEntryImages: (216 finalizeEntryImages: (
236 scopeKey: string,
237 entryId: string,217 entryId: string,
238 images: {218 images: {
239 attachments: ReadonlyArray<PersistedComposerImageAttachment>;219 attachments: ReadonlyArray<PersistedComposerImageAttachment>;
...@@ -244,58 +224,45 @@ interface PromptStashStoreState {...@@ -244,58 +224,45 @@ interface PromptStashStoreState {
244}224}
245225
246export const usePromptStashStore = create<PromptStashStoreState>()((set, get) => ({226export const usePromptStashStore = create<PromptStashStoreState>()((set, get) => ({
247 queuesByScopeKey: {},227 entries: [],
248 stashEntry: (entry) => {228 stashEntry: (entry) => {
249 const scopeKey = promptStashScopeKey(entry.providerInstanceId);229 const nextEntries = [entry, ...get().entries];
250 const queues = get().queuesByScopeKey;230 const evicted = nextEntries.length > MAX_STASH_ENTRIES ? (nextEntries.pop() ?? null) : null;
251 const nextQueue = [entry, ...readQueue(queues, scopeKey)];231 const { written, durable } = persistEntries(nextEntries);
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);
256 // A rejected write must not leave the entry visible either: the caller232 // A rejected write must not leave the entry visible either: the caller
257 // keeps the composer intact on failure, so a stashed copy would233 // keeps the composer intact on failure, so a stashed copy would
258 // duplicate the prompt. Eviction likewise only sticks on success.234 // duplicate the prompt. Eviction likewise only sticks on success.
259 if (!written) {235 if (!written) {
260 return { evicted: null, durable: false };236 return { evicted: null, written: false, durable: false };
261 }237 }
262 set(() => ({ queuesByScopeKey: next }));238 set(() => ({ entries: nextEntries }));
263 return { evicted, durable };239 return { evicted, written: true, durable };
264 },240 },
265 takeEntry: (scopeKey, entryId) => {241 takeEntry: (entryId) => {
266 const queues = get().queuesByScopeKey;242 const entries = get().entries;
267 const queue = readQueue(queues, scopeKey);243 const entry = entries.find((candidate) => candidate.id === entryId) ?? null;
268 const entry = queue.find((candidate) => candidate.id === entryId) ?? null;
269 if (!entry) return { entry: null, durable: true };244 if (!entry) return { entry: null, durable: true };
270 const nextQueue = queue.filter((candidate) => candidate.id !== entryId);245 const nextEntries = entries.filter((candidate) => candidate.id !== entryId);
271 const next = { ...queues };246 const { durable } = persistEntries(nextEntries);
272 if (nextQueue.length === 0) {247 set(() => ({ entries: nextEntries }));
273 delete next[scopeKey];
274 } else {
275 next[scopeKey] = nextQueue;
276 }
277 const { durable } = persistQueues(next);
278 set(() => ({ queuesByScopeKey: next }));
279 return { entry, durable };248 return { entry, durable };
280 },249 },
281 finalizeEntryImages: (scopeKey, entryId, images) => {250 finalizeEntryImages: (entryId, images) => {
282 const queues = get().queuesByScopeKey;251 const entries = get().entries;
283 const queue = readQueue(queues, scopeKey);252 const index = entries.findIndex((candidate) => candidate.id === entryId);
284 const index = queue.findIndex((candidate) => candidate.id === entryId);253 const existing = index === -1 ? undefined : entries[index];
285 const existing = index === -1 ? undefined : queue[index];
286 // Restored or deleted mid-encode: nothing to attach to.254 // Restored or deleted mid-encode: nothing to attach to.
287 if (!existing) return { attached: false, durable: true };255 if (!existing) return { attached: false, durable: true };
288 const nextQueue = [...queue];256 const nextEntries = [...entries];
289 nextQueue[index] = {257 nextEntries[index] = {
290 ...existing,258 ...existing,
291 attachments: images.attachments,259 attachments: images.attachments,
292 droppedImageNames: images.droppedImageNames,260 droppedImageNames: images.droppedImageNames,
293 unreadableImageNames: images.unreadableImageNames,261 unreadableImageNames: images.unreadableImageNames,
294 pendingImageCount: 0,262 pendingImageCount: 0,
295 };263 };
296 const next = { ...queues, [scopeKey]: nextQueue };264 const { durable } = persistEntries(nextEntries);
297 const { durable } = persistQueues(next);265 set(() => ({ entries: nextEntries }));
298 set(() => ({ queuesByScopeKey: next }));
299 return { attached: true, durable };266 return { attached: true, durable };
300 },267 },
301}));268}));
...@@ -303,14 +270,18 @@ export const usePromptStashStore = create<PromptStashStoreState>()((set, get) =>...@@ -303,14 +270,18 @@ export const usePromptStashStore = create<PromptStashStoreState>()((set, get) =>
303// Hydrate once at startup. Like the app's other persisted stores, tabs are270// Hydrate once at startup. Like the app's other persisted stores, tabs are
304// last-write-wins: no cross-tab merging or storage-event syncing.271// last-write-wins: no cross-tab merging or storage-event syncing.
305{272{
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();
307 if (persisted) {280 if (persisted) {
308 usePromptStashStore.setState({ queuesByScopeKey: persisted });281 usePromptStashStore.setState({ entries: persisted });
309 }282 }
310}283}
311284
312export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray<PromptStashEntry> = [];
313
314/**285/**
315 * Test seam: seeds the persisted payload through the same storage the store286 * Test seam: seeds the persisted payload through the same storage the store
316 * reads and rehydrates, without needing a real `localStorage` global.287 * reads and rehydrates, without needing a real `localStorage` global.
...@@ -318,5 +289,5 @@ export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray<PromptStashEntry> = [];...@@ -318,5 +289,5 @@ export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray<PromptStashEntry> = [];
318 */289 */
319export function writePromptStashStorageForTest(raw: string): void {290export function writePromptStashStorageForTest(raw: string): void {
320 baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw);291 baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw);
321 usePromptStashStore.setState({ queuesByScopeKey: readPersistedQueues() ?? {} });292 usePromptStashStore.setState({ entries: readPersistedEntries() ?? [] });
322}293}