fix(web): warn when pasted attachments are too large (#1983)
* fix(web): warn when pasted attachment is too large * test(web): provide toast context to chat input tests * fix(web): warn when browser image paste lacks file data * fix(web): warn about oversized new task attachments * fix(web): keep toasts above modal overlays * fix(web): handle file-list-only dialog pastes * fix(web): avoid parsing clipboard HTML * fix(web): handle attachment paste edge cases * fix(web): improve attachment limit feedback * fix(web): show aggregate attachment limit feedback
This commit is contained in:
@@ -1,10 +1,21 @@
|
||||
import { createRef, type ReactNode } from "react";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { createRef, StrictMode, type ReactNode } from "react";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { MAX_FILES, MAX_TOTAL_SIZE, processFile } from "@/components/task/chat/file-attachment";
|
||||
import { TaskFormInputs } from "./task-create-dialog-selectors";
|
||||
import type { TaskFormInputsHandle } from "./task-create-dialog-types";
|
||||
|
||||
const TOAST_MESSAGE_TEST_ID = "toast-message";
|
||||
|
||||
vi.mock("@/components/task/chat/file-attachment", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/components/task/chat/file-attachment")>(
|
||||
"@/components/task/chat/file-attachment",
|
||||
);
|
||||
return { ...actual, processFile: vi.fn() };
|
||||
});
|
||||
|
||||
// Capture the props (notably `onTranscript` / `onAutoSend`) that
|
||||
// TaskFormInputs hands the voice button so we can drive transcripts
|
||||
// without instantiating the real VoiceInputButton (which subscribes to
|
||||
@@ -43,6 +54,8 @@ vi.mock("@/hooks/use-task-create-prompt-mention", () => ({
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
voiceCalls.length = 0;
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(processFile).mockReset();
|
||||
});
|
||||
|
||||
function lastVoiceProps(): VoiceProps {
|
||||
@@ -52,12 +65,16 @@ function lastVoiceProps(): VoiceProps {
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <TooltipProvider>{children}</TooltipProvider>;
|
||||
return (
|
||||
<ToastProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTaskFormInputs(initial: string) {
|
||||
function renderTaskFormInputs(initial: string, strict = false) {
|
||||
const ref = createRef<TaskFormInputsHandle>();
|
||||
const utils = render(
|
||||
const form = (
|
||||
<TaskFormInputs
|
||||
isSessionMode={false}
|
||||
autoFocus={false}
|
||||
@@ -65,9 +82,9 @@ function renderTaskFormInputs(initial: string) {
|
||||
onDescriptionChange={() => {}}
|
||||
onKeyDown={() => {}}
|
||||
descriptionValueRef={ref}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
/>
|
||||
);
|
||||
const utils = render(strict ? <StrictMode>{form}</StrictMode> : form, { wrapper: Wrapper });
|
||||
const textarea = screen.getByTestId("task-description-input") as HTMLTextAreaElement;
|
||||
return { ...utils, textarea, ref };
|
||||
}
|
||||
@@ -212,3 +229,111 @@ describe("TaskFormInputs voice-input wiring — at-cursor splice", () => {
|
||||
expect(textarea.value).toBe("line\ntwo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskFormInputs attachment feedback", () => {
|
||||
it("shows one count-limit toast when Strict Mode replays an attachment-limit update", async () => {
|
||||
vi.mocked(processFile).mockImplementation(async (file) => ({
|
||||
id: file.name,
|
||||
data: "",
|
||||
mimeType: file.type,
|
||||
fileName: file.name,
|
||||
size: file.size,
|
||||
isImage: false,
|
||||
deliveryMode: "path",
|
||||
}));
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { textarea } = renderTaskFormInputs("", true);
|
||||
const files = Array.from(
|
||||
{ length: MAX_FILES + 1 },
|
||||
(_, index) => new File(["file"], `attachment-${index}.txt`, { type: "text/plain" }),
|
||||
);
|
||||
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
files,
|
||||
items: files.map((file) => ({ kind: "file", type: file.type, getAsFile: () => file })),
|
||||
getData: () => "",
|
||||
},
|
||||
});
|
||||
|
||||
const warning = await screen.findByTestId(TOAST_MESSAGE_TEST_ID);
|
||||
expect(warning.textContent).toContain("Attachment limit reached");
|
||||
expect(warning.textContent).toContain(`You can attach up to ${MAX_FILES} files.`);
|
||||
expect(screen.getAllByTestId(TOAST_MESSAGE_TEST_ID)).toHaveLength(1);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a total-size toast when pasted attachments exceed the aggregate limit", async () => {
|
||||
vi.mocked(processFile).mockImplementation(async (file) => ({
|
||||
id: file.name,
|
||||
data: "",
|
||||
mimeType: file.type,
|
||||
fileName: file.name,
|
||||
size: file.size,
|
||||
isImage: false,
|
||||
deliveryMode: "path",
|
||||
}));
|
||||
const { textarea } = renderTaskFormInputs("");
|
||||
const fileSize = MAX_TOTAL_SIZE / 3 + 1;
|
||||
const files = Array.from({ length: 3 }, (_, index) => {
|
||||
const file = new File(["attachment"], `attachment-${index}.txt`, { type: "text/plain" });
|
||||
Object.defineProperty(file, "size", { value: fileSize });
|
||||
return file;
|
||||
});
|
||||
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
files,
|
||||
items: files.map((file) => ({ kind: "file", type: file.type, getAsFile: () => file })),
|
||||
getData: () => "",
|
||||
},
|
||||
});
|
||||
|
||||
const warning = await screen.findByTestId(TOAST_MESSAGE_TEST_ID);
|
||||
expect(warning.textContent).toContain("Attachment limit reached");
|
||||
expect(warning.textContent).toContain(
|
||||
`Attachments can total up to ${MAX_TOTAL_SIZE / 1024 / 1024} MB.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when a pasted image exceeds the attachment limit", async () => {
|
||||
const { textarea } = renderTaskFormInputs("");
|
||||
const image = new File(["image"], "copied-image.png", { type: "image/png" });
|
||||
Object.defineProperty(image, "size", { value: 14 * 1024 * 1024 });
|
||||
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
files: [image],
|
||||
items: [{ kind: "file", type: image.type, getAsFile: () => image }],
|
||||
getData: () => "",
|
||||
},
|
||||
});
|
||||
|
||||
const warning = await screen.findByTestId(TOAST_MESSAGE_TEST_ID);
|
||||
expect(warning.textContent).toContain("Attachment is too large");
|
||||
expect(warning.textContent).toContain(
|
||||
"copied-image.png is 14 MB. The maximum file size is 10 MB.",
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when Chrome exposes a copied image without readable file data", async () => {
|
||||
const { textarea } = renderTaskFormInputs("");
|
||||
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
files: [],
|
||||
items: [{ kind: "string", type: "text/html" }],
|
||||
getData: (type: string) =>
|
||||
type === "text/html"
|
||||
? '<meta charset="utf-8"><img src="https://images.example.test/copied-image.png">'
|
||||
: "",
|
||||
},
|
||||
});
|
||||
|
||||
const warning = await screen.findByTestId(TOAST_MESSAGE_TEST_ID);
|
||||
expect(warning.textContent).toContain("Pasted image couldn’t be attached");
|
||||
expect(warning.textContent).toContain(
|
||||
"The browser didn’t provide image data. Save the image, then attach the file instead.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,16 @@ import {
|
||||
MAX_TOTAL_SIZE,
|
||||
type FileAttachment,
|
||||
} from "@/components/task/chat/file-attachment";
|
||||
import {
|
||||
useAttachmentCountFeedback,
|
||||
useAttachmentFileFeedback,
|
||||
useAttachmentTotalSizeFeedback,
|
||||
useUnreadablePastedImageFeedback,
|
||||
} from "@/components/task/chat/use-attachment-file-feedback";
|
||||
import {
|
||||
readClipboardAttachments,
|
||||
type ImagePasteIssue,
|
||||
} from "@/components/task/chat/clipboard-attachments";
|
||||
import { ContextZone } from "@/components/task/chat/context-items/context-zone";
|
||||
import { MentionMenu } from "@/components/task/chat/mention-menu";
|
||||
import type { ContextItem, ImageContextItem, FileAttachmentContextItem } from "@/lib/types/context";
|
||||
@@ -30,6 +40,29 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const CURSOR_POINTER_CLASS = "cursor-pointer";
|
||||
|
||||
type AttachmentLimitRejection = "count" | "total-size" | null;
|
||||
|
||||
function acceptAttachmentsWithinLimits(
|
||||
existing: FileAttachment[],
|
||||
processed: FileAttachment[],
|
||||
): { accepted: FileAttachment[]; rejection: AttachmentLimitRejection } {
|
||||
let nextCount = existing.length;
|
||||
let nextTotalSize = existing.reduce((sum, att) => sum + att.size, 0);
|
||||
const accepted: FileAttachment[] = [];
|
||||
|
||||
for (const att of processed) {
|
||||
if (nextCount >= MAX_FILES) return { accepted, rejection: "count" };
|
||||
if (nextTotalSize + att.size > MAX_TOTAL_SIZE) {
|
||||
return { accepted, rejection: "total-size" };
|
||||
}
|
||||
accepted.push(att);
|
||||
nextCount += 1;
|
||||
nextTotalSize += att.size;
|
||||
}
|
||||
|
||||
return { accepted, rejection: null };
|
||||
}
|
||||
|
||||
type RepositoryOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -318,38 +351,54 @@ type TaskFormInputsProps = {
|
||||
|
||||
function useFileAttachments() {
|
||||
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
|
||||
const attachmentsRef = useRef<FileAttachment[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const warnAttachmentCountLimit = useAttachmentCountFeedback();
|
||||
const rejectOversizedFile = useAttachmentFileFeedback();
|
||||
const warnAttachmentTotalSizeLimit = useAttachmentTotalSizeFeedback();
|
||||
const warnUnreadablePastedImage = useUnreadablePastedImageFeedback();
|
||||
|
||||
const addFiles = useCallback(async (files: File[]) => {
|
||||
const processed: FileAttachment[] = [];
|
||||
for (const file of files) {
|
||||
const attachment = await processFile(file);
|
||||
if (attachment) processed.push(attachment);
|
||||
}
|
||||
if (processed.length === 0) return;
|
||||
const addFiles = useCallback(
|
||||
async (files: File[], issue?: ImagePasteIssue) => {
|
||||
if (issue === "unreadable-image") {
|
||||
warnUnreadablePastedImage();
|
||||
return;
|
||||
}
|
||||
const processed: FileAttachment[] = [];
|
||||
for (const file of files) {
|
||||
if (rejectOversizedFile(file)) continue;
|
||||
const attachment = await processFile(file);
|
||||
if (attachment) processed.push(attachment);
|
||||
}
|
||||
if (processed.length === 0) return;
|
||||
|
||||
setAttachments((prev) => {
|
||||
let nextCount = prev.length;
|
||||
let nextTotalSize = prev.reduce((sum, att) => sum + att.size, 0);
|
||||
const accepted: FileAttachment[] = [];
|
||||
for (const att of processed) {
|
||||
if (nextCount >= MAX_FILES) break;
|
||||
if (nextTotalSize + att.size > MAX_TOTAL_SIZE) break;
|
||||
accepted.push(att);
|
||||
nextCount += 1;
|
||||
nextTotalSize += att.size;
|
||||
const { accepted, rejection } = acceptAttachmentsWithinLimits(
|
||||
attachmentsRef.current,
|
||||
processed,
|
||||
);
|
||||
if (rejection === "count") {
|
||||
warnAttachmentCountLimit();
|
||||
} else if (rejection === "total-size") {
|
||||
warnAttachmentTotalSizeLimit();
|
||||
}
|
||||
if (nextCount >= MAX_FILES && accepted.length < processed.length) {
|
||||
console.warn(`Maximum ${MAX_FILES} files allowed`);
|
||||
} else if (accepted.length < processed.length) {
|
||||
console.warn(`Total attachment size limit exceeded (max: ${formatBytes(MAX_TOTAL_SIZE)})`);
|
||||
}
|
||||
return accepted.length > 0 ? [...prev, ...accepted] : prev;
|
||||
});
|
||||
}, []);
|
||||
if (accepted.length === 0) return;
|
||||
|
||||
const next = [...attachmentsRef.current, ...accepted];
|
||||
attachmentsRef.current = next;
|
||||
setAttachments(next);
|
||||
},
|
||||
[
|
||||
rejectOversizedFile,
|
||||
warnAttachmentCountLimit,
|
||||
warnAttachmentTotalSizeLimit,
|
||||
warnUnreadablePastedImage,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRemoveAttachment = useCallback((id: string) => {
|
||||
setAttachments((prev) => prev.filter((att) => att.id !== id));
|
||||
const next = attachmentsRef.current.filter((att) => att.id !== id);
|
||||
attachmentsRef.current = next;
|
||||
setAttachments(next);
|
||||
}, []);
|
||||
|
||||
return { attachments, isDragging, setIsDragging, addFiles, handleRemoveAttachment };
|
||||
@@ -357,24 +406,16 @@ function useFileAttachments() {
|
||||
|
||||
function useAttachmentHandlers(
|
||||
disabled: boolean | undefined,
|
||||
addFiles: (files: File[]) => Promise<void>,
|
||||
addFiles: (files: File[], issue?: ImagePasteIssue) => Promise<void>,
|
||||
setIsDragging: (v: boolean) => void,
|
||||
) {
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
if (disabled) return;
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const files: File[] = [];
|
||||
for (const item of items) {
|
||||
if (item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
}
|
||||
if (files.length > 0) {
|
||||
const { files, issue } = readClipboardAttachments(e.clipboardData);
|
||||
if (files.length > 0 || issue) {
|
||||
e.preventDefault();
|
||||
void addFiles(files);
|
||||
void addFiles(files, issue);
|
||||
}
|
||||
},
|
||||
[disabled, addFiles],
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ChatInputToolbar } from "./chat-input-toolbar";
|
||||
import { ContextZone } from "./context-items/context-zone";
|
||||
import type { ContextItem } from "@/lib/types/context";
|
||||
import type { ContextFile } from "@/lib/state/context-files-store";
|
||||
import type { ImagePasteIssue } from "./clipboard-attachments";
|
||||
|
||||
export type ChatInputEditorAreaProps = {
|
||||
inputRef: React.RefObject<import("./tiptap-input").TipTapInputHandle | null>;
|
||||
@@ -32,7 +33,7 @@ export type ChatInputEditorAreaProps = {
|
||||
onAddContextFile?: (file: ContextFile) => void;
|
||||
onToggleContextFile?: (file: ContextFile) => void;
|
||||
planContextEnabled: boolean;
|
||||
addFiles: (files: File[]) => Promise<void>;
|
||||
addFiles: (files: File[], issue?: ImagePasteIssue) => Promise<void>;
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
showRequestChangesTooltip: boolean;
|
||||
hideSessionsDropdown?: boolean;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { readClipboardAttachments } from "./clipboard-attachments";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function clipboardData(html: string, plainText = ""): DataTransfer {
|
||||
return {
|
||||
files: [],
|
||||
items: [],
|
||||
getData: (type: string) => (type === "text/html" ? html : plainText),
|
||||
} as unknown as DataTransfer;
|
||||
}
|
||||
|
||||
describe("readClipboardAttachments", () => {
|
||||
it("detects image-only HTML without parsing clipboard markup into a DOM", () => {
|
||||
const parseFromString = vi.spyOn(DOMParser.prototype, "parseFromString");
|
||||
|
||||
expect(
|
||||
readClipboardAttachments(clipboardData('<img src="https://example.test/image.png">')),
|
||||
).toEqual({
|
||||
files: [],
|
||||
issue: "unreadable-image",
|
||||
});
|
||||
expect(parseFromString).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves image HTML with visible clipboard text to the editor", () => {
|
||||
expect(
|
||||
readClipboardAttachments(
|
||||
clipboardData(
|
||||
'<p>Visible caption <img src="https://example.test/image.png"></p>',
|
||||
"Visible caption",
|
||||
),
|
||||
),
|
||||
).toEqual({ files: [] });
|
||||
});
|
||||
|
||||
it.each(["<IMG>", "<img/>", "<img\nsrc='https://example.test/image.png'>"])(
|
||||
"recognizes an image tag in %s",
|
||||
(html) => {
|
||||
expect(readClipboardAttachments(clipboardData(html))).toEqual({
|
||||
files: [],
|
||||
issue: "unreadable-image",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["<image>", "<imgfoo>"])("does not mistake %s for an image tag", (html) => {
|
||||
expect(readClipboardAttachments(clipboardData(html))).toEqual({ files: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
export type ImagePasteIssue = "unreadable-image";
|
||||
|
||||
export function readClipboardAttachments(clipboardData: DataTransfer): {
|
||||
files: File[];
|
||||
issue?: ImagePasteIssue;
|
||||
} {
|
||||
const listedFiles = Array.from(clipboardData.files);
|
||||
if (listedFiles.length > 0) return { files: listedFiles };
|
||||
|
||||
const itemFiles: File[] = [];
|
||||
for (const item of clipboardData.items) {
|
||||
if (item.kind !== "file") continue;
|
||||
const file = item.getAsFile();
|
||||
if (file) itemFiles.push(file);
|
||||
}
|
||||
if (itemFiles.length > 0) return { files: itemFiles };
|
||||
|
||||
const hasImageItem = Array.from(clipboardData.items).some((item) =>
|
||||
item.type.startsWith("image/"),
|
||||
);
|
||||
if (hasImageItem || hasImageOnlyHtml(clipboardData)) {
|
||||
return { files: [], issue: "unreadable-image" };
|
||||
}
|
||||
return { files: [] };
|
||||
}
|
||||
|
||||
function hasImageOnlyHtml(clipboardData: DataTransfer): boolean {
|
||||
if (clipboardData.getData("text/plain").trim()) return false;
|
||||
return /<img(?=[\t\n\f\r />])/i.test(clipboardData.getData("text/html"));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getMarkdownText, textToEditorContent } from "./tiptap-helpers";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import { getMarkdownText, handleEditorPaste, textToEditorContent } from "./tiptap-helpers";
|
||||
import * as tiptapHelpers from "./tiptap-helpers";
|
||||
|
||||
describe("entity reference trigger", () => {
|
||||
@@ -246,3 +247,119 @@ describe("textToEditorContent", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const PNG_MIME_TYPE = "image/png";
|
||||
|
||||
describe("handleEditorPaste", () => {
|
||||
it("uses clipboard files when item conversion does not provide a file", () => {
|
||||
const image = new File(["image"], "copied.png", { type: PNG_MIME_TYPE });
|
||||
const onImagePaste = vi.fn();
|
||||
const preventDefault = vi.fn();
|
||||
const event = {
|
||||
clipboardData: {
|
||||
files: [image],
|
||||
items: [],
|
||||
getData: () => "",
|
||||
},
|
||||
preventDefault,
|
||||
} as unknown as ClipboardEvent;
|
||||
|
||||
const handled = handleEditorPaste({} as EditorView, event, {
|
||||
current: onImagePaste,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(onImagePaste).toHaveBeenCalledWith([image]);
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to a readable file item when the clipboard file list is empty", () => {
|
||||
const image = new File(["image"], "copied.png", { type: PNG_MIME_TYPE });
|
||||
const onImagePaste = vi.fn();
|
||||
const preventDefault = vi.fn();
|
||||
const event = {
|
||||
clipboardData: {
|
||||
files: [],
|
||||
items: [{ kind: "file", type: PNG_MIME_TYPE, getAsFile: () => image }],
|
||||
getData: () => "",
|
||||
},
|
||||
preventDefault,
|
||||
} as unknown as ClipboardEvent;
|
||||
|
||||
const handled = handleEditorPaste({} as EditorView, event, {
|
||||
current: onImagePaste,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(onImagePaste).toHaveBeenCalledWith([image]);
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not duplicate a file mirrored in the clipboard file list and items", () => {
|
||||
const image = new File(["image"], "copied.png", { type: PNG_MIME_TYPE });
|
||||
const onImagePaste = vi.fn();
|
||||
const event = {
|
||||
clipboardData: {
|
||||
files: [image],
|
||||
items: [{ kind: "file", type: PNG_MIME_TYPE, getAsFile: () => image }],
|
||||
getData: () => "",
|
||||
},
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as ClipboardEvent;
|
||||
|
||||
handleEditorPaste({} as EditorView, event, {
|
||||
current: onImagePaste,
|
||||
});
|
||||
|
||||
expect(onImagePaste).toHaveBeenCalledOnce();
|
||||
expect(onImagePaste).toHaveBeenCalledWith([image]);
|
||||
});
|
||||
|
||||
it("reports image-only HTML when the browser exposes no readable file", () => {
|
||||
const onImagePaste = vi.fn();
|
||||
const preventDefault = vi.fn();
|
||||
const event = {
|
||||
clipboardData: {
|
||||
files: [],
|
||||
items: [{ kind: "string", type: "text/html" }],
|
||||
getData: (type: string) =>
|
||||
type === "text/html"
|
||||
? '<meta charset="utf-8"><style>img { max-width: 100%; }</style><img src="https://images.example.test/large-image.png" alt="">'
|
||||
: "",
|
||||
},
|
||||
preventDefault,
|
||||
} as unknown as ClipboardEvent;
|
||||
|
||||
const handled = handleEditorPaste({} as EditorView, event, {
|
||||
current: onImagePaste,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(onImagePaste).toHaveBeenCalledWith([], "unreadable-image");
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("leaves rich HTML with visible text to the editor", () => {
|
||||
const onImagePaste = vi.fn();
|
||||
const preventDefault = vi.fn();
|
||||
const event = {
|
||||
clipboardData: {
|
||||
files: [],
|
||||
items: [{ kind: "string", type: "text/html" }],
|
||||
getData: (type: string) =>
|
||||
type === "text/html"
|
||||
? '<p>Visible caption <img src="https://images.example.test/image.png"></p>'
|
||||
: "Visible caption",
|
||||
},
|
||||
preventDefault,
|
||||
} as unknown as ClipboardEvent;
|
||||
|
||||
const handled = handleEditorPaste({} as EditorView, event, {
|
||||
current: onImagePaste,
|
||||
});
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(onImagePaste).not.toHaveBeenCalled();
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { EntityReference } from "@/lib/types/entity-reference";
|
||||
import { isEntityReference } from "@/lib/entity-references/message-references";
|
||||
import { formatSlashCommandLabel, normalizeSlashCommandName } from "./tiptap-slash-command-utils";
|
||||
import type { SlashCommand } from "./slash-command-types";
|
||||
import { readClipboardAttachments, type ImagePasteIssue } from "./clipboard-attachments";
|
||||
|
||||
// ── JSON node types ─────────────────────────────────────────────────
|
||||
|
||||
@@ -287,17 +288,6 @@ export function parseCodeFences(text: string): FenceSegment[] {
|
||||
|
||||
// ── Paste handler ───────────────────────────────────────────────────
|
||||
|
||||
function extractFiles(items: DataTransferItemList): File[] {
|
||||
const files: File[] = [];
|
||||
for (const item of items) {
|
||||
if (item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function segmentToNodes(
|
||||
seg: FenceSegment,
|
||||
schema: import("@tiptap/pm/model").Schema,
|
||||
@@ -332,21 +322,26 @@ function insertCodeFenceNodes(
|
||||
export function handleEditorPaste(
|
||||
view: import("@tiptap/pm/view").EditorView,
|
||||
event: ClipboardEvent,
|
||||
onImagePasteRef: React.RefObject<((files: File[]) => void) | undefined>,
|
||||
onImagePasteRef: React.RefObject<((files: File[], issue?: ImagePasteIssue) => void) | undefined>,
|
||||
): boolean {
|
||||
// 1. File paste (images and other files)
|
||||
const items = event.clipboardData?.items;
|
||||
if (items) {
|
||||
const files = extractFiles(items);
|
||||
const clipboardData = event.clipboardData;
|
||||
if (clipboardData) {
|
||||
const { files, issue } = readClipboardAttachments(clipboardData);
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
onImagePasteRef.current?.(files);
|
||||
return true;
|
||||
}
|
||||
if (issue) {
|
||||
event.preventDefault();
|
||||
onImagePasteRef.current?.([], issue);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Markdown code fence paste
|
||||
const text = event.clipboardData?.getData("text/plain");
|
||||
const text = clipboardData?.getData("text/plain");
|
||||
if (text && text.includes("```")) {
|
||||
const segments = parseCodeFences(text);
|
||||
if (segments.some((s) => s.type === "code")) {
|
||||
|
||||
@@ -34,6 +34,7 @@ import type { MentionItem } from "@/hooks/use-inline-mention";
|
||||
import type { SlashCommand } from "./slash-command-types";
|
||||
import type { ContextFile } from "@/lib/state/context-files-store";
|
||||
import { useEntityReferenceComposer } from "./use-entity-reference-composer";
|
||||
import type { ImagePasteIssue } from "./clipboard-attachments";
|
||||
|
||||
export type { TipTapInputHandle } from "./use-tiptap-editor";
|
||||
|
||||
@@ -58,7 +59,7 @@ type TipTapInputProps = {
|
||||
onAddContextFile?: (file: ContextFile) => void;
|
||||
onToggleContextFile?: (file: ContextFile) => void;
|
||||
planContextEnabled?: boolean;
|
||||
onImagePaste?: (files: File[]) => void;
|
||||
onImagePaste?: (files: File[], issue?: ImagePasteIssue) => void;
|
||||
onPlanModeChange?: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useCallback } from "react";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { formatBytes, MAX_FILES, MAX_FILE_SIZE, MAX_TOTAL_SIZE } from "./file-attachment";
|
||||
|
||||
export function useAttachmentCountFeedback() {
|
||||
const { toast } = useToast();
|
||||
|
||||
return useCallback(() => {
|
||||
toast({
|
||||
title: "Attachment limit reached",
|
||||
description: `You can attach up to ${MAX_FILES} files.`,
|
||||
variant: "error",
|
||||
});
|
||||
}, [toast]);
|
||||
}
|
||||
|
||||
export function useAttachmentTotalSizeFeedback() {
|
||||
const { toast } = useToast();
|
||||
|
||||
return useCallback(() => {
|
||||
toast({
|
||||
title: "Attachment limit reached",
|
||||
description: `Attachments can total up to ${formatBytes(MAX_TOTAL_SIZE)}.`,
|
||||
variant: "error",
|
||||
});
|
||||
}, [toast]);
|
||||
}
|
||||
|
||||
export function useAttachmentFileFeedback() {
|
||||
const { toast } = useToast();
|
||||
|
||||
return useCallback(
|
||||
(file: File): boolean => {
|
||||
if (file.size <= MAX_FILE_SIZE) return false;
|
||||
|
||||
toast({
|
||||
title: "Attachment is too large",
|
||||
description: `${file.name} is ${formatBytes(file.size)}. The maximum file size is ${formatBytes(MAX_FILE_SIZE)}.`,
|
||||
variant: "error",
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
}
|
||||
|
||||
export function useUnreadablePastedImageFeedback() {
|
||||
const { toast } = useToast();
|
||||
|
||||
return useCallback(() => {
|
||||
toast({
|
||||
title: "Pasted image couldn’t be attached",
|
||||
description:
|
||||
"The browser didn’t provide image data. Save the image, then attach the file instead.",
|
||||
variant: "error",
|
||||
});
|
||||
}, [toast]);
|
||||
}
|
||||
@@ -1,34 +1,39 @@
|
||||
import { createRef } from "react";
|
||||
import React, { createRef } from "react";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { shouldShowChatFocusHint, useChatInputContainer } from "./use-chat-input-container";
|
||||
import type { ChatInputContainerHandle } from "./chat-input-container";
|
||||
|
||||
function renderInputState(overrides: Partial<Parameters<typeof useChatInputContainer>[0]> = {}) {
|
||||
return renderHook(() =>
|
||||
useChatInputContainer({
|
||||
ref: createRef<ChatInputContainerHandle>(),
|
||||
sessionId: "session-1",
|
||||
isSending: false,
|
||||
isStarting: false,
|
||||
isPreparingEnvironment: false,
|
||||
isMoving: false,
|
||||
isFailed: false,
|
||||
needsRecovery: false,
|
||||
executorUnavailable: false,
|
||||
isAgentBusy: false,
|
||||
hasAgentCommands: true,
|
||||
placeholder: undefined,
|
||||
contextItems: [],
|
||||
pendingClarification: null,
|
||||
onClarificationResolved: undefined,
|
||||
pendingCommentsByFile: undefined,
|
||||
hasContextComments: false,
|
||||
showRequestChangesTooltip: false,
|
||||
onRequestChangesTooltipDismiss: undefined,
|
||||
onSubmit: vi.fn(),
|
||||
...overrides,
|
||||
}),
|
||||
return renderHook(
|
||||
() =>
|
||||
useChatInputContainer({
|
||||
ref: createRef<ChatInputContainerHandle>(),
|
||||
sessionId: "session-1",
|
||||
isSending: false,
|
||||
isStarting: false,
|
||||
isPreparingEnvironment: false,
|
||||
isMoving: false,
|
||||
isFailed: false,
|
||||
needsRecovery: false,
|
||||
executorUnavailable: false,
|
||||
isAgentBusy: false,
|
||||
hasAgentCommands: true,
|
||||
placeholder: undefined,
|
||||
contextItems: [],
|
||||
pendingClarification: null,
|
||||
onClarificationResolved: undefined,
|
||||
pendingCommentsByFile: undefined,
|
||||
hasContextComments: false,
|
||||
showRequestChangesTooltip: false,
|
||||
onRequestChangesTooltipDismiss: undefined,
|
||||
onSubmit: vi.fn(),
|
||||
...overrides,
|
||||
}),
|
||||
{
|
||||
wrapper: ({ children }) => React.createElement(ToastProvider, null, children),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type React from "react";
|
||||
import { act, cleanup, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import React from "react";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { useChatInputState } from "./use-chat-input-state";
|
||||
import { MAX_FILES, MAX_FILE_SIZE, MAX_TOTAL_SIZE } from "./file-attachment";
|
||||
import type { TipTapInputHandle } from "./tiptap-input";
|
||||
import type { EntityReference } from "@/lib/types/entity-reference";
|
||||
|
||||
type SubmitHandler = Parameters<typeof useChatInputState>[0]["onSubmit"];
|
||||
|
||||
function renderInputState(onSubmit: SubmitHandler) {
|
||||
return renderHook(() =>
|
||||
useChatInputState({
|
||||
sessionId: "session-1",
|
||||
isSending: false,
|
||||
contextItems: [],
|
||||
showRequestChangesTooltip: false,
|
||||
onSubmit,
|
||||
}),
|
||||
return renderHook(
|
||||
() =>
|
||||
useChatInputState({
|
||||
sessionId: "session-1",
|
||||
isSending: false,
|
||||
contextItems: [],
|
||||
showRequestChangesTooltip: false,
|
||||
onSubmit,
|
||||
}),
|
||||
{
|
||||
wrapper: ({ children }) => React.createElement(ToastProvider, null, children),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,11 +58,26 @@ function deferred<T>() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("useChatInputState", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
function fileWithSize(name: string, size: number) {
|
||||
const file = new File(["attachment"], name, { type: "text/plain" });
|
||||
Object.defineProperty(file, "size", { value: size });
|
||||
return file;
|
||||
}
|
||||
|
||||
const attachmentCountLimitMessage = `You can attach up to ${MAX_FILES} files.`;
|
||||
|
||||
function toastMessage() {
|
||||
return screen.getByTestId("toast-message").textContent;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("useChatInputState", () => {
|
||||
it("keeps the draft when async submit reports failure", async () => {
|
||||
const onSubmit = vi
|
||||
.fn<(...args: Parameters<SubmitHandler>) => ReturnType<SubmitHandler>>()
|
||||
@@ -162,3 +183,89 @@ describe("useChatInputState", () => {
|
||||
expect(resetHeight).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChatInputState attachment feedback", () => {
|
||||
it("warns when a batch exceeds the maximum number of files", async () => {
|
||||
const { result } = renderInputState(vi.fn());
|
||||
const files = Array.from({ length: MAX_FILES + 1 }, (_, index) =>
|
||||
fileWithSize(`attachment-${index}.txt`, 1),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.addFiles(files);
|
||||
});
|
||||
|
||||
expect(result.current.attachments).toHaveLength(MAX_FILES);
|
||||
expect(toastMessage()).toContain(attachmentCountLimitMessage);
|
||||
});
|
||||
|
||||
it("warns when adding files after reaching the maximum number of files", async () => {
|
||||
const { result } = renderInputState(vi.fn());
|
||||
const files = Array.from({ length: MAX_FILES }, (_, index) =>
|
||||
fileWithSize(`attachment-${index}.txt`, 1),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.addFiles(files);
|
||||
});
|
||||
await waitFor(() => expect(result.current.attachments).toHaveLength(MAX_FILES));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.addFiles([fileWithSize("one-too-many.txt", 1)]);
|
||||
});
|
||||
|
||||
expect(result.current.attachments).toHaveLength(MAX_FILES);
|
||||
expect(toastMessage()).toContain(attachmentCountLimitMessage);
|
||||
});
|
||||
|
||||
it("accepts only files that fit within the total size limit and warns once", async () => {
|
||||
const { result } = renderInputState(vi.fn());
|
||||
const fileSize = MAX_TOTAL_SIZE / 3 + 1;
|
||||
const files = [
|
||||
fileWithSize("first.txt", fileSize),
|
||||
fileWithSize("second.txt", fileSize),
|
||||
fileWithSize("third.txt", fileSize),
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
await result.current.addFiles(files);
|
||||
});
|
||||
|
||||
expect(result.current.attachments).toHaveLength(2);
|
||||
expect(screen.getAllByTestId("toast-message")).toHaveLength(1);
|
||||
expect(toastMessage()).toContain("Attachment limit reached");
|
||||
expect(toastMessage()).toContain(
|
||||
`Attachments can total up to ${MAX_TOTAL_SIZE / 1024 / 1024} MB.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when a pasted attachment exceeds the file size limit", async () => {
|
||||
const { result } = renderInputState(vi.fn());
|
||||
const oversizedFile = new File(["video"], "recording.mov", { type: "video/quicktime" });
|
||||
Object.defineProperty(oversizedFile, "size", { value: 11 * 1024 * 1024 });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.addFiles([oversizedFile]);
|
||||
});
|
||||
|
||||
expect(toastMessage()).toContain("Attachment is too large");
|
||||
expect(toastMessage()).toContain(
|
||||
`recording.mov is 11 MB. The maximum file size is ${MAX_FILE_SIZE / 1024 / 1024} MB.`,
|
||||
);
|
||||
expect(result.current.attachments).toEqual([]);
|
||||
});
|
||||
|
||||
it("warns when a pasted image has no readable file data", async () => {
|
||||
const { result } = renderInputState(vi.fn());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.addFiles([], "unreadable-image");
|
||||
});
|
||||
|
||||
expect(toastMessage()).toContain("Pasted image couldn’t be attached");
|
||||
expect(toastMessage()).toContain(
|
||||
"The browser didn’t provide image data. Save the image, then attach the file instead.",
|
||||
);
|
||||
expect(result.current.attachments).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,12 @@ import {
|
||||
MAX_TOTAL_SIZE,
|
||||
type FileAttachment,
|
||||
} from "./file-attachment";
|
||||
import {
|
||||
useAttachmentCountFeedback,
|
||||
useAttachmentFileFeedback,
|
||||
useAttachmentTotalSizeFeedback,
|
||||
useUnreadablePastedImageFeedback,
|
||||
} from "./use-attachment-file-feedback";
|
||||
import type { ContextItem, ImageContextItem, FileAttachmentContextItem } from "@/lib/types/context";
|
||||
import type { DiffComment } from "@/lib/diff/types";
|
||||
import type {
|
||||
@@ -33,6 +39,7 @@ import type {
|
||||
MessageAttachment,
|
||||
} from "./chat-input-container";
|
||||
import type { TipTapInputHandle } from "./tiptap-input";
|
||||
import type { ImagePasteIssue } from "./clipboard-attachments";
|
||||
|
||||
type UseChatInputStateProps = {
|
||||
sessionId: string | null;
|
||||
@@ -203,6 +210,10 @@ function useAttachments(sessionId: string | null) {
|
||||
const [attachments, setAttachments] = useState<FileAttachment[]>(() =>
|
||||
sessionId ? getChatDraftAttachments(sessionId).map(restoreAttachmentPreview) : [],
|
||||
);
|
||||
const warnAttachmentCountLimit = useAttachmentCountFeedback();
|
||||
const rejectOversizedFile = useAttachmentFileFeedback();
|
||||
const warnAttachmentTotalSizeLimit = useAttachmentTotalSizeFeedback();
|
||||
const warnUnreadablePastedImage = useUnreadablePastedImageFeedback();
|
||||
const attachmentsRef = useRef(attachments);
|
||||
const prevSessionIdRef = useRef(sessionId);
|
||||
const prevPersistSessionIdRef = useRef(sessionId);
|
||||
@@ -232,23 +243,42 @@ function useAttachments(sessionId: string | null) {
|
||||
}, [attachments, sessionId]);
|
||||
|
||||
const addFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (attachments.length >= MAX_FILES) {
|
||||
console.warn(`Maximum ${MAX_FILES} files allowed`);
|
||||
async (files: File[], issue?: ImagePasteIssue) => {
|
||||
if (issue === "unreadable-image") {
|
||||
warnUnreadablePastedImage();
|
||||
return;
|
||||
}
|
||||
const currentTotalSize = attachments.reduce((sum, att) => sum + att.size, 0);
|
||||
if (attachments.length >= MAX_FILES) {
|
||||
warnAttachmentCountLimit();
|
||||
return;
|
||||
}
|
||||
let acceptedCount = attachments.length;
|
||||
let acceptedTotalSize = attachments.reduce((sum, att) => sum + att.size, 0);
|
||||
for (const file of files) {
|
||||
if (attachments.length >= MAX_FILES) break;
|
||||
if (currentTotalSize + file.size > MAX_TOTAL_SIZE) {
|
||||
console.warn("Total attachment size limit exceeded");
|
||||
if (acceptedCount >= MAX_FILES) {
|
||||
warnAttachmentCountLimit();
|
||||
break;
|
||||
}
|
||||
if (rejectOversizedFile(file)) continue;
|
||||
if (acceptedTotalSize + file.size > MAX_TOTAL_SIZE) {
|
||||
warnAttachmentTotalSizeLimit();
|
||||
break;
|
||||
}
|
||||
const attachment = await processFile(file);
|
||||
if (attachment) setAttachments((prev) => [...prev, attachment]);
|
||||
if (attachment) {
|
||||
acceptedCount += 1;
|
||||
acceptedTotalSize += attachment.size;
|
||||
setAttachments((prev) => [...prev, attachment]);
|
||||
}
|
||||
}
|
||||
},
|
||||
[attachments],
|
||||
[
|
||||
attachments,
|
||||
rejectOversizedFile,
|
||||
warnAttachmentCountLimit,
|
||||
warnAttachmentTotalSizeLimit,
|
||||
warnUnreadablePastedImage,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRemoveAttachment = useCallback((id: string) => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
textToHtml,
|
||||
handleEditorPaste,
|
||||
} from "./tiptap-helpers";
|
||||
import type { ImagePasteIssue } from "./clipboard-attachments";
|
||||
import { CodeBlockView } from "./tiptap-code-block-view";
|
||||
import { DynamicPlaceholder, updateDynamicPlaceholder } from "./tiptap-dynamic-placeholder";
|
||||
import { EntityReferenceNode } from "./tiptap-entity-reference-extension";
|
||||
@@ -65,7 +66,7 @@ type UseTipTapEditorOptions = {
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
sessionId: string | null;
|
||||
onImagePaste?: (files: File[]) => void;
|
||||
onImagePaste?: (files: File[], issue?: ImagePasteIssue) => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mentionSuggestion: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -185,7 +186,7 @@ function buildEditorProps(args: {
|
||||
className: string | undefined;
|
||||
onFocus: (() => void) | undefined;
|
||||
onBlur: (() => void) | undefined;
|
||||
onImagePasteRef: React.RefObject<((files: File[]) => void) | undefined>;
|
||||
onImagePasteRef: React.RefObject<((files: File[], issue?: ImagePasteIssue) => void) | undefined>;
|
||||
}) {
|
||||
return {
|
||||
attributes: {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { act, cleanup, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { MAX_FILES, MAX_FILE_SIZE, MAX_TOTAL_SIZE } from "./chat/file-attachment";
|
||||
import { useDialogAttachments } from "./session-dialog-shared";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const toastMessageTestId = "toast-message";
|
||||
|
||||
function fileWithSize(name: string, size: number) {
|
||||
const file = new File(["attachment"], name, { type: "text/plain" });
|
||||
Object.defineProperty(file, "size", { value: size });
|
||||
return file;
|
||||
}
|
||||
|
||||
describe("useDialogAttachments", () => {
|
||||
it("warns when an image clipboard item has no readable file", async () => {
|
||||
const preventDefault = vi.fn();
|
||||
const clipboardData = {
|
||||
files: [],
|
||||
items: [{ kind: "file", type: "image/png", getAsFile: () => null }],
|
||||
getData: () => "",
|
||||
} as unknown as DataTransfer;
|
||||
const { result } = renderHook(() => useDialogAttachments(false), {
|
||||
wrapper: ({ children }) => React.createElement(ToastProvider, null, children),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.handlePaste({
|
||||
clipboardData,
|
||||
preventDefault,
|
||||
} as unknown as React.ClipboardEvent<HTMLTextAreaElement>);
|
||||
});
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(screen.getByTestId(toastMessageTestId).textContent).toContain(
|
||||
"Pasted image couldn’t be attached",
|
||||
);
|
||||
expect(result.current.attachments).toEqual([]);
|
||||
});
|
||||
|
||||
it("warns for an oversized clipboard file when item conversion provides no file", async () => {
|
||||
const oversizedImage = new File(["image"], "copied.png", { type: "image/png" });
|
||||
Object.defineProperty(oversizedImage, "size", { value: MAX_FILE_SIZE + 1 });
|
||||
const preventDefault = vi.fn();
|
||||
const clipboardData = {
|
||||
files: [oversizedImage],
|
||||
items: [{ kind: "file", type: "image/png", getAsFile: () => null }],
|
||||
getData: () => "",
|
||||
} as unknown as DataTransfer;
|
||||
const { result } = renderHook(() => useDialogAttachments(false), {
|
||||
wrapper: ({ children }) => React.createElement(ToastProvider, null, children),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.handlePaste({
|
||||
clipboardData,
|
||||
preventDefault,
|
||||
} as unknown as React.ClipboardEvent<HTMLTextAreaElement>);
|
||||
});
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(screen.getByTestId(toastMessageTestId).textContent).toContain("Attachment is too large");
|
||||
expect(result.current.attachments).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps fitting attachments and warns once when a batch exceeds the file count", async () => {
|
||||
const { result } = renderHook(() => useDialogAttachments(false), {
|
||||
wrapper: ({ children }) => React.createElement(ToastProvider, null, children),
|
||||
});
|
||||
const files = Array.from({ length: MAX_FILES + 1 }, (_, index) =>
|
||||
fileWithSize(`attachment-${index}.txt`, 1),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFileInputChange({
|
||||
target: { files, value: "" },
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.attachments).toHaveLength(MAX_FILES));
|
||||
expect(result.current.attachments.map((attachment) => attachment.fileName)).toEqual(
|
||||
files.slice(0, MAX_FILES).map((file) => file.name),
|
||||
);
|
||||
expect(screen.getAllByTestId(toastMessageTestId)).toHaveLength(1);
|
||||
expect(screen.getByTestId(toastMessageTestId).textContent).toContain(
|
||||
`You can attach up to ${MAX_FILES} files.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps fitting attachments and warns once when a batch exceeds total size", async () => {
|
||||
const { result } = renderHook(() => useDialogAttachments(false), {
|
||||
wrapper: ({ children }) => React.createElement(ToastProvider, null, children),
|
||||
});
|
||||
const fileSize = MAX_TOTAL_SIZE / 3 + 1;
|
||||
const files = [
|
||||
fileWithSize("first.txt", fileSize),
|
||||
fileWithSize("second.txt", fileSize),
|
||||
fileWithSize("third.txt", fileSize),
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleFileInputChange({
|
||||
target: { files, value: "" },
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.attachments).toHaveLength(2));
|
||||
expect(result.current.attachments.map((attachment) => attachment.fileName)).toEqual([
|
||||
"first.txt",
|
||||
"second.txt",
|
||||
]);
|
||||
expect(screen.getAllByTestId(toastMessageTestId)).toHaveLength(1);
|
||||
expect(screen.getByTestId(toastMessageTestId).textContent).toContain(
|
||||
`Attachments can total up to ${MAX_TOTAL_SIZE / 1024 / 1024} MB.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,13 @@ import {
|
||||
MAX_TOTAL_SIZE,
|
||||
type FileAttachment,
|
||||
} from "./chat/file-attachment";
|
||||
import { readClipboardAttachments } from "./chat/clipboard-attachments";
|
||||
import {
|
||||
useAttachmentCountFeedback,
|
||||
useAttachmentFileFeedback,
|
||||
useAttachmentTotalSizeFeedback,
|
||||
useUnreadablePastedImageFeedback,
|
||||
} from "./chat/use-attachment-file-feedback";
|
||||
import type { ContextItem, ImageContextItem, FileAttachmentContextItem } from "@/lib/types/context";
|
||||
|
||||
export function EnvironmentBadges({
|
||||
@@ -54,6 +61,44 @@ export function EnvironmentBadges({
|
||||
|
||||
export type SessionOption = { id: string; label: string; index?: number; agentName?: string };
|
||||
|
||||
type AttachmentLimitRejection = "count" | "total-size" | null;
|
||||
|
||||
function appendAttachmentsWithinLimits(
|
||||
current: FileAttachment[],
|
||||
processed: FileAttachment[],
|
||||
): { attachments: FileAttachment[]; rejection: AttachmentLimitRejection } {
|
||||
let count = current.length;
|
||||
let totalSize = current.reduce((sum, attachment) => sum + attachment.size, 0);
|
||||
const accepted: FileAttachment[] = [];
|
||||
for (const attachment of processed) {
|
||||
if (count >= MAX_FILES) return { attachments: [...current, ...accepted], rejection: "count" };
|
||||
if (totalSize + attachment.size > MAX_TOTAL_SIZE) {
|
||||
return { attachments: [...current, ...accepted], rejection: "total-size" };
|
||||
}
|
||||
accepted.push(attachment);
|
||||
count += 1;
|
||||
totalSize += attachment.size;
|
||||
}
|
||||
return {
|
||||
attachments: accepted.length > 0 ? [...current, ...accepted] : current,
|
||||
rejection: null,
|
||||
};
|
||||
}
|
||||
|
||||
function useDialogFileInput(addFiles: (files: File[]) => Promise<void>) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const handleAttachClick = useCallback(() => fileInputRef.current?.click(), []);
|
||||
const handleFileInputChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0) void addFiles(Array.from(files));
|
||||
event.target.value = "";
|
||||
},
|
||||
[addFiles],
|
||||
);
|
||||
return { fileInputRef, handleAttachClick, handleFileInputChange };
|
||||
}
|
||||
|
||||
/** Unified context selector: Blank, Copy prompt, and per-session summarize options. */
|
||||
export function ContextSelect({
|
||||
value,
|
||||
@@ -134,49 +179,53 @@ export function ContextSelect({
|
||||
export function useDialogAttachments(disabled: boolean) {
|
||||
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const warnAttachmentCountLimit = useAttachmentCountFeedback();
|
||||
const rejectOversizedFile = useAttachmentFileFeedback();
|
||||
const warnAttachmentTotalSizeLimit = useAttachmentTotalSizeFeedback();
|
||||
const warnUnreadablePastedImage = useUnreadablePastedImageFeedback();
|
||||
const attachmentsRef = useRef<FileAttachment[]>([]);
|
||||
|
||||
const addFiles = useCallback(async (files: File[]) => {
|
||||
const processed: FileAttachment[] = [];
|
||||
for (const file of files) {
|
||||
const attachment = await processFile(file);
|
||||
if (attachment) processed.push(attachment);
|
||||
}
|
||||
if (processed.length === 0) return;
|
||||
setAttachments((prev) => {
|
||||
let count = prev.length;
|
||||
let totalSize = prev.reduce((s, a) => s + a.size, 0);
|
||||
const accepted: FileAttachment[] = [];
|
||||
for (const att of processed) {
|
||||
if (count >= MAX_FILES || totalSize + att.size > MAX_TOTAL_SIZE) break;
|
||||
accepted.push(att);
|
||||
count += 1;
|
||||
totalSize += att.size;
|
||||
const addFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
const processed: FileAttachment[] = [];
|
||||
for (const file of files) {
|
||||
if (rejectOversizedFile(file)) continue;
|
||||
const attachment = await processFile(file);
|
||||
if (attachment) processed.push(attachment);
|
||||
}
|
||||
return accepted.length > 0 ? [...prev, ...accepted] : prev;
|
||||
});
|
||||
}, []);
|
||||
if (processed.length === 0) return;
|
||||
const { attachments: next, rejection } = appendAttachmentsWithinLimits(
|
||||
attachmentsRef.current,
|
||||
processed,
|
||||
);
|
||||
attachmentsRef.current = next;
|
||||
setAttachments(next);
|
||||
if (rejection === "count") warnAttachmentCountLimit();
|
||||
if (rejection === "total-size") warnAttachmentTotalSizeLimit();
|
||||
},
|
||||
[rejectOversizedFile, warnAttachmentCountLimit, warnAttachmentTotalSizeLimit],
|
||||
);
|
||||
const { fileInputRef, handleAttachClick, handleFileInputChange } = useDialogFileInput(addFiles);
|
||||
|
||||
const handleRemoveAttachment = useCallback((id: string) => {
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
const next = attachmentsRef.current.filter((attachment) => attachment.id !== id);
|
||||
attachmentsRef.current = next;
|
||||
setAttachments(next);
|
||||
}, []);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
if (disabled) return;
|
||||
const files: File[] = [];
|
||||
for (const item of e.clipboardData?.items ?? []) {
|
||||
if (item.kind === "file") {
|
||||
const f = item.getAsFile();
|
||||
if (f) files.push(f);
|
||||
}
|
||||
}
|
||||
const { files, issue } = readClipboardAttachments(e.clipboardData);
|
||||
if (files.length > 0) {
|
||||
e.preventDefault();
|
||||
void addFiles(files);
|
||||
} else if (issue === "unreadable-image") {
|
||||
e.preventDefault();
|
||||
warnUnreadablePastedImage();
|
||||
}
|
||||
},
|
||||
[disabled, addFiles],
|
||||
[disabled, addFiles, warnUnreadablePastedImage],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
@@ -216,17 +265,6 @@ export function useDialogAttachments(disabled: boolean) {
|
||||
[disabled, addFiles],
|
||||
);
|
||||
|
||||
const handleAttachClick = useCallback(() => fileInputRef.current?.click(), []);
|
||||
|
||||
const handleFileInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) void addFiles(Array.from(files));
|
||||
e.target.value = "";
|
||||
},
|
||||
[addFiles],
|
||||
);
|
||||
|
||||
return {
|
||||
attachments,
|
||||
isDragging,
|
||||
|
||||
@@ -133,7 +133,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
function ToastList({ toasts }: { toasts: Toast[] }) {
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-[calc(1rem+var(--app-status-bar-height))] right-4 z-50 flex w-[360px] flex-col-reverse gap-2"
|
||||
className="fixed bottom-[calc(1rem+var(--app-status-bar-height))] right-4 z-[60] flex w-[360px] flex-col-reverse gap-2"
|
||||
data-testid="toast-container"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { openTaskSession, waitForLatestSessionDone } from "../../helpers/session";
|
||||
|
||||
test.describe("browser image paste feedback", () => {
|
||||
test("warns when copied image content has no readable file", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Unreadable pasted image warning",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
await waitForLatestSessionDone(apiClient, task.id, 1, "Wait for paste warning task");
|
||||
const session = await openTaskSession(testPage, task.id);
|
||||
await session.waitForChatIdle({ timeout: 30_000 });
|
||||
|
||||
const editor = session.activeChat().locator(".tiptap.ProseMirror").first();
|
||||
await expect(editor).toBeEditable();
|
||||
await editor.evaluate((element) => {
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.items.add(
|
||||
'<img src="https://images.example.test/large-image.png" alt="">',
|
||||
"text/html",
|
||||
);
|
||||
const pasteEvent = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(pasteEvent, "clipboardData", { value: clipboardData });
|
||||
element.dispatchEvent(pasteEvent);
|
||||
});
|
||||
|
||||
const warning = testPage
|
||||
.getByTestId("toast-message")
|
||||
.filter({ hasText: "Pasted image couldn’t be attached" });
|
||||
await expect(warning).toContainText("Save the image, then attach the file instead.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { openTaskSession, waitForLatestSessionDone } from "../../helpers/session";
|
||||
|
||||
const OVERSIZED_FILE_BYTES = 11 * 1024 * 1024;
|
||||
|
||||
test.describe("oversized attachment feedback on mobile", () => {
|
||||
test("warns when a pasted file exceeds the attachment limit", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Oversized pasted attachment warning",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
await waitForLatestSessionDone(apiClient, task.id, 1, "Wait for attachment warning task");
|
||||
const session = await openTaskSession(testPage, task.id);
|
||||
await session.waitForChatIdle({ timeout: 30_000 });
|
||||
|
||||
const editor = session.activeChat().locator(".tiptap.ProseMirror").first();
|
||||
await expect(editor).toBeEditable();
|
||||
await editor.evaluate((element, fileSize) => {
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.items.add(
|
||||
new File([new Uint8Array(fileSize)], "recording.mov", {
|
||||
type: "video/quicktime",
|
||||
}),
|
||||
);
|
||||
const pasteEvent = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(pasteEvent, "clipboardData", { value: clipboardData });
|
||||
element.dispatchEvent(pasteEvent);
|
||||
}, OVERSIZED_FILE_BYTES);
|
||||
|
||||
const warning = testPage
|
||||
.getByTestId("toast-message")
|
||||
.filter({ hasText: "Attachment is too large" });
|
||||
await expect(warning).toContainText("recording.mov is 11 MB. The maximum file size is 10 MB.");
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [warningBox, viewport] = await Promise.all([
|
||||
warning.boundingBox(),
|
||||
Promise.resolve(testPage.viewportSize()),
|
||||
]);
|
||||
return (
|
||||
warningBox !== null &&
|
||||
viewport !== null &&
|
||||
warningBox.x >= 0 &&
|
||||
warningBox.x + warningBox.width <= viewport.width
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() =>
|
||||
testPage.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
useRegularMode();
|
||||
|
||||
test("warns when a pasted image is too large for a new task", async ({ testPage }) => {
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
|
||||
const prompt = testPage.getByTestId("task-description-input");
|
||||
await expect(prompt).toBeEditable();
|
||||
await prompt.evaluate((element) => {
|
||||
const image = new File([new Uint8Array(14 * 1024 * 1024)], "copied-image.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.items.add(image);
|
||||
const pasteEvent = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(pasteEvent, "clipboardData", { value: clipboardData });
|
||||
element.dispatchEvent(pasteEvent);
|
||||
});
|
||||
|
||||
const warning = testPage
|
||||
.getByTestId("toast-message")
|
||||
.filter({ hasText: "Attachment is too large" });
|
||||
await expect(warning).toContainText("copied-image.png is 14 MB. The maximum file size is 10 MB.");
|
||||
|
||||
const overlay = testPage.locator('[data-slot="dialog-overlay"]:visible');
|
||||
await expect(overlay).toBeVisible();
|
||||
const [toastZIndex, overlayZIndex] = await Promise.all([
|
||||
testPage
|
||||
.getByTestId("toast-container")
|
||||
.evaluate((element) => Number.parseInt(getComputedStyle(element).zIndex, 10)),
|
||||
overlay.evaluate((element) => Number.parseInt(getComputedStyle(element).zIndex, 10)),
|
||||
]);
|
||||
expect(
|
||||
toastZIndex,
|
||||
"attachment warning should render above the task dialog overlay",
|
||||
).toBeGreaterThan(overlayZIndex);
|
||||
});
|
||||
Reference in New Issue
Block a user