| author | |
| committer | |
| log | d9cce6402ae875b24d9ddbe73866eee94ec16ddb |
| tree | ce9c28f6d989e834b7e3f86f51f5562d13e33e6e |
| parent | 508872e04b87dc7cb53632988115b54b15f78e06 |
- 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 checks3 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", () => { |
| 156 | 156 | ||
| 157 | describe("composerDraftStore project draft thread mapping", () => { | 157 | describe("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"); |
| 161 | 162 | ||
| ... | @@ -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"); | ||
| 198 | 200 | ||
| 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 | }); |
| 208 | 211 | ||
| 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 | }); |
| 216 | 250 | ||
| 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[] { |
| 355 | function hydreatePersistedComposerImageAttachment( | 355 | function 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"; |