| author | |
| committer | |
| log | 200fa826b02cf0503c6f6c2bd7250a58747bff2d |
| tree | 95594158761f3bceebc46a2248a36868ea5067f4 |
| parent | f4c394323d458224b25898ef784ca0a0cc2969af |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>11 files changed, 1784 insertions(+), 1 deletions(-)
apps/web/src/components/chat/ChatComposer.tsx+448| ... | @@ -51,13 +51,29 @@ import { | ... | @@ -51,13 +51,29 @@ import { |
| 51 | type ComposerImageAttachment, | 51 | type ComposerImageAttachment, |
| 52 | type DraftId, | 52 | type DraftId, |
| 53 | type PersistedComposerImageAttachment, | 53 | type PersistedComposerImageAttachment, |
| 54 | hydrateImagesFromPersisted, | ||
| 54 | useComposerDraftStore, | 55 | useComposerDraftStore, |
| 55 | useComposerThreadDraft, | 56 | useComposerThreadDraft, |
| 56 | useEffectiveComposerModelState, | 57 | useEffectiveComposerModelState, |
| 57 | } from "../../composerDraftStore"; | 58 | } from "../../composerDraftStore"; |
| 59 | import { | ||
| 60 | EMPTY_PROMPT_STASH_QUEUE, | ||
| 61 | MAX_STASH_ENTRIES_PER_QUEUE, | ||
| 62 | partitionStashAttachments, | ||
| 63 | promptStashScopeKey, | ||
| 64 | usePromptStashStore, | ||
| 65 | type PromptStashEntry, | ||
| 66 | } from "../../promptStashStore"; | ||
| 67 | import { ComposerStashBadge } from "./ComposerStashBadge"; | ||
| 68 | import { ComposerStashMenu } from "./ComposerStashMenu"; | ||
| 69 | import { compressImageForStash } from "../../lib/stashImageCompression"; | ||
| 70 | import { isCommandPaletteOpen } from "../../commandPaletteBus"; | ||
| 71 | import { getTerminalFocusOwner } from "../../lib/terminalFocus"; | ||
| 72 | import { resolveShortcutCommand } from "../../keybindings"; | ||
| 58 | import { | 73 | import { |
| 59 | type TerminalContextDraft, | 74 | type TerminalContextDraft, |
| 60 | type TerminalContextSelection, | 75 | type TerminalContextSelection, |
| 76 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, | ||
| 61 | insertInlineTerminalContextPlaceholder, | 77 | insertInlineTerminalContextPlaceholder, |
| 62 | removeInlineTerminalContextPlaceholder, | 78 | removeInlineTerminalContextPlaceholder, |
| 63 | } from "../../lib/terminalContext"; | 79 | } from "../../lib/terminalContext"; |
| ... | @@ -723,6 +739,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -723,6 +739,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 723 | const clearComposerDraftPersistedAttachments = useComposerDraftStore( | 739 | const clearComposerDraftPersistedAttachments = useComposerDraftStore( |
| 724 | (store) => store.clearPersistedAttachments, | 740 | (store) => store.clearPersistedAttachments, |
| 725 | ); | 741 | ); |
| 742 | const clearComposerDraftPromptAndImages = useComposerDraftStore( | ||
| 743 | (store) => store.clearComposerPromptAndImages, | ||
| 744 | ); | ||
| 745 | const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); | ||
| 726 | const syncComposerDraftPersistedAttachments = useComposerDraftStore( | 746 | const syncComposerDraftPersistedAttachments = useComposerDraftStore( |
| 727 | (store) => store.syncPersistedAttachments, | 747 | (store) => store.syncPersistedAttachments, |
| 728 | ); | 748 | ); |
| ... | @@ -961,6 +981,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -961,6 +981,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 961 | const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); | 981 | const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); |
| 962 | const [isComposerFocused, setIsComposerFocused] = useState(false); | 982 | const [isComposerFocused, setIsComposerFocused] = useState(false); |
| 963 | const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null); | 983 | const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null); |
| 984 | const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); | ||
| 985 | const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ | ||
| 986 | key: 0, | ||
| 987 | active: false, | ||
| 988 | }); | ||
| 964 | const isMobileViewport = useMediaQuery("max-sm"); | 989 | const isMobileViewport = useMediaQuery("max-sm"); |
| 965 | const isComposerCollapsedMobile = | 990 | const isComposerCollapsedMobile = |
| 966 | isMobileViewport && !forceExpandedOnMobile && !isComposerFocused; | 991 | isMobileViewport && !forceExpandedOnMobile && !isComposerFocused; |
| ... | @@ -980,6 +1005,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -980,6 +1005,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 980 | const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null); | 1005 | const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null); |
| 981 | const mobileComposerExpandInFlightRef = useRef(false); | 1006 | const mobileComposerExpandInFlightRef = useRef(false); |
| 982 | const dragDepthRef = useRef(0); | 1007 | const dragDepthRef = useRef(0); |
| 1008 | const stashPulseKeyRef = useRef(0); | ||
| 1009 | const stashPulseTimeoutRef = useRef<number | null>(null); | ||
| 1010 | /** | ||
| 1011 | * Snapshots currently being encoded, keyed by target+prompt+image ids. | ||
| 1012 | * Keyed rather than boolean so a genuinely different prompt (or a different | ||
| 1013 | * thread) can still be stashed while an earlier encode is running. | ||
| 1014 | */ | ||
| 1015 | const stashInFlightRef = useRef<Set<string>>(new Set()); | ||
| 983 | 1016 | ||
| 984 | // ------------------------------------------------------------------ | 1017 | // ------------------------------------------------------------------ |
| 985 | // Derived: composer send state | 1018 | // Derived: composer send state |
| ... | @@ -1861,6 +1894,400 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1861,6 +1894,400 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1861 | return false; | 1894 | return false; |
| 1862 | }; | 1895 | }; |
| 1863 | 1896 | ||
| 1897 | // ------------------------------------------------------------------ | ||
| 1898 | // Prompt stash (⌘S) | ||
| 1899 | // ------------------------------------------------------------------ | ||
| 1900 | const stashScopeInstanceId = noProviderAvailable ? null : selectedInstanceId; | ||
| 1901 | const stashScope = promptStashScopeKey(stashScopeInstanceId); | ||
| 1902 | const stashQueue = usePromptStashStore( | ||
| 1903 | (state) => state.queuesByScopeKey[stashScope] ?? EMPTY_PROMPT_STASH_QUEUE, | ||
| 1904 | ); | ||
| 1905 | const stashOtherScopesCount = usePromptStashStore((state) => | ||
| 1906 | Object.entries(state.queuesByScopeKey).reduce( | ||
| 1907 | (total, [key, queue]) => (key === stashScope ? total : total + queue.length), | ||
| 1908 | 0, | ||
| 1909 | ), | ||
| 1910 | ); | ||
| 1911 | const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry); | ||
| 1912 | const takeStashEntry = usePromptStashStore((state) => state.takeEntry); | ||
| 1913 | const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages); | ||
| 1914 | const stashProviderLabel = noProviderAvailable | ||
| 1915 | ? "No provider" | ||
| 1916 | : getProviderDisplayName(providerStatuses, selectedProvider); | ||
| 1917 | |||
| 1918 | useEffect(() => { | ||
| 1919 | return () => { | ||
| 1920 | if (stashPulseTimeoutRef.current !== null) { | ||
| 1921 | window.clearTimeout(stashPulseTimeoutRef.current); | ||
| 1922 | } | ||
| 1923 | }; | ||
| 1924 | }, []); | ||
| 1925 | |||
| 1926 | /** Briefly highlight the badge so the save registers without a flourish. */ | ||
| 1927 | const pulseStashBadge = useCallback(() => { | ||
| 1928 | stashPulseKeyRef.current += 1; | ||
| 1929 | setStashPulse({ key: stashPulseKeyRef.current, active: true }); | ||
| 1930 | if (stashPulseTimeoutRef.current !== null) { | ||
| 1931 | window.clearTimeout(stashPulseTimeoutRef.current); | ||
| 1932 | } | ||
| 1933 | stashPulseTimeoutRef.current = window.setTimeout(() => { | ||
| 1934 | stashPulseTimeoutRef.current = null; | ||
| 1935 | setStashPulse((current) => ({ ...current, active: false })); | ||
| 1936 | }, 1200); | ||
| 1937 | }, []); | ||
| 1938 | |||
| 1939 | const restoreStashEntry = useCallback( | ||
| 1940 | (entry: PromptStashEntry) => { | ||
| 1941 | // Remove first so a double activation (click + Enter) can't restore twice. | ||
| 1942 | const { entry: taken, durable } = takeStashEntry( | ||
| 1943 | promptStashScopeKey(entry.providerInstanceId), | ||
| 1944 | entry.id, | ||
| 1945 | ); | ||
| 1946 | if (!taken) return; | ||
| 1947 | if (!durable) { | ||
| 1948 | toastManager.add({ | ||
| 1949 | type: "warning", | ||
| 1950 | title: "Restored prompt may reappear in the stash", | ||
| 1951 | description: | ||
| 1952 | "Browser storage rejected the update, so this entry could still be there after a reload.", | ||
| 1953 | data: { hideCopyButton: true }, | ||
| 1954 | }); | ||
| 1955 | } | ||
| 1956 | setIsStashMenuOpen(false); | ||
| 1957 | |||
| 1958 | const currentPrompt = promptRef.current; | ||
| 1959 | // An image-only stash must not append blank lines to whatever is | ||
| 1960 | // already in the composer. | ||
| 1961 | const nextPrompt = | ||
| 1962 | entry.prompt.length === 0 | ||
| 1963 | ? currentPrompt | ||
| 1964 | : currentPrompt.trim().length | ||
| 1965 | ? `${currentPrompt.replace(/\s+$/, "")}\n\n${entry.prompt}` | ||
| 1966 | : entry.prompt; | ||
| 1967 | const promptChanged = nextPrompt !== currentPrompt; | ||
| 1968 | if (promptChanged) { | ||
| 1969 | promptRef.current = nextPrompt; | ||
| 1970 | setComposerDraftPrompt(composerDraftTarget, nextPrompt); | ||
| 1971 | setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); | ||
| 1972 | setComposerTrigger(null); | ||
| 1973 | } | ||
| 1974 | |||
| 1975 | let unrestoredImageNames: string[] = []; | ||
| 1976 | if (entry.attachments.length > 0) { | ||
| 1977 | const existingIds = new Set(composerImagesRef.current.map((image) => image.id)); | ||
| 1978 | // The draft store also dedupes by mimeType+sizeBytes+name, so filter | ||
| 1979 | // on the same key here. Counting a duplicate against capacity would | ||
| 1980 | // burn a slot the store then refuses to fill, pushing a genuinely | ||
| 1981 | // unique image into the overflow list for nothing. | ||
| 1982 | const existingDedupKeys = new Set( | ||
| 1983 | composerImagesRef.current.map( | ||
| 1984 | (image) => `${image.mimeType} ${image.sizeBytes} ${image.name}`, | ||
| 1985 | ), | ||
| 1986 | ); | ||
| 1987 | const capacity = Math.max( | ||
| 1988 | 0, | ||
| 1989 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS - composerImagesRef.current.length, | ||
| 1990 | ); | ||
| 1991 | const pending = entry.attachments.filter( | ||
| 1992 | (attachment) => | ||
| 1993 | !existingIds.has(attachment.id) && | ||
| 1994 | !existingDedupKeys.has( | ||
| 1995 | `${attachment.mimeType} ${attachment.sizeBytes} ${attachment.name}`, | ||
| 1996 | ), | ||
| 1997 | ); | ||
| 1998 | // Anything past the attachment limit cannot be restored. The entry is | ||
| 1999 | // already out of the queue, so report the overflow by name instead of | ||
| 2000 | // discarding it silently. | ||
| 2001 | unrestoredImageNames = pending.slice(capacity).map((attachment) => attachment.name); | ||
| 2002 | const restoredImages = hydrateImagesFromPersisted(pending.slice(0, capacity)); | ||
| 2003 | if (restoredImages.length > 0) { | ||
| 2004 | addComposerDraftImages(composerDraftTarget, restoredImages); | ||
| 2005 | } | ||
| 2006 | } | ||
| 2007 | |||
| 2008 | const restorableSelection = | ||
| 2009 | entry.modelSelection && | ||
| 2010 | providerInstanceEntries.some( | ||
| 2011 | (candidate) => | ||
| 2012 | candidate.instanceId === entry.modelSelection?.instanceId && | ||
| 2013 | candidate.enabled && | ||
| 2014 | candidate.isAvailable, | ||
| 2015 | ) | ||
| 2016 | ? entry.modelSelection | ||
| 2017 | : null; | ||
| 2018 | if (restorableSelection) { | ||
| 2019 | setComposerDraftModelSelection(composerDraftTarget, restorableSelection, { | ||
| 2020 | replaceOptions: true, | ||
| 2021 | }); | ||
| 2022 | } | ||
| 2023 | |||
| 2024 | // Each cause gets its own sentence so "too large" is never blamed for a | ||
| 2025 | // file that actually failed to decode, or for one the composer simply | ||
| 2026 | // had no room to take back. | ||
| 2027 | const missingImageReasons: string[] = []; | ||
| 2028 | if (entry.droppedImageNames.length > 0) { | ||
| 2029 | missingImageReasons.push( | ||
| 2030 | `${entry.droppedImageNames.join(", ")} exceeded the stash size limit when this prompt was saved.`, | ||
| 2031 | ); | ||
| 2032 | } | ||
| 2033 | if (entry.unreadableImageNames && entry.unreadableImageNames.length > 0) { | ||
| 2034 | missingImageReasons.push( | ||
| 2035 | `${entry.unreadableImageNames.join(", ")} could not be read when this prompt was saved.`, | ||
| 2036 | ); | ||
| 2037 | } | ||
| 2038 | if (unrestoredImageNames.length > 0) { | ||
| 2039 | missingImageReasons.push( | ||
| 2040 | `${unrestoredImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-image limit.`, | ||
| 2041 | ); | ||
| 2042 | } | ||
| 2043 | if (missingImageReasons.length > 0) { | ||
| 2044 | toastManager.add({ | ||
| 2045 | type: "warning", | ||
| 2046 | title: "Some images were not restored", | ||
| 2047 | description: missingImageReasons.join(" "), | ||
| 2048 | }); | ||
| 2049 | } | ||
| 2050 | |||
| 2051 | // Only yank the caret to the end when text was actually inserted; | ||
| 2052 | // restoring images alone should leave the user where they were typing. | ||
| 2053 | if (promptChanged) { | ||
| 2054 | window.requestAnimationFrame(() => { | ||
| 2055 | composerEditorRef.current?.focusAtEnd(); | ||
| 2056 | }); | ||
| 2057 | } | ||
| 2058 | }, | ||
| 2059 | [ | ||
| 2060 | addComposerDraftImages, | ||
| 2061 | composerDraftTarget, | ||
| 2062 | composerImagesRef, | ||
| 2063 | promptRef, | ||
| 2064 | providerInstanceEntries, | ||
| 2065 | setComposerDraftModelSelection, | ||
| 2066 | setComposerDraftPrompt, | ||
| 2067 | takeStashEntry, | ||
| 2068 | ], | ||
| 2069 | ); | ||
| 2070 | |||
| 2071 | const deleteStashEntry = useCallback( | ||
| 2072 | (entry: PromptStashEntry) => { | ||
| 2073 | const { durable } = takeStashEntry(promptStashScopeKey(entry.providerInstanceId), entry.id); | ||
| 2074 | if (!durable) { | ||
| 2075 | toastManager.add({ | ||
| 2076 | type: "warning", | ||
| 2077 | title: "Stash entry may come back", | ||
| 2078 | description: | ||
| 2079 | "Browser storage rejected the delete, so this prompt could reappear after a reload.", | ||
| 2080 | data: { hideCopyButton: true }, | ||
| 2081 | }); | ||
| 2082 | } | ||
| 2083 | }, | ||
| 2084 | [takeStashEntry], | ||
| 2085 | ); | ||
| 2086 | |||
| 2087 | const stashCurrentPrompt = useCallback(async () => { | ||
| 2088 | // Terminal-context placeholders reference live sessions the stash can't | ||
| 2089 | // round-trip, so they are stripped from the stashed prompt. | ||
| 2090 | const prompt = promptRef.current.split(INLINE_TERMINAL_CONTEXT_PLACEHOLDER).join("").trim(); | ||
| 2091 | const images = [...composerImagesRef.current]; | ||
| 2092 | if (prompt.length === 0 && images.length === 0) { | ||
| 2093 | setIsStashMenuOpen((open) => !open); | ||
| 2094 | return; | ||
| 2095 | } | ||
| 2096 | // A repeat ⌘S on the *same* still-unencoded snapshot would stash it | ||
| 2097 | // twice. Guard on the snapshot itself rather than a bare boolean: once | ||
| 2098 | // the composer has been cleared the user can type something genuinely | ||
| 2099 | // new (or switch threads) while encoding continues, and that deserves its | ||
| 2100 | // own entry. | ||
| 2101 | const snapshotKey = `${String(composerDraftTarget)} ${prompt} ${images | ||
| 2102 | .map((image) => image.id) | ||
| 2103 | .join(",")}`; | ||
| 2104 | if (stashInFlightRef.current.has(snapshotKey)) return; | ||
| 2105 | stashInFlightRef.current.add(snapshotKey); | ||
| 2106 | |||
| 2107 | const stashTarget = composerDraftTarget; | ||
| 2108 | const entryId = randomUUID(); | ||
| 2109 | const scopeKey = promptStashScopeKey(stashScopeInstanceId); | ||
| 2110 | try { | ||
| 2111 | // Persist the text-only entry *first*, then clear. Ordering matters in | ||
| 2112 | // both directions: writing before clearing means a crash or closed tab | ||
| 2113 | // mid-encode still leaves the prompt recoverable, while clearing before | ||
| 2114 | // the async image work means edits typed during encoding are not wiped. | ||
| 2115 | // Images are appended to the stored entry as they finish encoding. | ||
| 2116 | const { evicted, durable } = stashEntryToQueue({ | ||
| 2117 | id: entryId, | ||
| 2118 | createdAt: new Date().toISOString(), | ||
| 2119 | prompt, | ||
| 2120 | attachments: [], | ||
| 2121 | providerInstanceId: stashScopeInstanceId, | ||
| 2122 | modelSelection: noProviderAvailable ? null : selectedModelSelection, | ||
| 2123 | droppedImageNames: [], | ||
| 2124 | unreadableImageNames: [], | ||
| 2125 | pendingImageCount: images.length, | ||
| 2126 | }); | ||
| 2127 | |||
| 2128 | // Clearing the composer is only safe once the entry is durable. If the | ||
| 2129 | // write was rejected (quota, blocked storage) the store has already | ||
| 2130 | // rolled itself back, so leave the composer untouched rather than | ||
| 2131 | // making it the second casualty of a reload. | ||
| 2132 | if (!durable) { | ||
| 2133 | toastManager.add({ | ||
| 2134 | type: "error", | ||
| 2135 | title: "Could not stash this prompt", | ||
| 2136 | description: | ||
| 2137 | "Browser storage rejected the write, so the composer was left as-is. Free up site data and try again.", | ||
| 2138 | data: { hideCopyButton: true }, | ||
| 2139 | }); | ||
| 2140 | return; | ||
| 2141 | } | ||
| 2142 | |||
| 2143 | // Only the prompt and images are cleared — terminal/element contexts, | ||
| 2144 | // preview annotations, and review comments are not stashable, so | ||
| 2145 | // destroying them here would be unrecoverable. | ||
| 2146 | promptRef.current = ""; | ||
| 2147 | clearComposerDraftPromptAndImages(stashTarget); | ||
| 2148 | setComposerCursor(0); | ||
| 2149 | setComposerTrigger(null); | ||
| 2150 | pulseStashBadge(); | ||
| 2151 | |||
| 2152 | if (evicted) { | ||
| 2153 | toastManager.add({ | ||
| 2154 | type: "warning", | ||
| 2155 | title: "Oldest stashed prompt discarded", | ||
| 2156 | description: `The ${stashProviderLabel} stash holds ${MAX_STASH_ENTRIES_PER_QUEUE} prompts; the oldest was removed to make room.`, | ||
| 2157 | data: { hideCopyButton: true }, | ||
| 2158 | }); | ||
| 2159 | } | ||
| 2160 | |||
| 2161 | // Images are re-encoded for the stash rather than stored verbatim: the | ||
| 2162 | // composer allows up to 10MB per image, but localStorage gives the whole | ||
| 2163 | // origin ~5MB. Only the stashed copy shrinks; the live attachment (and | ||
| 2164 | // anything sent without stashing) keeps the original file. | ||
| 2165 | const candidateAttachments: PersistedComposerImageAttachment[] = []; | ||
| 2166 | const oversizedImageNames: string[] = []; | ||
| 2167 | const unreadableImageNames: string[] = []; | ||
| 2168 | for (const image of images) { | ||
| 2169 | const result = await compressImageForStash(image.file); | ||
| 2170 | if (!result.ok) { | ||
| 2171 | // "too large" and "could not be read" are distinct outcomes; the | ||
| 2172 | // menu and restore toast report them separately. | ||
| 2173 | (result.reason === "too-large" ? oversizedImageNames : unreadableImageNames).push( | ||
| 2174 | image.name, | ||
| 2175 | ); | ||
| 2176 | continue; | ||
| 2177 | } | ||
| 2178 | candidateAttachments.push({ | ||
| 2179 | id: image.id, | ||
| 2180 | name: image.name, | ||
| 2181 | mimeType: result.image.mimeType, | ||
| 2182 | sizeBytes: result.image.sizeBytes, | ||
| 2183 | dataUrl: result.image.dataUrl, | ||
| 2184 | }); | ||
| 2185 | } | ||
| 2186 | const { kept, droppedNames } = partitionStashAttachments(candidateAttachments); | ||
| 2187 | |||
| 2188 | const { attached, durable: imagesDurable } = finalizeStashEntryImages(scopeKey, entryId, { | ||
| 2189 | attachments: kept, | ||
| 2190 | droppedImageNames: [...oversizedImageNames, ...droppedNames], | ||
| 2191 | unreadableImageNames, | ||
| 2192 | }); | ||
| 2193 | if (attached) { | ||
| 2194 | // The second phase can be rejected on its own: the text-only entry | ||
| 2195 | // fit, but adding image payloads pushed past the quota. Disk would | ||
| 2196 | // then still hold the phase-one entry with pendingImageCount set, | ||
| 2197 | // which reads as an orphan after reload — so say so now. | ||
| 2198 | if (!imagesDurable && images.length > 0) { | ||
| 2199 | toastManager.add({ | ||
| 2200 | type: "warning", | ||
| 2201 | title: "Stashed images were not saved", | ||
| 2202 | description: | ||
| 2203 | "The prompt was stashed, but browser storage rejected its images. They will be missing if you reload.", | ||
| 2204 | data: { hideCopyButton: true }, | ||
| 2205 | }); | ||
| 2206 | } | ||
| 2207 | } else if (kept.length > 0) { | ||
| 2208 | // The entry was restored or deleted before its images finished | ||
| 2209 | // encoding, so they have nowhere to land. Say so rather than letting | ||
| 2210 | // them evaporate. | ||
| 2211 | toastManager.add({ | ||
| 2212 | type: "warning", | ||
| 2213 | title: "Stashed images did not attach", | ||
| 2214 | description: `That prompt was restored or deleted before ${kept.length} image${kept.length === 1 ? "" : "s"} finished saving. Re-attach ${kept.length === 1 ? "it" : "them"} if you still need ${kept.length === 1 ? "it" : "them"}.`, | ||
| 2215 | data: { hideCopyButton: true }, | ||
| 2216 | }); | ||
| 2217 | } | ||
| 2218 | } finally { | ||
| 2219 | // Must clear on every path: a throw that left this set would wedge this | ||
| 2220 | // snapshot's ⌘S until the composer remounts. | ||
| 2221 | stashInFlightRef.current.delete(snapshotKey); | ||
| 2222 | } | ||
| 2223 | }, [ | ||
| 2224 | clearComposerDraftPromptAndImages, | ||
| 2225 | composerDraftTarget, | ||
| 2226 | composerImagesRef, | ||
| 2227 | finalizeStashEntryImages, | ||
| 2228 | noProviderAvailable, | ||
| 2229 | promptRef, | ||
| 2230 | pulseStashBadge, | ||
| 2231 | selectedModelSelection, | ||
| 2232 | stashEntryToQueue, | ||
| 2233 | stashProviderLabel, | ||
| 2234 | stashScopeInstanceId, | ||
| 2235 | ]); | ||
| 2236 | |||
| 2237 | const toggleStashMenu = useCallback(() => { | ||
| 2238 | setIsStashMenuOpen((open) => !open); | ||
| 2239 | }, []); | ||
| 2240 | |||
| 2241 | // Close the stash menu whenever the trigger-driven command menu opens so | ||
| 2242 | // the two popovers never stack in the same layer, and when the user | ||
| 2243 | // resumes typing (the menu is a transient picker, not a panel). | ||
| 2244 | useEffect(() => { | ||
| 2245 | if (composerMenuOpen) { | ||
| 2246 | setIsStashMenuOpen(false); | ||
| 2247 | } | ||
| 2248 | }, [composerMenuOpen]); | ||
| 2249 | useEffect(() => { | ||
| 2250 | setIsStashMenuOpen(false); | ||
| 2251 | }, [prompt]); | ||
| 2252 | |||
| 2253 | useEffect(() => { | ||
| 2254 | const handler = (event: globalThis.KeyboardEvent) => { | ||
| 2255 | const command = resolveShortcutCommand(event, keybindings, { | ||
| 2256 | context: { | ||
| 2257 | terminalFocus: getTerminalFocusOwner() !== null, | ||
| 2258 | terminalOpen, | ||
| 2259 | modelPickerOpen: isComposerModelPickerOpen, | ||
| 2260 | }, | ||
| 2261 | }); | ||
| 2262 | if (command !== "composer.stash") return; | ||
| 2263 | // Always claim the shortcut so the browser save dialog never opens, | ||
| 2264 | // even when the composer is in a state that can't stash. | ||
| 2265 | event.preventDefault(); | ||
| 2266 | event.stopPropagation(); | ||
| 2267 | if ( | ||
| 2268 | isCommandPaletteOpen() || | ||
| 2269 | isComposerApprovalState || | ||
| 2270 | pendingUserInputs.length > 0 || | ||
| 2271 | projectSelectionRequired || | ||
| 2272 | activePendingProgress !== null | ||
| 2273 | ) { | ||
| 2274 | return; | ||
| 2275 | } | ||
| 2276 | void stashCurrentPrompt(); | ||
| 2277 | }; | ||
| 2278 | window.addEventListener("keydown", handler, true); | ||
| 2279 | return () => window.removeEventListener("keydown", handler, true); | ||
| 2280 | }, [ | ||
| 2281 | activePendingProgress, | ||
| 2282 | isComposerApprovalState, | ||
| 2283 | isComposerModelPickerOpen, | ||
| 2284 | keybindings, | ||
| 2285 | pendingUserInputs.length, | ||
| 2286 | projectSelectionRequired, | ||
| 2287 | stashCurrentPrompt, | ||
| 2288 | terminalOpen, | ||
| 2289 | ]); | ||
| 2290 | |||
| 1864 | // ------------------------------------------------------------------ | 2291 | // ------------------------------------------------------------------ |
| 1865 | // Callbacks: images | 2292 | // Callbacks: images |
| 1866 | // ------------------------------------------------------------------ | 2293 | // ------------------------------------------------------------------ |
| ... | @@ -2402,6 +2829,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2402,6 +2829,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2402 | isComposerCollapsedMobile && "hidden", | 2829 | isComposerCollapsedMobile && "hidden", |
| 2403 | )} | 2830 | )} |
| 2404 | > | 2831 | > |
| 2832 | <ComposerStashBadge | ||
| 2833 | count={stashQueue.length} | ||
| 2834 | pulseKey={stashPulse.key} | ||
| 2835 | pulsing={stashPulse.active} | ||
| 2836 | menuOpen={isStashMenuOpen} | ||
| 2837 | onToggleMenu={toggleStashMenu} | ||
| 2838 | /> | ||
| 2839 | |||
| 2840 | {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && ( | ||
| 2841 | <ComposerCommandMenuLayer anchor={composerMenuAnchor}> | ||
| 2842 | <ComposerStashMenu | ||
| 2843 | entries={stashQueue} | ||
| 2844 | providerLabel={stashProviderLabel} | ||
| 2845 | otherScopesCount={stashOtherScopesCount} | ||
| 2846 | onRestore={restoreStashEntry} | ||
| 2847 | onDelete={deleteStashEntry} | ||
| 2848 | onClose={() => setIsStashMenuOpen(false)} | ||
| 2849 | /> | ||
| 2850 | </ComposerCommandMenuLayer> | ||
| 2851 | )} | ||
| 2852 | |||
| 2405 | {composerMenuOpen && !isComposerApprovalState && ( | 2853 | {composerMenuOpen && !isComposerApprovalState && ( |
| 2406 | <ComposerCommandMenuLayer anchor={composerMenuAnchor}> | 2854 | <ComposerCommandMenuLayer anchor={composerMenuAnchor}> |
| 2407 | <ComposerCommandMenu | 2855 | <ComposerCommandMenu |
apps/web/src/components/chat/ComposerStashBadge.tsx created+58| ... | @@ -0,0 +1,58 @@ | ||
| 1 | import { BookmarkIcon } from "lucide-react"; | ||
| 2 | import { memo } from "react"; | ||
| 3 | |||
| 4 | import { cn } from "~/lib/utils"; | ||
| 5 | |||
| 6 | /** | ||
| 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. | ||
| 10 | * | ||
| 11 | * On save the badge gives one quiet acknowledgement: it lifts to full | ||
| 12 | * opacity and the count ticks over. `pulseKey` changes per stash, remounting | ||
| 13 | * the count so the transition replays without a continuous animation. | ||
| 14 | */ | ||
| 15 | export const ComposerStashBadge = memo(function ComposerStashBadge(props: { | ||
| 16 | count: number; | ||
| 17 | pulseKey: number; | ||
| 18 | pulsing: boolean; | ||
| 19 | menuOpen: boolean; | ||
| 20 | onToggleMenu: () => void; | ||
| 21 | }) { | ||
| 22 | if (props.count === 0) return null; | ||
| 23 | |||
| 24 | return ( | ||
| 25 | <button | ||
| 26 | type="button" | ||
| 27 | data-prompt-stash-badge="true" | ||
| 28 | aria-label={`Stashed prompts: ${props.count}. Open stash.`} | ||
| 29 | aria-expanded={props.menuOpen} | ||
| 30 | className={cn( | ||
| 31 | "absolute -top-3 right-4 z-10 inline-flex cursor-pointer items-center gap-1.5 rounded-full border bg-popover px-2.5 py-0.5 text-xs shadow-sm", | ||
| 32 | "transition-[color,border-color,opacity] duration-200", | ||
| 33 | props.menuOpen || props.pulsing | ||
| 34 | ? "border-border text-foreground opacity-100" | ||
| 35 | : "border-border/70 text-muted-foreground opacity-70 hover:opacity-100 hover:text-foreground", | ||
| 36 | )} | ||
| 37 | onPointerDown={(event) => { | ||
| 38 | // Keep composer focus so Escape/typing flows stay intact. | ||
| 39 | event.preventDefault(); | ||
| 40 | }} | ||
| 41 | onClick={props.onToggleMenu} | ||
| 42 | > | ||
| 43 | <BookmarkIcon className="size-3" aria-hidden="true" /> | ||
| 44 | Stash | ||
| 45 | <span | ||
| 46 | key={props.pulseKey} | ||
| 47 | className={cn( | ||
| 48 | "rounded-full px-1.5 text-[10px] font-medium tabular-nums", | ||
| 49 | props.pulsing | ||
| 50 | ? "prompt-stash-count-enter bg-primary text-primary-foreground" | ||
| 51 | : "bg-muted text-muted-foreground", | ||
| 52 | )} | ||
| 53 | > | ||
| 54 | {props.count} | ||
| 55 | </span> | ||
| 56 | </button> | ||
| 57 | ); | ||
| 58 | }); | ||
apps/web/src/components/chat/ComposerStashMenu.tsx created+186| ... | @@ -0,0 +1,186 @@ | ||
| 1 | import { BookmarkIcon, XIcon } from "lucide-react"; | ||
| 2 | import { memo, useEffect, useState } from "react"; | ||
| 3 | |||
| 4 | import { formatRelativeTimeLabel } from "../../timestampFormat"; | ||
| 5 | import { cn } from "~/lib/utils"; | ||
| 6 | import { type PromptStashEntry } from "../../promptStashStore"; | ||
| 7 | import { Command, CommandGroup, CommandGroupLabel, CommandItem, CommandList } from "../ui/command"; | ||
| 8 | import { Button } from "../ui/button"; | ||
| 9 | |||
| 10 | const SNIPPET_MAX_CHARS = 90; | ||
| 11 | |||
| 12 | /** Images that did not make it into the entry, whatever the reason. */ | ||
| 13 | function missingImageCount(entry: PromptStashEntry): number { | ||
| 14 | return entry.droppedImageNames.length + (entry.unreadableImageNames?.length ?? 0); | ||
| 15 | } | ||
| 16 | |||
| 17 | function stashEntrySnippet(entry: PromptStashEntry): string { | ||
| 18 | const trimmed = entry.prompt.trim().replace(/\s+/g, " "); | ||
| 19 | if (trimmed.length > 0) { | ||
| 20 | return trimmed.length > SNIPPET_MAX_CHARS ? `${trimmed.slice(0, SNIPPET_MAX_CHARS)}…` : trimmed; | ||
| 21 | } | ||
| 22 | const imageCount = entry.attachments.length + entry.droppedImageNames.length; | ||
| 23 | return imageCount > 0 ? `(${imageCount} image${imageCount === 1 ? "" : "s"})` : "(empty)"; | ||
| 24 | } | ||
| 25 | |||
| 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. | ||
| 32 | */ | ||
| 33 | export const ComposerStashMenu = memo(function ComposerStashMenu(props: { | ||
| 34 | entries: ReadonlyArray<PromptStashEntry>; | ||
| 35 | providerLabel: string; | ||
| 36 | otherScopesCount: number; | ||
| 37 | onRestore: (entry: PromptStashEntry) => void; | ||
| 38 | onDelete: (entry: PromptStashEntry) => void; | ||
| 39 | onClose: () => void; | ||
| 40 | }) { | ||
| 41 | const { entries, onRestore, onDelete, onClose } = props; | ||
| 42 | const [highlightedId, setHighlightedId] = useState<string | null>(entries[0]?.id ?? null); | ||
| 43 | |||
| 44 | const highlightedEntry = entries.find((entry) => entry.id === highlightedId) ?? entries[0]; | ||
| 45 | |||
| 46 | useEffect(() => { | ||
| 47 | if (entries.length === 0) return; | ||
| 48 | if (!entries.some((entry) => entry.id === highlightedId)) { | ||
| 49 | setHighlightedId(entries[0]?.id ?? null); | ||
| 50 | } | ||
| 51 | }, [entries, highlightedId]); | ||
| 52 | |||
| 53 | useEffect(() => { | ||
| 54 | const handler = (event: KeyboardEvent) => { | ||
| 55 | if (event.key === "Escape") { | ||
| 56 | event.preventDefault(); | ||
| 57 | event.stopPropagation(); | ||
| 58 | onClose(); | ||
| 59 | return; | ||
| 60 | } | ||
| 61 | if (event.key === "ArrowDown" || event.key === "ArrowUp") { | ||
| 62 | if (entries.length === 0) return; | ||
| 63 | event.preventDefault(); | ||
| 64 | event.stopPropagation(); | ||
| 65 | const currentIndex = entries.findIndex((entry) => entry.id === highlightedId); | ||
| 66 | const offset = event.key === "ArrowDown" ? 1 : -1; | ||
| 67 | const normalizedIndex = currentIndex >= 0 ? currentIndex : offset === 1 ? -1 : 0; | ||
| 68 | const nextIndex = (normalizedIndex + offset + entries.length) % entries.length; | ||
| 69 | setHighlightedId(entries[nextIndex]?.id ?? null); | ||
| 70 | return; | ||
| 71 | } | ||
| 72 | if (event.key === "Enter") { | ||
| 73 | // A focused control inside the row (the delete button) owns its own | ||
| 74 | // activation; swallowing Enter here would restore instead of delete. | ||
| 75 | if (event.target instanceof HTMLElement && event.target.closest("button[aria-label]")) { | ||
| 76 | return; | ||
| 77 | } | ||
| 78 | if (!highlightedEntry) return; | ||
| 79 | event.preventDefault(); | ||
| 80 | event.stopPropagation(); | ||
| 81 | onRestore(highlightedEntry); | ||
| 82 | return; | ||
| 83 | } | ||
| 84 | if (event.key === "Backspace" && (event.metaKey || event.ctrlKey)) { | ||
| 85 | if (!highlightedEntry) return; | ||
| 86 | event.preventDefault(); | ||
| 87 | event.stopPropagation(); | ||
| 88 | onDelete(highlightedEntry); | ||
| 89 | } | ||
| 90 | }; | ||
| 91 | window.addEventListener("keydown", handler, true); | ||
| 92 | return () => window.removeEventListener("keydown", handler, true); | ||
| 93 | }, [entries, highlightedEntry, highlightedId, onClose, onDelete, onRestore]); | ||
| 94 | |||
| 95 | return ( | ||
| 96 | <Command autoHighlight={false} mode="none"> | ||
| 97 | <div className="dropdown-glass relative w-full overflow-hidden rounded-[20px]"> | ||
| 98 | <CommandList className="max-h-72"> | ||
| 99 | <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"> | ||
| 101 | <BookmarkIcon className="size-3" aria-hidden="true" /> | ||
| 102 | Stashed prompts — {props.providerLabel} | ||
| 103 | </CommandGroupLabel> | ||
| 104 | {entries.length === 0 ? ( | ||
| 105 | <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. | ||
| 108 | </p> | ||
| 109 | ) : ( | ||
| 110 | entries.map((entry) => ( | ||
| 111 | <CommandItem | ||
| 112 | key={entry.id} | ||
| 113 | value={entry.id} | ||
| 114 | className={cn( | ||
| 115 | "group/stash cursor-pointer select-none gap-2 hover:bg-transparent hover:text-inherit data-highlighted:bg-transparent data-highlighted:text-inherit", | ||
| 116 | highlightedId === entry.id && "bg-accent! text-accent-foreground!", | ||
| 117 | )} | ||
| 118 | onMouseMove={() => { | ||
| 119 | if (highlightedId !== entry.id) setHighlightedId(entry.id); | ||
| 120 | }} | ||
| 121 | onMouseDown={(event) => { | ||
| 122 | event.preventDefault(); | ||
| 123 | }} | ||
| 124 | onClick={() => { | ||
| 125 | onRestore(entry); | ||
| 126 | }} | ||
| 127 | > | ||
| 128 | {entry.attachments.length > 0 ? ( | ||
| 129 | <span className="flex shrink-0 items-center -space-x-1.5"> | ||
| 130 | {entry.attachments.slice(0, 3).map((attachment) => ( | ||
| 131 | <img | ||
| 132 | key={attachment.id} | ||
| 133 | src={attachment.dataUrl} | ||
| 134 | alt="" | ||
| 135 | aria-hidden="true" | ||
| 136 | className="size-5 rounded border border-border/70 object-cover" | ||
| 137 | /> | ||
| 138 | ))} | ||
| 139 | </span> | ||
| 140 | ) : ( | ||
| 141 | <BookmarkIcon className="size-4 shrink-0 text-muted-foreground/60" /> | ||
| 142 | )} | ||
| 143 | <span className="min-w-0 flex-1 truncate text-sm"> | ||
| 144 | {stashEntrySnippet(entry)} | ||
| 145 | </span> | ||
| 146 | {entry.pendingImageCount ? ( | ||
| 147 | <span className="shrink-0 text-[10px] text-muted-foreground/60"> | ||
| 148 | saving {entry.pendingImageCount} image | ||
| 149 | {entry.pendingImageCount === 1 ? "" : "s"}… | ||
| 150 | </span> | ||
| 151 | ) : missingImageCount(entry) > 0 ? ( | ||
| 152 | <span className="shrink-0 text-[10px] text-amber-600"> | ||
| 153 | {missingImageCount(entry)} image | ||
| 154 | {missingImageCount(entry) === 1 ? "" : "s"} dropped | ||
| 155 | </span> | ||
| 156 | ) : null} | ||
| 157 | <span className="shrink-0 text-muted-foreground/60 text-xs"> | ||
| 158 | {formatRelativeTimeLabel(entry.createdAt)} | ||
| 159 | </span> | ||
| 160 | <Button | ||
| 161 | variant="ghost" | ||
| 162 | size="icon-xs" | ||
| 163 | className="shrink-0 opacity-0 transition-opacity group-hover/stash:opacity-100" | ||
| 164 | aria-label="Delete stashed prompt" | ||
| 165 | onClick={(event) => { | ||
| 166 | event.stopPropagation(); | ||
| 167 | onDelete(entry); | ||
| 168 | }} | ||
| 169 | > | ||
| 170 | <XIcon /> | ||
| 171 | </Button> | ||
| 172 | </CommandItem> | ||
| 173 | )) | ||
| 174 | )} | ||
| 175 | </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> | ||
| 183 | </div> | ||
| 184 | </Command> | ||
| 185 | ); | ||
| 186 | }); | ||
apps/web/src/composerDraftStore.ts+37-1| ... | @@ -495,6 +495,13 @@ interface ComposerDraftStoreState { | ... | @@ -495,6 +495,13 @@ interface ComposerDraftStoreState { |
| 495 | attachments: PersistedComposerImageAttachment[], | 495 | attachments: PersistedComposerImageAttachment[], |
| 496 | ) => void; | 496 | ) => void; |
| 497 | clearComposerContent: (threadRef: ComposerThreadTarget) => void; | 497 | clearComposerContent: (threadRef: ComposerThreadTarget) => void; |
| 498 | /** | ||
| 499 | * Clears only the prompt text and image attachments, preserving terminal / | ||
| 500 | * element contexts, preview annotations, and review comments. Used by the | ||
| 501 | * prompt stash, which can only round-trip text + images: clearing the | ||
| 502 | * session-bound contexts would destroy state nothing can restore. | ||
| 503 | */ | ||
| 504 | clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; | ||
| 498 | } | 505 | } |
| 499 | 506 | ||
| 500 | export interface EffectiveComposerModelState { | 507 | export interface EffectiveComposerModelState { |
| ... | @@ -2091,7 +2098,7 @@ function hydratePersistedComposerImageAttachment( | ... | @@ -2091,7 +2098,7 @@ function hydratePersistedComposerImageAttachment( |
| 2091 | } | 2098 | } |
| 2092 | } | 2099 | } |
| 2093 | 2100 | ||
| 2094 | function hydrateImagesFromPersisted( | 2101 | export function hydrateImagesFromPersisted( |
| 2095 | attachments: ReadonlyArray<PersistedComposerImageAttachment>, | 2102 | attachments: ReadonlyArray<PersistedComposerImageAttachment>, |
| 2096 | ): ComposerImageAttachment[] { | 2103 | ): ComposerImageAttachment[] { |
| 2097 | return attachments.flatMap((attachment) => { | 2104 | return attachments.flatMap((attachment) => { |
| ... | @@ -3347,6 +3354,35 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -3347,6 +3354,35 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 3347 | return { draftsByThreadKey: nextDraftsByThreadKey }; | 3354 | return { draftsByThreadKey: nextDraftsByThreadKey }; |
| 3348 | }); | 3355 | }); |
| 3349 | }, | 3356 | }, |
| 3357 | clearComposerPromptAndImages: (threadRef) => { | ||
| 3358 | const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; | ||
| 3359 | if (threadKey.length === 0) { | ||
| 3360 | return; | ||
| 3361 | } | ||
| 3362 | set((state) => { | ||
| 3363 | const current = state.draftsByThreadKey[threadKey]; | ||
| 3364 | if (!current) { | ||
| 3365 | return state; | ||
| 3366 | } | ||
| 3367 | for (const image of current.images) { | ||
| 3368 | revokeObjectPreviewUrl(image.previewUrl); | ||
| 3369 | } | ||
| 3370 | const nextDraft: ComposerThreadDraftState = { | ||
| 3371 | ...current, | ||
| 3372 | prompt: ensureInlineTerminalContextPlaceholders("", current.terminalContexts.length), | ||
| 3373 | images: [], | ||
| 3374 | nonPersistedImageIds: [], | ||
| 3375 | persistedAttachments: [], | ||
| 3376 | }; | ||
| 3377 | const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | ||
| 3378 | if (shouldRemoveDraft(nextDraft)) { | ||
| 3379 | delete nextDraftsByThreadKey[threadKey]; | ||
| 3380 | } else { | ||
| 3381 | nextDraftsByThreadKey[threadKey] = nextDraft; | ||
| 3382 | } | ||
| 3383 | return { draftsByThreadKey: nextDraftsByThreadKey }; | ||
| 3384 | }); | ||
| 3385 | }, | ||
| 3350 | }; | 3386 | }; |
| 3351 | }, | 3387 | }, |
| 3352 | { | 3388 | { |
apps/web/src/index.css+24| ... | @@ -1432,6 +1432,30 @@ label:has(> select#reasoning-effort) select { | ... | @@ -1432,6 +1432,30 @@ label:has(> select#reasoning-effort) select { |
| 1432 | margin-top: 0.125rem; | 1432 | margin-top: 0.125rem; |
| 1433 | } | 1433 | } |
| 1434 | 1434 | ||
| 1435 | /* Prompt-stash save acknowledgement: the new count fades up from just below | ||
| 1436 | its resting position, once, then stops. One-shot and event-driven (React | ||
| 1437 | remounts the element by key on each stash) — no continuous animation. */ | ||
| 1438 | @keyframes prompt-stash-count-enter { | ||
| 1439 | from { | ||
| 1440 | opacity: 0; | ||
| 1441 | transform: translateY(2px); | ||
| 1442 | } | ||
| 1443 | to { | ||
| 1444 | opacity: 1; | ||
| 1445 | transform: translateY(0); | ||
| 1446 | } | ||
| 1447 | } | ||
| 1448 | |||
| 1449 | .prompt-stash-count-enter { | ||
| 1450 | animation: prompt-stash-count-enter 180ms ease-out both; | ||
| 1451 | } | ||
| 1452 | |||
| 1453 | @media (prefers-reduced-motion: reduce) { | ||
| 1454 | .prompt-stash-count-enter { | ||
| 1455 | animation: none; | ||
| 1456 | } | ||
| 1457 | } | ||
| 1458 | |||
| 1435 | @keyframes provider-update-pill-countdown { | 1459 | @keyframes provider-update-pill-countdown { |
| 1436 | from { | 1460 | from { |
| 1437 | transform: scaleX(1); | 1461 | transform: scaleX(1); |
apps/web/src/lib/stashImageCompression.test.ts created+198| ... | @@ -0,0 +1,198 @@ | ||
| 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { compressImageForStash, MAX_STASH_IMAGE_DATA_URL_CHARS } from "./stashImageCompression"; | ||
| 4 | |||
| 5 | /** | ||
| 6 | * jsdom has no real canvas/codec, so the re-encode path is exercised with | ||
| 7 | * stubbed `createImageBitmap` + `OffscreenCanvas`. The encoder stub returns a | ||
| 8 | * payload whose size scales with quality, mirroring how a real JPEG encoder | ||
| 9 | * shrinks as quality drops — enough to verify the ladder logic and budget | ||
| 10 | * enforcement without pulling in a native canvas. | ||
| 11 | */ | ||
| 12 | |||
| 13 | const originalCreateImageBitmap = globalThis.createImageBitmap; | ||
| 14 | const originalOffscreenCanvas = globalThis.OffscreenCanvas; | ||
| 15 | |||
| 16 | function makeFile(sizeBytes: number, type = "image/png"): File { | ||
| 17 | return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type }); | ||
| 18 | } | ||
| 19 | |||
| 20 | /** | ||
| 21 | * Installs a fake bitmap + canvas whose encoded size follows `sizeForQuality`. | ||
| 22 | * `supportsWebp: false` makes `convertToBlob` hand back a differently-typed | ||
| 23 | * blob for WebP requests, which is how a real browser signals it cannot | ||
| 24 | * encode that format. | ||
| 25 | */ | ||
| 26 | function stubCanvasPipeline( | ||
| 27 | sizeForQuality: (quality: number) => number, | ||
| 28 | options?: { supportsWebp?: boolean }, | ||
| 29 | ) { | ||
| 30 | const supportsWebp = options?.supportsWebp ?? true; | ||
| 31 | const close = vi.fn(); | ||
| 32 | const fillRect = vi.fn(); | ||
| 33 | vi.stubGlobal( | ||
| 34 | "createImageBitmap", | ||
| 35 | vi.fn(async () => ({ width: 4000, height: 3000, close })), | ||
| 36 | ); | ||
| 37 | vi.stubGlobal( | ||
| 38 | "OffscreenCanvas", | ||
| 39 | class { | ||
| 40 | constructor( | ||
| 41 | public width: number, | ||
| 42 | public height: number, | ||
| 43 | ) {} | ||
| 44 | getContext() { | ||
| 45 | return { | ||
| 46 | fillStyle: "", | ||
| 47 | fillRect, | ||
| 48 | drawImage: vi.fn(), | ||
| 49 | }; | ||
| 50 | } | ||
| 51 | async convertToBlob({ type, quality }: { type: string; quality: number }) { | ||
| 52 | const resolvedType = type === "image/webp" && !supportsWebp ? "image/png" : type; | ||
| 53 | return new Blob([new Uint8Array(sizeForQuality(quality))], { type: resolvedType }); | ||
| 54 | } | ||
| 55 | }, | ||
| 56 | ); | ||
| 57 | return { close, fillRect }; | ||
| 58 | } | ||
| 59 | |||
| 60 | afterEach(() => { | ||
| 61 | vi.unstubAllGlobals(); | ||
| 62 | globalThis.createImageBitmap = originalCreateImageBitmap; | ||
| 63 | globalThis.OffscreenCanvas = originalOffscreenCanvas; | ||
| 64 | }); | ||
| 65 | |||
| 66 | describe("compressImageForStash", () => { | ||
| 67 | it("stores a small image verbatim without re-encoding", async () => { | ||
| 68 | const bitmapSpy = vi.fn(); | ||
| 69 | vi.stubGlobal("createImageBitmap", bitmapSpy); | ||
| 70 | |||
| 71 | const result = await compressImageForStash(makeFile(1024)); | ||
| 72 | |||
| 73 | expect(result.ok).toBe(true); | ||
| 74 | expect(result.ok && result.image.recompressed).toBe(false); | ||
| 75 | expect(result.ok && result.image.mimeType).toBe("image/png"); | ||
| 76 | expect(result.ok && result.image.dataUrl.startsWith("data:image/png")).toBe(true); | ||
| 77 | // Untouched payloads must not pay for a decode. | ||
| 78 | expect(bitmapSpy).not.toHaveBeenCalled(); | ||
| 79 | }); | ||
| 80 | |||
| 81 | it("re-encodes an oversized image to WebP within the budget", async () => { | ||
| 82 | // Comfortably under budget at the very first quality step. | ||
| 83 | const { close, fillRect } = stubCanvasPipeline(() => 120_000); | ||
| 84 | |||
| 85 | const result = await compressImageForStash(makeFile(4_000_000)); | ||
| 86 | |||
| 87 | expect(result.ok).toBe(true); | ||
| 88 | expect(result.ok && result.image.recompressed).toBe(true); | ||
| 89 | expect(result.ok && result.image.mimeType).toBe("image/webp"); | ||
| 90 | expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true); | ||
| 91 | // sizeBytes should describe the re-encoded payload, not the 4MB original. | ||
| 92 | expect(result.ok && result.image.sizeBytes).toBeLessThan(4_000_000); | ||
| 93 | // WebP keeps alpha, so no white matte should be painted. | ||
| 94 | expect(fillRect).not.toHaveBeenCalled(); | ||
| 95 | expect(close).toHaveBeenCalled(); | ||
| 96 | }); | ||
| 97 | |||
| 98 | it("falls back to JPEG with a white matte when WebP encoding is unavailable", async () => { | ||
| 99 | const { fillRect } = stubCanvasPipeline(() => 120_000, { supportsWebp: false }); | ||
| 100 | |||
| 101 | const result = await compressImageForStash(makeFile(4_000_000)); | ||
| 102 | |||
| 103 | expect(result.ok && result.image.recompressed).toBe(true); | ||
| 104 | expect(result.ok && result.image.mimeType).toBe("image/jpeg"); | ||
| 105 | // JPEG has no alpha, so transparent regions must be matted white. | ||
| 106 | expect(fillRect).toHaveBeenCalled(); | ||
| 107 | }); | ||
| 108 | |||
| 109 | it("steps quality down until the encoded image fits", async () => { | ||
| 110 | // Only the lowest quality step (0.68) lands under the budget. | ||
| 111 | const { close } = stubCanvasPipeline((quality) => (quality <= 0.68 ? 400_000 : 3_000_000)); | ||
| 112 | |||
| 113 | const result = await compressImageForStash(makeFile(9_000_000)); | ||
| 114 | |||
| 115 | expect(result.ok && result.image.recompressed).toBe(true); | ||
| 116 | expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true); | ||
| 117 | expect(close).toHaveBeenCalled(); | ||
| 118 | }); | ||
| 119 | |||
| 120 | it("reports too-large when even the smallest encoding overflows the budget", async () => { | ||
| 121 | const { close } = stubCanvasPipeline(() => 8_000_000); | ||
| 122 | |||
| 123 | const result = await compressImageForStash(makeFile(9_000_000)); | ||
| 124 | |||
| 125 | expect(result).toEqual({ ok: false, reason: "too-large" }); | ||
| 126 | // The bitmap must still be released on the give-up path. | ||
| 127 | expect(close).toHaveBeenCalled(); | ||
| 128 | }); | ||
| 129 | |||
| 130 | it("reports too-large for an oversized image when the browser cannot re-encode", async () => { | ||
| 131 | vi.stubGlobal("createImageBitmap", undefined); | ||
| 132 | vi.stubGlobal("OffscreenCanvas", undefined); | ||
| 133 | |||
| 134 | expect(await compressImageForStash(makeFile(4_000_000))).toEqual({ | ||
| 135 | ok: false, | ||
| 136 | reason: "too-large", | ||
| 137 | }); | ||
| 138 | }); | ||
| 139 | |||
| 140 | it("reports unreadable when the image fails to decode", async () => { | ||
| 141 | vi.stubGlobal( | ||
| 142 | "createImageBitmap", | ||
| 143 | vi.fn(async () => { | ||
| 144 | throw new Error("corrupt image"); | ||
| 145 | }), | ||
| 146 | ); | ||
| 147 | vi.stubGlobal( | ||
| 148 | "OffscreenCanvas", | ||
| 149 | class { | ||
| 150 | getContext() { | ||
| 151 | return null; | ||
| 152 | } | ||
| 153 | }, | ||
| 154 | ); | ||
| 155 | |||
| 156 | expect(await compressImageForStash(makeFile(4_000_000))).toEqual({ | ||
| 157 | ok: false, | ||
| 158 | reason: "unreadable", | ||
| 159 | }); | ||
| 160 | }); | ||
| 161 | |||
| 162 | it("shrinks below the source size when the image is already under MAX_DIMENSION", async () => { | ||
| 163 | // A small-but-heavy source (e.g. a dense PNG): only a real downscale can | ||
| 164 | // get it under budget, since quality alone is stubbed to never suffice. | ||
| 165 | let smallestRequested = Number.POSITIVE_INFINITY; | ||
| 166 | const close = vi.fn(); | ||
| 167 | vi.stubGlobal( | ||
| 168 | "createImageBitmap", | ||
| 169 | vi.fn(async () => ({ width: 800, height: 600, close })), | ||
| 170 | ); | ||
| 171 | vi.stubGlobal( | ||
| 172 | "OffscreenCanvas", | ||
| 173 | class { | ||
| 174 | constructor( | ||
| 175 | public width: number, | ||
| 176 | public height: number, | ||
| 177 | ) { | ||
| 178 | smallestRequested = Math.min(smallestRequested, width); | ||
| 179 | } | ||
| 180 | getContext() { | ||
| 181 | return { fillStyle: "", fillRect: vi.fn(), drawImage: vi.fn() }; | ||
| 182 | } | ||
| 183 | async convertToBlob({ type }: { type: string; quality: number }) { | ||
| 184 | // Only a genuinely downscaled pass fits the budget. | ||
| 185 | const size = smallestRequested < 800 ? 100_000 : 5_000_000; | ||
| 186 | return new Blob([new Uint8Array(size)], { type }); | ||
| 187 | } | ||
| 188 | }, | ||
| 189 | ); | ||
| 190 | |||
| 191 | const result = await compressImageForStash(makeFile(4_000_000)); | ||
| 192 | |||
| 193 | expect(result.ok).toBe(true); | ||
| 194 | // Fallback passes must scale off the bitmap, not a fixed 2048 ceiling | ||
| 195 | // that would never go below an 800px source. | ||
| 196 | expect(smallestRequested).toBeLessThan(800); | ||
| 197 | }); | ||
| 198 | }); | ||
apps/web/src/lib/stashImageCompression.ts created+250| ... | @@ -0,0 +1,250 @@ | ||
| 1 | /** | ||
| 2 | * Re-encoding for stashed image attachments. | ||
| 3 | * | ||
| 4 | * The composer accepts images up to `PROVIDER_SEND_TURN_MAX_IMAGE_BYTES` | ||
| 5 | * (10MB), but the stash persists them as base64 in localStorage, where the | ||
| 6 | * whole origin shares a ~5MB quota. Rather than refuse large screenshots, | ||
| 7 | * downscale + re-encode them to JPEG so a stashed prompt keeps its images. | ||
| 8 | * | ||
| 9 | * Only the *stashed copy* is compressed; the live composer attachment is | ||
| 10 | * untouched, so sending without stashing still uploads the original file. | ||
| 11 | */ | ||
| 12 | |||
| 13 | /** | ||
| 14 | * Longest edge kept when an image has to be re-encoded. Sized so a typical | ||
| 15 | * retina screenshot (3024px wide) stays legible rather than being halved. | ||
| 16 | */ | ||
| 17 | const MAX_DIMENSION = 2048; | ||
| 18 | /** Base64 budget for a single stashed image (~975KB of binary). */ | ||
| 19 | export const MAX_STASH_IMAGE_DATA_URL_CHARS = 1_300_000; | ||
| 20 | /** | ||
| 21 | * Quality ladder tried in order until the encoded image fits the budget. | ||
| 22 | * The floor stays high enough to avoid visible blocking on UI screenshots; | ||
| 23 | * if even that overflows we drop resolution instead of quality. | ||
| 24 | */ | ||
| 25 | const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const; | ||
| 26 | /** Extra downscale passes applied when even the lowest quality overflows. */ | ||
| 27 | const FALLBACK_SCALE_STEPS = [0.75, 0.55] as const; | ||
| 28 | |||
| 29 | export interface CompressedStashImage { | ||
| 30 | dataUrl: string; | ||
| 31 | mimeType: string; | ||
| 32 | sizeBytes: number; | ||
| 33 | /** True when the payload was re-encoded rather than stored verbatim. */ | ||
| 34 | recompressed: boolean; | ||
| 35 | } | ||
| 36 | |||
| 37 | /** | ||
| 38 | * Why an image could not be stashed. Callers report these differently: | ||
| 39 | * "too large" is a budget outcome, "unreadable" is a decode failure. | ||
| 40 | */ | ||
| 41 | export type StashImageFailureReason = "too-large" | "unreadable"; | ||
| 42 | |||
| 43 | export type CompressStashImageResult = | ||
| 44 | | { ok: true; image: CompressedStashImage } | ||
| 45 | | { ok: false; reason: StashImageFailureReason }; | ||
| 46 | |||
| 47 | /** Chunked so a large image can't blow the argument limit of `fromCharCode`. */ | ||
| 48 | const BASE64_CHUNK_SIZE = 0x8000; | ||
| 49 | |||
| 50 | function bytesToBase64(bytes: Uint8Array): string { | ||
| 51 | let binary = ""; | ||
| 52 | for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) { | ||
| 53 | binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE)); | ||
| 54 | } | ||
| 55 | return btoa(binary); | ||
| 56 | } | ||
| 57 | |||
| 58 | /** | ||
| 59 | * Blob → base64 data URL. Uses `arrayBuffer()` rather than `FileReader` so | ||
| 60 | * the module works anywhere `Blob` does (including non-DOM test runners). | ||
| 61 | */ | ||
| 62 | async function blobToDataUrl(blob: File | Blob, mimeTypeOverride?: string): Promise<string> { | ||
| 63 | const buffer = await blob.arrayBuffer(); | ||
| 64 | const mimeType = mimeTypeOverride || blob.type || "application/octet-stream"; | ||
| 65 | return `data:${mimeType};base64,${bytesToBase64(new Uint8Array(buffer))}`; | ||
| 66 | } | ||
| 67 | |||
| 68 | /** Approximate decoded byte count for a base64 data URL. */ | ||
| 69 | function dataUrlByteLength(dataUrl: string): number { | ||
| 70 | const commaIndex = dataUrl.indexOf(","); | ||
| 71 | const payload = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1); | ||
| 72 | const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0; | ||
| 73 | return Math.max(0, Math.floor((payload.length * 3) / 4) - padding); | ||
| 74 | } | ||
| 75 | |||
| 76 | function canRecompress(): boolean { | ||
| 77 | return ( | ||
| 78 | typeof createImageBitmap === "function" && | ||
| 79 | (typeof OffscreenCanvas === "function" || typeof document !== "undefined") | ||
| 80 | ); | ||
| 81 | } | ||
| 82 | |||
| 83 | interface Canvas2D { | ||
| 84 | canvas: OffscreenCanvas | HTMLCanvasElement; | ||
| 85 | context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; | ||
| 86 | } | ||
| 87 | |||
| 88 | function createCanvas(width: number, height: number): Canvas2D | null { | ||
| 89 | if (typeof OffscreenCanvas === "function") { | ||
| 90 | const canvas = new OffscreenCanvas(width, height); | ||
| 91 | const context = canvas.getContext("2d"); | ||
| 92 | if (!context) return null; | ||
| 93 | return { canvas, context }; | ||
| 94 | } | ||
| 95 | if (typeof document === "undefined") return null; | ||
| 96 | const canvas = document.createElement("canvas"); | ||
| 97 | canvas.width = width; | ||
| 98 | canvas.height = height; | ||
| 99 | const context = canvas.getContext("2d"); | ||
| 100 | if (!context) return null; | ||
| 101 | return { canvas, context }; | ||
| 102 | } | ||
| 103 | |||
| 104 | /** | ||
| 105 | * WebP is preferred: at matched visual quality it lands roughly 25-35% | ||
| 106 | * smaller than JPEG, so the same budget buys more resolution and detail — | ||
| 107 | * and it keeps alpha, so screenshots with transparency survive intact. | ||
| 108 | * Browsers that can't encode it silently fall back to JPEG. | ||
| 109 | */ | ||
| 110 | async function encodeToDataUrl( | ||
| 111 | canvas: OffscreenCanvas | HTMLCanvasElement, | ||
| 112 | quality: number, | ||
| 113 | mimeType: string, | ||
| 114 | ): Promise<{ dataUrl: string; mimeType: string } | null> { | ||
| 115 | if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) { | ||
| 116 | const dataUrl = canvas.toDataURL(mimeType, quality); | ||
| 117 | // toDataURL silently returns a PNG when the requested type is unsupported. | ||
| 118 | if (!dataUrl.startsWith(`data:${mimeType}`)) return null; | ||
| 119 | return { dataUrl, mimeType }; | ||
| 120 | } | ||
| 121 | const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality }); | ||
| 122 | if (blob.type && blob.type !== mimeType) return null; | ||
| 123 | return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType }; | ||
| 124 | } | ||
| 125 | |||
| 126 | /** | ||
| 127 | * Draws `bitmap` scaled to fit `maxDimension` and encodes it as JPEG, | ||
| 128 | * stepping quality down until the data URL fits `budgetChars`. Returns the | ||
| 129 | * smallest encoding produced, even if it still exceeds the budget, so the | ||
| 130 | * caller can decide whether to keep or drop it. | ||
| 131 | */ | ||
| 132 | async function encodeWithinBudget( | ||
| 133 | bitmap: ImageBitmap, | ||
| 134 | maxDimension: number, | ||
| 135 | budgetChars: number, | ||
| 136 | ): Promise<{ dataUrl: string; mimeType: string } | null> { | ||
| 137 | const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height)); | ||
| 138 | const width = Math.max(1, Math.round(bitmap.width * scale)); | ||
| 139 | const height = Math.max(1, Math.round(bitmap.height * scale)); | ||
| 140 | const target = createCanvas(width, height); | ||
| 141 | if (!target) return null; | ||
| 142 | |||
| 143 | // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to | ||
| 144 | // happen before drawing and depends on which codec we end up using. | ||
| 145 | const probe = await encodeToDataUrl(target.canvas, QUALITY_STEPS[0], "image/webp"); | ||
| 146 | const mimeType = probe ? "image/webp" : "image/jpeg"; | ||
| 147 | |||
| 148 | if (mimeType === "image/jpeg") { | ||
| 149 | target.context.fillStyle = "#ffffff"; | ||
| 150 | target.context.fillRect(0, 0, width, height); | ||
| 151 | } | ||
| 152 | target.context.drawImage(bitmap, 0, 0, width, height); | ||
| 153 | |||
| 154 | let smallest: { dataUrl: string; mimeType: string } | null = null; | ||
| 155 | for (const quality of QUALITY_STEPS) { | ||
| 156 | const encoded = await encodeToDataUrl(target.canvas, quality, mimeType); | ||
| 157 | if (!encoded) break; | ||
| 158 | if (smallest === null || encoded.dataUrl.length < smallest.dataUrl.length) { | ||
| 159 | smallest = encoded; | ||
| 160 | } | ||
| 161 | if (encoded.dataUrl.length <= budgetChars) { | ||
| 162 | return encoded; | ||
| 163 | } | ||
| 164 | } | ||
| 165 | return smallest; | ||
| 166 | } | ||
| 167 | |||
| 168 | /** | ||
| 169 | * Produces the payload to persist for a stashed image. | ||
| 170 | * | ||
| 171 | * Small images are stored verbatim (preserving PNG transparency and exact | ||
| 172 | * pixels). Anything over budget is downscaled and JPEG-encoded; if it still | ||
| 173 | * doesn't fit after the fallback passes, returns `null` so the caller can | ||
| 174 | * record it as dropped. | ||
| 175 | */ | ||
| 176 | export async function compressImageForStash( | ||
| 177 | file: File, | ||
| 178 | budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS, | ||
| 179 | ): Promise<CompressStashImageResult> { | ||
| 180 | let originalDataUrl: string; | ||
| 181 | try { | ||
| 182 | originalDataUrl = await blobToDataUrl(file); | ||
| 183 | } catch { | ||
| 184 | return { ok: false, reason: "unreadable" }; | ||
| 185 | } | ||
| 186 | if (originalDataUrl.length <= budgetChars) { | ||
| 187 | return { | ||
| 188 | ok: true, | ||
| 189 | image: { | ||
| 190 | dataUrl: originalDataUrl, | ||
| 191 | mimeType: file.type, | ||
| 192 | sizeBytes: file.size, | ||
| 193 | recompressed: false, | ||
| 194 | }, | ||
| 195 | }; | ||
| 196 | } | ||
| 197 | if (!canRecompress()) { | ||
| 198 | return { ok: false, reason: "too-large" }; | ||
| 199 | } | ||
| 200 | |||
| 201 | let bitmap: ImageBitmap; | ||
| 202 | try { | ||
| 203 | bitmap = await createImageBitmap(file); | ||
| 204 | } catch { | ||
| 205 | return { ok: false, reason: "unreadable" }; | ||
| 206 | } | ||
| 207 | |||
| 208 | try { | ||
| 209 | // Each pass shrinks relative to the *previous target*, capped by | ||
| 210 | // MAX_DIMENSION. Scaling a fixed ceiling instead would be a no-op for | ||
| 211 | // images already smaller than that ceiling — the fallback passes would | ||
| 212 | // all resolve to the source size and never actually reduce resolution. | ||
| 213 | const baseDimension = Math.min(MAX_DIMENSION, Math.max(bitmap.width, bitmap.height)); | ||
| 214 | // Tracks whether the *last* attempt threw, so a run of encoder failures | ||
| 215 | // is reported as unreadable while a run of merely-too-big results is | ||
| 216 | // reported as too-large. | ||
| 217 | let encodeFailed = false; | ||
| 218 | for (const dimensionScale of [1, ...FALLBACK_SCALE_STEPS]) { | ||
| 219 | const targetDimension = Math.max(1, Math.round(baseDimension * dimensionScale)); | ||
| 220 | let encoded: { dataUrl: string; mimeType: string } | null; | ||
| 221 | try { | ||
| 222 | encoded = await encodeWithinBudget(bitmap, targetDimension, budgetChars); | ||
| 223 | } catch { | ||
| 224 | // Canvas allocation, drawing, or the codec itself can throw — often | ||
| 225 | // precisely *because* the target is too big (OOM on a large bitmap). | ||
| 226 | // Keep trying the smaller fallback scales rather than giving up: a | ||
| 227 | // reduced pass may well succeed. The exception must never escape, | ||
| 228 | // though, since the caller finalizes the entry after this returns and | ||
| 229 | // a throw would strand it as permanently "still saving". | ||
| 230 | encodeFailed = true; | ||
| 231 | continue; | ||
| 232 | } | ||
| 233 | encodeFailed = false; | ||
| 234 | if (encoded && encoded.dataUrl.length <= budgetChars) { | ||
| 235 | return { | ||
| 236 | ok: true, | ||
| 237 | image: { | ||
| 238 | dataUrl: encoded.dataUrl, | ||
| 239 | mimeType: encoded.mimeType, | ||
| 240 | sizeBytes: dataUrlByteLength(encoded.dataUrl), | ||
| 241 | recompressed: true, | ||
| 242 | }, | ||
| 243 | }; | ||
| 244 | } | ||
| 245 | } | ||
| 246 | return { ok: false, reason: encodeFailed ? "unreadable" : "too-large" }; | ||
| 247 | } finally { | ||
| 248 | bitmap.close(); | ||
| 249 | } | ||
| 250 | } | ||
apps/web/src/promptStashStore.test.ts created+259| ... | @@ -0,0 +1,259 @@ | ||
| 1 | import { ProviderInstanceId } from "@t3tools/contracts"; | ||
| 2 | import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { removeLocalStorageItem } from "./hooks/useLocalStorage"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | MAX_STASH_ENTRIES_PER_QUEUE, | ||
| 8 | PROMPT_STASH_STORAGE_KEY, | ||
| 9 | MAX_STASH_ENTRY_ATTACHMENT_CHARS, | ||
| 10 | PROMPT_STASH_UNSCOPED_KEY, | ||
| 11 | partitionStashAttachments, | ||
| 12 | promptStashScopeKey, | ||
| 13 | usePromptStashStore, | ||
| 14 | writePromptStashStorageForTest, | ||
| 15 | type PromptStashEntry, | ||
| 16 | } from "./promptStashStore"; | ||
| 17 | |||
| 18 | const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent"); | ||
| 19 | const CODEX_INSTANCE = ProviderInstanceId.make("codex"); | ||
| 20 | |||
| 21 | function makeEntry(input: { | ||
| 22 | id: string; | ||
| 23 | providerInstanceId?: ProviderInstanceId | null; | ||
| 24 | prompt?: string; | ||
| 25 | attachmentChars?: number; | ||
| 26 | }): PromptStashEntry { | ||
| 27 | return { | ||
| 28 | id: input.id, | ||
| 29 | createdAt: "2026-07-24T12:00:00.000Z", | ||
| 30 | prompt: input.prompt ?? `prompt ${input.id}`, | ||
| 31 | attachments: | ||
| 32 | input.attachmentChars !== undefined | ||
| 33 | ? [ | ||
| 34 | { | ||
| 35 | id: `${input.id}-img`, | ||
| 36 | name: "shot.png", | ||
| 37 | mimeType: "image/png", | ||
| 38 | sizeBytes: input.attachmentChars, | ||
| 39 | dataUrl: "x".repeat(input.attachmentChars), | ||
| 40 | }, | ||
| 41 | ] | ||
| 42 | : [], | ||
| 43 | providerInstanceId: | ||
| 44 | input.providerInstanceId === undefined ? CLAUDE_AGENT_INSTANCE : input.providerInstanceId, | ||
| 45 | modelSelection: null, | ||
| 46 | droppedImageNames: [], | ||
| 47 | }; | ||
| 48 | } | ||
| 49 | |||
| 50 | function resetPromptStashStore() { | ||
| 51 | usePromptStashStore.setState({ queuesByScopeKey: {} }); | ||
| 52 | writePromptStashStorageForTest(""); | ||
| 53 | removeLocalStorageItem(PROMPT_STASH_STORAGE_KEY); | ||
| 54 | } | ||
| 55 | |||
| 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", () => { | ||
| 73 | it("keeps attachments within the budget and reports dropped names in order", () => { | ||
| 74 | const small = { | ||
| 75 | id: "a", | ||
| 76 | name: "small.png", | ||
| 77 | mimeType: "image/png", | ||
| 78 | sizeBytes: 10, | ||
| 79 | dataUrl: "x".repeat(10), | ||
| 80 | }; | ||
| 81 | const huge = { | ||
| 82 | id: "b", | ||
| 83 | name: "huge.png", | ||
| 84 | mimeType: "image/png", | ||
| 85 | sizeBytes: MAX_STASH_ENTRY_ATTACHMENT_CHARS, | ||
| 86 | dataUrl: "x".repeat(MAX_STASH_ENTRY_ATTACHMENT_CHARS), | ||
| 87 | }; | ||
| 88 | const alsoSmall = { | ||
| 89 | id: "c", | ||
| 90 | name: "also-small.png", | ||
| 91 | mimeType: "image/png", | ||
| 92 | sizeBytes: 10, | ||
| 93 | dataUrl: "x".repeat(10), | ||
| 94 | }; | ||
| 95 | const { kept, droppedNames } = partitionStashAttachments([small, huge, alsoSmall]); | ||
| 96 | expect(kept.map((attachment) => attachment.id)).toEqual(["a", "c"]); | ||
| 97 | expect(droppedNames).toEqual(["huge.png"]); | ||
| 98 | }); | ||
| 99 | |||
| 100 | it("admits a single attachment that exactly fits the budget", () => { | ||
| 101 | const exact = { | ||
| 102 | id: "a", | ||
| 103 | name: "exact.png", | ||
| 104 | mimeType: "image/png", | ||
| 105 | sizeBytes: MAX_STASH_ENTRY_ATTACHMENT_CHARS, | ||
| 106 | dataUrl: "x".repeat(MAX_STASH_ENTRY_ATTACHMENT_CHARS), | ||
| 107 | }; | ||
| 108 | const { kept, droppedNames } = partitionStashAttachments([exact]); | ||
| 109 | expect(kept).toHaveLength(1); | ||
| 110 | expect(droppedNames).toEqual([]); | ||
| 111 | }); | ||
| 112 | }); | ||
| 113 | |||
| 114 | describe("promptStashStore", () => { | ||
| 115 | beforeEach(() => { | ||
| 116 | resetPromptStashStore(); | ||
| 117 | }); | ||
| 118 | |||
| 119 | afterEach(() => { | ||
| 120 | resetPromptStashStore(); | ||
| 121 | }); | ||
| 122 | |||
| 123 | it("prepends entries so the newest stash is first", () => { | ||
| 124 | const store = usePromptStashStore.getState(); | ||
| 125 | store.stashEntry(makeEntry({ id: "first" })); | ||
| 126 | 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"]); | ||
| 146 | }); | ||
| 147 | |||
| 148 | it("evicts the oldest entry past the per-queue cap and returns it", () => { | ||
| 149 | const store = usePromptStashStore.getState(); | ||
| 150 | for (let index = 0; index < MAX_STASH_ENTRIES_PER_QUEUE; index += 1) { | ||
| 151 | expect(store.stashEntry(makeEntry({ id: `entry-${index}` })).evicted).toBeNull(); | ||
| 152 | } | ||
| 153 | const { evicted } = store.stashEntry(makeEntry({ id: "overflow" })); | ||
| 154 | 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"); | ||
| 160 | }); | ||
| 161 | |||
| 162 | it("takeEntry removes and returns the entry; second take returns null", () => { | ||
| 163 | 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"]); | ||
| 174 | }); | ||
| 175 | |||
| 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 | }); | ||
| 184 | const store = usePromptStashStore.getState(); | ||
| 185 | expect(() => store.takeEntry("__proto__", "missing")).not.toThrow(); | ||
| 186 | expect(store.takeEntry("__proto__", "missing").entry).toBeNull(); | ||
| 187 | }); | ||
| 188 | |||
| 189 | it("finalizeEntryImages attaches images and clears the pending count", () => { | ||
| 190 | const store = usePromptStashStore.getState(); | ||
| 191 | const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); | ||
| 192 | store.stashEntry({ ...makeEntry({ id: "pending" }), pendingImageCount: 2 }); | ||
| 193 | |||
| 194 | const { attached } = store.finalizeEntryImages(scopeKey, "pending", { | ||
| 195 | attachments: [ | ||
| 196 | { | ||
| 197 | id: "img-1", | ||
| 198 | name: "a.webp", | ||
| 199 | mimeType: "image/webp", | ||
| 200 | sizeBytes: 10, | ||
| 201 | dataUrl: "data:image/webp;base64,AAAA", | ||
| 202 | }, | ||
| 203 | ], | ||
| 204 | droppedImageNames: ["big.png"], | ||
| 205 | unreadableImageNames: [], | ||
| 206 | }); | ||
| 207 | |||
| 208 | expect(attached).toBe(true); | ||
| 209 | const entry = usePromptStashStore.getState().queuesByScopeKey[scopeKey]?.[0]; | ||
| 210 | expect(entry?.attachments).toHaveLength(1); | ||
| 211 | expect(entry?.droppedImageNames).toEqual(["big.png"]); | ||
| 212 | expect(entry?.pendingImageCount).toBe(0); | ||
| 213 | }); | ||
| 214 | |||
| 215 | it("finalizeEntryImages reports false when the entry was already taken", () => { | ||
| 216 | const store = usePromptStashStore.getState(); | ||
| 217 | const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); | ||
| 218 | store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 }); | ||
| 219 | // Restored (or deleted) while its images were still encoding. | ||
| 220 | store.takeEntry(scopeKey, "racing"); | ||
| 221 | |||
| 222 | const { attached } = store.finalizeEntryImages(scopeKey, "racing", { | ||
| 223 | attachments: [], | ||
| 224 | droppedImageNames: [], | ||
| 225 | unreadableImageNames: [], | ||
| 226 | }); | ||
| 227 | |||
| 228 | expect(attached).toBe(false); | ||
| 229 | }); | ||
| 230 | |||
| 231 | it("settles a pending count left behind by a crashed or closed session", () => { | ||
| 232 | const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); | ||
| 233 | writePromptStashStorageForTest( | ||
| 234 | JSON.stringify({ | ||
| 235 | version: 1, | ||
| 236 | state: { | ||
| 237 | queuesByScopeKey: { | ||
| 238 | [scopeKey]: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }], | ||
| 239 | }, | ||
| 240 | }, | ||
| 241 | }), | ||
| 242 | ); | ||
| 243 | |||
| 244 | // Hydration must settle the stale count, or the entry would stay stuck | ||
| 245 | // showing "saving…" with images that no longer exist anywhere. | ||
| 246 | const entry = usePromptStashStore.getState().queuesByScopeKey[scopeKey]?.[0]; | ||
| 247 | expect(entry?.pendingImageCount).toBe(0); | ||
| 248 | expect(entry?.unreadableImageNames).toHaveLength(2); | ||
| 249 | }); | ||
| 250 | |||
| 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(); | ||
| 258 | }); | ||
| 259 | }); | ||
apps/web/src/promptStashStore.ts created+322| ... | @@ -0,0 +1,322 @@ | ||
| 1 | import { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | import { create } from "zustand"; | ||
| 4 | |||
| 5 | import { PersistedComposerImageAttachment } from "./composerDraftStore"; | ||
| 6 | import { createMemoryStorage, type StateStorage } from "./lib/storage"; | ||
| 7 | |||
| 8 | export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1"; | ||
| 9 | const PROMPT_STASH_STORAGE_VERSION = 1; | ||
| 10 | |||
| 11 | /** | ||
| 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__`. | ||
| 16 | */ | ||
| 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:"; | ||
| 20 | |||
| 21 | export const MAX_STASH_ENTRIES_PER_QUEUE = 20; | ||
| 22 | /** | ||
| 23 | * Budget for an entry's serialized attachment payload. localStorage is a | ||
| 24 | * ~5MB origin-wide quota shared with the composer draft store, so oversized | ||
| 25 | * images are dropped (tracked in `droppedImageNames`) rather than persisted. | ||
| 26 | * | ||
| 27 | * Sized to hold two images at the per-image compression budget | ||
| 28 | * (`MAX_STASH_IMAGE_DATA_URL_CHARS`) so a typical before/after screenshot | ||
| 29 | * pair survives intact. | ||
| 30 | */ | ||
| 31 | export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000; | ||
| 32 | |||
| 33 | const StashEntrySchema = Schema.Struct({ | ||
| 34 | id: Schema.String, | ||
| 35 | createdAt: Schema.String, | ||
| 36 | prompt: Schema.String, | ||
| 37 | 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. */ | ||
| 41 | droppedImageNames: Schema.Array(Schema.String), | ||
| 42 | /** | ||
| 43 | * Names of images that could not be decoded or re-encoded at all — a | ||
| 44 | * distinct failure from exceeding the size budget, so the menu can explain | ||
| 45 | * which actually happened. Optional: entries written before this field | ||
| 46 | * existed decode without it. | ||
| 47 | */ | ||
| 48 | unreadableImageNames: Schema.optionalKey(Schema.Array(Schema.String)), | ||
| 49 | /** | ||
| 50 | * Images still being encoded when the entry was written. The entry is | ||
| 51 | * persisted before its images so a crash mid-encode cannot lose the prompt; | ||
| 52 | * this field lets the UI show "N images still saving" until | ||
| 53 | * `finalizeEntryImages` lands, and flags entries orphaned by a reload. | ||
| 54 | */ | ||
| 55 | pendingImageCount: Schema.optionalKey(Schema.Number), | ||
| 56 | }); | ||
| 57 | export type PromptStashEntry = typeof StashEntrySchema.Type; | ||
| 58 | |||
| 59 | const PersistedPromptStashState = Schema.Struct({ | ||
| 60 | queuesByScopeKey: Schema.Record(Schema.String, Schema.Array(StashEntrySchema)), | ||
| 61 | }); | ||
| 62 | type PersistedPromptStashState = typeof PersistedPromptStashState.Type; | ||
| 63 | |||
| 64 | const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPromptStashState); | ||
| 65 | |||
| 66 | /** | ||
| 67 | * `pendingImageCount` only has meaning within the session that wrote it: the | ||
| 68 | * encode loop that would clear it does not survive a reload. Any entry that | ||
| 69 | * comes back from storage still pending was orphaned by a closed tab or a | ||
| 70 | * crash mid-encode, so the count is settled here — otherwise the entry would | ||
| 71 | * be stuck showing "saving…" and refuse to restore forever. | ||
| 72 | * | ||
| 73 | * The images are genuinely gone (they were never written), so they are | ||
| 74 | * recorded as unreadable to keep the prompt itself restorable. | ||
| 75 | */ | ||
| 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, | ||
| 113 | ): ReadonlyArray<PromptStashEntry> { | ||
| 114 | return Object.hasOwn(queues, scopeKey) ? (queues[scopeKey] ?? []) : []; | ||
| 115 | } | ||
| 116 | |||
| 117 | /** | ||
| 118 | * Splits candidate attachments into a persistable set within the entry | ||
| 119 | * budget plus the names of any that had to be dropped. Attachments are | ||
| 120 | * admitted in order so the earliest-added images win. | ||
| 121 | */ | ||
| 122 | export function partitionStashAttachments( | ||
| 123 | attachments: ReadonlyArray<PersistedComposerImageAttachment>, | ||
| 124 | ): { | ||
| 125 | kept: PersistedComposerImageAttachment[]; | ||
| 126 | droppedNames: string[]; | ||
| 127 | } { | ||
| 128 | const kept: PersistedComposerImageAttachment[] = []; | ||
| 129 | const droppedNames: string[] = []; | ||
| 130 | let usedChars = 0; | ||
| 131 | for (const attachment of attachments) { | ||
| 132 | if (usedChars + attachment.dataUrl.length > MAX_STASH_ENTRY_ATTACHMENT_CHARS) { | ||
| 133 | droppedNames.push(attachment.name); | ||
| 134 | continue; | ||
| 135 | } | ||
| 136 | usedChars += attachment.dataUrl.length; | ||
| 137 | kept.push(attachment); | ||
| 138 | } | ||
| 139 | return { kept, droppedNames }; | ||
| 140 | } | ||
| 141 | |||
| 142 | /** | ||
| 143 | * Reading the `localStorage` property itself can throw `SecurityError` when | ||
| 144 | * storage is blocked by policy or the page is a sandboxed iframe — so the | ||
| 145 | * access has to be guarded, not just the get/set calls on it. Otherwise | ||
| 146 | * importing this module would crash the app at load. | ||
| 147 | * | ||
| 148 | * `durable` is false for the in-memory fallback: writes there "succeed" but | ||
| 149 | * vanish on reload, and callers clear the composer on the strength of a | ||
| 150 | * successful stash, so they must be told the difference. | ||
| 151 | */ | ||
| 152 | function resolveBaseStorage(): { storage: StateStorage; durable: boolean } { | ||
| 153 | try { | ||
| 154 | if (typeof localStorage !== "undefined") { | ||
| 155 | return { storage: localStorage, durable: true }; | ||
| 156 | } | ||
| 157 | } catch { | ||
| 158 | // Fall through to the in-memory store. | ||
| 159 | } | ||
| 160 | return { storage: createMemoryStorage(), durable: false }; | ||
| 161 | } | ||
| 162 | |||
| 163 | const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStorage(); | ||
| 164 | |||
| 165 | /** | ||
| 166 | * Persists the queues, immediately rather than debounced. Stashing is a | ||
| 167 | * deliberate, infrequent keystroke — not a per-character autosave — so there | ||
| 168 | * 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. | ||
| 170 | * | ||
| 171 | * Returns whether the write will survive a reload: false on a quota rejection | ||
| 172 | * or when only the in-memory fallback is available. | ||
| 173 | */ | ||
| 174 | function persistQueues(queues: Record<string, ReadonlyArray<PromptStashEntry>>): { | ||
| 175 | /** The write succeeded (possibly only into the in-memory fallback). */ | ||
| 176 | written: boolean; | ||
| 177 | /** The write will survive a reload. */ | ||
| 178 | durable: boolean; | ||
| 179 | } { | ||
| 180 | try { | ||
| 181 | baseStashStorage.setItem( | ||
| 182 | PROMPT_STASH_STORAGE_KEY, | ||
| 183 | JSON.stringify({ | ||
| 184 | version: PROMPT_STASH_STORAGE_VERSION, | ||
| 185 | state: { queuesByScopeKey: queues }, | ||
| 186 | }), | ||
| 187 | ); | ||
| 188 | return { written: true, durable: storageIsDurable }; | ||
| 189 | } catch (error) { | ||
| 190 | console.error("[PROMPT-STASH] Could not persist stash (storage quota?).", error); | ||
| 191 | return { written: false, durable: false }; | ||
| 192 | } | ||
| 193 | } | ||
| 194 | |||
| 195 | /** Reads the persisted queues, settling stale pending counts. */ | ||
| 196 | function readPersistedQueues(): Record<string, ReadonlyArray<PromptStashEntry>> | null { | ||
| 197 | try { | ||
| 198 | const raw = baseStashStorage.getItem(PROMPT_STASH_STORAGE_KEY); | ||
| 199 | if (typeof raw !== "string" || raw.length === 0) return null; | ||
| 200 | const parsed: unknown = JSON.parse(raw); | ||
| 201 | const state = (parsed as { state?: unknown } | null)?.state; | ||
| 202 | if (!state) return null; | ||
| 203 | return clearOrphanedPendingImages(decodePersistedPromptStashState(state).queuesByScopeKey); | ||
| 204 | } catch { | ||
| 205 | return null; | ||
| 206 | } | ||
| 207 | } | ||
| 208 | |||
| 209 | interface PromptStashStoreState { | ||
| 210 | queuesByScopeKey: Record<string, ReadonlyArray<PromptStashEntry>>; | ||
| 211 | /** | ||
| 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. | ||
| 214 | */ | ||
| 215 | stashEntry: (entry: PromptStashEntry) => { | ||
| 216 | evicted: PromptStashEntry | null; | ||
| 217 | /** False when the write did not reach durable storage; nothing was kept. */ | ||
| 218 | durable: boolean; | ||
| 219 | }; | ||
| 220 | /** | ||
| 221 | * Removes and returns an entry from a scope's queue (restore + delete). | ||
| 222 | * `durable` is false when the removal could not be persisted, meaning a | ||
| 223 | * reload would resurrect the entry. | ||
| 224 | */ | ||
| 225 | takeEntry: ( | ||
| 226 | scopeKey: string, | ||
| 227 | entryId: string, | ||
| 228 | ) => { entry: PromptStashEntry | null; durable: boolean }; | ||
| 229 | /** | ||
| 230 | * Attaches the encoded images to an entry written earlier by `stashEntry`, | ||
| 231 | * clearing its pending count. Returns attached=false when the entry is gone | ||
| 232 | * (restored or deleted while encoding was still running) so the caller can | ||
| 233 | * tell the user their images did not make it. | ||
| 234 | */ | ||
| 235 | finalizeEntryImages: ( | ||
| 236 | scopeKey: string, | ||
| 237 | entryId: string, | ||
| 238 | images: { | ||
| 239 | attachments: ReadonlyArray<PersistedComposerImageAttachment>; | ||
| 240 | droppedImageNames: ReadonlyArray<string>; | ||
| 241 | unreadableImageNames: ReadonlyArray<string>; | ||
| 242 | }, | ||
| 243 | ) => { attached: boolean; durable: boolean }; | ||
| 244 | } | ||
| 245 | |||
| 246 | export const usePromptStashStore = create<PromptStashStoreState>()((set, get) => ({ | ||
| 247 | queuesByScopeKey: {}, | ||
| 248 | 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); | ||
| 256 | // A rejected write must not leave the entry visible either: the caller | ||
| 257 | // keeps the composer intact on failure, so a stashed copy would | ||
| 258 | // duplicate the prompt. Eviction likewise only sticks on success. | ||
| 259 | if (!written) { | ||
| 260 | return { evicted: null, durable: false }; | ||
| 261 | } | ||
| 262 | set(() => ({ queuesByScopeKey: next })); | ||
| 263 | return { evicted, durable }; | ||
| 264 | }, | ||
| 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; | ||
| 269 | 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 })); | ||
| 279 | return { entry, durable }; | ||
| 280 | }, | ||
| 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]; | ||
| 286 | // Restored or deleted mid-encode: nothing to attach to. | ||
| 287 | if (!existing) return { attached: false, durable: true }; | ||
| 288 | const nextQueue = [...queue]; | ||
| 289 | nextQueue[index] = { | ||
| 290 | ...existing, | ||
| 291 | attachments: images.attachments, | ||
| 292 | droppedImageNames: images.droppedImageNames, | ||
| 293 | unreadableImageNames: images.unreadableImageNames, | ||
| 294 | pendingImageCount: 0, | ||
| 295 | }; | ||
| 296 | const next = { ...queues, [scopeKey]: nextQueue }; | ||
| 297 | const { durable } = persistQueues(next); | ||
| 298 | set(() => ({ queuesByScopeKey: next })); | ||
| 299 | return { attached: true, durable }; | ||
| 300 | }, | ||
| 301 | })); | ||
| 302 | |||
| 303 | // 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. | ||
| 305 | { | ||
| 306 | const persisted = readPersistedQueues(); | ||
| 307 | if (persisted) { | ||
| 308 | usePromptStashStore.setState({ queuesByScopeKey: persisted }); | ||
| 309 | } | ||
| 310 | } | ||
| 311 | |||
| 312 | export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray<PromptStashEntry> = []; | ||
| 313 | |||
| 314 | /** | ||
| 315 | * Test seam: seeds the persisted payload through the same storage the store | ||
| 316 | * reads and rehydrates, without needing a real `localStorage` global. | ||
| 317 | * Pass an empty string to clear. | ||
| 318 | */ | ||
| 319 | export function writePromptStashStorageForTest(raw: string): void { | ||
| 320 | baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw); | ||
| 321 | usePromptStashStore.setState({ queuesByScopeKey: readPersistedQueues() ?? {} }); | ||
| 322 | } | ||
packages/contracts/src/keybindings.ts+1| ... | @@ -63,6 +63,7 @@ const STATIC_KEYBINDING_COMMANDS = [ | ... | @@ -63,6 +63,7 @@ const STATIC_KEYBINDING_COMMANDS = [ |
| 63 | "preview.zoomOut", | 63 | "preview.zoomOut", |
| 64 | "preview.resetZoom", | 64 | "preview.resetZoom", |
| 65 | "commandPalette.toggle", | 65 | "commandPalette.toggle", |
| 66 | "composer.stash", | ||
| 66 | "chat.new", | 67 | "chat.new", |
| 67 | "chat.newLocal", | 68 | "chat.newLocal", |
| 68 | "editor.openFavorite", | 69 | "editor.openFavorite", |
packages/shared/src/keybindings.ts+1| ... | @@ -35,6 +35,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ | ... | @@ -35,6 +35,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 35 | { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, | 35 | { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, |
| 36 | { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, | 36 | { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, |
| 37 | { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, | 37 | { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, |
| 38 | { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, | ||
| 38 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, | 39 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, |
| 39 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, | 40 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, |
| 40 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, | 41 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, |