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<PromptStashEntry>,): ReadonlyArray<PromptStashEntry> { 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<PersistedComposerImageAttachment>,): { 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<PromptStashEntry>): { /** 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<PromptStashEntry> | 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<PromptStashEntry>; /** * 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<PersistedComposerImageAttachment>; droppedImageNames: ReadonlyArray<string>; unreadableImageNames: ReadonlyArray<string>; }, ) => { attached: boolean; durable: boolean };} export const usePromptStashStore = create<PromptStashStoreState>()((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() ?? [] });}
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<PromptStashEntry>,
): ReadonlyArray<PromptStashEntry> {
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<PersistedComposerImageAttachment>,
): {
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<PromptStashEntry>): {
/** 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<PromptStashEntry> | 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<PromptStashEntry>;
/**
* 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<PersistedComposerImageAttachment>;
droppedImageNames: ReadonlyArray<string>;
unreadableImageNames: ReadonlyArray<string>;
},
) => { attached: boolean; durable: boolean };
}
export const usePromptStashStore = create<PromptStashStoreState>()((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() ?? [] });
}