diff --git a/ui/src/components/IssueAttachmentsSection.tsx b/ui/src/components/IssueAttachmentsSection.tsx index 5e1a590b27f..759bfa1ae1d 100644 --- a/ui/src/components/IssueAttachmentsSection.tsx +++ b/ui/src/components/IssueAttachmentsSection.tsx @@ -1,3 +1,4 @@ +import { FILE_DROP_TARGET_ATTRIBUTE } from "@/lib/file-drop-target"; import { TextAttachmentContext } from "@/context/TextAttachmentContext"; import { isTextAttachment } from "@/lib/issue-attachments"; import { useContext, useMemo, useState, type DragEvent, type ReactNode } from "react"; @@ -272,6 +273,7 @@ export function IssueAttachmentsSection({ "space-y-3 rounded-lg transition-colors", dragActive && "bg-primary/5", )} + {...(onDrop ? { [FILE_DROP_TARGET_ATTRIBUTE]: "" } : {})} onDragEnter={onDragEnter} onDragOver={onDragOver} onDragLeave={onDragLeave} diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 2d1521a3864..c05cc77413a 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -1,3 +1,4 @@ +import { FILE_DROP_TARGET_ATTRIBUTE } from "../lib/file-drop-target"; import { AgentAvatar } from "./AgentAvatar"; import { Component, @@ -1384,6 +1385,7 @@ export const MarkdownEditor = forwardRef return (
{ } finally { storage?.mockRestore(); act(() => root.unmount()); queryClient.clear(); } }); - it.each(["image/png", "", "application/octet-stream"])("shows staged images above text with MIME '%s' and uploads only retained files", async (type) => { + it.each([["image/png", "picker"], ["", "picker"], ["application/octet-stream", "picker"], ["image/png", "screen drop"]])("shows staged images with MIME '%s' from %s and uploads only retained files", async (type, source) => { dialogState.newIssueDefaults = { title: "Review image references" }; mockIssuesApi.create.mockResolvedValue({ id: "image-task", identifier: "PAP-1", companyId: "company-1" }); mockIssuesApi.uploadAttachment.mockResolvedValue({}); @@ -1860,9 +1860,16 @@ describe("NewIssueDialog", () => { await typeTextareaValue(editor, "Keep this caption"); const retained = new File(["image"], "reference.png", { type }); const removed = new File(["other"], "removed.png", { type: "image/png" }); - const input = container.querySelector('input[type="file"]')!; - Object.defineProperty(input, "files", { configurable: true, value: [retained, removed] }); - await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); }); + if (source === "screen drop") { + const event = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { value: { types: ["Files"], files: [retained, removed] } }); + await act(async () => { document.body.dispatchEvent(event); }); + expect(event.defaultPrevented).toBe(true); + } else { + const input = container.querySelector('input[type="file"]')!; + Object.defineProperty(input, "files", { configurable: true, value: [retained, removed] }); + await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); }); + } await flush(); const row = container.querySelector('[data-testid="task-chat-composer-images"]')!; expect(row.querySelectorAll("img")).toHaveLength(2); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 434c704ccd0..e0b3c0bbacf 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1,6 +1,6 @@ import { usePrimaryAgentPresentation } from "./primary-agent/PrimaryAgentPresentation"; import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls"; -import { useState, useEffect, useRef, useCallback, useMemo, type CSSProperties, type DragEvent } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo, type CSSProperties } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import type { AgentEnvConfig, EnvBinding, IssueWorkMode } from "@paperclipai/shared"; import { useDialog } from "../context/DialogContext"; @@ -35,7 +35,6 @@ import { assigneeValueFromSelection, currentUserAssigneeOption, parseAssigneeVal import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Paperclip, FileText, Flag, PauseCircle, ListTree, X, ShieldAlert, Folder, ChevronDown, Lock } from "lucide-react"; -import { cn } from "../lib/utils"; import type { MentionOption } from "./MarkdownEditor"; import { TaskChatComposer } from "./task-chat/TaskChatComposer"; import { ComposerWorktreePicker } from "./task-chat/ComposerWorktreePicker"; @@ -485,7 +484,6 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean }) const [isPrivate, setIsPrivate] = useState(false); const { isMobile } = useSidebar(); const [composerSettings, setComposerSettings] = useState(null); - const [isFileDragOver, setIsFileDragOver] = useState(false); const draftTimer = useRef | null>(null); const executionWorkspaceDefaultProjectId = useRef(null); const initializationKeyRef = useRef(null); @@ -1048,7 +1046,6 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean }) setIsPrivate(false); setStagedFiles([]); - setIsFileDragOver(false); executionWorkspaceDefaultProjectId.current = null; initializationKeyRef.current = null; @@ -1220,31 +1217,6 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean }) }); } - function handleFileDragEnter(evt: DragEvent) { - if (!evt.dataTransfer.types.includes("Files")) return; - evt.preventDefault(); - setIsFileDragOver(true); - } - - function handleFileDragOver(evt: DragEvent) { - if (!evt.dataTransfer.types.includes("Files")) return; - evt.preventDefault(); - evt.dataTransfer.dropEffect = "copy"; - setIsFileDragOver(true); - } - - function handleFileDragLeave(evt: DragEvent) { - if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return; - setIsFileDragOver(false); - } - - function handleFileDrop(evt: DragEvent) { - if (!evt.dataTransfer.files.length) return; - evt.preventDefault(); - setIsFileDragOver(false); - stageFiles(Array.from(evt.dataTransfer.files)); - } - function removeStagedFile(id: string) { setStagedFiles((current) => current.filter((file) => file.id !== id)); } @@ -1448,11 +1420,7 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean }) {isSubIssueMode ? "New sub-task" : "New task"}
{filesStorageError ?

{filesStorageError}

: null} diff --git a/ui/src/components/artifacts/ArtifactDropzone.tsx b/ui/src/components/artifacts/ArtifactDropzone.tsx index 7830ac1bc0a..0beaa3ad48e 100644 --- a/ui/src/components/artifacts/ArtifactDropzone.tsx +++ b/ui/src/components/artifacts/ArtifactDropzone.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { Upload } from "lucide-react"; +import { FILE_DROP_TARGET_ATTRIBUTE } from "../../lib/file-drop-target"; /** The target can be the host frame when its tab header is rendered by a portal. */ export function ArtifactDropzone({ children, onUpload, enabled = true, target }: { @@ -18,6 +19,8 @@ export function ArtifactDropzone({ children, onUpload, enabled = true, target }: const element = target ?? rootRef.current; setDragging(false); if (!enabled || !element) return; + const previousTarget = element.getAttribute(FILE_DROP_TARGET_ATTRIBUTE); + element.setAttribute(FILE_DROP_TARGET_ATTRIBUTE, ""); let depth = 0; const hasFiles = (event: DragEvent) => Array.from(event.dataTransfer?.types ?? []).includes("Files"); const enter = (event: DragEvent) => { @@ -53,6 +56,8 @@ export function ArtifactDropzone({ children, onUpload, enabled = true, target }: element.addEventListener("dragleave", leave); element.addEventListener("drop", drop); return () => { + if (previousTarget === null) element.removeAttribute(FILE_DROP_TARGET_ATTRIBUTE); + else element.setAttribute(FILE_DROP_TARGET_ATTRIBUTE, previousTarget); element.removeEventListener("dragenter", enter); element.removeEventListener("dragover", over); element.removeEventListener("dragleave", leave); diff --git a/ui/src/components/task-chat/ComposerFileDropTarget.test.tsx b/ui/src/components/task-chat/ComposerFileDropTarget.test.tsx new file mode 100644 index 00000000000..19ac1465d27 --- /dev/null +++ b/ui/src/components/task-chat/ComposerFileDropTarget.test.tsx @@ -0,0 +1,226 @@ +// @vitest-environment jsdom +import { act, type ReactElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MarkdownEditor } from "../MarkdownEditor"; +import { IssueAttachmentsSection } from "../IssueAttachmentsSection"; +import { ArtifactDropzone } from "../artifacts/ArtifactDropzone"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; +import { ComposerFileDropTarget } from "./ComposerFileDropTarget"; + +let host: HTMLDivElement; +let root: Root; +const file = new File(["image"], "screenshot.png", { type: "image/png" }); +function render(content: ReactElement) { act(() => root.render(content)); } +function drag(type: string, target: EventTarget = document.body, types = ["Files"], relatedTarget: EventTarget | null = null) { + const event = new MouseEvent(type, { bubbles: true, cancelable: true }); + Object.defineProperties(event, { + dataTransfer: { value: { types, files: types.includes("Files") ? [file] : [], items: types.includes("Files") ? [{ kind: "file", type: file.type, getAsFile: () => file }] : [], dropEffect: "none" } }, + relatedTarget: { value: relatedTarget }, + }); + act(() => { target.dispatchEvent(event); }); + return event; +} +const overlay = () => document.querySelector('[data-testid="composer-file-drop-overlay"]'); +beforeEach(() => { vi.stubGlobal("DragEvent", MouseEvent); host = document.createElement("div"); document.body.append(host); root = createRoot(host); }); +afterEach(() => { act(() => root.unmount()); host.remove(); vi.unstubAllGlobals(); }); + +describe("composer screen file drops", () => { + it("accepts a drop outside the composer exactly once and clears the overlay", () => { + const onFiles = vi.fn(); + render(); + expect(drag("dragenter").defaultPrevented).toBe(true); + expect(overlay()?.textContent).toContain("Drop files to attach"); + expect(drag("dragover").defaultPrevented).toBe(true); + const downstream = vi.fn(); + document.body.addEventListener("drop", downstream, { once: true }); + expect(drag("drop").defaultPrevented).toBe(true); + expect(onFiles).toHaveBeenCalledExactlyOnceWith([file]); + expect(downstream).not.toHaveBeenCalled(); + document.body.removeEventListener("drop", downstream); + expect(overlay()).toBeNull(); + }); + + it("keeps the overlay while moving between children, then clears on exit or cancellation", () => { + render(); + drag("dragenter"); drag("dragenter", host); drag("dragleave", document.body, ["Files"], host); + expect(overlay()).not.toBeNull(); + drag("dragleave", host); + expect(overlay()).toBeNull(); + for (const cancel of ["dragend", "blur"]) { + drag("dragenter"); + act(() => { window.dispatchEvent(new Event(cancel)); }); + expect(overlay()).toBeNull(); + } + }); + + it("clears a nested drag when the pointer leaves the window", () => { + render(); + drag("dragenter"); drag("dragenter", host); + drag("dragleave", document.documentElement); + expect(overlay()).toBeNull(); + }); + + it("preserves description editor file uploads and clears both overlays", async () => { + const onFiles = vi.fn(); const onDropFile = vi.fn().mockResolvedValue(undefined); + render(<> + + ); + const editor = host.querySelector('[contenteditable="true"]')!; + expect(editor).not.toBeNull(); + drag("dragenter"); expect(overlay()).not.toBeNull(); + drag("dragenter", editor); drag("dragover", editor); + expect(overlay()).toBeNull(); + expect(host.textContent).toContain("Drop file to upload"); + const attachment = new File(["notes"], "notes.txt", { type: "text/plain" }); + const event = new MouseEvent("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { value: { types: ["Files"], files: [attachment] } }); + await act(async () => { editor.dispatchEvent(event); }); + expect(onDropFile).toHaveBeenCalledExactlyOnceWith(attachment); + expect(onFiles).not.toHaveBeenCalled(); + expect(host.textContent).not.toContain("Drop file to upload"); + expect(overlay()).toBeNull(); + drag("drop"); expect(onFiles).toHaveBeenCalledExactlyOnceWith([file]); + }); + + it("keeps image drops in the description image plugin", async () => { + const onFiles = vi.fn(); + const uploadImage = vi.fn().mockResolvedValue("/description-image.png"); + render(<> + + ); + const editor = host.querySelector('[contenteditable="true"]')!; + drag("dragenter", editor); expect(overlay()).toBeNull(); + expect(host.textContent).toContain("Drop image to upload"); + await act(async () => { drag("drop", editor); }); + expect(uploadImage).toHaveBeenCalledExactlyOnceWith(file); + expect(onFiles).not.toHaveBeenCalled(); + expect(host.textContent).not.toContain("Drop image to upload"); + }); + + it.each(["parent", "readOnly"])("leaves %s editor drops with the composer", (mode) => { + const onFiles = vi.fn(); const onDropFile = vi.fn(); + render(<> + + ); + drag("drop", host.querySelector(".paperclip-mdxeditor-scope")!); + expect(onFiles).toHaveBeenCalledExactlyOnceWith([file]); + expect(onDropFile).not.toHaveBeenCalled(); + }); + + it("preserves the task attachments section drop handler", () => { + const onFiles = vi.fn(); const onDrop = vi.fn(); + render(<> + + ); + const label = Array.from(host.querySelectorAll("h3")).find(item => item.textContent === "Attachments")!; + drag("drop", label); + expect(onDrop).toHaveBeenCalledOnce(); expect(onFiles).not.toHaveBeenCalled(); + }); + + it("ignores text and link drags", () => { + const onFiles = vi.fn(); + render(); + expect(drag("dragenter", host, ["text/plain", "text/uri-list"]).defaultPrevented).toBe(false); + expect(drag("drop", host, ["text/plain"]).defaultPrevented).toBe(false); + expect(overlay()).toBeNull(); + expect(onFiles).not.toHaveBeenCalled(); + }); + + it("gives the open dialog exclusive ownership and restores the task on close", () => { + const taskFiles = vi.fn(); const newFiles = vi.fn(); + const content = (open: boolean, enabled = true) => <> + + {open ? New task : null} + ; + render(content(true)); + drag("dragenter"); + expect(overlay()?.textContent).toContain("your new task"); + drag("drop"); + expect(newFiles).toHaveBeenCalledExactlyOnceWith([file]); + expect(taskFiles).not.toHaveBeenCalled(); + render(content(true, false)); + drag("dragenter"); drag("drop"); + expect(overlay()).toBeNull(); + expect(taskFiles).not.toHaveBeenCalled(); + render(content(false)); + drag("drop"); + expect(taskFiles).toHaveBeenCalledExactlyOnceWith([file]); + }); + + it("does not send files to the underlying task while another dialog is open", () => { + const onFiles = vi.fn(); + render(<>Settings); + drag("dragenter"); drag("drop"); + expect(overlay()).toBeNull(); expect(onFiles).not.toHaveBeenCalled(); + }); + + it.each([false, true])("preserves artifact uploads with an external target: %s", (external) => { + const onFiles = vi.fn(); const onUpload = vi.fn(); + const frame = document.createElement("div"); + const header = document.createElement("button"); + frame.append(header); document.body.append(frame); + try { + render(<> + ); + const destination = external ? header : host.querySelector("[data-artifact-file]")!; + drag("dragenter"); expect(overlay()).not.toBeNull(); + drag("dragenter", destination); drag("dragover", destination); + expect(overlay()).toBeNull(); + expect(document.querySelector("[data-artifact-drop-overlay]")).not.toBeNull(); + expect(drag("drop", destination).defaultPrevented).toBe(true); + expect(onUpload).toHaveBeenCalledExactlyOnceWith([file]); + expect(onFiles).not.toHaveBeenCalled(); + drag("dragenter"); expect(overlay()).not.toBeNull(); drag("drop"); + expect(onFiles).toHaveBeenCalledExactlyOnceWith([file]); + render(
); + expect(frame.hasAttribute("data-file-drop-target")).toBe(false); + } finally { frame.remove(); } + }); + + it("keeps composer drops available while a portaled picker is open", () => { + const onFiles = vi.fn(); + render(<> + ModelChoose model); + const picker = document.querySelector('[data-slot="popover-content"]')!; + expect(picker.getAttribute("role")).toBe("dialog"); + expect(host.contains(picker)).toBe(false); + drag("dragenter"); expect(overlay()).not.toBeNull(); + expect(drag("drop", picker).defaultPrevented).toBe(true); + expect(onFiles).toHaveBeenCalledExactlyOnceWith([file]); + }); + + it("prevents browser navigation while the new task is read-only", () => { + const onFiles = vi.fn(); + render(New task + + ); + expect(drag("dragenter").defaultPrevented).toBe(true); + expect(drag("dragover").defaultPrevented).toBe(true); + expect(drag("drop").defaultPrevented).toBe(true); + expect(overlay()).toBeNull(); expect(onFiles).not.toHaveBeenCalled(); + }); + + it("does not accept drops for a concealed composer", () => { + const onFiles = vi.fn(); + render(
); + drag("dragenter"); drag("drop"); + expect(overlay()).toBeNull(); expect(onFiles).not.toHaveBeenCalled(); + }); + + it("uses the latest callback and clears on disable and unmount", () => { + const previous = vi.fn(); const next = vi.fn(); + render(); + drag("dragenter"); + render(); + expect(overlay()).toBeNull(); drag("drop"); + render(); + drag("drop"); + expect(next).toHaveBeenCalledExactlyOnceWith([file]); expect(previous).not.toHaveBeenCalled(); + drag("dragenter"); render(
); + expect(overlay()).toBeNull(); + expect(drag("drop").defaultPrevented).toBe(false); + }); +}); diff --git a/ui/src/components/task-chat/ComposerFileDropTarget.tsx b/ui/src/components/task-chat/ComposerFileDropTarget.tsx new file mode 100644 index 00000000000..1b769aed1b5 --- /dev/null +++ b/ui/src/components/task-chat/ComposerFileDropTarget.tsx @@ -0,0 +1,109 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Upload } from "lucide-react"; +import { FILE_DROP_TARGET_SELECTOR } from "../../lib/file-drop-target"; + +interface DropTarget { + element: HTMLSpanElement; + reset: () => void; +} + +const targets: DropTarget[] = []; + +function activeTarget() { + const unavailable = '[inert], [hidden], [aria-hidden="true"]'; + const dialogs = Array.from(document.querySelectorAll('[aria-modal="true"], [role="alertdialog"], dialog[open], [data-slot="dialog-content"], [data-slot="sheet-content"]')) + .filter(dialog => !dialog.closest(unavailable)); + const dialog = dialogs.at(-1); + return targets.findLast(target => target.element.isConnected + && !target.element.parentElement?.closest(unavailable) + && (!dialog || dialog.contains(target.element))); +} + +export function ComposerFileDropTarget({ + enabled, + newTask = false, + onFiles, +}: { + enabled: boolean; + newTask?: boolean; + onFiles: (files: File[]) => void; +}) { + const marker = useRef(null); + const current = useRef({ enabled, onFiles }); + const [dragging, setDragging] = useState(false); + useLayoutEffect(() => { current.current = { enabled, onFiles }; }); + + useEffect(() => { + if (!enabled) setDragging(false); + }, [enabled]); + + useEffect(() => { + let depth = 0; + const reset = () => { depth = 0; setDragging(false); }; + const target: DropTarget = { element: marker.current!, reset }; + targets.push(target); + targets.forEach(item => item.reset()); + + const owns = (event: DragEvent) => activeTarget() === target + && !(event.target instanceof Element && event.target.closest(FILE_DROP_TARGET_SELECTOR)) + && Array.from(event.dataTransfer?.types ?? []).includes("Files"); + const enter = (event: DragEvent) => { + if (!owns(event)) { reset(); return; } + event.preventDefault(); + if (!current.current.enabled) return; + depth += 1; + setDragging(true); + }; + const over = (event: DragEvent) => { + if (!owns(event)) { reset(); return; } + event.preventDefault(); + event.dataTransfer!.dropEffect = current.current.enabled ? "copy" : "none"; + setDragging(current.current.enabled); + }; + const leave = (event: DragEvent) => { + if (!owns(event)) return; + depth = Math.max(0, depth - 1); + if (depth === 0 || (!event.relatedTarget && event.target === document.documentElement)) reset(); + }; + const drop = (event: DragEvent) => { + const owned = owns(event); + reset(); + if (!owned) return; + event.preventDefault(); + event.stopPropagation(); + if (!current.current.enabled) return; + const files = Array.from(event.dataTransfer?.files ?? []); + if (files.length) current.current.onFiles(files); + }; + document.addEventListener("dragenter", enter, true); + document.addEventListener("dragover", over, true); + document.addEventListener("dragleave", leave, true); + document.addEventListener("drop", drop, true); + window.addEventListener("dragend", reset); + window.addEventListener("blur", reset); + return () => { + targets.splice(targets.indexOf(target), 1); + document.removeEventListener("dragenter", enter, true); + document.removeEventListener("dragover", over, true); + document.removeEventListener("dragleave", leave, true); + document.removeEventListener("drop", drop, true); + window.removeEventListener("dragend", reset); + window.removeEventListener("blur", reset); + }; + }, []); + + return <> +