| 1 | import { ComposerContextRecord, ForwardCompatibleArray } from "@t3tools/contracts"; |
| 2 | import * as Schema from "effect/Schema"; |
| 3 | import { create } from "zustand"; |
| 4 | |
| 5 | import { |
| 6 | PersistedComposerFileAttachment, |
| 7 | PersistedComposerImageAttachment, |
| 8 | } from "./composerDraftStore"; |
| 9 | import { createMemoryStorage, type StateStorage } from "./lib/storage"; |
| 10 | |
| 11 | export const PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v2"; |
| 12 | /** |
| 13 | * v1 bucketed entries into per-provider-instance queues and stored a model |
| 14 | * selection with each prompt. The stash is provider-agnostic now, so the old |
| 15 | * payload is deleted at startup rather than migrated — left behind it would |
| 16 | * silently hold megabytes of the origin's ~5MB localStorage quota forever. |
| 17 | */ |
| 18 | const LEGACY_PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1"; |
| 19 | const PROMPT_STASH_STORAGE_VERSION = 2; |
| 20 | |
| 21 | export const MAX_STASH_ENTRIES = 20; |
| 22 | /** |
| 23 | * Budget for an entry's serialized attachment payload. localStorage is a |
| 24 | * ~5MB origin-wide quota shared with the composer draft store, so oversized |
| 25 | * images are dropped (tracked in `droppedImageNames`) rather than persisted. |
| 26 | * |
| 27 | * Sized to hold two images at the per-image compression budget |
| 28 | * (`MAX_STASH_IMAGE_DATA_URL_CHARS`) so a typical before/after screenshot |
| 29 | * pair survives intact. |
| 30 | */ |
| 31 | export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000; |
| 32 | |
| 33 | /** |
| 34 | * Stashed files keep signed-upload references instead of storing their bytes. |
| 35 | * Image payloads remain subject to the localStorage budget. |
| 36 | */ |
| 37 | const StashEntrySchema = Schema.Struct({ |
| 38 | id: Schema.String, |
| 39 | createdAt: Schema.String, |
| 40 | prompt: Schema.String, |
| 41 | attachments: Schema.Array(PersistedComposerImageAttachment), |
| 42 | files: Schema.optionalKey(Schema.Array(PersistedComposerFileAttachment)), |
| 43 | /** Names of images that exceeded the attachment budget and were not saved. */ |
| 44 | droppedImageNames: Schema.Array(Schema.String), |
| 45 | /** |
| 46 | * Names of images that could not be decoded or re-encoded at all — a |
| 47 | * distinct failure from exceeding the size budget, so the menu can explain |
| 48 | * which actually happened. Optional: entries written before this field |
| 49 | * existed decode without it. |
| 50 | */ |
| 51 | unreadableImageNames: Schema.optionalKey(Schema.Array(Schema.String)), |
| 52 | /** |
| 53 | * Images still being encoded when the entry was written. The entry is |
| 54 | * persisted before its images so a crash mid-encode cannot lose the prompt; |
| 55 | * this field lets the UI show "N images still saving" until |
| 56 | * `finalizeEntryImages` lands, and flags entries orphaned by a reload. |
| 57 | */ |
| 58 | pendingImageCount: Schema.optionalKey(Schema.Number), |
| 59 | /** |
| 60 | * Payloads behind the prompt's context links (terminal excerpts, review comments, preview |
| 61 | * annotations). Images and files have their own fields above. Optional: older entries |
| 62 | * decode without it. |
| 63 | */ |
| 64 | records: Schema.optionalKey(ForwardCompatibleArray(ComposerContextRecord)), |
| 65 | }); |
| 66 | export type PromptStashEntry = typeof StashEntrySchema.Type; |
| 67 | |
| 68 | const PersistedPromptStashState = Schema.Struct({ |
| 69 | entries: Schema.Array(StashEntrySchema), |
| 70 | }); |
| 71 | type PersistedPromptStashState = typeof PersistedPromptStashState.Type; |
| 72 | |
| 73 | const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPromptStashState); |
| 74 | |
| 75 | /** |
| 76 | * `pendingImageCount` only has meaning within the session that wrote it: the |
| 77 | * encode loop that would clear it does not survive a reload. Any entry that |
| 78 | * comes back from storage still pending was orphaned by a closed tab or a |
| 79 | * crash mid-encode, so the count is settled here — otherwise the entry would |
| 80 | * be stuck showing "saving…" and refuse to restore forever. |
| 81 | * |
| 82 | * The images are genuinely gone (they were never written), so they are |
| 83 | * recorded as unreadable to keep the prompt itself restorable. |
| 84 | */ |
| 85 | function clearOrphanedPendingImages( |
| 86 | entries: ReadonlyArray<PromptStashEntry>, |
| 87 | ): ReadonlyArray<PromptStashEntry> { |
| 88 | return entries.map((entry) => { |
| 89 | if (!entry.pendingImageCount) return entry; |
| 90 | const lostCount = entry.pendingImageCount; |
| 91 | return { |
| 92 | ...entry, |
| 93 | pendingImageCount: 0, |
| 94 | unreadableImageNames: [ |
| 95 | ...(entry.unreadableImageNames ?? []), |
| 96 | ...Array.from( |
| 97 | { length: lostCount }, |
| 98 | (_, index) => `image ${index + 1} (not saved before reload)`, |
| 99 | ), |
| 100 | ], |
| 101 | }; |
| 102 | }); |
| 103 | } |
| 104 | |
| 105 | /** |
| 106 | * Splits candidate attachments into a persistable set within the entry |
| 107 | * budget plus the names of any that had to be dropped. Attachments are |
| 108 | * admitted in order so the earliest-added images win. |
| 109 | */ |
| 110 | export function partitionStashAttachments( |
| 111 | attachments: ReadonlyArray<PersistedComposerImageAttachment>, |
| 112 | ): { |
| 113 | kept: PersistedComposerImageAttachment[]; |
| 114 | droppedNames: string[]; |
| 115 | } { |
| 116 | const kept: PersistedComposerImageAttachment[] = []; |
| 117 | const droppedNames: string[] = []; |
| 118 | let usedChars = 0; |
| 119 | for (const attachment of attachments) { |
| 120 | if (usedChars + attachment.dataUrl.length > MAX_STASH_ENTRY_ATTACHMENT_CHARS) { |
| 121 | droppedNames.push(attachment.name); |
| 122 | continue; |
| 123 | } |
| 124 | usedChars += attachment.dataUrl.length; |
| 125 | kept.push(attachment); |
| 126 | } |
| 127 | return { kept, droppedNames }; |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * Reading the `localStorage` property itself can throw `SecurityError` when |
| 132 | * storage is blocked by policy or the page is a sandboxed iframe — so the |
| 133 | * access has to be guarded, not just the get/set calls on it. Otherwise |
| 134 | * importing this module would crash the app at load. |
| 135 | * |
| 136 | * `durable` is false for the in-memory fallback: writes there "succeed" but |
| 137 | * vanish on reload, and callers clear the composer on the strength of a |
| 138 | * successful stash, so they must be told the difference. |
| 139 | */ |
| 140 | function resolveBaseStorage(): { storage: StateStorage; durable: boolean } { |
| 141 | try { |
| 142 | if (typeof localStorage !== "undefined") { |
| 143 | return { storage: localStorage, durable: true }; |
| 144 | } |
| 145 | } catch { |
| 146 | // Fall through to the in-memory store. |
| 147 | } |
| 148 | return { storage: createMemoryStorage(), durable: false }; |
| 149 | } |
| 150 | |
| 151 | const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStorage(); |
| 152 | |
| 153 | /** |
| 154 | * Persists the queue, immediately rather than debounced. Stashing is a |
| 155 | * deliberate, infrequent keystroke — not a per-character autosave — so there |
| 156 | * is nothing to coalesce, and the caller clears the composer on the strength |
| 157 | * of this write landing, which a debounce timer cannot honestly report. |
| 158 | * |
| 159 | * Returns whether the write will survive a reload: false on a quota rejection |
| 160 | * or when only the in-memory fallback is available. |
| 161 | */ |
| 162 | function persistEntries(entries: ReadonlyArray<PromptStashEntry>): { |
| 163 | /** The write succeeded (possibly only into the in-memory fallback). */ |
| 164 | written: boolean; |
| 165 | /** The write will survive a reload. */ |
| 166 | durable: boolean; |
| 167 | } { |
| 168 | try { |
| 169 | baseStashStorage.setItem( |
| 170 | PROMPT_STASH_STORAGE_KEY, |
| 171 | JSON.stringify({ |
| 172 | version: PROMPT_STASH_STORAGE_VERSION, |
| 173 | state: { entries }, |
| 174 | }), |
| 175 | ); |
| 176 | return { written: true, durable: storageIsDurable }; |
| 177 | } catch (error) { |
| 178 | console.error("[PROMPT-STASH] Could not persist stash (storage quota?).", error); |
| 179 | return { written: false, durable: false }; |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | /** Reads the persisted queue, settling stale pending counts. */ |
| 184 | function readPersistedEntries(): ReadonlyArray<PromptStashEntry> | null { |
| 185 | try { |
| 186 | const raw = baseStashStorage.getItem(PROMPT_STASH_STORAGE_KEY); |
| 187 | if (typeof raw !== "string" || raw.length === 0) return null; |
| 188 | const parsed: unknown = JSON.parse(raw); |
| 189 | const state = (parsed as { state?: unknown } | null)?.state; |
| 190 | if (!state) return null; |
| 191 | return clearOrphanedPendingImages(decodePersistedPromptStashState(state).entries); |
| 192 | } catch { |
| 193 | return null; |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | interface PromptStashStoreState { |
| 198 | entries: ReadonlyArray<PromptStashEntry>; |
| 199 | /** |
| 200 | * Prepends an entry to the queue, evicting the oldest entry past the cap. |
| 201 | * Returns the evicted entry (for messaging) if any. |
| 202 | */ |
| 203 | stashEntry: (entry: PromptStashEntry) => { |
| 204 | evicted: PromptStashEntry | null; |
| 205 | /** False when the write failed outright (e.g. quota); nothing was kept. */ |
| 206 | written: boolean; |
| 207 | /** |
| 208 | * False when the write will not survive a reload: either it failed, or it |
| 209 | * landed only in the in-memory fallback because localStorage is blocked. |
| 210 | */ |
| 211 | durable: boolean; |
| 212 | }; |
| 213 | /** |
| 214 | * Removes and returns an entry from the queue (restore + delete). |
| 215 | * `durable` is false when the removal could not be persisted, meaning a |
| 216 | * reload would resurrect the entry. |
| 217 | */ |
| 218 | takeEntry: (entryId: string) => { entry: PromptStashEntry | null; durable: boolean }; |
| 219 | /** |
| 220 | * Attaches the encoded images to an entry written earlier by `stashEntry`, |
| 221 | * clearing its pending count. Returns attached=false when the entry is gone |
| 222 | * (restored or deleted while encoding was still running) so the caller can |
| 223 | * tell the user their images did not make it. |
| 224 | */ |
| 225 | finalizeEntryImages: ( |
| 226 | entryId: string, |
| 227 | images: { |
| 228 | attachments: ReadonlyArray<PersistedComposerImageAttachment>; |
| 229 | droppedImageNames: ReadonlyArray<string>; |
| 230 | unreadableImageNames: ReadonlyArray<string>; |
| 231 | }, |
| 232 | ) => { attached: boolean; durable: boolean }; |
| 233 | } |
| 234 | |
| 235 | export const usePromptStashStore = create<PromptStashStoreState>()((set, get) => ({ |
| 236 | entries: [], |
| 237 | stashEntry: (entry) => { |
| 238 | const nextEntries = [entry, ...get().entries]; |
| 239 | const evicted = nextEntries.length > MAX_STASH_ENTRIES ? (nextEntries.pop() ?? null) : null; |
| 240 | const { written, durable } = persistEntries(nextEntries); |
| 241 | // A rejected write must not leave the entry visible either: the caller |
| 242 | // keeps the composer intact on failure, so a stashed copy would |
| 243 | // duplicate the prompt. Eviction likewise only sticks on success. |
| 244 | if (!written) { |
| 245 | return { evicted: null, written: false, durable: false }; |
| 246 | } |
| 247 | set(() => ({ entries: nextEntries })); |
| 248 | return { evicted, written: true, durable }; |
| 249 | }, |
| 250 | takeEntry: (entryId) => { |
| 251 | const entries = get().entries; |
| 252 | const entry = entries.find((candidate) => candidate.id === entryId) ?? null; |
| 253 | if (!entry) return { entry: null, durable: true }; |
| 254 | const nextEntries = entries.filter((candidate) => candidate.id !== entryId); |
| 255 | const { durable } = persistEntries(nextEntries); |
| 256 | set(() => ({ entries: nextEntries })); |
| 257 | return { entry, durable }; |
| 258 | }, |
| 259 | finalizeEntryImages: (entryId, images) => { |
| 260 | const entries = get().entries; |
| 261 | const index = entries.findIndex((candidate) => candidate.id === entryId); |
| 262 | const existing = index === -1 ? undefined : entries[index]; |
| 263 | // Restored or deleted mid-encode: nothing to attach to. |
| 264 | if (!existing) return { attached: false, durable: true }; |
| 265 | const nextEntries = [...entries]; |
| 266 | nextEntries[index] = { |
| 267 | ...existing, |
| 268 | attachments: images.attachments, |
| 269 | droppedImageNames: images.droppedImageNames, |
| 270 | unreadableImageNames: images.unreadableImageNames, |
| 271 | pendingImageCount: 0, |
| 272 | }; |
| 273 | const { durable } = persistEntries(nextEntries); |
| 274 | set(() => ({ entries: nextEntries })); |
| 275 | return { attached: true, durable }; |
| 276 | }, |
| 277 | })); |
| 278 | |
| 279 | // Hydrate once at startup. Like the app's other persisted stores, tabs are |
| 280 | // last-write-wins: no cross-tab merging or storage-event syncing. |
| 281 | { |
| 282 | try { |
| 283 | baseStashStorage.removeItem(LEGACY_PROMPT_STASH_STORAGE_KEY); |
| 284 | } catch { |
| 285 | // Purging the v1 payload is best-effort; a storage policy that rejects |
| 286 | // the delete must not take down module init. |
| 287 | } |
| 288 | const persisted = readPersistedEntries(); |
| 289 | if (persisted) { |
| 290 | usePromptStashStore.setState({ entries: persisted }); |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | /** |
| 295 | * Test seam: seeds the persisted payload through the same storage the store |
| 296 | * reads and rehydrates, without needing a real `localStorage` global. |
| 297 | * Pass an empty string to clear. |
| 298 | */ |
| 299 | export function writePromptStashStorageForTest(raw: string): void { |
| 300 | baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw); |
| 301 | usePromptStashStore.setState({ entries: readPersistedEntries() ?? [] }); |
| 302 | } |