diff --git a/apps/web/components/task-create-dialog-selectors.test.tsx b/apps/web/components/task-create-dialog-selectors.test.tsx index 043285af4a..a9fe157680 100644 --- a/apps/web/components/task-create-dialog-selectors.test.tsx +++ b/apps/web/components/task-create-dialog-selectors.test.tsx @@ -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( + "@/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 {children}; + return ( + + {children} + + ); } -function renderTaskFormInputs(initial: string) { +function renderTaskFormInputs(initial: string, strict = false) { const ref = createRef(); - const utils = render( + const form = ( {}} onKeyDown={() => {}} descriptionValueRef={ref} - />, - { wrapper: Wrapper }, + /> ); + const utils = render(strict ? {form} : 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" + ? '' + : "", + }, + }); + + 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.", + ); + }); +}); diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index c5ed8e5dc9..ed0a88cbd2 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -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([]); + const attachmentsRef = useRef([]); 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, + addFiles: (files: File[], issue?: ImagePasteIssue) => Promise, setIsDragging: (v: boolean) => void, ) { const handlePaste = useCallback( (e: React.ClipboardEvent) => { 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], diff --git a/apps/web/components/task/chat/chat-input-body.tsx b/apps/web/components/task/chat/chat-input-body.tsx index 9f38f6a0f0..68ed172293 100644 --- a/apps/web/components/task/chat/chat-input-body.tsx +++ b/apps/web/components/task/chat/chat-input-body.tsx @@ -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; @@ -32,7 +33,7 @@ export type ChatInputEditorAreaProps = { onAddContextFile?: (file: ContextFile) => void; onToggleContextFile?: (file: ContextFile) => void; planContextEnabled: boolean; - addFiles: (files: File[]) => Promise; + addFiles: (files: File[], issue?: ImagePasteIssue) => Promise; fileInputRef: React.RefObject; showRequestChangesTooltip: boolean; hideSessionsDropdown?: boolean; diff --git a/apps/web/components/task/chat/clipboard-attachments.test.ts b/apps/web/components/task/chat/clipboard-attachments.test.ts new file mode 100644 index 0000000000..9779e2b78c --- /dev/null +++ b/apps/web/components/task/chat/clipboard-attachments.test.ts @@ -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('')), + ).toEqual({ + files: [], + issue: "unreadable-image", + }); + expect(parseFromString).not.toHaveBeenCalled(); + }); + + it("leaves image HTML with visible clipboard text to the editor", () => { + expect( + readClipboardAttachments( + clipboardData( + '

Visible caption

', + "Visible caption", + ), + ), + ).toEqual({ files: [] }); + }); + + it.each(["", "", ""])( + "recognizes an image tag in %s", + (html) => { + expect(readClipboardAttachments(clipboardData(html))).toEqual({ + files: [], + issue: "unreadable-image", + }); + }, + ); + + it.each(["", ""])("does not mistake %s for an image tag", (html) => { + expect(readClipboardAttachments(clipboardData(html))).toEqual({ files: [] }); + }); +}); diff --git a/apps/web/components/task/chat/clipboard-attachments.ts b/apps/web/components/task/chat/clipboard-attachments.ts new file mode 100644 index 0000000000..b169207da9 --- /dev/null +++ b/apps/web/components/task/chat/clipboard-attachments.ts @@ -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 /])/i.test(clipboardData.getData("text/html")); +} diff --git a/apps/web/components/task/chat/tiptap-helpers.test.ts b/apps/web/components/task/chat/tiptap-helpers.test.ts index 2f092ba341..4959d78377 100644 --- a/apps/web/components/task/chat/tiptap-helpers.test.ts +++ b/apps/web/components/task/chat/tiptap-helpers.test.ts @@ -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" + ? '' + : "", + }, + 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" + ? '

Visible caption

