import { scopedProjectKey, scopedThreadKey, scopeProjectRef, scopeThreadRef,} from "@t3tools/client-runtime/environment";import * as Schema from "effect/Schema";import { defaultInstanceIdForDriver, EnvironmentId, ProjectId, ProviderDriverKind, ProviderInstanceId, ThreadId, type ModelSelection, type ProviderOptionSelection,} from "@t3tools/contracts";import { createModelSelection } from "@t3tools/shared/model"; // The composer draft's `modelSelectionByProvider` and// `stickyModelSelectionByProvider` maps are keyed by `ProviderInstanceId`// in production; these aliases keep the legacy-key migration tests concise.const CODEX_INSTANCE = ProviderInstanceId.make("codex");const CODEX_SECONDARY_INSTANCE = ProviderInstanceId.make("codex_secondary");const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent");const CURSOR_INSTANCE = ProviderInstanceId.make("cursor");const CODEX_DRIVER = ProviderDriverKind.make("codex");const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent");const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); type ProviderOptionSelectionBag = ReadonlyArray<ProviderOptionSelection>;type ProviderOptionSelectionsByProvider = Partial<Record<string, ProviderOptionSelectionBag>>; function toSelections( options: Record<string, string | boolean | undefined> | undefined,): ReadonlyArray<ProviderOptionSelection> { const result: Array<ProviderOptionSelection> = []; if (!options) return result; for (const [id, value] of Object.entries(options)) { if (typeof value === "string" || typeof value === "boolean") { result.push({ id, value }); } } return result;} function selectionsByProvider( options: Partial<Record<ProviderDriverKind, Record<string, string | boolean | undefined>>>,): ProviderOptionSelectionsByProvider { const result: ProviderOptionSelectionsByProvider = {}; for (const [provider, bag] of Object.entries(options) as Array< [ProviderDriverKind, Record<string, string | boolean | undefined>] >) { result[provider] = toSelections(bag); } return result;}import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { COMPOSER_DRAFT_STORAGE_KEY, clearComposerDraftsEnvironment, finalizePromotedDraftThreadByRef, markPromotedDraftThread, markPromotedDraftThreadByRef, markPromotedDraftThreads, markPromotedDraftThreadsByRef, type ComposerFileAttachment, type ComposerImageAttachment, useComposerDraftStore, DraftId,} from "./composerDraftStore";import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage";import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, insertInlineTerminalContextPlaceholder, type TerminalContextDraft,} from "./lib/terminalContext";import { createDebouncedStorage } from "./lib/storage"; function makeImage(input: { id: string; previewUrl: string; name?: string; mimeType?: string; sizeBytes?: number; lastModified?: number;}): ComposerImageAttachment { const name = input.name ?? "image.png"; const mimeType = input.mimeType ?? "image/png"; const sizeBytes = input.sizeBytes ?? 4; const lastModified = input.lastModified ?? 1_700_000_000_000; const file = new File([new Uint8Array(sizeBytes).fill(1)], name, { type: mimeType, lastModified, }); return { type: "image", id: input.id, name, mimeType, sizeBytes: file.size, previewUrl: input.previewUrl, file, };} function makeFile(input: { id: string; previewUrl: string; name?: string; mimeType?: string; sizeBytes?: number; lastModified?: number;}): ComposerFileAttachment { const name = input.name ?? "report.pdf"; const mimeType = input.mimeType ?? "application/pdf"; const sizeBytes = input.sizeBytes ?? 4; const lastModified = input.lastModified ?? 1_700_000_000_000; const file = new File([new Uint8Array(sizeBytes).fill(1)], name, { type: mimeType, lastModified, }); return { type: "file", id: input.id, name, mimeType, sizeBytes: file.size, previewUrl: input.previewUrl, file, };} function makeTerminalContext(input: { id: string; text?: string; terminalId?: string; terminalLabel?: string; lineStart?: number; lineEnd?: number;}): TerminalContextDraft { return { id: input.id, threadId: ThreadId.make("thread-dedupe"), terminalId: input.terminalId ?? "default", terminalLabel: input.terminalLabel ?? "Terminal 1", lineStart: input.lineStart ?? 4, lineEnd: input.lineEnd ?? 5, text: input.text ?? "git status\nOn branch main", createdAt: "2026-03-13T12:00:00.000Z", };} function resetComposerDraftStore() { useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {}, logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, });} function modelSelection( provider: ProviderDriverKind, model: string, options?: Record<string, string | boolean | undefined>,): ModelSelection { return createModelSelection(defaultInstanceIdForDriver(provider), model, toSelections(options));} function providerModelOptions( options: Partial<Record<string, Record<string, string | boolean | undefined>>>,): ProviderOptionSelectionsByProvider { return selectionsByProvider(options);} const TEST_ENVIRONMENT_ID = EnvironmentId.make("environment-local");const OTHER_TEST_ENVIRONMENT_ID = EnvironmentId.make("environment-remote");const LEGACY_TEST_ENVIRONMENT_ID = EnvironmentId.make("__legacy__"); function threadKeyFor( threadId: ThreadId, environmentId: EnvironmentId = LEGACY_TEST_ENVIRONMENT_ID,): string { if (environmentId === LEGACY_TEST_ENVIRONMENT_ID) { return threadId; } return scopedThreadKey(scopeThreadRef(environmentId, threadId));} function draftFor(threadId: ThreadId, environmentId: EnvironmentId = LEGACY_TEST_ENVIRONMENT_ID) { const store = useComposerDraftStore.getState().draftsByThreadKey; return store[threadKeyFor(threadId, environmentId)] ?? store[threadId] ?? undefined;} function draftByKey(key: string) { return useComposerDraftStore.getState().draftsByThreadKey[key] ?? undefined;} describe("composerDraftStore addImages", () => { const threadId = ThreadId.make("thread-dedupe"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>; beforeEach(() => { resetComposerDraftStore(); originalRevokeObjectUrl = URL.revokeObjectURL; revokeSpy = vi.fn(); URL.revokeObjectURL = revokeSpy; }); afterEach(() => { URL.revokeObjectURL = originalRevokeObjectUrl; }); it("deduplicates identical images in one batch by file signature", () => { const first = makeImage({ id: "img-1", previewUrl: "blob:first", name: "same.png", mimeType: "image/png", sizeBytes: 12, lastModified: 12345, }); const duplicate = makeImage({ id: "img-2", previewUrl: "blob:duplicate", name: "same.png", mimeType: "image/png", sizeBytes: 12, lastModified: 12345, }); useComposerDraftStore.getState().addImages(threadRef, [first, duplicate]); const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.images.map((image) => image.id)).toEqual(["img-1"]); expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate"); }); it("deduplicates against existing images across calls by file signature", () => { const first = makeImage({ id: "img-a", previewUrl: "blob:a", name: "same.png", mimeType: "image/png", sizeBytes: 9, lastModified: 777, }); const duplicateLater = makeImage({ id: "img-b", previewUrl: "blob:b", name: "same.png", mimeType: "image/png", sizeBytes: 9, lastModified: 999, }); useComposerDraftStore.getState().addImage(threadRef, first); useComposerDraftStore.getState().addImage(threadRef, duplicateLater); const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]); expect(revokeSpy).toHaveBeenCalledWith("blob:b"); }); it("does not revoke blob URLs that are still used by an accepted duplicate image", () => { const first = makeImage({ id: "img-shared", previewUrl: "blob:shared", }); const duplicateSameUrl = makeImage({ id: "img-shared", previewUrl: "blob:shared", }); useComposerDraftStore.getState().addImages(threadRef, [first, duplicateSameUrl]); const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]); expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared"); });}); describe("composerDraftStore clearComposerContent", () => { const threadId = ThreadId.make("thread-clear"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>; beforeEach(() => { resetComposerDraftStore(); originalRevokeObjectUrl = URL.revokeObjectURL; revokeSpy = vi.fn(); URL.revokeObjectURL = revokeSpy; }); afterEach(() => { URL.revokeObjectURL = originalRevokeObjectUrl; }); it("does not revoke blob preview URLs when clearing composer content", () => { const first = makeImage({ id: "img-optimistic", previewUrl: "blob:optimistic", }); useComposerDraftStore.getState().addImage(threadRef, first); useComposerDraftStore.getState().clearComposerContent(threadRef); const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft).toBeUndefined(); expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic"); });}); describe("composerDraftStore syncPersistedAttachments", () => { const threadId = ThreadId.make("thread-sync-persisted"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {}, logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); }); afterEach(() => { removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); }); it("treats malformed persisted draft storage as empty", async () => { const image = makeImage({ id: "img-persisted", previewUrl: "blob:persisted", }); useComposerDraftStore.getState().addImage(threadRef, image); setLocalStorageItem( COMPOSER_DRAFT_STORAGE_KEY, { version: 2, state: { draftsByThreadId: { [threadId]: { attachments: "not-an-array", }, }, }, }, Schema.Unknown, ); useComposerDraftStore.getState().syncPersistedAttachments(threadRef, [ { id: image.id, name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, dataUrl: image.previewUrl, }, ]); await Promise.resolve(); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.persistedAttachments).toEqual([]); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.nonPersistedImageIds).toEqual([image.id]); });}); describe("composerDraftStore file attachments", () => { const threadId = ThreadId.make("thread-file-attachments"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); resetComposerDraftStore(); }); afterEach(() => { removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); }); function mergePersistedDraft(attachment: Record<string, unknown>) { const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { merge: ( persistedState: unknown, currentState: ReturnType<typeof useComposerDraftStore.getState>, ) => ReturnType<typeof useComposerDraftStore.getState>; }; }; return persistApi.getOptions().merge( { draftsByThreadId: { [threadId]: { prompt: "", attachments: [attachment], }, }, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectKey: {}, }, useComposerDraftStore.getInitialState(), ); } it("hydrates persisted attachments without a type as images (legacy drafts)", () => { const mergedState = mergePersistedDraft({ id: "img-legacy", name: "legacy.png", mimeType: "image/png", sizeBytes: 5, dataUrl: "data:image/png;base64,aGVsbG8=", }); expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]?.images).toMatchObject([ { type: "image", id: "img-legacy", name: "legacy.png", mimeType: "image/png", sizeBytes: 5, previewUrl: "data:image/png;base64,aGVsbG8=", }, ]); }); it("hydrates persisted attachments with type file as file attachments", () => { const mergedState = mergePersistedDraft({ type: "file", id: "file-persisted", name: "report.pdf", mimeType: "application/pdf", sizeBytes: 5, dataUrl: "data:application/pdf;base64,aGVsbG8=", }); expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]?.images).toMatchObject([ { type: "file", id: "file-persisted", name: "report.pdf", mimeType: "application/pdf", sizeBytes: 5, previewUrl: "data:application/pdf;base64,aGVsbG8=", }, ]); }); it("persists the file type for staged file attachments (round trip)", () => { const attachment = makeFile({ id: "file-round-trip", previewUrl: "data:application/pdf;base64,aGVsbG8=", }); useComposerDraftStore.getState().addImage(threadRef, attachment); useComposerDraftStore.getState().syncPersistedAttachments(threadRef, [ { type: "file", id: attachment.id, name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, dataUrl: attachment.previewUrl, }, ]); const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown; }; }; const persisted = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { draftsByThreadKey?: Record<string, { attachments?: Array<Record<string, unknown>> }>; }; expect( persisted.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.attachments?.[0], ).toMatchObject({ type: "file", id: "file-round-trip", name: "report.pdf", mimeType: "application/pdf", }); });}); describe("composerDraftStore terminal contexts", () => { const threadId = ThreadId.make("thread-dedupe"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {},Lines 1–500 of 1915. Load more
import { scopedProjectKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; import * as Schema from "effect/Schema"; import { defaultInstanceIdForDriver, EnvironmentId, ProjectId, ProviderDriverKind, ProviderInstanceId, ThreadId, type ModelSelection, type ProviderOptionSelection, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; // The composer draft's `modelSelectionByProvider` and // `stickyModelSelectionByProvider` maps are keyed by `ProviderInstanceId` // in production; these aliases keep the legacy-key migration tests concise. const CODEX_INSTANCE = ProviderInstanceId.make("codex"); const CODEX_SECONDARY_INSTANCE = ProviderInstanceId.make("codex_secondary"); const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent"); const CURSOR_INSTANCE = ProviderInstanceId.make("cursor"); const CODEX_DRIVER = ProviderDriverKind.make("codex"); const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); type ProviderOptionSelectionBag = ReadonlyArray<ProviderOptionSelection>; type ProviderOptionSelectionsByProvider = Partial<Record<string, ProviderOptionSelectionBag>>; function toSelections( options: Record<string, string | boolean | undefined> | undefined, ): ReadonlyArray<ProviderOptionSelection> { const result: Array<ProviderOptionSelection> = []; if (!options) return result; for (const [id, value] of Object.entries(options)) { if (typeof value === "string" || typeof value === "boolean") { result.push({ id, value }); } } return result; } function selectionsByProvider( options: Partial<Record<ProviderDriverKind, Record<string, string | boolean | undefined>>>, ): ProviderOptionSelectionsByProvider { const result: ProviderOptionSelectionsByProvider = {}; for (const [provider, bag] of Object.entries(options) as Array< [ProviderDriverKind, Record<string, string | boolean | undefined>] >) { result[provider] = toSelections(bag); } return result; } import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { COMPOSER_DRAFT_STORAGE_KEY, clearComposerDraftsEnvironment, finalizePromotedDraftThreadByRef, markPromotedDraftThread, markPromotedDraftThreadByRef, markPromotedDraftThreads, markPromotedDraftThreadsByRef, type ComposerFileAttachment, type ComposerImageAttachment, useComposerDraftStore, DraftId, } from "./composerDraftStore"; import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, insertInlineTerminalContextPlaceholder, type TerminalContextDraft, } from "./lib/terminalContext"; import { createDebouncedStorage } from "./lib/storage"; function makeImage(input: { id: string; previewUrl: string; name?: string; mimeType?: string; sizeBytes?: number; lastModified?: number; }): ComposerImageAttachment { const name = input.name ?? "image.png"; const mimeType = input.mimeType ?? "image/png"; const sizeBytes = input.sizeBytes ?? 4; const lastModified = input.lastModified ?? 1_700_000_000_000; const file = new File([new Uint8Array(sizeBytes).fill(1)], name, { type: mimeType, lastModified, }); return { type: "image", id: input.id, name, mimeType, sizeBytes: file.size, previewUrl: input.previewUrl, file, }; } function makeFile(input: { id: string; previewUrl: string; name?: string; mimeType?: string; sizeBytes?: number; lastModified?: number; }): ComposerFileAttachment { const name = input.name ?? "report.pdf"; const mimeType = input.mimeType ?? "application/pdf"; const sizeBytes = input.sizeBytes ?? 4; const lastModified = input.lastModified ?? 1_700_000_000_000; const file = new File([new Uint8Array(sizeBytes).fill(1)], name, { type: mimeType, lastModified, }); return { type: "file", id: input.id, name, mimeType, sizeBytes: file.size, previewUrl: input.previewUrl, file, }; } function makeTerminalContext(input: { id: string; text?: string; terminalId?: string; terminalLabel?: string; lineStart?: number; lineEnd?: number; }): TerminalContextDraft { return { id: input.id, threadId: ThreadId.make("thread-dedupe"), terminalId: input.terminalId ?? "default", terminalLabel: input.terminalLabel ?? "Terminal 1", lineStart: input.lineStart ?? 4, lineEnd: input.lineEnd ?? 5, text: input.text ?? "git status\nOn branch main", createdAt: "2026-03-13T12:00:00.000Z", }; } function resetComposerDraftStore() { useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {}, logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); } function modelSelection( provider: ProviderDriverKind, model: string, options?: Record<string, string | boolean | undefined>, ): ModelSelection { return createModelSelection(defaultInstanceIdForDriver(provider), model, toSelections(options)); } function providerModelOptions( options: Partial<Record<string, Record<string, string | boolean | undefined>>>, ): ProviderOptionSelectionsByProvider { return selectionsByProvider(options); } const TEST_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const OTHER_TEST_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); const LEGACY_TEST_ENVIRONMENT_ID = EnvironmentId.make("__legacy__"); function threadKeyFor( threadId: ThreadId, environmentId: EnvironmentId = LEGACY_TEST_ENVIRONMENT_ID, ): string { if (environmentId === LEGACY_TEST_ENVIRONMENT_ID) { return threadId; } return scopedThreadKey(scopeThreadRef(environmentId, threadId)); } function draftFor(threadId: ThreadId, environmentId: EnvironmentId = LEGACY_TEST_ENVIRONMENT_ID) { const store = useComposerDraftStore.getState().draftsByThreadKey; return store[threadKeyFor(threadId, environmentId)] ?? store[threadId] ?? undefined; } function draftByKey(key: string) { return useComposerDraftStore.getState().draftsByThreadKey[key] ?? undefined; } describe("composerDraftStore addImages", () => { const threadId = ThreadId.make("thread-dedupe"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>; beforeEach(() => { resetComposerDraftStore(); originalRevokeObjectUrl = URL.revokeObjectURL; revokeSpy = vi.fn(); URL.revokeObjectURL = revokeSpy; }); afterEach(() => { URL.revokeObjectURL = originalRevokeObjectUrl; }); it("deduplicates identical images in one batch by file signature", () => { const first = makeImage({ id: "img-1", previewUrl: "blob:first", name: "same.png", mimeType: "image/png", sizeBytes: 12, lastModified: 12345, }); const duplicate = makeImage({ id: "img-2", previewUrl: "blob:duplicate", name: "same.png", mimeType: "image/png", sizeBytes: 12, lastModified: 12345, }); useComposerDraftStore.getState().addImages(threadRef, [first, duplicate]); const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.images.map((image) => image.id)).toEqual(["img-1"]); expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate"); }); it("deduplicates against existing images across calls by file signature", () => { const first = makeImage({ id: "img-a", previewUrl: "blob:a", name: "same.png", mimeType: "image/png", sizeBytes: 9, lastModified: 777, }); const duplicateLater = makeImage({ id: "img-b", previewUrl: "blob:b", name: "same.png", mimeType: "image/png", sizeBytes: 9, lastModified: 999, }); useComposerDraftStore.getState().addImage(threadRef, first); useComposerDraftStore.getState().addImage(threadRef, duplicateLater); const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]); expect(revokeSpy).toHaveBeenCalledWith("blob:b"); }); it("does not revoke blob URLs that are still used by an accepted duplicate image", () => { const first = makeImage({ id: "img-shared", previewUrl: "blob:shared", }); const duplicateSameUrl = makeImage({ id: "img-shared", previewUrl: "blob:shared", }); useComposerDraftStore.getState().addImages(threadRef, [first, duplicateSameUrl]); const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]); expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared"); }); }); describe("composerDraftStore clearComposerContent", () => { const threadId = ThreadId.make("thread-clear"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>; beforeEach(() => { resetComposerDraftStore(); originalRevokeObjectUrl = URL.revokeObjectURL; revokeSpy = vi.fn(); URL.revokeObjectURL = revokeSpy; }); afterEach(() => { URL.revokeObjectURL = originalRevokeObjectUrl; }); it("does not revoke blob preview URLs when clearing composer content", () => { const first = makeImage({ id: "img-optimistic", previewUrl: "blob:optimistic", }); useComposerDraftStore.getState().addImage(threadRef, first); useComposerDraftStore.getState().clearComposerContent(threadRef); const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft).toBeUndefined(); expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic"); }); }); describe("composerDraftStore syncPersistedAttachments", () => { const threadId = ThreadId.make("thread-sync-persisted"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {}, logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); }); afterEach(() => { removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); }); it("treats malformed persisted draft storage as empty", async () => { const image = makeImage({ id: "img-persisted", previewUrl: "blob:persisted", }); useComposerDraftStore.getState().addImage(threadRef, image); setLocalStorageItem( COMPOSER_DRAFT_STORAGE_KEY, { version: 2, state: { draftsByThreadId: { [threadId]: { attachments: "not-an-array", }, }, }, }, Schema.Unknown, ); useComposerDraftStore.getState().syncPersistedAttachments(threadRef, [ { id: image.id, name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, dataUrl: image.previewUrl, }, ]); await Promise.resolve(); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.persistedAttachments).toEqual([]); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.nonPersistedImageIds).toEqual([image.id]); }); }); describe("composerDraftStore file attachments", () => { const threadId = ThreadId.make("thread-file-attachments"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); resetComposerDraftStore(); }); afterEach(() => { removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); }); function mergePersistedDraft(attachment: Record<string, unknown>) { const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { merge: ( persistedState: unknown, currentState: ReturnType<typeof useComposerDraftStore.getState>, ) => ReturnType<typeof useComposerDraftStore.getState>; }; }; return persistApi.getOptions().merge( { draftsByThreadId: { [threadId]: { prompt: "", attachments: [attachment], }, }, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectKey: {}, }, useComposerDraftStore.getInitialState(), ); } it("hydrates persisted attachments without a type as images (legacy drafts)", () => { const mergedState = mergePersistedDraft({ id: "img-legacy", name: "legacy.png", mimeType: "image/png", sizeBytes: 5, dataUrl: "data:image/png;base64,aGVsbG8=", }); expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]?.images).toMatchObject([ { type: "image", id: "img-legacy", name: "legacy.png", mimeType: "image/png", sizeBytes: 5, previewUrl: "data:image/png;base64,aGVsbG8=", }, ]); }); it("hydrates persisted attachments with type file as file attachments", () => { const mergedState = mergePersistedDraft({ type: "file", id: "file-persisted", name: "report.pdf", mimeType: "application/pdf", sizeBytes: 5, dataUrl: "data:application/pdf;base64,aGVsbG8=", }); expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]?.images).toMatchObject([ { type: "file", id: "file-persisted", name: "report.pdf", mimeType: "application/pdf", sizeBytes: 5, previewUrl: "data:application/pdf;base64,aGVsbG8=", }, ]); }); it("persists the file type for staged file attachments (round trip)", () => { const attachment = makeFile({ id: "file-round-trip", previewUrl: "data:application/pdf;base64,aGVsbG8=", }); useComposerDraftStore.getState().addImage(threadRef, attachment); useComposerDraftStore.getState().syncPersistedAttachments(threadRef, [ { type: "file", id: attachment.id, name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, dataUrl: attachment.previewUrl, }, ]); const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown; }; }; const persisted = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { draftsByThreadKey?: Record<string, { attachments?: Array<Record<string, unknown>> }>; }; expect( persisted.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.attachments?.[0], ).toMatchObject({ type: "file", id: "file-round-trip", name: "report.pdf", mimeType: "application/pdf", }); }); }); describe("composerDraftStore terminal contexts", () => { const threadId = ThreadId.make("thread-dedupe"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {},