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", () => {...@@ -156,6 +156,7 @@ describe("composerDraftStore clearComposerContent", () => {
156156
157describe("composerDraftStore project draft thread mapping", () => {157describe("composerDraftStore project draft thread mapping", () => {
158 const projectId = ProjectId.makeUnsafe("project-a");158 const projectId = ProjectId.makeUnsafe("project-a");
159 const otherProjectId = ProjectId.makeUnsafe("project-b");
159 const threadId = ThreadId.makeUnsafe("thread-a");160 const threadId = ThreadId.makeUnsafe("thread-a");
160 const otherThreadId = ThreadId.makeUnsafe("thread-b");161 const otherThreadId = ThreadId.makeUnsafe("thread-b");
161162
...@@ -195,6 +196,7 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -195,6 +196,7 @@ describe("composerDraftStore project draft thread mapping", () => {
195 it("clears only matching project draft mapping entries", () => {196 it("clears only matching project draft mapping entries", () => {
196 const store = useComposerDraftStore.getState();197 const store = useComposerDraftStore.getState();
197 store.setProjectDraftThreadId(projectId, threadId);198 store.setProjectDraftThreadId(projectId, threadId);
199 store.setPrompt(threadId, "hello");
198200
199 store.clearProjectDraftThreadById(projectId, otherThreadId);201 store.clearProjectDraftThreadById(projectId, otherThreadId);
200 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(202 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe(
...@@ -204,14 +206,46 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -204,14 +206,46 @@ describe("composerDraftStore project draft thread mapping", () => {
204 store.clearProjectDraftThreadById(projectId, threadId);206 store.clearProjectDraftThreadById(projectId, threadId);
205 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();207 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
206 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();208 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();
209 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
207 });210 });
208211
209 it("clears project draft mapping by project id", () => {212 it("clears project draft mapping by project id", () => {
210 const store = useComposerDraftStore.getState();213 const store = useComposerDraftStore.getState();
211 store.setProjectDraftThreadId(projectId, threadId);214 store.setProjectDraftThreadId(projectId, threadId);
215 store.setPrompt(threadId, "hello");
212 store.clearProjectDraftThreadId(projectId);216 store.clearProjectDraftThreadId(projectId);
213 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();217 expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull();
214 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull();218 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");
215 });249 });
216250
217 it("clears draft registration independently", () => {251 it("clears draft registration independently", () => {
apps/web/src/composerDraftStore.ts+23-3
...@@ -355,9 +355,11 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] {...@@ -355,9 +355,11 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] {
355function hydreatePersistedComposerImageAttachment(355function hydreatePersistedComposerImageAttachment(
356 attachment: PersistedComposerImageAttachment,356 attachment: PersistedComposerImageAttachment,
357): File | null {357): File | null {
358 const [rawHeader, payload] = attachment.dataUrl.split(",", 2);358 const commaIndex = attachment.dataUrl.indexOf(",");
359 const header = rawHeader ?? "";359 const header =
360 if (!payload) {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) {
361 return null;363 return null;
362 }364 }
363 try {365 try {
...@@ -484,14 +486,20 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -484,14 +486,20 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
484 ...state.draftThreadsByThreadId,486 ...state.draftThreadsByThreadId,
485 [threadId]: nextDraftThread,487 [threadId]: nextDraftThread,
486 };488 };
489 let nextDraftsByThreadId = state.draftsByThreadId;
487 if (490 if (
488 previousThreadIdForProject &&491 previousThreadIdForProject &&
489 previousThreadIdForProject !== threadId &&492 previousThreadIdForProject !== threadId &&
490 !Object.values(nextProjectDraftThreadIdByProjectId).includes(previousThreadIdForProject)493 !Object.values(nextProjectDraftThreadIdByProjectId).includes(previousThreadIdForProject)
491 ) {494 ) {
492 delete nextDraftThreadsByThreadId[previousThreadIdForProject];495 delete nextDraftThreadsByThreadId[previousThreadIdForProject];
496 if (state.draftsByThreadId[previousThreadIdForProject] !== undefined) {
497 nextDraftsByThreadId = { ...state.draftsByThreadId };
498 delete nextDraftsByThreadId[previousThreadIdForProject];
499 }
493 }500 }
494 return {501 return {
502 draftsByThreadId: nextDraftsByThreadId,
495 draftThreadsByThreadId: nextDraftThreadsByThreadId,503 draftThreadsByThreadId: nextDraftThreadsByThreadId,
496 projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,504 projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId,
497 };505 };
...@@ -563,10 +571,16 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -563,10 +571,16 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
563 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {571 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
564 ...state.draftThreadsByThreadId,572 ...state.draftThreadsByThreadId,
565 };573 };
574 let nextDraftsByThreadId = state.draftsByThreadId;
566 if (!Object.values(restProjectMappings).includes(threadId)) {575 if (!Object.values(restProjectMappings).includes(threadId)) {
567 delete nextDraftThreadsByThreadId[threadId];576 delete nextDraftThreadsByThreadId[threadId];
577 if (state.draftsByThreadId[threadId] !== undefined) {
578 nextDraftsByThreadId = { ...state.draftsByThreadId };
579 delete nextDraftsByThreadId[threadId];
580 }
568 }581 }
569 return {582 return {
583 draftsByThreadId: nextDraftsByThreadId,
570 draftThreadsByThreadId: nextDraftThreadsByThreadId,584 draftThreadsByThreadId: nextDraftThreadsByThreadId,
571 projectDraftThreadIdByProjectId: restProjectMappings,585 projectDraftThreadIdByProjectId: restProjectMappings,
572 };586 };
...@@ -586,10 +600,16 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -586,10 +600,16 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
586 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {600 const nextDraftThreadsByThreadId: Record<ThreadId, DraftThreadState> = {
587 ...state.draftThreadsByThreadId,601 ...state.draftThreadsByThreadId,
588 };602 };
603 let nextDraftsByThreadId = state.draftsByThreadId;
589 if (!Object.values(restProjectMappings).includes(threadId)) {604 if (!Object.values(restProjectMappings).includes(threadId)) {
590 delete nextDraftThreadsByThreadId[threadId];605 delete nextDraftThreadsByThreadId[threadId];
606 if (state.draftsByThreadId[threadId] !== undefined) {
607 nextDraftsByThreadId = { ...state.draftsByThreadId };
608 delete nextDraftsByThreadId[threadId];
609 }
591 }610 }
592 return {611 return {
612 draftsByThreadId: nextDraftsByThreadId,
593 draftThreadsByThreadId: nextDraftThreadsByThreadId,613 draftThreadsByThreadId: nextDraftThreadsByThreadId,
594 projectDraftThreadIdByProjectId: restProjectMappings,614 projectDraftThreadIdByProjectId: restProjectMappings,
595 };615 };
apps/web/src/routes/_chat.$threadId.tsx+1-1
...@@ -141,7 +141,7 @@ function ChatThreadRouteView() {...@@ -141,7 +141,7 @@ function ChatThreadRouteView() {
141 const search = Route.useSearch();141 const search = Route.useSearch();
142 const threadExists = state.threads.some((thread) => thread.id === threadId);142 const threadExists = state.threads.some((thread) => thread.id === threadId);
143 const draftThreadExists = useComposerDraftStore(143 const draftThreadExists = useComposerDraftStore(
144 (store) => store.draftThreadsByThreadId[threadId] !== undefined,144 (store) => Object.hasOwn(store.draftThreadsByThreadId, threadId),
145 );145 );
146 const routeThreadExists = threadExists || draftThreadExists;146 const routeThreadExists = threadExists || draftThreadExists;
147 const diffOpen = search.diff === "1";147 const diffOpen = search.diff === "1";