' + : "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(); + }); +}); diff --git a/apps/web/components/task/chat/tiptap-helpers.ts b/apps/web/components/task/chat/tiptap-helpers.ts index e1b00a438e..92d08cac2d 100644 --- a/apps/web/components/task/chat/tiptap-helpers.ts +++ b/apps/web/components/task/chat/tiptap-helpers.ts @@ -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")) { diff --git a/apps/web/components/task/chat/tiptap-input.tsx b/apps/web/components/task/chat/tiptap-input.tsx index 18ffa150c0..b88aee5742 100644 --- a/apps/web/components/task/chat/tiptap-input.tsx +++ b/apps/web/components/task/chat/tiptap-input.tsx @@ -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; }; diff --git a/apps/web/components/task/chat/use-attachment-file-feedback.ts b/apps/web/components/task/chat/use-attachment-file-feedback.ts new file mode 100644 index 0000000000..af0d44ff21 --- /dev/null +++ b/apps/web/components/task/chat/use-attachment-file-feedback.ts @@ -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]); +} diff --git a/apps/web/components/task/chat/use-chat-input-container.test.ts b/apps/web/components/task/chat/use-chat-input-container.test.ts index bc06539509..c29c84c25b 100644 --- a/apps/web/components/task/chat/use-chat-input-container.test.ts +++ b/apps/web/components/task/chat/use-chat-input-container.test.ts @@ -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[0]> = {}) { - return renderHook(() => - useChatInputContainer({ - ref: createRef(), - 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(), + 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), + }, ); } diff --git a/apps/web/components/task/chat/use-chat-input-state.test.ts b/apps/web/components/task/chat/use-chat-input-state.test.ts index 7acb8f1067..a8a39242f8 100644 --- a/apps/web/components/task/chat/use-chat-input-state.test.ts +++ b/apps/web/components/task/chat/use-chat-input-state.test.ts @@ -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[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() { 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) => ReturnType>() @@ -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([]); + }); +}); diff --git a/apps/web/components/task/chat/use-chat-input-state.ts b/apps/web/components/task/chat/use-chat-input-state.ts index b7d1b41215..259e6fc372 100644 --- a/apps/web/components/task/chat/use-chat-input-state.ts +++ b/apps/web/components/task/chat/use-chat-input-state.ts @@ -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(() => 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) => { diff --git a/apps/web/components/task/chat/use-tiptap-editor.ts b/apps/web/components/task/chat/use-tiptap-editor.ts index dd85c592c2..91914e3f26 100644 --- a/apps/web/components/task/chat/use-tiptap-editor.ts +++ b/apps/web/components/task/chat/use-tiptap-editor.ts @@ -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: { diff --git a/apps/web/components/task/session-dialog-shared.test.tsx b/apps/web/components/task/session-dialog-shared.test.tsx new file mode 100644 index 0000000000..41f1da30b3 --- /dev/null +++ b/apps/web/components/task/session-dialog-shared.test.tsx @@ -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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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.`, + ); + }); +}); diff --git a/apps/web/components/task/session-dialog-shared.tsx b/apps/web/components/task/session-dialog-shared.tsx index 1d090acb94..ed83af7900 100644 --- a/apps/web/components/task/session-dialog-shared.tsx +++ b/apps/web/components/task/session-dialog-shared.tsx @@ -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) { + const fileInputRef = useRef(null); + const handleAttachClick = useCallback(() => fileInputRef.current?.click(), []); + const handleFileInputChange = useCallback( + (event: React.ChangeEvent) => { + 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([]); const [isDragging, setIsDragging] = useState(false); - const fileInputRef = useRef(null); + const warnAttachmentCountLimit = useAttachmentCountFeedback(); + const rejectOversizedFile = useAttachmentFileFeedback(); + const warnAttachmentTotalSizeLimit = useAttachmentTotalSizeFeedback(); + const warnUnreadablePastedImage = useUnreadablePastedImageFeedback(); + const attachmentsRef = useRef([]); - 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) => { 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) => { - const files = e.target.files; - if (files && files.length > 0) void addFiles(Array.from(files)); - e.target.value = ""; - }, - [addFiles], - ); - return { attachments, isDragging, diff --git a/apps/web/components/toast-provider.tsx b/apps/web/components/toast-provider.tsx index ebb2b4cd14..cf0b68194f 100644 --- a/apps/web/components/toast-provider.tsx +++ b/apps/web/components/toast-provider.tsx @@ -133,7 +133,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { function ToastList({ toasts }: { toasts: Toast[] }) { return (
{ + 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( + '', + "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."); + }); +}); diff --git a/apps/web/e2e/tests/chat/mobile-attachment-size-warning.spec.ts b/apps/web/e2e/tests/chat/mobile-attachment-size-warning.spec.ts new file mode 100644 index 0000000000..1aaf840431 --- /dev/null +++ b/apps/web/e2e/tests/chat/mobile-attachment-size-warning.spec.ts @@ -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); + }); +}); diff --git a/apps/web/e2e/tests/task/create-task-attachment-warning.spec.ts b/apps/web/e2e/tests/task/create-task-attachment-warning.spec.ts new file mode 100644 index 0000000000..7afcf7d584 --- /dev/null +++ b/apps/web/e2e/tests/task/create-task-attachment-warning.spec.ts @@ -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); +});