From d9cce6402ae875b24d9ddbe73866eee94ec16ddb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 1 Mar 2026 19:01:52 -0800 Subject: [PATCH] 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 --- apps/web/src/composerDraftStore.test.ts | 34 +++++++++++++++++++++++++ apps/web/src/composerDraftStore.ts | 26 ++++++++++++++++--- apps/web/src/routes/_chat.$threadId.tsx | 2 +- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 80ec5c39aac8101100382dc3535f701797a5b6da..ce88deb494d11b0d9054ce92b747cb3d0d21fd24 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -156,6 +156,7 @@ describe("composerDraftStore clearComposerContent", () => { describe("composerDraftStore project draft thread mapping", () => { const projectId = ProjectId.makeUnsafe("project-a"); + const otherProjectId = ProjectId.makeUnsafe("project-b"); const threadId = ThreadId.makeUnsafe("thread-a"); const otherThreadId = ThreadId.makeUnsafe("thread-b"); @@ -195,6 +196,7 @@ describe("composerDraftStore project draft thread mapping", () => { it("clears only matching project draft mapping entries", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId); + store.setPrompt(threadId, "hello"); store.clearProjectDraftThreadById(projectId, otherThreadId); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( @@ -204,14 +206,46 @@ describe("composerDraftStore project draft thread mapping", () => { store.clearProjectDraftThreadById(projectId, threadId); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); it("clears project draft mapping by project id", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId); + store.setPrompt(threadId, "hello"); store.clearProjectDraftThreadId(projectId); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + }); + + it("clears orphaned composer drafts when remapping a project to a new draft thread", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectId, threadId); + store.setPrompt(threadId, "orphan me"); + + store.setProjectDraftThreadId(projectId, otherThreadId); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( + otherThreadId, + ); + expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + }); + + it("keeps composer drafts when the thread is still mapped by another project", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectId, threadId); + store.setProjectDraftThreadId(otherProjectId, threadId); + store.setPrompt(threadId, "keep me"); + + store.clearProjectDraftThreadId(projectId); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId).toBe( + threadId, + ); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); }); it("clears draft registration independently", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 675aa7402b78665ac7e8addbaf67b8cb0fcee7f5..a5002f200c07398255d19e4d7c8609e6724257c5 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -355,9 +355,11 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { function hydreatePersistedComposerImageAttachment( attachment: PersistedComposerImageAttachment, ): File | null { - const [rawHeader, payload] = attachment.dataUrl.split(",", 2); - const header = rawHeader ?? ""; - if (!payload) { + const commaIndex = attachment.dataUrl.indexOf(","); + const header = + commaIndex === -1 ? attachment.dataUrl : attachment.dataUrl.slice(0, commaIndex); + const payload = commaIndex === -1 ? "" : attachment.dataUrl.slice(commaIndex + 1); + if (payload.length === 0) { return null; } try { @@ -484,14 +486,20 @@ export const useComposerDraftStore = create()( ...state.draftThreadsByThreadId, [threadId]: nextDraftThread, }; + let nextDraftsByThreadId = state.draftsByThreadId; if ( previousThreadIdForProject && previousThreadIdForProject !== threadId && !Object.values(nextProjectDraftThreadIdByProjectId).includes(previousThreadIdForProject) ) { delete nextDraftThreadsByThreadId[previousThreadIdForProject]; + if (state.draftsByThreadId[previousThreadIdForProject] !== undefined) { + nextDraftsByThreadId = { ...state.draftsByThreadId }; + delete nextDraftsByThreadId[previousThreadIdForProject]; + } } return { + draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId: nextDraftThreadsByThreadId, projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, }; @@ -563,10 +571,16 @@ export const useComposerDraftStore = create()( const nextDraftThreadsByThreadId: Record = { ...state.draftThreadsByThreadId, }; + let nextDraftsByThreadId = state.draftsByThreadId; if (!Object.values(restProjectMappings).includes(threadId)) { delete nextDraftThreadsByThreadId[threadId]; + if (state.draftsByThreadId[threadId] !== undefined) { + nextDraftsByThreadId = { ...state.draftsByThreadId }; + delete nextDraftsByThreadId[threadId]; + } } return { + draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId: nextDraftThreadsByThreadId, projectDraftThreadIdByProjectId: restProjectMappings, }; @@ -586,10 +600,16 @@ export const useComposerDraftStore = create()( const nextDraftThreadsByThreadId: Record = { ...state.draftThreadsByThreadId, }; + let nextDraftsByThreadId = state.draftsByThreadId; if (!Object.values(restProjectMappings).includes(threadId)) { delete nextDraftThreadsByThreadId[threadId]; + if (state.draftsByThreadId[threadId] !== undefined) { + nextDraftsByThreadId = { ...state.draftsByThreadId }; + delete nextDraftsByThreadId[threadId]; + } } return { + draftsByThreadId: nextDraftsByThreadId, draftThreadsByThreadId: nextDraftThreadsByThreadId, projectDraftThreadIdByProjectId: restProjectMappings, }; diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 03c5bb080496ffa33be8dff46adb50d68be59787..5c54673d748954b417a572e1c69153794ef316f1 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -141,7 +141,7 @@ function ChatThreadRouteView() { const search = Route.useSearch(); const threadExists = state.threads.some((thread) => thread.id === threadId); const draftThreadExists = useComposerDraftStore( - (store) => store.draftThreadsByThreadId[threadId] !== undefined, + (store) => Object.hasOwn(store.draftThreadsByThreadId, threadId), ); const routeThreadExists = threadExists || draftThreadExists; const diffOpen = search.diff === "1"; -- 2.54.0