authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 19:01:52-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 22:36:35-08:00
logd9cce6402ae875b24d9ddbe73866eee94ec16ddb
treece9c28f6d989e834b7e3f86f51f5562d13e33e6e
parent508872e04b87dc7cb53632988115b54b15f78e06

Clean up orphaned composer drafts and harden draft checks

- Remove stale `draftsByThreadId` entries when project-thread mappings are replaced or cleared - Preserve drafts when a thread is still mapped to another project - Parse persisted image `dataUrl` payloads more defensively and use `Object.hasOwn` for draft-thread existence checks

3 files changed, 58 insertions(+), 4 deletions(-)

apps/web/src/composerDraftStore.test.ts+34
......@@ -156,6 +156,7 @@ describe("composerDraftStore clearComposerContent", () => {
156156
157157describe("composerDraftStore project draft thread mapping", () => {
158158 const projectId = ProjectId.makeUnsafe("project-a");
159 const otherProjectId = ProjectId.makeUnsafe("project-b");
159160 const threadId = ThreadId.makeUnsafe("thread-a");
160161 const otherThreadId = ThreadId.makeUnsafe("thread-b");
161162
......@@ -195,6 +196,7 @@ describe("composerDraftStore project draft thread mapping", () => {
195196 it("clears only matching project draft mapping entries", () => {
196197 const store = useComposerDraftStore.getState();
197198 store.setProjectDraftThreadId(projectId, threadId);
199 store.setPrompt(threadId, "hello");
198200
199201 store.clearProjectDraftThreadById(projectId, otherThreadId);
200202 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(
......@@ -204,14 +206,46 @@ describe("composerDraftStore project draft thread mapping", () => {
204206 store.clearProjectDraftThreadById(projectId, threadId);
205207 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
206208 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
209 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
207210 });
208211
209212 it("clears project draft mapping by project id", () => {
210213 const store = useComposerDraftStore.getState();
211214 store.setProjectDraftThreadId(projectId, threadId);
215 store.setPrompt(threadId, "hello");
212216 store.clearProjectDraftThreadId(projectId);
213217 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
214218 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
219 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
220 });
221
222 it("clears orphaned composer drafts when remapping a project to a new draft thread", () => {
223 const store = useComposerDraftStore.getState();
224 store.setProjectDraftThreadId(projectId, threadId);
225 store.setPrompt(threadId, "orphan me");
226
227 store.setProjectDraftThreadId(projectId, otherThreadId);
228
229 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(
230 otherThreadId,
231 );
232 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
233 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
234 });
235
236 it("keeps composer drafts when the thread is still mapped by another project", () => {
237 const store = useComposerDraftStore.getState();
238 store.setProjectDraftThreadId(projectId, threadId);
239 store.setProjectDraftThreadId(otherProjectId, threadId);
240 store.setPrompt(threadId, "keep me");
241
242 store.clearProjectDraftThreadId(projectId);
243
244 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
245 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId).toBe(
246 threadId,
247 );
248 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me");
215249 });
216250
217251 it("clears draft registration independently", () => {
apps/web/src/composerDraftStore.ts+23-3
......@@ -355,9 +355,11 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] {
355355function hydreatePersistedComposerImageAttachment(
356356 attachment: PersistedComposerImageAttachment,
357357): File | null {
358 const [rawHeader, payload] = attachment.dataUrl.split(",", 2);
359 const header = rawHeader ?? "";
360 if (!payload) {
358 const commaIndex = attachment.dataUrl.indexOf(",");
359 const header =
360 commaIndex === -1 ? attachment.dataUrl : attachment.dataUrl.slice(0, commaIndex);
361 const payload = commaIndex === -1 ? "" : attachment.dataUrl.slice(commaIndex + 1);
362 if (payload.length === 0) {
361363 return null;
362364 }
363365 try {
......@@ -484,14 +486,20 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
484486 ...state.draftThreadsByThreadId,
485487 [threadId]: nextDraftThread,
486488 };
489 let nextDraftsByThreadId = state.draftsByThreadId;
487490 if (
488491 previousThreadIdForProject &&
489492 previousThreadIdForProject !== threadId &&
490493 !Object.values(nextProjectDraftThreadIdByProjectId).includes(previousThreadIdForProject)
491494 ) {
492495 delete nextDraftThreadsByThreadId[previousThreadIdForProject];
496 if (state.draftsByThreadId[previousThreadIdForProject] !== undefined) {
497 nextDraftsByThreadId = { ...state.draftsByThreadId };
498 delete nextDraftsByThreadId[previousThreadIdForProject];
499 }
493500 }
494501 return {
502 draftsByThreadId: nextDraftsByThreadId,
495503 draftThreadsByThreadId: nextDraftThreadsByThreadId,
496504 projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,
497505 };
......@@ -563,10 +571,16 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
563571 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
564572 ...state.draftThreadsByThreadId,
565573 };
574 let nextDraftsByThreadId = state.draftsByThreadId;
566575 if (!Object.values(restProjectMappings).includes(threadId)) {
567576 delete nextDraftThreadsByThreadId[threadId];
577 if (state.draftsByThreadId[threadId] !== undefined) {
578 nextDraftsByThreadId = { ...state.draftsByThreadId };
579 delete nextDraftsByThreadId[threadId];
580 }
568581 }
569582 return {
583 draftsByThreadId: nextDraftsByThreadId,
570584 draftThreadsByThreadId: nextDraftThreadsByThreadId,
571585 projectDraftThreadIdByProjectId: restProjectMappings,
572586 };
......@@ -586,10 +600,16 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
586600 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
587601 ...state.draftThreadsByThreadId,
588602 };
603 let nextDraftsByThreadId = state.draftsByThreadId;
589604 if (!Object.values(restProjectMappings).includes(threadId)) {
590605 delete nextDraftThreadsByThreadId[threadId];
606 if (state.draftsByThreadId[threadId] !== undefined) {
607 nextDraftsByThreadId = { ...state.draftsByThreadId };
608 delete nextDraftsByThreadId[threadId];
609 }
591610 }
592611 return {
612 draftsByThreadId: nextDraftsByThreadId,
593613 draftThreadsByThreadId: nextDraftThreadsByThreadId,
594614 projectDraftThreadIdByProjectId: restProjectMappings,
595615 };
apps/web/src/routes/_chat.$threadId.tsx+1-1
......@@ -141,7 +141,7 @@ function ChatThreadRouteView() {
141141 const search = Route.useSearch();
142142 const threadExists = state.threads.some((thread) => thread.id === threadId);
143143 const draftThreadExists = useComposerDraftStore(
144 (store) => store.draftThreadsByThreadId[threadId] !== undefined,
144 (store) => Object.hasOwn(store.draftThreadsByThreadId, threadId),
145145 );
146146 const routeThreadExists = threadExists || draftThreadExists;
147147 const diffOpen = search.diff === "1";