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;