diff --git a/src/components/ai-toolbar.tsx b/src/components/ai-toolbar.tsx index 2440ee4..e7622f9 100644 --- a/src/components/ai-toolbar.tsx +++ b/src/components/ai-toolbar.tsx @@ -40,6 +40,15 @@ function useOs(): Os { return os; } +function createClipboardFallbackFile(blob: Blob, index: number): File { + const createdAt = Date.now() + index; + const extension = blob.type.split("/")[1] || "png"; + return new File([blob], `clipboard-image-${createdAt}.${extension}`, { + type: blob.type, + lastModified: createdAt, + }); +} + // ─── Shared shortcuts list (rendered in both HoverCard and Popover) ─────────── function ShortcutsList({ os }: { os: Os }) { @@ -220,9 +229,7 @@ export const AIToolbar = ({ for (const mimeType of typesToTry) { try { const blob = await clipboardItem.getType(mimeType); - files.push( - new File([blob], "clipboard-image", { type: mimeType }), - ); + files.push(createClipboardFallbackFile(blob, files.length)); break; // got this item, move to next clipboardItem } catch { // NotFoundError — type not present, try next @@ -310,6 +317,8 @@ export const AIToolbar = ({ if ( e.key === "Enter" && (e.metaKey || e.ctrlKey) && + !e.shiftKey && + !e.altKey && !aiLoading && canUseAi && (aiPrompt.trim() || hasImages) diff --git a/tests/ai-toolbar.test.ts b/tests/ai-toolbar.test.ts index a8f72b8..f25a282 100644 --- a/tests/ai-toolbar.test.ts +++ b/tests/ai-toolbar.test.ts @@ -488,6 +488,24 @@ describe("AI toolbar paste capture", () => { expect(onAiCreate).toHaveBeenCalledTimes(1); }); + test("Mod+Enter ignores extra modifiers so Shift+Ctrl+Enter does not generate", async () => { + const onAiCreate = mock(); + + const { textareaProps } = await renderToolbarBoundary({ + aiPrompt: "Draft a kickoff", + onAiCreate, + }); + const event = createTextareaKeydownEvent({ + ctrlKey: true, + shiftKey: true, + }); + + textareaProps.onKeyDown?.(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(onAiCreate).not.toHaveBeenCalled(); + }); + test("Mod+Enter does not trigger AI generation while loading", async () => { const onAiCreate = mock(); @@ -630,6 +648,41 @@ describe("AI toolbar paste capture", () => { expect(onImagesSelect.mock.calls[0]?.[0][0]?.type).toBe("image/png"); }); + test("clipboard fallback creates distinct files for same-sized clipboard images", async () => { + const onImagesSelect = mock(); + const clipboardRead = mock(async () => [ + { + types: ["image/png"], + getType: async () => new Blob(["abcd"], { type: "image/png" }), + }, + { + types: ["image/png"], + getType: async () => new Blob(["wxyz"], { type: "image/png" }), + }, + ]); + + (globalThis as { navigator?: Navigator }).navigator = { + clipboard: { read: clipboardRead }, + } as Navigator; + + await renderToolbar({ onImagesSelect }); + + const handleDocumentKeydown = getDocumentListener("keydown"); + + await handleDocumentKeydown( + createDocumentKeydownEvent({ ctrlKey: true }), + ); + + expect(onImagesSelect).toHaveBeenCalledTimes(1); + expect(onImagesSelect.mock.calls[0]?.[0]).toHaveLength(2); + expect(onImagesSelect.mock.calls[0]?.[0][0]?.name).not.toBe( + onImagesSelect.mock.calls[0]?.[0][1]?.name, + ); + expect(onImagesSelect.mock.calls[0]?.[0][0]?.size).toBe( + onImagesSelect.mock.calls[0]?.[0][1]?.size, + ); + }); + test("async clipboard fallback does not double-handle a paste already handled by the synchronous document paste flow", async () => { const onImagesSelect = mock(); const clipboardRead = mock(async () => [