fix(ai-toolbar): honor AI state in shortcuts and clipboard dedup
This commit is contained in:
@@ -310,6 +310,8 @@ export const AIToolbar = ({
|
|||||||
if (
|
if (
|
||||||
e.key === "Enter" &&
|
e.key === "Enter" &&
|
||||||
(e.metaKey || e.ctrlKey) &&
|
(e.metaKey || e.ctrlKey) &&
|
||||||
|
!aiLoading &&
|
||||||
|
canUseAi &&
|
||||||
(aiPrompt.trim() || hasImages)
|
(aiPrompt.trim() || hasImages)
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -8,14 +8,15 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a stable deduplication key for a File.
|
* Returns a stable deduplication key for a File.
|
||||||
* Key = `name:size` — cheap, deterministic, and catches re-selections of the
|
* Key = `name:size:lastModified` — cheap, deterministic, and catches
|
||||||
* exact same file (same name *and* same byte count).
|
* 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
|
* Two different files that happen to share a generated fallback name but have
|
||||||
* will have different sizes and therefore different keys.
|
* different timestamps will not collapse to the same key.
|
||||||
*/
|
*/
|
||||||
export function imageFileKey(file: File): string {
|
export function imageFileKey(file: File): string {
|
||||||
return `${file.name}:${file.size}`;
|
return `${file.name}:${file.size}:${file.lastModified}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -488,6 +488,30 @@ describe("AI toolbar paste capture", () => {
|
|||||||
expect(onAiCreate).toHaveBeenCalledTimes(1);
|
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 () => {
|
test("Shift+Mod+A opens the image picker when the composer is idle", async () => {
|
||||||
const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary();
|
const { textareaProps, imageTriggerOpen } = await renderToolbarBoundary();
|
||||||
const ctrlEvent = createTextareaKeydownEvent({
|
const ctrlEvent = createTextareaKeydownEvent({
|
||||||
|
|||||||
@@ -13,35 +13,74 @@ import {
|
|||||||
|
|
||||||
describe("imageFileKey – stable identity for dedup", () => {
|
describe("imageFileKey – stable identity for dedup", () => {
|
||||||
test("returns a string combining name and size", () => {
|
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);
|
const key = imageFileKey(file);
|
||||||
expect(typeof key).toBe("string");
|
expect(typeof key).toBe("string");
|
||||||
expect(key).toContain("flyer.png");
|
expect(key).toContain("flyer.png");
|
||||||
expect(key).toContain(String(file.size));
|
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", () => {
|
test("two files with the same name, size, and lastModified produce the same key", () => {
|
||||||
const a = new File(["hello"], "a.png", { type: "image/png" });
|
const a = new File(["hello"], "a.png", {
|
||||||
const b = new File(["hello"], "a.png", { type: "image/png" });
|
type: "image/png",
|
||||||
|
lastModified: 123,
|
||||||
|
});
|
||||||
|
const b = new File(["hello"], "a.png", {
|
||||||
|
type: "image/png",
|
||||||
|
lastModified: 123,
|
||||||
|
});
|
||||||
expect(imageFileKey(a)).toBe(imageFileKey(b));
|
expect(imageFileKey(a)).toBe(imageFileKey(b));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("two files with the same name but different content produce different keys", () => {
|
test("two files with the same name but different content produce different keys", () => {
|
||||||
const a = new File(["hello"], "a.png", { type: "image/png" });
|
const a = new File(["hello"], "a.png", {
|
||||||
const b = new File(["hello world"], "a.png", { type: "image/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));
|
expect(imageFileKey(a)).not.toBe(imageFileKey(b));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("two files with different names but same content produce different keys", () => {
|
test("two files with different names but same content produce different keys", () => {
|
||||||
const a = new File(["hello"], "a.png", { type: "image/png" });
|
const a = new File(["hello"], "a.png", {
|
||||||
const b = new File(["hello"], "b.png", { type: "image/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));
|
expect(imageFileKey(a)).not.toBe(imageFileKey(b));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("appendImagesDeduped – append with deduplication", () => {
|
describe("appendImagesDeduped – append with deduplication", () => {
|
||||||
const makeFile = (name: string, content = "data") =>
|
const makeFile = (
|
||||||
new File([content], name, { type: "image/png" });
|
name: string,
|
||||||
|
content = "data",
|
||||||
|
lastModified = 123,
|
||||||
|
) => new File([content], name, { type: "image/png", lastModified });
|
||||||
|
|
||||||
test("appends new files to an empty list", () => {
|
test("appends new files to an empty list", () => {
|
||||||
const result = appendImagesDeduped([], [makeFile("a.png")]);
|
const result = appendImagesDeduped([], [makeFile("a.png")]);
|
||||||
@@ -100,4 +139,18 @@ describe("appendImagesDeduped – append with deduplication", () => {
|
|||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
expect(result.map((f) => f.name)).toEqual(["a.png", "b.png"]);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user