From 1feac07821d576f8e616b667654a3d189798ea3d Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 24 Apr 2026 06:27:30 -0500 Subject: [PATCH] Fix issue chat composer dropzone scope --- ui/src/components/IssueChatThread.test.tsx | 4 ++ ui/src/components/IssueChatThread.tsx | 4 +- ui/src/components/MarkdownEditor.test.tsx | 81 ++++++++++++++++++++++ ui/src/components/MarkdownEditor.tsx | 6 +- 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 920eba15c22..51c4f5f2707 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -69,12 +69,14 @@ vi.mock("./MarkdownEditor", () => ({ placeholder, className, contentClassName, + fileDropTarget, }: { value?: string; onChange?: (value: string) => void; placeholder?: string; className?: string; contentClassName?: string; + fileDropTarget?: "editor" | "parent"; }, ref) => { useImperativeHandle(ref, () => ({ focus: markdownEditorFocusMock, @@ -85,6 +87,7 @@ vi.mock("./MarkdownEditor", () => ({ aria-label="Issue chat editor" data-class-name={className} data-content-class-name={contentClassName} + data-file-drop-target={fileDropTarget} placeholder={placeholder} value={value} onChange={(event) => onChange?.(event.target.value)} @@ -818,6 +821,7 @@ describe("IssueChatThread", () => { expect(editor?.dataset.contentClassName).toContain("max-h-[28dvh]"); expect(editor?.dataset.contentClassName).toContain("overflow-y-auto"); expect(editor?.dataset.contentClassName).not.toContain("min-h-[72px]"); + expect(editor?.dataset.fileDropTarget).toBe("parent"); act(() => { root.unmount(); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 746844ac544..d6e930efe61 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -2254,6 +2254,7 @@ const IssueChatComposer = forwardRef @@ -2270,7 +2271,6 @@ const IssueChatComposer = forwardRef @@ -2279,7 +2279,7 @@ const IssueChatComposer = forwardRef attachInputRef.current?.click()} disabled={attaching} - title="Attach image" + title="Attach file" > diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index 976ad51c561..8aa1b5d2838 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -148,6 +148,17 @@ async function flush() { }); } +function createFileDragEvent(type: string) { + const event = new Event(type, { bubbles: true, cancelable: true }) as Event & { + dataTransfer: { types: string[]; files: File[]; dropEffect?: string }; + }; + event.dataTransfer = { + types: ["Files"], + files: [], + }; + return event; +} + describe("MarkdownEditor", () => { let container: HTMLDivElement; let originalRangeRect: typeof Range.prototype.getBoundingClientRect; @@ -319,6 +330,76 @@ describe("MarkdownEditor", () => { root.unmount(); }); }); + + it("shows the editor-scoped dropzone by default when files are dragged over it", async () => { + const root = createRoot(container); + + await act(async () => { + root.render( + {}} + placeholder="Markdown body" + imageUploadHandler={async () => "https://example.com/image.png"} + />, + ); + }); + + await flush(); + + const scope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement as HTMLDivElement | null; + expect(scope).not.toBeNull(); + + act(() => { + scope?.dispatchEvent(createFileDragEvent("dragenter")); + }); + + expect(scope?.className).toContain("ring-1"); + expect(container.textContent).toContain("Drop image to upload"); + + act(() => { + scope?.dispatchEvent(createFileDragEvent("dragleave")); + }); + + expect(scope?.className).not.toContain("ring-1"); + + await act(async () => { + root.unmount(); + }); + }); + + it("defers file-drop visuals to a parent container when requested", async () => { + const root = createRoot(container); + + await act(async () => { + root.render( + {}} + placeholder="Markdown body" + imageUploadHandler={async () => "https://example.com/image.png"} + fileDropTarget="parent" + />, + ); + }); + + await flush(); + + const scope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement as HTMLDivElement | null; + expect(scope).not.toBeNull(); + + act(() => { + scope?.dispatchEvent(createFileDragEvent("dragenter")); + }); + + expect(scope?.className).not.toContain("ring-1"); + expect(container.textContent).not.toContain("Drop image to upload"); + + await act(async () => { + root.unmount(); + }); + }); + it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => { expect( computeMentionMenuPosition( diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 64dfba357e4..888a41a8977 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -68,6 +68,8 @@ interface MarkdownEditorProps { imageUploadHandler?: (file: File) => Promise; /** Called when a non-image file is dropped onto the editor (e.g. .zip). */ onDropFile?: (file: File) => Promise; + /** When set to `parent`, a wrapper owns drag/drop behavior and visuals. */ + fileDropTarget?: "editor" | "parent"; bordered?: boolean; /** List of mentionable entities. Enables @-mention autocomplete. */ mentions?: MentionOption[]; @@ -491,6 +493,7 @@ export const MarkdownEditor = forwardRef onBlur, imageUploadHandler, onDropFile, + fileDropTarget = "editor", bordered = true, mentions, onSubmit, @@ -897,8 +900,7 @@ export const MarkdownEditor = forwardRef return Array.from(evt.dataTransfer?.types ?? []).includes("Files"); } - const canDropImage = Boolean(imageUploadHandler); - const canDropFile = Boolean(imageUploadHandler || onDropFile); + const canDropFile = fileDropTarget === "editor" && Boolean(imageUploadHandler || onDropFile); const handlePasteCapture = useCallback((event: ClipboardEvent) => { const clipboard = event.clipboardData; if (!clipboard || !ref.current) return;