Unified · Split
| @@ -57,10 +57,8 @@ import { · 56 unmodified lines | ||
| 57 | 57 | useEffectiveComposerModelState, |
| 58 | 58 | } from "../../composerDraftStore"; |
| 59 | 59 | import { |
| 60 | − EMPTY_PROMPT_STASH_QUEUE, | |
| 61 | − MAX_STASH_ENTRIES_PER_QUEUE, | |
| 60 | + MAX_STASH_ENTRIES, | |
| 62 | 61 | partitionStashAttachments, |
| 63 | − promptStashScopeKey, | |
| 64 | 62 | usePromptStashStore, |
| 65 | 63 | type PromptStashEntry, |
| 66 | 64 | } from "../../promptStashStore"; |
| @@ -733,7 +731,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 666 unmodified lines | ||
| 733 | 731 | const clearComposerDraftPromptAndImages = useComposerDraftStore( |
| 734 | 732 | (store) => store.clearComposerPromptAndImages, |
| 735 | 733 | ); |
| 736 | − const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); | |
| 737 | 734 | const syncComposerDraftPersistedAttachments = useComposerDraftStore( |
| 738 | 735 | (store) => store.syncPersistedAttachments, |
| 739 | 736 | ); |
| @@ -1888,23 +1885,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 1148 unmodified lines | ||
| 1888 | 1885 | // ------------------------------------------------------------------ |
| 1889 | 1886 | // Prompt stash (⌘S) |
| 1890 | 1887 | // ------------------------------------------------------------------ |
| 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); | |
| 1902 | 1892 | const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry); |
| 1903 | 1893 | const takeStashEntry = usePromptStashStore((state) => state.takeEntry); |
| 1904 | 1894 | const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages); |
| 1905 | − const stashProviderLabel = noProviderAvailable | |
| 1906 | − ? "No provider" | |
| 1907 | − : getProviderDisplayName(providerStatuses, selectedProvider); | |
| 1908 | 1895 | |
| 1909 | 1896 | useEffect(() => { |
| 1910 | 1897 | return () => { |
| @@ -1930,10 +1917,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 19 unmodified lines | ||
| 1930 | 1917 | const restoreStashEntry = useCallback( |
| 1931 | 1918 | (entry: PromptStashEntry) => { |
| 1932 | 1919 | // 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); | |
| 1937 | 1921 | if (!taken) return; |
| 1938 | 1922 | if (!durable) { |
| 1939 | 1923 | toastManager.add({ |
| @@ -1996,21 +1980,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 56 unmodified lines | ||
| 1996 | 1980 | } |
| 1997 | 1981 | } |
| 1998 | 1982 | |
| 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. | |
| 2014 | 1986 | |
| 2015 | 1987 | // Each cause gets its own sentence so "too large" is never blamed for a |
| 2016 | 1988 | // 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 | ||
| 2052 | 2024 | composerDraftTarget, |
| 2053 | 2025 | composerImagesRef, |
| 2054 | 2026 | promptRef, |
| 2055 | − providerInstanceEntries, | |
| 2056 | − setComposerDraftModelSelection, | |
| 2057 | 2027 | setComposerDraftPrompt, |
| 2058 | 2028 | takeStashEntry, |
| 2059 | 2029 | ], |
| @@ -2061,7 +2031,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 1 unmodified line | ||
| 2061 | 2031 | |
| 2062 | 2032 | const deleteStashEntry = useCallback( |
| 2063 | 2033 | (entry: PromptStashEntry) => { |
| 2064 | − const { durable } = takeStashEntry(promptStashScopeKey(entry.providerInstanceId), entry.id); | |
| 2034 | + const { durable } = takeStashEntry(entry.id); | |
| 2065 | 2035 | if (!durable) { |
| 2066 | 2036 | toastManager.add({ |
| 2067 | 2037 | type: "warning", |
| @@ -2097,30 +2067,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 29 unmodified lines | ||
| 2097 | 2067 | |
| 2098 | 2068 | const stashTarget = composerDraftTarget; |
| 2099 | 2069 | const entryId = randomUUID(); |
| 2100 | − const scopeKey = promptStashScopeKey(stashScopeInstanceId); | |
| 2101 | 2070 | try { |
| 2102 | 2071 | // Persist the text-only entry *first*, then clear. Ordering matters in |
| 2103 | 2072 | // both directions: writing before clearing means a crash or closed tab |
| 2104 | 2073 | // mid-encode still leaves the prompt recoverable, while clearing before |
| 2105 | 2074 | // the async image work means edits typed during encoding are not wiped. |
| 2106 | 2075 | // Images are appended to the stored entry as they finish encoding. |
| 2107 | − const { evicted, durable } = stashEntryToQueue({ | |
| 2076 | + const { evicted, written, durable } = stashEntryToQueue({ | |
| 2108 | 2077 | id: entryId, |
| 2109 | 2078 | createdAt: new Date().toISOString(), |
| 2110 | 2079 | prompt, |
| 2111 | 2080 | attachments: [], |
| 2112 | − providerInstanceId: stashScopeInstanceId, | |
| 2113 | − modelSelection: noProviderAvailable ? null : selectedModelSelection, | |
| 2114 | 2081 | droppedImageNames: [], |
| 2115 | 2082 | unreadableImageNames: [], |
| 2116 | 2083 | pendingImageCount: images.length, |
| 2117 | 2084 | }); |
| 2118 | 2085 | |
| 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) { | |
| 2124 | 2091 | toastManager.add({ |
| 2125 | 2092 | type: "error", |
| 2126 | 2093 | title: "Could not stash this prompt", |
| @@ -2130,6 +2097,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 3 unmodified lines | ||
| 2130 | 2097 | }); |
| 2131 | 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 | + } | |
| 2133 | 2112 | |
| 2134 | 2113 | // Only the prompt and images are cleared — terminal/element contexts, |
| 2135 | 2114 | // preview annotations, and review comments are not stashable, so |
| @@ -2144,7 +2123,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 8 unmodified lines | ||
| 2144 | 2123 | toastManager.add({ |
| 2145 | 2124 | type: "warning", |
| 2146 | 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 | 2127 | data: { hideCopyButton: true }, |
| 2149 | 2128 | }); |
| 2150 | 2129 | } |
| @@ -2176,7 +2155,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 25 unmodified lines | ||
| 2176 | 2155 | } |
| 2177 | 2156 | const { kept, droppedNames } = partitionStashAttachments(candidateAttachments); |
| 2178 | 2157 | |
| 2179 | − const { attached, durable: imagesDurable } = finalizeStashEntryImages(scopeKey, entryId, { | |
| 2158 | + const { attached, durable: imagesDurable } = finalizeStashEntryImages(entryId, { | |
| 2180 | 2159 | attachments: kept, |
| 2181 | 2160 | droppedImageNames: [...oversizedImageNames, ...droppedNames], |
| 2182 | 2161 | unreadableImageNames, |
| @@ -2185,8 +2164,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 2 unmodified lines | ||
| 2185 | 2164 | // The second phase can be rejected on its own: the text-only entry |
| 2186 | 2165 | // fit, but adding image payloads pushed past the quota. Disk would |
| 2187 | 2166 | // 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) { | |
| 2190 | 2171 | toastManager.add({ |
| 2191 | 2172 | type: "warning", |
| 2192 | 2173 | title: "Stashed images were not saved", |
| @@ -2216,13 +2197,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 23 unmodified lines | ||
| 2216 | 2197 | composerDraftTarget, |
| 2217 | 2198 | composerImagesRef, |
| 2218 | 2199 | finalizeStashEntryImages, |
| 2219 | − noProviderAvailable, | |
| 2220 | 2200 | promptRef, |
| 2221 | 2201 | pulseStashBadge, |
| 2222 | − selectedModelSelection, | |
| 2223 | 2202 | stashEntryToQueue, |
| 2224 | − stashProviderLabel, | |
| 2225 | − stashScopeInstanceId, | |
| 2226 | 2203 | ]); |
| 2227 | 2204 | |
| 2228 | 2205 | const toggleStashMenu = useCallback(() => { |
| @@ -2832,8 +2809,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 603 unmodified lines | ||
| 2832 | 2809 | <ComposerCommandMenuLayer anchor={composerMenuAnchor}> |
| 2833 | 2810 | <ComposerStashMenu |
| 2834 | 2811 | entries={stashQueue} |
| 2835 | − providerLabel={stashProviderLabel} | |
| 2836 | − otherScopesCount={stashOtherScopesCount} | |
| 2837 | 2812 | onRestore={restoreStashEntry} |
| 2838 | 2813 | onDelete={deleteStashEntry} |
| 2839 | 2814 | onClose={() => setIsStashMenuOpen(false)} |
| @@ -5,8 +5,7 @@ import { cn } from "~/lib/utils"; · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | /** |
| 7 | 7 | * 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. | |
| 10 | 9 | * |
| 11 | 10 | * On save the badge gives one quiet acknowledgement: it lifts to full |
| 12 | 11 | * opacity and the count ticks over. `pulseKey` changes per stash, remounting |
| @@ -24,16 +24,13 @@ function stashEntrySnippet(entry: PromptStashEntry): string { · 23 unmodified lines | ||
| 24 | 24 | } |
| 25 | 25 | |
| 26 | 26 | /** |
| 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. | |
| 32 | 31 | */ |
| 33 | 32 | export const ComposerStashMenu = memo(function ComposerStashMenu(props: { |
| 34 | 33 | entries: ReadonlyArray<PromptStashEntry>; |
| 35 | − providerLabel: string; | |
| 36 | − otherScopesCount: number; | |
| 37 | 34 | onRestore: (entry: PromptStashEntry) => void; |
| 38 | 35 | onDelete: (entry: PromptStashEntry) => void; |
| 39 | 36 | onClose: () => void; |
| @@ -99,12 +96,11 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { · 59 unmodified lines | ||
| 99 | 96 | <CommandGroup> |
| 100 | 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 | 98 | <BookmarkIcon className="size-3" aria-hidden="true" /> |
| 102 | − Stashed prompts — {props.providerLabel} | |
| 99 | + Stashed prompts | |
| 103 | 100 | </CommandGroupLabel> |
| 104 | 101 | {entries.length === 0 ? ( |
| 105 | 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 stash | |
| 107 | − it. | |
| 103 | + Nothing stashed yet. Press ⌘S with a prompt in the composer to stash it. | |
| 108 | 104 | </p> |
| 109 | 105 | ) : ( |
| 110 | 106 | entries.map((entry) => ( |
| @@ -173,12 +169,6 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { · 62 unmodified lines | ||
| 173 | 169 | )) |
| 174 | 170 | )} |
| 175 | 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 | 172 | </CommandList> |
| 183 | 173 | </div> |
| 184 | 174 | </Command> |
| @@ -1,26 +1,19 @@ | ||
| 1 | −import { ProviderInstanceId } from "@t3tools/contracts"; | |
| 2 | 1 | import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; |
| 3 | 2 | |
| 4 | 3 | import { removeLocalStorageItem } from "./hooks/useLocalStorage"; |
| 5 | 4 | |
| 6 | 5 | import { |
| 7 | − MAX_STASH_ENTRIES_PER_QUEUE, | |
| 6 | + MAX_STASH_ENTRIES, | |
| 8 | 7 | PROMPT_STASH_STORAGE_KEY, |
| 9 | 8 | MAX_STASH_ENTRY_ATTACHMENT_CHARS, |
| 10 | − PROMPT_STASH_UNSCOPED_KEY, | |
| 11 | 9 | partitionStashAttachments, |
| 12 | − promptStashScopeKey, | |
| 13 | 10 | usePromptStashStore, |
| 14 | 11 | writePromptStashStorageForTest, |
| 15 | 12 | type PromptStashEntry, |
| 16 | 13 | } from "./promptStashStore"; |
| 17 | 14 | |
| 18 | −const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent"); | |
| 19 | −const CODEX_INSTANCE = ProviderInstanceId.make("codex"); | |
| 20 | − | |
| 21 | 15 | function makeEntry(input: { |
| 22 | 16 | id: string; |
| 23 | − providerInstanceId?: ProviderInstanceId | null; | |
| 24 | 17 | prompt?: string; |
| 25 | 18 | attachmentChars?: number; |
| 26 | 19 | }): PromptStashEntry { |
| @@ -40,35 +33,16 @@ function makeEntry(input: { · 13 unmodified lines | ||
| 40 | 33 | }, |
| 41 | 34 | ] |
| 42 | 35 | : [], |
| 43 | − providerInstanceId: | |
| 44 | − input.providerInstanceId === undefined ? CLAUDE_AGENT_INSTANCE : input.providerInstanceId, | |
| 45 | − modelSelection: null, | |
| 46 | 36 | droppedImageNames: [], |
| 47 | 37 | }; |
| 48 | 38 | } |
| 49 | 39 | |
| 50 | 40 | function resetPromptStashStore() { |
| 51 | − usePromptStashStore.setState({ queuesByScopeKey: {} }); | |
| 41 | + usePromptStashStore.setState({ entries: [] }); | |
| 52 | 42 | writePromptStashStorageForTest(""); |
| 53 | 43 | removeLocalStorageItem(PROMPT_STASH_STORAGE_KEY); |
| 54 | 44 | } |
| 55 | 45 | |
| 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 | − | |
| 72 | 46 | describe("partitionStashAttachments", () => { |
| 73 | 47 | it("keeps attachments within the budget and reports dropped names in order", () => { |
| 74 | 48 | const small = { |
| @@ -124,74 +98,50 @@ describe("promptStashStore", () => { · 49 unmodified lines | ||
| 124 | 98 | const store = usePromptStashStore.getState(); |
| 125 | 99 | store.stashEntry(makeEntry({ id: "first" })); |
| 126 | 100 | 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"]); | |
| 146 | 103 | }); |
| 147 | 104 | |
| 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 | 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 | 108 | expect(store.stashEntry(makeEntry({ id: `entry-${index}` })).evicted).toBeNull(); |
| 152 | 109 | } |
| 153 | 110 | const { evicted } = store.stashEntry(makeEntry({ id: "overflow" })); |
| 154 | 111 | 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"); | |
| 160 | 115 | }); |
| 161 | 116 | |
| 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 | 121 | 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 | + ]); | |
| 174 | 128 | }); |
| 175 | 129 | |
| 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", () => { | |
| 184 | 131 | 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"]); | |
| 187 | 138 | }); |
| 188 | 139 | |
| 189 | 140 | it("finalizeEntryImages attaches images and clears the pending count", () => { |
| 190 | 141 | const store = usePromptStashStore.getState(); |
| 191 | − const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); | |
| 192 | 142 | store.stashEntry({ ...makeEntry({ id: "pending" }), pendingImageCount: 2 }); |
| 193 | 143 | |
| 194 | − const { attached } = store.finalizeEntryImages(scopeKey, "pending", { | |
| 144 | + const { attached } = store.finalizeEntryImages("pending", { | |
| 195 | 145 | attachments: [ |
| 196 | 146 | { |
| 197 | 147 | id: "img-1", |
| @@ -206,7 +156,7 @@ describe("promptStashStore", () => { · 8 unmodified lines | ||
| 206 | 156 | }); |
| 207 | 157 | |
| 208 | 158 | expect(attached).toBe(true); |
| 209 | − const entry = usePromptStashStore.getState().queuesByScopeKey[scopeKey]?.[0]; | |
| 159 | + const entry = usePromptStashStore.getState().entries[0]; | |
| 210 | 160 | expect(entry?.attachments).toHaveLength(1); |
| 211 | 161 | expect(entry?.droppedImageNames).toEqual(["big.png"]); |
| 212 | 162 | expect(entry?.pendingImageCount).toBe(0); |
| @@ -214,12 +164,11 @@ describe("promptStashStore", () => { · 1 unmodified line | ||
| 214 | 164 | |
| 215 | 165 | it("finalizeEntryImages reports false when the entry was already taken", () => { |
| 216 | 166 | const store = usePromptStashStore.getState(); |
| 217 | − const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); | |
| 218 | 167 | store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 }); |
| 219 | 168 | // Restored (or deleted) while its images were still encoding. |
| 220 | − store.takeEntry(scopeKey, "racing"); | |
| 169 | + store.takeEntry("racing"); | |
| 221 | 170 | |
| 222 | − const { attached } = store.finalizeEntryImages(scopeKey, "racing", { | |
| 171 | + const { attached } = store.finalizeEntryImages("racing", { | |
| 223 | 172 | attachments: [], |
| 224 | 173 | droppedImageNames: [], |
| 225 | 174 | unreadableImageNames: [], |
| @@ -229,31 +178,31 @@ describe("promptStashStore", () => { · 3 unmodified lines | ||
| 229 | 178 | }); |
| 230 | 179 | |
| 231 | 180 | it("settles a pending count left behind by a crashed or closed session", () => { |
| 232 | − const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); | |
| 233 | 181 | writePromptStashStorageForTest( |
| 234 | 182 | JSON.stringify({ |
| 235 | − version: 1, | |
| 183 | + version: 2, | |
| 236 | 184 | state: { |
| 237 | − queuesByScopeKey: { | |
| 238 | − [scopeKey]: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }], | |
| 239 | − }, | |
| 185 | + entries: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }], | |
| 240 | 186 | }, |
| 241 | 187 | }), |
| 242 | 188 | ); |
| 243 | 189 | |
| 244 | 190 | // Hydration must settle the stale count, or the entry would stay stuck |
| 245 | 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 | 193 | expect(entry?.pendingImageCount).toBe(0); |
| 248 | 194 | expect(entry?.unreadableImageNames).toHaveLength(2); |
| 249 | 195 | }); |
| 250 | 196 | |
| 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([]); | |
| 258 | 207 | }); |
| 259 | 208 | }); |
| @@ -1,24 +1,20 @@ | ||
| 1 | −import { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; | |
| 2 | 1 | import * as Schema from "effect/Schema"; |
| 3 | 2 | import { create } from "zustand"; |
| 4 | 3 | |
| 5 | 4 | import { PersistedComposerImageAttachment } from "./composerDraftStore"; |
| 6 | 5 | import { createMemoryStorage, type StateStorage } from "./lib/storage"; |
| 7 | 6 | |
| 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"; | |
| 11 | 8 | /** |
| 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. | |
| 16 | 13 | */ |
| 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; | |
| 20 | 16 | |
| 21 | −export const MAX_STASH_ENTRIES_PER_QUEUE = 20; | |
| 17 | +export const MAX_STASH_ENTRIES = 20; | |
| 22 | 18 | /** |
| 23 | 19 | * Budget for an entry's serialized attachment payload. localStorage is a |
| 24 | 20 | * ~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 | ||
| 30 | 26 | */ |
| 31 | 27 | export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000; |
| 32 | 28 | |
| 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 | + */ | |
| 33 | 35 | const StashEntrySchema = Schema.Struct({ |
| 34 | 36 | id: Schema.String, |
| 35 | 37 | createdAt: Schema.String, |
| 36 | 38 | prompt: Schema.String, |
| 37 | 39 | attachments: Schema.Array(PersistedComposerImageAttachment), |
| 38 | − providerInstanceId: Schema.NullOr(ProviderInstanceId), | |
| 39 | − modelSelection: Schema.NullOr(ModelSelection), | |
| 40 | 40 | /** Names of images that exceeded the attachment budget and were not saved. */ |
| 41 | 41 | droppedImageNames: Schema.Array(Schema.String), |
| 42 | 42 | /** |
| @@ -57,7 +57,7 @@ const StashEntrySchema = Schema.Struct({ · 14 unmodified lines | ||
| 57 | 57 | export type PromptStashEntry = typeof StashEntrySchema.Type; |
| 58 | 58 | |
| 59 | 59 | const PersistedPromptStashState = Schema.Struct({ |
| 60 | − queuesByScopeKey: Schema.Record(Schema.String, Schema.Array(StashEntrySchema)), | |
| 60 | + entries: Schema.Array(StashEntrySchema), | |
| 61 | 61 | }); |
| 62 | 62 | type PersistedPromptStashState = typeof PersistedPromptStashState.Type; |
| 63 | 63 | |
| @@ -74,44 +74,23 @@ const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPrompt · 10 unmodified lines | ||
| 74 | 74 | * recorded as unreadable to keep the prompt itself restorable. |
| 75 | 75 | */ |
| 76 | 76 | 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>, | |
| 113 | 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 | } |
| 116 | 95 | |
| 117 | 96 | /** |
| @@ -163,7 +142,7 @@ function resolveBaseStorage(): { storage: StateStorage; durable: boolean } { · 45 unmodified lines | ||
| 163 | 142 | const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStorage(); |
| 164 | 143 | |
| 165 | 144 | /** |
| 166 | − * Persists the queues, immediately rather than debounced. Stashing is a | |
| 145 | + * Persists the queue, immediately rather than debounced. Stashing is a | |
| 167 | 146 | * deliberate, infrequent keystroke — not a per-character autosave — so there |
| 168 | 147 | * is nothing to coalesce, and the caller clears the composer on the strength |
| 169 | 148 | * of this write landing, which a debounce timer cannot honestly report. |
| @@ -171,7 +150,7 @@ const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStor · 1 unmodified line | ||
| 171 | 150 | * Returns whether the write will survive a reload: false on a quota rejection |
| 172 | 151 | * or when only the in-memory fallback is available. |
| 173 | 152 | */ |
| 174 | −function persistQueues(queues: Record<string, ReadonlyArray<PromptStashEntry>>): { | |
| 153 | +function persistEntries(entries: ReadonlyArray<PromptStashEntry>): { | |
| 175 | 154 | /** The write succeeded (possibly only into the in-memory fallback). */ |
| 176 | 155 | written: boolean; |
| 177 | 156 | /** The write will survive a reload. */ |
| @@ -182,7 +161,7 @@ function persistQueues(queues: Record<string, ReadonlyArray<PromptStashEntry>>): · 4 unmodified lines | ||
| 182 | 161 | PROMPT_STASH_STORAGE_KEY, |
| 183 | 162 | JSON.stringify({ |
| 184 | 163 | version: PROMPT_STASH_STORAGE_VERSION, |
| 185 | − state: { queuesByScopeKey: queues }, | |
| 164 | + state: { entries }, | |
| 186 | 165 | }), |
| 187 | 166 | ); |
| 188 | 167 | return { written: true, durable: storageIsDurable }; |
| @@ -192,40 +171,42 @@ function persistQueues(queues: Record<string, ReadonlyArray<PromptStashEntry>>): · 3 unmodified lines | ||
| 192 | 171 | } |
| 193 | 172 | } |
| 194 | 173 | |
| 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 { | |
| 197 | 176 | try { |
| 198 | 177 | const raw = baseStashStorage.getItem(PROMPT_STASH_STORAGE_KEY); |
| 199 | 178 | if (typeof raw !== "string" || raw.length === 0) return null; |
| 200 | 179 | const parsed: unknown = JSON.parse(raw); |
| 201 | 180 | const state = (parsed as { state?: unknown } | null)?.state; |
| 202 | 181 | if (!state) return null; |
| 203 | − return clearOrphanedPendingImages(decodePersistedPromptStashState(state).queuesByScopeKey); | |
| 182 | + return clearOrphanedPendingImages(decodePersistedPromptStashState(state).entries); | |
| 204 | 183 | } catch { |
| 205 | 184 | return null; |
| 206 | 185 | } |
| 207 | 186 | } |
| 208 | 187 | |
| 209 | 188 | interface 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 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. | |
| 214 | 193 | */ |
| 215 | 194 | stashEntry: (entry: PromptStashEntry) => { |
| 216 | 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 | 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 | 206 | * `durable` is false when the removal could not be persisted, meaning a |
| 223 | 207 | * reload would resurrect the entry. |
| 224 | 208 | */ |
| 225 | − takeEntry: ( | |
| 226 | − scopeKey: string, | |
| 227 | − entryId: string, | |
| 228 | − ) => { entry: PromptStashEntry | null; durable: boolean }; | |
| 209 | + takeEntry: (entryId: string) => { entry: PromptStashEntry | null; durable: boolean }; | |
| 229 | 210 | /** |
| 230 | 211 | * Attaches the encoded images to an entry written earlier by `stashEntry`, |
| 231 | 212 | * clearing its pending count. Returns attached=false when the entry is gone |
| @@ -233,7 +214,6 @@ interface PromptStashStoreState { · 1 unmodified line | ||
| 233 | 214 | * tell the user their images did not make it. |
| 234 | 215 | */ |
| 235 | 216 | finalizeEntryImages: ( |
| 236 | − scopeKey: string, | |
| 237 | 217 | entryId: string, |
| 238 | 218 | images: { |
| 239 | 219 | attachments: ReadonlyArray<PersistedComposerImageAttachment>; |
| @@ -244,58 +224,45 @@ interface PromptStashStoreState { · 4 unmodified lines | ||
| 244 | 224 | } |
| 245 | 225 | |
| 246 | 226 | export const usePromptStashStore = create<PromptStashStoreState>()((set, get) => ({ |
| 247 | − queuesByScopeKey: {}, | |
| 227 | + entries: [], | |
| 248 | 228 | 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); | |
| 256 | 232 | // A rejected write must not leave the entry visible either: the caller |
| 257 | 233 | // keeps the composer intact on failure, so a stashed copy would |
| 258 | 234 | // duplicate the prompt. Eviction likewise only sticks on success. |
| 259 | 235 | if (!written) { |
| 260 | − return { evicted: null, durable: false }; | |
| 236 | + return { evicted: null, written: false, durable: false }; | |
| 261 | 237 | } |
| 262 | − set(() => ({ queuesByScopeKey: next })); | |
| 263 | − return { evicted, durable }; | |
| 238 | + set(() => ({ entries: nextEntries })); | |
| 239 | + return { evicted, written: true, durable }; | |
| 264 | 240 | }, |
| 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; | |
| 269 | 244 | 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 })); | |
| 279 | 248 | return { entry, durable }; |
| 280 | 249 | }, |
| 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]; | |
| 286 | 254 | // Restored or deleted mid-encode: nothing to attach to. |
| 287 | 255 | if (!existing) return { attached: false, durable: true }; |
| 288 | − const nextQueue = [...queue]; | |
| 289 | − nextQueue[index] = { | |
| 256 | + const nextEntries = [...entries]; | |
| 257 | + nextEntries[index] = { | |
| 290 | 258 | ...existing, |
| 291 | 259 | attachments: images.attachments, |
| 292 | 260 | droppedImageNames: images.droppedImageNames, |
| 293 | 261 | unreadableImageNames: images.unreadableImageNames, |
| 294 | 262 | pendingImageCount: 0, |
| 295 | 263 | }; |
| 296 | − const next = { ...queues, [scopeKey]: nextQueue }; | |
| 297 | − const { durable } = persistQueues(next); | |
| 298 | − set(() => ({ queuesByScopeKey: next })); | |
| 264 | + const { durable } = persistEntries(nextEntries); | |
| 265 | + set(() => ({ entries: nextEntries })); | |
| 299 | 266 | return { attached: true, durable }; |
| 300 | 267 | }, |
| 301 | 268 | })); |
| @@ -303,14 +270,18 @@ export const usePromptStashStore = create<PromptStashStoreState>()((set, get) => · 1 unmodified line | ||
| 303 | 270 | // Hydrate once at startup. Like the app's other persisted stores, tabs are |
| 304 | 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 | 280 | if (persisted) { |
| 308 | − usePromptStashStore.setState({ queuesByScopeKey: persisted }); | |
| 281 | + usePromptStashStore.setState({ entries: persisted }); | |
| 309 | 282 | } |
| 310 | 283 | } |
| 311 | 284 | |
| 312 | −export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray<PromptStashEntry> = []; | |
| 313 | − | |
| 314 | 285 | /** |
| 315 | 286 | * Test seam: seeds the persisted payload through the same storage the store |
| 316 | 287 | * reads and rehydrates, without needing a real `localStorage` global. |
| @@ -318,5 +289,5 @@ export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray<PromptStashEntry> = []; · 1 unmodified line | ||
| 318 | 289 | */ |
| 319 | 290 | export function writePromptStashStorageForTest(raw: string): void { |
| 320 | 291 | baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw); |
| 321 | − usePromptStashStore.setState({ queuesByScopeKey: readPersistedQueues() ?? {} }); | |
| 292 | + usePromptStashStore.setState({ entries: readPersistedEntries() ?? [] }); | |
| 322 | 293 | } |