fix(ai-toolbar): tighten modifier and clipboard fallback guards

This commit is contained in:
2026-04-23 04:47:11 -04:00
parent 470d76d46c
commit d9aa035dce
2 changed files with 65 additions and 3 deletions

View File

@@ -40,6 +40,15 @@ function useOs(): Os {
return 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) ─────────── // ─── Shared shortcuts list (rendered in both HoverCard and Popover) ───────────
function ShortcutsList({ os }: { os: Os }) { function ShortcutsList({ os }: { os: Os }) {
@@ -220,9 +229,7 @@ export const AIToolbar = ({
for (const mimeType of typesToTry) { for (const mimeType of typesToTry) {
try { try {
const blob = await clipboardItem.getType(mimeType); const blob = await clipboardItem.getType(mimeType);
files.push( files.push(createClipboardFallbackFile(blob, files.length));
new File([blob], "clipboard-image", { type: mimeType }),
);
break; // got this item, move to next clipboardItem break; // got this item, move to next clipboardItem
} catch { } catch {
// NotFoundError — type not present, try next // NotFoundError — type not present, try next
@@ -310,6 +317,8 @@ export const AIToolbar = ({
if ( if (
e.key === "Enter" && e.key === "Enter" &&
(e.metaKey || e.ctrlKey) && (e.metaKey || e.ctrlKey) &&
!e.shiftKey &&
!e.altKey &&
!aiLoading && !aiLoading &&
canUseAi && canUseAi &&
(aiPrompt.trim() || hasImages) (aiPrompt.trim() || hasImages)

View File

@@ -488,6 +488,24 @@ describe("AI toolbar paste capture", () => {
expect(onAiCreate).toHaveBeenCalledTimes(1); 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 () => { test("Mod+Enter does not trigger AI generation while loading", async () => {
const onAiCreate = mock(); const onAiCreate = mock();
@@ -630,6 +648,41 @@ describe("AI toolbar paste capture", () => {
expect(onImagesSelect.mock.calls[0]?.[0][0]?.type).toBe("image/png"); 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 () => { test("async clipboard fallback does not double-handle a paste already handled by the synchronous document paste flow", async () => {
const onImagesSelect = mock(); const onImagesSelect = mock();
const clipboardRead = mock(async () => [ const clipboardRead = mock(async () => [