| ... | ... | @@ -75,6 +75,7 @@ import { |
| 75 | 75 | type ComposerFileAttachment, |
| 76 | 76 | type ComposerImageAttachment, |
| 77 | 77 | composerFileNeedsReattach, |
| 78 | partializeComposerDraftStoreState, |
| 78 | 79 | useComposerDraftStore, |
| 79 | 80 | DraftId, |
| 80 | 81 | } from "./composerDraftStore"; |
| ... | ... | @@ -84,7 +85,7 @@ import { |
| 84 | 85 | insertInlineTerminalContextPlaceholder, |
| 85 | 86 | type TerminalContextDraft, |
| 86 | 87 | } from "./lib/terminalContext"; |
| 87 | | import { createDebouncedStorage } from "./lib/storage"; |
| 88 | import { createDeferredStorage } from "./lib/storage"; |
| 88 | 89 | |
| 89 | 90 | function makeImage(input: { |
| 90 | 91 | id: string; |
| ... | ... | @@ -196,37 +197,42 @@ describe("composerDraftStore assistant citations", () => { |
| 196 | 197 | beforeEach(resetComposerDraftStore); |
| 197 | 198 | afterEach(resetComposerDraftStore); |
| 198 | 199 | |
| 199 | | it("keeps quotes, comments, and remote source IDs through persistence and removes them on clear", () => { |
| 200 | | const threadId = ThreadId.make("citation-draft"); |
| 201 | | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); |
| 202 | | const citation = { |
| 203 | | version: 1 as const, |
| 204 | | environmentId: EnvironmentId.make("remote-source"), |
| 205 | | threadId: ThreadId.make("source-thread"), |
| 206 | | messageId: MessageId.make("source-message"), |
| 207 | | text: "Preserve the selected text after reload.", |
| 208 | | comment: "Why is this important?\nPlease show an example.", |
| 209 | | start: 12, |
| 210 | | end: 52, |
| 211 | | prefix: "Before. ", |
| 212 | | suffix: " After.", |
| 213 | | }; |
| 214 | | const prompt = `Explain ${serializeAssistantCitation(citation)} further.`; |
| 215 | | useComposerDraftStore.getState().setPrompt(threadRef, prompt); |
| 216 | | const options = useComposerDraftStore.persist.getOptions(); |
| 217 | | const saved = JSON.parse( |
| 218 | | JSON.stringify(options.partialize!(useComposerDraftStore.getState())), |
| 219 | | ) as unknown; |
| 220 | | resetComposerDraftStore(); |
| 221 | | const hydrated = options.merge!(saved, useComposerDraftStore.getState()); |
| 222 | | useComposerDraftStore.setState(hydrated); |
| 223 | | const restored = draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? ""; |
| 224 | | expect(restored).toBe(prompt); |
| 225 | | expect(collectAssistantCitations(restored).map((entry) => entry.citation)).toEqual([citation]); |
| 226 | | useComposerDraftStore.getState().clearComposerContent(threadRef); |
| 227 | | expect( |
| 228 | | collectAssistantCitations(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? ""), |
| 229 | | ).toEqual([]); |
| 200 | it("keeps quotes, comments, and remote source IDs through persistence and removes them on clear", async () => { |
| 201 | await useComposerDraftStore.persist.clearStorage(); |
| 202 | vi.useFakeTimers(); |
| 203 | try { |
| 204 | const threadId = ThreadId.make("citation-draft"); |
| 205 | const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); |
| 206 | const citation = { |
| 207 | version: 1 as const, |
| 208 | environmentId: EnvironmentId.make("remote-source"), |
| 209 | threadId: ThreadId.make("source-thread"), |
| 210 | messageId: MessageId.make("source-message"), |
| 211 | text: "Preserve the selected text after reload.", |
| 212 | comment: "Why is this important?\nPlease show an example.", |
| 213 | start: 12, |
| 214 | end: 52, |
| 215 | prefix: "Before. ", |
| 216 | suffix: " After.", |
| 217 | }; |
| 218 | const prompt = `Explain ${serializeAssistantCitation(citation)} further.`; |
| 219 | useComposerDraftStore.getState().setPrompt(threadRef, prompt); |
| 220 | await vi.advanceTimersByTimeAsync(300); |
| 221 | resetComposerDraftStore(); |
| 222 | await useComposerDraftStore.persist.rehydrate(); |
| 223 | const restored = draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? ""; |
| 224 | expect(restored).toBe(prompt); |
| 225 | expect(collectAssistantCitations(restored).map((entry) => entry.citation)).toEqual([ |
| 226 | citation, |
| 227 | ]); |
| 228 | useComposerDraftStore.getState().clearComposerContent(threadRef); |
| 229 | expect( |
| 230 | collectAssistantCitations(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? ""), |
| 231 | ).toEqual([]); |
| 232 | } finally { |
| 233 | await useComposerDraftStore.persist.clearStorage(); |
| 234 | vi.useRealTimers(); |
| 235 | } |
| 230 | 236 | }); |
| 231 | 237 | }); |
| 232 | 238 | |
| ... | ... | @@ -388,7 +394,6 @@ describe("composerDraftStore file attachments", () => { |
| 388 | 394 | |
| 389 | 395 | const persistApi = useComposerDraftStore.persist as unknown as { |
| 390 | 396 | getOptions: () => { |
| 391 | | partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown; |
| 392 | 397 | merge: ( |
| 393 | 398 | persistedState: unknown, |
| 394 | 399 | currentState: ReturnType<typeof useComposerDraftStore.getState>, |
| ... | ... | @@ -396,9 +401,7 @@ describe("composerDraftStore file attachments", () => { |
| 396 | 401 | }; |
| 397 | 402 | }; |
| 398 | 403 | const options = persistApi.getOptions(); |
| 399 | | const persisted = options.partialize(useComposerDraftStore.getState()) as { |
| 400 | | draftsByThreadKey: Record<string, { files?: Array<Record<string, unknown>> }>; |
| 401 | | }; |
| 404 | const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState()); |
| 402 | 405 | |
| 403 | 406 | expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual( |
| 404 | 407 | [ |
| ... | ... | @@ -436,7 +439,6 @@ describe("composerDraftStore file attachments", () => { |
| 436 | 439 | |
| 437 | 440 | const persistApi = useComposerDraftStore.persist as unknown as { |
| 438 | 441 | getOptions: () => { |
| 439 | | partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown; |
| 440 | 442 | merge: ( |
| 441 | 443 | persistedState: unknown, |
| 442 | 444 | currentState: ReturnType<typeof useComposerDraftStore.getState>, |
| ... | ... | @@ -444,9 +446,7 @@ describe("composerDraftStore file attachments", () => { |
| 444 | 446 | }; |
| 445 | 447 | }; |
| 446 | 448 | const options = persistApi.getOptions(); |
| 447 | | const persisted = options.partialize(useComposerDraftStore.getState()) as { |
| 448 | | draftsByThreadKey: Record<string, { files?: Array<Record<string, unknown>> }>; |
| 449 | | }; |
| 449 | const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState()); |
| 450 | 450 | |
| 451 | 451 | expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual( |
| 452 | 452 | [ |
| ... | ... | @@ -835,12 +835,9 @@ describe("composerDraftStore terminal contexts", () => { |
| 835 | 835 | .getState() |
| 836 | 836 | .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-persist" })); |
| 837 | 837 | |
| 838 | | const persistApi = useComposerDraftStore.persist as unknown as { |
| 839 | | getOptions: () => { |
| 840 | | partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown; |
| 841 | | }; |
| 842 | | }; |
| 843 | | const persistedState = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { |
| 838 | const persistedState = partializeComposerDraftStoreState( |
| 839 | useComposerDraftStore.getState(), |
| 840 | ) as unknown as { |
| 844 | 841 | draftsByThreadKey?: Record<string, { terminalContexts?: Array<Record<string, unknown>> }>; |
| 845 | 842 | }; |
| 846 | 843 | |
| ... | ... | @@ -1009,12 +1006,9 @@ describe("composerDraftStore element contexts", () => { |
| 1009 | 1006 | |
| 1010 | 1007 | it("persists element contexts via the partializer (round-trippable)", () => { |
| 1011 | 1008 | useComposerDraftStore.getState().addElementContext(threadRef, baseSelection); |
| 1012 | | const persistApi = useComposerDraftStore.persist as unknown as { |
| 1013 | | getOptions: () => { |
| 1014 | | partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown; |
| 1015 | | }; |
| 1016 | | }; |
| 1017 | | const persisted = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { |
| 1009 | const persisted = partializeComposerDraftStoreState( |
| 1010 | useComposerDraftStore.getState(), |
| 1011 | ) as unknown as { |
| 1018 | 1012 | draftsByThreadKey?: Record<string, { elementContexts?: Array<Record<string, unknown>> }>; |
| 1019 | 1013 | }; |
| 1020 | 1014 | const entry = |
| ... | ... | @@ -1067,12 +1061,9 @@ describe("composerDraftStore review comments", () => { |
| 1067 | 1061 | it("persists review comments and clears them with composer content", () => { |
| 1068 | 1062 | const store = useComposerDraftStore.getState(); |
| 1069 | 1063 | store.addReviewComment(threadRef, comment); |
| 1070 | | const persistApi = useComposerDraftStore.persist as unknown as { |
| 1071 | | getOptions: () => { |
| 1072 | | partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown; |
| 1073 | | }; |
| 1074 | | }; |
| 1075 | | const persisted = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { |
| 1064 | const persisted = partializeComposerDraftStoreState( |
| 1065 | useComposerDraftStore.getState(), |
| 1066 | ) as unknown as { |
| 1076 | 1067 | draftsByThreadKey?: Record<string, { reviewComments?: Array<Record<string, unknown>> }>; |
| 1077 | 1068 | }; |
| 1078 | 1069 | |
| ... | ... | @@ -2517,7 +2508,7 @@ describe("composerDraftStore runtime and interaction settings", () => { |
| 2517 | 2508 | }); |
| 2518 | 2509 | |
| 2519 | 2510 | // --------------------------------------------------------------------------- |
| 2520 | | // createDebouncedStorage |
| 2511 | // createDeferredStorage |
| 2521 | 2512 | // --------------------------------------------------------------------------- |
| 2522 | 2513 | |
| 2523 | 2514 | function createMockStorage() { |
| ... | ... | @@ -2533,9 +2524,75 @@ function createMockStorage() { |
| 2533 | 2524 | }; |
| 2534 | 2525 | } |
| 2535 | 2526 | |
| 2536 | | describe("createDebouncedStorage", () => { |
| 2527 | describe("composer draft persistence", () => { |
| 2528 | it("defers attachment reads and serialization until typing stops, then restores the last draft", async () => { |
| 2529 | await useComposerDraftStore.persist.clearStorage(); |
| 2530 | vi.useFakeTimers(); |
| 2531 | const stringify = vi.spyOn(JSON, "stringify"); |
| 2532 | try { |
| 2533 | resetComposerDraftStore(); |
| 2534 | const heavyThreadId = ThreadId.make("heavy-draft"); |
| 2535 | const typingThreadId = ThreadId.make("typing-draft"); |
| 2536 | const heavyRef = scopeThreadRef(TEST_ENVIRONMENT_ID, heavyThreadId); |
| 2537 | const typingRef = scopeThreadRef(TEST_ENVIRONMENT_ID, typingThreadId); |
| 2538 | const heavyKey = scopedThreadKey(heavyRef); |
| 2539 | useComposerDraftStore.getState().setPrompt(heavyRef, "Keep this image"); |
| 2540 | const attachments = [ |
| 2541 | { |
| 2542 | id: "heavy-image", |
| 2543 | name: "image.png", |
| 2544 | mimeType: "image/png", |
| 2545 | sizeBytes: 49_152, |
| 2546 | dataUrl: `data:image/png;base64,${"AQID".repeat(16_384)}`, |
| 2547 | }, |
| 2548 | ]; |
| 2549 | let attachmentReads = 0; |
| 2550 | useComposerDraftStore.setState((state) => ({ |
| 2551 | draftsByThreadKey: { |
| 2552 | ...state.draftsByThreadKey, |
| 2553 | [heavyKey]: { |
| 2554 | ...state.draftsByThreadKey[heavyKey]!, |
| 2555 | get persistedAttachments() { |
| 2556 | attachmentReads += 1; |
| 2557 | return attachments; |
| 2558 | }, |
| 2559 | }, |
| 2560 | }, |
| 2561 | })); |
| 2562 | attachmentReads = 0; |
| 2563 | stringify.mockClear(); |
| 2564 | |
| 2565 | for (let index = 1; index <= 20; index++) { |
| 2566 | useComposerDraftStore.getState().setPrompt(typingRef, `Draft ${index}`); |
| 2567 | } |
| 2568 | expect(attachmentReads).toBe(0); |
| 2569 | expect(stringify).not.toHaveBeenCalled(); |
| 2570 | |
| 2571 | await vi.advanceTimersByTimeAsync(300); |
| 2572 | expect(attachmentReads).toBe(1); |
| 2573 | expect(stringify).toHaveBeenCalledTimes(1); |
| 2574 | |
| 2575 | resetComposerDraftStore(); |
| 2576 | await useComposerDraftStore.persist.rehydrate(); |
| 2577 | expect(draftFor(typingThreadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("Draft 20"); |
| 2578 | expect(draftFor(heavyThreadId, TEST_ENVIRONMENT_ID)?.persistedAttachments).toEqual( |
| 2579 | attachments, |
| 2580 | ); |
| 2581 | } finally { |
| 2582 | stringify.mockRestore(); |
| 2583 | await useComposerDraftStore.persist.clearStorage(); |
| 2584 | vi.useRealTimers(); |
| 2585 | resetComposerDraftStore(); |
| 2586 | } |
| 2587 | }); |
| 2588 | }); |
| 2589 | |
| 2590 | describe("createDeferredStorage", () => { |
| 2591 | const serialize = vi.fn((value: string) => `s:${value}`); |
| 2592 | |
| 2537 | 2593 | beforeEach(() => { |
| 2538 | 2594 | vi.useFakeTimers(); |
| 2595 | serialize.mockClear(); |
| 2539 | 2596 | }); |
| 2540 | 2597 | |
| 2541 | 2598 | afterEach(() => { |
| ... | ... | @@ -2545,69 +2602,74 @@ describe("createDebouncedStorage", () => { |
| 2545 | 2602 | it("delegates getItem immediately", () => { |
| 2546 | 2603 | const base = createMockStorage(); |
| 2547 | 2604 | base.getItem.mockReturnValueOnce("value"); |
| 2548 | | const storage = createDebouncedStorage(base); |
| 2605 | const storage = createDeferredStorage(base, serialize); |
| 2549 | 2606 | |
| 2550 | 2607 | expect(storage.getItem("key")).toBe("value"); |
| 2551 | 2608 | expect(base.getItem).toHaveBeenCalledWith("key"); |
| 2552 | 2609 | }); |
| 2553 | 2610 | |
| 2554 | | it("does not write to base storage until the debounce fires", () => { |
| 2611 | it("neither serializes nor writes until the debounce fires", () => { |
| 2555 | 2612 | const base = createMockStorage(); |
| 2556 | | const storage = createDebouncedStorage(base); |
| 2613 | const storage = createDeferredStorage(base, serialize); |
| 2557 | 2614 | |
| 2558 | 2615 | storage.setItem("key", "v1"); |
| 2616 | expect(serialize).not.toHaveBeenCalled(); |
| 2559 | 2617 | expect(base.setItem).not.toHaveBeenCalled(); |
| 2560 | 2618 | |
| 2561 | 2619 | vi.advanceTimersByTime(299); |
| 2620 | expect(serialize).not.toHaveBeenCalled(); |
| 2562 | 2621 | expect(base.setItem).not.toHaveBeenCalled(); |
| 2563 | 2622 | |
| 2564 | 2623 | vi.advanceTimersByTime(1); |
| 2565 | | expect(base.setItem).toHaveBeenCalledWith("key", "v1"); |
| 2624 | expect(base.setItem).toHaveBeenCalledWith("key", "s:v1"); |
| 2566 | 2625 | }); |
| 2567 | 2626 | |
| 2568 | | it("only writes the last value when setItem is called rapidly", () => { |
| 2627 | it("serializes and writes only the last value when setItem is called rapidly", () => { |
| 2569 | 2628 | const base = createMockStorage(); |
| 2570 | | const storage = createDebouncedStorage(base); |
| 2629 | const storage = createDeferredStorage(base, serialize); |
| 2571 | 2630 | |
| 2572 | 2631 | storage.setItem("key", "v1"); |
| 2573 | 2632 | storage.setItem("key", "v2"); |
| 2574 | 2633 | storage.setItem("key", "v3"); |
| 2575 | 2634 | |
| 2576 | 2635 | vi.advanceTimersByTime(300); |
| 2636 | expect(serialize).toHaveBeenCalledTimes(1); |
| 2577 | 2637 | expect(base.setItem).toHaveBeenCalledTimes(1); |
| 2578 | | expect(base.setItem).toHaveBeenCalledWith("key", "v3"); |
| 2638 | expect(base.setItem).toHaveBeenCalledWith("key", "s:v3"); |
| 2579 | 2639 | }); |
| 2580 | 2640 | |
| 2581 | 2641 | it("removeItem cancels a pending setItem write", () => { |
| 2582 | 2642 | const base = createMockStorage(); |
| 2583 | | const storage = createDebouncedStorage(base); |
| 2643 | const storage = createDeferredStorage(base, serialize); |
| 2584 | 2644 | |
| 2585 | 2645 | storage.setItem("key", "v1"); |
| 2586 | 2646 | storage.removeItem("key"); |
| 2587 | 2647 | |
| 2588 | 2648 | vi.advanceTimersByTime(300); |
| 2649 | expect(serialize).not.toHaveBeenCalled(); |
| 2589 | 2650 | expect(base.setItem).not.toHaveBeenCalled(); |
| 2590 | 2651 | expect(base.removeItem).toHaveBeenCalledWith("key"); |
| 2591 | 2652 | }); |
| 2592 | 2653 | |
| 2593 | | it("flush writes the pending value immediately", () => { |
| 2654 | it("flush serializes and writes the pending value immediately", () => { |
| 2594 | 2655 | const base = createMockStorage(); |
| 2595 | | const storage = createDebouncedStorage(base); |
| 2656 | const storage = createDeferredStorage(base, serialize); |
| 2596 | 2657 | |
| 2597 | 2658 | storage.setItem("key", "v1"); |
| 2598 | 2659 | expect(base.setItem).not.toHaveBeenCalled(); |
| 2599 | 2660 | |
| 2600 | 2661 | storage.flush(); |
| 2601 | | expect(base.setItem).toHaveBeenCalledWith("key", "v1"); |
| 2662 | expect(base.setItem).toHaveBeenCalledWith("key", "s:v1"); |
| 2602 | 2663 | |
| 2603 | 2664 | // Timer should be cancelled; no duplicate write. |
| 2604 | 2665 | vi.advanceTimersByTime(300); |
| 2666 | expect(serialize).toHaveBeenCalledTimes(1); |
| 2605 | 2667 | expect(base.setItem).toHaveBeenCalledTimes(1); |
| 2606 | 2668 | }); |
| 2607 | 2669 | |
| 2608 | 2670 | it("flush is a no-op when nothing is pending", () => { |
| 2609 | 2671 | const base = createMockStorage(); |
| 2610 | | const storage = createDebouncedStorage(base); |
| 2672 | const storage = createDeferredStorage(base, serialize); |
| 2611 | 2673 | |
| 2612 | 2674 | storage.flush(); |
| 2613 | 2675 | expect(base.setItem).not.toHaveBeenCalled(); |
| ... | ... | @@ -2615,7 +2677,7 @@ describe("createDebouncedStorage", () => { |
| 2615 | 2677 | |
| 2616 | 2678 | it("flush after removeItem is a no-op", () => { |
| 2617 | 2679 | const base = createMockStorage(); |
| 2618 | | const storage = createDebouncedStorage(base); |
| 2680 | const storage = createDeferredStorage(base, serialize); |
| 2619 | 2681 | |
| 2620 | 2682 | storage.setItem("key", "v1"); |
| 2621 | 2683 | storage.removeItem("key"); |
| ... | ... | @@ -2626,7 +2688,7 @@ describe("createDebouncedStorage", () => { |
| 2626 | 2688 | |
| 2627 | 2689 | it("setItem works normally after removeItem cancels a pending write", () => { |
| 2628 | 2690 | const base = createMockStorage(); |
| 2629 | | const storage = createDebouncedStorage(base); |
| 2691 | const storage = createDeferredStorage(base, serialize); |
| 2630 | 2692 | |
| 2631 | 2693 | storage.setItem("key", "v1"); |
| 2632 | 2694 | storage.removeItem("key"); |
| ... | ... | @@ -2634,6 +2696,6 @@ describe("createDebouncedStorage", () => { |
| 2634 | 2696 | |
| 2635 | 2697 | vi.advanceTimersByTime(300); |
| 2636 | 2698 | expect(base.setItem).toHaveBeenCalledTimes(1); |
| 2637 | | expect(base.setItem).toHaveBeenCalledWith("key", "v2"); |
| 2699 | expect(base.setItem).toHaveBeenCalledWith("key", "s:v2"); |
| 2638 | 2700 | }); |
| 2639 | 2701 | }); |