authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-27 06:34:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-27 06:34:13-07:00
log200fa826b02cf0503c6f6c2bd7250a58747bff2d
tree95594158761f3bceebc46a2248a36868ea5067f4
parentf4c394323d458224b25898ef784ca0a0cc2969af
signature Signed by PGP key B5690EEEBB952194

feat(web): prompt stash — cmd+S saves the composer to a per-provider queue (#4453)

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 {
5151 type ComposerImageAttachment,
5252 type DraftId,
5353 type PersistedComposerImageAttachment,
54 hydrateImagesFromPersisted,
5455 useComposerDraftStore,
5556 useComposerThreadDraft,
5657 useEffectiveComposerModelState,
5758} from "../../composerDraftStore";
59import {
60 EMPTY_PROMPT_STASH_QUEUE,
61 MAX_STASH_ENTRIES_PER_QUEUE,
62 partitionStashAttachments,
63 promptStashScopeKey,
64 usePromptStashStore,
65 type PromptStashEntry,
66} from "../../promptStashStore";
67import { ComposerStashBadge } from "./ComposerStashBadge";
68import { ComposerStashMenu } from "./ComposerStashMenu";
69import { compressImageForStash } from "../../lib/stashImageCompression";
70import { isCommandPaletteOpen } from "../../commandPaletteBus";
71import { getTerminalFocusOwner } from "../../lib/terminalFocus";
72import { resolveShortcutCommand } from "../../keybindings";
5873import {
5974 type TerminalContextDraft,
6075 type TerminalContextSelection,
76 INLINE_TERMINAL_CONTEXT_PLACEHOLDER,
6177 insertInlineTerminalContextPlaceholder,
6278 removeInlineTerminalContextPlaceholder,
6379} from "../../lib/terminalContext";
......@@ -723,6 +739,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
723739 const clearComposerDraftPersistedAttachments = useComposerDraftStore(
724740 (store) => store.clearPersistedAttachments,
725741 );
742 const clearComposerDraftPromptAndImages = useComposerDraftStore(
743 (store) => store.clearComposerPromptAndImages,
744 );
745 const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection);
726746 const syncComposerDraftPersistedAttachments = useComposerDraftStore(
727747 (store) => store.syncPersistedAttachments,
728748 );
......@@ -961,6 +981,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
961981 const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
962982 const [isComposerFocused, setIsComposerFocused] = useState(false);
963983 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 });
964989 const isMobileViewport = useMediaQuery("max-sm");
965990 const isComposerCollapsedMobile =
966991 isMobileViewport && !forceExpandedOnMobile && !isComposerFocused;
......@@ -980,6 +1005,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
9801005 const mobileComposerExpandReleaseFrameRef = useRef<number | null>(null);
9811006 const mobileComposerExpandInFlightRef = useRef(false);
9821007 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());
9831016
9841017 // ------------------------------------------------------------------
9851018 // Derived: composer send state
......@@ -1861,6 +1894,400 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18611894 return false;
18621895 };
18631896
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
18642291 // ------------------------------------------------------------------
18652292 // Callbacks: images
18662293 // ------------------------------------------------------------------
......@@ -2402,6 +2829,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
24022829 isComposerCollapsedMobile && "hidden",
24032830 )}
24042831 >
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
24052853 {composerMenuOpen && !isComposerApprovalState && (
24062854 <ComposerCommandMenuLayer anchor={composerMenuAnchor}>
24072855 <ComposerCommandMenu
apps/web/src/components/chat/ComposerStashBadge.tsx created+58
......@@ -0,0 +1,58 @@
1import { BookmarkIcon } from "lucide-react";
2import { memo } from "react";
3
4import { 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 */
15export 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 @@
1import { BookmarkIcon, XIcon } from "lucide-react";
2import { memo, useEffect, useState } from "react";
3
4import { formatRelativeTimeLabel } from "../../timestampFormat";
5import { cn } from "~/lib/utils";
6import { type PromptStashEntry } from "../../promptStashStore";
7import { Command, CommandGroup, CommandGroupLabel, CommandItem, CommandList } from "../ui/command";
8import { Button } from "../ui/button";
9
10const SNIPPET_MAX_CHARS = 90;
11
12/** Images that did not make it into the entry, whatever the reason. */
13function missingImageCount(entry: PromptStashEntry): number {
14 return entry.droppedImageNames.length + (entry.unreadableImageNames?.length ?? 0);
15}
16
17function 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 */
33export 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 {
495495 attachments: PersistedComposerImageAttachment[],
496496 ) => void;
497497 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;
498505}
499506
500507export interface EffectiveComposerModelState {
......@@ -2091,7 +2098,7 @@ function hydratePersistedComposerImageAttachment(
20912098 }
20922099}
20932100
2094function hydrateImagesFromPersisted(
2101export function hydrateImagesFromPersisted(
20952102 attachments: ReadonlyArray<PersistedComposerImageAttachment>,
20962103): ComposerImageAttachment[] {
20972104 return attachments.flatMap((attachment) => {
......@@ -3347,6 +3354,35 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
33473354 return { draftsByThreadKey: nextDraftsByThreadKey };
33483355 });
33493356 },
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 },
33503386 };
33513387 },
33523388 {
apps/web/src/index.css+24
......@@ -1432,6 +1432,30 @@ label:has(> select#reasoning-effort) select {
14321432 margin-top: 0.125rem;
14331433}
14341434
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
14351459@keyframes provider-update-pill-countdown {
14361460 from {
14371461 transform: scaleX(1);
apps/web/src/lib/stashImageCompression.test.ts created+198
......@@ -0,0 +1,198 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { 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
13const originalCreateImageBitmap = globalThis.createImageBitmap;
14const originalOffscreenCanvas = globalThis.OffscreenCanvas;
15
16function 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 */
26function 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
60afterEach(() => {
61 vi.unstubAllGlobals();
62 globalThis.createImageBitmap = originalCreateImageBitmap;
63 globalThis.OffscreenCanvas = originalOffscreenCanvas;
64});
65
66describe("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 */
17const MAX_DIMENSION = 2048;
18/** Base64 budget for a single stashed image (~975KB of binary). */
19export 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 */
25const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const;
26/** Extra downscale passes applied when even the lowest quality overflows. */
27const FALLBACK_SCALE_STEPS = [0.75, 0.55] as const;
28
29export 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 */
41export type StashImageFailureReason = "too-large" | "unreadable";
42
43export 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`. */
48const BASE64_CHUNK_SIZE = 0x8000;
49
50function 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 */
62async 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. */
69function 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
76function canRecompress(): boolean {
77 return (
78 typeof createImageBitmap === "function" &&
79 (typeof OffscreenCanvas === "function" || typeof document !== "undefined")
80 );
81}
82
83interface Canvas2D {
84 canvas: OffscreenCanvas | HTMLCanvasElement;
85 context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
86}
87
88function 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 */
110async 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 */
132async 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 */
176export 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 @@
1import { ProviderInstanceId } from "@t3tools/contracts";
2import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
3
4import { removeLocalStorageItem } from "./hooks/useLocalStorage";
5
6import {
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
18const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent");
19const CODEX_INSTANCE = ProviderInstanceId.make("codex");
20
21function 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
50function resetPromptStashStore() {
51 usePromptStashStore.setState({ queuesByScopeKey: {} });
52 writePromptStashStorageForTest("");
53 removeLocalStorageItem(PROMPT_STASH_STORAGE_KEY);
54}
55
56describe("promptStashScopeKey", () => {
57 it("maps a provider instance to its own bucket and null to the unscoped bucket", () => {
58 expect(promptStashScopeKey(CLAUDE_AGENT_INSTANCE)).toBe("provider:claudeAgent");
59 expect(promptStashScopeKey(null)).toBe(PROMPT_STASH_UNSCOPED_KEY);
60 expect(promptStashScopeKey(undefined)).toBe(PROMPT_STASH_UNSCOPED_KEY);
61 });
62
63 // Provider slugs must match /^[a-zA-Z][a-zA-Z0-9_-]*$/, so no real instance
64 // id can equal the unscoped sentinel. The namespace prefix makes that
65 // structural rather than incidental.
66 it("namespaces provider keys so they can never equal the unscoped sentinel", () => {
67 expect(promptStashScopeKey(CLAUDE_AGENT_INSTANCE)).not.toBe(PROMPT_STASH_UNSCOPED_KEY);
68 expect(promptStashScopeKey(CODEX_INSTANCE).startsWith("provider:")).toBe(true);
69 });
70});
71
72describe("partitionStashAttachments", () => {
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
114describe("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 @@
1import { ModelSelection, ProviderInstanceId } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3import { create } from "zustand";
4
5import { PersistedComposerImageAttachment } from "./composerDraftStore";
6import { createMemoryStorage, type StateStorage } from "./lib/storage";
7
8export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1";
9const 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 */
17export const PROMPT_STASH_UNSCOPED_KEY = "__none__";
18/** Namespace applied to provider-derived keys to keep them collision-proof. */
19const PROVIDER_SCOPE_PREFIX = "provider:";
20
21export 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 */
31export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000;
32
33const 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});
57export type PromptStashEntry = typeof StashEntrySchema.Type;
58
59const PersistedPromptStashState = Schema.Struct({
60 queuesByScopeKey: Schema.Record(Schema.String, Schema.Array(StashEntrySchema)),
61});
62type PersistedPromptStashState = typeof PersistedPromptStashState.Type;
63
64const 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 */
76function 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. */
101export function promptStashScopeKey(instanceId: ProviderInstanceId | null | undefined): string {
102 return instanceId ? `${PROVIDER_SCOPE_PREFIX}${instanceId}` : PROMPT_STASH_UNSCOPED_KEY;
103}
104
105/**
106 * Reads a queue without inheriting from `Object.prototype`. Scope keys derive
107 * from user-authored provider slugs, so a key like `__proto__` must not
108 * resolve to the prototype chain.
109 */
110function readQueue(
111 queues: Record<string, ReadonlyArray<PromptStashEntry>>,
112 scopeKey: string,
113): ReadonlyArray<PromptStashEntry> {
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 */
122export 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 */
152function 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
163const { 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 */
174function 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. */
196function 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
209interface 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
246export 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
312export 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 */
319export 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 = [
6363 "preview.zoomOut",
6464 "preview.resetZoom",
6565 "commandPalette.toggle",
66 "composer.stash",
6667 "chat.new",
6768 "chat.newLocal",
6869 "editor.openFavorite",
packages/shared/src/keybindings.ts+1
......@@ -35,6 +35,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
3535 { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" },
3636 { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" },
3737 { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
38 { key: "mod+s", command: "composer.stash", when: "!terminalFocus" },
3839 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
3940 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
4041 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },