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:
José Almeida
2026-07-27 17:19:02 +01:00
committed by GitHub
parent 5df06ad138
commit 85820e4a43
19 changed files with 1033 additions and 157 deletions
@@ -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();
});
});
+11 -16
View File
@@ -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,
+1 -1
View File
@@ -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);
});