authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 11:36:19-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 22:36:35-08:00
log6ab246676ebaee8ef895ab4067fb6457854b0a1c
tree3218448eda579de34067dea5f2dfb43b9153c296
parent122c94a24acf31d72097faad3e943e939e7fff91

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

4 files changed, 213 insertions(+), 166 deletions(-)

apps/web/src/components/ChatView.tsx+6-18
......@@ -112,11 +112,10 @@ import { newCommandId, newMessageId } from "~/lib/utils";
112112import { readNativeApi } from "~/nativeApi";
113113import { getAppModelOptions, useAppSettings } from "../appSettings";
114114import {
115 COMPOSER_DRAFT_STORAGE_KEY,
116115 type ComposerImageAttachment,
117116 type DraftThreadState,
118117 type PersistedComposerImageAttachment,
119 readPersistedComposerDraftAttachments,
118 readComposerDraftPersistStorageChars,
120119 useComposerDraftStore,
121120 useComposerThreadDraft,
122121} from "../composerDraftStore";
......@@ -295,18 +294,6 @@ function cloneComposerImageForRetry(image: ComposerImageAttachment): ComposerIma
295294 }
296295}
297296
298function readComposerDraftStorageChars(): number | null {
299 if (typeof window === "undefined") {
300 return null;
301 }
302 try {
303 const raw = window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY);
304 return raw?.length ?? 0;
305 } catch {
306 return null;
307 }
308}
309
310297function debugDraftPersist(message: string, payload: Record<string, unknown>): void {
311298 if (!ENABLE_DRAFT_PERSIST_DEBUG_LOGS) {
312299 return;
......@@ -1286,7 +1273,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
12861273 sizeBytes: attachment.sizeBytes,
12871274 dataUrlChars: attachment.dataUrl.length,
12881275 })),
1289 storageCharsBefore: readComposerDraftStorageChars(),
1276 storageCharsBefore: readComposerDraftPersistStorageChars(),
12901277 });
12911278 syncComposerDraftPersistedAttachments(
12921279 threadId,
......@@ -1296,11 +1283,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
12961283 threadId,
12971284 stagedCount: stagedAttachmentIds.length,
12981285 serializationFailedCount: serializationFailedImageIds.length,
1299 storageCharsAfter: readComposerDraftStorageChars(),
1286 storageCharsAfter: readComposerDraftPersistStorageChars(),
13001287 });
13011288 } catch (error) {
13021289 const currentImageIds = new Set(composerImages.map((image) => image.id));
1303 const fallbackPersistedAttachments = readPersistedComposerDraftAttachments(threadId);
1290 const fallbackPersistedAttachments =
1291 useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? [];
13041292 const fallbackPersistedIds = fallbackPersistedAttachments
13051293 .map((attachment) => attachment.id)
13061294 .filter((id) => currentImageIds.has(id));
......@@ -1313,7 +1301,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
13131301 error: error instanceof Error ? error.message : String(error),
13141302 imageIds: composerImages.map((image) => image.id),
13151303 fallbackPersistedCount: fallbackPersistedIds.length,
1316 storageChars: readComposerDraftStorageChars(),
1304 storageChars: readComposerDraftPersistStorageChars(),
13171305 });
13181306 if (cancelled) {
13191307 return;
apps/web/src/components/Sidebar.tsx+35-33
......@@ -230,11 +230,15 @@ function ProjectFavicon({ cwd }: { cwd: string }) {
230230export default function Sidebar() {
231231 const { state, dispatch } = useStore();
232232 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);
233 const readDraftThread = useComposerDraftStore((store) => store.readDraftThread);
234 const readProjectDraftThreadId = useComposerDraftStore((store) => store.readProjectDraftThreadId);
233 const getDraftThreadByProjectId = useComposerDraftStore(
234 (store) => store.getDraftThreadByProjectId,
235 );
236 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
235237 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
236238 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
237 const clearProjectDraftThreadId = useComposerDraftStore((store) => store.clearProjectDraftThreadId);
239 const clearProjectDraftThreadId = useComposerDraftStore(
240 (store) => store.clearProjectDraftThreadId,
241 );
238242 const clearProjectDraftThreadById = useComposerDraftStore(
239243 (store) => store.clearProjectDraftThreadById,
240244 );
......@@ -275,15 +279,14 @@ export default function Sidebar() {
275279 [projectCwdById, state.threads],
276280 );
277281 const threadGitStatusCwds = useMemo(
278 () =>
279 [
280 ...new Set(
281 threadGitTargets
282 .filter((target) => target.branch !== null)
283 .map((target) => target.cwd)
284 .filter((cwd): cwd is string => cwd !== null),
285 ),
286 ],
282 () => [
283 ...new Set(
284 threadGitTargets
285 .filter((target) => target.branch !== null)
286 .map((target) => target.cwd)
287 .filter((cwd): cwd is string => cwd !== null),
288 ),
289 ],
287290 [threadGitTargets],
288291 );
289292 const threadGitStatusQueries = useQueries({
......@@ -346,31 +349,28 @@ export default function Sidebar() {
346349 ): Promise<void> => {
347350 const hasBranchOption = options?.branch !== undefined;
348351 const hasWorktreePathOption = options?.worktreePath !== undefined;
349 const storedDraftThreadId = readProjectDraftThreadId(projectId);
350 const storedDraftThread = storedDraftThreadId ? readDraftThread(storedDraftThreadId) : null;
351 if (storedDraftThread && storedDraftThread.projectId === projectId && storedDraftThreadId) {
352 const storedDraftThread = getDraftThreadByProjectId(projectId);
353 if (storedDraftThread) {
352354 return (async () => {
353355 if (hasBranchOption || hasWorktreePathOption) {
354 setDraftThreadContext(storedDraftThreadId, {
356 setDraftThreadContext(storedDraftThread.threadId, {
355357 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
356358 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
357359 });
358360 }
359 setProjectDraftThreadId(projectId, storedDraftThreadId);
360 if (routeThreadId === storedDraftThreadId) {
361 setProjectDraftThreadId(projectId, storedDraftThread.threadId);
362 if (routeThreadId === storedDraftThread.threadId) {
361363 return;
362364 }
363365 await navigate({
364366 to: "/$threadId",
365 params: { threadId: storedDraftThreadId },
367 params: { threadId: storedDraftThread.threadId },
366368 });
367369 })();
368370 }
369 if (storedDraftThreadId) {
370 clearProjectDraftThreadId(projectId);
371 }
371 clearProjectDraftThreadId(projectId);
372372
373 const activeDraftThread = routeThreadId ? readDraftThread(routeThreadId) : null;
373 const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null;
374374 if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) {
375375 if (hasBranchOption || hasWorktreePathOption) {
376376 setDraftThreadContext(routeThreadId, {
......@@ -398,9 +398,9 @@ export default function Sidebar() {
398398 },
399399 [
400400 clearProjectDraftThreadId,
401 getDraftThreadByProjectId,
401402 navigate,
402 readDraftThread,
403 readProjectDraftThreadId,
403 getDraftThread,
404404 routeThreadId,
405405 setDraftThreadContext,
406406 setProjectDraftThreadId,
......@@ -632,9 +632,9 @@ export default function Sidebar() {
632632 if (!confirmed) return;
633633
634634 try {
635 const projectDraftThreadId = readProjectDraftThreadId(projectId);
636 if (projectDraftThreadId) {
637 clearComposerDraftForThread(projectDraftThreadId);
635 const projectDraftThread = getDraftThreadByProjectId(projectId);
636 if (projectDraftThread) {
637 clearComposerDraftForThread(projectDraftThread.threadId);
638638 }
639639 clearProjectDraftThreadId(projectId);
640640 await api.orchestration.dispatchCommand({
......@@ -655,7 +655,7 @@ export default function Sidebar() {
655655 [
656656 clearComposerDraftForThread,
657657 clearProjectDraftThreadId,
658 readProjectDraftThreadId,
658 getDraftThreadByProjectId,
659659 state.projects,
660660 state.threads,
661661 ],
......@@ -666,9 +666,10 @@ export default function Sidebar() {
666666 const activeThread = routeThreadId
667667 ? state.threads.find((thread) => thread.id === routeThreadId)
668668 : undefined;
669 const activeDraftThread = routeThreadId ? readDraftThread(routeThreadId) : null;
669 const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null;
670670 if (isChatNewLocalShortcut(event, keybindings)) {
671 const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? state.projects[0]?.id;
671 const projectId =
672 activeThread?.projectId ?? activeDraftThread?.projectId ?? state.projects[0]?.id;
672673 if (!projectId) return;
673674 event.preventDefault();
674675 void handleNewThread(projectId);
......@@ -676,7 +677,8 @@ export default function Sidebar() {
676677 }
677678
678679 if (!isChatNewShortcut(event, keybindings)) return;
679 const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? state.projects[0]?.id;
680 const projectId =
681 activeThread?.projectId ?? activeDraftThread?.projectId ?? state.projects[0]?.id;
680682 if (!projectId) return;
681683 event.preventDefault();
682684 void handleNewThread(projectId, {
......@@ -689,7 +691,7 @@ export default function Sidebar() {
689691 return () => {
690692 window.removeEventListener("keydown", onWindowKeyDown);
691693 };
692 }, [handleNewThread, keybindings, readDraftThread, routeThreadId, state.projects, state.threads]);
694 }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);
693695
694696 const onCreateThreadClick = () => {
695697 if (state.projects.length === 0) {
apps/web/src/composerDraftStore.test.ts+27-14
......@@ -15,7 +15,10 @@ function makeImage(input: {
1515 const mimeType = input.mimeType ?? "image/png";
1616 const sizeBytes = input.sizeBytes ?? 4;
1717 const lastModified = input.lastModified ?? 1_700_000_000_000;
18 const file = new File([new Uint8Array(sizeBytes).fill(1)], name, { type: mimeType, lastModified });
18 const file = new File([new Uint8Array(sizeBytes).fill(1)], name, {
19 type: mimeType,
20 lastModified,
21 });
1922 return {
2023 type: "image",
2124 id: input.id,
......@@ -166,16 +169,22 @@ describe("composerDraftStore project draft thread mapping", () => {
166169
167170 it("stores and reads project draft thread ids via actions", () => {
168171 const store = useComposerDraftStore.getState();
169 expect(store.readProjectDraftThreadId(projectId)).toBeNull();
170 expect(store.readDraftThread(threadId)).toBeNull();
172 expect(store.getDraftThreadByProjectId(projectId)).toBeNull();
173 expect(store.getDraftThread(threadId)).toBeNull();
171174
172175 store.setProjectDraftThreadId(projectId, threadId, {
173176 branch: "feature/test",
174177 worktreePath: "/tmp/worktree-test",
175178 createdAt: "2026-01-01T00:00:00.000Z",
176179 });
177 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBe(threadId);
178 expect(useComposerDraftStore.getState().readDraftThread(threadId)).toEqual({
180 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toEqual({
181 threadId,
182 projectId,
183 branch: "feature/test",
184 worktreePath: "/tmp/worktree-test",
185 createdAt: "2026-01-01T00:00:00.000Z",
186 });
187 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({
179188 projectId,
180189 branch: "feature/test",
181190 worktreePath: "/tmp/worktree-test",
......@@ -188,27 +197,29 @@ describe("composerDraftStore project draft thread mapping", () => {
188197 store.setProjectDraftThreadId(projectId, threadId);
189198
190199 store.clearProjectDraftThreadById(projectId, otherThreadId);
191 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBe(threadId);
200 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(
201 threadId,
202 );
192203
193204 store.clearProjectDraftThreadById(projectId, threadId);
194 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull();
195 expect(useComposerDraftStore.getState().readDraftThread(threadId)).toBeNull();
205 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
206 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
196207 });
197208
198209 it("clears project draft mapping by project id", () => {
199210 const store = useComposerDraftStore.getState();
200211 store.setProjectDraftThreadId(projectId, threadId);
201212 store.clearProjectDraftThreadId(projectId);
202 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull();
203 expect(useComposerDraftStore.getState().readDraftThread(threadId)).toBeNull();
213 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
214 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
204215 });
205216
206217 it("clears draft registration independently", () => {
207218 const store = useComposerDraftStore.getState();
208219 store.setProjectDraftThreadId(projectId, threadId);
209220 store.clearDraftThread(threadId);
210 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull();
211 expect(useComposerDraftStore.getState().readDraftThread(threadId)).toBeNull();
221 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
222 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
212223 });
213224
214225 it("updates branch context on an existing draft thread", () => {
......@@ -221,8 +232,10 @@ describe("composerDraftStore project draft thread mapping", () => {
221232 branch: "feature/next",
222233 worktreePath: "/tmp/feature-next",
223234 });
224 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBe(threadId);
225 expect(useComposerDraftStore.getState().readDraftThread(threadId)).toMatchObject({
235 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(
236 threadId,
237 );
238 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({
226239 projectId,
227240 branch: "feature/next",
228241 worktreePath: "/tmp/feature-next",
apps/web/src/composerDraftStore.ts+145-101
......@@ -8,43 +8,10 @@ import {
88} from "@t3tools/contracts";
99import type { ChatImageAttachment } from "./types";
1010import { create } from "zustand";
11import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
11import { createJSONStorage, persist } from "zustand/middleware";
1212
1313export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
1414
15const SAFE_LOCAL_STORAGE: StateStorage = {
16 getItem: (key) => {
17 if (typeof window === "undefined") {
18 return null;
19 }
20 try {
21 return window.localStorage.getItem(key);
22 } catch {
23 return null;
24 }
25 },
26 setItem: (key, value) => {
27 if (typeof window === "undefined") {
28 return;
29 }
30 try {
31 window.localStorage.setItem(key, value);
32 } catch {
33 // Swallow quota/storage errors so draft state stays usable in-memory.
34 }
35 },
36 removeItem: (key) => {
37 if (typeof window === "undefined") {
38 return;
39 }
40 try {
41 window.localStorage.removeItem(key);
42 } catch {
43 // Best-effort cleanup.
44 }
45 },
46};
47
4815export interface PersistedComposerImageAttachment {
4916 id: string;
5017 name: string;
......@@ -79,7 +46,7 @@ interface PersistedComposerDraftStoreState {
7946 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
8047}
8148
82export interface ComposerThreadDraftState {
49interface ComposerThreadDraftState {
8350 prompt: string;
8451 cursor: number;
8552 images: ComposerImageAttachment[];
......@@ -96,13 +63,16 @@ export interface DraftThreadState {
9663 worktreePath: string | null;
9764}
9865
66interface ProjectDraftThread extends DraftThreadState {
67 threadId: ThreadId;
68}
69
9970interface ComposerDraftStoreState {
10071 draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;
10172 draftThreadsByThreadId: Record<ThreadId, DraftThreadState>;
10273 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
103 readDraftThread: (threadId: ThreadId) => DraftThreadState | null;
104 isDraftThread: (threadId: ThreadId) => boolean;
105 readProjectDraftThreadId: (projectId: ProjectId) => ThreadId | null;
74 getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null;
75 getDraftThread: (threadId: ThreadId) => DraftThreadState | null;
10676 setProjectDraftThreadId: (
10777 projectId: ProjectId,
10878 threadId: ThreadId,
......@@ -132,11 +102,23 @@ interface ComposerDraftStoreState {
132102 addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void;
133103 removeImage: (threadId: ThreadId, imageId: string) => void;
134104 clearPersistedAttachments: (threadId: ThreadId) => void;
135 syncPersistedAttachments: (threadId: ThreadId, attachments: PersistedComposerImageAttachment[]) => void;
105 syncPersistedAttachments: (
106 threadId: ThreadId,
107 attachments: PersistedComposerImageAttachment[],
108 ) => void;
136109 clearComposerContent: (threadId: ThreadId) => void;
137110 clearThreadDraft: (threadId: ThreadId) => void;
138111}
139112
113const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = {
114 draftsByThreadId: {},
115 draftThreadsByThreadId: {},
116 projectDraftThreadIdByProjectId: {},
117};
118
119let persistedDraftStateCache: PersistedComposerDraftStoreState = EMPTY_PERSISTED_DRAFT_STORE_STATE;
120let persistedDraftStorageChars: number | null = null;
121
140122const EMPTY_IMAGES: ComposerImageAttachment[] = [];
141123const EMPTY_IDS: string[] = [];
142124const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];
......@@ -223,7 +205,7 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt
223205
224206function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState {
225207 if (!value || typeof value !== "object") {
226 return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
208 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
227209 }
228210 const candidate = value as Record<string, unknown>;
229211 const rawDraftMap = candidate.draftsByThreadId;
......@@ -250,7 +232,10 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
250232 }
251233 draftThreadsByThreadId[threadId as ThreadId] = {
252234 projectId: projectId as ProjectId,
253 createdAt: typeof createdAt === "string" && createdAt.length > 0 ? createdAt : new Date().toISOString(),
235 createdAt:
236 typeof createdAt === "string" && createdAt.length > 0
237 ? createdAt
238 : new Date().toISOString(),
254239 branch: typeof branch === "string" ? branch : null,
255240 worktreePath: typeof worktreePath === "string" ? worktreePath : null,
256241 };
......@@ -258,7 +243,10 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
258243 }
259244 const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] =
260245 {};
261 if (rawProjectDraftThreadIdByProjectId && typeof rawProjectDraftThreadIdByProjectId === "object") {
246 if (
247 rawProjectDraftThreadIdByProjectId &&
248 typeof rawProjectDraftThreadIdByProjectId === "object"
249 ) {
262250 for (const [projectId, threadId] of Object.entries(
263251 rawProjectDraftThreadIdByProjectId as Record<string, unknown>,
264252 )) {
......@@ -327,7 +315,35 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
327315 ...(effort ? { effort } : {}),
328316 };
329317 }
330 return { draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId };
318 return {
319 draftsByThreadId: nextDraftsByThreadId,
320 draftThreadsByThreadId,
321 projectDraftThreadIdByProjectId,
322 };
323}
324
325function parsePersistedDraftStateRaw(raw: string | null): PersistedComposerDraftStoreState {
326 if (!raw) {
327 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
328 }
329 try {
330 const parsed = JSON.parse(raw) as unknown;
331 if (parsed && typeof parsed === "object" && "state" in parsed) {
332 return normalizePersistedComposerDraftState((parsed as { state?: unknown }).state);
333 }
334 return normalizePersistedComposerDraftState(parsed);
335 } catch {
336 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
337 }
338}
339
340function readPersistedAttachmentIdsFromCache(threadId: ThreadId): string[] {
341 if (threadId.length === 0) {
342 return [];
343 }
344 return (persistedDraftStateCache.draftsByThreadId[threadId]?.attachments ?? []).map(
345 (attachment) => attachment.id,
346 );
331347}
332348
333349function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null {
......@@ -379,25 +395,6 @@ function hydrateImagesFromPersisted(
379395 });
380396}
381397
382function readPersistedDraftStateFromLocalStorage(): PersistedComposerDraftStoreState {
383 if (typeof window === "undefined") {
384 return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
385 }
386 try {
387 const raw = window.localStorage.getItem(COMPOSER_DRAFT_STORAGE_KEY);
388 if (!raw) {
389 return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
390 }
391 const parsed = JSON.parse(raw) as unknown;
392 if (parsed && typeof parsed === "object" && "state" in parsed) {
393 return normalizePersistedComposerDraftState((parsed as { state?: unknown }).state);
394 }
395 return normalizePersistedComposerDraftState(parsed);
396 } catch {
397 return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} };
398 }
399}
400
401398function toHydratedThreadDraft(
402399 persistedDraft: PersistedComposerThreadDraftState,
403400): ComposerThreadDraftState {
......@@ -412,29 +409,80 @@ function toHydratedThreadDraft(
412409 };
413410}
414411
412const composerDraftStorage = createJSONStorage(() => ({
413 getItem: (name: string): string | null => {
414 if (typeof window === "undefined") {
415 persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE;
416 persistedDraftStorageChars = null;
417 return null;
418 }
419 try {
420 const raw = window.localStorage.getItem(name);
421 persistedDraftStateCache = parsePersistedDraftStateRaw(raw);
422 persistedDraftStorageChars = raw?.length ?? 0;
423 return raw;
424 } catch {
425 persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE;
426 persistedDraftStorageChars = null;
427 return null;
428 }
429 },
430 setItem: (name: string, value: string): void => {
431 if (typeof window === "undefined") {
432 return;
433 }
434 try {
435 window.localStorage.setItem(name, value);
436 persistedDraftStateCache = parsePersistedDraftStateRaw(value);
437 persistedDraftStorageChars = value.length;
438 } catch {
439 // Quota/storage errors are expected for large drafts. Keep last successful snapshot.
440 }
441 },
442 removeItem: (name: string): void => {
443 if (typeof window === "undefined") {
444 persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE;
445 persistedDraftStorageChars = null;
446 return;
447 }
448 try {
449 window.localStorage.removeItem(name);
450 } catch {
451 // Ignore remove failures; clear in-memory cache regardless.
452 }
453 persistedDraftStateCache = EMPTY_PERSISTED_DRAFT_STORE_STATE;
454 persistedDraftStorageChars = 0;
455 },
456}));
457
415458export const useComposerDraftStore = create<ComposerDraftStoreState>()(
416459 persist(
417460 (set, get) => ({
418461 draftsByThreadId: {},
419462 draftThreadsByThreadId: {},
420463 projectDraftThreadIdByProjectId: {},
421 readDraftThread: (threadId) => {
422 if (threadId.length === 0) {
464 getDraftThreadByProjectId: (projectId) => {
465 if (projectId.length === 0) {
423466 return null;
424467 }
425 return get().draftThreadsByThreadId[threadId] ?? null;
426 },
427 isDraftThread: (threadId) => {
428 if (threadId.length === 0) {
429 return false;
468 const threadId = get().projectDraftThreadIdByProjectId[projectId];
469 if (!threadId) {
470 return null;
430471 }
431 return get().draftThreadsByThreadId[threadId] !== undefined;
472 const draftThread = get().draftThreadsByThreadId[threadId];
473 if (!draftThread || draftThread.projectId !== projectId) {
474 return null;
475 }
476 return {
477 threadId,
478 ...draftThread,
479 };
432480 },
433 readProjectDraftThreadId: (projectId) => {
434 if (projectId.length === 0) {
481 getDraftThread: (threadId) => {
482 if (threadId.length === 0) {
435483 return null;
436484 }
437 return get().projectDraftThreadIdByProjectId[projectId] ?? null;
485 return get().draftThreadsByThreadId[threadId] ?? null;
438486 },
439487 setProjectDraftThreadId: (projectId, threadId, options) => {
440488 if (projectId.length === 0 || threadId.length === 0) {
......@@ -502,7 +550,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
502550 const nextDraftThread: DraftThreadState = {
503551 projectId: nextProjectId,
504552 createdAt:
505 options.createdAt === undefined ? existing.createdAt : (options.createdAt || existing.createdAt),
553 options.createdAt === undefined
554 ? existing.createdAt
555 : options.createdAt || existing.createdAt,
506556 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),
507557 worktreePath:
508558 options.worktreePath === undefined
......@@ -544,7 +594,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
544594 if (threadId === undefined) {
545595 return state;
546596 }
547 const { [projectId]: _removed, ...restProjectMappingsRaw } = state.projectDraftThreadIdByProjectId;
597 const { [projectId]: _removed, ...restProjectMappingsRaw } =
598 state.projectDraftThreadIdByProjectId;
548599 const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>;
549600 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
550601 ...state.draftThreadsByThreadId,
......@@ -566,7 +617,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
566617 if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) {
567618 return state;
568619 }
569 const { [projectId]: _removed, ...restProjectMappingsRaw } = state.projectDraftThreadIdByProjectId;
620 const { [projectId]: _removed, ...restProjectMappingsRaw } =
621 state.projectDraftThreadIdByProjectId;
570622 const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>;
571623 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
572624 ...state.draftThreadsByThreadId,
......@@ -586,7 +638,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
586638 }
587639 set((state) => {
588640 const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined;
589 const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes(threadId);
641 const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes(
642 threadId,
643 );
590644 if (!hasDraftThread && !hasProjectMapping) {
591645 return state;
592646 }
......@@ -681,9 +735,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
681735 return;
682736 }
683737 const nextEffort =
684 effort &&
685 REASONING_EFFORT_VALUES.has(effort) &&
686 effort !== DEFAULT_REASONING
738 effort && REASONING_EFFORT_VALUES.has(effort) && effort !== DEFAULT_REASONING
687739 ? effort
688740 : null;
689741 set((state) => {
......@@ -721,7 +773,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
721773 set((state) => {
722774 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
723775 const existingIds = new Set(existing.images.map((image) => image.id));
724 const existingDedupKeys = new Set(existing.images.map((image) => composerImageDedupKey(image)));
776 const existingDedupKeys = new Set(
777 existing.images.map((image) => composerImageDedupKey(image)),
778 );
725779 const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl));
726780 const dedupedIncoming: ComposerImageAttachment[] = [];
727781 for (const image of images) {
......@@ -823,8 +877,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
823877 ...current,
824878 // Stage attempted attachments so persist middleware can try writing them.
825879 persistedAttachments: attachments,
826 nonPersistedImageIds: current.nonPersistedImageIds.filter((id) =>
827 !attachmentIdSet.has(id),
880 nonPersistedImageIds: current.nonPersistedImageIds.filter(
881 (id) => !attachmentIdSet.has(id),
828882 ),
829883 };
830884 const nextDraftsByThreadId = { ...state.draftsByThreadId };
......@@ -836,7 +890,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
836890 return { draftsByThreadId: nextDraftsByThreadId };
837891 });
838892 Promise.resolve().then(() => {
839 const persistedIdSet = new Set(readPersistedComposerDraftAttachmentIds(threadId));
893 const persistedIdSet = new Set(readPersistedAttachmentIdsFromCache(threadId));
840894 set((state) => {
841895 const current = state.draftsByThreadId[threadId];
842896 if (!current) {
......@@ -903,7 +957,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
903957 set((state) => {
904958 const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined;
905959 const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined;
906 const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes(threadId);
960 const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes(
961 threadId,
962 );
907963 if (!hasComposerDraft && !hasDraftThread && !hasProjectMapping) {
908964 return state;
909965 }
......@@ -927,7 +983,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
927983 {
928984 name: COMPOSER_DRAFT_STORAGE_KEY,
929985 version: 1,
930 storage: createJSONStorage(() => SAFE_LOCAL_STORAGE),
986 storage: composerDraftStorage,
931987 partialize: (state) => {
932988 const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};
933989 for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) {
......@@ -963,6 +1019,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
9631019 },
9641020 merge: (persistedState, currentState) => {
9651021 const normalizedPersisted = normalizePersistedComposerDraftState(persistedState);
1022 persistedDraftStateCache = normalizedPersisted;
9661023 const draftsByThreadId = Object.fromEntries(
9671024 Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [
9681025 threadId,
......@@ -984,19 +1041,6 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS
9841041 return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT);
9851042}
9861043
987export function readPersistedComposerDraftAttachmentIds(threadId: ThreadId): string[] {
988 if (threadId.length === 0) {
989 return [];
990 }
991 return readPersistedComposerDraftAttachments(threadId).map((attachment) => attachment.id);
992}
993
994export function readPersistedComposerDraftAttachments(
995 threadId: ThreadId,
996): PersistedComposerImageAttachment[] {
997 if (threadId.length === 0) {
998 return [];
999 }
1000 const persisted = readPersistedDraftStateFromLocalStorage();
1001 return persisted.draftsByThreadId[threadId]?.attachments ?? [];
1044export function readComposerDraftPersistStorageChars(): number | null {
1045 return persistedDraftStorageChars;
10021046}