| 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,10 +57,8 @@ import { |
| 57 | useEffectiveComposerModelState, | 57 | useEffectiveComposerModelState, |
| 58 | } from "../../composerDraftStore"; | 58 | } from "../../composerDraftStore"; |
| 59 | import { | 59 | import { |
| 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); | ||
| 1908 | 1895 | ||
| 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 | } |
| 1998 | 1982 | ||
| 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 | } | ||
| 2014 | 1986 | ||
| 2015 | // Each cause gets its own sentence so "too large" is never blamed for a | 1987 | // 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 simply | 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,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) |
| 2061 | 2031 | ||
| 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) |
| 2097 | 2067 | ||
| 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 in | 2071 | // Persist the text-only entry *first*, then clear. Ordering matters in |
| 2103 | // both directions: writing before clearing means a crash or closed tab | 2072 | // both directions: writing before clearing means a crash or closed tab |
| 2104 | // mid-encode still leaves the prompt recoverable, while clearing before | 2073 | // 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 | }); |
| 2118 | 2085 | ||
| 2119 | // Clearing the composer is only safe once the entry is durable. If the | 2086 | // Clearing the composer is only safe once the write actually landed. |
| 2120 | // write was rejected (quota, blocked storage) the store has already | 2087 | // If it was rejected (quota) the store has already rolled itself back, |
| 2121 | // rolled itself back, so leave the composer untouched rather than | 2088 | // 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 | } | ||
| 2133 | 2112 | ||
| 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, so | 2114 | // 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); |
| 2178 | 2157 | ||
| 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 entry | 2164 | // The second phase can be rejected on its own: the text-only entry |
| 2186 | // fit, but adding image payloads pushed past the quota. Disk would | 2165 | // 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 | ]); |
| 2227 | 2204 | ||
| 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 | <ComposerStashMenu | 2810 | <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"; |
| 5 | 5 | ||
| 6 | /** | 6 | /** |
| 7 | * Bookmark pill perched on the composer's top-right shoulder. Shows the | 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 | 8 | * 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 full | 10 | * On save the badge gives one quiet acknowledgement: it lifts to full |
| 12 | * opacity and the count ticks over. `pulseKey` changes per stash, remounting | 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,16 +24,13 @@ function stashEntrySnippet(entry: PromptStashEntry): string { |
| 24 | } | 24 | } |
| 25 | 25 | ||
| 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 with | 28 | * empty composer, navigated with arrows, restored with Enter, dismissed |
| 29 | * arrows, restored with Enter, dismissed with Escape. The listener runs | 29 | * 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 handlers | 30 | * the Lexical editor's handlers while the menu is open. |
| 31 | * while the menu is open. | ||
| 32 | */ | 31 | */ |
| 33 | export const ComposerStashMenu = memo(function ComposerStashMenu(props: { | 32 | export 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 stash | 103 | 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 @@ |
| 1 | import { ProviderInstanceId } from "@t3tools/contracts"; | ||
| 2 | import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; | 1 | import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; |
| 3 | 2 | ||
| 4 | import { removeLocalStorageItem } from "./hooks/useLocalStorage"; | 3 | import { removeLocalStorageItem } from "./hooks/useLocalStorage"; |
| 5 | 4 | ||
| 6 | import { | 5 | import { |
| 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"; |
| 17 | 14 | ||
| 18 | const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent"); | ||
| 19 | const CODEX_INSTANCE = ProviderInstanceId.make("codex"); | ||
| 20 | |||
| 21 | function makeEntry(input: { | 15 | function 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 | } |
| 49 | 39 | ||
| 50 | function resetPromptStashStore() { | 40 | function 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 | } |
| 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 | describe("partitionStashAttachments", () => { | 46 | describe("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 | }); |
| 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 | 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 | }); |
| 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 | 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 | }); |
| 175 | 129 | ||
| 176 | // Queue keys are persisted as plain strings, so a hand-edited or corrupted | 130 | 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 | }); |
| 188 | 139 | ||
| 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 }); |
| 193 | 143 | ||
| 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 | }); |
| 207 | 157 | ||
| 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", () => { |
| 214 | 164 | ||
| 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"); |
| 221 | 170 | ||
| 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 | }); |
| 230 | 179 | ||
| 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 | ); |
| 243 | 189 | ||
| 244 | // Hydration must settle the stale count, or the entry would stay stuck | 190 | // 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 | }); |
| 250 | 196 | ||
| 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 @@ |
| 1 | import { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 2 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 3 | import { create } from "zustand"; | 2 | import { create } from "zustand"; |
| 4 | 3 | ||
| 5 | import { PersistedComposerImageAttachment } from "./composerDraftStore"; | 4 | import { PersistedComposerImageAttachment } from "./composerDraftStore"; |
| 6 | import { createMemoryStorage, type StateStorage } from "./lib/storage"; | 5 | import { createMemoryStorage, type StateStorage } from "./lib/storage"; |
| 7 | 6 | ||
| 8 | export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1"; | 7 | export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v2"; |
| 9 | const 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 this | 11 | * 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 | */ |
| 17 | export const PROMPT_STASH_UNSCOPED_KEY = "__none__"; | 14 | const LEGACY_PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1"; |
| 18 | /** Namespace applied to provider-derived keys to keep them collision-proof. */ | 15 | const PROMPT_STASH_STORAGE_VERSION = 2; |
| 19 | const PROVIDER_SCOPE_PREFIX = "provider:"; | ||
| 20 | 16 | ||
| 21 | export const MAX_STASH_ENTRIES_PER_QUEUE = 20; | 17 | export const MAX_STASH_ENTRIES = 20; |
| 22 | /** | 18 | /** |
| 23 | * Budget for an entry's serialized attachment payload. localStorage is a | 19 | * Budget for an entry's serialized attachment payload. localStorage is a |
| 24 | * ~5MB origin-wide quota shared with the composer draft store, so oversized | 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,13 +26,17 @@ export const MAX_STASH_ENTRIES_PER_QUEUE = 20; |
| 30 | */ | 26 | */ |
| 31 | export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000; | 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 | const StashEntrySchema = Schema.Struct({ | 35 | const 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({ |
| 57 | export type PromptStashEntry = typeof StashEntrySchema.Type; | 57 | export type PromptStashEntry = typeof StashEntrySchema.Type; |
| 58 | 58 | ||
| 59 | const PersistedPromptStashState = Schema.Struct({ | 59 | const PersistedPromptStashState = Schema.Struct({ |
| 60 | queuesByScopeKey: Schema.Record(Schema.String, Schema.Array(StashEntrySchema)), | 60 | entries: Schema.Array(StashEntrySchema), |
| 61 | }); | 61 | }); |
| 62 | type PersistedPromptStashState = typeof PersistedPromptStashState.Type; | 62 | type PersistedPromptStashState = typeof PersistedPromptStashState.Type; |
| 63 | 63 | ||
| ... | @@ -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 | */ |
| 76 | function clearOrphanedPendingImages( | 76 | function 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. */ | ||
| 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, | ||
| 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 | } |
| 116 | 95 | ||
| 117 | /** | 96 | /** |
| ... | @@ -163,7 +142,7 @@ function resolveBaseStorage(): { storage: StateStorage; durable: boolean } { | ... | @@ -163,7 +142,7 @@ function resolveBaseStorage(): { storage: StateStorage; durable: boolean } { |
| 163 | const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStorage(); | 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 | * deliberate, infrequent keystroke — not a per-character autosave — so there | 146 | * deliberate, infrequent keystroke — not a per-character autosave — so there |
| 168 | * is nothing to coalesce, and the caller clears the composer on the strength | 147 | * 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 rejection | 150 | * 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 | */ |
| 174 | function persistQueues(queues: Record<string, ReadonlyArray<PromptStashEntry>>): { | 153 | function 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 | } |
| 194 | 173 | ||
| 195 | /** Reads the persisted queues, settling stale pending counts. */ | 174 | /** Reads the persisted queue, settling stale pending counts. */ |
| 196 | function readPersistedQueues(): Record<string, ReadonlyArray<PromptStashEntry>> | null { | 175 | function 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 | } |
| 208 | 187 | ||
| 209 | interface PromptStashStoreState { | 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 | 191 | * 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 a | 206 | * `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 gone | 212 | * 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 | } |
| 245 | 225 | ||
| 246 | export const usePromptStashStore = create<PromptStashStoreState>()((set, get) => ({ | 226 | export 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 caller | 232 | // A rejected write must not leave the entry visible either: the caller |
| 257 | // keeps the composer intact on failure, so a stashed copy would | 233 | // 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 are | 270 | // 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 | } |
| 311 | 284 | ||
| 312 | export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray<PromptStashEntry> = []; | ||
| 313 | |||
| 314 | /** | 285 | /** |
| 315 | * Test seam: seeds the persisted payload through the same storage the store | 286 | * 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 | */ |
| 319 | export function writePromptStashStorageForTest(raw: string): void { | 290 | export 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 | } |