diff --git a/src/components/ai-toolbar.tsx b/src/components/ai-toolbar.tsx index 01c7ae9..2440ee4 100644 --- a/src/components/ai-toolbar.tsx +++ b/src/components/ai-toolbar.tsx @@ -310,6 +310,8 @@ export const AIToolbar = ({ if ( e.key === "Enter" && (e.metaKey || e.ctrlKey) && + !aiLoading && + canUseAi && (aiPrompt.trim() || hasImages) ) { e.preventDefault(); diff --git a/src/lib/multi-image.ts b/src/lib/multi-image.ts index 1b5e5e3..df63f8a 100644 --- a/src/lib/multi-image.ts +++ b/src/lib/multi-image.ts @@ -8,14 +8,15 @@ /** * Returns a stable deduplication key for a File. - * Key = `name:size` — cheap, deterministic, and catches re-selections of the - * exact same file (same name *and* same byte count). + * Key = `name:size:lastModified` — cheap, deterministic, and catches + * re-selections of the exact same file while keeping clipboard fallback files + * distinct when they share a generated name and byte size. * - * Two different files that happen to share a name but have different content - * will have different sizes and therefore different keys. + * Two different files that happen to share a generated fallback name but have + * different timestamps will not collapse to the same key. */ export function imageFileKey(file: File): string { - return `${file.name}:${file.size}`; + return `${file.name}:${file.size}:${file.lastModified}`; } /** diff --git a/tests/ai-toolbar.test.ts b/tests/ai-toolbar.test.ts index ac10ce7..a8f72b8 100644 --- a/tests/ai-toolbar.test.ts +++ b/tests/ai-toolbar.test.ts @@ -488,6 +488,30 @@ describe("AI toolbar paste capture", () => { expect(onAiCreate).toHaveBeenCalledTimes(1); }); + test("Mod+Enter does not trigger AI generation while loading", async () => { + const onAiCreate = mock(); + + const { textareaProps } = await renderToolbarBoundary({ + aiPrompt: "Draft a kickoff", + aiLoading: true, + onAiCreate, + }); + const event = createTextareaKeydownEvent({ ctrlKey: true }); + + textareaProps.onKeyDown?.(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(onAiCreate).not.toHaveBeenCalled(); + }); + + test("AI unavailable state removes the composer so generate shortcuts are not exposed", async () => { + const markup = await renderToolbarMarkup({ aiEnabled: false }); + + expect(markup).toContain("AI integrations are unavailable"); + expect(markup).not.toContain("Type or paste event details..."); + expect(markup).not.toContain("Generate event"); + }); + test("Shift+Mod+A opens the image picker when the composer is idle", async () => { const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary(); const ctrlEvent = createTextareaKeydownEvent({ diff --git a/tests/multi-image.test.ts b/tests/multi-image.test.ts index 9ea948a..524d945 100644 --- a/tests/multi-image.test.ts +++ b/tests/multi-image.test.ts @@ -13,35 +13,74 @@ import { describe("imageFileKey – stable identity for dedup", () => { test("returns a string combining name and size", () => { - const file = new File(["hello"], "flyer.png", { type: "image/png" }); + const file = new File(["hello"], "flyer.png", { + type: "image/png", + lastModified: 123, + }); const key = imageFileKey(file); expect(typeof key).toBe("string"); expect(key).toContain("flyer.png"); expect(key).toContain(String(file.size)); + expect(key).toContain(String(file.lastModified)); }); - test("two files with the same name and size produce the same key", () => { - const a = new File(["hello"], "a.png", { type: "image/png" }); - const b = new File(["hello"], "a.png", { type: "image/png" }); + test("two files with the same name, size, and lastModified produce the same key", () => { + const a = new File(["hello"], "a.png", { + type: "image/png", + lastModified: 123, + }); + const b = new File(["hello"], "a.png", { + type: "image/png", + lastModified: 123, + }); expect(imageFileKey(a)).toBe(imageFileKey(b)); }); test("two files with the same name but different content produce different keys", () => { - const a = new File(["hello"], "a.png", { type: "image/png" }); - const b = new File(["hello world"], "a.png", { type: "image/png" }); + const a = new File(["hello"], "a.png", { + type: "image/png", + lastModified: 123, + }); + const b = new File(["hello world"], "a.png", { + type: "image/png", + lastModified: 123, + }); expect(imageFileKey(a)).not.toBe(imageFileKey(b)); }); test("two files with different names but same content produce different keys", () => { - const a = new File(["hello"], "a.png", { type: "image/png" }); - const b = new File(["hello"], "b.png", { type: "image/png" }); + const a = new File(["hello"], "a.png", { + type: "image/png", + lastModified: 123, + }); + const b = new File(["hello"], "b.png", { + type: "image/png", + lastModified: 123, + }); + expect(imageFileKey(a)).not.toBe(imageFileKey(b)); + }); + + test("clipboard fallback files with the same fallback name and size stay distinct when lastModified differs", () => { + const a = new File(["abcd"], "clipboard-image", { + type: "image/png", + lastModified: 100, + }); + const b = new File(["wxyz"], "clipboard-image", { + type: "image/png", + lastModified: 101, + }); + + expect(a.size).toBe(b.size); expect(imageFileKey(a)).not.toBe(imageFileKey(b)); }); }); describe("appendImagesDeduped – append with deduplication", () => { - const makeFile = (name: string, content = "data") => - new File([content], name, { type: "image/png" }); + const makeFile = ( + name: string, + content = "data", + lastModified = 123, + ) => new File([content], name, { type: "image/png", lastModified }); test("appends new files to an empty list", () => { const result = appendImagesDeduped([], [makeFile("a.png")]); @@ -100,4 +139,18 @@ describe("appendImagesDeduped – append with deduplication", () => { expect(result).toHaveLength(2); expect(result.map((f) => f.name)).toEqual(["a.png", "b.png"]); }); + + test("keeps distinct clipboard fallback files that share the same name and byte size", () => { + const existing: File[] = []; + const incoming = [ + makeFile("clipboard-image", "abcd", 100), + makeFile("clipboard-image", "wxyz", 101), + ]; + + const result = appendImagesDeduped(existing, incoming); + + expect(result).toHaveLength(2); + expect(result[0].lastModified).toBe(100); + expect(result[1].lastModified).toBe(101); + }); });