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
This commit is contained in:
Dotta authored and GitHub committed 2026-11-04 15:41:48 -06:00
1 parent 61b2455319
commit c6cb7e2a2d
12 files changed
+432 -52

No files matched your search

@@ -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}
+2
View File
@@ -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<MarkdownEditorRef, MarkdownEditorProps>
return (
<div
ref={containerRef}
{...(canDropFile && !readOnly ? { [FILE_DROP_TARGET_ATTRIBUTE]: "" } : {})}
className={cn(
"relative paperclip-mdxeditor-scope",
bordered ? "rounded-md border border-border bg-transparent" : "bg-transparent",
+11 -4
View File
@@ -1849,7 +1849,7 @@ describe("NewIssueDialog", () => {
} 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<HTMLInputElement>('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<HTMLInputElement>('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);
+2 -34
View File
@@ -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<ComposerRunSettings | null>(null);
const [isFileDragOver, setIsFileDragOver] = useState(false);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const executionWorkspaceDefaultProjectId = useRef<string | null>(null);
const initializationKeyRef = useRef<string | null>(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<HTMLDivElement>) {
if (!evt.dataTransfer.types.includes("Files")) return;
evt.preventDefault();
setIsFileDragOver(true);
}
function handleFileDragOver(evt: DragEvent<HTMLDivElement>) {
if (!evt.dataTransfer.types.includes("Files")) return;
evt.preventDefault();
evt.dataTransfer.dropEffect = "copy";
setIsFileDragOver(true);
}
function handleFileDragLeave(evt: DragEvent<HTMLDivElement>) {
if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return;
setIsFileDragOver(false);
}
function handleFileDrop(evt: DragEvent<HTMLDivElement>) {
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 })
<DialogTitle className="sr-only">{isSubIssueMode ? "New sub-task" : "New task"}</DialogTitle>
<div
ref={dialogBodyRef}
className={cn("min-h-0 overflow-y-auto overscroll-contain", isFileDragOver && "bg-accent/20")}
onDragEnter={handleFileDragEnter}
onDragOver={handleFileDragOver}
onDragLeave={handleFileDragLeave}
onDrop={handleFileDrop}
className="min-h-0 overflow-y-auto overscroll-contain"
>
{filesStorageError ? <p className="mb-2 text-sm text-destructive" role="alert">{filesStorageError}</p> : null}
<TaskChatPresentationProvider mode={streamlinedUiEnabled || coordinatorEnabled ? "streamlined" : "production"}>
@@ -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);
@@ -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(<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);
});
});
@@ -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<HTMLSpanElement>(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 <>
<span ref={marker} hidden />
{dragging && enabled ? createPortal(
<div data-testid="composer-file-drop-overlay" role="status"
className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-6 backdrop-blur-(--blur-1px)">
<div className="flex h-full w-full flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed border-primary bg-primary/5 text-foreground">
<Upload className="h-10 w-10 text-primary" aria-hidden />
<p className="text-xl font-semibold">Drop files to attach</p>
<p className="text-sm text-muted-foreground">{newTask ? "Add images and files to your new task" : "Add images and files to your message"}</p>
</div>
</div>, document.body,
) : null}
</>;
}
@@ -1314,7 +1314,7 @@ describe("TaskChatComposer", () => {
expect(openPicker).toHaveBeenCalledOnce();
});
it.each(["picker", "drop"])("uploads images from %s into a preview row above the text", async (source) => {
it.each(["picker", "drop", "page drop"])("uploads images from %s into a preview row above the text", async (source) => {
const onAdd = vi.fn();
const onAttachImage = vi.fn().mockResolvedValue({ contentPath: "/attachments/shot.png" });
renderComposer({ onAdd, workMode: "standard", onAttachImage });
@@ -1326,8 +1326,8 @@ describe("TaskChatComposer", () => {
flushSync(() => input.dispatchEvent(new Event("change", { bubbles: true })));
} else {
const event = new Event("drop", { bubbles: true, cancelable: true });
Object.defineProperty(event, "dataTransfer", { value: { files: [file] } });
flushSync(() => editable().dispatchEvent(event));
Object.defineProperty(event, "dataTransfer", { value: { types: ["Files"], files: [file] } });
flushSync(() => (source === "page drop" ? document.body : editable()).dispatchEvent(event));
expect(event.defaultPrevented).toBe(true);
}
await flushAsync();
@@ -1,4 +1,5 @@
import { useCurrentAccountGuard } from "@/hooks/useAccountBoundMutation";
import { ComposerFileDropTarget } from "./ComposerFileDropTarget";
import { AgentAvatar } from "../AgentAvatar";
import {
useEffect,
@@ -1243,6 +1244,14 @@ export function TaskChatComposer({
return (
<div className={cn("flex min-w-0 flex-col", creation?.contextBar ? "gap-0" : "gap-2")}>
<ComposerFileDropTarget
enabled={canAcceptFiles && !disabled && !submitting && !creation?.readOnly}
newTask={Boolean(creation)}
onFiles={(files) => {
if (creation) creation.onSelectFiles(files);
else for (const file of files) void attachPickedFile(file);
}}
/>
{takeoverVisible && takeover ? (
<section
className={cn(
@@ -1335,17 +1344,6 @@ export function TaskChatComposer({
}
}}
onPasteCapture={handlePasteCapture}
onDragOver={(event) => {
if (canAcceptFiles && !disabled && !creation && event.dataTransfer.types.includes("Files")) event.preventDefault();
}}
onDropCapture={(event) => {
if (!canAcceptFiles || disabled || creation) return;
const files = Array.from(event.dataTransfer.files);
if (!files.length) return;
event.preventDefault();
event.stopPropagation();
for (const file of files) void attachPickedFile(file);
}}
>
{creation?.header}
{uncertainSubmission ? (
+3
View File
@@ -0,0 +1,3 @@
/** Explicit upload surfaces take priority over the screen-wide composer fallback. */
export const FILE_DROP_TARGET_ATTRIBUTE = "data-file-drop-target";
export const FILE_DROP_TARGET_SELECTOR = `[${FILE_DROP_TARGET_ATTRIBUTE}]`;
@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, waitFor, within } from "storybook/test";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
import { TaskChatComposerDock } from "@/components/task-chat/TaskChatComposerDock";
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
function TaskFileDrop() {
return <div className="flex min-h-dvh flex-col bg-background text-foreground">
<header className="flex h-12 items-center border-b border-border px-6 text-sm">Tasks / Fix the sign-in redirect</header>
<main className="mx-auto flex w-full max-w-3xl flex-1 flex-col p-6">
<h1 className="mb-6 text-xl font-semibold">Fix the sign-in redirect</h1>
<div className="flex flex-1 flex-col" data-task-chat-shell="">
<TaskChatThreadView items={[
{ id: "request", kind: "message", author: "human", text: "Review the sign-in flow and fix the redirect after a session expires." },
{ id: "response", kind: "message", author: "agent", authorName: "Codex", text: "I checked the redirect. Add a screenshot of the page you see after signing in so I can compare the result." },
]} />
<TaskChatComposerDock mobile={false} streamlined={false}>
<TaskChatComposer workMode="standard" onAdd={async () => {}}
onImageUpload={async () => "/favicon.svg"} />
</TaskChatComposerDock>
</div>
</main>
</div>;
}
const meta = {
id: "composer-file-drop",
title: "Tasks and conversations/Task creation and lifecycle/Composer/Screen file drop",
component: TaskFileDrop,
parameters: {
layout: "fullscreen",
paperclip: { capabilities: ["tasks/discussion"], implementation: "production", boundary: "fixtures",
notes: "Production thread, composer, and screen drop target in a task layout. Upload is a local fixture; no task or agent runs." },
},
} satisfies Meta<typeof TaskFileDrop>;
export default meta;
type Story = StoryObj<typeof meta>;
export const TaskPage: Story = {};
export const DragOver: Story = {
play: async ({ canvasElement }) => {
const document = canvasElement.ownerDocument;
const transfer = new DataTransfer();
transfer.items.add(new File(["image"], "screenshot.png", { type: "image/png" }));
document.body.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer: transfer }));
await waitFor(() => expect(within(document.body).getByTestId("composer-file-drop-overlay")).toBeVisible());
},
};
@@ -552,3 +552,16 @@ export const MobileMentionPicker: Story = { ...MentionPicker, globals: { viewpor
export const MobileSlashCommands: Story = { ...SlashCommands, globals: { viewport: { value: "mobile", isRotated: false } } };
export const MobileRichChips: Story = { ...RichChips, globals: { viewport: { value: "mobile", isRotated: false } } };
export const MobileInsertRichChips: Story = { ...InsertRichChips, globals: { viewport: { value: "mobile", isRotated: false } } };
export const ScreenDragOver: Story = {
play: async ({ canvasElement }) => {
const document = canvasElement.ownerDocument;
const page = within(document.body);
await expect(await page.findByRole("textbox", { name: "editable markdown" })).toBeVisible();
await waitFor(() => expect(page.getByTestId("task-chat-composer-add")).toBeEnabled());
const transfer = new DataTransfer();
transfer.items.add(new File(["image"], "screenshot.png", { type: "image/png" }));
document.body.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer: transfer }));
await waitFor(() => expect(page.getByTestId("composer-file-drop-overlay")).toBeVisible());
},
};