From 6c0a0de8792293de5770e4f93b138ed81a0a45a2 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:57:52 -0400 Subject: [PATCH] Add prompt stashing across threads - Persist stashed prompts with attachments and composer context - Restore stashes through the composer control and mod+s shortcut - Add image compression, stash persistence tests, and keybinding updates --- KEYBINDINGS.md | 2 + apps/web/src/components/chat/ChatComposer.tsx | 457 ++++++++++++++++++ .../chat/ComposerStashControl.browser.tsx | 115 +++++ .../components/chat/ComposerStashControl.tsx | 190 ++++++++ apps/web/src/composerDraftStore.ts | 110 +++-- .../web/src/lib/stashImageCompression.test.ts | 219 +++++++++ apps/web/src/lib/stashImageCompression.ts | 280 +++++++++++ apps/web/src/promptStashStore.test.ts | 268 ++++++++++ apps/web/src/promptStashStore.ts | 451 +++++++++++++++++ apps/web/src/routeTree.gen.ts | 250 +++++----- packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 12 files changed, 2184 insertions(+), 160 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerStashControl.browser.tsx create mode 100644 apps/web/src/components/chat/ComposerStashControl.tsx create mode 100644 apps/web/src/lib/stashImageCompression.test.ts create mode 100644 apps/web/src/lib/stashImageCompression.ts create mode 100644 apps/web/src/promptStashStore.test.ts create mode 100644 apps/web/src/promptStashStore.ts diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 94737d3a9..4c9fc8ae6 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -27,6 +27,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "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" }, + { "key": "mod+s", "command": "composer.stash", "when": "!terminalFocus" }, { "key": "mod+o", "command": "editor.openFavorite" } ] ``` @@ -54,6 +55,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `commandPalette.toggle`: open or close the global command palette - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) +- `composer.stash`: stash the composer's prompt for later; with an empty composer, open the stash list - `editor.openFavorite`: open current project/worktree in the last-used editor - `script.{id}.run`: run a project script by id (for example `script.test.run`) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 80dba42f6..ec61d0876 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -62,10 +62,24 @@ import { type ComposerAttachment, type DraftId, type PersistedComposerAttachment, + composerAttachmentDedupKey, + hydrateAttachmentsFromPersisted, useComposerDraftStore, useComposerThreadDraft, useEffectiveComposerModelState, } from "../../composerDraftStore"; +import { + buildStashEntryContexts, + MAX_STASH_ENTRIES, + partitionStashAttachments, + stashEntryContextDrafts, + usePromptStashStore, + type PromptStashEntry, +} from "../../promptStashStore"; +import { compressImageForStash, readFileForStash } from "../../lib/stashImageCompression"; +import { resolveShortcutCommand, shortcutLabelForCommand } from "../../keybindings"; +import { isTerminalFocused } from "../../lib/terminalFocus"; +import { useCommandPaletteStore } from "../../commandPaletteStore"; import { type TerminalContextDraft, type TerminalContextSelection, @@ -86,6 +100,7 @@ import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommand import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; import { ComposerAttachmentMenu } from "./ComposerAttachmentMenu"; +import { ComposerStashControl } from "./ComposerStashControl"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; @@ -769,6 +784,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (store) => store.syncPersistedAttachments, ); const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); + const clearStashableComposerContent = useComposerDraftStore( + (store) => store.clearStashableComposerContent, + ); + const addComposerDraftTerminalContexts = useComposerDraftStore( + (store) => store.addTerminalContexts, + ); + const addComposerDraftTranscriptHighlightContexts = useComposerDraftStore( + (store) => store.addTranscriptHighlightContexts, + ); + const addComposerDraftFileSelectionContext = useComposerDraftStore( + (store) => store.addFileSelectionContext, + ); // ------------------------------------------------------------------ // Model state @@ -1469,6 +1496,435 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [composerDraftTarget, removeComposerDraftAttachment], ); + // ------------------------------------------------------------------ + // Prompt stash + // ------------------------------------------------------------------ + // One global queue. A stashed prompt carries text, attachments and the + // context chips that survive a move, so "stash here, restore there" works + // across threads and providers. Deliberately no model or provider: the + // composer keeps whatever is selected right now. + const stashEntries = usePromptStashStore((state) => state.entries); + const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry); + const takeStashEntry = usePromptStashStore((state) => state.takeEntry); + const finalizeStashEntryAttachments = usePromptStashStore( + (state) => state.finalizeEntryAttachments, + ); + const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); + const stashInFlightRef = useRef>(new Set()); + const stashShortcutLabel = useMemo( + () => + shortcutLabelForCommand(keybindings, "composer.stash", { + context: { terminalFocus: false }, + }), + [keybindings], + ); + + // Chips alone are worth stashing: a set of picked elements and file + // selections is as much work to reassemble as the sentence about them. + const composerHasStashableContent = + prompt.trim().length > 0 || + composerAttachments.length > 0 || + composerTerminalContexts.length > 0 || + composerTranscriptHighlightContexts.length > 0 || + composerFileSelectionContexts.length > 0 || + composerPickedElementContexts.length > 0; + + const restoreStashEntry = useCallback( + (entry: PromptStashEntry) => { + // Remove first so a double activation (click + Enter) cannot restore twice. + const { entry: taken, durable } = takeStashEntry(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 attachment-only or chip-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; + setPrompt(nextPrompt); + setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); + setComposerTrigger(null); + } + + let unrestoredAttachmentNames: string[] = []; + if (entry.attachments.length > 0) { + const existingAttachments = composerAttachmentsRef.current; + const existingIds = new Set(existingAttachments.map((attachment) => attachment.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 attachment into the overflow list for nothing. + const existingDedupKeys = new Set( + existingAttachments.map((attachment) => composerAttachmentDedupKey(attachment)), + ); + const capacity = Math.max( + 0, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS - existingAttachments.length, + ); + const pending = entry.attachments.filter( + (attachment) => + !existingIds.has(attachment.id) && + !existingDedupKeys.has(composerAttachmentDedupKey(attachment)), + ); + // 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. + unrestoredAttachmentNames = pending.slice(capacity).map((attachment) => attachment.name); + const restored = hydrateAttachmentsFromPersisted(pending.slice(0, capacity)); + if (restored.length > 0) { + addComposerDraftAttachments(composerDraftTarget, restored); + } + } + + // Chips go back through the store's own add paths, so each one is + // re-stamped for this thread and deduped against what is already there. + const chips = stashEntryContextDrafts(taken); + if (chips.terminalContexts.length > 0) { + addComposerDraftTerminalContexts(composerDraftTarget, chips.terminalContexts); + } + if (chips.transcriptHighlightContexts.length > 0) { + addComposerDraftTranscriptHighlightContexts( + composerDraftTarget, + chips.transcriptHighlightContexts, + ); + } + for (const context of chips.fileSelectionContexts) { + addComposerDraftFileSelectionContext(composerDraftTarget, context); + } + if (chips.pickedElementContexts.length > 0) { + // Picked elements are set as a whole list, so merge rather than + // replace, and drop anything already pinned to the same element. + const existingPicked = composerPickedElementContextsRef.current; + const existingKeys = new Set( + existingPicked.map((context) => pickedElementContextDedupKey(context)), + ); + const additions = chips.pickedElementContexts.filter((context) => { + const key = pickedElementContextDedupKey(context); + if (existingKeys.has(key)) return false; + existingKeys.add(key); + return true; + }); + if (additions.length > 0) { + commitPickedElementContexts([...existingPicked, ...additions]); + } + } + + // Each cause gets its own sentence so "too large" is never blamed for a + // file that actually failed to read, or for one the composer simply had + // no room to take back. + const missingReasons: string[] = []; + if (taken.droppedAttachmentNames.length > 0) { + missingReasons.push( + `${taken.droppedAttachmentNames.join(", ")} exceeded the stash size limit when this prompt was saved.`, + ); + } + if (taken.unreadableAttachmentNames && taken.unreadableAttachmentNames.length > 0) { + missingReasons.push( + `${taken.unreadableAttachmentNames.join(", ")} could not be read when this prompt was saved.`, + ); + } + if (unrestoredAttachmentNames.length > 0) { + missingReasons.push( + `${unrestoredAttachmentNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`, + ); + } + if (missingReasons.length > 0) { + toastManager.add({ + type: "warning", + title: "Some attachments were not restored", + description: missingReasons.join(" "), + }); + } + + // Only yank the caret to the end when text was actually inserted; + // restoring attachments alone should leave the user where they were. + if (promptChanged) { + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAtEnd(); + }); + } + }, + [ + addComposerDraftAttachments, + addComposerDraftFileSelectionContext, + addComposerDraftTerminalContexts, + addComposerDraftTranscriptHighlightContexts, + commitPickedElementContexts, + composerAttachmentsRef, + composerDraftTarget, + composerPickedElementContextsRef, + promptRef, + setPrompt, + takeStashEntry, + ], + ); + + const deleteStashEntry = useCallback( + (entry: PromptStashEntry) => { + const { durable } = takeStashEntry(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 () => { + // Inline terminal placeholders stay in the stashed text: their contexts + // travel with the entry, and the n-th placeholder still maps to the n-th + // context once both are appended back in order. + const stashedPrompt = promptRef.current.trim(); + const attachments = [...composerAttachmentsRef.current]; + const contexts = buildStashEntryContexts({ + terminalContexts: composerTerminalContextsRef.current, + transcriptHighlightContexts: composerTranscriptHighlightContextsRef.current, + fileSelectionContexts: composerFileSelectionContextsRef.current, + pickedElementContexts: composerPickedElementContextsRef.current, + }); + const hasChips = Object.keys(contexts).length > 0; + if (stashedPrompt.length === 0 && attachments.length === 0 && !hasChips) { + setIsStashMenuOpen((open) => !open); + return; + } + // A repeat press 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)}${stashedPrompt}${attachments + .map((attachment) => attachment.id) + .join(",")}`; + if (stashInFlightRef.current.has(snapshotKey)) return; + stashInFlightRef.current.add(snapshotKey); + + const stashTarget = composerDraftTarget; + const entryId = randomUUID(); + try { + // Persist the text-and-chips 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 attachment work means edits typed during + // encoding are not wiped. Attachments are appended to the stored entry + // as they finish encoding. + const { evicted, written, durable } = stashEntryToQueue({ + id: entryId, + createdAt: new Date().toISOString(), + prompt: stashedPrompt, + attachments: [], + droppedAttachmentNames: [], + unreadableAttachmentNames: [], + pendingAttachmentCount: attachments.length, + ...contexts, + }); + + // Clearing the composer is only safe once the write actually landed. + // If it was rejected (quota) the store has already rolled itself back, + // so leave the composer untouched rather than making it the second + // casualty of a reload. + if (!written) { + 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; + } + // Written but only into the in-memory fallback (localStorage blocked): + // the entry is visible and restorable this session, so proceed with the + // clear, but say it will not survive a reload. + if (!durable) { + toastManager.add({ + type: "warning", + title: "Stashed prompt will not survive a reload", + description: + "Browser storage is unavailable, so this stash is kept in memory only for this session.", + data: { hideCopyButton: true }, + }); + } + + // Drawings are deliberately left in place: the stash cannot carry their + // picture, so clearing them here would destroy them for good. + promptRef.current = ""; + clearStashableComposerContent(stashTarget); + setComposerCursor(0); + setComposerTrigger(null); + + if (evicted) { + toastManager.add({ + type: "warning", + title: "Oldest stashed prompt discarded", + description: `The stash holds ${MAX_STASH_ENTRIES} 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 large files, but localStorage gives the whole origin + // roughly 5MB. Only the stashed copy shrinks; the live attachment (and + // anything sent without stashing) keeps the original file. + const candidateAttachments: PersistedComposerAttachment[] = []; + const oversizedNames: string[] = []; + const unreadableNames: string[] = []; + for (const attachment of attachments) { + const result = attachment.mimeType.startsWith("image/") + ? await compressImageForStash(attachment.file) + : await readFileForStash(attachment.file); + if (!result.ok) { + // "too large" and "could not be read" are distinct outcomes; the + // restore toast reports them separately. + (result.reason === "too-large" ? oversizedNames : unreadableNames).push(attachment.name); + continue; + } + candidateAttachments.push({ + id: attachment.id, + name: attachment.name, + mimeType: result.image.mimeType, + sizeBytes: result.image.sizeBytes, + dataUrl: result.image.dataUrl, + }); + } + const { kept, droppedNames } = partitionStashAttachments(candidateAttachments); + + const { attached, durable: attachmentsDurable } = finalizeStashEntryAttachments(entryId, { + attachments: kept, + droppedAttachmentNames: [...oversizedNames, ...droppedNames], + unreadableAttachmentNames: unreadableNames, + }); + if (attached) { + // The second phase can be rejected on its own: the text entry fit, but + // adding attachment payloads pushed past the quota. Disk would then + // still hold the phase-one entry with a pending count set, which reads + // as an orphan after reload, so say so now. Gated on the entry write + // having been durable: on the in-memory fallback nothing is ever + // durable and the session-only warning already covered it. + if (!attachmentsDurable && durable && attachments.length > 0) { + toastManager.add({ + type: "warning", + title: "Stashed attachments were not saved", + description: + "The prompt was stashed, but browser storage rejected its attachments. They will be missing if you reload.", + data: { hideCopyButton: true }, + }); + } + } else if (kept.length > 0) { + // The entry was restored or deleted before its attachments finished + // encoding, so they have nowhere to land. Say so rather than letting + // them evaporate. + toastManager.add({ + type: "warning", + title: "Stashed attachments did not attach", + description: `That prompt was restored or deleted before ${kept.length} attachment${kept.length === 1 ? "" : "s"} finished saving. Add ${kept.length === 1 ? "it" : "them"} again 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 shortcut until the composer remounts. + stashInFlightRef.current.delete(snapshotKey); + } + }, [ + clearStashableComposerContent, + composerAttachmentsRef, + composerDraftTarget, + composerFileSelectionContextsRef, + composerPickedElementContextsRef, + composerTerminalContextsRef, + composerTranscriptHighlightContextsRef, + finalizeStashEntryAttachments, + promptRef, + stashEntryToQueue, + ]); + + const stashControlProps = useMemo( + () => ({ + entries: stashEntries, + open: isStashMenuOpen, + onOpenChange: setIsStashMenuOpen, + canStash: composerHasStashableContent && !attachmentsDisabled, + stashShortcutLabel, + onStash: () => { + void stashCurrentPrompt(); + }, + onRestore: restoreStashEntry, + onDelete: deleteStashEntry, + }), + [ + attachmentsDisabled, + composerHasStashableContent, + deleteStashEntry, + isStashMenuOpen, + restoreStashEntry, + stashCurrentPrompt, + stashEntries, + stashShortcutLabel, + ], + ); + + useEffect(() => { + const handler = (event: globalThis.KeyboardEvent) => { + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen, + modelPickerOpen: isComposerModelPickerOpen, + }, + }); + if (command !== "composer.stash") return; + // Always claim the shortcut so the browser's save dialog never opens, + // even when the composer is in a state that cannot stash. + event.preventDefault(); + event.stopPropagation(); + if (useCommandPaletteStore.getState().open || attachmentsDisabled) { + return; + } + void stashCurrentPrompt(); + }; + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, [ + attachmentsDisabled, + isComposerModelPickerOpen, + keybindings, + stashCurrentPrompt, + terminalOpen, + ]); + + // The stash popup is a transient picker, not a panel: close it when the + // trigger-driven command menu takes over the same layer, and when the user + // resumes typing. + useEffect(() => { + if (composerMenuOpen) { + setIsStashMenuOpen(false); + } + }, [composerMenuOpen]); + useEffect(() => { + setIsStashMenuOpen(false); + }, [prompt]); + const removeComposerTerminalContextFromDraft = useCallback( (contextId: string) => { const contextIndex = composerTerminalContexts.findIndex( @@ -3181,6 +3637,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) aria-hidden="true" onChange={onAttachmentFileInputChange} /> + ; + canStash: boolean; + onRestore?: (entry: PromptStashEntry) => void; + onDelete?: (entry: PromptStashEntry) => void; +}) { + function Harness() { + const [open, setOpen] = useState(false); + return ( + + ); + } + return render(); +} + +describe("ComposerStashControl", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("lists stashed prompts with their chip count and restores the row that was clicked", async () => { + const onRestore = vi.fn(); + const screen = await renderStashControl({ + entries: [ + makeEntry({ id: "newest", prompt: "rewrite the parser", withChip: true }), + makeEntry({ id: "older", prompt: "add a retry" }), + ], + canStash: true, + onRestore, + }); + + await page.getByRole("button", { name: "Stashed prompts (2)" }).click(); + await expect.element(page.getByRole("menuitem", { name: /Stash this prompt/ })).toBeVisible(); + await expect.element(page.getByText("1 chip")).toBeVisible(); + + await page.getByRole("menuitem", { name: /rewrite the parser/ }).click(); + expect(onRestore).toHaveBeenCalledTimes(1); + expect(onRestore.mock.calls[0]?.[0]?.id).toBe("newest"); + await screen.unmount(); + }); + + it("deletes an entry without restoring it, and offers no stash action on an empty composer", async () => { + const onRestore = vi.fn(); + const onDelete = vi.fn(); + const screen = await renderStashControl({ + entries: [makeEntry({ id: "only", prompt: "ship the fix" })], + canStash: false, + onRestore, + onDelete, + }); + + await page.getByRole("button", { name: "Stashed prompts (1)" }).click(); + // Nothing typed, so the popover is a list only. + expect(page.getByRole("menuitem", { name: /Stash this prompt/ }).query()).toBeNull(); + + await page.getByRole("button", { name: /Delete stashed prompt/ }).click(); + expect(onDelete).toHaveBeenCalledTimes(1); + // The delete must not double as an activation of the row it sits in. + expect(onRestore).not.toHaveBeenCalled(); + await screen.unmount(); + }); + + it("explains how to fill an empty stash", async () => { + const screen = await renderStashControl({ entries: [], canStash: false }); + + await page.getByRole("button", { name: "Stashed prompts" }).click(); + await expect.element(page.getByText(/Nothing stashed yet/)).toBeVisible(); + await screen.unmount(); + }); +}); diff --git a/apps/web/src/components/chat/ComposerStashControl.tsx b/apps/web/src/components/chat/ComposerStashControl.tsx new file mode 100644 index 000000000..604fdaf8e --- /dev/null +++ b/apps/web/src/components/chat/ComposerStashControl.tsx @@ -0,0 +1,190 @@ +import { BookmarkIcon, XIcon } from "lucide-react"; +import { memo } from "react"; + +import { cn } from "~/lib/utils"; +import { stripInlineTerminalContextPlaceholders } from "../../lib/terminalContext"; +import { stashEntryChipCount, type PromptStashEntry } from "../../promptStashStore"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { Button } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "../ui/menu"; + +const SNIPPET_MAX_CHARS = 90; +const MAX_ROW_THUMBNAILS = 3; + +export interface ComposerStashControlProps { + entries: ReadonlyArray; + open: boolean; + onOpenChange: (open: boolean) => void; + /** The composer has something worth stashing right now. */ + canStash: boolean; + /** Rendered right-aligned on the stash action row; null when unbound. */ + stashShortcutLabel: string | null; + onStash: () => void; + onRestore: (entry: PromptStashEntry) => void; + onDelete: (entry: PromptStashEntry) => void; +} + +/** Attachments that did not make it into the entry, whatever the reason. */ +function missingAttachmentCount(entry: PromptStashEntry): number { + return entry.droppedAttachmentNames.length + (entry.unreadableAttachmentNames?.length ?? 0); +} + +function stashEntrySnippet(entry: PromptStashEntry): string { + // Inline terminal placeholders are invisible object-replacement characters + // in the live composer; left in they would render as tofu in the list. + const trimmed = stripInlineTerminalContextPlaceholders(entry.prompt).trim().replace(/\s+/g, " "); + if (trimmed.length > 0) { + return trimmed.length > SNIPPET_MAX_CHARS ? `${trimmed.slice(0, SNIPPET_MAX_CHARS)}…` : trimmed; + } + const attachmentCount = entry.attachments.length + entry.droppedAttachmentNames.length; + if (attachmentCount > 0) { + return `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; + } + const chipCount = stashEntryChipCount(entry); + return chipCount > 0 ? `${chipCount} chip${chipCount === 1 ? "" : "s"}` : "Empty prompt"; +} + +const StashEntryRow = memo(function StashEntryRow(props: { + entry: PromptStashEntry; + onRestore: (entry: PromptStashEntry) => void; + onDelete: (entry: PromptStashEntry) => void; +}) { + const { entry, onRestore, onDelete } = props; + const thumbnails = entry.attachments + .filter((attachment) => attachment.mimeType.startsWith("image/")) + .slice(0, MAX_ROW_THUMBNAILS); + const chipCount = stashEntryChipCount(entry); + const missingCount = missingAttachmentCount(entry); + + return ( + { + onRestore(entry); + }} + > + {thumbnails.length > 0 ? ( + + {thumbnails.map((attachment) => ( + + ))} + + ) : ( + + ); +}); + +/** + * Popover body: the stash action for the current prompt, then the queue + * itself as a dense divided list. + */ +const ComposerStashList = memo(function ComposerStashList(props: ComposerStashControlProps) { + const { entries, canStash, stashShortcutLabel, onStash, onRestore, onDelete } = props; + + return ( + <> + {canStash ? ( + <> + + + + + ) : null} + {entries.length === 0 ? ( +

+ Nothing stashed yet. Press {stashShortcutLabel ?? "the stash shortcut"} with a prompt + typed to stash it. +

+ ) : ( + entries.map((entry) => ( + + )) + )} + + ); +}); + +/** + * Composer footer stash control: a bookmark button carrying the queue depth, + * opening the stashed prompts above it. Always visible, so the queue is never + * something the user has to remember a shortcut to find. + */ +export const ComposerStashControl = memo(function ComposerStashControl( + props: ComposerStashControlProps, +) { + const { entries, open, onOpenChange } = props; + + return ( + + 0 ? "sm" : "icon-sm"} + className={cn( + "rounded-full text-muted-foreground/70 hover:text-foreground/80", + entries.length > 0 && "gap-1 px-2", + )} + aria-label={ + entries.length > 0 ? `Stashed prompts (${entries.length})` : "Stashed prompts" + } + /> + } + > + + + + + + ); +}); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 623b77e4a..5dfb6721c 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -121,7 +121,12 @@ export interface ComposerFileAttachment extends ChatFileAttachment { export type ComposerAttachment = ComposerImageAttachment | ComposerFileAttachment; -const PersistedTerminalContextDraft = Schema.Struct({ +/** + * Context-chip schemas are exported so other persisted stores (the prompt + * stash) can carry the same chips without redeclaring their shapes. A second + * copy would drift the moment a field is added here. + */ +export const PersistedTerminalContextDraft = Schema.Struct({ id: Schema.String, threadId: ThreadId, createdAt: Schema.String, @@ -132,7 +137,7 @@ const PersistedTerminalContextDraft = Schema.Struct({ }); type PersistedTerminalContextDraft = typeof PersistedTerminalContextDraft.Type; -const PersistedTranscriptHighlightContextDraft = Schema.Struct({ +export const PersistedTranscriptHighlightContextDraft = Schema.Struct({ id: Schema.String, threadId: ThreadId, createdAt: Schema.String, @@ -144,7 +149,7 @@ const PersistedTranscriptHighlightContextDraft = Schema.Struct({ type PersistedTranscriptHighlightContextDraft = typeof PersistedTranscriptHighlightContextDraft.Type; -const PersistedPickedElementContextDraft = Schema.Struct({ +export const PersistedPickedElementContextDraft = Schema.Struct({ id: Schema.String, threadId: ThreadId, createdAt: Schema.String, @@ -163,7 +168,7 @@ const PersistedPickedElementContextDraft = Schema.Struct({ }); type PersistedPickedElementContextDraft = typeof PersistedPickedElementContextDraft.Type; -const PersistedFileSelectionContextDraft = Schema.Struct({ +export const PersistedFileSelectionContextDraft = Schema.Struct({ id: Schema.String, threadId: Schema.String, createdAt: Schema.String, @@ -534,6 +539,12 @@ interface ComposerDraftStoreState { attachments: PersistedComposerAttachment[], ) => void; clearComposerContent: (threadRef: ComposerThreadTarget) => void; + /** + * Clears exactly what a stash takes: prompt, attachments and the four + * persisted chip types. Drawings stay put, since the stash cannot carry + * their image and a chip without its picture shows nothing. + */ + clearStashableComposerContent: (threadRef: ComposerThreadTarget) => void; } export interface EffectiveComposerModelState { @@ -651,7 +662,15 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { }; } -function composerAttachmentDedupKey(attachment: ComposerAttachment): string { +/** + * Identity used to decide whether two attachments are "the same file" when + * one of them was rebuilt from storage. Exported so the prompt stash counts + * capacity against the same key the store will dedupe on, instead of burning + * a slot on an attachment the store then refuses. + */ +export function composerAttachmentDedupKey( + attachment: Pick, +): string { // Keep this independent from File.lastModified so dedupe is stable for hydrated // attachments reconstructed from localStorage (which get a fresh lastModified value). return `${attachment.mimeType}\u0000${attachment.sizeBytes}\u0000${attachment.name}`; @@ -2172,7 +2191,12 @@ function hydratePersistedComposerAttachment(attachment: PersistedComposerAttachm } } -function hydrateAttachmentsFromPersisted( +/** + * Rebuilds live composer attachments from their persisted data URLs. + * Exported so the prompt stash restores attachments through exactly the same + * path a reloaded draft does, rather than reinventing the image/file split. + */ +export function hydrateAttachmentsFromPersisted( attachments: ReadonlyArray, ): ComposerAttachment[] { return attachments.flatMap((attachment): ComposerAttachment[] => { @@ -2287,6 +2311,47 @@ const composerDraftStore = create()( (setBase, get) => { const set = setBase; + /** + * Empties the composer's content for a target. Sending clears + * everything; stashing keeps drawings, because a drawing is mostly its + * image and the stash does not carry one — clearing it here would + * destroy work the user can never get back. + */ + const clearDraftContent = ( + threadRef: ComposerThreadTarget, + options: { includeDrawingContexts: boolean }, + ): void => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; + } + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + prompt: "", + attachments: [], + nonPersistedAttachmentIds: [], + persistedAttachments: [], + terminalContexts: [], + transcriptHighlightContexts: [], + fileSelectionContexts: [], + pickedElementContexts: [], + ...(options.includeDrawingContexts ? { drawingContexts: [] } : {}), + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }; + return { draftsByThreadKey: {}, draftThreadsByThreadKey: {}, @@ -3542,35 +3607,10 @@ const composerDraftStore = create()( }); }, clearComposerContent: (threadRef) => { - const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; - if (threadKey.length === 0) { - return; - } - set((state) => { - const current = state.draftsByThreadKey[threadKey]; - if (!current) { - return state; - } - const nextDraft: ComposerThreadDraftState = { - ...current, - prompt: "", - attachments: [], - nonPersistedAttachmentIds: [], - persistedAttachments: [], - terminalContexts: [], - transcriptHighlightContexts: [], - fileSelectionContexts: [], - pickedElementContexts: [], - drawingContexts: [], - }; - const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadKey[threadKey]; - } else { - nextDraftsByThreadKey[threadKey] = nextDraft; - } - return { draftsByThreadKey: nextDraftsByThreadKey }; - }); + clearDraftContent(threadRef, { includeDrawingContexts: true }); + }, + clearStashableComposerContent: (threadRef) => { + clearDraftContent(threadRef, { includeDrawingContexts: false }); }, }; }, diff --git a/apps/web/src/lib/stashImageCompression.test.ts b/apps/web/src/lib/stashImageCompression.test.ts new file mode 100644 index 000000000..0bd3b1876 --- /dev/null +++ b/apps/web/src/lib/stashImageCompression.test.ts @@ -0,0 +1,219 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + compressImageForStash, + MAX_STASH_IMAGE_DATA_URL_CHARS, + readFileForStash, +} from "./stashImageCompression"; + +/** + * The unit environment has no real canvas or 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 + * 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", name = "shot.png"): File { + return new File([new Uint8Array(sizeBytes).fill(7)], name, { 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); + }); +}); + +describe("readFileForStash", () => { + it("stores a non-image file verbatim when it fits the budget", async () => { + const result = await readFileForStash(makeFile(1024, "application/pdf", "spec.pdf")); + + expect(result.ok).toBe(true); + expect(result.ok && result.image.recompressed).toBe(false); + expect(result.ok && result.image.mimeType).toBe("application/pdf"); + expect(result.ok && result.image.dataUrl.startsWith("data:application/pdf")).toBe(true); + }); + + it("reports too-large rather than lossily shrinking a file it cannot re-encode", async () => { + const result = await readFileForStash(makeFile(4_000_000, "application/pdf", "huge.pdf")); + + expect(result).toEqual({ ok: false, reason: "too-large" }); + }); +}); diff --git a/apps/web/src/lib/stashImageCompression.ts b/apps/web/src/lib/stashImageCompression.ts new file mode 100644 index 000000000..5f4e375eb --- /dev/null +++ b/apps/web/src/lib/stashImageCompression.ts @@ -0,0 +1,280 @@ +/** + * 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 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 to 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 cannot 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, 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 re-encoded; if it still + * does not fit after the fallback passes, the result is a `too-large` + * failure 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(); + } +} + +/** + * Serializes a non-image attachment verbatim. Files cannot be lossily + * re-encoded the way images can, so the only outcomes are "fits" (kept as-is) + * and "too-large" (dropped by name). Decode failures are reported separately + * so the restore toast can say which actually happened. + */ +export async function readFileForStash( + file: File, + budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS, +): Promise { + let dataUrl: string; + try { + dataUrl = await blobToDataUrl(file); + } catch { + return { ok: false, reason: "unreadable" }; + } + if (dataUrl.length > budgetChars) { + return { ok: false, reason: "too-large" }; + } + return { + ok: true, + image: { + dataUrl, + mimeType: file.type, + sizeBytes: file.size, + recompressed: false, + }, + }; +} diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts new file mode 100644 index 000000000..713e8e9ca --- /dev/null +++ b/apps/web/src/promptStashStore.test.ts @@ -0,0 +1,268 @@ +import { ThreadId } from "@threadlines/contracts"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +import { + MAX_STASH_ENTRIES, + MAX_STASH_ENTRY_ATTACHMENT_CHARS, + partitionStashAttachments, + setPromptStashStorageForTest, + stashEntryChipCount, + usePromptStashStore, + writePromptStashStorageForTest, + type PromptStashEntry, +} from "./promptStashStore"; + +function makeEntry(input: { + id: string; + 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}-file`, + name: "shot.png", + mimeType: "image/png", + sizeBytes: input.attachmentChars, + dataUrl: "x".repeat(input.attachmentChars), + }, + ] + : [], + droppedAttachmentNames: [], + }; +} + +function resetPromptStashStore() { + usePromptStashStore.setState({ entries: [] }); + writePromptStashStorageForTest(""); +} + +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.pdf", + mimeType: "application/pdf", + 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.pdf"]); + }); + + 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" })); + expect(usePromptStashStore.getState().entries.map((entry) => entry.id)).toEqual([ + "second", + "first", + ]); + }); + + it("evicts the oldest entry past the cap and returns it", () => { + const store = usePromptStashStore.getState(); + for (let index = 0; index < MAX_STASH_ENTRIES; 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 entries = usePromptStashStore.getState().entries; + expect(entries).toHaveLength(MAX_STASH_ENTRIES); + expect(entries[0]?.id).toBe("overflow"); + }); + + // The unit test environment has no `localStorage`, so the store runs on its + // in-memory fallback: the exact "kept for this session, gone on reload" + // case the composer must distinguish from an outright write failure. + it("distinguishes a memory-only write (written, not durable) from a failed one", () => { + const result = usePromptStashStore.getState().stashEntry(makeEntry({ id: "memory-only" })); + expect(result.written).toBe(true); + expect(result.durable).toBe(false); + expect(usePromptStashStore.getState().entries.map((entry) => entry.id)).toEqual([ + "memory-only", + ]); + }); + + it("takeEntry removes and returns the entry; a second take returns null", () => { + const store = usePromptStashStore.getState(); + store.stashEntry(makeEntry({ id: "keep" })); + store.stashEntry(makeEntry({ id: "take" })); + expect(store.takeEntry("take").entry?.id).toBe("take"); + expect(store.takeEntry("take").entry).toBeNull(); + expect(usePromptStashStore.getState().entries.map((entry) => entry.id)).toEqual(["keep"]); + }); + + it("finalizeEntryAttachments attaches files and clears the pending count", () => { + const store = usePromptStashStore.getState(); + store.stashEntry({ ...makeEntry({ id: "pending" }), pendingAttachmentCount: 2 }); + + const { attached } = store.finalizeEntryAttachments("pending", { + attachments: [ + { + id: "file-1", + name: "a.webp", + mimeType: "image/webp", + sizeBytes: 10, + dataUrl: "data:image/webp;base64,AAAA", + }, + ], + droppedAttachmentNames: ["big.png"], + unreadableAttachmentNames: [], + }); + + expect(attached).toBe(true); + const entry = usePromptStashStore.getState().entries[0]; + expect(entry?.attachments).toHaveLength(1); + expect(entry?.droppedAttachmentNames).toEqual(["big.png"]); + expect(entry?.pendingAttachmentCount).toBe(0); + }); + + it("finalizeEntryAttachments reports false when the entry was already taken", () => { + const store = usePromptStashStore.getState(); + store.stashEntry({ ...makeEntry({ id: "racing" }), pendingAttachmentCount: 1 }); + // Restored (or deleted) while its attachments were still encoding. + store.takeEntry("racing"); + + const { attached } = store.finalizeEntryAttachments("racing", { + attachments: [], + droppedAttachmentNames: [], + unreadableAttachmentNames: [], + }); + + expect(attached).toBe(false); + }); + + it("settles a pending count left behind by a crashed or closed session", () => { + writePromptStashStorageForTest( + JSON.stringify({ + version: 1, + state: { + entries: [{ ...makeEntry({ id: "orphan" }), pendingAttachmentCount: 2 }], + }, + }), + ); + + // Hydration must settle the stale count, or the entry would stay stuck + // showing "saving" with attachments that no longer exist anywhere. + const entry = usePromptStashStore.getState().entries[0]; + expect(entry?.pendingAttachmentCount).toBe(0); + expect(entry?.unreadableAttachmentNames).toHaveLength(2); + }); + + it("round-trips context chips through storage", () => { + const threadId = ThreadId.make("thread-1"); + const entry: PromptStashEntry = { + ...makeEntry({ id: "chips", prompt: "look at this " }), + terminalContexts: [ + { + id: "term-1", + threadId, + createdAt: "2026-07-24T12:00:00.000Z", + terminalId: "t1", + terminalLabel: "zsh", + lineStart: 3, + lineEnd: 9, + text: "npm run build\nfailed", + }, + ], + fileSelectionContexts: [ + { + id: "file-1", + threadId: "thread-1", + createdAt: "2026-07-24T12:00:00.000Z", + relativePath: "src/app.ts", + startLine: 1, + endLine: 4, + selectedText: "const a = 1;", + }, + ], + }; + usePromptStashStore.getState().stashEntry(entry); + + // Force a decode of the persisted payload rather than trusting the + // in-memory copy: a chip schema that cannot round-trip would drop the + // whole entry on the next reload. + const raw = JSON.stringify({ version: 1, state: { entries: [entry] } }); + writePromptStashStorageForTest(raw); + + const hydrated = usePromptStashStore.getState().entries[0]; + expect(hydrated?.terminalContexts?.[0]?.text).toBe("npm run build\nfailed"); + expect(hydrated?.fileSelectionContexts?.[0]?.relativePath).toBe("src/app.ts"); + expect(stashEntryChipCount(hydrated as PromptStashEntry)).toBe(2); + }); + + it("rolls back a rejected write so the queue never shows an unsaved entry", () => { + const store = usePromptStashStore.getState(); + store.stashEntry(makeEntry({ id: "already-there" })); + + setPromptStashStorageForTest({ + getItem: () => null, + setItem: () => { + throw new DOMException("quota", "QuotaExceededError"); + }, + removeItem: () => {}, + }); + const result = store.stashEntry(makeEntry({ id: "rejected" })); + setPromptStashStorageForTest(null); + + expect(result.written).toBe(false); + expect(result.durable).toBe(false); + expect(result.evicted).toBeNull(); + // The composer keeps its content on a failed write, so a visible entry + // here would duplicate the prompt the user still has typed. + expect(usePromptStashStore.getState().entries.map((entry) => entry.id)).toEqual([ + "already-there", + ]); + }); + + it("ignores an undecodable payload rather than throwing at hydrate", () => { + writePromptStashStorageForTest( + JSON.stringify({ version: 1, state: { entries: [{ id: "broken" }] } }), + ); + expect(usePromptStashStore.getState().entries).toEqual([]); + }); +}); diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts new file mode 100644 index 000000000..9f3d462fa --- /dev/null +++ b/apps/web/src/promptStashStore.ts @@ -0,0 +1,451 @@ +import * as Schema from "effect/Schema"; +import { create } from "zustand"; + +import { + PersistedComposerAttachment, + PersistedFileSelectionContextDraft, + PersistedPickedElementContextDraft, + PersistedTerminalContextDraft, + PersistedTranscriptHighlightContextDraft, +} from "./composerDraftStore"; +import type { FileSelectionContextDraft } from "./lib/fileSelectionContext"; +import type { PickedElementContextDraft } from "./lib/pickedElementContext"; +import { createMemoryStorage, type StateStorage } from "./lib/storage"; +import type { TerminalContextDraft } from "./lib/terminalContext"; +import type { TranscriptHighlightContextDraft } from "./lib/transcriptHighlightContext"; + +export const PROMPT_STASH_STORAGE_KEY = "threadlines:prompt-stash:v1"; +const PROMPT_STASH_STORAGE_VERSION = 1; + +export const MAX_STASH_ENTRIES = 20; +/** + * Budget for an entry's serialized attachment payload. localStorage is a + * ~5MB origin-wide quota shared with the composer draft store, so oversized + * attachments are dropped (tracked in `droppedAttachmentNames`) 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; + +/** + * Terminal chips carry their captured text in the stash, unlike the draft + * store which persists only the selection range. A draft is reopened in the + * same session against a live terminal, so it can re-read the buffer; a + * stashed prompt is explicitly meant to be restored later, possibly in + * another thread, where that terminal may be long gone. Without the snapshot + * the restored chip would come back already expired. + */ +const StashTerminalContextDraft = Schema.Struct({ + ...PersistedTerminalContextDraft.fields, + text: Schema.String, +}); + +/** + * A stashed prompt carries the composer content that survives a move to + * another thread: text, attachments, and the context chips the draft store + * already knows how to persist. Deliberately no provider instance or model + * selection — the point of stashing is to move a prompt into a different + * thread or provider, so restoring must never drag the old model choice + * along. Drawing contexts are excluded for the same reason drafts skip them: + * a drawing is mostly its image, and one without a picture is a chip that + * shows nothing. + */ +const StashEntrySchema = Schema.Struct({ + id: Schema.String, + createdAt: Schema.String, + prompt: Schema.String, + attachments: Schema.Array(PersistedComposerAttachment), + /** Names of attachments that exceeded the entry budget and were not saved. */ + droppedAttachmentNames: Schema.Array(Schema.String), + /** + * Names of attachments that could not be read or re-encoded at all — a + * distinct failure from exceeding the size budget, so the restore toast can + * explain which actually happened. Optional: entries written before this + * field existed decode without it. + */ + unreadableAttachmentNames: Schema.optionalKey(Schema.Array(Schema.String)), + /** + * Attachments still being encoded when the entry was written. The entry is + * persisted before its attachments so a crash mid-encode cannot lose the + * prompt; this field lets the popover show "N still saving" until + * `finalizeEntryAttachments` lands, and flags entries orphaned by a reload. + */ + pendingAttachmentCount: Schema.optionalKey(Schema.Number), + terminalContexts: Schema.optionalKey(Schema.Array(StashTerminalContextDraft)), + transcriptHighlightContexts: Schema.optionalKey( + Schema.Array(PersistedTranscriptHighlightContextDraft), + ), + fileSelectionContexts: Schema.optionalKey(Schema.Array(PersistedFileSelectionContextDraft)), + pickedElementContexts: Schema.optionalKey(Schema.Array(PersistedPickedElementContextDraft)), +}); +export type PromptStashEntry = typeof StashEntrySchema.Type; + +const PersistedPromptStashState = Schema.Struct({ + entries: Schema.Array(StashEntrySchema), +}); + +const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPromptStashState); + +type StashEntryContexts = Pick< + PromptStashEntry, + | "terminalContexts" + | "transcriptHighlightContexts" + | "fileSelectionContexts" + | "pickedElementContexts" +>; + +/** + * Narrows live composer chips to the fields the stash persists, dropping the + * keys the schema does not declare. Keeping this beside the schema means a + * new chip field is added in one place, not two. + * + * Empty lists are omitted entirely so an entry without chips stays as small + * on disk as it was before chips existed. + */ +export function buildStashEntryContexts(input: { + terminalContexts: ReadonlyArray; + transcriptHighlightContexts: ReadonlyArray; + fileSelectionContexts: ReadonlyArray; + pickedElementContexts: ReadonlyArray; +}): StashEntryContexts { + const contexts: { -readonly [K in keyof StashEntryContexts]: StashEntryContexts[K] } = {}; + if (input.terminalContexts.length > 0) { + contexts.terminalContexts = input.terminalContexts.map((context) => ({ + id: context.id, + threadId: context.threadId, + createdAt: context.createdAt, + terminalId: context.terminalId, + terminalLabel: context.terminalLabel, + lineStart: context.lineStart, + lineEnd: context.lineEnd, + text: context.text, + })); + } + if (input.transcriptHighlightContexts.length > 0) { + contexts.transcriptHighlightContexts = input.transcriptHighlightContexts.map((context) => ({ + id: context.id, + threadId: context.threadId, + createdAt: context.createdAt, + sourceMessageId: context.sourceMessageId, + sourceRole: context.sourceRole, + selectedText: context.selectedText, + note: context.note, + })); + } + if (input.fileSelectionContexts.length > 0) { + contexts.fileSelectionContexts = input.fileSelectionContexts.map((context) => ({ + id: context.id, + threadId: context.threadId, + createdAt: context.createdAt, + relativePath: context.relativePath, + startLine: context.startLine, + endLine: context.endLine, + selectedText: context.selectedText, + ...(context.wholeFile === undefined ? {} : { wholeFile: context.wholeFile }), + })); + } + if (input.pickedElementContexts.length > 0) { + contexts.pickedElementContexts = input.pickedElementContexts.map((context) => ({ + id: context.id, + threadId: context.threadId, + createdAt: context.createdAt, + note: context.note, + styleChanges: context.styleChanges.map((change) => ({ ...change })), + tagName: context.tagName, + role: context.role, + name: context.name, + selector: context.selector, + text: context.text, + width: context.width, + height: context.height, + url: context.url, + })); + } + return contexts; +} + +/** + * Turns an entry's chips back into live composer drafts. The `threadId` on + * each is left as stashed: every store action that accepts these re-stamps it + * for the thread being restored into, which is what makes a stash portable + * across threads in the first place. + */ +export function stashEntryContextDrafts(entry: PromptStashEntry): { + terminalContexts: TerminalContextDraft[]; + transcriptHighlightContexts: TranscriptHighlightContextDraft[]; + fileSelectionContexts: FileSelectionContextDraft[]; + pickedElementContexts: PickedElementContextDraft[]; +} { + return { + terminalContexts: (entry.terminalContexts ?? []).map((context) => ({ ...context })), + transcriptHighlightContexts: (entry.transcriptHighlightContexts ?? []).map((context) => ({ + ...context, + })), + fileSelectionContexts: (entry.fileSelectionContexts ?? []).map((context) => ({ ...context })), + pickedElementContexts: (entry.pickedElementContexts ?? []).map((context) => ({ + ...context, + styleChanges: context.styleChanges.map((change) => ({ ...change })), + })), + }; +} + +/** Total context chips carried by an entry, for the popover's summary. */ +export function stashEntryChipCount(entry: PromptStashEntry): number { + return ( + (entry.terminalContexts?.length ?? 0) + + (entry.transcriptHighlightContexts?.length ?? 0) + + (entry.fileSelectionContexts?.length ?? 0) + + (entry.pickedElementContexts?.length ?? 0) + ); +} + +/** + * `pendingAttachmentCount` 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 attachments are genuinely gone (they were never written), so they are + * recorded as unreadable to keep the prompt itself restorable. + */ +function clearOrphanedPendingAttachments( + entries: ReadonlyArray, +): ReadonlyArray { + return entries.map((entry) => { + if (!entry.pendingAttachmentCount) return entry; + const lostCount = entry.pendingAttachmentCount; + return { + ...entry, + pendingAttachmentCount: 0, + unreadableAttachmentNames: [ + ...(entry.unreadableAttachmentNames ?? []), + ...Array.from( + { length: lostCount }, + (_, index) => `attachment ${index + 1} (not saved before reload)`, + ), + ], + }; + }); +} + +/** + * 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 ones win. + */ +export function partitionStashAttachments( + attachments: ReadonlyArray, +): { + kept: PersistedComposerAttachment[]; + droppedNames: string[]; +} { + const kept: PersistedComposerAttachment[] = []; + 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 }; +} + +let baseStashStorage: StateStorage; +let storageIsDurable: boolean; +{ + const resolved = resolveBaseStorage(); + baseStashStorage = resolved.storage; + storageIsDurable = resolved.durable; +} + +/** + * Persists the queue, 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 persistEntries(entries: ReadonlyArray): { + /** 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: { entries }, + }), + ); + 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 queue, settling stale pending counts. */ +function readPersistedEntries(): ReadonlyArray | 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 clearOrphanedPendingAttachments(decodePersistedPromptStashState(state).entries); + } catch { + return null; + } +} + +interface PromptStashStoreState { + entries: ReadonlyArray; + /** + * Prepends an entry to the queue, evicting the oldest entry past the cap. + * Returns the evicted entry (for messaging) if any. + */ + stashEntry: (entry: PromptStashEntry) => { + evicted: PromptStashEntry | null; + /** False when the write failed outright (e.g. quota); nothing was kept. */ + written: boolean; + /** + * False when the write will not survive a reload: either it failed, or it + * landed only in the in-memory fallback because localStorage is blocked. + */ + durable: boolean; + }; + /** + * Removes and returns an entry from the queue (restore + delete). + * `durable` is false when the removal could not be persisted, meaning a + * reload would resurrect the entry. + */ + takeEntry: (entryId: string) => { entry: PromptStashEntry | null; durable: boolean }; + /** + * Attaches the encoded attachments 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 files did not make it. + */ + finalizeEntryAttachments: ( + entryId: string, + attachments: { + attachments: ReadonlyArray; + droppedAttachmentNames: ReadonlyArray; + unreadableAttachmentNames: ReadonlyArray; + }, + ) => { attached: boolean; durable: boolean }; +} + +export const usePromptStashStore = create()((set, get) => ({ + entries: [], + stashEntry: (entry) => { + const nextEntries = [entry, ...get().entries]; + const evicted = nextEntries.length > MAX_STASH_ENTRIES ? (nextEntries.pop() ?? null) : null; + const { written, durable } = persistEntries(nextEntries); + // 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, written: false, durable: false }; + } + set(() => ({ entries: nextEntries })); + return { evicted, written: true, durable }; + }, + takeEntry: (entryId) => { + const entries = get().entries; + const entry = entries.find((candidate) => candidate.id === entryId) ?? null; + if (!entry) return { entry: null, durable: true }; + const nextEntries = entries.filter((candidate) => candidate.id !== entryId); + const { durable } = persistEntries(nextEntries); + set(() => ({ entries: nextEntries })); + return { entry, durable }; + }, + finalizeEntryAttachments: (entryId, incoming) => { + const entries = get().entries; + const index = entries.findIndex((candidate) => candidate.id === entryId); + const existing = index === -1 ? undefined : entries[index]; + // Restored or deleted mid-encode: nothing to attach to. + if (!existing) return { attached: false, durable: true }; + const nextEntries = [...entries]; + nextEntries[index] = { + ...existing, + attachments: incoming.attachments, + droppedAttachmentNames: incoming.droppedAttachmentNames, + unreadableAttachmentNames: incoming.unreadableAttachmentNames, + pendingAttachmentCount: 0, + }; + const { durable } = persistEntries(nextEntries); + set(() => ({ entries: nextEntries })); + 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 = readPersistedEntries(); + if (persisted) { + usePromptStashStore.setState({ entries: persisted }); + } +} + +/** + * 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({ entries: readPersistedEntries() ?? [] }); +} + +/** + * Test seam: swaps the backing storage so the quota-rejection path (a + * `setItem` that throws) can be exercised. Pass `null` to restore the + * environment's own storage. + */ +export function setPromptStashStorageForTest( + storage: StateStorage | null, + options?: { durable?: boolean }, +): void { + if (storage === null) { + const resolved = resolveBaseStorage(); + baseStashStorage = resolved.storage; + storageIsDurable = resolved.durable; + return; + } + baseStashStorage = storage; + storageIsDurable = options?.durable ?? true; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 262dcdc59..f4c57490c 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,27 +9,26 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as SettingsRouteImport } from './routes/settings' -import { Route as PairRouteImport } from './routes/pair' import { Route as ChatRouteImport } from './routes/_chat' -import { Route as SettingsIndexRouteImport } from './routes/settings.index' +import { Route as PairRouteImport } from './routes/pair' +import { Route as SettingsRouteImport } from './routes/settings' import { Route as ChatIndexRouteImport } from './routes/_chat.index' -import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' -import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' -import { Route as SettingsPluginsRouteImport } from './routes/settings.plugins' -import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' -import { Route as SettingsInstructionsRouteImport } from './routes/settings.instructions' -import { Route as SettingsGeneralRouteImport } from './routes/settings.general' -import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' -import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' -import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ChatChatsRouteImport } from './routes/_chat.chats' -import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' +import { Route as SettingsIndexRouteImport } from './routes/settings.index' +import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' +import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' +import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' +import { Route as SettingsGeneralRouteImport } from './routes/settings.general' +import { Route as SettingsInstructionsRouteImport } from './routes/settings.instructions' +import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' +import { Route as SettingsPluginsRouteImport } from './routes/settings.plugins' +import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' +import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' +import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' -const SettingsRoute = SettingsRouteImport.update({ - id: '/settings', - path: '/settings', +const ChatRoute = ChatRouteImport.update({ + id: '/_chat', getParentRoute: () => rootRouteImport, } as any) const PairRoute = PairRouteImport.update({ @@ -37,43 +36,39 @@ const PairRoute = PairRouteImport.update({ path: '/pair', getParentRoute: () => rootRouteImport, } as any) -const ChatRoute = ChatRouteImport.update({ - id: '/_chat', +const SettingsRoute = SettingsRouteImport.update({ + id: '/settings', + path: '/settings', getParentRoute: () => rootRouteImport, } as any) -const SettingsIndexRoute = SettingsIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => SettingsRoute, -} as any) const ChatIndexRoute = ChatIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => ChatRoute, } as any) -const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ - id: '/source-control', - path: '/source-control', - getParentRoute: () => SettingsRoute, +const ChatChatsRoute = ChatChatsRouteImport.update({ + id: '/chats', + path: '/chats', + getParentRoute: () => ChatRoute, } as any) -const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ - id: '/providers', - path: '/providers', +const SettingsIndexRoute = SettingsIndexRouteImport.update({ + id: '/', + path: '/', getParentRoute: () => SettingsRoute, } as any) -const SettingsPluginsRoute = SettingsPluginsRouteImport.update({ - id: '/plugins', - path: '/plugins', +const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ + id: '/archived', + path: '/archived', getParentRoute: () => SettingsRoute, } as any) -const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ - id: '/keybindings', - path: '/keybindings', +const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ + id: '/connections', + path: '/connections', getParentRoute: () => SettingsRoute, } as any) -const SettingsInstructionsRoute = SettingsInstructionsRouteImport.update({ - id: '/instructions', - path: '/instructions', +const SettingsDiagnosticsRoute = SettingsDiagnosticsRouteImport.update({ + id: '/diagnostics', + path: '/diagnostics', getParentRoute: () => SettingsRoute, } as any) const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ @@ -81,30 +76,30 @@ const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ path: '/general', getParentRoute: () => SettingsRoute, } as any) -const SettingsDiagnosticsRoute = SettingsDiagnosticsRouteImport.update({ - id: '/diagnostics', - path: '/diagnostics', +const SettingsInstructionsRoute = SettingsInstructionsRouteImport.update({ + id: '/instructions', + path: '/instructions', getParentRoute: () => SettingsRoute, } as any) -const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ - id: '/connections', - path: '/connections', +const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ + id: '/keybindings', + path: '/keybindings', getParentRoute: () => SettingsRoute, } as any) -const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ - id: '/archived', - path: '/archived', +const SettingsPluginsRoute = SettingsPluginsRouteImport.update({ + id: '/plugins', + path: '/plugins', getParentRoute: () => SettingsRoute, } as any) -const ChatChatsRoute = ChatChatsRouteImport.update({ - id: '/chats', - path: '/chats', - getParentRoute: () => ChatRoute, +const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ + id: '/providers', + path: '/providers', + getParentRoute: () => SettingsRoute, } as any) -const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ - id: '/draft/$draftId', - path: '/draft/$draftId', - getParentRoute: () => ChatRoute, +const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ + id: '/source-control', + path: '/source-control', + getParentRoute: () => SettingsRoute, } as any) const ChatEnvironmentIdThreadIdRoute = ChatEnvironmentIdThreadIdRouteImport.update({ @@ -112,6 +107,11 @@ const ChatEnvironmentIdThreadIdRoute = path: '/$environmentId/$threadId', getParentRoute: () => ChatRoute, } as any) +const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ + id: '/draft/$draftId', + path: '/draft/$draftId', + getParentRoute: () => ChatRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute @@ -233,11 +233,11 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/settings': { - id: '/settings' - path: '/settings' - fullPath: '/settings' - preLoaderRoute: typeof SettingsRouteImport + '/_chat': { + id: '/_chat' + path: '' + fullPath: '/' + preLoaderRoute: typeof ChatRouteImport parentRoute: typeof rootRouteImport } '/pair': { @@ -247,20 +247,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PairRouteImport parentRoute: typeof rootRouteImport } - '/_chat': { - id: '/_chat' - path: '' - fullPath: '/' - preLoaderRoute: typeof ChatRouteImport + '/settings': { + id: '/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof SettingsRouteImport parentRoute: typeof rootRouteImport } - '/settings/': { - id: '/settings/' - path: '/' - fullPath: '/settings/' - preLoaderRoute: typeof SettingsIndexRouteImport - parentRoute: typeof SettingsRoute - } '/_chat/': { id: '/_chat/' path: '/' @@ -268,32 +261,46 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatIndexRouteImport parentRoute: typeof ChatRoute } - '/settings/source-control': { - id: '/settings/source-control' - path: '/source-control' - fullPath: '/settings/source-control' - preLoaderRoute: typeof SettingsSourceControlRouteImport + '/_chat/chats': { + id: '/_chat/chats' + path: '/chats' + fullPath: '/chats' + preLoaderRoute: typeof ChatChatsRouteImport + parentRoute: typeof ChatRoute + } + '/settings/': { + id: '/settings/' + path: '/' + fullPath: '/settings/' + preLoaderRoute: typeof SettingsIndexRouteImport parentRoute: typeof SettingsRoute } - '/settings/providers': { - id: '/settings/providers' - path: '/providers' - fullPath: '/settings/providers' - preLoaderRoute: typeof SettingsProvidersRouteImport + '/settings/archived': { + id: '/settings/archived' + path: '/archived' + fullPath: '/settings/archived' + preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } - '/settings/plugins': { - id: '/settings/plugins' - path: '/plugins' - fullPath: '/settings/plugins' - preLoaderRoute: typeof SettingsPluginsRouteImport + '/settings/connections': { + id: '/settings/connections' + path: '/connections' + fullPath: '/settings/connections' + preLoaderRoute: typeof SettingsConnectionsRouteImport parentRoute: typeof SettingsRoute } - '/settings/keybindings': { - id: '/settings/keybindings' - path: '/keybindings' - fullPath: '/settings/keybindings' - preLoaderRoute: typeof SettingsKeybindingsRouteImport + '/settings/diagnostics': { + id: '/settings/diagnostics' + path: '/diagnostics' + fullPath: '/settings/diagnostics' + preLoaderRoute: typeof SettingsDiagnosticsRouteImport + parentRoute: typeof SettingsRoute + } + '/settings/general': { + id: '/settings/general' + path: '/general' + fullPath: '/settings/general' + preLoaderRoute: typeof SettingsGeneralRouteImport parentRoute: typeof SettingsRoute } '/settings/instructions': { @@ -303,39 +310,39 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsInstructionsRouteImport parentRoute: typeof SettingsRoute } - '/settings/general': { - id: '/settings/general' - path: '/general' - fullPath: '/settings/general' - preLoaderRoute: typeof SettingsGeneralRouteImport + '/settings/keybindings': { + id: '/settings/keybindings' + path: '/keybindings' + fullPath: '/settings/keybindings' + preLoaderRoute: typeof SettingsKeybindingsRouteImport parentRoute: typeof SettingsRoute } - '/settings/diagnostics': { - id: '/settings/diagnostics' - path: '/diagnostics' - fullPath: '/settings/diagnostics' - preLoaderRoute: typeof SettingsDiagnosticsRouteImport + '/settings/plugins': { + id: '/settings/plugins' + path: '/plugins' + fullPath: '/settings/plugins' + preLoaderRoute: typeof SettingsPluginsRouteImport parentRoute: typeof SettingsRoute } - '/settings/connections': { - id: '/settings/connections' - path: '/connections' - fullPath: '/settings/connections' - preLoaderRoute: typeof SettingsConnectionsRouteImport + '/settings/providers': { + id: '/settings/providers' + path: '/providers' + fullPath: '/settings/providers' + preLoaderRoute: typeof SettingsProvidersRouteImport parentRoute: typeof SettingsRoute } - '/settings/archived': { - id: '/settings/archived' - path: '/archived' - fullPath: '/settings/archived' - preLoaderRoute: typeof SettingsArchivedRouteImport + '/settings/source-control': { + id: '/settings/source-control' + path: '/source-control' + fullPath: '/settings/source-control' + preLoaderRoute: typeof SettingsSourceControlRouteImport parentRoute: typeof SettingsRoute } - '/_chat/chats': { - id: '/_chat/chats' - path: '/chats' - fullPath: '/chats' - preLoaderRoute: typeof ChatChatsRouteImport + '/_chat/$environmentId/$threadId': { + id: '/_chat/$environmentId/$threadId' + path: '/$environmentId/$threadId' + fullPath: '/$environmentId/$threadId' + preLoaderRoute: typeof ChatEnvironmentIdThreadIdRouteImport parentRoute: typeof ChatRoute } '/_chat/draft/$draftId': { @@ -345,13 +352,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatDraftDraftIdRouteImport parentRoute: typeof ChatRoute } - '/_chat/$environmentId/$threadId': { - id: '/_chat/$environmentId/$threadId' - path: '/$environmentId/$threadId' - fullPath: '/$environmentId/$threadId' - preLoaderRoute: typeof ChatEnvironmentIdThreadIdRouteImport - parentRoute: typeof ChatRoute - } } } diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 303d5c71c..ca7f17348 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -56,6 +56,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "commandPalette.toggle", "chat.new", "chat.newLocal", + "composer.stash", "editor.openFavorite", ...MODEL_PICKER_KEYBINDING_COMMANDS, ...THREAD_KEYBINDING_COMMANDS, diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index e558c0969..76430d43e 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -29,6 +29,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, + { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" },