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";...@@ -112,11 +112,10 @@ import { newCommandId, newMessageId } from "~/lib/utils";
112import { readNativeApi } from "~/nativeApi";112import { readNativeApi } from "~/nativeApi";
113import { getAppModelOptions, useAppSettings } from "../appSettings";113import { getAppModelOptions, useAppSettings } from "../appSettings";
114import {114import {
115 COMPOSER_DRAFT_STORAGE_KEY,
116 type ComposerImageAttachment,115 type ComposerImageAttachment,
117 type DraftThreadState,116 type DraftThreadState,
118 type PersistedComposerImageAttachment,117 type PersistedComposerImageAttachment,
119 readPersistedComposerDraftAttachments,118 readComposerDraftPersistStorageChars,
120 useComposerDraftStore,119 useComposerDraftStore,
121 useComposerThreadDraft,120 useComposerThreadDraft,
122} from "../composerDraftStore";121} from "../composerDraftStore";
...@@ -295,18 +294,6 @@ function cloneComposerImageForRetry(image: ComposerImageAttachment): ComposerIma...@@ -295,18 +294,6 @@ function cloneComposerImageForRetry(image: ComposerImageAttachment): ComposerIma
295 }294 }
296}295}
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
310function debugDraftPersist(message: string, payload: Record<string, unknown>): void {297function debugDraftPersist(message: string, payload: Record<string, unknown>): void {
311 if (!ENABLE_DRAFT_PERSIST_DEBUG_LOGS) {298 if (!ENABLE_DRAFT_PERSIST_DEBUG_LOGS) {
312 return;299 return;
...@@ -1286,7 +1273,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1286,7 +1273,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1286 sizeBytes: attachment.sizeBytes,1273 sizeBytes: attachment.sizeBytes,
1287 dataUrlChars: attachment.dataUrl.length,1274 dataUrlChars: attachment.dataUrl.length,
1288 })),1275 })),
1289 storageCharsBefore: readComposerDraftStorageChars(),1276 storageCharsBefore: readComposerDraftPersistStorageChars(),
1290 });1277 });
1291 syncComposerDraftPersistedAttachments(1278 syncComposerDraftPersistedAttachments(
1292 threadId,1279 threadId,
...@@ -1296,11 +1283,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1296,11 +1283,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
1296 threadId,1283 threadId,
1297 stagedCount: stagedAttachmentIds.length,1284 stagedCount: stagedAttachmentIds.length,
1298 serializationFailedCount: serializationFailedImageIds.length,1285 serializationFailedCount: serializationFailedImageIds.length,
1299 storageCharsAfter: readComposerDraftStorageChars(),1286 storageCharsAfter: readComposerDraftPersistStorageChars(),
1300 });1287 });
1301 } catch (error) {1288 } catch (error) {
1302 const currentImageIds = new Set(composerImages.map((image) => image.id));1289 const currentImageIds = new Set(composerImages.map((image) => image.id));
1303 const fallbackPersistedAttachments = readPersistedComposerDraftAttachments(threadId);1290 const fallbackPersistedAttachments =
1291 useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? [];
1304 const fallbackPersistedIds = fallbackPersistedAttachments1292 const fallbackPersistedIds = fallbackPersistedAttachments
1305 .map((attachment) => attachment.id)1293 .map((attachment) => attachment.id)
1306 .filter((id) => currentImageIds.has(id));1294 .filter((id) => currentImageIds.has(id));
...@@ -1313,7 +1301,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1313,7 +1301,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1313 error: error instanceof Error ? error.message : String(error),1301 error: error instanceof Error ? error.message : String(error),
1314 imageIds: composerImages.map((image) => image.id),1302 imageIds: composerImages.map((image) => image.id),
1315 fallbackPersistedCount: fallbackPersistedIds.length,1303 fallbackPersistedCount: fallbackPersistedIds.length,
1316 storageChars: readComposerDraftStorageChars(),1304 storageChars: readComposerDraftPersistStorageChars(),
1317 });1305 });
1318 if (cancelled) {1306 if (cancelled) {
1319 return;1307 return;
apps/web/src/components/Sidebar.tsx+35-33
...@@ -230,11 +230,15 @@ function ProjectFavicon({ cwd }: { cwd: string }) {...@@ -230,11 +230,15 @@ function ProjectFavicon({ cwd }: { cwd: string }) {
230export default function Sidebar() {230export default function Sidebar() {
231 const { state, dispatch } = useStore();231 const { state, dispatch } = useStore();
232 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);232 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);
233 const readDraftThread = useComposerDraftStore((store) => store.readDraftThread);233 const getDraftThreadByProjectId = useComposerDraftStore(
234 const readProjectDraftThreadId = useComposerDraftStore((store) => store.readProjectDraftThreadId);234 (store) => store.getDraftThreadByProjectId,
235 );
236 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
235 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);237 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
236 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);238 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
237 const clearProjectDraftThreadId = useComposerDraftStore((store) => store.clearProjectDraftThreadId);239 const clearProjectDraftThreadId = useComposerDraftStore(
240 (store) => store.clearProjectDraftThreadId,
241 );
238 const clearProjectDraftThreadById = useComposerDraftStore(242 const clearProjectDraftThreadById = useComposerDraftStore(
239 (store) => store.clearProjectDraftThreadById,243 (store) => store.clearProjectDraftThreadById,
240 );244 );
...@@ -275,15 +279,14 @@ export default function Sidebar() {...@@ -275,15 +279,14 @@ export default function Sidebar() {
275 [projectCwdById, state.threads],279 [projectCwdById, state.threads],
276 );280 );
277 const threadGitStatusCwds = useMemo(281 const threadGitStatusCwds = useMemo(
278 () =>282 () => [
279 [283 ...new Set(
280 ...new Set(284 threadGitTargets
281 threadGitTargets285 .filter((target) => target.branch !== null)
282 .filter((target) => target.branch !== null)286 .map((target) => target.cwd)
283 .map((target) => target.cwd)287 .filter((cwd): cwd is string => cwd !== null),
284 .filter((cwd): cwd is string => cwd !== null),288 ),
285 ),289 ],
286 ],
287 [threadGitTargets],290 [threadGitTargets],
288 );291 );
289 const threadGitStatusQueries = useQueries({292 const threadGitStatusQueries = useQueries({
...@@ -346,31 +349,28 @@ export default function Sidebar() {...@@ -346,31 +349,28 @@ export default function Sidebar() {
346 ): Promise<void> => {349 ): Promise<void> => {
347 const hasBranchOption = options?.branch !== undefined;350 const hasBranchOption = options?.branch !== undefined;
348 const hasWorktreePathOption = options?.worktreePath !== undefined;351 const hasWorktreePathOption = options?.worktreePath !== undefined;
349 const storedDraftThreadId = readProjectDraftThreadId(projectId);352 const storedDraftThread = getDraftThreadByProjectId(projectId);
350 const storedDraftThread = storedDraftThreadId ? readDraftThread(storedDraftThreadId) : null;353 if (storedDraftThread) {
351 if (storedDraftThread && storedDraftThread.projectId === projectId && storedDraftThreadId) {
352 return (async () => {354 return (async () => {
353 if (hasBranchOption || hasWorktreePathOption) {355 if (hasBranchOption || hasWorktreePathOption) {
354 setDraftThreadContext(storedDraftThreadId, {356 setDraftThreadContext(storedDraftThread.threadId, {
355 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),357 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
356 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),358 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
357 });359 });
358 }360 }
359 setProjectDraftThreadId(projectId, storedDraftThreadId);361 setProjectDraftThreadId(projectId, storedDraftThread.threadId);
360 if (routeThreadId === storedDraftThreadId) {362 if (routeThreadId === storedDraftThread.threadId) {
361 return;363 return;
362 }364 }
363 await navigate({365 await navigate({
364 to: "/$threadId",366 to: "/$threadId",
365 params: { threadId: storedDraftThreadId },367 params: { threadId: storedDraftThread.threadId },
366 });368 });
367 })();369 })();
368 }370 }
369 if (storedDraftThreadId) {371 clearProjectDraftThreadId(projectId);
370 clearProjectDraftThreadId(projectId);
371 }
372372
373 const activeDraftThread = routeThreadId ? readDraftThread(routeThreadId) : null;373 const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null;
374 if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) {374 if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) {
375 if (hasBranchOption || hasWorktreePathOption) {375 if (hasBranchOption || hasWorktreePathOption) {
376 setDraftThreadContext(routeThreadId, {376 setDraftThreadContext(routeThreadId, {
...@@ -398,9 +398,9 @@ export default function Sidebar() {...@@ -398,9 +398,9 @@ export default function Sidebar() {
398 },398 },
399 [399 [
400 clearProjectDraftThreadId,400 clearProjectDraftThreadId,
401 getDraftThreadByProjectId,
401 navigate,402 navigate,
402 readDraftThread,403 getDraftThread,
403 readProjectDraftThreadId,
404 routeThreadId,404 routeThreadId,
405 setDraftThreadContext,405 setDraftThreadContext,
406 setProjectDraftThreadId,406 setProjectDraftThreadId,
...@@ -632,9 +632,9 @@ export default function Sidebar() {...@@ -632,9 +632,9 @@ export default function Sidebar() {
632 if (!confirmed) return;632 if (!confirmed) return;
633633
634 try {634 try {
635 const projectDraftThreadId = readProjectDraftThreadId(projectId);635 const projectDraftThread = getDraftThreadByProjectId(projectId);
636 if (projectDraftThreadId) {636 if (projectDraftThread) {
637 clearComposerDraftForThread(projectDraftThreadId);637 clearComposerDraftForThread(projectDraftThread.threadId);
638 }638 }
639 clearProjectDraftThreadId(projectId);639 clearProjectDraftThreadId(projectId);
640 await api.orchestration.dispatchCommand({640 await api.orchestration.dispatchCommand({
...@@ -655,7 +655,7 @@ export default function Sidebar() {...@@ -655,7 +655,7 @@ export default function Sidebar() {
655 [655 [
656 clearComposerDraftForThread,656 clearComposerDraftForThread,
657 clearProjectDraftThreadId,657 clearProjectDraftThreadId,
658 readProjectDraftThreadId,658 getDraftThreadByProjectId,
659 state.projects,659 state.projects,
660 state.threads,660 state.threads,
661 ],661 ],
...@@ -666,9 +666,10 @@ export default function Sidebar() {...@@ -666,9 +666,10 @@ export default function Sidebar() {
666 const activeThread = routeThreadId666 const activeThread = routeThreadId
667 ? state.threads.find((thread) => thread.id === routeThreadId)667 ? state.threads.find((thread) => thread.id === routeThreadId)
668 : undefined;668 : undefined;
669 const activeDraftThread = routeThreadId ? readDraftThread(routeThreadId) : null;669 const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null;
670 if (isChatNewLocalShortcut(event, keybindings)) {670 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;
672 if (!projectId) return;673 if (!projectId) return;
673 event.preventDefault();674 event.preventDefault();
674 void handleNewThread(projectId);675 void handleNewThread(projectId);
...@@ -676,7 +677,8 @@ export default function Sidebar() {...@@ -676,7 +677,8 @@ export default function Sidebar() {
676 }677 }
677678
678 if (!isChatNewShortcut(event, keybindings)) return;679 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;
680 if (!projectId) return;682 if (!projectId) return;
681 event.preventDefault();683 event.preventDefault();
682 void handleNewThread(projectId, {684 void handleNewThread(projectId, {
...@@ -689,7 +691,7 @@ export default function Sidebar() {...@@ -689,7 +691,7 @@ export default function Sidebar() {
689 return () => {691 return () => {
690 window.removeEventListener("keydown", onWindowKeyDown);692 window.removeEventListener("keydown", onWindowKeyDown);
691 };693 };
692 }, [handleNewThread, keybindings, readDraftThread, routeThreadId, state.projects, state.threads]);694 }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);
693695
694 const onCreateThreadClick = () => {696 const onCreateThreadClick = () => {
695 if (state.projects.length === 0) {697 if (state.projects.length === 0) {
apps/web/src/composerDraftStore.test.ts+27-14
...@@ -15,7 +15,10 @@ function makeImage(input: {...@@ -15,7 +15,10 @@ function makeImage(input: {
15 const mimeType = input.mimeType ?? "image/png";15 const mimeType = input.mimeType ?? "image/png";
16 const sizeBytes = input.sizeBytes ?? 4;16 const sizeBytes = input.sizeBytes ?? 4;
17 const lastModified = input.lastModified ?? 1_700_000_000_000;17 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 });
19 return {22 return {
20 type: "image",23 type: "image",
21 id: input.id,24 id: input.id,
...@@ -166,16 +169,22 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -166,16 +169,22 @@ describe("composerDraftStore project draft thread mapping", () => {
166169
167 it("stores and reads project draft thread ids via actions", () => {170 it("stores and reads project draft thread ids via actions", () => {
168 const store = useComposerDraftStore.getState();171 const store = useComposerDraftStore.getState();
169 expect(store.readProjectDraftThreadId(projectId)).toBeNull();172 expect(store.getDraftThreadByProjectId(projectId)).toBeNull();
170 expect(store.readDraftThread(threadId)).toBeNull();173 expect(store.getDraftThread(threadId)).toBeNull();
171174
172 store.setProjectDraftThreadId(projectId, threadId, {175 store.setProjectDraftThreadId(projectId, threadId, {
173 branch: "feature/test",176 branch: "feature/test",
174 worktreePath: "/tmp/worktree-test",177 worktreePath: "/tmp/worktree-test",
175 createdAt: "2026-01-01T00:00:00.000Z",178 createdAt: "2026-01-01T00:00:00.000Z",
176 });179 });
177 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBe(threadId);180 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toEqual({
178 expect(useComposerDraftStore.getState().readDraftThread(threadId)).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({
179 projectId,188 projectId,
180 branch: "feature/test",189 branch: "feature/test",
181 worktreePath: "/tmp/worktree-test",190 worktreePath: "/tmp/worktree-test",
...@@ -188,27 +197,29 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -188,27 +197,29 @@ describe("composerDraftStore project draft thread mapping", () => {
188 store.setProjectDraftThreadId(projectId, threadId);197 store.setProjectDraftThreadId(projectId, threadId);
189198
190 store.clearProjectDraftThreadById(projectId, otherThreadId);199 store.clearProjectDraftThreadById(projectId, otherThreadId);
191 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBe(threadId);200 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(
201 threadId,
202 );
192203
193 store.clearProjectDraftThreadById(projectId, threadId);204 store.clearProjectDraftThreadById(projectId, threadId);
194 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull();205 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
195 expect(useComposerDraftStore.getState().readDraftThread(threadId)).toBeNull();206 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
196 });207 });
197208
198 it("clears project draft mapping by project id", () => {209 it("clears project draft mapping by project id", () => {
199 const store = useComposerDraftStore.getState();210 const store = useComposerDraftStore.getState();
200 store.setProjectDraftThreadId(projectId, threadId);211 store.setProjectDraftThreadId(projectId, threadId);
201 store.clearProjectDraftThreadId(projectId);212 store.clearProjectDraftThreadId(projectId);
202 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull();213 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
203 expect(useComposerDraftStore.getState().readDraftThread(threadId)).toBeNull();214 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
204 });215 });
205216
206 it("clears draft registration independently", () => {217 it("clears draft registration independently", () => {
207 const store = useComposerDraftStore.getState();218 const store = useComposerDraftStore.getState();
208 store.setProjectDraftThreadId(projectId, threadId);219 store.setProjectDraftThreadId(projectId, threadId);
209 store.clearDraftThread(threadId);220 store.clearDraftThread(threadId);
210 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBeNull();221 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
211 expect(useComposerDraftStore.getState().readDraftThread(threadId)).toBeNull();222 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
212 });223 });
213224
214 it("updates branch context on an existing draft thread", () => {225 it("updates branch context on an existing draft thread", () => {
...@@ -221,8 +232,10 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -221,8 +232,10 @@ describe("composerDraftStore project draft thread mapping", () => {
221 branch: "feature/next",232 branch: "feature/next",
222 worktreePath: "/tmp/feature-next",233 worktreePath: "/tmp/feature-next",
223 });234 });
224 expect(useComposerDraftStore.getState().readProjectDraftThreadId(projectId)).toBe(threadId);235 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(
225 expect(useComposerDraftStore.getState().readDraftThread(threadId)).toMatchObject({236 threadId,
237 );
238 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({
226 projectId,239 projectId,
227 branch: "feature/next",240 branch: "feature/next",
228 worktreePath: "/tmp/feature-next",241 worktreePath: "/tmp/feature-next",
apps/web/src/composerDraftStore.ts+145-101
...@@ -8,43 +8,10 @@ import {...@@ -8,43 +8,10 @@ import {
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import type { ChatImageAttachment } from "./types";9import type { ChatImageAttachment } from "./types";
10import { create } from "zustand";10import { create } from "zustand";
11import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";11import { createJSONStorage, persist } from "zustand/middleware";
1212
13export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";13export 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
48export interface PersistedComposerImageAttachment {15export interface PersistedComposerImageAttachment {
49 id: string;16 id: string;
50 name: string;17 name: string;
...@@ -79,7 +46,7 @@ interface PersistedComposerDraftStoreState {...@@ -79,7 +46,7 @@ interface PersistedComposerDraftStoreState {
79 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;46 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
80}47}
8148
82export interface ComposerThreadDraftState {49interface ComposerThreadDraftState {
83 prompt: string;50 prompt: string;
84 cursor: number;51 cursor: number;
85 images: ComposerImageAttachment[];52 images: ComposerImageAttachment[];
...@@ -96,13 +63,16 @@ export interface DraftThreadState {...@@ -96,13 +63,16 @@ export interface DraftThreadState {
96 worktreePath: string | null;63 worktreePath: string | null;
97}64}
9865
66interface ProjectDraftThread extends DraftThreadState {
67 threadId: ThreadId;
68}
69
99interface ComposerDraftStoreState {70interface ComposerDraftStoreState {
100 draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;71 draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;
101 draftThreadsByThreadId: Record<ThreadId, DraftThreadState>;72 draftThreadsByThreadId: Record<ThreadId, DraftThreadState>;
102 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;73 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
103 readDraftThread: (threadId: ThreadId) => DraftThreadState | null;74 getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null;
104 isDraftThread: (threadId: ThreadId) => boolean;75 getDraftThread: (threadId: ThreadId) => DraftThreadState | null;
105 readProjectDraftThreadId: (projectId: ProjectId) => ThreadId | null;
106 setProjectDraftThreadId: (76 setProjectDraftThreadId: (
107 projectId: ProjectId,77 projectId: ProjectId,
108 threadId: ThreadId,78 threadId: ThreadId,
...@@ -132,11 +102,23 @@ interface ComposerDraftStoreState {...@@ -132,11 +102,23 @@ interface ComposerDraftStoreState {
132 addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void;102 addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void;
133 removeImage: (threadId: ThreadId, imageId: string) => void;103 removeImage: (threadId: ThreadId, imageId: string) => void;
134 clearPersistedAttachments: (threadId: ThreadId) => void;104 clearPersistedAttachments: (threadId: ThreadId) => void;
135 syncPersistedAttachments: (threadId: ThreadId, attachments: PersistedComposerImageAttachment[]) => void;105 syncPersistedAttachments: (
106 threadId: ThreadId,
107 attachments: PersistedComposerImageAttachment[],
108 ) => void;
136 clearComposerContent: (threadId: ThreadId) => void;109 clearComposerContent: (threadId: ThreadId) => void;
137 clearThreadDraft: (threadId: ThreadId) => void;110 clearThreadDraft: (threadId: ThreadId) => void;
138}111}
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
140const EMPTY_IMAGES: ComposerImageAttachment[] = [];122const EMPTY_IMAGES: ComposerImageAttachment[] = [];
141const EMPTY_IDS: string[] = [];123const EMPTY_IDS: string[] = [];
142const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];124const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = [];
...@@ -223,7 +205,7 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt...@@ -223,7 +205,7 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt
223205
224function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState {206function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState {
225 if (!value || typeof value !== "object") {207 if (!value || typeof value !== "object") {
226 return { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {} };208 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
227 }209 }
228 const candidate = value as Record<string, unknown>;210 const candidate = value as Record<string, unknown>;
229 const rawDraftMap = candidate.draftsByThreadId;211 const rawDraftMap = candidate.draftsByThreadId;
...@@ -250,7 +232,10 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -250,7 +232,10 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
250 }232 }
251 draftThreadsByThreadId[threadId as ThreadId] = {233 draftThreadsByThreadId[threadId as ThreadId] = {
252 projectId: projectId as ProjectId,234 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(),
254 branch: typeof branch === "string" ? branch : null,239 branch: typeof branch === "string" ? branch : null,
255 worktreePath: typeof worktreePath === "string" ? worktreePath : null,240 worktreePath: typeof worktreePath === "string" ? worktreePath : null,
256 };241 };
...@@ -258,7 +243,10 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -258,7 +243,10 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
258 }243 }
259 const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] =244 const projectDraftThreadIdByProjectId: PersistedComposerDraftStoreState["projectDraftThreadIdByProjectId"] =
260 {};245 {};
261 if (rawProjectDraftThreadIdByProjectId && typeof rawProjectDraftThreadIdByProjectId === "object") {246 if (
247 rawProjectDraftThreadIdByProjectId &&
248 typeof rawProjectDraftThreadIdByProjectId === "object"
249 ) {
262 for (const [projectId, threadId] of Object.entries(250 for (const [projectId, threadId] of Object.entries(
263 rawProjectDraftThreadIdByProjectId as Record<string, unknown>,251 rawProjectDraftThreadIdByProjectId as Record<string, unknown>,
264 )) {252 )) {
...@@ -327,7 +315,35 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -327,7 +315,35 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
327 ...(effort ? { effort } : {}),315 ...(effort ? { effort } : {}),
328 };316 };
329 }317 }
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 );
331}347}
332348
333function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null {349function fileFromDataUrl(dataUrl: string, name: string, mimeType: string): File | null {
...@@ -379,25 +395,6 @@ function hydrateImagesFromPersisted(...@@ -379,25 +395,6 @@ function hydrateImagesFromPersisted(
379 });395 });
380}396}
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
401function toHydratedThreadDraft(398function toHydratedThreadDraft(
402 persistedDraft: PersistedComposerThreadDraftState,399 persistedDraft: PersistedComposerThreadDraftState,
403): ComposerThreadDraftState {400): ComposerThreadDraftState {
...@@ -412,29 +409,80 @@ function toHydratedThreadDraft(...@@ -412,29 +409,80 @@ function toHydratedThreadDraft(
412 };409 };
413}410}
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
415export const useComposerDraftStore = create<ComposerDraftStoreState>()(458export const useComposerDraftStore = create<ComposerDraftStoreState>()(
416 persist(459 persist(
417 (set, get) => ({460 (set, get) => ({
418 draftsByThreadId: {},461 draftsByThreadId: {},
419 draftThreadsByThreadId: {},462 draftThreadsByThreadId: {},
420 projectDraftThreadIdByProjectId: {},463 projectDraftThreadIdByProjectId: {},
421 readDraftThread: (threadId) => {464 getDraftThreadByProjectId: (projectId) => {
422 if (threadId.length === 0) {465 if (projectId.length === 0) {
423 return null;466 return null;
424 }467 }
425 return get().draftThreadsByThreadId[threadId] ?? null;468 const threadId = get().projectDraftThreadIdByProjectId[projectId];
426 },469 if (!threadId) {
427 isDraftThread: (threadId) => {470 return null;
428 if (threadId.length === 0) {
429 return false;
430 }471 }
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 };
432 },480 },
433 readProjectDraftThreadId: (projectId) => {481 getDraftThread: (threadId) => {
434 if (projectId.length === 0) {482 if (threadId.length === 0) {
435 return null;483 return null;
436 }484 }
437 return get().projectDraftThreadIdByProjectId[projectId] ?? null;485 return get().draftThreadsByThreadId[threadId] ?? null;
438 },486 },
439 setProjectDraftThreadId: (projectId, threadId, options) => {487 setProjectDraftThreadId: (projectId, threadId, options) => {
440 if (projectId.length === 0 || threadId.length === 0) {488 if (projectId.length === 0 || threadId.length === 0) {
...@@ -502,7 +550,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -502,7 +550,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
502 const nextDraftThread: DraftThreadState = {550 const nextDraftThread: DraftThreadState = {
503 projectId: nextProjectId,551 projectId: nextProjectId,
504 createdAt:552 createdAt:
505 options.createdAt === undefined ? existing.createdAt : (options.createdAt || existing.createdAt),553 options.createdAt === undefined
554 ? existing.createdAt
555 : options.createdAt || existing.createdAt,
506 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),556 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),
507 worktreePath:557 worktreePath:
508 options.worktreePath === undefined558 options.worktreePath === undefined
...@@ -544,7 +594,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -544,7 +594,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
544 if (threadId === undefined) {594 if (threadId === undefined) {
545 return state;595 return state;
546 }596 }
547 const { [projectId]: _removed, ...restProjectMappingsRaw } = state.projectDraftThreadIdByProjectId;597 const { [projectId]: _removed, ...restProjectMappingsRaw } =
598 state.projectDraftThreadIdByProjectId;
548 const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>;599 const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>;
549 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {600 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
550 ...state.draftThreadsByThreadId,601 ...state.draftThreadsByThreadId,
...@@ -566,7 +617,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -566,7 +617,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
566 if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) {617 if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) {
567 return state;618 return state;
568 }619 }
569 const { [projectId]: _removed, ...restProjectMappingsRaw } = state.projectDraftThreadIdByProjectId;620 const { [projectId]: _removed, ...restProjectMappingsRaw } =
621 state.projectDraftThreadIdByProjectId;
570 const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>;622 const restProjectMappings = restProjectMappingsRaw as Record<ProjectId, ThreadId>;
571 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {623 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
572 ...state.draftThreadsByThreadId,624 ...state.draftThreadsByThreadId,
...@@ -586,7 +638,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -586,7 +638,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
586 }638 }
587 set((state) => {639 set((state) => {
588 const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined;640 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 );
590 if (!hasDraftThread && !hasProjectMapping) {644 if (!hasDraftThread && !hasProjectMapping) {
591 return state;645 return state;
592 }646 }
...@@ -681,9 +735,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -681,9 +735,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
681 return;735 return;
682 }736 }
683 const nextEffort =737 const nextEffort =
684 effort &&738 effort && REASONING_EFFORT_VALUES.has(effort) && effort !== DEFAULT_REASONING
685 REASONING_EFFORT_VALUES.has(effort) &&
686 effort !== DEFAULT_REASONING
687 ? effort739 ? effort
688 : null;740 : null;
689 set((state) => {741 set((state) => {
...@@ -721,7 +773,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -721,7 +773,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
721 set((state) => {773 set((state) => {
722 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();774 const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft();
723 const existingIds = new Set(existing.images.map((image) => image.id));775 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 );
725 const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl));779 const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl));
726 const dedupedIncoming: ComposerImageAttachment[] = [];780 const dedupedIncoming: ComposerImageAttachment[] = [];
727 for (const image of images) {781 for (const image of images) {
...@@ -823,8 +877,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -823,8 +877,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
823 ...current,877 ...current,
824 // Stage attempted attachments so persist middleware can try writing them.878 // Stage attempted attachments so persist middleware can try writing them.
825 persistedAttachments: attachments,879 persistedAttachments: attachments,
826 nonPersistedImageIds: current.nonPersistedImageIds.filter((id) =>880 nonPersistedImageIds: current.nonPersistedImageIds.filter(
827 !attachmentIdSet.has(id),881 (id) => !attachmentIdSet.has(id),
828 ),882 ),
829 };883 };
830 const nextDraftsByThreadId = { ...state.draftsByThreadId };884 const nextDraftsByThreadId = { ...state.draftsByThreadId };
...@@ -836,7 +890,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -836,7 +890,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
836 return { draftsByThreadId: nextDraftsByThreadId };890 return { draftsByThreadId: nextDraftsByThreadId };
837 });891 });
838 Promise.resolve().then(() => {892 Promise.resolve().then(() => {
839 const persistedIdSet = new Set(readPersistedComposerDraftAttachmentIds(threadId));893 const persistedIdSet = new Set(readPersistedAttachmentIdsFromCache(threadId));
840 set((state) => {894 set((state) => {
841 const current = state.draftsByThreadId[threadId];895 const current = state.draftsByThreadId[threadId];
842 if (!current) {896 if (!current) {
...@@ -903,7 +957,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -903,7 +957,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
903 set((state) => {957 set((state) => {
904 const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined;958 const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined;
905 const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined;959 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 );
907 if (!hasComposerDraft && !hasDraftThread && !hasProjectMapping) {963 if (!hasComposerDraft && !hasDraftThread && !hasProjectMapping) {
908 return state;964 return state;
909 }965 }
...@@ -927,7 +983,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -927,7 +983,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
927 {983 {
928 name: COMPOSER_DRAFT_STORAGE_KEY,984 name: COMPOSER_DRAFT_STORAGE_KEY,
929 version: 1,985 version: 1,
930 storage: createJSONStorage(() => SAFE_LOCAL_STORAGE),986 storage: composerDraftStorage,
931 partialize: (state) => {987 partialize: (state) => {
932 const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};988 const persistedDraftsByThreadId: PersistedComposerDraftStoreState["draftsByThreadId"] = {};
933 for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) {989 for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) {
...@@ -963,6 +1019,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -963,6 +1019,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
963 },1019 },
964 merge: (persistedState, currentState) => {1020 merge: (persistedState, currentState) => {
965 const normalizedPersisted = normalizePersistedComposerDraftState(persistedState);1021 const normalizedPersisted = normalizePersistedComposerDraftState(persistedState);
1022 persistedDraftStateCache = normalizedPersisted;
966 const draftsByThreadId = Object.fromEntries(1023 const draftsByThreadId = Object.fromEntries(
967 Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [1024 Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [
968 threadId,1025 threadId,
...@@ -984,19 +1041,6 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS...@@ -984,19 +1041,6 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS
984 return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT);1041 return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT);
985}1042}
9861043
987export function readPersistedComposerDraftAttachmentIds(threadId: ThreadId): string[] {1044export function readComposerDraftPersistStorageChars(): number | null {
988 if (threadId.length === 0) {1045 return persistedDraftStorageChars;
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 ?? [];
1002}1046}