From f2e2e776b5b32ffee57868e95f9307771a69afbe Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 07:50:15 -0700 Subject: [PATCH 1/2] perf(web): defer composer draft serialization (#9695) Defer the draft walk and JSON serialization until the storage write flushes. Preserve hydration, migrations, attachment verification, and final flushes. Continues the web portion of [#9049](https://github.com/pingdotgg/t3code/pull/9049). The mobile storage migration remains separate. Created with GPT-6 Astra (preview) in Codex. Co-authored-by: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Co-authored-by: Claude Fable 5 (cherry picked from commit dab5f6e6e02e78675655e69503aa89654e5b8050) --- apps/web/src/composerDraftStore.test.ts | 212 +++++++++++++++--------- apps/web/src/composerDraftStore.ts | 42 ++++- apps/web/src/lib/storage.ts | 17 +- 3 files changed, 185 insertions(+), 86 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 88f95718c..d378a048d 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -74,6 +74,7 @@ import { type ComposerFileAttachment, type ComposerImageAttachment, composerFileNeedsReattach, + partializeComposerDraftStoreState, useComposerDraftStore, DraftId, } from "./composerDraftStore"; @@ -83,7 +84,7 @@ import { insertInlineTerminalContextPlaceholder, type TerminalContextDraft, } from "./lib/terminalContext"; -import { createDebouncedStorage } from "./lib/storage"; +import { createDeferredStorage } from "./lib/storage"; function makeImage(input: { id: string; @@ -195,37 +196,42 @@ describe("composerDraftStore assistant citations", () => { beforeEach(resetComposerDraftStore); afterEach(resetComposerDraftStore); - it("keeps quotes, comments, and remote source IDs through persistence and removes them on clear", () => { - const threadId = ThreadId.make("citation-draft"); - const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); - const citation = { - version: 1 as const, - environmentId: EnvironmentId.make("remote-source"), - threadId: ThreadId.make("source-thread"), - messageId: MessageId.make("source-message"), - text: "Preserve the selected text after reload.", - comment: "Why is this important?\nPlease show an example.", - start: 12, - end: 52, - prefix: "Before. ", - suffix: " After.", - }; - const prompt = `Explain ${serializeAssistantCitation(citation)} further.`; - useComposerDraftStore.getState().setPrompt(threadRef, prompt); - const options = useComposerDraftStore.persist.getOptions(); - const saved = JSON.parse( - JSON.stringify(options.partialize!(useComposerDraftStore.getState())), - ) as unknown; - resetComposerDraftStore(); - const hydrated = options.merge!(saved, useComposerDraftStore.getState()); - useComposerDraftStore.setState(hydrated); - const restored = draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? ""; - expect(restored).toBe(prompt); - expect(collectAssistantCitations(restored).map((entry) => entry.citation)).toEqual([citation]); - useComposerDraftStore.getState().clearComposerContent(threadRef); - expect( - collectAssistantCitations(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? ""), - ).toEqual([]); + it("keeps quotes, comments, and remote source IDs through persistence and removes them on clear", async () => { + await useComposerDraftStore.persist.clearStorage(); + vi.useFakeTimers(); + try { + const threadId = ThreadId.make("citation-draft"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const citation = { + version: 1 as const, + environmentId: EnvironmentId.make("remote-source"), + threadId: ThreadId.make("source-thread"), + messageId: MessageId.make("source-message"), + text: "Preserve the selected text after reload.", + comment: "Why is this important?\nPlease show an example.", + start: 12, + end: 52, + prefix: "Before. ", + suffix: " After.", + }; + const prompt = `Explain ${serializeAssistantCitation(citation)} further.`; + useComposerDraftStore.getState().setPrompt(threadRef, prompt); + await vi.advanceTimersByTimeAsync(300); + resetComposerDraftStore(); + await useComposerDraftStore.persist.rehydrate(); + const restored = draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? ""; + expect(restored).toBe(prompt); + expect(collectAssistantCitations(restored).map((entry) => entry.citation)).toEqual([ + citation, + ]); + useComposerDraftStore.getState().clearComposerContent(threadRef); + expect( + collectAssistantCitations(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt ?? ""), + ).toEqual([]); + } finally { + await useComposerDraftStore.persist.clearStorage(); + vi.useRealTimers(); + } }); }); @@ -362,7 +368,6 @@ describe("composerDraftStore file attachments", () => { const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { - partialize: (state: ReturnType) => unknown; merge: ( persistedState: unknown, currentState: ReturnType, @@ -370,9 +375,7 @@ describe("composerDraftStore file attachments", () => { }; }; const options = persistApi.getOptions(); - const persisted = options.partialize(useComposerDraftStore.getState()) as { - draftsByThreadKey: Record> }>; - }; + const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState()); expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual( [ @@ -410,7 +413,6 @@ describe("composerDraftStore file attachments", () => { const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { - partialize: (state: ReturnType) => unknown; merge: ( persistedState: unknown, currentState: ReturnType, @@ -418,9 +420,7 @@ describe("composerDraftStore file attachments", () => { }; }; const options = persistApi.getOptions(); - const persisted = options.partialize(useComposerDraftStore.getState()) as { - draftsByThreadKey: Record> }>; - }; + const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState()); expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual( [ @@ -809,12 +809,9 @@ describe("composerDraftStore terminal contexts", () => { .getState() .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-persist" })); - const persistApi = useComposerDraftStore.persist as unknown as { - getOptions: () => { - partialize: (state: ReturnType) => unknown; - }; - }; - const persistedState = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { + const persistedState = partializeComposerDraftStoreState( + useComposerDraftStore.getState(), + ) as unknown as { draftsByThreadKey?: Record> }>; }; @@ -983,12 +980,9 @@ describe("composerDraftStore element contexts", () => { it("persists element contexts via the partializer (round-trippable)", () => { useComposerDraftStore.getState().addElementContext(threadRef, baseSelection); - const persistApi = useComposerDraftStore.persist as unknown as { - getOptions: () => { - partialize: (state: ReturnType) => unknown; - }; - }; - const persisted = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { + const persisted = partializeComposerDraftStoreState( + useComposerDraftStore.getState(), + ) as unknown as { draftsByThreadKey?: Record> }>; }; const entry = @@ -1041,12 +1035,9 @@ describe("composerDraftStore review comments", () => { it("persists review comments and clears them with composer content", () => { const store = useComposerDraftStore.getState(); store.addReviewComment(threadRef, comment); - const persistApi = useComposerDraftStore.persist as unknown as { - getOptions: () => { - partialize: (state: ReturnType) => unknown; - }; - }; - const persisted = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { + const persisted = partializeComposerDraftStoreState( + useComposerDraftStore.getState(), + ) as unknown as { draftsByThreadKey?: Record> }>; }; @@ -2757,7 +2748,7 @@ describe("composerDraftStore runtime and interaction settings", () => { }); // --------------------------------------------------------------------------- -// createDebouncedStorage +// createDeferredStorage // --------------------------------------------------------------------------- function createMockStorage() { @@ -2773,9 +2764,75 @@ function createMockStorage() { }; } -describe("createDebouncedStorage", () => { +describe("composer draft persistence", () => { + it("defers attachment reads and serialization until typing stops, then restores the last draft", async () => { + await useComposerDraftStore.persist.clearStorage(); + vi.useFakeTimers(); + const stringify = vi.spyOn(JSON, "stringify"); + try { + resetComposerDraftStore(); + const heavyThreadId = ThreadId.make("heavy-draft"); + const typingThreadId = ThreadId.make("typing-draft"); + const heavyRef = scopeThreadRef(TEST_ENVIRONMENT_ID, heavyThreadId); + const typingRef = scopeThreadRef(TEST_ENVIRONMENT_ID, typingThreadId); + const heavyKey = scopedThreadKey(heavyRef); + useComposerDraftStore.getState().setPrompt(heavyRef, "Keep this image"); + const attachments = [ + { + id: "heavy-image", + name: "image.png", + mimeType: "image/png", + sizeBytes: 49_152, + dataUrl: `data:image/png;base64,${"AQID".repeat(16_384)}`, + }, + ]; + let attachmentReads = 0; + useComposerDraftStore.setState((state) => ({ + draftsByThreadKey: { + ...state.draftsByThreadKey, + [heavyKey]: { + ...state.draftsByThreadKey[heavyKey]!, + get persistedAttachments() { + attachmentReads += 1; + return attachments; + }, + }, + }, + })); + attachmentReads = 0; + stringify.mockClear(); + + for (let index = 1; index <= 20; index++) { + useComposerDraftStore.getState().setPrompt(typingRef, `Draft ${index}`); + } + expect(attachmentReads).toBe(0); + expect(stringify).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(300); + expect(attachmentReads).toBe(1); + expect(stringify).toHaveBeenCalledTimes(1); + + resetComposerDraftStore(); + await useComposerDraftStore.persist.rehydrate(); + expect(draftFor(typingThreadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("Draft 20"); + expect(draftFor(heavyThreadId, TEST_ENVIRONMENT_ID)?.persistedAttachments).toEqual( + attachments, + ); + } finally { + stringify.mockRestore(); + await useComposerDraftStore.persist.clearStorage(); + vi.useRealTimers(); + resetComposerDraftStore(); + } + }); +}); + +describe("createDeferredStorage", () => { + const serialize = vi.fn((value: string) => `s:${value}`); + beforeEach(() => { vi.useFakeTimers(); + serialize.mockClear(); }); afterEach(() => { @@ -2785,69 +2842,74 @@ describe("createDebouncedStorage", () => { it("delegates getItem immediately", () => { const base = createMockStorage(); base.getItem.mockReturnValueOnce("value"); - const storage = createDebouncedStorage(base); + const storage = createDeferredStorage(base, serialize); expect(storage.getItem("key")).toBe("value"); expect(base.getItem).toHaveBeenCalledWith("key"); }); - it("does not write to base storage until the debounce fires", () => { + it("neither serializes nor writes until the debounce fires", () => { const base = createMockStorage(); - const storage = createDebouncedStorage(base); + const storage = createDeferredStorage(base, serialize); storage.setItem("key", "v1"); + expect(serialize).not.toHaveBeenCalled(); expect(base.setItem).not.toHaveBeenCalled(); vi.advanceTimersByTime(299); + expect(serialize).not.toHaveBeenCalled(); expect(base.setItem).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); - expect(base.setItem).toHaveBeenCalledWith("key", "v1"); + expect(base.setItem).toHaveBeenCalledWith("key", "s:v1"); }); - it("only writes the last value when setItem is called rapidly", () => { + it("serializes and writes only the last value when setItem is called rapidly", () => { const base = createMockStorage(); - const storage = createDebouncedStorage(base); + const storage = createDeferredStorage(base, serialize); storage.setItem("key", "v1"); storage.setItem("key", "v2"); storage.setItem("key", "v3"); vi.advanceTimersByTime(300); + expect(serialize).toHaveBeenCalledTimes(1); expect(base.setItem).toHaveBeenCalledTimes(1); - expect(base.setItem).toHaveBeenCalledWith("key", "v3"); + expect(base.setItem).toHaveBeenCalledWith("key", "s:v3"); }); it("removeItem cancels a pending setItem write", () => { const base = createMockStorage(); - const storage = createDebouncedStorage(base); + const storage = createDeferredStorage(base, serialize); storage.setItem("key", "v1"); storage.removeItem("key"); vi.advanceTimersByTime(300); + expect(serialize).not.toHaveBeenCalled(); expect(base.setItem).not.toHaveBeenCalled(); expect(base.removeItem).toHaveBeenCalledWith("key"); }); - it("flush writes the pending value immediately", () => { + it("flush serializes and writes the pending value immediately", () => { const base = createMockStorage(); - const storage = createDebouncedStorage(base); + const storage = createDeferredStorage(base, serialize); storage.setItem("key", "v1"); expect(base.setItem).not.toHaveBeenCalled(); storage.flush(); - expect(base.setItem).toHaveBeenCalledWith("key", "v1"); + expect(base.setItem).toHaveBeenCalledWith("key", "s:v1"); // Timer should be cancelled; no duplicate write. vi.advanceTimersByTime(300); + expect(serialize).toHaveBeenCalledTimes(1); expect(base.setItem).toHaveBeenCalledTimes(1); }); it("flush is a no-op when nothing is pending", () => { const base = createMockStorage(); - const storage = createDebouncedStorage(base); + const storage = createDeferredStorage(base, serialize); storage.flush(); expect(base.setItem).not.toHaveBeenCalled(); @@ -2855,7 +2917,7 @@ describe("createDebouncedStorage", () => { it("flush after removeItem is a no-op", () => { const base = createMockStorage(); - const storage = createDebouncedStorage(base); + const storage = createDeferredStorage(base, serialize); storage.setItem("key", "v1"); storage.removeItem("key"); @@ -2866,7 +2928,7 @@ describe("createDebouncedStorage", () => { it("setItem works normally after removeItem cancels a pending write", () => { const base = createMockStorage(); - const storage = createDebouncedStorage(base); + const storage = createDeferredStorage(base, serialize); storage.setItem("key", "v1"); storage.removeItem("key"); @@ -2874,6 +2936,6 @@ describe("createDebouncedStorage", () => { vi.advanceTimersByTime(300); expect(base.setItem).toHaveBeenCalledTimes(1); - expect(base.setItem).toHaveBeenCalledWith("key", "v2"); + expect(base.setItem).toHaveBeenCalledWith("key", "s:v2"); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index b75980d4c..745531963 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -53,9 +53,9 @@ import { newElementContextId, } from "./lib/elementContext"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist, type PersistStorage, type StorageValue } from "zustand/middleware"; import { useShallow } from "zustand/react/shallow"; -import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; +import { createDeferredStorage, createMemoryStorage } from "./lib/storage"; import { getDefaultServerModel } from "./providerModels"; import { UnifiedSettings } from "@t3tools/contracts/settings"; import { ReviewCommentContextSchema, type ReviewCommentContext } from "./reviewCommentContext"; @@ -73,11 +73,39 @@ export type DraftId = typeof DraftId.Type; const COMPOSER_PERSIST_DEBOUNCE_MS = 300; -const composerDebouncedStorage = createDebouncedStorage( +// Keep the immutable state until flush. Migration writebacks already have the persisted shape. +type ComposerPersistState = + | { capturedState: ComposerDraftStoreState } + | PersistedComposerDraftStoreState; + +const composerDebouncedStorage = createDeferredStorage>( typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(), + (value) => + JSON.stringify({ + state: + "capturedState" in value.state + ? partializeComposerDraftStoreState(value.state.capturedState) + : value.state, + version: value.version, + }), COMPOSER_PERSIST_DEBOUNCE_MS, ); +const composerPersistStorage: PersistStorage = { + getItem: (name) => { + // The base storage is localStorage (or in-memory), which is synchronous. + const raw = composerDebouncedStorage.getItem(name); + if (typeof raw !== "string") { + return null; + } + // Parsed persisted JSON. `migrate` and `merge` normalize it from unknown, + // so the cast mirrors the one zustand's createJSONStorage performs. + return JSON.parse(raw) as StorageValue; + }, + setItem: (name, value) => composerDebouncedStorage.setItem(name, value), + removeItem: (name) => composerDebouncedStorage.removeItem(name), +}; + // Flush pending composer draft writes before page unload to prevent data loss. if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener("beforeunload", () => { @@ -2090,7 +2118,8 @@ function migratePersistedComposerDraftStoreState( }; } -function partializeComposerDraftStoreState( +/** Select the persisted draft fields when the storage write is ready to flush. */ +export function partializeComposerDraftStoreState( state: ComposerDraftStoreState, ): PersistedComposerDraftStoreState { // Draft sessions worth persisting: mapped (a new-thread flow targets @@ -4142,9 +4171,10 @@ const composerDraftStore = create()( { name: COMPOSER_DRAFT_STORAGE_KEY, version: COMPOSER_DRAFT_STORAGE_VERSION, - storage: createJSONStorage(() => composerDebouncedStorage), + storage: composerPersistStorage, migrate: migratePersistedComposerDraftStoreState, - partialize: partializeComposerDraftStoreState, + // Defer the draft walk and serialization until the storage write flushes. + partialize: (state): ComposerPersistState => ({ capturedState: state }), merge: (persistedState, currentState) => { const normalizedPersisted = normalizeCurrentPersistedComposerDraftStoreState(persistedState); diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts index a37c67064..87b9b12ea 100644 --- a/apps/web/src/lib/storage.ts +++ b/apps/web/src/lib/storage.ts @@ -6,7 +6,10 @@ export interface StateStorage { removeItem: (name: string) => R; } -export interface DebouncedStorage extends StateStorage { +export interface DeferredStorage { + getItem: (name: string) => string | null | Promise; + setItem: (name: string, value: TValue) => void; + removeItem: (name: string) => void; flush: () => void; } @@ -39,14 +42,16 @@ export function resolveStorage(storage: Partial | null | undefined return isStateStorage(storage) ? storage : createMemoryStorage(); } -export function createDebouncedStorage( +/** Keep the latest value and serialize it when the debounce fires or `flush` runs. */ +export function createDeferredStorage( baseStorage: Partial | null | undefined, + serialize: (value: TValue) => string, debounceMs: number = 300, -): DebouncedStorage { +): DeferredStorage { const resolvedStorage = resolveStorage(baseStorage); const debouncedSetItem = new Debouncer( - (name: string, value: string) => { - resolvedStorage.setItem(name, value); + (name: string, value: TValue) => { + resolvedStorage.setItem(name, serialize(value)); }, { wait: debounceMs }, ); @@ -58,6 +63,8 @@ export function createDebouncedStorage( }, removeItem: (name) => { debouncedSetItem.cancel(); + // cancel() leaves the captured value in Pacer's lastArgs. + debouncedSetItem.reset(); resolvedStorage.removeItem(name); }, flush: () => { From 7082702718a4f79002c5947322ad1ee70fd089ef Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Sun, 6 Sep 2026 11:40:52 -0600 Subject: [PATCH 2/2] fix(web): retain saved annotations through draft reload --- apps/web/src/composerDraftStore.test.ts | 89 ++++++++++++++++--------- apps/web/src/composerDraftStore.ts | 8 +++ docs/user/composer.md | 3 + 3 files changed, 70 insertions(+), 30 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index d378a048d..72e2d4ffa 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -75,6 +75,7 @@ import { type ComposerImageAttachment, composerFileNeedsReattach, partializeComposerDraftStoreState, + flushComposerDraftStore, useComposerDraftStore, DraftId, } from "./composerDraftStore"; @@ -1648,7 +1649,8 @@ describe("composerDraftStore modelSelection", () => { resetComposerDraftStore(); }); - it("durably blocks a cross-client binding without changing content or explicit routing", () => { + it("durably blocks a cross-client binding without changing content or explicit routing", async () => { + await useComposerDraftStore.persist.clearStorage(); const store = useComposerDraftStore.getState(); const image = makeImage({ id: "binding-image", previewUrl: "data:image/png;base64,AQ==" }); const file = makeFile("binding-file"); @@ -1691,19 +1693,10 @@ describe("composerDraftStore modelSelection", () => { expect(draft?.files.map((entry) => entry.id)).toEqual([file.id]); expect(draft?.modelSelectionByProvider[CODEX_INSTANCE]).toEqual(originalSelection); - const persistApi = useComposerDraftStore.persist as unknown as { - getOptions: () => { - partialize: (state: ReturnType) => unknown; - merge: ( - persistedState: unknown, - currentState: ReturnType, - ) => ReturnType; - }; - }; - const options = persistApi.getOptions(); - const persisted = options.partialize(useComposerDraftStore.getState()); - const hydrated = options.merge(persisted, useComposerDraftStore.getState()); - const hydratedDraft = hydrated.getComposerDraft(threadRef); + flushComposerDraftStore(); + resetComposerDraftStore(); + await useComposerDraftStore.persist.rehydrate(); + const hydratedDraft = useComposerDraftStore.getState().getComposerDraft(threadRef); expect(hydratedDraft?.providerBindingConflict).toEqual(draft?.providerBindingConflict); expect(hydratedDraft?.prompt).toBe("keep this prompt"); expect(hydratedDraft?.images.map((entry) => entry.id)).toEqual([image.id]); @@ -1734,7 +1727,8 @@ describe("composerDraftStore modelSelection", () => { ).toEqual(boundSelection); }); - it("atomically transfers a complete blocked composer snapshot with its exact selection", () => { + it("atomically transfers a complete blocked composer snapshot with its exact selection", async () => { + await useComposerDraftStore.persist.clearStorage(); const store = useComposerDraftStore.getState(); const destinationDraftId = DraftId.make("draft-provider-conflict-destination"); const destinationThreadId = ThreadId.make("thread-provider-conflict-destination"); @@ -1858,21 +1852,12 @@ describe("composerDraftStore modelSelection", () => { expect(source?.modelSelectionByProvider[CODEX_INSTANCE]).toBeUndefined(); expect(source?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE]).toEqual(boundSelection); - const persistApi = useComposerDraftStore.persist as unknown as { - getOptions: () => { - partialize: (state: ReturnType) => unknown; - merge: ( - persistedState: unknown, - currentState: ReturnType, - ) => ReturnType; - }; - }; - const options = persistApi.getOptions(); - const hydrated = options.merge( - options.partialize(useComposerDraftStore.getState()), - useComposerDraftStore.getInitialState(), - ); - const hydratedDestination = hydrated.getComposerDraft(destinationDraftId); + flushComposerDraftStore(); + resetComposerDraftStore(); + await useComposerDraftStore.persist.rehydrate(); + const hydratedDestination = useComposerDraftStore + .getState() + .getComposerDraft(destinationDraftId); expect(hydratedDestination?.files).toMatchObject([ { id: file.id, @@ -2765,6 +2750,50 @@ function createMockStorage() { } describe("composer draft persistence", () => { + it("restores annotation-only drafts and ignores malformed saved annotations", async () => { + await useComposerDraftStore.persist.clearStorage(); + const threadId = ThreadId.make("annotation-only-draft"); + const threadKey = threadKeyFor(threadId, TEST_ENVIRONMENT_ID); + const annotation = { + id: "saved-preview-annotation", + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + comment: "Keep the primary action visible.", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-09-02T10:00:00.000Z", + }; + try { + const storage = useComposerDraftStore.persist.getOptions().storage!; + storage.setItem(COMPOSER_DRAFT_STORAGE_KEY, { + version: 10, + state: { + draftsByThreadKey: { + [threadKey]: { + prompt: "", + attachments: [], + previewAnnotations: [annotation, { ...annotation, pageUrl: 42 }], + }, + }, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, + }, + } as never); + flushComposerDraftStore(); + resetComposerDraftStore(); + await useComposerDraftStore.persist.rehydrate(); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.previewAnnotations).toEqual([annotation]); + } finally { + await useComposerDraftStore.persist.clearStorage(); + resetComposerDraftStore(); + } + }); + it("defers attachment reads and serialization until typing stops, then restores the last draft", async () => { await useComposerDraftStore.persist.clearStorage(); vi.useFakeTimers(); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 745531963..fbff300a7 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -62,6 +62,7 @@ import { ReviewCommentContextSchema, type ReviewCommentContext } from "./reviewC const isRuntimeMode = Schema.is(RuntimeMode); const isProviderDriverKind = Schema.is(ProviderDriverKind); const isReviewCommentContext = Schema.is(ReviewCommentContextSchema); +const isPreviewAnnotationPayload = Schema.is(PreviewAnnotationPayloadSchema); export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; const COMPOSER_DRAFT_STORAGE_VERSION = 10; @@ -1941,6 +1942,11 @@ function normalizePersistedDraftsByThreadId( return normalized ? [normalized] : []; }) : []; + const previewAnnotations = Array.isArray(draftCandidate.previewAnnotations) + ? draftCandidate.previewAnnotations + .filter(isPreviewAnnotationPayload) + .map((annotation) => ({ ...annotation }) as DeepMutable) + : []; const reviewComments = Array.isArray(draftCandidate.reviewComments) ? draftCandidate.reviewComments.filter(isReviewCommentContext) : []; @@ -2024,6 +2030,7 @@ function normalizePersistedDraftsByThreadId( files.length === 0 && terminalContexts.length === 0 && elementContexts.length === 0 && + previewAnnotations.length === 0 && reviewComments.length === 0 && !hasModelData && providerBindingConflict === undefined && @@ -2050,6 +2057,7 @@ function normalizePersistedDraftsByThreadId( ...(files.length > 0 ? { files } : {}), ...(terminalContexts.length > 0 ? { terminalContexts } : {}), ...(elementContexts.length > 0 ? { elementContexts } : {}), + ...(previewAnnotations.length > 0 ? { previewAnnotations } : {}), ...(reviewComments.length > 0 ? { reviewComments } : {}), ...(hasModelData ? { diff --git a/docs/user/composer.md b/docs/user/composer.md index 57da54e40..6611c0a0c 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -44,6 +44,9 @@ the system share options. On Android, use **Save or share video** inside the pre On web and desktop, if you reload before a file finishes uploading, the draft keeps the file's name and shows **Attach again** next to it. Attach the file again or remove it, then send. +Preview annotations in an unsent web or desktop draft survive a reload, including when you move +the draft into a new thread to resolve a provider conflict. + On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into the composer or paste them into a message. On iOS, selecting them from **Photo Library** also converts them to JPEG. The 10 MB image limit applies to the converted photo.