From 6ab246676ebaee8ef895ab4067fb6457854b0a1c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 1 Mar 2026 11:36:19 -0800 Subject: [PATCH] Refactor draft thread lookups and cache persisted draft state - replace `read*` draft selectors with `getDraftThread`/`getDraftThreadByProjectId` - centralize persisted localStorage parsing/caching in `composerDraftStore` - make ChatView fallback read persisted attachments from in-memory store state --- apps/web/src/components/ChatView.tsx | 24 +-- apps/web/src/components/Sidebar.tsx | 68 +++---- apps/web/src/composerDraftStore.test.ts | 41 ++-- apps/web/src/composerDraftStore.ts | 250 ++++++++++++++---------- 4 files changed, 215 insertions(+), 168 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7c600faa6e27698d06c0b72e4bba9cce9e58cee9..032dbb5d608e1fba39aafd77ef36dcdd404c2b24 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -112,11 +112,10 @@ import { newCommandId, newMessageId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { getAppModelOptions, useAppSettings } from "../appSettings"; import { - COMPOSER_DRAFT_STORAGE_KEY, type ComposerImageAttachment, type DraftThreadState, type PersistedComposerImageAttachment, - readPersistedComposerDraftAttachments, + readComposerDraftPersistStorageChars, useComposerDraftStore, useComposerThreadDraft, } from "../composerDraftStore"; @@ -295,18 +294,6 @@ function cloneComposerImageForRetry(image: ComposerImageAttachment): ComposerIma } } -function readComposerDraftStorageChars(): number | null { - if (typeof window === "undefined") { - return null; - } - try { - const raw = window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY); - return raw?.length ?? 0; - } catch { - return null; - } -} - function debugDraftPersist(message: string, payload: Record): void { if (!ENABLE_DRAFT_PERSIST_DEBUG_LOGS) { return; @@ -1286,7 +1273,7 @@ export default function ChatView({ threadId }: ChatViewProps) { sizeBytes: attachment.sizeBytes, dataUrlChars: attachment.dataUrl.length, })), - storageCharsBefore: readComposerDraftStorageChars(), + storageCharsBefore: readComposerDraftPersistStorageChars(), }); syncComposerDraftPersistedAttachments( threadId, @@ -1296,11 +1283,12 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId, stagedCount: stagedAttachmentIds.length, serializationFailedCount: serializationFailedImageIds.length, - storageCharsAfter: readComposerDraftStorageChars(), + storageCharsAfter: readComposerDraftPersistStorageChars(), }); } catch (error) { const currentImageIds = new Set(composerImages.map((image) => image.id)); - const fallbackPersistedAttachments = readPersistedComposerDraftAttachments(threadId); + const fallbackPersistedAttachments = + useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? []; const fallbackPersistedIds = fallbackPersistedAttachments .map((attachment) => attachment.id) .filter((id) => currentImageIds.has(id)); @@ -1313,7 +1301,7 @@ export default function ChatView({ threadId }: ChatViewProps) { error: error instanceof Error ? error.message : String(error), imageIds: composerImages.map((image) => image.id), fallbackPersistedCount: fallbackPersistedIds.length, - storageChars: readComposerDraftStorageChars(), + storageChars: readComposerDraftPersistStorageChars(), }); if (cancelled) { return; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 495ed4491961b3b46799aa0062848029ed3d2ab8..9e38eb9dfc808846be8daad09d485dfd74b02a05 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -230,11 +230,15 @@ function ProjectFavicon({ cwd }: { cwd: string }) { export default function Sidebar() { const { state, dispatch } = useStore(); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); - const readDraftThread = useComposerDraftStore((store) => store.readDraftThread); - const readProjectDraftThreadId = useComposerDraftStore((store) => store.readProjectDraftThreadId); + const getDraftThreadByProjectId = useComposerDraftStore( + (store) => store.getDraftThreadByProjectId, + ); + const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const clearProjectDraftThreadId = useComposerDraftStore((store) => store.clearProjectDraftThreadId); + const clearProjectDraftThreadId = useComposerDraftStore( + (store) => store.clearProjectDraftThreadId, + ); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, ); @@ -275,15 +279,14 @@ export default function Sidebar() { [projectCwdById, state.threads], ); const threadGitStatusCwds = useMemo( - () => - [ - ...new Set( - threadGitTargets - .filter((target) => target.branch !== null) - .map((target) => target.cwd) - .filter((cwd): cwd is string => cwd !== null), - ), - ], + () => [ + ...new Set( + threadGitTargets + .filter((target) => target.branch !== null) + .map((target) => target.cwd) + .filter((cwd): cwd is string => cwd !== null), + ), + ], [threadGitTargets], ); const threadGitStatusQueries = useQueries({ @@ -346,31 +349,28 @@ export default function Sidebar() { ): Promise => { const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; - const storedDraftThreadId = readProjectDraftThreadId(projectId); - const storedDraftThread = storedDraftThreadId ? readDraftThread(storedDraftThreadId) : null; - if (storedDraftThread && storedDraftThread.projectId === projectId && storedDraftThreadId) { + const storedDraftThread = getDraftThreadByProjectId(projectId); + if (storedDraftThread) { return (async () => { if (hasBranchOption || hasWorktreePathOption) { - setDraftThreadContext(storedDraftThreadId, { + setDraftThreadContext(storedDraftThread.threadId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), }); } - setProjectDraftThreadId(projectId, storedDraftThreadId); - if (routeThreadId === storedDraftThreadId) { + setProjectDraftThreadId(projectId, storedDraftThread.threadId); + if (routeThreadId === storedDraftThread.threadId) { return; } await navigate({ to: "/$threadId", - params: { threadId: storedDraftThreadId }, + params: { threadId: storedDraftThread.threadId }, }); })(); } - if (storedDraftThreadId) { - clearProjectDraftThreadId(projectId); - } + clearProjectDraftThreadId(projectId); - const activeDraftThread = routeThreadId ? readDraftThread(routeThreadId) : null; + const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) { if (hasBranchOption || hasWorktreePathOption) { setDraftThreadContext(routeThreadId, { @@ -398,9 +398,9 @@ export default function Sidebar() { }, [ clearProjectDraftThreadId, + getDraftThreadByProjectId, navigate, - readDraftThread, - readProjectDraftThreadId, + getDraftThread, routeThreadId, setDraftThreadContext, setProjectDraftThreadId, @@ -632,9 +632,9 @@ export default function Sidebar() { if (!confirmed) return; try { - const projectDraftThreadId = readProjectDraftThreadId(projectId); - if (projectDraftThreadId) { - clearComposerDraftForThread(projectDraftThreadId); + const projectDraftThread = getDraftThreadByProjectId(projectId); + if (projectDraftThread) { + clearComposerDraftForThread(projectDraftThread.threadId); } clearProjectDraftThreadId(projectId); await api.orchestration.dispatchCommand({ @@ -655,7 +655,7 @@ export default function Sidebar() { [ clearComposerDraftForThread, clearProjectDraftThreadId, - readProjectDraftThreadId, + getDraftThreadByProjectId, state.projects, state.threads, ], @@ -666,9 +666,10 @@ export default function Sidebar() { const activeThread = routeThreadId ? state.threads.find((thread) => thread.id === routeThreadId) : undefined; - const activeDraftThread = routeThreadId ? readDraftThread(routeThreadId) : null; + const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; if (isChatNewLocalShortcut(event, keybindings)) { - const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? state.projects[0]?.id; + const projectId = + activeThread?.projectId ?? activeDraftThread?.projectId ?? state.projects[0]?.id; if (!projectId) return; event.preventDefault(); void handleNewThread(projectId); @@ -676,7 +677,8 @@ export default function Sidebar() { } if (!isChatNewShortcut(event, keybindings)) return; - const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? state.projects[0]?.id; + const projectId = + activeThread?.projectId ?? activeDraftThread?.projectId ?? state.projects[0]?.id; if (!projectId) return; event.preventDefault(); void handleNewThread(projectId, { @@ -689,7 +691,7 @@ export default function Sidebar() { return () => { window.removeEventListener("keydown", onWindowKeyDown); }; - }, [handleNewThread, keybindings, readDraftThread, routeThreadId, state.projects, state.threads]); + }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]); const onCreateThreadClick = () => { if (state.projects.length === 0) { diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 0563d7b1245505427e2225adf1e4dd5f73e5d05d..246dfbf4c01e698e57b2805a7c15fa323d44620e 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -15,7 +15,10 @@ function makeImage(input: { const mimeType = input.mimeType ?? "image/png"; const sizeBytes = input.sizeBytes ?? 4; const lastModified = input.lastModified ?? 1_700_000_000_000; - const file = new File([new Uint8Array(sizeBytes).fill(1)], name, { type: mimeType, lastModified }); + const file = new File([new Uint8Array(sizeBytes).fill(1)], name, { + type: mimeType, + lastModified, + }); return { type: "image", id: input.id, @@ -166,16 +169,22 @@ describe("composerDraftStore project draft thread mapping", () => { it("stores and reads project draft thread ids via actions", () => { const store = useComposerDraftStore.getState(); - expect(store.readProjectDraftThreadId(projectId)).toBeNull(); - expect(store.readDraftThread(threadId)).toBeNull(); + expect(store.getDraftThreadByProjectId(projectId)).toBeNull(); + expect(store.getDraftThread(threadId)).toBeNull(); store.setProjectDraftThreadId(projectId, threadId, { branch: "feature/test", worktreePath: "/tmp/worktree-test", createdAt: "2026-01-01T00:00:00.000Z", }); - expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBe(threadId); - expect(useComposerDraftStore.getState().readDraftThread(threadId)).toEqual({ + expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toEqual({ + threadId, + projectId, + branch: "feature/test", + worktreePath: "/tmp/worktree-test", + createdAt: "2026-01-01T00:00:00.000Z", + }); + expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({ projectId, branch: "feature/test", worktreePath: "/tmp/worktree-test", @@ -188,27 +197,29 @@ describe("composerDraftStore project draft thread mapping", () => { store.setProjectDraftThreadId(projectId, threadId); store.clearProjectDraftThreadById(projectId, otherThreadId); - expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBe(threadId); + expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( + threadId, + ); store.clearProjectDraftThreadById(projectId, threadId); - expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull(); - expect(useComposerDraftStore.getState().readDraftThread(threadId)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); }); it("clears project draft mapping by project id", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId); store.clearProjectDraftThreadId(projectId); - expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull(); - expect(useComposerDraftStore.getState().readDraftThread(threadId)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); }); it("clears draft registration independently", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId); store.clearDraftThread(threadId); - expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull(); - expect(useComposerDraftStore.getState().readDraftThread(threadId)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); }); it("updates branch context on an existing draft thread", () => { @@ -221,8 +232,10 @@ describe("composerDraftStore project draft thread mapping", () => { branch: "feature/next", worktreePath: "/tmp/feature-next", }); - expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBe(threadId); - expect(useComposerDraftStore.getState().readDraftThread(threadId)).toMatchObject({ + expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( + threadId, + ); + expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ projectId, branch: "feature/next", worktreePath: "/tmp/feature-next", diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 40064e5eb021e8fba54479eda5b3a1a888419523..0cb844c0f4471727e2e27da707410403a93684d5 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -8,43 +8,10 @@ import { } from "@t3tools/contracts"; import type { ChatImageAttachment } from "./types"; import { create } from "zustand"; -import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; +import { createJSONStorage, persist } from "zustand/middleware"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; -const SAFE_LOCAL_STORAGE: StateStorage = { - getItem: (key) => { - if (typeof window === "undefined") { - return null; - } - try { - return window.localStorage.getItem(key); - } catch { - return null; - } - }, - setItem: (key, value) => { - if (typeof window === "undefined") { - return; - } - try { - window.localStorage.setItem(key, value); - } catch { - // Swallow quota/storage errors so draft state stays usable in-memory. - } - }, - removeItem: (key) => { - if (typeof window === "undefined") { - return; - } - try { - window.localStorage.removeItem(key); - } catch { - // Best-effort cleanup. - } - }, -}; - export interface PersistedComposerImageAttachment { id: string; name: string; @@ -79,7 +46,7 @@ interface PersistedComposerDraftStoreState { projectDraftThreadIdByProjectId: Record; } -export interface ComposerThreadDraftState { +interface ComposerThreadDraftState { prompt: string; cursor: number; images: ComposerImageAttachment[]; @@ -96,13 +63,16 @@ export interface DraftThreadState { worktreePath: string | null; } +interface ProjectDraftThread extends DraftThreadState { + threadId: ThreadId; +} + interface ComposerDraftStoreState { draftsByThreadId: Record; draftThreadsByThreadId: Record; projectDraftThreadIdByProjectId: Record; - readDraftThread: (threadId: ThreadId) => DraftThreadState | null; - isDraftThread: (threadId: ThreadId) => boolean; - readProjectDraftThreadId: (projectId: ProjectId) => ThreadId | null; + getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; + getDraftThread: (threadId: ThreadId) => DraftThreadState | null; setProjectDraftThreadId: ( projectId: ProjectId, threadId: ThreadId, @@ -132,11 +102,23 @@ interface ComposerDraftStoreState { addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; removeImage: (threadId: ThreadId, imageId: string) => void; clearPersistedAttachments: (threadId: ThreadId) => void; - syncPersistedAttachments: (threadId: ThreadId, attachments: PersistedComposerImageAttachment[]) => void; + syncPersistedAttachments: ( + threadId: ThreadId, + attachments: PersistedComposerImageAttachment[], + ) => void; clearComposerContent: (threadId: ThreadId) => void; clearThreadDraft: (threadId: ThreadId) => void; } +const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + 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[] = []; @@ -223,7 +205,7 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState { if (!value || typeof value !== "object") { - return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; + return EMPTY_PERSISTED_DRAFT_STORE_STATE; } const candidate = value as Record; const rawDraftMap = candidate.draftsByThreadId; @@ -250,7 +232,10 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer } draftThreadsByThreadId[threadId as ThreadId] = { projectId: projectId as ProjectId, - createdAt: typeof createdAt === "string" && createdAt.length > 0 ? createdAt : new Date().toISOString(), + createdAt: + typeof createdAt === "string" && createdAt.length > 0 + ? createdAt + : new Date().toISOString(), branch: typeof branch === "string" ? branch : null, worktreePath: typeof worktreePath === "string" ? worktreePath : null, }; @@ -258,7 +243,10 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer } const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] = {}; - if (rawProjectDraftThreadIdByProjectId && typeof rawProjectDraftThreadIdByProjectId === "object") { + if ( + rawProjectDraftThreadIdByProjectId && + typeof rawProjectDraftThreadIdByProjectId === "object" + ) { for (const [projectId, threadId] of Object.entries( rawProjectDraftThreadIdByProjectId as Record, )) { @@ -327,7 +315,35 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer ...(effort ? { effort } : {}), }; } - return { draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId }; + return { + draftsByThreadId: nextDraftsByThreadId, + draftThreadsByThreadId, + projectDraftThreadIdByProjectId, + }; +} + +function parsePersistedDraftStateRaw(raw: string | null): PersistedComposerDraftStoreState { + if (!raw) { + return EMPTY_PERSISTED_DRAFT_STORE_STATE; + } + try { + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object" && "state" in parsed) { + return normalizePersistedComposerDraftState((parsed as { state?: unknown }).state); + } + return normalizePersistedComposerDraftState(parsed); + } catch { + return EMPTY_PERSISTED_DRAFT_STORE_STATE; + } +} + +function readPersistedAttachmentIdsFromCache(threadId: ThreadId): string[] { + if (threadId.length === 0) { + return []; + } + return (persistedDraftStateCache.draftsByThreadId[threadId]?.attachments ?? []).map( + (attachment) => attachment.id, + ); } function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null { @@ -379,25 +395,6 @@ function hydrateImagesFromPersisted( }); } -function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreState { - if (typeof window === "undefined") { - return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; - } - try { - const raw = window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY); - if (!raw) { - return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; - } - const parsed = JSON.parse(raw) as unknown; - if (parsed && typeof parsed === "object" && "state" in parsed) { - return normalizePersistedComposerDraftState((parsed as { state?: unknown }).state); - } - return normalizePersistedComposerDraftState(parsed); - } catch { - return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} }; - } -} - function toHydratedThreadDraft( persistedDraft: PersistedComposerThreadDraftState, ): ComposerThreadDraftState { @@ -412,30 +409,81 @@ 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) => ({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, - readDraftThread: (threadId) => { + getDraftThreadByProjectId: (projectId) => { + if (projectId.length === 0) { + return null; + } + const threadId = get().projectDraftThreadIdByProjectId[projectId]; + if (!threadId) { + return null; + } + const draftThread = get().draftThreadsByThreadId[threadId]; + if (!draftThread || draftThread.projectId !== projectId) { + return null; + } + return { + threadId, + ...draftThread, + }; + }, + getDraftThread: (threadId) => { if (threadId.length === 0) { return null; } return get().draftThreadsByThreadId[threadId] ?? null; }, - isDraftThread: (threadId) => { - if (threadId.length === 0) { - return false; - } - return get().draftThreadsByThreadId[threadId] !== undefined; - }, - readProjectDraftThreadId: (projectId) => { - if (projectId.length === 0) { - return null; - } - return get().projectDraftThreadIdByProjectId[projectId] ?? null; - }, setProjectDraftThreadId: (projectId, threadId, options) => { if (projectId.length === 0 || threadId.length === 0) { return; @@ -502,7 +550,9 @@ export const useComposerDraftStore = create()( const nextDraftThread: DraftThreadState = { projectId: nextProjectId, createdAt: - options.createdAt === undefined ? existing.createdAt : (options.createdAt || existing.createdAt), + options.createdAt === undefined + ? existing.createdAt + : options.createdAt || existing.createdAt, branch: options.branch === undefined ? existing.branch : (options.branch ?? null), worktreePath: options.worktreePath === undefined @@ -544,7 +594,8 @@ export const useComposerDraftStore = create()( if (threadId === undefined) { return state; } - const { [projectId]: _removed, ...restProjectMappingsRaw } = state.projectDraftThreadIdByProjectId; + const { [projectId]: _removed, ...restProjectMappingsRaw } = + state.projectDraftThreadIdByProjectId; const restProjectMappings = restProjectMappingsRaw as Record; const nextDraftThreadsByThreadId: Record = { ...state.draftThreadsByThreadId, @@ -566,7 +617,8 @@ export const useComposerDraftStore = create()( if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) { return state; } - const { [projectId]: _removed, ...restProjectMappingsRaw } = state.projectDraftThreadIdByProjectId; + const { [projectId]: _removed, ...restProjectMappingsRaw } = + state.projectDraftThreadIdByProjectId; const restProjectMappings = restProjectMappingsRaw as Record; const nextDraftThreadsByThreadId: Record = { ...state.draftThreadsByThreadId, @@ -586,7 +638,9 @@ export const useComposerDraftStore = create()( } set((state) => { const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; - const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes(threadId); + const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( + threadId, + ); if (!hasDraftThread && !hasProjectMapping) { return state; } @@ -681,9 +735,7 @@ export const useComposerDraftStore = create()( return; } const nextEffort = - effort && - REASONING_EFFORT_VALUES.has(effort) && - effort !== DEFAULT_REASONING + effort && REASONING_EFFORT_VALUES.has(effort) && effort !== DEFAULT_REASONING ? effort : null; set((state) => { @@ -721,7 +773,9 @@ export const useComposerDraftStore = create()( set((state) => { const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); const existingIds = new Set(existing.images.map((image) => image.id)); - const existingDedupKeys = new Set(existing.images.map((image) => composerImageDedupKey(image))); + const existingDedupKeys = new Set( + existing.images.map((image) => composerImageDedupKey(image)), + ); const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl)); const dedupedIncoming: ComposerImageAttachment[] = []; for (const image of images) { @@ -823,8 +877,8 @@ export const useComposerDraftStore = create()( ...current, // Stage attempted attachments so persist middleware can try writing them. persistedAttachments: attachments, - nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => - !attachmentIdSet.has(id), + nonPersistedImageIds: current.nonPersistedImageIds.filter( + (id) => !attachmentIdSet.has(id), ), }; const nextDraftsByThreadId = { ...state.draftsByThreadId }; @@ -836,7 +890,7 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); Promise.resolve().then(() => { - const persistedIdSet = new Set(readPersistedComposerDraftAttachmentIds(threadId)); + const persistedIdSet = new Set(readPersistedAttachmentIdsFromCache(threadId)); set((state) => { const current = state.draftsByThreadId[threadId]; if (!current) { @@ -903,7 +957,9 @@ export const useComposerDraftStore = create()( set((state) => { const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined; const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; - const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes(threadId); + const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( + threadId, + ); if (!hasComposerDraft && !hasDraftThread && !hasProjectMapping) { return state; } @@ -927,7 +983,7 @@ export const useComposerDraftStore = create()( { name: COMPOSER_DRAFT_STORAGE_KEY, version: 1, - storage: createJSONStorage(() => SAFE_LOCAL_STORAGE), + storage: composerDraftStorage, partialize: (state) => { const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {}; for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) { @@ -963,6 +1019,7 @@ 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, @@ -984,19 +1041,6 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); } -export function readPersistedComposerDraftAttachmentIds(threadId: ThreadId): string[] { - if (threadId.length === 0) { - return []; - } - return readPersistedComposerDraftAttachments(threadId).map((attachment) => attachment.id); -} - -export function readPersistedComposerDraftAttachments( - threadId: ThreadId, -): PersistedComposerImageAttachment[] { - if (threadId.length === 0) { - return []; - } - const persisted = readPersistedDraftStateFromLocalStorage(); - return persisted.draftsByThreadId[threadId]?.attachments ?? []; +export function readComposerDraftPersistStorageChars(): number | null { + return persistedDraftStorageChars; } -- 2.54.0