import * as Schema from "effect/Schema"; import { create } from "zustand"; import { PersistedComposerFileAttachment, PersistedComposerImageAttachment, } from "./composerDraftStore"; import { createMemoryStorage, type StateStorage } from "./lib/storage"; export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v2"; /** * v1 bucketed entries into per-provider-instance queues and stored a model * selection with each prompt. The stash is provider-agnostic now, so the old * payload is deleted at startup rather than migrated — left behind it would * silently hold megabytes of the origin's ~5MB localStorage quota forever. */ const LEGACY_PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1"; const PROMPT_STASH_STORAGE_VERSION = 2; 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 * 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; /** * Stashed files keep signed-upload references instead of storing their bytes. * Image payloads remain subject to the localStorage budget. */ const StashEntrySchema = Schema.Struct({ id: Schema.String, createdAt: Schema.String, prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), files: Schema.optionalKey(Schema.Array(PersistedComposerFileAttachment)), /** 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({ entries: 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( entries: ReadonlyArray, ): ReadonlyArray { return entries.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)`, ), ], }; }); } /** * 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 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 clearOrphanedPendingImages(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 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: ( entryId: string, images: { attachments: ReadonlyArray; droppedImageNames: ReadonlyArray; unreadableImageNames: 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 }; }, finalizeEntryImages: (entryId, images) => { 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: images.attachments, droppedImageNames: images.droppedImageNames, unreadableImageNames: images.unreadableImageNames, pendingImageCount: 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. { try { baseStashStorage.removeItem(LEGACY_PROMPT_STASH_STORAGE_KEY); } catch { // Purging the v1 payload is best-effort; a storage policy that rejects // the delete must not take down module init. } 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() ?? [] }); }