From 9e19eeadf79ac9d4dc7eaa3f21a495e153218a12 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 06:02:23 -0700 Subject: [PATCH 1/8] =?UTF-8?q?feat(web):=20prompt=20stash=20=E2=80=94=20c?= =?UTF-8?q?md+S=20saves=20the=20composer=20to=20a=20per-provider=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing mod+S snapshots the composer (prompt + image attachments + model selection) into a localStorage-backed stash queue scoped to the active provider instance, then clears the composer with an Undo toast. A bookmark badge on the composer shoulder shows the queue count with a one-shot ghost-fly animation on save; mod+S on an empty composer (or clicking the badge) opens a picker to restore or delete entries. - composer.stash keybinding command, default mod+s (!terminalFocus) - promptStashStore: zustand persist under t3code:prompt-stash:v1, schema-validated, 20 entries/queue, per-entry attachment budget with dropped-image tracking, quota-safe writes - restore rehydrates images to live Files via the existing persisted attachment codec and reapplies the saved model selection when the provider is still available Client-only; terminal/element contexts are not stashed since they reference live sessions. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/chat/ChatComposer.tsx | 303 ++++++++++++++++++ .../components/chat/ComposerStashBadge.tsx | 68 ++++ .../src/components/chat/ComposerStashMenu.tsx | 171 ++++++++++ apps/web/src/composerDraftStore.ts | 2 +- apps/web/src/index.css | 46 +++ apps/web/src/promptStashStore.test.ts | 152 +++++++++ apps/web/src/promptStashStore.ts | 181 +++++++++++ packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 9 files changed, 924 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/chat/ComposerStashBadge.tsx create mode 100644 apps/web/src/components/chat/ComposerStashMenu.tsx create mode 100644 apps/web/src/promptStashStore.test.ts create mode 100644 apps/web/src/promptStashStore.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b427037bdf7..876efd9540e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -51,13 +51,28 @@ import { type ComposerImageAttachment, type DraftId, type PersistedComposerImageAttachment, + hydrateImagesFromPersisted, useComposerDraftStore, useComposerThreadDraft, useEffectiveComposerModelState, } from "../../composerDraftStore"; +import { + EMPTY_PROMPT_STASH_QUEUE, + flushPromptStashStorage, + partitionStashAttachments, + promptStashScopeKey, + usePromptStashStore, + type PromptStashEntry, +} from "../../promptStashStore"; +import { ComposerStashBadge, type StashFlyGhost } from "./ComposerStashBadge"; +import { ComposerStashMenu } from "./ComposerStashMenu"; +import { isCommandPaletteOpen } from "../../commandPaletteBus"; +import { getTerminalFocusOwner } from "../../lib/terminalFocus"; +import { resolveShortcutCommand } from "../../keybindings"; import { type TerminalContextDraft, type TerminalContextSelection, + INLINE_TERMINAL_CONTEXT_PLACEHOLDER, insertInlineTerminalContextPlaceholder, removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; @@ -723,6 +738,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); + const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); + const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); @@ -961,6 +978,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const [isComposerFocused, setIsComposerFocused] = useState(false); const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); + const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); + const [stashGhost, setStashGhost] = useState(null); const isMobileViewport = useMediaQuery("max-sm"); const isComposerCollapsedMobile = isMobileViewport && !forceExpandedOnMobile && !isComposerFocused; @@ -980,6 +999,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); const dragDepthRef = useRef(0); + const stashGhostKeyRef = useRef(0); + const stashGhostTimeoutRef = useRef(null); // ------------------------------------------------------------------ // Derived: composer send state @@ -1861,6 +1882,268 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return false; }; + // ------------------------------------------------------------------ + // Prompt stash (⌘S) + // ------------------------------------------------------------------ + const stashScopeInstanceId = noProviderAvailable ? null : selectedInstanceId; + const stashScope = promptStashScopeKey(stashScopeInstanceId); + const stashQueue = usePromptStashStore( + (state) => state.queuesByScopeKey[stashScope] ?? EMPTY_PROMPT_STASH_QUEUE, + ); + const stashOtherScopesCount = usePromptStashStore((state) => + Object.entries(state.queuesByScopeKey).reduce( + (total, [key, queue]) => (key === stashScope ? total : total + queue.length), + 0, + ), + ); + const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry); + const takeStashEntry = usePromptStashStore((state) => state.takeEntry); + const stashProviderLabel = noProviderAvailable + ? "No provider" + : getProviderDisplayName(providerStatuses, selectedProvider); + + useEffect(() => { + return () => { + if (stashGhostTimeoutRef.current !== null) { + window.clearTimeout(stashGhostTimeoutRef.current); + } + }; + }, []); + + const playStashGhost = useCallback((snippet: string) => { + stashGhostKeyRef.current += 1; + setStashGhost({ key: stashGhostKeyRef.current, snippet }); + if (stashGhostTimeoutRef.current !== null) { + window.clearTimeout(stashGhostTimeoutRef.current); + } + stashGhostTimeoutRef.current = window.setTimeout(() => { + stashGhostTimeoutRef.current = null; + setStashGhost(null); + }, 600); + }, []); + + const restoreStashEntry = useCallback( + (entry: PromptStashEntry) => { + // Remove first so a double activation (click + Enter) can't restore twice. + const taken = takeStashEntry(promptStashScopeKey(entry.providerInstanceId), entry.id); + if (!taken) return; + flushPromptStashStorage(); + setIsStashMenuOpen(false); + + const currentPrompt = promptRef.current; + const nextPrompt = currentPrompt.trim().length + ? `${currentPrompt.replace(/\s+$/, "")}\n\n${entry.prompt}` + : entry.prompt; + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); + setComposerCursor(nextCursor); + setComposerTrigger(null); + + if (entry.attachments.length > 0) { + const existingIds = new Set(composerImagesRef.current.map((image) => image.id)); + const capacity = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - composerImagesRef.current.length; + const restoredImages = hydrateImagesFromPersisted( + entry.attachments.filter((attachment) => !existingIds.has(attachment.id)), + ).slice(0, Math.max(0, capacity)); + if (restoredImages.length > 0) { + addComposerDraftImages(composerDraftTarget, restoredImages); + } + } + + const restorableSelection = + entry.modelSelection && + providerInstanceEntries.some( + (candidate) => + candidate.instanceId === entry.modelSelection?.instanceId && + candidate.enabled && + candidate.isAvailable, + ) + ? entry.modelSelection + : null; + if (restorableSelection) { + setComposerDraftModelSelection(composerDraftTarget, restorableSelection, { + replaceOptions: true, + }); + } + + if (entry.droppedImageNames.length > 0) { + toastManager.add({ + type: "warning", + title: "Some images were not stashed", + description: `${entry.droppedImageNames.join(", ")} exceeded the stash size limit when this prompt was saved.`, + }); + } + + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAtEnd(); + }); + }, + [ + addComposerDraftImages, + composerDraftTarget, + composerImagesRef, + promptRef, + providerInstanceEntries, + setComposerDraftModelSelection, + setComposerDraftPrompt, + takeStashEntry, + ], + ); + + const deleteStashEntry = useCallback( + (entry: PromptStashEntry) => { + takeStashEntry(promptStashScopeKey(entry.providerInstanceId), entry.id); + flushPromptStashStorage(); + }, + [takeStashEntry], + ); + + const stashCurrentPrompt = useCallback(async () => { + // Terminal-context placeholders reference live sessions the stash can't + // round-trip, so they are stripped from the stashed prompt. + const prompt = promptRef.current.split(INLINE_TERMINAL_CONTEXT_PLACEHOLDER).join("").trim(); + const images = composerImagesRef.current; + if (prompt.length === 0 && images.length === 0) { + setIsStashMenuOpen((open) => !open); + return; + } + + const persistedById = new Map( + (getComposerDraft(composerDraftTarget)?.persistedAttachments ?? []).map( + (attachment) => [attachment.id, attachment] as const, + ), + ); + const candidateAttachments: PersistedComposerImageAttachment[] = []; + const unreadableImageNames: string[] = []; + for (const image of images) { + const persisted = persistedById.get(image.id); + if (persisted) { + candidateAttachments.push(persisted); + continue; + } + try { + candidateAttachments.push({ + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: await readFileAsDataUrl(image.file), + }); + } catch { + unreadableImageNames.push(image.name); + } + } + const { kept, droppedNames } = partitionStashAttachments(candidateAttachments); + const allDroppedNames = [...droppedNames, ...unreadableImageNames]; + + const entry: PromptStashEntry = { + id: randomUUID(), + createdAt: new Date().toISOString(), + prompt, + attachments: kept, + providerInstanceId: stashScopeInstanceId, + modelSelection: noProviderAvailable ? null : selectedModelSelection, + droppedImageNames: allDroppedNames, + }; + const evicted = stashEntryToQueue(entry); + flushPromptStashStorage(); + + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + setComposerCursor(0); + setComposerTrigger(null); + + playStashGhost( + prompt.length > 0 ? prompt : `${images.length} image${images.length === 1 ? "" : "s"}`, + ); + + toastManager.add({ + type: "success", + title: `Stashed to ${stashProviderLabel}`, + description: + allDroppedNames.length > 0 + ? `Saved without ${allDroppedNames.join(", ")} (too large to stash).` + : evicted + ? "Oldest stashed prompt was discarded to make room." + : "Press ⌘S in an empty composer to bring it back.", + actionProps: { + children: "Undo", + onClick: () => { + restoreStashEntry(entry); + }, + }, + data: { hideCopyButton: true }, + }); + }, [ + clearComposerDraftContent, + composerDraftTarget, + composerImagesRef, + getComposerDraft, + noProviderAvailable, + playStashGhost, + promptRef, + restoreStashEntry, + selectedModelSelection, + stashEntryToQueue, + stashProviderLabel, + stashScopeInstanceId, + ]); + + const toggleStashMenu = useCallback(() => { + setIsStashMenuOpen((open) => !open); + }, []); + + // Close the stash menu whenever the trigger-driven command menu opens so + // the two popovers never stack in the same layer, and when the user + // resumes typing (the menu is a transient picker, not a panel). + useEffect(() => { + if (composerMenuOpen) { + setIsStashMenuOpen(false); + } + }, [composerMenuOpen]); + useEffect(() => { + setIsStashMenuOpen(false); + }, [prompt]); + + useEffect(() => { + const handler = (event: globalThis.KeyboardEvent) => { + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: getTerminalFocusOwner() !== null, + terminalOpen, + modelPickerOpen: isComposerModelPickerOpen, + }, + }); + if (command !== "composer.stash") return; + // Always claim the shortcut so the browser save dialog never opens, + // even when the composer is in a state that can't stash. + event.preventDefault(); + event.stopPropagation(); + if ( + isCommandPaletteOpen() || + isComposerApprovalState || + pendingUserInputs.length > 0 || + projectSelectionRequired || + activePendingProgress !== null + ) { + return; + } + void stashCurrentPrompt(); + }; + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, [ + activePendingProgress, + isComposerApprovalState, + isComposerModelPickerOpen, + keybindings, + pendingUserInputs.length, + projectSelectionRequired, + stashCurrentPrompt, + terminalOpen, + ]); + // ------------------------------------------------------------------ // Callbacks: images // ------------------------------------------------------------------ @@ -2402,6 +2685,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerCollapsedMobile && "hidden", )} > + + + {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && ( + + setIsStashMenuOpen(false)} + /> + + )} + {composerMenuOpen && !isComposerApprovalState && ( void; +}) { + if (props.count === 0 && !props.ghost) return null; + + return ( + <> + {props.ghost ? ( + + ) : null} + {props.count > 0 ? ( + + ) : null} + + ); +}); diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx new file mode 100644 index 00000000000..75aa4a6ec48 --- /dev/null +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -0,0 +1,171 @@ +import { BookmarkIcon, XIcon } from "lucide-react"; +import { memo, useEffect, useState } from "react"; + +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { cn } from "~/lib/utils"; +import { type PromptStashEntry } from "../../promptStashStore"; +import { Command, CommandGroup, CommandGroupLabel, CommandItem, CommandList } from "../ui/command"; +import { Button } from "../ui/button"; + +const SNIPPET_MAX_CHARS = 90; + +function stashEntrySnippet(entry: PromptStashEntry): string { + const trimmed = entry.prompt.trim().replace(/\s+/g, " "); + if (trimmed.length > 0) { + return trimmed.length > SNIPPET_MAX_CHARS ? `${trimmed.slice(0, SNIPPET_MAX_CHARS)}…` : trimmed; + } + const imageCount = entry.attachments.length + entry.droppedImageNames.length; + return imageCount > 0 ? `(${imageCount} image${imageCount === 1 ? "" : "s"})` : "(empty)"; +} + +/** + * Popover listing the current connection method's stashed prompts. + * Keyboard-first: opened by ⌘S on an empty composer, navigated with + * arrows, restored with Enter, dismissed with Escape. The listener runs + * capture-phase on window so it wins over the Lexical editor's handlers + * while the menu is open. + */ +export const ComposerStashMenu = memo(function ComposerStashMenu(props: { + entries: ReadonlyArray; + providerLabel: string; + otherScopesCount: number; + onRestore: (entry: PromptStashEntry) => void; + onDelete: (entry: PromptStashEntry) => void; + onClose: () => void; +}) { + const { entries, onRestore, onDelete, onClose } = props; + const [highlightedId, setHighlightedId] = useState(entries[0]?.id ?? null); + + const highlightedEntry = entries.find((entry) => entry.id === highlightedId) ?? entries[0]; + + useEffect(() => { + if (entries.length === 0) return; + if (!entries.some((entry) => entry.id === highlightedId)) { + setHighlightedId(entries[0]?.id ?? null); + } + }, [entries, highlightedId]); + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + return; + } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + if (entries.length === 0) return; + event.preventDefault(); + event.stopPropagation(); + const currentIndex = entries.findIndex((entry) => entry.id === highlightedId); + const offset = event.key === "ArrowDown" ? 1 : -1; + const normalizedIndex = currentIndex >= 0 ? currentIndex : offset === 1 ? -1 : 0; + const nextIndex = (normalizedIndex + offset + entries.length) % entries.length; + setHighlightedId(entries[nextIndex]?.id ?? null); + return; + } + if (event.key === "Enter") { + if (!highlightedEntry) return; + event.preventDefault(); + event.stopPropagation(); + onRestore(highlightedEntry); + return; + } + if (event.key === "Backspace" && (event.metaKey || event.ctrlKey)) { + if (!highlightedEntry) return; + event.preventDefault(); + event.stopPropagation(); + onDelete(highlightedEntry); + } + }; + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, [entries, highlightedEntry, highlightedId, onClose, onDelete, onRestore]); + + return ( + +
+ + + + + {entries.length === 0 ? ( +

+ Nothing stashed for this method yet. Press ⌘S with a prompt in the composer to stash + it. +

+ ) : ( + entries.map((entry) => ( + { + if (highlightedId !== entry.id) setHighlightedId(entry.id); + }} + onMouseDown={(event) => { + event.preventDefault(); + }} + onClick={() => { + onRestore(entry); + }} + > + {entry.attachments.length > 0 ? ( + + {entry.attachments.slice(0, 3).map((attachment) => ( + + ))} + + ) : ( + + )} + + {stashEntrySnippet(entry)} + + {entry.droppedImageNames.length > 0 ? ( + + {entry.droppedImageNames.length} image + {entry.droppedImageNames.length === 1 ? "" : "s"} dropped + + ) : null} + + {formatRelativeTimeLabel(entry.createdAt)} + + + + )) + )} +
+ {props.otherScopesCount > 0 ? ( +

+ {props.otherScopesCount} more stashed under other connection methods — switch provider + to see them. +

+ ) : null} +
+
+
+ ); +}); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index d4f38adcacd..6e7138b6c35 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2091,7 +2091,7 @@ function hydratePersistedComposerImageAttachment( } } -function hydrateImagesFromPersisted( +export function hydrateImagesFromPersisted( attachments: ReadonlyArray, ): ComposerImageAttachment[] { return attachments.flatMap((attachment) => { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 29dd99b8e6d..14bc71525c9 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1432,6 +1432,52 @@ label:has(> select#reasoning-effort) select { margin-top: 0.125rem; } +/* Prompt-stash save moment: one-shot, event-driven (retriggered by React + key remount on each stash) — not a continuous animation. A ghost of the + prompt flies from the composer into the stash badge while the badge count + pops. Both settle to their final state and stop. */ +@keyframes prompt-stash-ghost-fly { + 0% { + opacity: 0; + transform: translate(0, 0) scale(1); + } + 15% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translate(var(--stash-fly-x, 120px), var(--stash-fly-y, -40px)) scale(0.1); + } +} + +@keyframes prompt-stash-badge-pop { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.3); + } +} + +.prompt-stash-ghost { + animation: prompt-stash-ghost-fly 550ms cubic-bezier(0.4, 0, 0.7, 0) forwards; +} + +.prompt-stash-badge-pop { + animation: prompt-stash-badge-pop 300ms ease 250ms both; +} + +@media (prefers-reduced-motion: reduce) { + .prompt-stash-ghost { + animation: none; + opacity: 0; + } + .prompt-stash-badge-pop { + animation: none; + } +} + @keyframes provider-update-pill-countdown { from { transform: scaleX(1); diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts new file mode 100644 index 00000000000..347541f4ef1 --- /dev/null +++ b/apps/web/src/promptStashStore.test.ts @@ -0,0 +1,152 @@ +import { ProviderInstanceId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { + MAX_STASH_ENTRIES_PER_QUEUE, + MAX_STASH_ENTRY_ATTACHMENT_CHARS, + PROMPT_STASH_UNSCOPED_KEY, + partitionStashAttachments, + promptStashScopeKey, + usePromptStashStore, + type PromptStashEntry, +} from "./promptStashStore"; + +const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent"); +const CODEX_INSTANCE = ProviderInstanceId.make("codex"); + +function makeEntry(input: { + id: string; + providerInstanceId?: ProviderInstanceId | null; + prompt?: string; + attachmentChars?: number; +}): PromptStashEntry { + return { + id: input.id, + createdAt: "2026-07-24T12:00:00.000Z", + prompt: input.prompt ?? `prompt ${input.id}`, + attachments: + input.attachmentChars !== undefined + ? [ + { + id: `${input.id}-img`, + name: "shot.png", + mimeType: "image/png", + sizeBytes: input.attachmentChars, + dataUrl: "x".repeat(input.attachmentChars), + }, + ] + : [], + providerInstanceId: + input.providerInstanceId === undefined ? CLAUDE_AGENT_INSTANCE : input.providerInstanceId, + modelSelection: null, + droppedImageNames: [], + }; +} + +function resetPromptStashStore() { + usePromptStashStore.setState({ queuesByScopeKey: {} }); +} + +describe("promptStashScopeKey", () => { + it("maps a provider instance to its own bucket and null to the unscoped bucket", () => { + expect(promptStashScopeKey(CLAUDE_AGENT_INSTANCE)).toBe("claudeAgent"); + expect(promptStashScopeKey(null)).toBe(PROMPT_STASH_UNSCOPED_KEY); + expect(promptStashScopeKey(undefined)).toBe(PROMPT_STASH_UNSCOPED_KEY); + }); +}); + +describe("partitionStashAttachments", () => { + it("keeps attachments within the budget and reports dropped names in order", () => { + const small = { + id: "a", + name: "small.png", + mimeType: "image/png", + sizeBytes: 10, + dataUrl: "x".repeat(10), + }; + const huge = { + id: "b", + name: "huge.png", + mimeType: "image/png", + sizeBytes: MAX_STASH_ENTRY_ATTACHMENT_CHARS, + dataUrl: "x".repeat(MAX_STASH_ENTRY_ATTACHMENT_CHARS), + }; + const alsoSmall = { + id: "c", + name: "also-small.png", + mimeType: "image/png", + sizeBytes: 10, + dataUrl: "x".repeat(10), + }; + const { kept, droppedNames } = partitionStashAttachments([small, huge, alsoSmall]); + expect(kept.map((attachment) => attachment.id)).toEqual(["a", "c"]); + expect(droppedNames).toEqual(["huge.png"]); + }); + + it("admits a single attachment that exactly fits the budget", () => { + const exact = { + id: "a", + name: "exact.png", + mimeType: "image/png", + sizeBytes: MAX_STASH_ENTRY_ATTACHMENT_CHARS, + dataUrl: "x".repeat(MAX_STASH_ENTRY_ATTACHMENT_CHARS), + }; + const { kept, droppedNames } = partitionStashAttachments([exact]); + expect(kept).toHaveLength(1); + expect(droppedNames).toEqual([]); + }); +}); + +describe("promptStashStore", () => { + beforeEach(() => { + resetPromptStashStore(); + }); + + it("prepends entries so the newest stash is first", () => { + const store = usePromptStashStore.getState(); + store.stashEntry(makeEntry({ id: "first" })); + store.stashEntry(makeEntry({ id: "second" })); + const queue = usePromptStashStore.getState().queuesByScopeKey["claudeAgent"] ?? []; + expect(queue.map((entry) => entry.id)).toEqual(["second", "first"]); + }); + + it("scopes queues by provider instance, including the unscoped bucket", () => { + const store = usePromptStashStore.getState(); + store.stashEntry(makeEntry({ id: "claude" })); + store.stashEntry(makeEntry({ id: "codex", providerInstanceId: CODEX_INSTANCE })); + store.stashEntry(makeEntry({ id: "none", providerInstanceId: null })); + const queues = usePromptStashStore.getState().queuesByScopeKey; + expect(queues["claudeAgent"]?.map((entry) => entry.id)).toEqual(["claude"]); + expect(queues["codex"]?.map((entry) => entry.id)).toEqual(["codex"]); + expect(queues[PROMPT_STASH_UNSCOPED_KEY]?.map((entry) => entry.id)).toEqual(["none"]); + }); + + it("evicts the oldest entry past the per-queue cap and returns it", () => { + const store = usePromptStashStore.getState(); + for (let index = 0; index < MAX_STASH_ENTRIES_PER_QUEUE; index += 1) { + expect(store.stashEntry(makeEntry({ id: `entry-${index}` }))).toBeNull(); + } + const evicted = store.stashEntry(makeEntry({ id: "overflow" })); + expect(evicted?.id).toBe("entry-0"); + const queue = usePromptStashStore.getState().queuesByScopeKey["claudeAgent"] ?? []; + expect(queue).toHaveLength(MAX_STASH_ENTRIES_PER_QUEUE); + expect(queue[0]?.id).toBe("overflow"); + }); + + it("takeEntry removes and returns the entry; second take returns null", () => { + const store = usePromptStashStore.getState(); + store.stashEntry(makeEntry({ id: "keep" })); + store.stashEntry(makeEntry({ id: "take" })); + expect(store.takeEntry("claudeAgent", "take")?.id).toBe("take"); + expect(store.takeEntry("claudeAgent", "take")).toBeNull(); + const queue = usePromptStashStore.getState().queuesByScopeKey["claudeAgent"] ?? []; + expect(queue.map((entry) => entry.id)).toEqual(["keep"]); + }); + + it("drops the scope key entirely when its queue empties", () => { + const store = usePromptStashStore.getState(); + store.stashEntry(makeEntry({ id: "only" })); + store.takeEntry("claudeAgent", "only"); + expect(usePromptStashStore.getState().queuesByScopeKey["claudeAgent"]).toBeUndefined(); + }); +}); diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts new file mode 100644 index 00000000000..51ad6bbdab7 --- /dev/null +++ b/apps/web/src/promptStashStore.ts @@ -0,0 +1,181 @@ +import { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { PersistedComposerImageAttachment } from "./composerDraftStore"; +import { createDebouncedStorage, createMemoryStorage, type StateStorage } from "./lib/storage"; + +export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1"; +const PROMPT_STASH_STORAGE_VERSION = 1; +const PROMPT_STASH_PERSIST_DEBOUNCE_MS = 300; + +/** Queue bucket for prompts stashed while no provider instance is selected. */ +export const PROMPT_STASH_UNSCOPED_KEY = "__none__"; + +export const MAX_STASH_ENTRIES_PER_QUEUE = 20; +/** + * Budget for an entry's serialized attachment payload. localStorage is a + * ~5MB origin-wide quota shared with the composer draft store, so oversized + * images are dropped (tracked in `droppedImageNames`) rather than persisted. + */ +export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_000_000; + +const StashEntrySchema = Schema.Struct({ + id: Schema.String, + createdAt: Schema.String, + prompt: Schema.String, + attachments: Schema.Array(PersistedComposerImageAttachment), + providerInstanceId: Schema.NullOr(ProviderInstanceId), + modelSelection: Schema.NullOr(ModelSelection), + /** Names of images that exceeded the attachment budget and were not saved. */ + droppedImageNames: Schema.Array(Schema.String), +}); +export type PromptStashEntry = typeof StashEntrySchema.Type; + +const PersistedPromptStashState = Schema.Struct({ + queuesByScopeKey: Schema.Record(Schema.String, Schema.Array(StashEntrySchema)), +}); +type PersistedPromptStashState = typeof PersistedPromptStashState.Type; + +const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPromptStashState); + +/** Maps the composer's active provider instance to a stash queue bucket. */ +export function promptStashScopeKey(instanceId: ProviderInstanceId | null | undefined): string { + return instanceId ?? PROMPT_STASH_UNSCOPED_KEY; +} + +/** + * Splits candidate attachments into a persistable set within the entry + * budget plus the names of any that had to be dropped. Attachments are + * admitted in order so the earliest-added images win. + */ +export function partitionStashAttachments( + attachments: ReadonlyArray, +): { + kept: PersistedComposerImageAttachment[]; + droppedNames: string[]; +} { + const kept: PersistedComposerImageAttachment[] = []; + const droppedNames: string[] = []; + let usedChars = 0; + for (const attachment of attachments) { + if (usedChars + attachment.dataUrl.length > MAX_STASH_ENTRY_ATTACHMENT_CHARS) { + droppedNames.push(attachment.name); + continue; + } + usedChars += attachment.dataUrl.length; + kept.push(attachment); + } + return { kept, droppedNames }; +} + +/** + * Base64 image payloads can hit the origin's localStorage quota. A quota + * failure must not become an uncaught exception inside the debounce timer: + * the in-memory queue still works for the session, so log and move on. + */ +function createQuotaSafeStorage(base: StateStorage): StateStorage { + return { + getItem: (name) => base.getItem(name), + setItem: (name, value) => { + try { + base.setItem(name, value); + } catch (error) { + console.error("[PROMPT-STASH] Could not persist stash (storage quota?).", error); + } + }, + removeItem: (name) => { + try { + base.removeItem(name); + } catch (error) { + console.error("[PROMPT-STASH] Could not remove stash entry.", error); + } + }, + }; +} + +const promptStashDebouncedStorage = createDebouncedStorage( + createQuotaSafeStorage( + typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(), + ), + PROMPT_STASH_PERSIST_DEBOUNCE_MS, +); + +if (typeof window !== "undefined" && typeof window.addEventListener === "function") { + window.addEventListener("beforeunload", () => { + promptStashDebouncedStorage.flush(); + }); +} + +interface PromptStashStoreState { + queuesByScopeKey: Record>; + /** + * Prepends an entry to its scope's queue, evicting the oldest entry past + * the per-queue cap. Returns the evicted entry (for messaging) if any. + */ + stashEntry: (entry: PromptStashEntry) => PromptStashEntry | null; + /** Removes and returns an entry from a scope's queue (restore + delete). */ + takeEntry: (scopeKey: string, entryId: string) => PromptStashEntry | null; +} + +export const usePromptStashStore = create()( + persist( + (set, get) => ({ + queuesByScopeKey: {}, + stashEntry: (entry) => { + const scopeKey = promptStashScopeKey(entry.providerInstanceId); + const queue = get().queuesByScopeKey[scopeKey] ?? []; + const nextQueue = [entry, ...queue]; + const evicted = + nextQueue.length > MAX_STASH_ENTRIES_PER_QUEUE ? (nextQueue.pop() ?? null) : null; + set((state) => ({ + queuesByScopeKey: { ...state.queuesByScopeKey, [scopeKey]: nextQueue }, + })); + return evicted; + }, + takeEntry: (scopeKey, entryId) => { + const queue = get().queuesByScopeKey[scopeKey] ?? []; + const entry = queue.find((candidate) => candidate.id === entryId) ?? null; + if (!entry) return null; + set((state) => { + const nextQueue = (state.queuesByScopeKey[scopeKey] ?? []).filter( + (candidate) => candidate.id !== entryId, + ); + const nextQueues = { ...state.queuesByScopeKey }; + if (nextQueue.length === 0) { + delete nextQueues[scopeKey]; + } else { + nextQueues[scopeKey] = nextQueue; + } + return { queuesByScopeKey: nextQueues }; + }); + return entry; + }, + }), + { + name: PROMPT_STASH_STORAGE_KEY, + version: PROMPT_STASH_STORAGE_VERSION, + storage: createJSONStorage(() => promptStashDebouncedStorage), + partialize: (state): PersistedPromptStashState => ({ + queuesByScopeKey: state.queuesByScopeKey, + }), + merge: (persistedState, currentState) => { + try { + const decoded = decodePersistedPromptStashState(persistedState); + return { ...currentState, queuesByScopeKey: { ...decoded.queuesByScopeKey } }; + } catch { + // Corrupt or incompatible payload: start empty rather than crash. + return currentState; + } + }, + }, + ), +); + +/** Flushes pending stash writes immediately (e.g. right after a stash). */ +export function flushPromptStashStorage(): void { + promptStashDebouncedStorage.flush(); +} + +export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray = []; diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index c7cff9943cd..d966c1e7e61 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -63,6 +63,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "preview.zoomOut", "preview.resetZoom", "commandPalette.toggle", + "composer.stash", "chat.new", "chat.newLocal", "editor.openFavorite", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index b6bdd7b4783..0688cf07254 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -35,6 +35,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, + { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, From caa0491f5dcfb8746a724097f4e6ddfd6447ecba Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 27 Jul 2026 01:12:20 -0700 Subject: [PATCH 2/8] fix(web): address prompt stash review feedback Image handling: - compress stashed images (WebP, JPEG fallback) instead of dropping any over the localStorage budget; only the stashed copy is re-encoded, the live attachment and anything sent without stashing keeps the original - distinguish unreadable images from budget drops so the UI stops blaming "too large" for files that simply failed to decode Data loss fixes: - clear the composer synchronously at keypress, before image encoding awaits, so edits made during encoding are no longer wiped - guard against a second cmd+S while encoding is in flight (duplicate entries from one snapshot) - clear only prompt + images on stash; terminal/element contexts, preview annotations, and review comments are not stashable, so destroying them was unrecoverable - report images that could not be restored because the composer is at its attachment limit rather than discarding them silently Robustness: - namespace provider scope keys ("provider:") so they can never collide with the unscoped sentinel - guard queue lookups with Object.hasOwn so a "__proto__" key rehydrated from a corrupted payload cannot resolve to Object.prototype - let a focused delete button handle its own Enter instead of the menu's capture-phase listener restoring the entry UI: - replace the fly-in save animation with a subtle badge pulse - drop the save toast; the badge pulse is the confirmation Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/chat/ChatComposer.tsx | 175 ++++++++------- .../components/chat/ComposerStashBadge.tsx | 90 ++++---- .../src/components/chat/ComposerStashMenu.tsx | 16 +- apps/web/src/composerDraftStore.ts | 36 +++ apps/web/src/index.css | 44 +--- .../web/src/lib/stashImageCompression.test.ts | 155 +++++++++++++ apps/web/src/lib/stashImageCompression.ts | 206 ++++++++++++++++++ apps/web/src/promptStashStore.test.ts | 53 ++++- apps/web/src/promptStashStore.ts | 42 +++- 9 files changed, 642 insertions(+), 175 deletions(-) create mode 100644 apps/web/src/lib/stashImageCompression.test.ts create mode 100644 apps/web/src/lib/stashImageCompression.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 876efd9540e..f30814c235b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -64,8 +64,9 @@ import { usePromptStashStore, type PromptStashEntry, } from "../../promptStashStore"; -import { ComposerStashBadge, type StashFlyGhost } from "./ComposerStashBadge"; +import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; +import { compressImageForStash } from "../../lib/stashImageCompression"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; import { getTerminalFocusOwner } from "../../lib/terminalFocus"; import { resolveShortcutCommand } from "../../keybindings"; @@ -738,7 +739,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); - const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); + const clearComposerDraftPromptAndImages = useComposerDraftStore( + (store) => store.clearComposerPromptAndImages, + ); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, @@ -979,7 +982,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerFocused, setIsComposerFocused] = useState(false); const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); - const [stashGhost, setStashGhost] = useState(null); + const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ + key: 0, + active: false, + }); const isMobileViewport = useMediaQuery("max-sm"); const isComposerCollapsedMobile = isMobileViewport && !forceExpandedOnMobile && !isComposerFocused; @@ -999,8 +1005,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); const dragDepthRef = useRef(0); - const stashGhostKeyRef = useRef(0); - const stashGhostTimeoutRef = useRef(null); + const stashPulseKeyRef = useRef(0); + const stashPulseTimeoutRef = useRef(null); + /** Guards against a second ⌘S landing while image encoding is in flight. */ + const stashInFlightRef = useRef(false); // ------------------------------------------------------------------ // Derived: composer send state @@ -1904,22 +1912,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { return () => { - if (stashGhostTimeoutRef.current !== null) { - window.clearTimeout(stashGhostTimeoutRef.current); + if (stashPulseTimeoutRef.current !== null) { + window.clearTimeout(stashPulseTimeoutRef.current); } }; }, []); - const playStashGhost = useCallback((snippet: string) => { - stashGhostKeyRef.current += 1; - setStashGhost({ key: stashGhostKeyRef.current, snippet }); - if (stashGhostTimeoutRef.current !== null) { - window.clearTimeout(stashGhostTimeoutRef.current); + /** Briefly highlight the badge so the save registers without a flourish. */ + const pulseStashBadge = useCallback(() => { + stashPulseKeyRef.current += 1; + setStashPulse({ key: stashPulseKeyRef.current, active: true }); + if (stashPulseTimeoutRef.current !== null) { + window.clearTimeout(stashPulseTimeoutRef.current); } - stashGhostTimeoutRef.current = window.setTimeout(() => { - stashGhostTimeoutRef.current = null; - setStashGhost(null); - }, 600); + stashPulseTimeoutRef.current = window.setTimeout(() => { + stashPulseTimeoutRef.current = null; + setStashPulse((current) => ({ ...current, active: false })); + }, 1200); }, []); const restoreStashEntry = useCallback( @@ -1940,12 +1949,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerCursor(nextCursor); setComposerTrigger(null); + let unrestoredImageNames: string[] = []; if (entry.attachments.length > 0) { const existingIds = new Set(composerImagesRef.current.map((image) => image.id)); - const capacity = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - composerImagesRef.current.length; - const restoredImages = hydrateImagesFromPersisted( - entry.attachments.filter((attachment) => !existingIds.has(attachment.id)), - ).slice(0, Math.max(0, capacity)); + const capacity = Math.max( + 0, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS - composerImagesRef.current.length, + ); + const pending = entry.attachments.filter((attachment) => !existingIds.has(attachment.id)); + // Anything past the attachment limit cannot be restored. The entry is + // already out of the queue, so report the overflow by name instead of + // discarding it silently. + unrestoredImageNames = pending.slice(capacity).map((attachment) => attachment.name); + const restoredImages = hydrateImagesFromPersisted(pending.slice(0, capacity)); if (restoredImages.length > 0) { addComposerDraftImages(composerDraftTarget, restoredImages); } @@ -1967,11 +1983,30 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); } + // Each cause gets its own sentence so "too large" is never blamed for a + // file that actually failed to decode, or for one the composer simply + // had no room to take back. + const missingImageReasons: string[] = []; if (entry.droppedImageNames.length > 0) { + missingImageReasons.push( + `${entry.droppedImageNames.join(", ")} exceeded the stash size limit when this prompt was saved.`, + ); + } + if (entry.unreadableImageNames && entry.unreadableImageNames.length > 0) { + missingImageReasons.push( + `${entry.unreadableImageNames.join(", ")} could not be read when this prompt was saved.`, + ); + } + if (unrestoredImageNames.length > 0) { + missingImageReasons.push( + `${unrestoredImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-image limit.`, + ); + } + if (missingImageReasons.length > 0) { toastManager.add({ type: "warning", - title: "Some images were not stashed", - description: `${entry.droppedImageNames.join(", ")} exceeded the stash size limit when this prompt was saved.`, + title: "Some images were not restored", + description: missingImageReasons.join(" "), }); } @@ -2003,39 +2038,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Terminal-context placeholders reference live sessions the stash can't // round-trip, so they are stripped from the stashed prompt. const prompt = promptRef.current.split(INLINE_TERMINAL_CONTEXT_PLACEHOLDER).join("").trim(); - const images = composerImagesRef.current; + const images = [...composerImagesRef.current]; if (prompt.length === 0 && images.length === 0) { setIsStashMenuOpen((open) => !open); return; } - - const persistedById = new Map( - (getComposerDraft(composerDraftTarget)?.persistedAttachments ?? []).map( - (attachment) => [attachment.id, attachment] as const, - ), - ); + // A repeat ⌘S while encoding is still running would stash the same + // snapshot twice. The composer is already cleared by then, so the second + // press has nothing new to save. + if (stashInFlightRef.current) return; + stashInFlightRef.current = true; + + // Clear synchronously, before any await. Image serialization is async, and + // the editor stays live throughout: clearing afterwards would wipe + // whatever the user typed in the meantime. Only the prompt and images are + // cleared — terminal/element contexts, preview annotations, and review + // comments are not stashable, so destroying them here would be + // unrecoverable. + const stashTarget = composerDraftTarget; + promptRef.current = ""; + clearComposerDraftPromptAndImages(stashTarget); + setComposerCursor(0); + setComposerTrigger(null); + // The badge pulse is the whole confirmation — no toast. Anything that + // didn't survive the save is recorded on the entry and shown in the + // stash menu, so nothing is silently lost. + pulseStashBadge(); + + // Images are re-encoded for the stash rather than stored verbatim: the + // composer allows up to 10MB per image, but localStorage gives the whole + // origin ~5MB. Only the stashed copy shrinks; the live attachment (and + // anything sent without stashing) keeps the original file. const candidateAttachments: PersistedComposerImageAttachment[] = []; const unreadableImageNames: string[] = []; for (const image of images) { - const persisted = persistedById.get(image.id); - if (persisted) { - candidateAttachments.push(persisted); - continue; - } try { + const compressed = await compressImageForStash(image.file); + if (!compressed) { + unreadableImageNames.push(image.name); + continue; + } candidateAttachments.push({ id: image.id, name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl: await readFileAsDataUrl(image.file), + mimeType: compressed.mimeType, + sizeBytes: compressed.sizeBytes, + dataUrl: compressed.dataUrl, }); } catch { unreadableImageNames.push(image.name); } } const { kept, droppedNames } = partitionStashAttachments(candidateAttachments); - const allDroppedNames = [...droppedNames, ...unreadableImageNames]; const entry: PromptStashEntry = { id: randomUUID(), @@ -2044,49 +2098,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) attachments: kept, providerInstanceId: stashScopeInstanceId, modelSelection: noProviderAvailable ? null : selectedModelSelection, - droppedImageNames: allDroppedNames, + // Kept separate so the menu can say *why* an image is missing rather + // than blaming everything on the size budget. + droppedImageNames: droppedNames, + unreadableImageNames, }; - const evicted = stashEntryToQueue(entry); + stashEntryToQueue(entry); flushPromptStashStorage(); - - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - setComposerCursor(0); - setComposerTrigger(null); - - playStashGhost( - prompt.length > 0 ? prompt : `${images.length} image${images.length === 1 ? "" : "s"}`, - ); - - toastManager.add({ - type: "success", - title: `Stashed to ${stashProviderLabel}`, - description: - allDroppedNames.length > 0 - ? `Saved without ${allDroppedNames.join(", ")} (too large to stash).` - : evicted - ? "Oldest stashed prompt was discarded to make room." - : "Press ⌘S in an empty composer to bring it back.", - actionProps: { - children: "Undo", - onClick: () => { - restoreStashEntry(entry); - }, - }, - data: { hideCopyButton: true }, - }); + stashInFlightRef.current = false; }, [ - clearComposerDraftContent, + clearComposerDraftPromptAndImages, composerDraftTarget, composerImagesRef, - getComposerDraft, noProviderAvailable, - playStashGhost, promptRef, - restoreStashEntry, + pulseStashBadge, selectedModelSelection, stashEntryToQueue, - stashProviderLabel, stashScopeInstanceId, ]); @@ -2687,7 +2715,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) > diff --git a/apps/web/src/components/chat/ComposerStashBadge.tsx b/apps/web/src/components/chat/ComposerStashBadge.tsx index ff8450dd031..cb2d98339c4 100644 --- a/apps/web/src/components/chat/ComposerStashBadge.tsx +++ b/apps/web/src/components/chat/ComposerStashBadge.tsx @@ -3,66 +3,56 @@ import { memo } from "react"; import { cn } from "~/lib/utils"; -export interface StashFlyGhost { - /** Monotonic id; a new value remounts the ghost so the animation replays. */ - key: number; - snippet: string; -} - /** * Bookmark pill perched on the composer's top-right shoulder. Shows the * current method's stash count and doubles as the click target for opening - * the stash menu. On each save a ghost of the stashed prompt flies from the - * composer into the badge (one-shot animation; see index.css). + * the stash menu. + * + * On save the badge gives one quiet acknowledgement: it lifts to full + * opacity and the count ticks over. `pulseKey` changes per stash, remounting + * the count so the transition replays without a continuous animation. */ export const ComposerStashBadge = memo(function ComposerStashBadge(props: { count: number; - ghost: StashFlyGhost | null; + pulseKey: number; + pulsing: boolean; menuOpen: boolean; onToggleMenu: () => void; }) { - if (props.count === 0 && !props.ghost) return null; + if (props.count === 0) return null; return ( - <> - {props.ghost ? ( - - ) : null} - {props.count > 0 ? ( - - ) : null} - + ); }); diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index 75aa4a6ec48..491b12ad826 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -9,6 +9,11 @@ import { Button } from "../ui/button"; const SNIPPET_MAX_CHARS = 90; +/** Images that did not make it into the entry, whatever the reason. */ +function missingImageCount(entry: PromptStashEntry): number { + return entry.droppedImageNames.length + (entry.unreadableImageNames?.length ?? 0); +} + function stashEntrySnippet(entry: PromptStashEntry): string { const trimmed = entry.prompt.trim().replace(/\s+/g, " "); if (trimmed.length > 0) { @@ -65,6 +70,11 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { return; } if (event.key === "Enter") { + // A focused control inside the row (the delete button) owns its own + // activation; swallowing Enter here would restore instead of delete. + if (event.target instanceof HTMLElement && event.target.closest("button[aria-label]")) { + return; + } if (!highlightedEntry) return; event.preventDefault(); event.stopPropagation(); @@ -133,10 +143,10 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { {stashEntrySnippet(entry)} - {entry.droppedImageNames.length > 0 ? ( + {missingImageCount(entry) > 0 ? ( - {entry.droppedImageNames.length} image - {entry.droppedImageNames.length === 1 ? "" : "s"} dropped + {missingImageCount(entry)} image + {missingImageCount(entry) === 1 ? "" : "s"} dropped ) : null} diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 6e7138b6c35..16c99abad8a 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -495,6 +495,13 @@ interface ComposerDraftStoreState { attachments: PersistedComposerImageAttachment[], ) => void; clearComposerContent: (threadRef: ComposerThreadTarget) => void; + /** + * Clears only the prompt text and image attachments, preserving terminal / + * element contexts, preview annotations, and review comments. Used by the + * prompt stash, which can only round-trip text + images: clearing the + * session-bound contexts would destroy state nothing can restore. + */ + clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; } export interface EffectiveComposerModelState { @@ -3347,6 +3354,35 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + clearComposerPromptAndImages: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; + } + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) { + return state; + } + for (const image of current.images) { + revokeObjectPreviewUrl(image.previewUrl); + } + const nextDraft: ComposerThreadDraftState = { + ...current, + prompt: ensureInlineTerminalContextPlaceholders("", current.terminalContexts.length), + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, }; }, { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 14bc71525c9..19c05845535 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1432,48 +1432,26 @@ label:has(> select#reasoning-effort) select { margin-top: 0.125rem; } -/* Prompt-stash save moment: one-shot, event-driven (retriggered by React - key remount on each stash) — not a continuous animation. A ghost of the - prompt flies from the composer into the stash badge while the badge count - pops. Both settle to their final state and stop. */ -@keyframes prompt-stash-ghost-fly { - 0% { +/* Prompt-stash save acknowledgement: the new count fades up from just below + its resting position, once, then stops. One-shot and event-driven (React + remounts the element by key on each stash) — no continuous animation. */ +@keyframes prompt-stash-count-enter { + from { opacity: 0; - transform: translate(0, 0) scale(1); + transform: translateY(2px); } - 15% { + to { opacity: 1; - } - 100% { - opacity: 0; - transform: translate(var(--stash-fly-x, 120px), var(--stash-fly-y, -40px)) scale(0.1); - } -} - -@keyframes prompt-stash-badge-pop { - 0%, - 100% { - transform: scale(1); - } - 50% { - transform: scale(1.3); + transform: translateY(0); } } -.prompt-stash-ghost { - animation: prompt-stash-ghost-fly 550ms cubic-bezier(0.4, 0, 0.7, 0) forwards; -} - -.prompt-stash-badge-pop { - animation: prompt-stash-badge-pop 300ms ease 250ms both; +.prompt-stash-count-enter { + animation: prompt-stash-count-enter 180ms ease-out both; } @media (prefers-reduced-motion: reduce) { - .prompt-stash-ghost { - animation: none; - opacity: 0; - } - .prompt-stash-badge-pop { + .prompt-stash-count-enter { animation: none; } } diff --git a/apps/web/src/lib/stashImageCompression.test.ts b/apps/web/src/lib/stashImageCompression.test.ts new file mode 100644 index 00000000000..b37a0bf6a72 --- /dev/null +++ b/apps/web/src/lib/stashImageCompression.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { compressImageForStash, MAX_STASH_IMAGE_DATA_URL_CHARS } from "./stashImageCompression"; + +/** + * jsdom has no real canvas/codec, so the re-encode path is exercised with + * stubbed `createImageBitmap` + `OffscreenCanvas`. The encoder stub returns a + * payload whose size scales with quality, mirroring how a real JPEG encoder + * shrinks as quality drops — enough to verify the ladder logic and budget + * enforcement without pulling in a native canvas. + */ + +const originalCreateImageBitmap = globalThis.createImageBitmap; +const originalOffscreenCanvas = globalThis.OffscreenCanvas; + +function makeFile(sizeBytes: number, type = "image/png"): File { + return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type }); +} + +/** + * Installs a fake bitmap + canvas whose encoded size follows `sizeForQuality`. + * `supportsWebp: false` makes `convertToBlob` hand back a differently-typed + * blob for WebP requests, which is how a real browser signals it cannot + * encode that format. + */ +function stubCanvasPipeline( + sizeForQuality: (quality: number) => number, + options?: { supportsWebp?: boolean }, +) { + const supportsWebp = options?.supportsWebp ?? true; + const close = vi.fn(); + const fillRect = vi.fn(); + vi.stubGlobal( + "createImageBitmap", + vi.fn(async () => ({ width: 4000, height: 3000, close })), + ); + vi.stubGlobal( + "OffscreenCanvas", + class { + constructor( + public width: number, + public height: number, + ) {} + getContext() { + return { + fillStyle: "", + fillRect, + drawImage: vi.fn(), + }; + } + async convertToBlob({ type, quality }: { type: string; quality: number }) { + const resolvedType = type === "image/webp" && !supportsWebp ? "image/png" : type; + return new Blob([new Uint8Array(sizeForQuality(quality))], { type: resolvedType }); + } + }, + ); + return { close, fillRect }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + globalThis.createImageBitmap = originalCreateImageBitmap; + globalThis.OffscreenCanvas = originalOffscreenCanvas; +}); + +describe("compressImageForStash", () => { + it("stores a small image verbatim without re-encoding", async () => { + const bitmapSpy = vi.fn(); + vi.stubGlobal("createImageBitmap", bitmapSpy); + + const result = await compressImageForStash(makeFile(1024)); + + expect(result).not.toBeNull(); + expect(result?.recompressed).toBe(false); + expect(result?.mimeType).toBe("image/png"); + expect(result?.dataUrl.startsWith("data:image/png")).toBe(true); + // Untouched payloads must not pay for a decode. + expect(bitmapSpy).not.toHaveBeenCalled(); + }); + + it("re-encodes an oversized image to WebP within the budget", async () => { + // Comfortably under budget at the very first quality step. + const { close, fillRect } = stubCanvasPipeline(() => 120_000); + + const result = await compressImageForStash(makeFile(4_000_000)); + + expect(result).not.toBeNull(); + expect(result?.recompressed).toBe(true); + expect(result?.mimeType).toBe("image/webp"); + expect(result?.dataUrl.length).toBeLessThanOrEqual(MAX_STASH_IMAGE_DATA_URL_CHARS); + // sizeBytes should describe the re-encoded payload, not the 4MB original. + expect(result?.sizeBytes).toBeLessThan(4_000_000); + // WebP keeps alpha, so no white matte should be painted. + expect(fillRect).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalled(); + }); + + it("falls back to JPEG with a white matte when WebP encoding is unavailable", async () => { + const { fillRect } = stubCanvasPipeline(() => 120_000, { supportsWebp: false }); + + const result = await compressImageForStash(makeFile(4_000_000)); + + expect(result?.recompressed).toBe(true); + expect(result?.mimeType).toBe("image/jpeg"); + // JPEG has no alpha, so transparent regions must be matted white. + expect(fillRect).toHaveBeenCalled(); + }); + + it("steps quality down until the encoded image fits", async () => { + // Only the lowest quality step (0.68) lands under the budget. + const { close } = stubCanvasPipeline((quality) => (quality <= 0.68 ? 400_000 : 3_000_000)); + + const result = await compressImageForStash(makeFile(9_000_000)); + + expect(result?.recompressed).toBe(true); + expect(result?.dataUrl.length).toBeLessThanOrEqual(MAX_STASH_IMAGE_DATA_URL_CHARS); + expect(close).toHaveBeenCalled(); + }); + + it("returns null when even the smallest encoding overflows the budget", async () => { + const { close } = stubCanvasPipeline(() => 8_000_000); + + const result = await compressImageForStash(makeFile(9_000_000)); + + expect(result).toBeNull(); + // The bitmap must still be released on the give-up path. + expect(close).toHaveBeenCalled(); + }); + + it("returns null for an oversized image when the browser cannot re-encode", async () => { + vi.stubGlobal("createImageBitmap", undefined); + vi.stubGlobal("OffscreenCanvas", undefined); + + expect(await compressImageForStash(makeFile(4_000_000))).toBeNull(); + }); + + it("returns null when the image fails to decode", async () => { + vi.stubGlobal( + "createImageBitmap", + vi.fn(async () => { + throw new Error("corrupt image"); + }), + ); + vi.stubGlobal( + "OffscreenCanvas", + class { + getContext() { + return null; + } + }, + ); + + expect(await compressImageForStash(makeFile(4_000_000))).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/stashImageCompression.ts b/apps/web/src/lib/stashImageCompression.ts new file mode 100644 index 00000000000..fea74247d24 --- /dev/null +++ b/apps/web/src/lib/stashImageCompression.ts @@ -0,0 +1,206 @@ +/** + * Re-encoding for stashed image attachments. + * + * The composer accepts images up to `PROVIDER_SEND_TURN_MAX_IMAGE_BYTES` + * (10MB), but the stash persists them as base64 in localStorage, where the + * whole origin shares a ~5MB quota. Rather than refuse large screenshots, + * downscale + re-encode them to JPEG so a stashed prompt keeps its images. + * + * Only the *stashed copy* is compressed; the live composer attachment is + * untouched, so sending without stashing still uploads the original file. + */ + +/** + * Longest edge kept when an image has to be re-encoded. Sized so a typical + * retina screenshot (3024px wide) stays legible rather than being halved. + */ +const MAX_DIMENSION = 2048; +/** Base64 budget for a single stashed image (~975KB of binary). */ +export const MAX_STASH_IMAGE_DATA_URL_CHARS = 1_300_000; +/** + * Quality ladder tried in order until the encoded image fits the budget. + * The floor stays high enough to avoid visible blocking on UI screenshots; + * if even that overflows we drop resolution instead of quality. + */ +const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const; +/** Extra downscale passes applied when even the lowest quality overflows. */ +const FALLBACK_SCALE_STEPS = [0.75, 0.55] as const; + +export interface CompressedStashImage { + dataUrl: string; + mimeType: string; + sizeBytes: number; + /** True when the payload was re-encoded rather than stored verbatim. */ + recompressed: boolean; +} + +/** Chunked so a large image can't blow the argument limit of `fromCharCode`. */ +const BASE64_CHUNK_SIZE = 0x8000; + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE)); + } + return btoa(binary); +} + +/** + * Blob → base64 data URL. Uses `arrayBuffer()` rather than `FileReader` so + * the module works anywhere `Blob` does (including non-DOM test runners). + */ +async function blobToDataUrl(blob: File | Blob, mimeTypeOverride?: string): Promise { + const buffer = await blob.arrayBuffer(); + const mimeType = mimeTypeOverride || blob.type || "application/octet-stream"; + return `data:${mimeType};base64,${bytesToBase64(new Uint8Array(buffer))}`; +} + +/** Approximate decoded byte count for a base64 data URL. */ +function dataUrlByteLength(dataUrl: string): number { + const commaIndex = dataUrl.indexOf(","); + const payload = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1); + const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0; + return Math.max(0, Math.floor((payload.length * 3) / 4) - padding); +} + +function canRecompress(): boolean { + return ( + typeof createImageBitmap === "function" && + (typeof OffscreenCanvas === "function" || typeof document !== "undefined") + ); +} + +interface Canvas2D { + canvas: OffscreenCanvas | HTMLCanvasElement; + context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; +} + +function createCanvas(width: number, height: number): Canvas2D | null { + if (typeof OffscreenCanvas === "function") { + const canvas = new OffscreenCanvas(width, height); + const context = canvas.getContext("2d"); + if (!context) return null; + return { canvas, context }; + } + if (typeof document === "undefined") return null; + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) return null; + return { canvas, context }; +} + +/** + * WebP is preferred: at matched visual quality it lands roughly 25-35% + * smaller than JPEG, so the same budget buys more resolution and detail — + * and it keeps alpha, so screenshots with transparency survive intact. + * Browsers that can't encode it silently fall back to JPEG. + */ +async function encodeToDataUrl( + canvas: OffscreenCanvas | HTMLCanvasElement, + quality: number, + mimeType: string, +): Promise<{ dataUrl: string; mimeType: string } | null> { + if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) { + const dataUrl = canvas.toDataURL(mimeType, quality); + // toDataURL silently returns a PNG when the requested type is unsupported. + if (!dataUrl.startsWith(`data:${mimeType}`)) return null; + return { dataUrl, mimeType }; + } + const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality }); + if (blob.type && blob.type !== mimeType) return null; + return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType }; +} + +/** + * Draws `bitmap` scaled to fit `maxDimension` and encodes it as JPEG, + * stepping quality down until the data URL fits `budgetChars`. Returns the + * smallest encoding produced, even if it still exceeds the budget, so the + * caller can decide whether to keep or drop it. + */ +async function encodeWithinBudget( + bitmap: ImageBitmap, + maxDimension: number, + budgetChars: number, +): Promise<{ dataUrl: string; mimeType: string } | null> { + const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height)); + const width = Math.max(1, Math.round(bitmap.width * scale)); + const height = Math.max(1, Math.round(bitmap.height * scale)); + const target = createCanvas(width, height); + if (!target) return null; + + // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to + // happen before drawing and depends on which codec we end up using. + const probe = await encodeToDataUrl(target.canvas, QUALITY_STEPS[0], "image/webp"); + const mimeType = probe ? "image/webp" : "image/jpeg"; + + if (mimeType === "image/jpeg") { + target.context.fillStyle = "#ffffff"; + target.context.fillRect(0, 0, width, height); + } + target.context.drawImage(bitmap, 0, 0, width, height); + + let smallest: { dataUrl: string; mimeType: string } | null = null; + for (const quality of QUALITY_STEPS) { + const encoded = await encodeToDataUrl(target.canvas, quality, mimeType); + if (!encoded) break; + if (smallest === null || encoded.dataUrl.length < smallest.dataUrl.length) { + smallest = encoded; + } + if (encoded.dataUrl.length <= budgetChars) { + return encoded; + } + } + return smallest; +} + +/** + * Produces the payload to persist for a stashed image. + * + * Small images are stored verbatim (preserving PNG transparency and exact + * pixels). Anything over budget is downscaled and JPEG-encoded; if it still + * doesn't fit after the fallback passes, returns `null` so the caller can + * record it as dropped. + */ +export async function compressImageForStash( + file: File, + budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS, +): Promise { + const originalDataUrl = await blobToDataUrl(file); + if (originalDataUrl.length <= budgetChars) { + return { + dataUrl: originalDataUrl, + mimeType: file.type, + sizeBytes: file.size, + recompressed: false, + }; + } + if (!canRecompress()) { + return null; + } + + let bitmap: ImageBitmap; + try { + bitmap = await createImageBitmap(file); + } catch { + return null; + } + + try { + for (const dimensionScale of [1, ...FALLBACK_SCALE_STEPS]) { + const encoded = await encodeWithinBudget(bitmap, MAX_DIMENSION * dimensionScale, budgetChars); + if (encoded && encoded.dataUrl.length <= budgetChars) { + return { + dataUrl: encoded.dataUrl, + mimeType: encoded.mimeType, + sizeBytes: dataUrlByteLength(encoded.dataUrl), + recompressed: true, + }; + } + } + return null; + } finally { + bitmap.close(); + } +} diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index 347541f4ef1..e94dd7c5889 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -49,10 +49,18 @@ function resetPromptStashStore() { describe("promptStashScopeKey", () => { it("maps a provider instance to its own bucket and null to the unscoped bucket", () => { - expect(promptStashScopeKey(CLAUDE_AGENT_INSTANCE)).toBe("claudeAgent"); + expect(promptStashScopeKey(CLAUDE_AGENT_INSTANCE)).toBe("provider:claudeAgent"); expect(promptStashScopeKey(null)).toBe(PROMPT_STASH_UNSCOPED_KEY); expect(promptStashScopeKey(undefined)).toBe(PROMPT_STASH_UNSCOPED_KEY); }); + + // Provider slugs must match /^[a-zA-Z][a-zA-Z0-9_-]*$/, so no real instance + // id can equal the unscoped sentinel. The namespace prefix makes that + // structural rather than incidental. + it("namespaces provider keys so they can never equal the unscoped sentinel", () => { + expect(promptStashScopeKey(CLAUDE_AGENT_INSTANCE)).not.toBe(PROMPT_STASH_UNSCOPED_KEY); + expect(promptStashScopeKey(CODEX_INSTANCE).startsWith("provider:")).toBe(true); + }); }); describe("partitionStashAttachments", () => { @@ -106,7 +114,9 @@ describe("promptStashStore", () => { const store = usePromptStashStore.getState(); store.stashEntry(makeEntry({ id: "first" })); store.stashEntry(makeEntry({ id: "second" })); - const queue = usePromptStashStore.getState().queuesByScopeKey["claudeAgent"] ?? []; + const queue = + usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ?? + []; expect(queue.map((entry) => entry.id)).toEqual(["second", "first"]); }); @@ -116,8 +126,12 @@ describe("promptStashStore", () => { store.stashEntry(makeEntry({ id: "codex", providerInstanceId: CODEX_INSTANCE })); store.stashEntry(makeEntry({ id: "none", providerInstanceId: null })); const queues = usePromptStashStore.getState().queuesByScopeKey; - expect(queues["claudeAgent"]?.map((entry) => entry.id)).toEqual(["claude"]); - expect(queues["codex"]?.map((entry) => entry.id)).toEqual(["codex"]); + expect(queues[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)]?.map((entry) => entry.id)).toEqual([ + "claude", + ]); + expect(queues[promptStashScopeKey(CODEX_INSTANCE)]?.map((entry) => entry.id)).toEqual([ + "codex", + ]); expect(queues[PROMPT_STASH_UNSCOPED_KEY]?.map((entry) => entry.id)).toEqual(["none"]); }); @@ -128,7 +142,9 @@ describe("promptStashStore", () => { } const evicted = store.stashEntry(makeEntry({ id: "overflow" })); expect(evicted?.id).toBe("entry-0"); - const queue = usePromptStashStore.getState().queuesByScopeKey["claudeAgent"] ?? []; + const queue = + usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ?? + []; expect(queue).toHaveLength(MAX_STASH_ENTRIES_PER_QUEUE); expect(queue[0]?.id).toBe("overflow"); }); @@ -137,16 +153,33 @@ describe("promptStashStore", () => { const store = usePromptStashStore.getState(); store.stashEntry(makeEntry({ id: "keep" })); store.stashEntry(makeEntry({ id: "take" })); - expect(store.takeEntry("claudeAgent", "take")?.id).toBe("take"); - expect(store.takeEntry("claudeAgent", "take")).toBeNull(); - const queue = usePromptStashStore.getState().queuesByScopeKey["claudeAgent"] ?? []; + expect(store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "take")?.id).toBe("take"); + expect(store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "take")).toBeNull(); + const queue = + usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ?? + []; expect(queue.map((entry) => entry.id)).toEqual(["keep"]); }); + // Queue keys are persisted as plain strings, so a hand-edited or corrupted + // localStorage payload can carry a literal `__proto__` key that survives + // JSON.parse as an own property. An unguarded lookup would resolve to + // Object.prototype and throw "not iterable" on spread. + it("tolerates a __proto__ scope key rehydrated from storage", () => { + usePromptStashStore.setState({ + queuesByScopeKey: JSON.parse('{"__proto__":[]}') as Record, + }); + const store = usePromptStashStore.getState(); + expect(() => store.takeEntry("__proto__", "missing")).not.toThrow(); + expect(store.takeEntry("__proto__", "missing")).toBeNull(); + }); + it("drops the scope key entirely when its queue empties", () => { const store = usePromptStashStore.getState(); store.stashEntry(makeEntry({ id: "only" })); - store.takeEntry("claudeAgent", "only"); - expect(usePromptStashStore.getState().queuesByScopeKey["claudeAgent"]).toBeUndefined(); + store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "only"); + expect( + usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)], + ).toBeUndefined(); }); }); diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index 51ad6bbdab7..823d446cd9a 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -10,16 +10,27 @@ export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1"; const PROMPT_STASH_STORAGE_VERSION = 1; const PROMPT_STASH_PERSIST_DEBOUNCE_MS = 300; -/** Queue bucket for prompts stashed while no provider instance is selected. */ +/** + * Queue bucket for prompts stashed while no provider instance is selected. + * + * Provider-scoped keys are prefixed (see `promptStashScopeKey`), so this + * sentinel can never collide with a provider literally named `__none__`. + */ export const PROMPT_STASH_UNSCOPED_KEY = "__none__"; +/** Namespace applied to provider-derived keys to keep them collision-proof. */ +const PROVIDER_SCOPE_PREFIX = "provider:"; export const MAX_STASH_ENTRIES_PER_QUEUE = 20; /** * Budget for an entry's serialized attachment payload. localStorage is a * ~5MB origin-wide quota shared with the composer draft store, so oversized * images are dropped (tracked in `droppedImageNames`) rather than persisted. + * + * Sized to hold two images at the per-image compression budget + * (`MAX_STASH_IMAGE_DATA_URL_CHARS`) so a typical before/after screenshot + * pair survives intact. */ -export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_000_000; +export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000; const StashEntrySchema = Schema.Struct({ id: Schema.String, @@ -30,6 +41,13 @@ const StashEntrySchema = Schema.Struct({ modelSelection: Schema.NullOr(ModelSelection), /** Names of images that exceeded the attachment budget and were not saved. */ droppedImageNames: Schema.Array(Schema.String), + /** + * Names of images that could not be decoded or re-encoded at all — a + * distinct failure from exceeding the size budget, so the menu can explain + * which actually happened. Optional: entries written before this field + * existed decode without it. + */ + unreadableImageNames: Schema.optionalKey(Schema.Array(Schema.String)), }); export type PromptStashEntry = typeof StashEntrySchema.Type; @@ -42,7 +60,19 @@ const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPrompt /** Maps the composer's active provider instance to a stash queue bucket. */ export function promptStashScopeKey(instanceId: ProviderInstanceId | null | undefined): string { - return instanceId ?? PROMPT_STASH_UNSCOPED_KEY; + return instanceId ? `${PROVIDER_SCOPE_PREFIX}${instanceId}` : PROMPT_STASH_UNSCOPED_KEY; +} + +/** + * Reads a queue without inheriting from `Object.prototype`. Scope keys derive + * from user-authored provider slugs, so a key like `__proto__` must not + * resolve to the prototype chain. + */ +function readQueue( + queues: Record>, + scopeKey: string, +): ReadonlyArray { + return Object.hasOwn(queues, scopeKey) ? (queues[scopeKey] ?? []) : []; } /** @@ -125,7 +155,7 @@ export const usePromptStashStore = create()( queuesByScopeKey: {}, stashEntry: (entry) => { const scopeKey = promptStashScopeKey(entry.providerInstanceId); - const queue = get().queuesByScopeKey[scopeKey] ?? []; + const queue = readQueue(get().queuesByScopeKey, scopeKey); const nextQueue = [entry, ...queue]; const evicted = nextQueue.length > MAX_STASH_ENTRIES_PER_QUEUE ? (nextQueue.pop() ?? null) : null; @@ -135,11 +165,11 @@ export const usePromptStashStore = create()( return evicted; }, takeEntry: (scopeKey, entryId) => { - const queue = get().queuesByScopeKey[scopeKey] ?? []; + const queue = readQueue(get().queuesByScopeKey, scopeKey); const entry = queue.find((candidate) => candidate.id === entryId) ?? null; if (!entry) return null; set((state) => { - const nextQueue = (state.queuesByScopeKey[scopeKey] ?? []).filter( + const nextQueue = readQueue(state.queuesByScopeKey, scopeKey).filter( (candidate) => candidate.id !== entryId, ); const nextQueues = { ...state.queuesByScopeKey }; From 71c62b4f77138fd8ddbe48ba9ac0f0dff33d8e57 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 27 Jul 2026 02:38:10 -0700 Subject: [PATCH 3/8] fix(web): address second round of prompt stash review feedback - write the stash entry before clearing the composer, then attach images as they finish encoding. Clearing first (the previous fix) protected edits made during encoding but left a window where a crash or closed tab lost the prompt entirely; entries now carry a pendingImageCount until finalizeEntryImages lands. - clear the in-flight guard in a finally block so a throw mid-stash no longer wedges cmd+S until remount - surface queue eviction: stashEntry's evicted entry was discarded by the caller, so hitting the 20-entry cap was silent - distinguish "too large" from "unreadable" in the compressor itself via a tagged result, instead of treating every null as a decode failure - scale fallback downscale passes off the bitmap rather than a fixed MAX_DIMENSION ceiling; for sources already under 2048px every pass resolved to the source size and never actually shrank - guard the localStorage property access itself, which can throw SecurityError under storage policy or in a sandboxed iframe and would crash at module import Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/chat/ChatComposer.tsx | 121 +++++++++++------- .../src/components/chat/ComposerStashMenu.tsx | 7 +- .../web/src/lib/stashImageCompression.test.ts | 81 +++++++++--- apps/web/src/lib/stashImageCompression.ts | 55 ++++++-- apps/web/src/promptStashStore.ts | 63 ++++++++- 5 files changed, 242 insertions(+), 85 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f30814c235b..63375b6af31 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -59,6 +59,7 @@ import { import { EMPTY_PROMPT_STASH_QUEUE, flushPromptStashStorage, + MAX_STASH_ENTRIES_PER_QUEUE, partitionStashAttachments, promptStashScopeKey, usePromptStashStore, @@ -1906,6 +1907,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry); const takeStashEntry = usePromptStashStore((state) => state.takeEntry); + const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages); const stashProviderLabel = noProviderAvailable ? "No provider" : getProviderDisplayName(providerStatuses, selectedProvider); @@ -2049,72 +2051,95 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (stashInFlightRef.current) return; stashInFlightRef.current = true; - // Clear synchronously, before any await. Image serialization is async, and - // the editor stays live throughout: clearing afterwards would wipe - // whatever the user typed in the meantime. Only the prompt and images are - // cleared — terminal/element contexts, preview annotations, and review - // comments are not stashable, so destroying them here would be - // unrecoverable. const stashTarget = composerDraftTarget; - promptRef.current = ""; - clearComposerDraftPromptAndImages(stashTarget); - setComposerCursor(0); - setComposerTrigger(null); - // The badge pulse is the whole confirmation — no toast. Anything that - // didn't survive the save is recorded on the entry and shown in the - // stash menu, so nothing is silently lost. - pulseStashBadge(); - - // Images are re-encoded for the stash rather than stored verbatim: the - // composer allows up to 10MB per image, but localStorage gives the whole - // origin ~5MB. Only the stashed copy shrinks; the live attachment (and - // anything sent without stashing) keeps the original file. - const candidateAttachments: PersistedComposerImageAttachment[] = []; - const unreadableImageNames: string[] = []; - for (const image of images) { - try { - const compressed = await compressImageForStash(image.file); - if (!compressed) { - unreadableImageNames.push(image.name); + const entryId = randomUUID(); + const scopeKey = promptStashScopeKey(stashScopeInstanceId); + try { + // Persist the text-only entry *first*, then clear. Ordering matters in + // both directions: writing before clearing means a crash or closed tab + // mid-encode still leaves the prompt recoverable, while clearing before + // the async image work means edits typed during encoding are not wiped. + // Images are appended to the stored entry as they finish encoding. + const evicted = stashEntryToQueue({ + id: entryId, + createdAt: new Date().toISOString(), + prompt, + attachments: [], + providerInstanceId: stashScopeInstanceId, + modelSelection: noProviderAvailable ? null : selectedModelSelection, + droppedImageNames: [], + unreadableImageNames: [], + pendingImageCount: images.length, + }); + flushPromptStashStorage(); + + // Only the prompt and images are cleared — terminal/element contexts, + // preview annotations, and review comments are not stashable, so + // destroying them here would be unrecoverable. + promptRef.current = ""; + clearComposerDraftPromptAndImages(stashTarget); + setComposerCursor(0); + setComposerTrigger(null); + pulseStashBadge(); + + if (evicted) { + toastManager.add({ + type: "warning", + title: "Oldest stashed prompt discarded", + description: `The ${stashProviderLabel} stash holds ${MAX_STASH_ENTRIES_PER_QUEUE} prompts; the oldest was removed to make room.`, + data: { hideCopyButton: true }, + }); + } + + // Images are re-encoded for the stash rather than stored verbatim: the + // composer allows up to 10MB per image, but localStorage gives the whole + // origin ~5MB. Only the stashed copy shrinks; the live attachment (and + // anything sent without stashing) keeps the original file. + const candidateAttachments: PersistedComposerImageAttachment[] = []; + const oversizedImageNames: string[] = []; + const unreadableImageNames: string[] = []; + for (const image of images) { + const result = await compressImageForStash(image.file); + if (!result.ok) { + // "too large" and "could not be read" are distinct outcomes; the + // menu and restore toast report them separately. + (result.reason === "too-large" ? oversizedImageNames : unreadableImageNames).push( + image.name, + ); continue; } candidateAttachments.push({ id: image.id, name: image.name, - mimeType: compressed.mimeType, - sizeBytes: compressed.sizeBytes, - dataUrl: compressed.dataUrl, + mimeType: result.image.mimeType, + sizeBytes: result.image.sizeBytes, + dataUrl: result.image.dataUrl, }); - } catch { - unreadableImageNames.push(image.name); } - } - const { kept, droppedNames } = partitionStashAttachments(candidateAttachments); + const { kept, droppedNames } = partitionStashAttachments(candidateAttachments); - const entry: PromptStashEntry = { - id: randomUUID(), - createdAt: new Date().toISOString(), - prompt, - attachments: kept, - providerInstanceId: stashScopeInstanceId, - modelSelection: noProviderAvailable ? null : selectedModelSelection, - // Kept separate so the menu can say *why* an image is missing rather - // than blaming everything on the size budget. - droppedImageNames: droppedNames, - unreadableImageNames, - }; - stashEntryToQueue(entry); - flushPromptStashStorage(); - stashInFlightRef.current = false; + finalizeStashEntryImages(scopeKey, entryId, { + attachments: kept, + droppedImageNames: [...oversizedImageNames, ...droppedNames], + unreadableImageNames, + }); + flushPromptStashStorage(); + } finally { + // Must clear on every path: a throw that left this set would wedge ⌘S + // until the composer remounts. + stashInFlightRef.current = false; + } }, [ clearComposerDraftPromptAndImages, composerDraftTarget, composerImagesRef, + finalizeStashEntryImages, noProviderAvailable, promptRef, pulseStashBadge, selectedModelSelection, stashEntryToQueue, + stashProviderLabel, stashScopeInstanceId, ]); diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index 491b12ad826..22950074307 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -143,7 +143,12 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { {stashEntrySnippet(entry)} - {missingImageCount(entry) > 0 ? ( + {entry.pendingImageCount ? ( + + saving {entry.pendingImageCount} image + {entry.pendingImageCount === 1 ? "" : "s"}… + + ) : missingImageCount(entry) > 0 ? ( {missingImageCount(entry)} image {missingImageCount(entry) === 1 ? "" : "s"} dropped diff --git a/apps/web/src/lib/stashImageCompression.test.ts b/apps/web/src/lib/stashImageCompression.test.ts index b37a0bf6a72..aaffa284cbb 100644 --- a/apps/web/src/lib/stashImageCompression.test.ts +++ b/apps/web/src/lib/stashImageCompression.test.ts @@ -70,10 +70,10 @@ describe("compressImageForStash", () => { const result = await compressImageForStash(makeFile(1024)); - expect(result).not.toBeNull(); - expect(result?.recompressed).toBe(false); - expect(result?.mimeType).toBe("image/png"); - expect(result?.dataUrl.startsWith("data:image/png")).toBe(true); + expect(result.ok).toBe(true); + expect(result.ok && result.image.recompressed).toBe(false); + expect(result.ok && result.image.mimeType).toBe("image/png"); + expect(result.ok && result.image.dataUrl.startsWith("data:image/png")).toBe(true); // Untouched payloads must not pay for a decode. expect(bitmapSpy).not.toHaveBeenCalled(); }); @@ -84,12 +84,12 @@ describe("compressImageForStash", () => { const result = await compressImageForStash(makeFile(4_000_000)); - expect(result).not.toBeNull(); - expect(result?.recompressed).toBe(true); - expect(result?.mimeType).toBe("image/webp"); - expect(result?.dataUrl.length).toBeLessThanOrEqual(MAX_STASH_IMAGE_DATA_URL_CHARS); + expect(result.ok).toBe(true); + expect(result.ok && result.image.recompressed).toBe(true); + expect(result.ok && result.image.mimeType).toBe("image/webp"); + expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true); // sizeBytes should describe the re-encoded payload, not the 4MB original. - expect(result?.sizeBytes).toBeLessThan(4_000_000); + expect(result.ok && result.image.sizeBytes).toBeLessThan(4_000_000); // WebP keeps alpha, so no white matte should be painted. expect(fillRect).not.toHaveBeenCalled(); expect(close).toHaveBeenCalled(); @@ -100,8 +100,8 @@ describe("compressImageForStash", () => { const result = await compressImageForStash(makeFile(4_000_000)); - expect(result?.recompressed).toBe(true); - expect(result?.mimeType).toBe("image/jpeg"); + expect(result.ok && result.image.recompressed).toBe(true); + expect(result.ok && result.image.mimeType).toBe("image/jpeg"); // JPEG has no alpha, so transparent regions must be matted white. expect(fillRect).toHaveBeenCalled(); }); @@ -112,29 +112,32 @@ describe("compressImageForStash", () => { const result = await compressImageForStash(makeFile(9_000_000)); - expect(result?.recompressed).toBe(true); - expect(result?.dataUrl.length).toBeLessThanOrEqual(MAX_STASH_IMAGE_DATA_URL_CHARS); + expect(result.ok && result.image.recompressed).toBe(true); + expect(result.ok && result.image.dataUrl.length <= MAX_STASH_IMAGE_DATA_URL_CHARS).toBe(true); expect(close).toHaveBeenCalled(); }); - it("returns null when even the smallest encoding overflows the budget", async () => { + it("reports too-large when even the smallest encoding overflows the budget", async () => { const { close } = stubCanvasPipeline(() => 8_000_000); const result = await compressImageForStash(makeFile(9_000_000)); - expect(result).toBeNull(); + expect(result).toEqual({ ok: false, reason: "too-large" }); // The bitmap must still be released on the give-up path. expect(close).toHaveBeenCalled(); }); - it("returns null for an oversized image when the browser cannot re-encode", async () => { + it("reports too-large for an oversized image when the browser cannot re-encode", async () => { vi.stubGlobal("createImageBitmap", undefined); vi.stubGlobal("OffscreenCanvas", undefined); - expect(await compressImageForStash(makeFile(4_000_000))).toBeNull(); + expect(await compressImageForStash(makeFile(4_000_000))).toEqual({ + ok: false, + reason: "too-large", + }); }); - it("returns null when the image fails to decode", async () => { + it("reports unreadable when the image fails to decode", async () => { vi.stubGlobal( "createImageBitmap", vi.fn(async () => { @@ -150,6 +153,46 @@ describe("compressImageForStash", () => { }, ); - expect(await compressImageForStash(makeFile(4_000_000))).toBeNull(); + expect(await compressImageForStash(makeFile(4_000_000))).toEqual({ + ok: false, + reason: "unreadable", + }); + }); + + it("shrinks below the source size when the image is already under MAX_DIMENSION", async () => { + // A small-but-heavy source (e.g. a dense PNG): only a real downscale can + // get it under budget, since quality alone is stubbed to never suffice. + let smallestRequested = Number.POSITIVE_INFINITY; + const close = vi.fn(); + vi.stubGlobal( + "createImageBitmap", + vi.fn(async () => ({ width: 800, height: 600, close })), + ); + vi.stubGlobal( + "OffscreenCanvas", + class { + constructor( + public width: number, + public height: number, + ) { + smallestRequested = Math.min(smallestRequested, width); + } + getContext() { + return { fillStyle: "", fillRect: vi.fn(), drawImage: vi.fn() }; + } + async convertToBlob({ type }: { type: string; quality: number }) { + // Only a genuinely downscaled pass fits the budget. + const size = smallestRequested < 800 ? 100_000 : 5_000_000; + return new Blob([new Uint8Array(size)], { type }); + } + }, + ); + + const result = await compressImageForStash(makeFile(4_000_000)); + + expect(result.ok).toBe(true); + // Fallback passes must scale off the bitmap, not a fixed 2048 ceiling + // that would never go below an 800px source. + expect(smallestRequested).toBeLessThan(800); }); }); diff --git a/apps/web/src/lib/stashImageCompression.ts b/apps/web/src/lib/stashImageCompression.ts index fea74247d24..33ccdeaef37 100644 --- a/apps/web/src/lib/stashImageCompression.ts +++ b/apps/web/src/lib/stashImageCompression.ts @@ -34,6 +34,16 @@ export interface CompressedStashImage { recompressed: boolean; } +/** + * Why an image could not be stashed. Callers report these differently: + * "too large" is a budget outcome, "unreadable" is a decode failure. + */ +export type StashImageFailureReason = "too-large" | "unreadable"; + +export type CompressStashImageResult = + | { ok: true; image: CompressedStashImage } + | { ok: false; reason: StashImageFailureReason }; + /** Chunked so a large image can't blow the argument limit of `fromCharCode`. */ const BASE64_CHUNK_SIZE = 0x8000; @@ -166,40 +176,57 @@ async function encodeWithinBudget( export async function compressImageForStash( file: File, budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS, -): Promise { - const originalDataUrl = await blobToDataUrl(file); +): Promise { + let originalDataUrl: string; + try { + originalDataUrl = await blobToDataUrl(file); + } catch { + return { ok: false, reason: "unreadable" }; + } if (originalDataUrl.length <= budgetChars) { return { - dataUrl: originalDataUrl, - mimeType: file.type, - sizeBytes: file.size, - recompressed: false, + ok: true, + image: { + dataUrl: originalDataUrl, + mimeType: file.type, + sizeBytes: file.size, + recompressed: false, + }, }; } if (!canRecompress()) { - return null; + return { ok: false, reason: "too-large" }; } let bitmap: ImageBitmap; try { bitmap = await createImageBitmap(file); } catch { - return null; + return { ok: false, reason: "unreadable" }; } try { + // Each pass shrinks relative to the *previous target*, capped by + // MAX_DIMENSION. Scaling a fixed ceiling instead would be a no-op for + // images already smaller than that ceiling — the fallback passes would + // all resolve to the source size and never actually reduce resolution. + const baseDimension = Math.min(MAX_DIMENSION, Math.max(bitmap.width, bitmap.height)); for (const dimensionScale of [1, ...FALLBACK_SCALE_STEPS]) { - const encoded = await encodeWithinBudget(bitmap, MAX_DIMENSION * dimensionScale, budgetChars); + const targetDimension = Math.max(1, Math.round(baseDimension * dimensionScale)); + const encoded = await encodeWithinBudget(bitmap, targetDimension, budgetChars); if (encoded && encoded.dataUrl.length <= budgetChars) { return { - dataUrl: encoded.dataUrl, - mimeType: encoded.mimeType, - sizeBytes: dataUrlByteLength(encoded.dataUrl), - recompressed: true, + ok: true, + image: { + dataUrl: encoded.dataUrl, + mimeType: encoded.mimeType, + sizeBytes: dataUrlByteLength(encoded.dataUrl), + recompressed: true, + }, }; } } - return null; + return { ok: false, reason: "too-large" }; } finally { bitmap.close(); } diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index 823d446cd9a..0a185148f49 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -48,6 +48,13 @@ const StashEntrySchema = Schema.Struct({ * existed decode without it. */ unreadableImageNames: Schema.optionalKey(Schema.Array(Schema.String)), + /** + * Images still being encoded when the entry was written. The entry is + * persisted before its images so a crash mid-encode cannot lose the prompt; + * this field lets the UI show "N images still saving" until + * `finalizeEntryImages` lands, and flags entries orphaned by a reload. + */ + pendingImageCount: Schema.optionalKey(Schema.Number), }); export type PromptStashEntry = typeof StashEntrySchema.Type; @@ -125,10 +132,26 @@ function createQuotaSafeStorage(base: StateStorage): StateStorage { }; } +/** + * Reading the `localStorage` property itself can throw `SecurityError` when + * storage is blocked by policy or the page is a sandboxed iframe — so the + * access has to be guarded, not just the get/set calls on it. Otherwise + * importing this module would crash the app at load. + */ +function resolveBaseStorage(): StateStorage { + try { + if (typeof localStorage !== "undefined") { + return localStorage; + } + } catch { + // Fall through to the in-memory store: the stash still works for the + // session, it just will not survive a reload. + } + return createMemoryStorage(); +} + const promptStashDebouncedStorage = createDebouncedStorage( - createQuotaSafeStorage( - typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(), - ), + createQuotaSafeStorage(resolveBaseStorage()), PROMPT_STASH_PERSIST_DEBOUNCE_MS, ); @@ -147,6 +170,20 @@ interface PromptStashStoreState { stashEntry: (entry: PromptStashEntry) => PromptStashEntry | null; /** Removes and returns an entry from a scope's queue (restore + delete). */ takeEntry: (scopeKey: string, entryId: string) => PromptStashEntry | null; + /** + * Attaches the encoded images to an entry written earlier by `stashEntry`, + * clearing its pending count. No-ops when the entry is gone (restored or + * deleted while encoding was still running). + */ + finalizeEntryImages: ( + scopeKey: string, + entryId: string, + images: { + attachments: ReadonlyArray; + droppedImageNames: ReadonlyArray; + unreadableImageNames: ReadonlyArray; + }, + ) => void; } export const usePromptStashStore = create()( @@ -182,6 +219,26 @@ export const usePromptStashStore = create()( }); return entry; }, + finalizeEntryImages: (scopeKey, entryId, images) => { + set((state) => { + const queue = readQueue(state.queuesByScopeKey, scopeKey); + const index = queue.findIndex((candidate) => candidate.id === entryId); + // Restored or deleted mid-encode: nothing to attach to. + if (index === -1) return state; + const existing = queue[index]; + if (!existing) return state; + const nextEntry: PromptStashEntry = { + ...existing, + attachments: images.attachments, + droppedImageNames: images.droppedImageNames, + unreadableImageNames: images.unreadableImageNames, + pendingImageCount: 0, + }; + const nextQueue = [...queue]; + nextQueue[index] = nextEntry; + return { queuesByScopeKey: { ...state.queuesByScopeKey, [scopeKey]: nextQueue } }; + }); + }, }), { name: PROMPT_STASH_STORAGE_KEY, From 29cf237a0ad1a7790e6368477c29cd4cc3be72ad Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 27 Jul 2026 03:08:20 -0700 Subject: [PATCH 4/8] fix(web): third round of prompt stash review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refuse to clear the composer when the stash write was rejected. Quota failures were swallowed by the storage wrapper, so a failed persist still cleared the composer and a reload lost both copies. flushPromptStashStorage now reports whether the write landed; on failure the entry is rolled back and the composer is left intact. - report images that finished encoding after their entry was restored or deleted. finalizeEntryImages returns false in that case instead of silently no-oping, and restore is blocked while a stash is still encoding. - contain encoder exceptions inside compressImageForStash. A throw from canvas allocation or convertToBlob escaped the retry loop, skipped finalization, and stranded the entry as permanently "saving…". Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/chat/ChatComposer.tsx | 34 +++++++++++++-- .../src/components/chat/ComposerStashMenu.tsx | 10 +++++ apps/web/src/lib/stashImageCompression.ts | 11 ++++- apps/web/src/promptStashStore.test.ts | 42 +++++++++++++++++++ apps/web/src/promptStashStore.ts | 36 +++++++++++++--- 5 files changed, 123 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 63375b6af31..39f06d62fc8 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2071,7 +2071,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) unreadableImageNames: [], pendingImageCount: images.length, }); - flushPromptStashStorage(); + + // Clearing the composer is only safe once the entry is durable. If the + // write was rejected (quota, blocked storage) the stash exists only in + // memory, so leave the composer untouched rather than making it the + // second casualty of a reload. + if (!flushPromptStashStorage()) { + takeStashEntry(scopeKey, entryId); + toastManager.add({ + type: "error", + title: "Could not stash this prompt", + description: + "Browser storage rejected the write, so the composer was left as-is. Free up site data and try again.", + data: { hideCopyButton: true }, + }); + return; + } // Only the prompt and images are cleared — terminal/element contexts, // preview annotations, and review comments are not stashable, so @@ -2118,12 +2133,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } const { kept, droppedNames } = partitionStashAttachments(candidateAttachments); - finalizeStashEntryImages(scopeKey, entryId, { + const finalized = finalizeStashEntryImages(scopeKey, entryId, { attachments: kept, droppedImageNames: [...oversizedImageNames, ...droppedNames], unreadableImageNames, }); - flushPromptStashStorage(); + if (finalized) { + flushPromptStashStorage(); + } else if (kept.length > 0) { + // The entry was restored or deleted before its images finished + // encoding, so they have nowhere to land. Say so rather than letting + // them evaporate. + toastManager.add({ + type: "warning", + title: "Stashed images did not attach", + 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"}.`, + data: { hideCopyButton: true }, + }); + } } finally { // Must clear on every path: a throw that left this set would wedge ⌘S // until the composer remounts. @@ -2141,6 +2168,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) stashEntryToQueue, stashProviderLabel, stashScopeInstanceId, + takeStashEntry, ]); const toggleStashMenu = useCallback(() => { diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index 22950074307..04ae5467cda 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -75,6 +75,12 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { if (event.target instanceof HTMLElement && event.target.closest("button[aria-label]")) { return; } + // Still encoding: restoring now would strand the pending images. + if (highlightedEntry?.pendingImageCount) { + event.preventDefault(); + event.stopPropagation(); + return; + } if (!highlightedEntry) return; event.preventDefault(); event.stopPropagation(); @@ -121,7 +127,11 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { onMouseDown={(event) => { event.preventDefault(); }} + aria-disabled={Boolean(entry.pendingImageCount)} onClick={() => { + // Restoring mid-encode would strand the images that are + // still being written into this entry. + if (entry.pendingImageCount) return; onRestore(entry); }} > diff --git a/apps/web/src/lib/stashImageCompression.ts b/apps/web/src/lib/stashImageCompression.ts index 33ccdeaef37..89cf89ef8e2 100644 --- a/apps/web/src/lib/stashImageCompression.ts +++ b/apps/web/src/lib/stashImageCompression.ts @@ -213,7 +213,16 @@ export async function compressImageForStash( const baseDimension = Math.min(MAX_DIMENSION, Math.max(bitmap.width, bitmap.height)); for (const dimensionScale of [1, ...FALLBACK_SCALE_STEPS]) { const targetDimension = Math.max(1, Math.round(baseDimension * dimensionScale)); - const encoded = await encodeWithinBudget(bitmap, targetDimension, budgetChars); + let encoded: { dataUrl: string; mimeType: string } | null; + try { + encoded = await encodeWithinBudget(bitmap, targetDimension, budgetChars); + } catch { + // Canvas allocation, drawing, or the codec itself can throw (OOM on a + // huge bitmap, a hostile decoder). Never let that escape: the caller + // finalizes the stash entry after this returns, and an exception here + // would strand the entry as permanently "still saving". + return { ok: false, reason: "unreadable" }; + } if (encoded && encoded.dataUrl.length <= budgetChars) { return { ok: true, diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index e94dd7c5889..d1d06a849c8 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -174,6 +174,48 @@ describe("promptStashStore", () => { expect(store.takeEntry("__proto__", "missing")).toBeNull(); }); + it("finalizeEntryImages attaches images and clears the pending count", () => { + const store = usePromptStashStore.getState(); + const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); + store.stashEntry({ ...makeEntry({ id: "pending" }), pendingImageCount: 2 }); + + const attached = store.finalizeEntryImages(scopeKey, "pending", { + attachments: [ + { + id: "img-1", + name: "a.webp", + mimeType: "image/webp", + sizeBytes: 10, + dataUrl: "data:image/webp;base64,AAAA", + }, + ], + droppedImageNames: ["big.png"], + unreadableImageNames: [], + }); + + expect(attached).toBe(true); + const entry = usePromptStashStore.getState().queuesByScopeKey[scopeKey]?.[0]; + expect(entry?.attachments).toHaveLength(1); + expect(entry?.droppedImageNames).toEqual(["big.png"]); + expect(entry?.pendingImageCount).toBe(0); + }); + + it("finalizeEntryImages reports false when the entry was already taken", () => { + const store = usePromptStashStore.getState(); + const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); + store.stashEntry({ ...makeEntry({ id: "racing" }), pendingImageCount: 1 }); + // Restored (or deleted) while its images were still encoding. + store.takeEntry(scopeKey, "racing"); + + const attached = store.finalizeEntryImages(scopeKey, "racing", { + attachments: [], + droppedImageNames: [], + unreadableImageNames: [], + }); + + expect(attached).toBe(false); + }); + it("drops the scope key entirely when its queue empties", () => { const store = usePromptStashStore.getState(); store.stashEntry(makeEntry({ id: "only" })); diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index 0a185148f49..05e8f1f4ca7 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -107,10 +107,17 @@ export function partitionStashAttachments( return { kept, droppedNames }; } +/** + * Tracks whether the most recent write actually reached disk. Callers clear + * the composer on the strength of a stash write, so "the write silently + * failed" has to be observable rather than only logged. + */ +let lastWriteFailed = false; + /** * Base64 image payloads can hit the origin's localStorage quota. A quota * failure must not become an uncaught exception inside the debounce timer: - * the in-memory queue still works for the session, so log and move on. + * the in-memory queue still works for the session, so record it and move on. */ function createQuotaSafeStorage(base: StateStorage): StateStorage { return { @@ -118,7 +125,9 @@ function createQuotaSafeStorage(base: StateStorage): StateStorage { setItem: (name, value) => { try { base.setItem(name, value); + lastWriteFailed = false; } catch (error) { + lastWriteFailed = true; console.error("[PROMPT-STASH] Could not persist stash (storage quota?).", error); } }, @@ -172,8 +181,9 @@ interface PromptStashStoreState { takeEntry: (scopeKey: string, entryId: string) => PromptStashEntry | null; /** * Attaches the encoded images to an entry written earlier by `stashEntry`, - * clearing its pending count. No-ops when the entry is gone (restored or - * deleted while encoding was still running). + * clearing its pending count. Returns false when the entry is gone (restored + * or deleted while encoding was still running) so the caller can tell the + * user their images did not make it. */ finalizeEntryImages: ( scopeKey: string, @@ -183,7 +193,7 @@ interface PromptStashStoreState { droppedImageNames: ReadonlyArray; unreadableImageNames: ReadonlyArray; }, - ) => void; + ) => boolean; } export const usePromptStashStore = create()( @@ -220,6 +230,12 @@ export const usePromptStashStore = create()( return entry; }, finalizeEntryImages: (scopeKey, entryId, images) => { + // Read before the update so the caller learns whether the entry + // survived long enough to receive its images. + const found = readQueue(get().queuesByScopeKey, scopeKey).some( + (candidate) => candidate.id === entryId, + ); + if (!found) return false; set((state) => { const queue = readQueue(state.queuesByScopeKey, scopeKey); const index = queue.findIndex((candidate) => candidate.id === entryId); @@ -238,6 +254,7 @@ export const usePromptStashStore = create()( nextQueue[index] = nextEntry; return { queuesByScopeKey: { ...state.queuesByScopeKey, [scopeKey]: nextQueue } }; }); + return true; }, }), { @@ -260,9 +277,16 @@ export const usePromptStashStore = create()( ), ); -/** Flushes pending stash writes immediately (e.g. right after a stash). */ -export function flushPromptStashStorage(): void { +/** + * Flushes pending stash writes immediately (e.g. right after a stash) and + * reports whether the write landed. Returns false when storage rejected it + * (quota, blocked storage), meaning the queue exists only in memory and will + * not survive a reload. + */ +export function flushPromptStashStorage(): boolean { + lastWriteFailed = false; promptStashDebouncedStorage.flush(); + return !lastWriteFailed; } export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray = []; From 9872f89e28c81b4635a4ef07c1b4ba9e8993b68f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 27 Jul 2026 03:10:14 -0700 Subject: [PATCH 5/8] fix(web): roll back the whole queue when a stash write is rejected Removing only the new entry left an older one permanently lost when the 20-entry cap had evicted it to make room. The queue is now snapshotted before the write and restored wholesale on failure. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/chat/ChatComposer.tsx | 11 ++++++++-- apps/web/src/promptStashStore.test.ts | 21 +++++++++++++++++++ apps/web/src/promptStashStore.ts | 20 ++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 39f06d62fc8..ca59d4c3c10 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1908,6 +1908,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry); const takeStashEntry = usePromptStashStore((state) => state.takeEntry); const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages); + const readStashQueueSnapshot = usePromptStashStore((state) => state.readQueueSnapshot); + const restoreStashQueueSnapshot = usePromptStashStore((state) => state.restoreQueueSnapshot); const stashProviderLabel = noProviderAvailable ? "No provider" : getProviderDisplayName(providerStatuses, selectedProvider); @@ -2054,6 +2056,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const stashTarget = composerDraftTarget; const entryId = randomUUID(); const scopeKey = promptStashScopeKey(stashScopeInstanceId); + // Captured before the write so a rejected persist can be rolled back + // whole. Removing just the new entry would strand an older one that the + // 20-entry cap evicted to make room for it. + const queueBeforeStash = readStashQueueSnapshot(scopeKey); try { // Persist the text-only entry *first*, then clear. Ordering matters in // both directions: writing before clearing means a crash or closed tab @@ -2077,7 +2083,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // memory, so leave the composer untouched rather than making it the // second casualty of a reload. if (!flushPromptStashStorage()) { - takeStashEntry(scopeKey, entryId); + restoreStashQueueSnapshot(scopeKey, queueBeforeStash); toastManager.add({ type: "error", title: "Could not stash this prompt", @@ -2164,11 +2170,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) noProviderAvailable, promptRef, pulseStashBadge, + readStashQueueSnapshot, + restoreStashQueueSnapshot, selectedModelSelection, stashEntryToQueue, stashProviderLabel, stashScopeInstanceId, - takeStashEntry, ]); const toggleStashMenu = useCallback(() => { diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index d1d06a849c8..7d20483c71b 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -216,6 +216,27 @@ describe("promptStashStore", () => { expect(attached).toBe(false); }); + it("restoreQueueSnapshot rolls back an eviction caused by a failed write", () => { + const store = usePromptStashStore.getState(); + const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); + for (let index = 0; index < MAX_STASH_ENTRIES_PER_QUEUE; index += 1) { + store.stashEntry(makeEntry({ id: `entry-${index}` })); + } + const snapshot = store.readQueueSnapshot(scopeKey); + + // A stash at the cap evicts the oldest entry... + const evicted = store.stashEntry(makeEntry({ id: "overflow" })); + expect(evicted?.id).toBe("entry-0"); + + // ...so a rejected write has to restore the whole queue, not just remove + // the new entry, or the evicted one is lost for nothing. + store.restoreQueueSnapshot(scopeKey, snapshot); + const queue = usePromptStashStore.getState().queuesByScopeKey[scopeKey] ?? []; + expect(queue.map((entry) => entry.id)).toEqual(snapshot.map((entry) => entry.id)); + expect(queue.some((entry) => entry.id === "entry-0")).toBe(true); + expect(queue.some((entry) => entry.id === "overflow")).toBe(false); + }); + it("drops the scope key entirely when its queue empties", () => { const store = usePromptStashStore.getState(); store.stashEntry(makeEntry({ id: "only" })); diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index 05e8f1f4ca7..39a0f1559db 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -179,6 +179,14 @@ interface PromptStashStoreState { stashEntry: (entry: PromptStashEntry) => PromptStashEntry | null; /** Removes and returns an entry from a scope's queue (restore + delete). */ takeEntry: (scopeKey: string, entryId: string) => PromptStashEntry | null; + /** Snapshot of a scope's queue, for rolling back a failed write. */ + readQueueSnapshot: (scopeKey: string) => ReadonlyArray; + /** + * Restores a queue captured by `readQueueSnapshot`. Used when a stash write + * is rejected by storage: rolling back only the new entry would leave an + * entry evicted by the cap permanently lost. + */ + restoreQueueSnapshot: (scopeKey: string, queue: ReadonlyArray) => void; /** * Attaches the encoded images to an entry written earlier by `stashEntry`, * clearing its pending count. Returns false when the entry is gone (restored @@ -229,6 +237,18 @@ export const usePromptStashStore = create()( }); return entry; }, + readQueueSnapshot: (scopeKey) => readQueue(get().queuesByScopeKey, scopeKey), + restoreQueueSnapshot: (scopeKey, queue) => { + set((state) => { + const nextQueues = { ...state.queuesByScopeKey }; + if (queue.length === 0) { + delete nextQueues[scopeKey]; + } else { + nextQueues[scopeKey] = [...queue]; + } + return { queuesByScopeKey: nextQueues }; + }); + }, finalizeEntryImages: (scopeKey, entryId, images) => { // Read before the update so the caller learns whether the entry // survived long enough to receive its images. From 5bfe69d383ebb42be1d699016b55b59cfc4094b2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 27 Jul 2026 03:28:20 -0700 Subject: [PATCH 6/8] fix(web): fix prompt stash issues found in self-audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clear pendingImageCount on rehydrate. A tab closed or reloaded during encoding left entries stuck showing "saving…" and refusing to restore forever, since the loop that clears the count does not survive a reload. - merge from storage before each queue mutation. Every write persisted the tab's whole map, so two tabs stashing would delete each other's entries; entries are now unioned by id with newest-first ordering. - report a rejected second-phase write. The images flush ignored its result, so quota failure left disk holding the phase-one entry with a pending count and no warning. - key the in-flight guard by target+prompt+images rather than a bare boolean, so a genuinely new prompt (or a different thread) can still be stashed while an earlier encode runs. - dedupe restore capacity the same way the draft store does (mimeType+sizeBytes+name). Counting a metadata duplicate against capacity burned a slot the store then refused to fill, pushing a unique image into overflow for nothing. - stop appending blank lines and moving the caret when an image-only stash is restored into a non-empty composer. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/chat/ChatComposer.tsx | 93 ++++++++++---- apps/web/src/promptStashStore.test.ts | 38 +++++- apps/web/src/promptStashStore.ts | 116 +++++++++++++++++- 3 files changed, 218 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ca59d4c3c10..505d771bc72 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1008,8 +1008,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const dragDepthRef = useRef(0); const stashPulseKeyRef = useRef(0); const stashPulseTimeoutRef = useRef(null); - /** Guards against a second ⌘S landing while image encoding is in flight. */ - const stashInFlightRef = useRef(false); + /** + * Snapshots currently being encoded, keyed by target+prompt+image ids. + * Keyed rather than boolean so a genuinely different prompt (or a different + * thread) can still be stashed while an earlier encode is running. + */ + const stashInFlightRef = useRef>(new Set()); // ------------------------------------------------------------------ // Derived: composer send state @@ -1944,23 +1948,45 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsStashMenuOpen(false); const currentPrompt = promptRef.current; - const nextPrompt = currentPrompt.trim().length - ? `${currentPrompt.replace(/\s+$/, "")}\n\n${entry.prompt}` - : entry.prompt; - promptRef.current = nextPrompt; - setComposerDraftPrompt(composerDraftTarget, nextPrompt); - const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); - setComposerCursor(nextCursor); - setComposerTrigger(null); + // An image-only stash must not append blank lines to whatever is + // already in the composer. + const nextPrompt = + entry.prompt.length === 0 + ? currentPrompt + : currentPrompt.trim().length + ? `${currentPrompt.replace(/\s+$/, "")}\n\n${entry.prompt}` + : entry.prompt; + const promptChanged = nextPrompt !== currentPrompt; + if (promptChanged) { + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); + setComposerTrigger(null); + } let unrestoredImageNames: string[] = []; if (entry.attachments.length > 0) { const existingIds = new Set(composerImagesRef.current.map((image) => image.id)); + // The draft store also dedupes by mimeType+sizeBytes+name, so filter + // on the same key here. Counting a duplicate against capacity would + // burn a slot the store then refuses to fill, pushing a genuinely + // unique image into the overflow list for nothing. + const existingDedupKeys = new Set( + composerImagesRef.current.map( + (image) => `${image.mimeType}${image.sizeBytes}${image.name}`, + ), + ); const capacity = Math.max( 0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - composerImagesRef.current.length, ); - const pending = entry.attachments.filter((attachment) => !existingIds.has(attachment.id)); + const pending = entry.attachments.filter( + (attachment) => + !existingIds.has(attachment.id) && + !existingDedupKeys.has( + `${attachment.mimeType}${attachment.sizeBytes}${attachment.name}`, + ), + ); // Anything past the attachment limit cannot be restored. The entry is // already out of the queue, so report the overflow by name instead of // discarding it silently. @@ -2014,9 +2040,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); } - window.requestAnimationFrame(() => { - composerEditorRef.current?.focusAtEnd(); - }); + // Only yank the caret to the end when text was actually inserted; + // restoring images alone should leave the user where they were typing. + if (promptChanged) { + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAtEnd(); + }); + } }, [ addComposerDraftImages, @@ -2047,11 +2077,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsStashMenuOpen((open) => !open); return; } - // A repeat ⌘S while encoding is still running would stash the same - // snapshot twice. The composer is already cleared by then, so the second - // press has nothing new to save. - if (stashInFlightRef.current) return; - stashInFlightRef.current = true; + // A repeat ⌘S on the *same* still-unencoded snapshot would stash it + // twice. Guard on the snapshot itself rather than a bare boolean: once + // the composer has been cleared the user can type something genuinely + // new (or switch threads) while encoding continues, and that deserves its + // own entry. + const snapshotKey = `${String(composerDraftTarget)}${prompt}${images + .map((image) => image.id) + .join(",")}`; + if (stashInFlightRef.current.has(snapshotKey)) return; + stashInFlightRef.current.add(snapshotKey); const stashTarget = composerDraftTarget; const entryId = randomUUID(); @@ -2145,7 +2180,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) unreadableImageNames, }); if (finalized) { - flushPromptStashStorage(); + // The second phase can be rejected on its own: the text-only entry + // fit, but adding image payloads pushed past the quota. Disk would + // then still hold the phase-one entry with pendingImageCount set, + // which reads as an orphan after reload — so say so now. + if (!flushPromptStashStorage() && images.length > 0) { + toastManager.add({ + type: "warning", + title: "Stashed images were not saved", + description: + "The prompt was stashed, but browser storage rejected its images. They will be missing if you reload.", + data: { hideCopyButton: true }, + }); + } } else if (kept.length > 0) { // The entry was restored or deleted before its images finished // encoding, so they have nowhere to land. Say so rather than letting @@ -2158,9 +2205,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); } } finally { - // Must clear on every path: a throw that left this set would wedge ⌘S - // until the composer remounts. - stashInFlightRef.current = false; + // Must clear on every path: a throw that left this set would wedge this + // snapshot's ⌘S until the composer remounts. + stashInFlightRef.current.delete(snapshotKey); } }, [ clearComposerDraftPromptAndImages, diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index 7d20483c71b..76654070de2 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -1,18 +1,23 @@ import { ProviderInstanceId } from "@t3tools/contracts"; -import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +import { removeLocalStorageItem } from "./hooks/useLocalStorage"; import { MAX_STASH_ENTRIES_PER_QUEUE, + PROMPT_STASH_STORAGE_KEY, MAX_STASH_ENTRY_ATTACHMENT_CHARS, PROMPT_STASH_UNSCOPED_KEY, partitionStashAttachments, promptStashScopeKey, usePromptStashStore, + writePromptStashStorageForTest, type PromptStashEntry, } from "./promptStashStore"; const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent"); const CODEX_INSTANCE = ProviderInstanceId.make("codex"); +const OLDER_TIMESTAMP = "2026-07-24T11:00:00.000Z"; function makeEntry(input: { id: string; @@ -45,6 +50,8 @@ function makeEntry(input: { function resetPromptStashStore() { usePromptStashStore.setState({ queuesByScopeKey: {} }); + writePromptStashStorageForTest(""); + removeLocalStorageItem(PROMPT_STASH_STORAGE_KEY); } describe("promptStashScopeKey", () => { @@ -110,6 +117,10 @@ describe("promptStashStore", () => { resetPromptStashStore(); }); + afterEach(() => { + resetPromptStashStore(); + }); + it("prepends entries so the newest stash is first", () => { const store = usePromptStashStore.getState(); store.stashEntry(makeEntry({ id: "first" })); @@ -237,6 +248,31 @@ describe("promptStashStore", () => { expect(queue.some((entry) => entry.id === "overflow")).toBe(false); }); + it("merges another tab's entries instead of overwriting them", () => { + const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); + // Simulate a second tab having written an entry after this tab hydrated, + // through the same storage the module reads (the node test project has no + // localStorage global, so it resolves to the in-memory fallback). + writePromptStashStorageForTest( + JSON.stringify({ + version: 1, + state: { + queuesByScopeKey: { + [scopeKey]: [{ ...makeEntry({ id: "from-other-tab" }), createdAt: OLDER_TIMESTAMP }], + }, + }, + }), + ); + + usePromptStashStore.getState().stashEntry(makeEntry({ id: "from-this-tab" })); + + const ids = (usePromptStashStore.getState().queuesByScopeKey[scopeKey] ?? []).map( + (entry) => entry.id, + ); + expect(ids).toContain("from-other-tab"); + expect(ids).toContain("from-this-tab"); + }); + it("drops the scope key entirely when its queue empties", () => { const store = usePromptStashStore.getState(); store.stashEntry(makeEntry({ id: "only" })); diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index 39a0f1559db..8893599b08e 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -65,6 +65,40 @@ type PersistedPromptStashState = typeof PersistedPromptStashState.Type; const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPromptStashState); +/** + * `pendingImageCount` only has meaning within the session that wrote it: the + * encode loop that would clear it does not survive a reload. Any entry that + * comes back from storage still pending was orphaned by a closed tab or a + * crash mid-encode, so the count is settled here — otherwise the entry would + * be stuck showing "saving…" and refuse to restore forever. + * + * The images are genuinely gone (they were never written), so they are + * recorded as unreadable to keep the prompt itself restorable. + */ +function clearOrphanedPendingImages( + queues: Record>, +): Record> { + const next: Record> = {}; + for (const [scopeKey, queue] of Object.entries(queues)) { + next[scopeKey] = queue.map((entry) => { + if (!entry.pendingImageCount) return entry; + const lostCount = entry.pendingImageCount; + return { + ...entry, + pendingImageCount: 0, + unreadableImageNames: [ + ...(entry.unreadableImageNames ?? []), + ...Array.from( + { length: lostCount }, + (_, index) => `image ${index + 1} (not saved before reload)`, + ), + ], + }; + }); + } + return next; +} + /** Maps the composer's active provider instance to a stash queue bucket. */ export function promptStashScopeKey(instanceId: ProviderInstanceId | null | undefined): string { return instanceId ? `${PROVIDER_SCOPE_PREFIX}${instanceId}` : PROMPT_STASH_UNSCOPED_KEY; @@ -159,8 +193,10 @@ function resolveBaseStorage(): StateStorage { return createMemoryStorage(); } +const baseStashStorage = resolveBaseStorage(); + const promptStashDebouncedStorage = createDebouncedStorage( - createQuotaSafeStorage(resolveBaseStorage()), + createQuotaSafeStorage(baseStashStorage), PROMPT_STASH_PERSIST_DEBOUNCE_MS, ); @@ -170,6 +206,61 @@ if (typeof window !== "undefined" && typeof window.addEventListener === "functio }); } +/** + * Reads the queues currently on disk. Every mutation persists this tab's whole + * `queuesByScopeKey`, so acting on a stale in-memory copy would silently drop + * entries written by another tab. Callers re-read first and merge. + */ +function readPersistedQueues(): Record> | null { + try { + // Read the backing store directly: the debounced wrapper's getItem is + // typed as possibly-async, and this has to resolve synchronously inside a + // store mutation. + const raw = baseStashStorage.getItem(PROMPT_STASH_STORAGE_KEY); + if (typeof raw !== "string" || raw.length === 0) return null; + const parsed: unknown = JSON.parse(raw); + const state = (parsed as { state?: unknown } | null)?.state; + if (!state) return null; + return decodePersistedPromptStashState(state).queuesByScopeKey; + } catch { + return null; + } +} + +/** + * Union of the on-disk queues and this tab's, keyed by entry id. Entries are + * immutable once finalized, so a plain id-union is sufficient: whichever copy + * has images attached wins, and ordering stays newest-first by `createdAt`. + */ +function mergeQueuesWithStorage( + local: Record>, +): Record> { + const persisted = readPersistedQueues(); + if (!persisted) return local; + + const merged: Record> = { ...local }; + for (const scopeKey of new Set([...Object.keys(persisted), ...Object.keys(local)])) { + const fromDisk = readQueue(persisted, scopeKey); + const fromMemory = readQueue(local, scopeKey); + const byId = new Map(); + for (const entry of fromDisk) byId.set(entry.id, entry); + for (const entry of fromMemory) { + const existing = byId.get(entry.id); + // Prefer whichever copy already has its images attached. + if (!existing || !entry.pendingImageCount) byId.set(entry.id, entry); + } + const combined = [...byId.values()].sort((left, right) => + right.createdAt.localeCompare(left.createdAt), + ); + if (combined.length === 0) { + delete merged[scopeKey]; + } else { + merged[scopeKey] = combined.slice(0, MAX_STASH_ENTRIES_PER_QUEUE); + } + } + return merged; +} + interface PromptStashStoreState { queuesByScopeKey: Record>; /** @@ -210,12 +301,15 @@ export const usePromptStashStore = create()( queuesByScopeKey: {}, stashEntry: (entry) => { const scopeKey = promptStashScopeKey(entry.providerInstanceId); - const queue = readQueue(get().queuesByScopeKey, scopeKey); + // Re-read from storage first: another tab may have stashed since this + // one hydrated, and persisting our whole map would erase its entries. + const mergedQueues = mergeQueuesWithStorage(get().queuesByScopeKey); + const queue = readQueue(mergedQueues, scopeKey); const nextQueue = [entry, ...queue]; const evicted = nextQueue.length > MAX_STASH_ENTRIES_PER_QUEUE ? (nextQueue.pop() ?? null) : null; - set((state) => ({ - queuesByScopeKey: { ...state.queuesByScopeKey, [scopeKey]: nextQueue }, + set(() => ({ + queuesByScopeKey: { ...mergedQueues, [scopeKey]: nextQueue }, })); return evicted; }, @@ -287,7 +381,10 @@ export const usePromptStashStore = create()( merge: (persistedState, currentState) => { try { const decoded = decodePersistedPromptStashState(persistedState); - return { ...currentState, queuesByScopeKey: { ...decoded.queuesByScopeKey } }; + return { + ...currentState, + queuesByScopeKey: clearOrphanedPendingImages(decoded.queuesByScopeKey), + }; } catch { // Corrupt or incompatible payload: start empty rather than crash. return currentState; @@ -310,3 +407,12 @@ export function flushPromptStashStorage(): boolean { } export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray = []; + +/** + * Test seam: writes the raw persisted payload through the same storage the + * store reads, so cross-tab merging can be exercised without a real + * `localStorage` global. + */ +export function writePromptStashStorageForTest(raw: string): void { + baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw); +} From f0abec9cf634b56c8b4d0926dbbdea44da59bea5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 27 Jul 2026 05:37:41 -0700 Subject: [PATCH 7/8] fix(web): make stash persistence a synchronous read-modify-write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The id-union merge added in the previous round could not distinguish "another tab created this entry" from "this tab deleted it", so deletes were resurrected, and its debounced whole-map writes still let concurrent tabs clobber each other. Replaces it with a read-modify-write against storage on every mutation, which removes the ambiguity structurally: - every mutation reads live disk state, applies its change, and writes back synchronously. Stashing is a deliberate keystroke, not a per-character autosave, so there is nothing to coalesce and the debounce window was pure race surface. - takeEntry and finalizeEntryImages go through the same path; previously only stashEntry re-read, so a restore or delete in a stale tab erased another tab's newer stashes. - drops the zustand persist middleware (writes are direct now) and adds a `storage` event listener so a tab follows another tab's changes. - settles stale pendingImageCount on every read, not just hydration, and stops blocking restore on it — orphans are now recoverable text. - reports durability separately from write success, so the in-memory fallback no longer claims a stash will survive a reload. - surfaces rejected writes on delete and restore, which previously ignored the result and could resurrect or duplicate entries. - keeps trying smaller fallback scales when an encode pass throws, since the throw is often caused by the target size itself. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/chat/ChatComposer.tsx | 53 +-- .../src/components/chat/ComposerStashMenu.tsx | 10 - apps/web/src/lib/stashImageCompression.ts | 20 +- apps/web/src/promptStashStore.test.ts | 101 +++-- apps/web/src/promptStashStore.ts | 366 ++++++++---------- 5 files changed, 264 insertions(+), 286 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 505d771bc72..866ed0c9b8c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -58,7 +58,6 @@ import { } from "../../composerDraftStore"; import { EMPTY_PROMPT_STASH_QUEUE, - flushPromptStashStorage, MAX_STASH_ENTRIES_PER_QUEUE, partitionStashAttachments, promptStashScopeKey, @@ -1912,8 +1911,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry); const takeStashEntry = usePromptStashStore((state) => state.takeEntry); const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages); - const readStashQueueSnapshot = usePromptStashStore((state) => state.readQueueSnapshot); - const restoreStashQueueSnapshot = usePromptStashStore((state) => state.restoreQueueSnapshot); const stashProviderLabel = noProviderAvailable ? "No provider" : getProviderDisplayName(providerStatuses, selectedProvider); @@ -1942,9 +1939,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const restoreStashEntry = useCallback( (entry: PromptStashEntry) => { // Remove first so a double activation (click + Enter) can't restore twice. - const taken = takeStashEntry(promptStashScopeKey(entry.providerInstanceId), entry.id); + const { entry: taken, durable } = takeStashEntry( + promptStashScopeKey(entry.providerInstanceId), + entry.id, + ); if (!taken) return; - flushPromptStashStorage(); + if (!durable) { + toastManager.add({ + type: "warning", + title: "Restored prompt may reappear in the stash", + description: + "Browser storage rejected the update, so this entry could still be there after a reload.", + data: { hideCopyButton: true }, + }); + } setIsStashMenuOpen(false); const currentPrompt = promptRef.current; @@ -2062,8 +2070,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const deleteStashEntry = useCallback( (entry: PromptStashEntry) => { - takeStashEntry(promptStashScopeKey(entry.providerInstanceId), entry.id); - flushPromptStashStorage(); + const { durable } = takeStashEntry(promptStashScopeKey(entry.providerInstanceId), entry.id); + if (!durable) { + toastManager.add({ + type: "warning", + title: "Stash entry may come back", + description: + "Browser storage rejected the delete, so this prompt could reappear after a reload.", + data: { hideCopyButton: true }, + }); + } }, [takeStashEntry], ); @@ -2091,17 +2107,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const stashTarget = composerDraftTarget; const entryId = randomUUID(); const scopeKey = promptStashScopeKey(stashScopeInstanceId); - // Captured before the write so a rejected persist can be rolled back - // whole. Removing just the new entry would strand an older one that the - // 20-entry cap evicted to make room for it. - const queueBeforeStash = readStashQueueSnapshot(scopeKey); try { // Persist the text-only entry *first*, then clear. Ordering matters in // both directions: writing before clearing means a crash or closed tab // mid-encode still leaves the prompt recoverable, while clearing before // the async image work means edits typed during encoding are not wiped. // Images are appended to the stored entry as they finish encoding. - const evicted = stashEntryToQueue({ + const { evicted, durable } = stashEntryToQueue({ id: entryId, createdAt: new Date().toISOString(), prompt, @@ -2114,11 +2126,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); // Clearing the composer is only safe once the entry is durable. If the - // write was rejected (quota, blocked storage) the stash exists only in - // memory, so leave the composer untouched rather than making it the - // second casualty of a reload. - if (!flushPromptStashStorage()) { - restoreStashQueueSnapshot(scopeKey, queueBeforeStash); + // write was rejected (quota, blocked storage) the store has already + // rolled itself back, so leave the composer untouched rather than + // making it the second casualty of a reload. + if (!durable) { toastManager.add({ type: "error", title: "Could not stash this prompt", @@ -2174,17 +2185,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } const { kept, droppedNames } = partitionStashAttachments(candidateAttachments); - const finalized = finalizeStashEntryImages(scopeKey, entryId, { + const { attached, durable: imagesDurable } = finalizeStashEntryImages(scopeKey, entryId, { attachments: kept, droppedImageNames: [...oversizedImageNames, ...droppedNames], unreadableImageNames, }); - if (finalized) { + if (attached) { // The second phase can be rejected on its own: the text-only entry // fit, but adding image payloads pushed past the quota. Disk would // then still hold the phase-one entry with pendingImageCount set, // which reads as an orphan after reload — so say so now. - if (!flushPromptStashStorage() && images.length > 0) { + if (!imagesDurable && images.length > 0) { toastManager.add({ type: "warning", title: "Stashed images were not saved", @@ -2217,8 +2228,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) noProviderAvailable, promptRef, pulseStashBadge, - readStashQueueSnapshot, - restoreStashQueueSnapshot, selectedModelSelection, stashEntryToQueue, stashProviderLabel, diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index 04ae5467cda..22950074307 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -75,12 +75,6 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { if (event.target instanceof HTMLElement && event.target.closest("button[aria-label]")) { return; } - // Still encoding: restoring now would strand the pending images. - if (highlightedEntry?.pendingImageCount) { - event.preventDefault(); - event.stopPropagation(); - return; - } if (!highlightedEntry) return; event.preventDefault(); event.stopPropagation(); @@ -127,11 +121,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { onMouseDown={(event) => { event.preventDefault(); }} - aria-disabled={Boolean(entry.pendingImageCount)} onClick={() => { - // Restoring mid-encode would strand the images that are - // still being written into this entry. - if (entry.pendingImageCount) return; onRestore(entry); }} > diff --git a/apps/web/src/lib/stashImageCompression.ts b/apps/web/src/lib/stashImageCompression.ts index 89cf89ef8e2..7f133fb2926 100644 --- a/apps/web/src/lib/stashImageCompression.ts +++ b/apps/web/src/lib/stashImageCompression.ts @@ -211,18 +211,26 @@ export async function compressImageForStash( // images already smaller than that ceiling — the fallback passes would // all resolve to the source size and never actually reduce resolution. const baseDimension = Math.min(MAX_DIMENSION, Math.max(bitmap.width, bitmap.height)); + // Tracks whether the *last* attempt threw, so a run of encoder failures + // is reported as unreadable while a run of merely-too-big results is + // reported as too-large. + let encodeFailed = false; for (const dimensionScale of [1, ...FALLBACK_SCALE_STEPS]) { const targetDimension = Math.max(1, Math.round(baseDimension * dimensionScale)); let encoded: { dataUrl: string; mimeType: string } | null; try { encoded = await encodeWithinBudget(bitmap, targetDimension, budgetChars); } catch { - // Canvas allocation, drawing, or the codec itself can throw (OOM on a - // huge bitmap, a hostile decoder). Never let that escape: the caller - // finalizes the stash entry after this returns, and an exception here - // would strand the entry as permanently "still saving". - return { ok: false, reason: "unreadable" }; + // Canvas allocation, drawing, or the codec itself can throw — often + // precisely *because* the target is too big (OOM on a large bitmap). + // Keep trying the smaller fallback scales rather than giving up: a + // reduced pass may well succeed. The exception must never escape, + // though, since the caller finalizes the entry after this returns and + // a throw would strand it as permanently "still saving". + encodeFailed = true; + continue; } + encodeFailed = false; if (encoded && encoded.dataUrl.length <= budgetChars) { return { ok: true, @@ -235,7 +243,7 @@ export async function compressImageForStash( }; } } - return { ok: false, reason: "too-large" }; + return { ok: false, reason: encodeFailed ? "unreadable" : "too-large" }; } finally { bitmap.close(); } diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index 76654070de2..155e5ed3ab2 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -149,9 +149,9 @@ describe("promptStashStore", () => { it("evicts the oldest entry past the per-queue cap and returns it", () => { const store = usePromptStashStore.getState(); for (let index = 0; index < MAX_STASH_ENTRIES_PER_QUEUE; index += 1) { - expect(store.stashEntry(makeEntry({ id: `entry-${index}` }))).toBeNull(); + expect(store.stashEntry(makeEntry({ id: `entry-${index}` })).evicted).toBeNull(); } - const evicted = store.stashEntry(makeEntry({ id: "overflow" })); + const { evicted } = store.stashEntry(makeEntry({ id: "overflow" })); expect(evicted?.id).toBe("entry-0"); const queue = usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ?? @@ -164,8 +164,10 @@ describe("promptStashStore", () => { const store = usePromptStashStore.getState(); store.stashEntry(makeEntry({ id: "keep" })); store.stashEntry(makeEntry({ id: "take" })); - expect(store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "take")?.id).toBe("take"); - expect(store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "take")).toBeNull(); + expect(store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "take").entry?.id).toBe( + "take", + ); + expect(store.takeEntry(promptStashScopeKey(CLAUDE_AGENT_INSTANCE), "take").entry).toBeNull(); const queue = usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ?? []; @@ -182,7 +184,7 @@ describe("promptStashStore", () => { }); const store = usePromptStashStore.getState(); expect(() => store.takeEntry("__proto__", "missing")).not.toThrow(); - expect(store.takeEntry("__proto__", "missing")).toBeNull(); + expect(store.takeEntry("__proto__", "missing").entry).toBeNull(); }); it("finalizeEntryImages attaches images and clears the pending count", () => { @@ -190,7 +192,7 @@ describe("promptStashStore", () => { const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); store.stashEntry({ ...makeEntry({ id: "pending" }), pendingImageCount: 2 }); - const attached = store.finalizeEntryImages(scopeKey, "pending", { + const { attached } = store.finalizeEntryImages(scopeKey, "pending", { attachments: [ { id: "img-1", @@ -218,7 +220,7 @@ describe("promptStashStore", () => { // Restored (or deleted) while its images were still encoding. store.takeEntry(scopeKey, "racing"); - const attached = store.finalizeEntryImages(scopeKey, "racing", { + const { attached } = store.finalizeEntryImages(scopeKey, "racing", { attachments: [], droppedImageNames: [], unreadableImageNames: [], @@ -227,50 +229,71 @@ describe("promptStashStore", () => { expect(attached).toBe(false); }); - it("restoreQueueSnapshot rolls back an eviction caused by a failed write", () => { - const store = usePromptStashStore.getState(); + /** Writes a queue straight to storage, as another tab would. */ + function seedStorage(scopeKey: string, entries: ReadonlyArray) { + writePromptStashStorageForTest( + JSON.stringify({ version: 1, state: { queuesByScopeKey: { [scopeKey]: entries } } }), + ); + } + + it("keeps another tab's entries when stashing", () => { const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); - for (let index = 0; index < MAX_STASH_ENTRIES_PER_QUEUE; index += 1) { - store.stashEntry(makeEntry({ id: `entry-${index}` })); - } - const snapshot = store.readQueueSnapshot(scopeKey); + seedStorage(scopeKey, [{ ...makeEntry({ id: "from-other-tab" }), createdAt: OLDER_TIMESTAMP }]); - // A stash at the cap evicts the oldest entry... - const evicted = store.stashEntry(makeEntry({ id: "overflow" })); - expect(evicted?.id).toBe("entry-0"); + usePromptStashStore.getState().stashEntry(makeEntry({ id: "from-this-tab" })); - // ...so a rejected write has to restore the whole queue, not just remove - // the new entry, or the evicted one is lost for nothing. - store.restoreQueueSnapshot(scopeKey, snapshot); - const queue = usePromptStashStore.getState().queuesByScopeKey[scopeKey] ?? []; - expect(queue.map((entry) => entry.id)).toEqual(snapshot.map((entry) => entry.id)); - expect(queue.some((entry) => entry.id === "entry-0")).toBe(true); - expect(queue.some((entry) => entry.id === "overflow")).toBe(false); + const ids = (usePromptStashStore.getState().queuesByScopeKey[scopeKey] ?? []).map( + (entry) => entry.id, + ); + expect(ids).toEqual(["from-this-tab", "from-other-tab"]); }); - it("merges another tab's entries instead of overwriting them", () => { + // The previous id-union merge could not tell "another tab created this" + // from "this tab deleted it", so deletes came back. Reading disk as the + // source of truth removes the ambiguity. + it("does not resurrect an entry another tab deleted", () => { const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); - // Simulate a second tab having written an entry after this tab hydrated, - // through the same storage the module reads (the node test project has no - // localStorage global, so it resolves to the in-memory fallback). - writePromptStashStorageForTest( - JSON.stringify({ - version: 1, - state: { - queuesByScopeKey: { - [scopeKey]: [{ ...makeEntry({ id: "from-other-tab" }), createdAt: OLDER_TIMESTAMP }], - }, - }, - }), + const store = usePromptStashStore.getState(); + store.stashEntry(makeEntry({ id: "doomed" })); + // The other tab deletes it and writes the emptied queue. + seedStorage(scopeKey, []); + + store.stashEntry(makeEntry({ id: "fresh" })); + + const ids = (usePromptStashStore.getState().queuesByScopeKey[scopeKey] ?? []).map( + (entry) => entry.id, ); + expect(ids).toEqual(["fresh"]); + expect(ids).not.toContain("doomed"); + }); - usePromptStashStore.getState().stashEntry(makeEntry({ id: "from-this-tab" })); + it("does not clobber another tab's finalized images when taking an entry", () => { + const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); + const store = usePromptStashStore.getState(); + store.stashEntry(makeEntry({ id: "mine" })); + // Another tab adds an entry after this one's in-memory state was built. + seedStorage(scopeKey, [ + { ...makeEntry({ id: "mine" }), createdAt: OLDER_TIMESTAMP }, + { ...makeEntry({ id: "theirs" }), createdAt: OLDER_TIMESTAMP }, + ]); + + store.takeEntry(scopeKey, "mine"); const ids = (usePromptStashStore.getState().queuesByScopeKey[scopeKey] ?? []).map( (entry) => entry.id, ); - expect(ids).toContain("from-other-tab"); - expect(ids).toContain("from-this-tab"); + expect(ids).toEqual(["theirs"]); + }); + + it("settles a pending count left behind by a closed tab", () => { + const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); + seedStorage(scopeKey, [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }]); + + // Any read of persisted state must settle the stale count, or the entry + // would stay stuck showing "saving…" and refuse to restore. + const entry = usePromptStashStore.getState().queuesByScopeKey[scopeKey]?.[0]; + expect(entry?.pendingImageCount).toBe(0); + expect(entry?.unreadableImageNames).toHaveLength(2); }); it("drops the scope key entirely when its queue empties", () => { diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index 8893599b08e..895a19f12e6 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -1,14 +1,12 @@ import { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; import { PersistedComposerImageAttachment } from "./composerDraftStore"; -import { createDebouncedStorage, createMemoryStorage, type StateStorage } from "./lib/storage"; +import { createMemoryStorage, type StateStorage } from "./lib/storage"; export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1"; const PROMPT_STASH_STORAGE_VERSION = 1; -const PROMPT_STASH_PERSIST_DEBOUNCE_MS = 300; /** * Queue bucket for prompts stashed while no provider instance is selected. @@ -141,75 +139,36 @@ export function partitionStashAttachments( return { kept, droppedNames }; } -/** - * Tracks whether the most recent write actually reached disk. Callers clear - * the composer on the strength of a stash write, so "the write silently - * failed" has to be observable rather than only logged. - */ -let lastWriteFailed = false; - -/** - * Base64 image payloads can hit the origin's localStorage quota. A quota - * failure must not become an uncaught exception inside the debounce timer: - * the in-memory queue still works for the session, so record it and move on. - */ -function createQuotaSafeStorage(base: StateStorage): StateStorage { - return { - getItem: (name) => base.getItem(name), - setItem: (name, value) => { - try { - base.setItem(name, value); - lastWriteFailed = false; - } catch (error) { - lastWriteFailed = true; - console.error("[PROMPT-STASH] Could not persist stash (storage quota?).", error); - } - }, - removeItem: (name) => { - try { - base.removeItem(name); - } catch (error) { - console.error("[PROMPT-STASH] Could not remove stash entry.", error); - } - }, - }; -} - /** * Reading the `localStorage` property itself can throw `SecurityError` when * storage is blocked by policy or the page is a sandboxed iframe — so the * access has to be guarded, not just the get/set calls on it. Otherwise * importing this module would crash the app at load. + * + * `durable` is false for the in-memory fallback: writes there "succeed" but + * vanish on reload, and callers clear the composer on the strength of a + * successful stash, so they must be told the difference. */ -function resolveBaseStorage(): StateStorage { +function resolveBaseStorage(): { storage: StateStorage; durable: boolean } { try { if (typeof localStorage !== "undefined") { - return localStorage; + return { storage: localStorage, durable: true }; } } catch { - // Fall through to the in-memory store: the stash still works for the - // session, it just will not survive a reload. + // Fall through to the in-memory store. } - return createMemoryStorage(); + return { storage: createMemoryStorage(), durable: false }; } -const baseStashStorage = resolveBaseStorage(); - -const promptStashDebouncedStorage = createDebouncedStorage( - createQuotaSafeStorage(baseStashStorage), - PROMPT_STASH_PERSIST_DEBOUNCE_MS, -); - -if (typeof window !== "undefined" && typeof window.addEventListener === "function") { - window.addEventListener("beforeunload", () => { - promptStashDebouncedStorage.flush(); - }); -} +const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStorage(); /** - * Reads the queues currently on disk. Every mutation persists this tab's whole - * `queuesByScopeKey`, so acting on a stale in-memory copy would silently drop - * entries written by another tab. Callers re-read first and merge. + * Reads the queues currently on disk, settling any stale pending counts. + * + * Disk — not this tab's memory — is the source of truth for every mutation. + * A union of the two could never distinguish "another tab added this entry" + * from "this tab deleted it", which would resurrect deletions; reading the + * live state and mutating *that* sidesteps the question entirely. */ function readPersistedQueues(): Record> | null { try { @@ -221,44 +180,63 @@ function readPersistedQueues(): Record> const parsed: unknown = JSON.parse(raw); const state = (parsed as { state?: unknown } | null)?.state; if (!state) return null; - return decodePersistedPromptStashState(state).queuesByScopeKey; + return clearOrphanedPendingImages(decodePersistedPromptStashState(state).queuesByScopeKey); } catch { return null; } } -/** - * Union of the on-disk queues and this tab's, keyed by entry id. Entries are - * immutable once finalized, so a plain id-union is sufficient: whichever copy - * has images attached wins, and ordering stays newest-first by `createdAt`. - */ -function mergeQueuesWithStorage( - local: Record>, -): Record> { - const persisted = readPersistedQueues(); - if (!persisted) return local; - - const merged: Record> = { ...local }; - for (const scopeKey of new Set([...Object.keys(persisted), ...Object.keys(local)])) { - const fromDisk = readQueue(persisted, scopeKey); - const fromMemory = readQueue(local, scopeKey); - const byId = new Map(); - for (const entry of fromDisk) byId.set(entry.id, entry); - for (const entry of fromMemory) { - const existing = byId.get(entry.id); - // Prefer whichever copy already has its images attached. - if (!existing || !entry.pendingImageCount) byId.set(entry.id, entry); - } - const combined = [...byId.values()].sort((left, right) => - right.createdAt.localeCompare(left.createdAt), +/** Serializes queues in the exact envelope zustand's persist middleware uses. */ +function writePersistedQueues(queues: Record>): boolean { + try { + baseStashStorage.setItem( + PROMPT_STASH_STORAGE_KEY, + JSON.stringify({ + version: PROMPT_STASH_STORAGE_VERSION, + state: { queuesByScopeKey: queues }, + }), ); - if (combined.length === 0) { - delete merged[scopeKey]; - } else { - merged[scopeKey] = combined.slice(0, MAX_STASH_ENTRIES_PER_QUEUE); - } + return true; + } catch (error) { + console.error("[PROMPT-STASH] Could not persist stash (storage quota?).", error); + return false; } - return merged; +} + +/** + * Applies `mutate` to the queues currently on disk and writes the result back + * synchronously, so concurrent tabs cannot clobber each other: each mutation + * starts from whatever the other tab last wrote. + * + * Writes are immediate rather than debounced. Stashing is a deliberate, + * infrequent keystroke — not a per-character autosave — so there is nothing to + * coalesce, and a debounce window is exactly where cross-tab races live. + * + * Returns the mutation's own result plus whether the write reached disk. + */ +function commitQueues( + localQueues: Record>, + mutate: (queues: Record>) => { + next: Record>; + result: T; + }, +): { + next: Record>; + result: T; + /** The write itself succeeded (possibly only in memory). */ + written: boolean; + /** The write will survive a reload. */ + durable: boolean; +} { + // Fall back to this tab's state only when there is nothing readable on disk + // (first write of the session, blocked storage, corrupt payload). + const base = readPersistedQueues() ?? localQueues; + const { next, result } = mutate(base); + // The write still happens against the in-memory fallback so the stash works + // within the session; `durable` reports only whether it will survive a + // reload, which is what callers gate composer-clearing on. + const written = writePersistedQueues(next); + return { next, result, written, durable: written && storageIsDurable }; } interface PromptStashStoreState { @@ -267,22 +245,25 @@ interface PromptStashStoreState { * Prepends an entry to its scope's queue, evicting the oldest entry past * the per-queue cap. Returns the evicted entry (for messaging) if any. */ - stashEntry: (entry: PromptStashEntry) => PromptStashEntry | null; - /** Removes and returns an entry from a scope's queue (restore + delete). */ - takeEntry: (scopeKey: string, entryId: string) => PromptStashEntry | null; - /** Snapshot of a scope's queue, for rolling back a failed write. */ - readQueueSnapshot: (scopeKey: string) => ReadonlyArray; + stashEntry: (entry: PromptStashEntry) => { + evicted: PromptStashEntry | null; + /** False when the write did not reach durable storage; nothing was kept. */ + durable: boolean; + }; /** - * Restores a queue captured by `readQueueSnapshot`. Used when a stash write - * is rejected by storage: rolling back only the new entry would leave an - * entry evicted by the cap permanently lost. + * Removes and returns an entry from a scope's queue (restore + delete). + * `durable` is false when the removal could not be persisted, meaning a + * reload would resurrect the entry. */ - restoreQueueSnapshot: (scopeKey: string, queue: ReadonlyArray) => void; + takeEntry: ( + scopeKey: string, + entryId: string, + ) => { entry: PromptStashEntry | null; durable: boolean }; /** * Attaches the encoded images to an entry written earlier by `stashEntry`, - * clearing its pending count. Returns false when the entry is gone (restored - * or deleted while encoding was still running) so the caller can tell the - * user their images did not make it. + * clearing its pending count. Returns attached=false when the entry is gone + * (restored or deleted while encoding was still running) so the caller can + * tell the user their images did not make it. */ finalizeEntryImages: ( scopeKey: string, @@ -292,127 +273,94 @@ interface PromptStashStoreState { droppedImageNames: ReadonlyArray; unreadableImageNames: ReadonlyArray; }, - ) => boolean; + ) => { attached: boolean; durable: boolean }; } -export const usePromptStashStore = create()( - persist( - (set, get) => ({ - queuesByScopeKey: {}, - stashEntry: (entry) => { - const scopeKey = promptStashScopeKey(entry.providerInstanceId); - // Re-read from storage first: another tab may have stashed since this - // one hydrated, and persisting our whole map would erase its entries. - const mergedQueues = mergeQueuesWithStorage(get().queuesByScopeKey); - const queue = readQueue(mergedQueues, scopeKey); - const nextQueue = [entry, ...queue]; - const evicted = - nextQueue.length > MAX_STASH_ENTRIES_PER_QUEUE ? (nextQueue.pop() ?? null) : null; - set(() => ({ - queuesByScopeKey: { ...mergedQueues, [scopeKey]: nextQueue }, - })); - return evicted; - }, - takeEntry: (scopeKey, entryId) => { - const queue = readQueue(get().queuesByScopeKey, scopeKey); - const entry = queue.find((candidate) => candidate.id === entryId) ?? null; - if (!entry) return null; - set((state) => { - const nextQueue = readQueue(state.queuesByScopeKey, scopeKey).filter( - (candidate) => candidate.id !== entryId, - ); - const nextQueues = { ...state.queuesByScopeKey }; - if (nextQueue.length === 0) { - delete nextQueues[scopeKey]; - } else { - nextQueues[scopeKey] = nextQueue; - } - return { queuesByScopeKey: nextQueues }; - }); - return entry; - }, - readQueueSnapshot: (scopeKey) => readQueue(get().queuesByScopeKey, scopeKey), - restoreQueueSnapshot: (scopeKey, queue) => { - set((state) => { - const nextQueues = { ...state.queuesByScopeKey }; - if (queue.length === 0) { - delete nextQueues[scopeKey]; - } else { - nextQueues[scopeKey] = [...queue]; - } - return { queuesByScopeKey: nextQueues }; - }); - }, - finalizeEntryImages: (scopeKey, entryId, images) => { - // Read before the update so the caller learns whether the entry - // survived long enough to receive its images. - const found = readQueue(get().queuesByScopeKey, scopeKey).some( - (candidate) => candidate.id === entryId, - ); - if (!found) return false; - set((state) => { - const queue = readQueue(state.queuesByScopeKey, scopeKey); - const index = queue.findIndex((candidate) => candidate.id === entryId); - // Restored or deleted mid-encode: nothing to attach to. - if (index === -1) return state; - const existing = queue[index]; - if (!existing) return state; - const nextEntry: PromptStashEntry = { - ...existing, - attachments: images.attachments, - droppedImageNames: images.droppedImageNames, - unreadableImageNames: images.unreadableImageNames, - pendingImageCount: 0, - }; - const nextQueue = [...queue]; - nextQueue[index] = nextEntry; - return { queuesByScopeKey: { ...state.queuesByScopeKey, [scopeKey]: nextQueue } }; - }); - return true; - }, - }), - { - name: PROMPT_STASH_STORAGE_KEY, - version: PROMPT_STASH_STORAGE_VERSION, - storage: createJSONStorage(() => promptStashDebouncedStorage), - partialize: (state): PersistedPromptStashState => ({ - queuesByScopeKey: state.queuesByScopeKey, - }), - merge: (persistedState, currentState) => { - try { - const decoded = decodePersistedPromptStashState(persistedState); - return { - ...currentState, - queuesByScopeKey: clearOrphanedPendingImages(decoded.queuesByScopeKey), - }; - } catch { - // Corrupt or incompatible payload: start empty rather than crash. - return currentState; - } - }, - }, - ), -); +export const usePromptStashStore = create()((set, get) => ({ + queuesByScopeKey: {}, + stashEntry: (entry) => { + const scopeKey = promptStashScopeKey(entry.providerInstanceId); + const { next, result, written, durable } = commitQueues(get().queuesByScopeKey, (queues) => { + const nextQueue = [entry, ...readQueue(queues, scopeKey)]; + const evicted = + nextQueue.length > MAX_STASH_ENTRIES_PER_QUEUE ? (nextQueue.pop() ?? null) : null; + return { next: { ...queues, [scopeKey]: nextQueue }, result: evicted }; + }); + // A rejected write must not leave the entry visible in this tab: the + // caller keeps the composer intact on failure, so showing a stashed + // copy too would duplicate the prompt. + set(() => ({ queuesByScopeKey: written ? next : (readPersistedQueues() ?? {}) })); + return { evicted: written ? result : null, durable }; + }, + takeEntry: (scopeKey, entryId) => { + const { next, result, durable } = commitQueues(get().queuesByScopeKey, (queues) => { + const queue = readQueue(queues, scopeKey); + const entry = queue.find((candidate) => candidate.id === entryId) ?? null; + if (!entry) return { next: queues, result: null }; + const nextQueue = queue.filter((candidate) => candidate.id !== entryId); + const nextQueues = { ...queues }; + if (nextQueue.length === 0) { + delete nextQueues[scopeKey]; + } else { + nextQueues[scopeKey] = nextQueue; + } + return { next: nextQueues, result: entry }; + }); + set(() => ({ queuesByScopeKey: next })); + return { entry: result, durable }; + }, + finalizeEntryImages: (scopeKey, entryId, images) => { + const { next, result, durable } = commitQueues(get().queuesByScopeKey, (queues) => { + const queue = readQueue(queues, scopeKey); + const index = queue.findIndex((candidate) => candidate.id === entryId); + const existing = index === -1 ? undefined : queue[index]; + // Restored or deleted mid-encode: nothing to attach to. + if (!existing) return { next: queues, result: false }; + const nextQueue = [...queue]; + nextQueue[index] = { + ...existing, + attachments: images.attachments, + droppedImageNames: images.droppedImageNames, + unreadableImageNames: images.unreadableImageNames, + pendingImageCount: 0, + }; + return { next: { ...queues, [scopeKey]: nextQueue }, result: true }; + }); + set(() => ({ queuesByScopeKey: next })); + return { attached: result, durable }; + }, +})); /** - * Flushes pending stash writes immediately (e.g. right after a stash) and - * reports whether the write landed. Returns false when storage rejected it - * (quota, blocked storage), meaning the queue exists only in memory and will - * not survive a reload. + * Refreshes the in-memory queues from disk. Mutations already read-modify-write + * synchronously, so this only matters for picking up another tab's changes. */ -export function flushPromptStashStorage(): boolean { - lastWriteFailed = false; - promptStashDebouncedStorage.flush(); - return !lastWriteFailed; +function syncQueuesFromStorage(): void { + const persisted = readPersistedQueues(); + if (persisted) { + usePromptStashStore.setState({ queuesByScopeKey: persisted }); + } +} + +if (typeof window !== "undefined" && typeof window.addEventListener === "function") { + // Hydrate once at startup, then follow other tabs. `storage` fires only in + // *other* tabs, which is exactly the case this tab cannot observe itself. + syncQueuesFromStorage(); + window.addEventListener("storage", (event) => { + if (event.key === null || event.key === PROMPT_STASH_STORAGE_KEY) { + syncQueuesFromStorage(); + } + }); } export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray = []; /** * Test seam: writes the raw persisted payload through the same storage the - * store reads, so cross-tab merging can be exercised without a real - * `localStorage` global. + * store reads, so cross-tab behavior can be exercised without a real + * `localStorage` global. Pass an empty string to clear. */ export function writePromptStashStorageForTest(raw: string): void { baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw); + syncQueuesFromStorage(); } From 69fcdddfe26efed03d803276e14713ff6e8dc10f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 27 Jul 2026 05:52:46 -0700 Subject: [PATCH 8/8] refactor(web): drop cross-tab handling from the prompt stash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No other persisted store in the app (composer drafts included) does cross-tab merging or storage-event syncing — they are all last-write-wins — and the machinery added for it here caused more bugs across review rounds than the two-tabs-stashing-simultaneously edge case it guarded against. Remove it and match the sibling stores: mutate in-memory state, persist it, hydrate once at startup. Kept, because they fix real single-tab bugs: synchronous immediate writes (the composer clears on the strength of the write landing, which a debounce timer cannot honestly report), durability reporting including the non-durable in-memory fallback, rollback of a rejected stash write, and settling stale pendingImageCount on hydration. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/promptStashStore.test.ts | 72 ++-------- apps/web/src/promptStashStore.ts | 198 ++++++++++---------------- 2 files changed, 89 insertions(+), 181 deletions(-) diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index 155e5ed3ab2..228e1e35714 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -17,7 +17,6 @@ import { const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent"); const CODEX_INSTANCE = ProviderInstanceId.make("codex"); -const OLDER_TIMESTAMP = "2026-07-24T11:00:00.000Z"; function makeEntry(input: { id: string; @@ -229,68 +228,21 @@ describe("promptStashStore", () => { expect(attached).toBe(false); }); - /** Writes a queue straight to storage, as another tab would. */ - function seedStorage(scopeKey: string, entries: ReadonlyArray) { - writePromptStashStorageForTest( - JSON.stringify({ version: 1, state: { queuesByScopeKey: { [scopeKey]: entries } } }), - ); - } - - it("keeps another tab's entries when stashing", () => { + it("settles a pending count left behind by a crashed or closed session", () => { const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); - seedStorage(scopeKey, [{ ...makeEntry({ id: "from-other-tab" }), createdAt: OLDER_TIMESTAMP }]); - - usePromptStashStore.getState().stashEntry(makeEntry({ id: "from-this-tab" })); - - const ids = (usePromptStashStore.getState().queuesByScopeKey[scopeKey] ?? []).map( - (entry) => entry.id, - ); - expect(ids).toEqual(["from-this-tab", "from-other-tab"]); - }); - - // The previous id-union merge could not tell "another tab created this" - // from "this tab deleted it", so deletes came back. Reading disk as the - // source of truth removes the ambiguity. - it("does not resurrect an entry another tab deleted", () => { - const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); - const store = usePromptStashStore.getState(); - store.stashEntry(makeEntry({ id: "doomed" })); - // The other tab deletes it and writes the emptied queue. - seedStorage(scopeKey, []); - - store.stashEntry(makeEntry({ id: "fresh" })); - - const ids = (usePromptStashStore.getState().queuesByScopeKey[scopeKey] ?? []).map( - (entry) => entry.id, - ); - expect(ids).toEqual(["fresh"]); - expect(ids).not.toContain("doomed"); - }); - - it("does not clobber another tab's finalized images when taking an entry", () => { - const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); - const store = usePromptStashStore.getState(); - store.stashEntry(makeEntry({ id: "mine" })); - // Another tab adds an entry after this one's in-memory state was built. - seedStorage(scopeKey, [ - { ...makeEntry({ id: "mine" }), createdAt: OLDER_TIMESTAMP }, - { ...makeEntry({ id: "theirs" }), createdAt: OLDER_TIMESTAMP }, - ]); - - store.takeEntry(scopeKey, "mine"); - - const ids = (usePromptStashStore.getState().queuesByScopeKey[scopeKey] ?? []).map( - (entry) => entry.id, + writePromptStashStorageForTest( + JSON.stringify({ + version: 1, + state: { + queuesByScopeKey: { + [scopeKey]: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }], + }, + }, + }), ); - expect(ids).toEqual(["theirs"]); - }); - - it("settles a pending count left behind by a closed tab", () => { - const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); - seedStorage(scopeKey, [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }]); - // Any read of persisted state must settle the stale count, or the entry - // would stay stuck showing "saving…" and refuse to restore. + // Hydration must settle the stale count, or the entry would stay stuck + // showing "saving…" with images that no longer exist anywhere. const entry = usePromptStashStore.getState().queuesByScopeKey[scopeKey]?.[0]; expect(entry?.pendingImageCount).toBe(0); expect(entry?.unreadableImageNames).toHaveLength(2); diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index 895a19f12e6..e4340935b77 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -163,31 +163,20 @@ function resolveBaseStorage(): { storage: StateStorage; durable: boolean } { const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStorage(); /** - * Reads the queues currently on disk, settling any stale pending counts. + * Persists the queues, immediately rather than debounced. Stashing is a + * deliberate, infrequent keystroke — not a per-character autosave — so there + * is nothing to coalesce, and the caller clears the composer on the strength + * of this write landing, which a debounce timer cannot honestly report. * - * Disk — not this tab's memory — is the source of truth for every mutation. - * A union of the two could never distinguish "another tab added this entry" - * from "this tab deleted it", which would resurrect deletions; reading the - * live state and mutating *that* sidesteps the question entirely. + * Returns whether the write will survive a reload: false on a quota rejection + * or when only the in-memory fallback is available. */ -function readPersistedQueues(): Record> | null { - try { - // Read the backing store directly: the debounced wrapper's getItem is - // typed as possibly-async, and this has to resolve synchronously inside a - // store mutation. - const raw = baseStashStorage.getItem(PROMPT_STASH_STORAGE_KEY); - if (typeof raw !== "string" || raw.length === 0) return null; - const parsed: unknown = JSON.parse(raw); - const state = (parsed as { state?: unknown } | null)?.state; - if (!state) return null; - return clearOrphanedPendingImages(decodePersistedPromptStashState(state).queuesByScopeKey); - } catch { - return null; - } -} - -/** Serializes queues in the exact envelope zustand's persist middleware uses. */ -function writePersistedQueues(queues: Record>): boolean { +function persistQueues(queues: Record>): { + /** The write succeeded (possibly only into the in-memory fallback). */ + written: boolean; + /** The write will survive a reload. */ + durable: boolean; +} { try { baseStashStorage.setItem( PROMPT_STASH_STORAGE_KEY, @@ -196,47 +185,25 @@ function writePersistedQueues(queues: Record( - localQueues: Record>, - mutate: (queues: Record>) => { - next: Record>; - result: T; - }, -): { - next: Record>; - result: T; - /** The write itself succeeded (possibly only in memory). */ - written: boolean; - /** The write will survive a reload. */ - durable: boolean; -} { - // Fall back to this tab's state only when there is nothing readable on disk - // (first write of the session, blocked storage, corrupt payload). - const base = readPersistedQueues() ?? localQueues; - const { next, result } = mutate(base); - // The write still happens against the in-memory fallback so the stash works - // within the session; `durable` reports only whether it will survive a - // reload, which is what callers gate composer-clearing on. - const written = writePersistedQueues(next); - return { next, result, written, durable: written && storageIsDurable }; +/** Reads the persisted queues, settling stale pending counts. */ +function readPersistedQueues(): Record> | null { + try { + const raw = baseStashStorage.getItem(PROMPT_STASH_STORAGE_KEY); + if (typeof raw !== "string" || raw.length === 0) return null; + const parsed: unknown = JSON.parse(raw); + const state = (parsed as { state?: unknown } | null)?.state; + if (!state) return null; + return clearOrphanedPendingImages(decodePersistedPromptStashState(state).queuesByScopeKey); + } catch { + return null; + } } interface PromptStashStoreState { @@ -280,87 +247,76 @@ export const usePromptStashStore = create()((set, get) => queuesByScopeKey: {}, stashEntry: (entry) => { const scopeKey = promptStashScopeKey(entry.providerInstanceId); - const { next, result, written, durable } = commitQueues(get().queuesByScopeKey, (queues) => { - const nextQueue = [entry, ...readQueue(queues, scopeKey)]; - const evicted = - nextQueue.length > MAX_STASH_ENTRIES_PER_QUEUE ? (nextQueue.pop() ?? null) : null; - return { next: { ...queues, [scopeKey]: nextQueue }, result: evicted }; - }); - // A rejected write must not leave the entry visible in this tab: the - // caller keeps the composer intact on failure, so showing a stashed - // copy too would duplicate the prompt. - set(() => ({ queuesByScopeKey: written ? next : (readPersistedQueues() ?? {}) })); - return { evicted: written ? result : null, durable }; + const queues = get().queuesByScopeKey; + const nextQueue = [entry, ...readQueue(queues, scopeKey)]; + const evicted = + nextQueue.length > MAX_STASH_ENTRIES_PER_QUEUE ? (nextQueue.pop() ?? null) : null; + const next = { ...queues, [scopeKey]: nextQueue }; + const { written, durable } = persistQueues(next); + // A rejected write must not leave the entry visible either: the caller + // keeps the composer intact on failure, so a stashed copy would + // duplicate the prompt. Eviction likewise only sticks on success. + if (!written) { + return { evicted: null, durable: false }; + } + set(() => ({ queuesByScopeKey: next })); + return { evicted, durable }; }, takeEntry: (scopeKey, entryId) => { - const { next, result, durable } = commitQueues(get().queuesByScopeKey, (queues) => { - const queue = readQueue(queues, scopeKey); - const entry = queue.find((candidate) => candidate.id === entryId) ?? null; - if (!entry) return { next: queues, result: null }; - const nextQueue = queue.filter((candidate) => candidate.id !== entryId); - const nextQueues = { ...queues }; - if (nextQueue.length === 0) { - delete nextQueues[scopeKey]; - } else { - nextQueues[scopeKey] = nextQueue; - } - return { next: nextQueues, result: entry }; - }); + const queues = get().queuesByScopeKey; + const queue = readQueue(queues, scopeKey); + const entry = queue.find((candidate) => candidate.id === entryId) ?? null; + if (!entry) return { entry: null, durable: true }; + const nextQueue = queue.filter((candidate) => candidate.id !== entryId); + const next = { ...queues }; + if (nextQueue.length === 0) { + delete next[scopeKey]; + } else { + next[scopeKey] = nextQueue; + } + const { durable } = persistQueues(next); set(() => ({ queuesByScopeKey: next })); - return { entry: result, durable }; + return { entry, durable }; }, finalizeEntryImages: (scopeKey, entryId, images) => { - const { next, result, durable } = commitQueues(get().queuesByScopeKey, (queues) => { - const queue = readQueue(queues, scopeKey); - const index = queue.findIndex((candidate) => candidate.id === entryId); - const existing = index === -1 ? undefined : queue[index]; - // Restored or deleted mid-encode: nothing to attach to. - if (!existing) return { next: queues, result: false }; - const nextQueue = [...queue]; - nextQueue[index] = { - ...existing, - attachments: images.attachments, - droppedImageNames: images.droppedImageNames, - unreadableImageNames: images.unreadableImageNames, - pendingImageCount: 0, - }; - return { next: { ...queues, [scopeKey]: nextQueue }, result: true }; - }); + const queues = get().queuesByScopeKey; + const queue = readQueue(queues, scopeKey); + const index = queue.findIndex((candidate) => candidate.id === entryId); + const existing = index === -1 ? undefined : queue[index]; + // Restored or deleted mid-encode: nothing to attach to. + if (!existing) return { attached: false, durable: true }; + const nextQueue = [...queue]; + nextQueue[index] = { + ...existing, + attachments: images.attachments, + droppedImageNames: images.droppedImageNames, + unreadableImageNames: images.unreadableImageNames, + pendingImageCount: 0, + }; + const next = { ...queues, [scopeKey]: nextQueue }; + const { durable } = persistQueues(next); set(() => ({ queuesByScopeKey: next })); - return { attached: result, durable }; + return { attached: true, durable }; }, })); -/** - * Refreshes the in-memory queues from disk. Mutations already read-modify-write - * synchronously, so this only matters for picking up another tab's changes. - */ -function syncQueuesFromStorage(): void { +// Hydrate once at startup. Like the app's other persisted stores, tabs are +// last-write-wins: no cross-tab merging or storage-event syncing. +{ const persisted = readPersistedQueues(); if (persisted) { usePromptStashStore.setState({ queuesByScopeKey: persisted }); } } -if (typeof window !== "undefined" && typeof window.addEventListener === "function") { - // Hydrate once at startup, then follow other tabs. `storage` fires only in - // *other* tabs, which is exactly the case this tab cannot observe itself. - syncQueuesFromStorage(); - window.addEventListener("storage", (event) => { - if (event.key === null || event.key === PROMPT_STASH_STORAGE_KEY) { - syncQueuesFromStorage(); - } - }); -} - export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray = []; /** - * Test seam: writes the raw persisted payload through the same storage the - * store reads, so cross-tab behavior can be exercised without a real - * `localStorage` global. Pass an empty string to clear. + * Test seam: seeds the persisted payload through the same storage the store + * reads and rehydrates, without needing a real `localStorage` global. + * Pass an empty string to clear. */ export function writePromptStashStorageForTest(raw: string): void { baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw); - syncQueuesFromStorage(); + usePromptStashStore.setState({ queuesByScopeKey: readPersistedQueues() ?? {} }); }