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