From 38487c6d3607358e591e36745178da671ab87a91 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 24 Apr 2026 07:39:02 -0500 Subject: [PATCH] Keep issue composer as sole drop target --- ui/src/components/IssueChatThread.test.tsx | 113 +++++++++++ ui/src/components/IssueChatThread.tsx | 215 +++++++++++++++++---- ui/src/pages/IssueDetail.tsx | 4 +- 3 files changed, 292 insertions(+), 40 deletions(-) diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 920eba15c22..3b12888f21c 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -249,6 +249,21 @@ function createExpiredRequestConfirmationInteraction( }; } +function createFileDragEvent(type: string, files: File[]) { + 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("IssueChatThread", () => { let container: HTMLDivElement; @@ -824,6 +839,104 @@ describe("IssueChatThread", () => { }); }); + it("shows only the outer composer drop overlay when dragging over the reply editor", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + imageUploadHandler={async () => "/api/attachments/image/content"} + onAttachImage={async () => undefined} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null; + const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null; + expect(composer).not.toBeNull(); + expect(editor).not.toBeNull(); + + act(() => { + editor?.dispatchEvent(createFileDragEvent("dragenter", [ + new File(["hello"], "notes.txt", { type: "text/plain" }), + ])); + }); + + expect(container.querySelector('[data-testid="issue-chat-composer-drop-overlay"]')).not.toBeNull(); + expect(container.textContent).toContain("Drop to upload"); + expect(container.textContent).not.toContain("Drop image to upload"); + expect(composer?.className).toContain("border-primary/45"); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement | null; + expect(fileInput?.getAttribute("accept")).toBeNull(); + + act(() => { + root.unmount(); + }); + }); + + it("shows non-image attachment upload state in the composer after a drop from the editor", async () => { + const root = createRoot(container); + const onAttachImage = vi.fn(async (file: File) => ({ + id: "attachment-1", + companyId: "company-1", + issueId: "issue-1", + issueCommentId: null, + assetId: "asset-1", + provider: "local_disk", + objectKey: "issues/issue-1/report.pdf", + contentPath: "/api/attachments/attachment-1/content", + originalFilename: file.name, + contentType: file.type, + byteSize: file.size, + sha256: "abc123", + createdByAgentId: null, + createdByUserId: "user-1", + createdAt: new Date("2026-04-24T12:00:00.000Z"), + updatedAt: new Date("2026-04-24T12:00:00.000Z"), + })); + + await act(async () => { + root.render( + + {}} + onAttachImage={onAttachImage} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null; + const file = new File(["report body"], "report.pdf", { type: "application/pdf" }); + + await act(async () => { + editor?.dispatchEvent(createFileDragEvent("drop", [file])); + }); + + expect(onAttachImage).toHaveBeenCalledWith(file); + expect(container.querySelector('[data-testid="issue-chat-composer-attachments"]')).not.toBeNull(); + expect(container.textContent).toContain("report.pdf"); + expect(container.textContent).toContain("Attached to issue"); + + await act(async () => { + root.unmount(); + }); + }); + it("renders the bottom spacer with zero height until the user has submitted", () => { const root = createRoot(container); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index ea1d4d3aff0..b2cd4aa7ad9 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -31,6 +31,7 @@ import type { FeedbackDataSharingPreference, FeedbackVote, FeedbackVoteValue, + IssueAttachment, IssueRelationIssueSummary, } from "@paperclipai/shared"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; @@ -219,7 +220,7 @@ export interface IssueChatComposerHandle { interface IssueChatComposerProps { onImageUpload?: (file: File) => Promise; - onAttachImage?: (file: File) => Promise; + onAttachImage?: (file: File) => Promise; draftKey?: string; enableReassign?: boolean; reassignOptions?: InlineEntityOption[]; @@ -259,7 +260,7 @@ interface IssueChatThreadProps { onCancelRun?: () => Promise; onStopRun?: (runId: string) => Promise; imageUploadHandler?: (file: File) => Promise; - onAttachImage?: (file: File) => Promise; + onAttachImage?: (file: File) => Promise; draftKey?: string; enableReassign?: boolean; reassignOptions?: InlineEntityOption[]; @@ -508,10 +509,27 @@ const DRAFT_DEBOUNCE_MS = 800; const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96; const SUBMIT_SCROLL_RESERVE_VH = 0.4; +type ComposerAttachmentItem = { + id: string; + name: string; + size: number; + status: "uploading" | "attached" | "error"; + inline: boolean; + contentPath?: string; + error?: string; +}; + function hasFilePayload(evt: ReactDragEvent) { return Array.from(evt.dataTransfer?.types ?? []).includes("Files"); } +function formatAttachmentSize(bytes: number) { + if (!Number.isFinite(bytes) || bytes <= 0) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + function toIsoString(value: string | Date | null | undefined): string | null { if (!value) return null; return typeof value === "string" ? value : value.toISOString(); @@ -2055,6 +2073,7 @@ const IssueChatComposer = forwardRef([]); const dragDepthRef = useRef(0); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue); @@ -2148,6 +2167,7 @@ const IssueChatComposer = forwardRef @@ -2163,13 +2183,59 @@ const IssueChatComposer = forwardRef prev ? `${prev}\n\n${markdown}` : markdown); - } else if (onAttachImage) { - await onAttachImage(file); + const attachmentId = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`; + const inline = Boolean(onImageUpload && file.type.startsWith("image/")); + setComposerAttachments((prev) => [ + ...prev, + { + id: attachmentId, + name: file.name, + size: file.size, + status: "uploading", + inline, + }, + ]); + + try { + if (onImageUpload && file.type.startsWith("image/")) { + const url = await onImageUpload(file); + const safeName = file.name.replace(/[[\]]/g, "\\$&"); + const markdown = `![${safeName}](${url})`; + setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown); + setComposerAttachments((prev) => prev.map((item) => + item.id === attachmentId + ? { ...item, status: "attached", contentPath: url } + : item, + )); + } else if (onAttachImage) { + const attachment = await onAttachImage(file); + setComposerAttachments((prev) => prev.map((item) => + item.id === attachmentId + ? { + ...item, + status: "attached", + contentPath: attachment?.contentPath, + name: attachment?.originalFilename ?? item.name, + } + : item, + )); + } else { + setComposerAttachments((prev) => prev.map((item) => + item.id === attachmentId + ? { ...item, status: "error", error: "This file type cannot be attached here" } + : item, + )); + } + } catch (err) { + setComposerAttachments((prev) => prev.map((item) => + item.id === attachmentId + ? { + ...item, + status: "error", + error: err instanceof Error ? err.message : "Upload failed", + } + : item, + )); } } @@ -2202,6 +2268,37 @@ const IssueChatComposer = forwardRef) { + if (!canAcceptFiles || !hasFilePayload(evt)) return; + evt.preventDefault(); + evt.stopPropagation(); + dragDepthRef.current += 1; + setIsDragOver(true); + } + + function handleFileDragOver(evt: ReactDragEvent) { + if (!canAcceptFiles || !hasFilePayload(evt)) return; + evt.preventDefault(); + evt.stopPropagation(); + evt.dataTransfer.dropEffect = "copy"; + } + + function handleFileDragLeave(evt: ReactDragEvent) { + if (!canAcceptFiles || !hasFilePayload(evt)) return; + evt.preventDefault(); + evt.stopPropagation(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsDragOver(false); + } + + function handleFileDrop(evt: ReactDragEvent) { + if (!canAcceptFiles || !hasFilePayload(evt)) return; + evt.preventDefault(); + evt.stopPropagation(); + resetDragState(); + void handleDroppedFiles(evt.dataTransfer?.files); + } + const canSubmit = !submitting && !!body.trim(); if (composerDisabledReason) { @@ -2217,35 +2314,33 @@ const IssueChatComposer = forwardRef { - if (!canAcceptFiles || !hasFilePayload(evt)) return; - dragDepthRef.current += 1; - setIsDragOver(true); - }} - onDragOver={(evt) => { - if (!canAcceptFiles || !hasFilePayload(evt)) return; - evt.preventDefault(); - evt.dataTransfer.dropEffect = "copy"; - }} - onDragLeave={() => { - if (!canAcceptFiles) return; - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); - if (dragDepthRef.current === 0) setIsDragOver(false); - }} - onDrop={(evt) => { - if (!canAcceptFiles) return; - if (evt.defaultPrevented) { - resetDragState(); - return; - } - evt.preventDefault(); - resetDragState(); - void handleDroppedFiles(evt.dataTransfer?.files); - }} + onDragEnterCapture={handleFileDragEnter} + onDragOverCapture={handleFileDragOver} + onDragLeaveCapture={handleFileDragLeave} + onDropCapture={handleFileDrop} > + {isDragOver && canAcceptFiles ? ( +
+
+ + + +
+
Drop to upload
+
+ Images insert into the reply. Other files are added to this issue. +
+
+
+
+ ) : null} + ) : null} + {composerAttachments.length > 0 ? ( +
+ {composerAttachments.map((attachment) => { + const sizeLabel = formatAttachmentSize(attachment.size); + const statusLabel = + attachment.status === "uploading" + ? "Uploading to issue" + : attachment.status === "error" + ? attachment.error ?? "Upload failed" + : attachment.inline + ? "Inserted inline" + : "Attached to issue"; + return ( +
+ {attachment.status === "uploading" ? ( + + ) : attachment.status === "attached" ? ( + + ) : ( + + )} + + {attachment.name} + + {sizeLabel ? ( + {sizeLabel} + ) : null} + {statusLabel} +
+ ); + })} +
+ ) : null} +
{(onImageUpload || onAttachImage) ? (
@@ -2279,7 +2418,7 @@ const IssueChatComposer = forwardRef attachInputRef.current?.click()} disabled={attaching} - title="Attach image" + title="Attach file" > diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index a4345e0a616..45f13050d92 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -578,7 +578,7 @@ type IssueDetailChatTabProps = { ) => Promise; onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; onImageUpload: (file: File) => Promise; - onAttachImage: (file: File) => Promise; + onAttachImage: (file: File) => Promise; onInterruptQueued: (runId: string) => Promise; onCancelQueued: (commentId: string) => void; interruptingQueuedRunId: string | null; @@ -2543,7 +2543,7 @@ export function IssueDetail() { return attachment.contentPath; }, [uploadAttachment]); const handleCommentAttachImage = useCallback(async (file: File) => { - await uploadAttachment.mutateAsync(file); + return uploadAttachment.mutateAsync(file); }, [uploadAttachment]); const handleInterruptQueuedRun = useCallback(async (runId: string) => { await interruptQueuedComment.mutateAsync(runId);