diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b427037bdf7..866ed0c9b8c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -51,13 +51,29 @@ import { type ComposerImageAttachment, type DraftId, type PersistedComposerImageAttachment, + hydrateImagesFromPersisted, useComposerDraftStore, useComposerThreadDraft, useEffectiveComposerModelState, } from "../../composerDraftStore"; +import { + EMPTY_PROMPT_STASH_QUEUE, + MAX_STASH_ENTRIES_PER_QUEUE, + partitionStashAttachments, + promptStashScopeKey, + usePromptStashStore, + type PromptStashEntry, +} from "../../promptStashStore"; +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"; import { type TerminalContextDraft, type TerminalContextSelection, + INLINE_TERMINAL_CONTEXT_PLACEHOLDER, insertInlineTerminalContextPlaceholder, removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; @@ -723,6 +739,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); + const clearComposerDraftPromptAndImages = useComposerDraftStore( + (store) => store.clearComposerPromptAndImages, + ); + const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); @@ -961,6 +981,11 @@ 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 [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ + key: 0, + active: false, + }); const isMobileViewport = useMediaQuery("max-sm"); const isComposerCollapsedMobile = isMobileViewport && !forceExpandedOnMobile && !isComposerFocused; @@ -980,6 +1005,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); const dragDepthRef = useRef(0); + const stashPulseKeyRef = useRef(0); + const stashPulseTimeoutRef = useRef(null); + /** + * 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 @@ -1861,6 +1894,400 @@ 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 finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages); + const stashProviderLabel = noProviderAvailable + ? "No provider" + : getProviderDisplayName(providerStatuses, selectedProvider); + + useEffect(() => { + return () => { + if (stashPulseTimeoutRef.current !== null) { + window.clearTimeout(stashPulseTimeoutRef.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); + } + stashPulseTimeoutRef.current = window.setTimeout(() => { + stashPulseTimeoutRef.current = null; + setStashPulse((current) => ({ ...current, active: false })); + }, 1200); + }, []); + + const restoreStashEntry = useCallback( + (entry: PromptStashEntry) => { + // Remove first so a double activation (click + Enter) can't restore twice. + const { entry: taken, durable } = takeStashEntry( + promptStashScopeKey(entry.providerInstanceId), + entry.id, + ); + if (!taken) return; + 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; + // 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) && + !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. + unrestoredImageNames = pending.slice(capacity).map((attachment) => attachment.name); + const restoredImages = hydrateImagesFromPersisted(pending.slice(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, + }); + } + + // 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 restored", + description: missingImageReasons.join(" "), + }); + } + + // 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, + composerDraftTarget, + composerImagesRef, + promptRef, + providerInstanceEntries, + setComposerDraftModelSelection, + setComposerDraftPrompt, + takeStashEntry, + ], + ); + + const deleteStashEntry = useCallback( + (entry: PromptStashEntry) => { + 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], + ); + + 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; + } + // 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(); + 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, durable } = stashEntryToQueue({ + id: entryId, + createdAt: new Date().toISOString(), + prompt, + attachments: [], + providerInstanceId: stashScopeInstanceId, + modelSelection: noProviderAvailable ? null : selectedModelSelection, + droppedImageNames: [], + unreadableImageNames: [], + pendingImageCount: images.length, + }); + + // Clearing the composer is only safe once the entry is durable. If the + // 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", + 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 + // 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: result.image.mimeType, + sizeBytes: result.image.sizeBytes, + dataUrl: result.image.dataUrl, + }); + } + const { kept, droppedNames } = partitionStashAttachments(candidateAttachments); + + const { attached, durable: imagesDurable } = finalizeStashEntryImages(scopeKey, entryId, { + attachments: kept, + droppedImageNames: [...oversizedImageNames, ...droppedNames], + unreadableImageNames, + }); + 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 (!imagesDurable && 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 + // 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 this + // snapshot's ⌘S until the composer remounts. + stashInFlightRef.current.delete(snapshotKey); + } + }, [ + clearComposerDraftPromptAndImages, + composerDraftTarget, + composerImagesRef, + finalizeStashEntryImages, + noProviderAvailable, + promptRef, + pulseStashBadge, + 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 +2829,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerCollapsedMobile && "hidden", )} > + + + {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && ( + + setIsStashMenuOpen(false)} + /> + + )} + {composerMenuOpen && !isComposerApprovalState && ( void; +}) { + if (props.count === 0) return null; + + return ( + + ); +}); diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx new file mode 100644 index 00000000000..22950074307 --- /dev/null +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -0,0 +1,186 @@ +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; + +/** 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) { + 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") { + // 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(); + 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.pendingImageCount ? ( + + saving {entry.pendingImageCount} image + {entry.pendingImageCount === 1 ? "" : "s"}… + + ) : missingImageCount(entry) > 0 ? ( + + {missingImageCount(entry)} image + {missingImageCount(entry) === 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..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 { @@ -2091,7 +2098,7 @@ function hydratePersistedComposerImageAttachment( } } -function hydrateImagesFromPersisted( +export function hydrateImagesFromPersisted( attachments: ReadonlyArray, ): ComposerImageAttachment[] { return attachments.flatMap((attachment) => { @@ -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 29dd99b8e6d..19c05845535 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1432,6 +1432,30 @@ label:has(> select#reasoning-effort) select { margin-top: 0.125rem; } +/* 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: translateY(2px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.prompt-stash-count-enter { + animation: prompt-stash-count-enter 180ms ease-out both; +} + +@media (prefers-reduced-motion: reduce) { + .prompt-stash-count-enter { + animation: none; + } +} + @keyframes provider-update-pill-countdown { from { transform: scaleX(1); diff --git a/apps/web/src/lib/stashImageCompression.test.ts b/apps/web/src/lib/stashImageCompression.test.ts new file mode 100644 index 00000000000..aaffa284cbb --- /dev/null +++ b/apps/web/src/lib/stashImageCompression.test.ts @@ -0,0 +1,198 @@ +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.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(); + }); + + 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.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.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(); + }); + + 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.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(); + }); + + 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.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("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).toEqual({ ok: false, reason: "too-large" }); + // The bitmap must still be released on the give-up path. + expect(close).toHaveBeenCalled(); + }); + + 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))).toEqual({ + ok: false, + reason: "too-large", + }); + }); + + it("reports unreadable 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))).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 new file mode 100644 index 00000000000..7f133fb2926 --- /dev/null +++ b/apps/web/src/lib/stashImageCompression.ts @@ -0,0 +1,250 @@ +/** + * 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; +} + +/** + * 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; + +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 { + let originalDataUrl: string; + try { + originalDataUrl = await blobToDataUrl(file); + } catch { + return { ok: false, reason: "unreadable" }; + } + if (originalDataUrl.length <= budgetChars) { + return { + ok: true, + image: { + dataUrl: originalDataUrl, + mimeType: file.type, + sizeBytes: file.size, + recompressed: false, + }, + }; + } + if (!canRecompress()) { + return { ok: false, reason: "too-large" }; + } + + let bitmap: ImageBitmap; + try { + bitmap = await createImageBitmap(file); + } catch { + 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)); + // 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 — 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, + image: { + dataUrl: encoded.dataUrl, + mimeType: encoded.mimeType, + sizeBytes: dataUrlByteLength(encoded.dataUrl), + recompressed: true, + }, + }; + } + } + 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 new file mode 100644 index 00000000000..228e1e35714 --- /dev/null +++ b/apps/web/src/promptStashStore.test.ts @@ -0,0 +1,259 @@ +import { ProviderInstanceId } from "@t3tools/contracts"; +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"); + +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: {} }); + writePromptStashStorageForTest(""); + removeLocalStorageItem(PROMPT_STASH_STORAGE_KEY); +} + +describe("promptStashScopeKey", () => { + it("maps a provider instance to its own bucket and null to the unscoped bucket", () => { + 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", () => { + 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(); + }); + + afterEach(() => { + 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[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ?? + []; + 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[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"]); + }); + + 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}` })).evicted).toBeNull(); + } + const { evicted } = store.stashEntry(makeEntry({ id: "overflow" })); + expect(evicted?.id).toBe("entry-0"); + const queue = + usePromptStashStore.getState().queuesByScopeKey[promptStashScopeKey(CLAUDE_AGENT_INSTANCE)] ?? + []; + 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(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)] ?? + []; + 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").entry).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("settles a pending count left behind by a crashed or closed session", () => { + const scopeKey = promptStashScopeKey(CLAUDE_AGENT_INSTANCE); + writePromptStashStorageForTest( + JSON.stringify({ + version: 1, + state: { + queuesByScopeKey: { + [scopeKey]: [{ ...makeEntry({ id: "orphan" }), pendingImageCount: 2 }], + }, + }, + }), + ); + + // 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); + }); + + it("drops the scope key entirely when its queue empties", () => { + const store = usePromptStashStore.getState(); + store.stashEntry(makeEntry({ id: "only" })); + 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 new file mode 100644 index 00000000000..e4340935b77 --- /dev/null +++ b/apps/web/src/promptStashStore.ts @@ -0,0 +1,322 @@ +import { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { create } from "zustand"; + +import { PersistedComposerImageAttachment } from "./composerDraftStore"; +import { createMemoryStorage, type StateStorage } from "./lib/storage"; + +export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1"; +const PROMPT_STASH_STORAGE_VERSION = 1; + +/** + * 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_700_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), + /** + * 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)), + /** + * 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; + +const PersistedPromptStashState = Schema.Struct({ + queuesByScopeKey: Schema.Record(Schema.String, Schema.Array(StashEntrySchema)), +}); +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; +} + +/** + * 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] ?? []) : []; +} + +/** + * 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 }; +} + +/** + * 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(): { storage: StateStorage; durable: boolean } { + try { + if (typeof localStorage !== "undefined") { + return { storage: localStorage, durable: true }; + } + } catch { + // Fall through to the in-memory store. + } + return { storage: createMemoryStorage(), durable: false }; +} + +const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStorage(); + +/** + * 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. + * + * Returns whether the write will survive a reload: false on a quota rejection + * or when only the in-memory fallback is available. + */ +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, + JSON.stringify({ + version: PROMPT_STASH_STORAGE_VERSION, + state: { queuesByScopeKey: queues }, + }), + ); + return { written: true, durable: storageIsDurable }; + } catch (error) { + console.error("[PROMPT-STASH] Could not persist stash (storage quota?).", error); + return { written: false, durable: false }; + } +} + +/** 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 { + 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) => { + evicted: PromptStashEntry | null; + /** False when the write did not reach durable storage; nothing was kept. */ + durable: boolean; + }; + /** + * 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. + */ + 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 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, + entryId: string, + images: { + attachments: ReadonlyArray; + droppedImageNames: ReadonlyArray; + unreadableImageNames: ReadonlyArray; + }, + ) => { attached: boolean; durable: boolean }; +} + +export const usePromptStashStore = create()((set, get) => ({ + queuesByScopeKey: {}, + stashEntry: (entry) => { + const scopeKey = promptStashScopeKey(entry.providerInstanceId); + 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 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, durable }; + }, + finalizeEntryImages: (scopeKey, entryId, images) => { + 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: true, durable }; + }, +})); + +// 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 }); + } +} + +export const EMPTY_PROMPT_STASH_QUEUE: ReadonlyArray = []; + +/** + * 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); + usePromptStashStore.setState({ queuesByScopeKey: readPersistedQueues() ?? {} }); +} 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" },