From 74948dab522b0d9ceab648dececce743fa28bbeb Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 24 Apr 2026 06:50:00 -0500 Subject: [PATCH] Show issue composer attachment feedback --- ui/src/components/IssueChatThread.test.tsx | 112 +++++++++++++++ ui/src/components/IssueChatThread.tsx | 152 +++++++++++++++++++-- ui/src/pages/IssueDetail.tsx | 4 +- 3 files changed, 255 insertions(+), 13 deletions(-) diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 51c4f5f2707..e9d8cab3a53 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -252,6 +252,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; @@ -828,6 +843,103 @@ describe("IssueChatThread", () => { }); }); + it("shows full-composer drop instructions while dragging files over the issue composer", () => { + 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; + expect(composer).not.toBeNull(); + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement | null; + expect(fileInput?.getAttribute("accept")).toBeNull(); + + act(() => { + composer?.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 files to attach"); + expect(container.textContent).toContain("Images insert into the reply"); + expect(container.textContent).toContain("Other files are added to this issue"); + expect(composer?.className).toContain("border-primary/45"); + + act(() => { + root.unmount(); + }); + }); + + it("shows non-image attachment upload state in the composer after a drop", 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 composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null; + const file = new File(["report body"], "report.pdf", { type: "application/pdf" }); + + await act(async () => { + composer?.dispatchEvent(createFileDragEvent("drop", [file])); + }); + + expect(onAttachImage).toHaveBeenCalledWith(file); + const attachmentList = container.querySelector('[data-testid="issue-chat-composer-attachments"]'); + expect(attachmentList).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 d6e930efe61..ac461fc0ac2 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, + )); } } @@ -2217,8 +2283,8 @@ const IssueChatComposer = forwardRef { if (!canAcceptFiles || !hasFilePayload(evt)) return; @@ -2246,6 +2312,25 @@ const IssueChatComposer = forwardRef + {isDragOver && canAcceptFiles ? ( +
+
+ + + +
+
Drop files to attach
+
+ 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) ? (
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);