From c6cb7e2a2d42743c0c3c4ead2cbad4a299f9a2e7 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:41:48 -0600 Subject: [PATCH] fix(ui): accept file drops across task and new-task views (#18849) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - People add images and files through task message composers. > - A file drop requires a small target inside the composer. > - A screen-wide target makes this action easier. > - Task pages and new-task dialogs use the same composer. > - This pull request adds one shared file-drop target. > - The open dialog receives the drop when it covers a task page. ## Linked Issues or Issue Description Refs #13206. This improves the task composer drop area. Related PRs: #18689 adds image previews. #15964 adds direct uploads in the Artifacts panel. **What existing behavior does this improve?** Image and file drops in the task composer and new-task dialog. **Current behavior** People must drop a file into the composer area. **Proposed behavior** People can drop a file anywhere on the screen when an available composer is present. A full-screen overlay shows the destination. **Reason and benefit** People can add an image without aiming at the text field. **Subsystem affected** React board UI. **Breaking changes** The available composer captures file drops across the screen. Text and link drags retain their current behavior. Upload rules and APIs stay the same. **Additional context** ROADMAP.md lists task discussion and attachments as existing features. This change improves their input area. ## What Changed - Add a shared screen-wide drop target to the task composer. - Give an open task dialog exclusive ownership of file drops. Keep file drops available while a model or assignee picker is open. - Give explicit editor, task attachment, and Artifacts drop areas priority over the screen-wide composer target. Share one marker for these areas. - Keep image and file uploads in description, question, and protocol editors. Read-only editors and editors that delegate uploads to a parent keep the composer fallback. - Prevent browser navigation from file drops while the active composer is read-only. - Use the existing file staging and upload callbacks. - Disable drops during pause, queued message edits, uncertain sends, submission, and read-only states. - Exclude hidden and inert composers. - Clear the overlay after drop, drag exit, cancellation, window blur, and unmount. - Add component tests and two Storybook drag-over examples. ## Verification - 316 tests passed across the drop target, real MarkdownEditor, task composer, new-task dialog, and Artifacts tab. Tests cover image and text-file drops in a real editor, overlay reset, parent and read-only editors, and task attachments. - Two new cases failed before the editor and attachment fixes. Both passed after the fixes. - An earlier expanded run had three new-task upload assertion failures. The five affected cases passed on the unchanged implementation in isolation. The expanded run then passed all 316 tests. The cause of the earlier failures remains unverified. - UI typecheck and UI production build passed. The first typecheck attempt lacked plugin SDK declarations. Building the SDK restored these declarations; typecheck then passed. - Design-token gates, Storybook catalog generation, and whitespace checks passed. - Chromium checks from the design review confirmed one image per screen drop in each view. The editor stayed empty and the overlay cleared. These checks used local Storybook fixtures. - The requested screenshots were accepted before this PR was opened. - Full repository typecheck and build were attempted. Both stopped in the native Runner package because this environment has no cargo executable. - The full repository test run was attempted. It was stopped during the first chat integration shard before a full-suite verdict. The focused UI suite passed. - The first CI attempt did not complete. Typecheck and build logs report runner shutdown signals. Most browser shards were cancelled; their logs could not be retrieved. No complete CI verdict is available. The new commit starts a fresh CI run. - Review findings about Artifacts drops, portaled pickers, read-only drop navigation, and editor drop ownership are fixed. Current-head CI and review are pending. - The previous CI run passed build, typecheck, general tests, and six browser shards. Browser shards 5/8 and 8/8 failed. Their logs returned BlobNotFound; the causes remain unverified. The new commit starts a fresh CI run. Manual check: open a task, drag a PNG over the page header, and drop it. Confirm one image preview. Open a new-task dialog over the task and repeat. Confirm the file appears only in the new task. Cancel the drag and confirm the overlay clears. Open the description editor. Drop an image and then a text file into it. Confirm each upload stays with the description and does not enter the chat draft. ## Risks - Screen-wide file capture changes which surface receives file drops. - Dialog ownership and disabled-state tests limit unintended uploads. - No server, API, database, or migration changes are required. - Full repository acceptance still requires the native Rust toolchain and CI results. ## Model Used OpenAI GPT-6 through Codex. The exact runtime snapshot ID and context-window size were not exposed. Capabilities used: repository inspection, code editing, shell commands, and browser automation. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- ui/src/components/IssueAttachmentsSection.tsx | 2 + ui/src/components/MarkdownEditor.tsx | 2 + ui/src/components/NewIssueDialog.test.tsx | 15 +- ui/src/components/NewIssueDialog.tsx | 36 +-- .../components/artifacts/ArtifactDropzone.tsx | 5 + .../task-chat/ComposerFileDropTarget.test.tsx | 226 ++++++++++++++++++ .../task-chat/ComposerFileDropTarget.tsx | 109 +++++++++ .../task-chat/TaskChatComposer.test.tsx | 6 +- .../components/task-chat/TaskChatComposer.tsx | 20 +- ui/src/lib/file-drop-target.ts | 3 + .../tasks/composer-file-drop.stories.tsx | 47 ++++ .../tasks/new-task-composer.stories.tsx | 13 + 12 files changed, 432 insertions(+), 52 deletions(-) create mode 100644 ui/src/components/task-chat/ComposerFileDropTarget.test.tsx create mode 100644 ui/src/components/task-chat/ComposerFileDropTarget.tsx create mode 100644 ui/src/lib/file-drop-target.ts create mode 100644 ui/storybook/stories/tasks/composer-file-drop.stories.tsx 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 <> +