diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 032dbb5d608e1fba39aafd77ef36dcdd404c2b24..093001cd50e3c86df8887ab6c3b221762765440b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -115,7 +115,6 @@ import { type ComposerImageAttachment, type DraftThreadState, type PersistedComposerImageAttachment, - readComposerDraftPersistStorageChars, useComposerDraftStore, useComposerThreadDraft, } from "../composerDraftStore"; @@ -138,8 +137,6 @@ const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; const WORKTREE_BRANCH_PREFIX = "t3code"; -const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; -const ENABLE_DRAFT_PERSIST_DEBUG_LOGS = import.meta.env.DEV; function readLastInvokedScriptByProjectFromStorage(): Record { const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); @@ -294,13 +291,6 @@ function cloneComposerImageForRetry(image: ComposerImageAttachment): ComposerIma } } -function debugDraftPersist(message: string, payload: Record): void { - if (!ENABLE_DRAFT_PERSIST_DEBUG_LOGS) { - return; - } - console.debug(`[draft-persist] ${message}`, payload); -} - const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { pathValue: string; kind: "file" | "directory"; @@ -1261,31 +1251,8 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } // Stage attachments in persisted draft state first so persist middleware can write them. - const stagedAttachmentIds = serialized.map((attachment) => attachment.id); - debugDraftPersist("stage", { - threadId, - attachmentCount: serialized.length, - serializationFailedCount: serializationFailedImageIds.length, - serializationFailedImageIds, - attachmentMeta: serialized.map((attachment) => ({ - id: attachment.id, - name: attachment.name, - sizeBytes: attachment.sizeBytes, - dataUrlChars: attachment.dataUrl.length, - })), - storageCharsBefore: readComposerDraftPersistStorageChars(), - }); - syncComposerDraftPersistedAttachments( - threadId, - serialized, - ); - debugDraftPersist("reconcile-request", { - threadId, - stagedCount: stagedAttachmentIds.length, - serializationFailedCount: serializationFailedImageIds.length, - storageCharsAfter: readComposerDraftPersistStorageChars(), - }); - } catch (error) { + syncComposerDraftPersistedAttachments(threadId, serialized); + } catch { const currentImageIds = new Set(composerImages.map((image) => image.id)); const fallbackPersistedAttachments = useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? []; @@ -1296,13 +1263,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => fallbackPersistedIdSet.has(attachment.id), ); - debugDraftPersist("error", { - threadId, - error: error instanceof Error ? error.message : String(error), - imageIds: composerImages.map((image) => image.id), - fallbackPersistedCount: fallbackPersistedIds.length, - storageChars: readComposerDraftPersistStorageChars(), - }); if (cancelled) { return; } diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 0cb844c0f4471727e2e27da707410403a93684d5..2ef520bcbf8efe05e52c86aff255cd51e2aaaa9e 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -116,9 +116,6 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { projectDraftThreadIdByProjectId: {}, }; -let persistedDraftStateCache: PersistedComposerDraftStoreState = EMPTY_PERSISTED_DRAFT_STORE_STATE; -let persistedDraftStorageChars: number | null = null; - const EMPTY_IMAGES: ComposerImageAttachment[] = []; const EMPTY_IDS: string[] = []; const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; @@ -337,17 +334,25 @@ function parsePersistedDraftStateRaw(raw: string | null): PersistedComposerDraft } } -function readPersistedAttachmentIdsFromCache(threadId: ThreadId): string[] { +function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { if (threadId.length === 0) { return []; } - return (persistedDraftStateCache.draftsByThreadId[threadId]?.attachments ?? []).map( - (attachment) => attachment.id, - ); + try { + const raw = localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY); + const persisted = parsePersistedDraftStateRaw(raw); + return (persisted.draftsByThreadId[threadId]?.attachments ?? []).map( + (attachment) => attachment.id, + ); + } catch { + return []; + } } -function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null { - const [rawHeader, payload] = dataUrl.split(",", 2); +function hydreatePersistedComposerImageAttachment( + attachment: PersistedComposerImageAttachment, +): File | null { + const [rawHeader, payload] = attachment.dataUrl.split(",", 2); const header = rawHeader ?? ""; if (!payload) { return null; @@ -359,15 +364,17 @@ function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File const inferredMimeType = header.startsWith("data:") && header.includes(";") ? header.slice("data:".length, header.indexOf(";")) - : mimeType; - return new File([decodedText], name, { type: inferredMimeType || mimeType }); + : attachment.mimeType; + return new File([decodedText], attachment.name, { + type: inferredMimeType || attachment.mimeType, + }); } const binary = atob(payload); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } - return new File([bytes], name, { type: mimeType }); + return new File([bytes], attachment.name, { type: attachment.mimeType }); } catch { return null; } @@ -377,10 +384,9 @@ function hydrateImagesFromPersisted( attachments: PersistedComposerImageAttachment[], ): ComposerImageAttachment[] { return attachments.flatMap((attachment) => { - const file = fileFromDataUrl(attachment.dataUrl, attachment.name, attachment.mimeType); - if (!file) { - return []; - } + const file = hydreatePersistedComposerImageAttachment(attachment); + if (!file) return []; + return [ { type: "image" as const, @@ -409,52 +415,6 @@ function toHydratedThreadDraft( }; } -const composerDraftStorage = createJSONStorage(() => ({ - getItem: (name: string): string | null => { - if (typeof window === "undefined") { - persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE; - persistedDraftStorageChars = null; - return null; - } - try { - const raw = window.localStorage.getItem(name); - persistedDraftStateCache = parsePersistedDraftStateRaw(raw); - persistedDraftStorageChars = raw?.length ?? 0; - return raw; - } catch { - persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE; - persistedDraftStorageChars = null; - return null; - } - }, - setItem: (name: string, value: string): void => { - if (typeof window === "undefined") { - return; - } - try { - window.localStorage.setItem(name, value); - persistedDraftStateCache = parsePersistedDraftStateRaw(value); - persistedDraftStorageChars = value.length; - } catch { - // Quota/storage errors are expected for large drafts. Keep last successful snapshot. - } - }, - removeItem: (name: string): void => { - if (typeof window === "undefined") { - persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE; - persistedDraftStorageChars = null; - return; - } - try { - window.localStorage.removeItem(name); - } catch { - // Ignore remove failures; clear in-memory cache regardless. - } - persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE; - persistedDraftStorageChars = 0; - }, -})); - export const useComposerDraftStore = create()( persist( (set, get) => ({ @@ -890,7 +850,7 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); Promise.resolve().then(() => { - const persistedIdSet = new Set(readPersistedAttachmentIdsFromCache(threadId)); + const persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadId)); set((state) => { const current = state.draftsByThreadId[threadId]; if (!current) { @@ -983,7 +943,7 @@ export const useComposerDraftStore = create()( { name: COMPOSER_DRAFT_STORAGE_KEY, version: 1, - storage: composerDraftStorage, + storage: createJSONStorage(() => localStorage), partialize: (state) => { const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) { @@ -1019,7 +979,6 @@ export const useComposerDraftStore = create()( }, merge: (persistedState, currentState) => { const normalizedPersisted = normalizePersistedComposerDraftState(persistedState); - persistedDraftStateCache = normalizedPersisted; const draftsByThreadId = Object.fromEntries( Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [ threadId, @@ -1040,7 +999,3 @@ export const useComposerDraftStore = create()( export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState { return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); } - -export function readComposerDraftPersistStorageChars(): number | null { - return persistedDraftStorageChars; -}