1import { ComposerContextRecord, ForwardCompatibleArray } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3import { create } from "zustand";
4
5import {
6 PersistedComposerFileAttachment,
7 PersistedComposerImageAttachment,
8} from "./composerDraftStore";
9import { createMemoryStorage, type StateStorage } from "./lib/storage";
10
11export 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 */
18const LEGACY_PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1";
19const PROMPT_STASH_STORAGE_VERSION = 2;
20
21export 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 */
31export 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 */
37const 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});
66export type PromptStashEntry = typeof StashEntrySchema.Type;
67
68const PersistedPromptStashState = Schema.Struct({
69 entries: Schema.Array(StashEntrySchema),
70});
71type PersistedPromptStashState = typeof PersistedPromptStashState.Type;
72
73const 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 */
85function 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 */
110export 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 */
140function 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
151const { 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 */
162function 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. */
184function 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
197interface 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
235export 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 */
299export function writePromptStashStorageForTest(raw: string): void {
300 baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw);
301 usePromptStashStore.setState({ entries: readPersistedEntries() ?? [] });
302}