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";
12const LEGACY_PROMPT_STASH_STORAGE_KEY = "t3code:prompt-stash:v1";
13const PROMPT_STASH_STORAGE_VERSION = 2;
14
15export const MAX_STASH_ENTRIES = 20;
16// Share localStorage's ~5MB quota with drafts; record oversized attachments in droppedImageNames.
17export const MAX_STASH_ENTRY_ATTACHMENT_CHARS = 2_700_000;
18
19const StashEntrySchema = Schema.Struct({
20 id: Schema.String,
21 createdAt: Schema.String,
22 prompt: Schema.String,
23 attachments: Schema.Array(PersistedComposerImageAttachment),
24 files: Schema.optionalKey(Schema.Array(PersistedComposerFileAttachment)),
25 droppedImageNames: Schema.Array(Schema.String),
26 unreadableImageNames: Schema.optionalKey(Schema.Array(Schema.String)),
27 pendingImageCount: Schema.optionalKey(Schema.Number),
28 records: Schema.optionalKey(ForwardCompatibleArray(ComposerContextRecord)),
29});
30export type PromptStashEntry = typeof StashEntrySchema.Type;
31
32const PersistedPromptStashState = Schema.Struct({
33 entries: Schema.Array(StashEntrySchema),
34});
35type PersistedPromptStashState = typeof PersistedPromptStashState.Type;
36
37const decodePersistedPromptStashState = Schema.decodeUnknownSync(PersistedPromptStashState);
38
39// Encoders cannot survive reload; clear orphaned counts so entries remain restorable.
40function clearOrphanedPendingImages(
41 entries: ReadonlyArray<PromptStashEntry>,
42): ReadonlyArray<PromptStashEntry> {
43 return entries.map((entry) => {
44 if (!entry.pendingImageCount) return entry;
45 const lostCount = entry.pendingImageCount;
46 return {
47 ...entry,
48 pendingImageCount: 0,
49 unreadableImageNames: [
50 ...(entry.unreadableImageNames ?? []),
51 ...Array.from(
52 { length: lostCount },
53 (_, index) => `image ${index + 1} (not saved before reload)`,
54 ),
55 ],
56 };
57 });
58}
59
60export function partitionStashAttachments(
61 attachments: ReadonlyArray<PersistedComposerImageAttachment>,
62): {
63 kept: PersistedComposerImageAttachment[];
64 droppedNames: string[];
65} {
66 const kept: PersistedComposerImageAttachment[] = [];
67 const droppedNames: string[] = [];
68 let usedChars = 0;
69 for (const attachment of attachments) {
70 if (usedChars + attachment.dataUrl.length > MAX_STASH_ENTRY_ATTACHMENT_CHARS) {
71 droppedNames.push(attachment.name);
72 continue;
73 }
74 usedChars += attachment.dataUrl.length;
75 kept.push(attachment);
76 }
77 return { kept, droppedNames };
78}
79
80// Accessing localStorage itself can throw SecurityError before get/set.
81function resolveBaseStorage(): { storage: StateStorage; durable: boolean } {
82 try {
83 if (typeof localStorage !== "undefined") {
84 return { storage: localStorage, durable: true };
85 }
86 } catch {}
87 return { storage: createMemoryStorage(), durable: false };
88}
89
90const { storage: baseStashStorage, durable: storageIsDurable } = resolveBaseStorage();
91
92// Clear the composer only after a durable write; debounce cannot confirm durability.
93function persistEntries(entries: ReadonlyArray<PromptStashEntry>): {
94 written: boolean;
95 durable: boolean;
96} {
97 try {
98 baseStashStorage.setItem(
99 PROMPT_STASH_STORAGE_KEY,
100 JSON.stringify({
101 version: PROMPT_STASH_STORAGE_VERSION,
102 state: { entries },
103 }),
104 );
105 return { written: true, durable: storageIsDurable };
106 } catch (error) {
107 console.error("[PROMPT-STASH] Could not persist stash (storage quota?).", error);
108 return { written: false, durable: false };
109 }
110}
111
112function readPersistedEntries(): ReadonlyArray<PromptStashEntry> | null {
113 try {
114 const raw = baseStashStorage.getItem(PROMPT_STASH_STORAGE_KEY);
115 if (typeof raw !== "string" || raw.length === 0) return null;
116 const parsed: unknown = JSON.parse(raw);
117 const state = (parsed as { state?: unknown } | null)?.state;
118 if (!state) return null;
119 return clearOrphanedPendingImages(decodePersistedPromptStashState(state).entries);
120 } catch {
121 return null;
122 }
123}
124
125/** Starts each write from storage so entries other tabs stashed or took are not overwritten. */
126function latestEntries(memory: ReadonlyArray<PromptStashEntry>): ReadonlyArray<PromptStashEntry> {
127 const stored = readPersistedEntries();
128 if (!stored) return memory;
129 const memoryById = new Map(memory.map((entry) => [entry.id, entry]));
130 return stored.map((entry) => memoryById.get(entry.id) ?? entry);
131}
132
133interface PromptStashStoreState {
134 entries: ReadonlyArray<PromptStashEntry>;
135 stashEntry: (entry: PromptStashEntry) => {
136 evicted: PromptStashEntry | null;
137 /** False when the write failed outright (e.g. quota); nothing was kept. */
138 written: boolean;
139 /** False for failed writes or in-memory fallback; the entry will not survive reload. */
140 durable: boolean;
141 };
142 /** durable=false means removal was not persisted and reload would restore the entry. */
143 takeEntry: (entryId: string) => { entry: PromptStashEntry | null; durable: boolean };
144 /** attached=false means the entry disappeared while encoding, so images could not be saved. */
145 finalizeEntryImages: (
146 entryId: string,
147 images: {
148 attachments: ReadonlyArray<PersistedComposerImageAttachment>;
149 droppedImageNames: ReadonlyArray<string>;
150 unreadableImageNames: ReadonlyArray<string>;
151 },
152 ) => { attached: boolean; durable: boolean };
153}
154
155export const usePromptStashStore = create<PromptStashStoreState>()((set, get) => ({
156 entries: [],
157 stashEntry: (entry) => {
158 const nextEntries = [entry, ...latestEntries(get().entries)];
159 const evicted = nextEntries.length > MAX_STASH_ENTRIES ? (nextEntries.pop() ?? null) : null;
160 const { written, durable } = persistEntries(nextEntries);
161 // Failed writes must not duplicate the intact composer; eviction also commits only on success.
162 if (!written) {
163 return { evicted: null, written: false, durable: false };
164 }
165 set(() => ({ entries: nextEntries }));
166 return { evicted, written: true, durable };
167 },
168 takeEntry: (entryId) => {
169 const entries = latestEntries(get().entries);
170 const entry = entries.find((candidate) => candidate.id === entryId) ?? null;
171 if (!entry) return { entry: null, durable: true };
172 const nextEntries = entries.filter((candidate) => candidate.id !== entryId);
173 const { durable } = persistEntries(nextEntries);
174 set(() => ({ entries: nextEntries }));
175 return { entry, durable };
176 },
177 finalizeEntryImages: (entryId, images) => {
178 const entries = latestEntries(get().entries);
179 const index = entries.findIndex((candidate) => candidate.id === entryId);
180 const existing = index === -1 ? undefined : entries[index];
181 if (!existing) return { attached: false, durable: true };
182 const nextEntries = [...entries];
183 nextEntries[index] = {
184 ...existing,
185 attachments: images.attachments,
186 droppedImageNames: images.droppedImageNames,
187 unreadableImageNames: images.unreadableImageNames,
188 pendingImageCount: 0,
189 };
190 const { durable } = persistEntries(nextEntries);
191 set(() => ({ entries: nextEntries }));
192 return { attached: true, durable };
193 },
194}));
195
196{
197 try {
198 baseStashStorage.removeItem(LEGACY_PROMPT_STASH_STORAGE_KEY);
199 } catch {}
200 const persisted = readPersistedEntries();
201 if (persisted) {
202 usePromptStashStore.setState({ entries: persisted });
203 }
204}
205
206export function writePromptStashStorageForTest(raw: string): void {
207 baseStashStorage.setItem(PROMPT_STASH_STORAGE_KEY, raw);
208 usePromptStashStore.setState({ entries: readPersistedEntries() ?? [] });
209}