import { scopedProjectKey, scopedThreadKey, scopeProjectRef, scopeThreadRef,} from "@t3tools/client-runtime/environment";import * as Schema from "effect/Schema";import { defaultInstanceIdForDriver, EnvironmentId, MessageId, ProjectId, ProviderDriverKind, ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ThreadId, type ModelSelection, type ProviderOptionSelection,} from "@t3tools/contracts";import { createModelSelection } from "@t3tools/shared/model";import { collectAssistantCitations, serializeAssistantCitation,} from "@t3tools/shared/assistantCitations"; // 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, composerDraftHasUserContent, finalizePromotedDraftThreadByRef, markPromotedDraftThreadByRef, type ComposerFileAttachment, type ComposerImageAttachment, composerFileNeedsReattach, partializeComposerDraftStoreState, useComposerDraftStore, DraftId,} from "./composerDraftStore";import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage";import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, insertInlineTerminalContextPlaceholder, type TerminalContextDraft,} from "./lib/terminalContext";import { createDeferredStorage } 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(id: string): ComposerFileAttachment { const file = new File(["report"], "report.pdf", { type: "application/pdf" }); return { type: "file", id, name: file.name, mimeType: file.type, sizeBytes: file.size, 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 assistant citations", () => { beforeEach(resetComposerDraftStore); afterEach(resetComposerDraftStore); 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(); } });}); 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, }); expect(useComposerDraftStore.getState().addImage(threadRef, first)).toBe(true); expect(useComposerDraftStore.getState().addImage(threadRef, duplicateLater)).toBe(false); const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]); expect(revokeSpy).toHaveBeenCalledWith("blob:b"); }); it("returns false when addImage receives an existing image id", () => { const first = makeImage({ id: "img-same", previewUrl: "blob:first" }); const duplicate = makeImage({ id: "img-same", previewUrl: "blob:duplicate", name: "different.png", }); expect(useComposerDraftStore.getState().addImage(threadRef, first)).toBe(true); expect(useComposerDraftStore.getState().addImage(threadRef, duplicate)).toBe(false); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.images).toEqual([first]); expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate"); }); 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 unsent draft marker", () => { const threadId = ThreadId.make("thread-unsent-marker"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { resetComposerDraftStore(); }); it("reports content for typed text and clears when the composer is emptied", () => { const hasDraft = () => composerDraftHasUserContent(useComposerDraftStore.getState().getComposerDraft(threadRef)); expect(hasDraft()).toBe(false); useComposerDraftStore.getState().setPrompt(threadRef, " "); expect(hasDraft()).toBe(false); useComposerDraftStore.getState().setPrompt(threadRef, "follow up on the relay case"); expect(hasDraft()).toBe(true); useComposerDraftStore.getState().clearComposerContent(threadRef); expect(hasDraft()).toBe(false); });}); describe("composerDraftStore file attachments", () => { const threadId = ThreadId.make("thread-files"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { resetComposerDraftStore(); }); it("persists uploaded file references without including file contents", () => { const store = useComposerDraftStore.getState(); store.addFiles(threadRef, [makeFile("file-1")]); store.setFileUpload(threadRef, "file-1", TEST_ENVIRONMENT_ID, "pending-report-pdf"); const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { merge: ( persistedState: unknown, currentState: ReturnType<typeof useComposerDraftStore.getState>, ) => ReturnType<typeof useComposerDraftStore.getState>; }; }; const options = persistApi.getOptions(); const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState()); expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual( [ { id: "file-1", name: "report.pdf", mimeType: "application/pdf", sizeBytes: 6, attachmentId: "pending-report-pdf", environmentId: TEST_ENVIRONMENT_ID, }, ], ); const hydrated = options.merge(persisted, useComposerDraftStore.getState()); expect(hydrated.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual([ { type: "file", id: "file-1", name: "report.pdf", mimeType: "application/pdf", sizeBytes: 6, file: null, uploadedAttachmentId: "pending-report-pdf", uploadEnvironmentId: TEST_ENVIRONMENT_ID, }, ]); }); it("persists a pending file as a needs-reattach marker instead of dropping it", () => { const store = useComposerDraftStore.getState(); // No setFileUpload: the upload never finished, so there is no attachment // id and the File handle cannot serialize. store.addFiles(threadRef, [makeFile("file-pending")]); const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { merge: ( persistedState: unknown, currentState: ReturnType<typeof useComposerDraftStore.getState>, ) => ReturnType<typeof useComposerDraftStore.getState>; }; }; const options = persistApi.getOptions(); const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState()); expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual( [ { id: "file-pending", name: "report.pdf", mimeType: "application/pdf", sizeBytes: 6, }, ], ); const hydrated = options.merge(persisted, useComposerDraftStore.getState()); const hydratedFiles = hydrated.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files; expect(hydratedFiles).toEqual([ { type: "file", id: "file-pending", name: "report.pdf", mimeType: "application/pdf", sizeBytes: 6, file: null, }, ]); expect(hydratedFiles?.every(composerFileNeedsReattach)).toBe(true); }); it("marks only the matching byte-less upload as missing", () => { const store = useComposerDraftStore.getState(); const hydrated: ComposerFileAttachment = { ...makeFile("file-hydrated"), file: null, uploadedAttachmentId: "pending-old", uploadEnvironmentId: TEST_ENVIRONMENT_ID, }; const local: ComposerFileAttachment = { ...makeFile("file-local"), name: "local.txt", mimeType: "text/plain",Lines 1–500 of 2776. Load more
import { scopedProjectKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; import * as Schema from "effect/Schema"; import { defaultInstanceIdForDriver, EnvironmentId, MessageId, ProjectId, ProviderDriverKind, ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ThreadId, type ModelSelection, type ProviderOptionSelection, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { collectAssistantCitations, serializeAssistantCitation, } from "@t3tools/shared/assistantCitations"; // 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, composerDraftHasUserContent, finalizePromotedDraftThreadByRef, markPromotedDraftThreadByRef, type ComposerFileAttachment, type ComposerImageAttachment, composerFileNeedsReattach, partializeComposerDraftStoreState, useComposerDraftStore, DraftId, } from "./composerDraftStore"; import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, insertInlineTerminalContextPlaceholder, type TerminalContextDraft, } from "./lib/terminalContext"; import { createDeferredStorage } 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(id: string): ComposerFileAttachment { const file = new File(["report"], "report.pdf", { type: "application/pdf" }); return { type: "file", id, name: file.name, mimeType: file.type, sizeBytes: file.size, 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 assistant citations", () => { beforeEach(resetComposerDraftStore); afterEach(resetComposerDraftStore); 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(); } }); }); 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, }); expect(useComposerDraftStore.getState().addImage(threadRef, first)).toBe(true); expect(useComposerDraftStore.getState().addImage(threadRef, duplicateLater)).toBe(false); const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]); expect(revokeSpy).toHaveBeenCalledWith("blob:b"); }); it("returns false when addImage receives an existing image id", () => { const first = makeImage({ id: "img-same", previewUrl: "blob:first" }); const duplicate = makeImage({ id: "img-same", previewUrl: "blob:duplicate", name: "different.png", }); expect(useComposerDraftStore.getState().addImage(threadRef, first)).toBe(true); expect(useComposerDraftStore.getState().addImage(threadRef, duplicate)).toBe(false); expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.images).toEqual([first]); expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate"); }); 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 unsent draft marker", () => { const threadId = ThreadId.make("thread-unsent-marker"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { resetComposerDraftStore(); }); it("reports content for typed text and clears when the composer is emptied", () => { const hasDraft = () => composerDraftHasUserContent(useComposerDraftStore.getState().getComposerDraft(threadRef)); expect(hasDraft()).toBe(false); useComposerDraftStore.getState().setPrompt(threadRef, " "); expect(hasDraft()).toBe(false); useComposerDraftStore.getState().setPrompt(threadRef, "follow up on the relay case"); expect(hasDraft()).toBe(true); useComposerDraftStore.getState().clearComposerContent(threadRef); expect(hasDraft()).toBe(false); }); }); describe("composerDraftStore file attachments", () => { const threadId = ThreadId.make("thread-files"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { resetComposerDraftStore(); }); it("persists uploaded file references without including file contents", () => { const store = useComposerDraftStore.getState(); store.addFiles(threadRef, [makeFile("file-1")]); store.setFileUpload(threadRef, "file-1", TEST_ENVIRONMENT_ID, "pending-report-pdf"); const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { merge: ( persistedState: unknown, currentState: ReturnType<typeof useComposerDraftStore.getState>, ) => ReturnType<typeof useComposerDraftStore.getState>; }; }; const options = persistApi.getOptions(); const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState()); expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual( [ { id: "file-1", name: "report.pdf", mimeType: "application/pdf", sizeBytes: 6, attachmentId: "pending-report-pdf", environmentId: TEST_ENVIRONMENT_ID, }, ], ); const hydrated = options.merge(persisted, useComposerDraftStore.getState()); expect(hydrated.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual([ { type: "file", id: "file-1", name: "report.pdf", mimeType: "application/pdf", sizeBytes: 6, file: null, uploadedAttachmentId: "pending-report-pdf", uploadEnvironmentId: TEST_ENVIRONMENT_ID, }, ]); }); it("persists a pending file as a needs-reattach marker instead of dropping it", () => { const store = useComposerDraftStore.getState(); // No setFileUpload: the upload never finished, so there is no attachment // id and the File handle cannot serialize. store.addFiles(threadRef, [makeFile("file-pending")]); const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { merge: ( persistedState: unknown, currentState: ReturnType<typeof useComposerDraftStore.getState>, ) => ReturnType<typeof useComposerDraftStore.getState>; }; }; const options = persistApi.getOptions(); const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState()); expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual( [ { id: "file-pending", name: "report.pdf", mimeType: "application/pdf", sizeBytes: 6, }, ], ); const hydrated = options.merge(persisted, useComposerDraftStore.getState()); const hydratedFiles = hydrated.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files; expect(hydratedFiles).toEqual([ { type: "file", id: "file-pending", name: "report.pdf", mimeType: "application/pdf", sizeBytes: 6, file: null, }, ]); expect(hydratedFiles?.every(composerFileNeedsReattach)).toBe(true); }); it("marks only the matching byte-less upload as missing", () => { const store = useComposerDraftStore.getState(); const hydrated: ComposerFileAttachment = { ...makeFile("file-hydrated"), file: null, uploadedAttachmentId: "pending-old", uploadEnvironmentId: TEST_ENVIRONMENT_ID, }; const local: ComposerFileAttachment = { ...makeFile("file-local"), name: "local.txt", mimeType: "text/plain",