| author | |
| committer | |
| log | f2d2fb2f2444bcabc31bf906b5b334aee0d9d79c |
| tree | 1429ff2b5dfe3923f3bfb837d58631bbc6e6bfff |
| parent | 8829e2f9b960eb31dc62ddab256d5da82c5077a6 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 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) |
| 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) |
| 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) |
| 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) |
| 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) |
| 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) |
| 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) |
| 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) |
| 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) |
| 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) |
| 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) |
| 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) |
| 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) |
| 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)} |
apps/web/src/components/chat/ComposerStashBadge.tsx+1-2| ... | ... | @@ -5,8 +5,7 @@ import { cn } from "~/lib/utils"; |
| 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 |
apps/web/src/components/chat/ComposerStashMenu.tsx+6-16| ... | ... | @@ -24,16 +24,13 @@ function stashEntrySnippet(entry: PromptStashEntry): string { |
| 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: { |
| 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: { |
| 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> |
apps/web/src/promptStashStore.test.ts+43-94| ... | ... | @@ -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: { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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", () => { |
| 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 | }); |
apps/web/src/promptStashStore.ts+78-107| ... | ... | @@ -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; |
| 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({ |
| 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 |
| 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 } { |
| 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 |
| 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>>): |
| 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>>): |
| 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 { |
| 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 { |
| 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) => |
| 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> = []; |
| 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 | } |