import { ProjectId, ThreadId } from "@t3tools/contracts";import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type ComposerImageAttachment, createDebouncedStorage, useComposerDraftStore,} from "./composerDraftStore";import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, insertInlineTerminalContextPlaceholder, type TerminalContextDraft,} from "./lib/terminalContext"; 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 makeTerminalContext(input: { id: string; text?: string; terminalId?: string; terminalLabel?: string; lineStart?: number; lineEnd?: number;}): TerminalContextDraft { return { id: input.id, threadId: ThreadId.makeUnsafe("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", };} describe("composerDraftStore addImages", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>; beforeEach(() => { useComposerDraftStore.setState({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, }); 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(threadId, [first, duplicate]); const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; 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(threadId, first); useComposerDraftStore.getState().addImage(threadId, duplicateLater); const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; 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(threadId, [first, duplicateSameUrl]); const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]); expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared"); });}); describe("composerDraftStore clearComposerContent", () => { const threadId = ThreadId.makeUnsafe("thread-clear"); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>; beforeEach(() => { useComposerDraftStore.setState({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, }); 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(threadId, first); useComposerDraftStore.getState().clearComposerContent(threadId); const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; expect(draft).toBeUndefined(); expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic"); });}); describe("composerDraftStore terminal contexts", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); beforeEach(() => { useComposerDraftStore.setState({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, }); }); it("deduplicates identical terminal contexts by selection signature", () => { const first = makeTerminalContext({ id: "ctx-1" }); const duplicate = makeTerminalContext({ id: "ctx-2" }); useComposerDraftStore.getState().addTerminalContexts(threadId, [first, duplicate]); const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-1"]); }); it("clears terminal contexts when clearing composer content", () => { useComposerDraftStore .getState() .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-1" })); useComposerDraftStore.getState().clearComposerContent(threadId); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); it("inserts terminal contexts at the requested inline prompt position", () => { const firstInsertion = insertInlineTerminalContextPlaceholder("alpha beta", 6); const secondInsertion = insertInlineTerminalContextPlaceholder(firstInsertion.prompt, 0); expect( useComposerDraftStore .getState() .insertTerminalContext( threadId, firstInsertion.prompt, makeTerminalContext({ id: "ctx-1" }), firstInsertion.contextIndex, ), ).toBe(true); expect( useComposerDraftStore.getState().insertTerminalContext( threadId, secondInsertion.prompt, makeTerminalContext({ id: "ctx-2", terminalLabel: "Terminal 2", lineStart: 9, lineEnd: 10, }), secondInsertion.contextIndex, ), ).toBe(true); const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; expect(draft?.prompt).toBe( `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} alpha ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} beta`, ); expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-2", "ctx-1"]); }); it("omits terminal context text from persisted drafts", () => { useComposerDraftStore .getState() .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-persist" })); const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown; }; }; const persistedState = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { draftsByThreadId?: Record<string, { terminalContexts?: Array<Record<string, unknown>> }>; }; expect( persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0], "Expected terminal context metadata to be persisted.", ).toMatchObject({ id: "ctx-persist", terminalId: "default", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 5, }); expect( persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0]?.text, ).toBeUndefined(); }); it("hydrates persisted terminal contexts without in-memory snapshot text", () => { const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { merge: ( persistedState: unknown, currentState: ReturnType<typeof useComposerDraftStore.getState>, ) => ReturnType<typeof useComposerDraftStore.getState>; }; }; const mergedState = persistApi.getOptions().merge( { draftsByThreadId: { [threadId]: { prompt: INLINE_TERMINAL_CONTEXT_PLACEHOLDER, attachments: [], terminalContexts: [ { id: "ctx-rehydrated", threadId, createdAt: "2026-03-13T12:00:00.000Z", terminalId: "default", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 5, }, ], }, }, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, }, useComposerDraftStore.getInitialState(), ); expect(mergedState.draftsByThreadId[threadId]?.terminalContexts).toMatchObject([ { id: "ctx-rehydrated", terminalId: "default", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 5, text: "", }, ]); });}); describe("composerDraftStore project draft thread mapping", () => { const projectId = ProjectId.makeUnsafe("project-a"); const otherProjectId = ProjectId.makeUnsafe("project-b"); const threadId = ThreadId.makeUnsafe("thread-a"); const otherThreadId = ThreadId.makeUnsafe("thread-b"); beforeEach(() => { useComposerDraftStore.setState({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, }); }); it("stores and reads project draft thread ids via actions", () => { const store = useComposerDraftStore.getState(); expect(store.getDraftThreadByProjectId(projectId)).toBeNull(); expect(store.getDraftThread(threadId)).toBeNull(); store.setProjectDraftThreadId(projectId, threadId, { branch: "feature/test", worktreePath: "/tmp/worktree-test", createdAt: "2026-01-01T00:00:00.000Z", }); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toEqual({ threadId, projectId, branch: "feature/test", worktreePath: "/tmp/worktree-test", envMode: "worktree", runtimeMode: "full-access", interactionMode: "default", createdAt: "2026-01-01T00:00:00.000Z", }); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({ projectId, branch: "feature/test", worktreePath: "/tmp/worktree-test", envMode: "worktree", runtimeMode: "full-access", interactionMode: "default", createdAt: "2026-01-01T00:00:00.000Z", }); }); it("clears only matching project draft mapping entries", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId); store.setPrompt(threadId, "hello"); store.clearProjectDraftThreadById(projectId, otherThreadId); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( threadId, ); store.clearProjectDraftThreadById(projectId, threadId); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); it("clears project draft mapping by project id", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId); store.setPrompt(threadId, "hello"); store.clearProjectDraftThreadId(projectId); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); it("clears orphaned composer drafts when remapping a project to a new draft thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId); store.setPrompt(threadId, "orphan me"); store.setProjectDraftThreadId(projectId, otherThreadId); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( otherThreadId, ); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); it("keeps composer drafts when the thread is still mapped by another project", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId); store.setProjectDraftThreadId(otherProjectId, threadId); store.setPrompt(threadId, "keep me"); store.clearProjectDraftThreadId(projectId); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); expect( useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId, ).toBe(threadId); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); }); it("clears draft registration independently", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId); store.clearDraftThread(threadId); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); }); it("updates branch context on an existing draft thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId, { branch: "main", worktreePath: null, }); store.setDraftThreadContext(threadId, { branch: "feature/next", worktreePath: "/tmp/feature-next", }); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( threadId, ); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ projectId, branch: "feature/next", worktreePath: "/tmp/feature-next", envMode: "worktree", }); }); it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId, { branch: "main", worktreePath: "/tmp/main-worktree", }); const runtimeUndefinedOptions = { branch: undefined, worktreePath: undefined, } as unknown as { branch?: string | null; worktreePath?: string | null; }; store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ projectId, branch: "main", worktreePath: "/tmp/main-worktree", envMode: "worktree", }); }); it("preserves worktree env mode without a worktree path", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId, { branch: "feature/base", worktreePath: null, envMode: "worktree", }); const runtimeUndefinedOptions = { branch: undefined, worktreePath: undefined, envMode: undefined, } as unknown as { branch?: string | null; worktreePath?: string | null; envMode?: "local" | "worktree"; }; store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ projectId, branch: "feature/base",Lines 1–500 of 748. Load more
import { ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type ComposerImageAttachment, createDebouncedStorage, useComposerDraftStore, } from "./composerDraftStore"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, insertInlineTerminalContextPlaceholder, type TerminalContextDraft, } from "./lib/terminalContext"; 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 makeTerminalContext(input: { id: string; text?: string; terminalId?: string; terminalLabel?: string; lineStart?: number; lineEnd?: number; }): TerminalContextDraft { return { id: input.id, threadId: ThreadId.makeUnsafe("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", }; } describe("composerDraftStore addImages", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>; beforeEach(() => { useComposerDraftStore.setState({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, }); 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(threadId, [first, duplicate]); const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; 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(threadId, first); useComposerDraftStore.getState().addImage(threadId, duplicateLater); const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; 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(threadId, [first, duplicateSameUrl]); const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]); expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared"); }); }); describe("composerDraftStore clearComposerContent", () => { const threadId = ThreadId.makeUnsafe("thread-clear"); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>; beforeEach(() => { useComposerDraftStore.setState({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, }); 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(threadId, first); useComposerDraftStore.getState().clearComposerContent(threadId); const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; expect(draft).toBeUndefined(); expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic"); }); }); describe("composerDraftStore terminal contexts", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); beforeEach(() => { useComposerDraftStore.setState({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, }); }); it("deduplicates identical terminal contexts by selection signature", () => { const first = makeTerminalContext({ id: "ctx-1" }); const duplicate = makeTerminalContext({ id: "ctx-2" }); useComposerDraftStore.getState().addTerminalContexts(threadId, [first, duplicate]); const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-1"]); }); it("clears terminal contexts when clearing composer content", () => { useComposerDraftStore .getState() .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-1" })); useComposerDraftStore.getState().clearComposerContent(threadId); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); it("inserts terminal contexts at the requested inline prompt position", () => { const firstInsertion = insertInlineTerminalContextPlaceholder("alpha beta", 6); const secondInsertion = insertInlineTerminalContextPlaceholder(firstInsertion.prompt, 0); expect( useComposerDraftStore .getState() .insertTerminalContext( threadId, firstInsertion.prompt, makeTerminalContext({ id: "ctx-1" }), firstInsertion.contextIndex, ), ).toBe(true); expect( useComposerDraftStore.getState().insertTerminalContext( threadId, secondInsertion.prompt, makeTerminalContext({ id: "ctx-2", terminalLabel: "Terminal 2", lineStart: 9, lineEnd: 10, }), secondInsertion.contextIndex, ), ).toBe(true); const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; expect(draft?.prompt).toBe( `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} alpha ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} beta`, ); expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-2", "ctx-1"]); }); it("omits terminal context text from persisted drafts", () => { useComposerDraftStore .getState() .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-persist" })); const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown; }; }; const persistedState = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { draftsByThreadId?: Record<string, { terminalContexts?: Array<Record<string, unknown>> }>; }; expect( persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0], "Expected terminal context metadata to be persisted.", ).toMatchObject({ id: "ctx-persist", terminalId: "default", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 5, }); expect( persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0]?.text, ).toBeUndefined(); }); it("hydrates persisted terminal contexts without in-memory snapshot text", () => { const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { merge: ( persistedState: unknown, currentState: ReturnType<typeof useComposerDraftStore.getState>, ) => ReturnType<typeof useComposerDraftStore.getState>; }; }; const mergedState = persistApi.getOptions().merge( { draftsByThreadId: { [threadId]: { prompt: INLINE_TERMINAL_CONTEXT_PLACEHOLDER, attachments: [], terminalContexts: [ { id: "ctx-rehydrated", threadId, createdAt: "2026-03-13T12:00:00.000Z", terminalId: "default", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 5, }, ], }, }, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, }, useComposerDraftStore.getInitialState(), ); expect(mergedState.draftsByThreadId[threadId]?.terminalContexts).toMatchObject([ { id: "ctx-rehydrated", terminalId: "default", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 5, text: "", }, ]); }); }); describe("composerDraftStore project draft thread mapping", () => { const projectId = ProjectId.makeUnsafe("project-a"); const otherProjectId = ProjectId.makeUnsafe("project-b"); const threadId = ThreadId.makeUnsafe("thread-a"); const otherThreadId = ThreadId.makeUnsafe("thread-b"); beforeEach(() => { useComposerDraftStore.setState({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, }); }); it("stores and reads project draft thread ids via actions", () => { const store = useComposerDraftStore.getState(); expect(store.getDraftThreadByProjectId(projectId)).toBeNull(); expect(store.getDraftThread(threadId)).toBeNull(); store.setProjectDraftThreadId(projectId, threadId, { branch: "feature/test", worktreePath: "/tmp/worktree-test", createdAt: "2026-01-01T00:00:00.000Z", }); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toEqual({ threadId, projectId, branch: "feature/test", worktreePath: "/tmp/worktree-test", envMode: "worktree", runtimeMode: "full-access", interactionMode: "default", createdAt: "2026-01-01T00:00:00.000Z", }); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({ projectId, branch: "feature/test", worktreePath: "/tmp/worktree-test", envMode: "worktree", runtimeMode: "full-access", interactionMode: "default", createdAt: "2026-01-01T00:00:00.000Z", }); }); it("clears only matching project draft mapping entries", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId); store.setPrompt(threadId, "hello"); store.clearProjectDraftThreadById(projectId, otherThreadId); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( threadId, ); store.clearProjectDraftThreadById(projectId, threadId); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); it("clears project draft mapping by project id", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId); store.setPrompt(threadId, "hello"); store.clearProjectDraftThreadId(projectId); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); it("clears orphaned composer drafts when remapping a project to a new draft thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId); store.setPrompt(threadId, "orphan me"); store.setProjectDraftThreadId(projectId, otherThreadId); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( otherThreadId, ); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); it("keeps composer drafts when the thread is still mapped by another project", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId); store.setProjectDraftThreadId(otherProjectId, threadId); store.setPrompt(threadId, "keep me"); store.clearProjectDraftThreadId(projectId); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); expect( useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId, ).toBe(threadId); expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); }); it("clears draft registration independently", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId); store.clearDraftThread(threadId); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); }); it("updates branch context on an existing draft thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId, { branch: "main", worktreePath: null, }); store.setDraftThreadContext(threadId, { branch: "feature/next", worktreePath: "/tmp/feature-next", }); expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( threadId, ); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ projectId, branch: "feature/next", worktreePath: "/tmp/feature-next", envMode: "worktree", }); }); it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId, { branch: "main", worktreePath: "/tmp/main-worktree", }); const runtimeUndefinedOptions = { branch: undefined, worktreePath: undefined, } as unknown as { branch?: string | null; worktreePath?: string | null; }; store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ projectId, branch: "main", worktreePath: "/tmp/main-worktree", envMode: "worktree", }); }); it("preserves worktree env mode without a worktree path", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId, { branch: "feature/base", worktreePath: null, envMode: "worktree", }); const runtimeUndefinedOptions = { branch: undefined, worktreePath: undefined, envMode: undefined, } as unknown as { branch?: string | null; worktreePath?: string | null; envMode?: "local" | "worktree"; }; store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ projectId, branch: "feature/base",