mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
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:
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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user