mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +01:00
## 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
227 lines
11 KiB
TypeScript
227 lines
11 KiB
TypeScript
// @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(<ComposerFileDropTarget enabled onFiles={onFiles} />);
|
|
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(<ComposerFileDropTarget enabled onFiles={vi.fn()} />);
|
|
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(<ComposerFileDropTarget enabled onFiles={vi.fn()} />);
|
|
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(<><ComposerFileDropTarget enabled onFiles={onFiles} />
|
|
<MarkdownEditor value="Description" onChange={vi.fn()} onDropFile={onDropFile} />
|
|
</>);
|
|
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(<><ComposerFileDropTarget enabled onFiles={onFiles} />
|
|
<MarkdownEditor value="Description" onChange={vi.fn()} imageUploadHandler={uploadImage} />
|
|
</>);
|
|
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(<><ComposerFileDropTarget enabled onFiles={onFiles} />
|
|
<MarkdownEditor value="Description" onChange={vi.fn()} onDropFile={onDropFile}
|
|
readOnly={mode === "readOnly"} fileDropTarget={mode === "parent" ? "parent" : "editor"} />
|
|
</>);
|
|
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(<><ComposerFileDropTarget enabled onFiles={onFiles} />
|
|
<IssueAttachmentsSection attachments={[]} onImageClick={vi.fn()} onDrop={onDrop} />
|
|
</>);
|
|
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(<ComposerFileDropTarget enabled onFiles={onFiles} />);
|
|
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) => <>
|
|
<ComposerFileDropTarget enabled onFiles={taskFiles} />
|
|
{open ? <Dialog open><DialogContent aria-describedby={undefined}><DialogTitle>New task</DialogTitle><ComposerFileDropTarget newTask enabled={enabled} onFiles={newFiles} /></DialogContent></Dialog> : 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(<><ComposerFileDropTarget enabled onFiles={onFiles} /><Dialog open><DialogContent aria-describedby={undefined}><DialogTitle>Settings</DialogTitle></DialogContent></Dialog></>);
|
|
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(<><ComposerFileDropTarget enabled onFiles={onFiles} />
|
|
<ArtifactDropzone onUpload={onUpload} target={external ? frame : undefined}><button data-artifact-file>File</button></ArtifactDropzone></>);
|
|
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(<div />);
|
|
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(<><ComposerFileDropTarget enabled onFiles={onFiles} />
|
|
<Popover open><PopoverTrigger>Model</PopoverTrigger><PopoverContent>Choose model</PopoverContent></Popover></>);
|
|
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(<Dialog open><DialogContent aria-describedby={undefined}><DialogTitle>New task</DialogTitle>
|
|
<ComposerFileDropTarget newTask enabled={false} onFiles={onFiles} />
|
|
</DialogContent></Dialog>);
|
|
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(<div inert><ComposerFileDropTarget enabled onFiles={onFiles} /></div>);
|
|
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(<ComposerFileDropTarget enabled onFiles={previous} />);
|
|
drag("dragenter");
|
|
render(<ComposerFileDropTarget enabled={false} onFiles={next} />);
|
|
expect(overlay()).toBeNull(); drag("drop");
|
|
render(<ComposerFileDropTarget enabled onFiles={next} />);
|
|
drag("drop");
|
|
expect(next).toHaveBeenCalledExactlyOnceWith([file]); expect(previous).not.toHaveBeenCalled();
|
|
drag("dragenter"); render(<div />);
|
|
expect(overlay()).toBeNull();
|
|
expect(drag("drop").defaultPrevented).toBe(false);
|
|
});
|
|
});
|