diff --git a/ui/src/components/IssueAttachmentsSection.test.tsx b/ui/src/components/IssueAttachmentsSection.test.tsx new file mode 100644 index 00000000..7cec72e6 --- /dev/null +++ b/ui/src/components/IssueAttachmentsSection.test.tsx @@ -0,0 +1,190 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { IssueAttachment } from "@paperclipai/shared"; +import { act, type ComponentProps, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { IssueAttachmentsSection } from "./IssueAttachmentsSection"; + +vi.mock("./MarkdownBody", () => ({ + MarkdownBody: ({ children, className }: { children: string; className?: string }) => ( +
{children}
+ ), +})); + +vi.mock("./FoldCurtain", () => ({ + FoldCurtain: ({ children }: { children?: ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + asChild, + children, + onClick, + type = "button", + ...props + }: ComponentProps<"button"> & { asChild?: boolean }) => { + if (asChild) return <>{children}; + return ; + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function makeAttachment(overrides: Partial = {}): IssueAttachment { + return { + id: "attachment-1", + companyId: "company-1", + issueId: "issue-1", + issueCommentId: null, + assetId: "asset-1", + provider: "local_disk", + objectKey: "attachments/attachment-1", + contentType: "text/plain", + byteSize: 1024, + sha256: "sha", + originalFilename: "notes.txt", + createdByAgentId: null, + createdByUserId: "user-1", + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), + contentPath: "/api/attachments/attachment-1/content", + openPath: "/api/attachments/attachment-1/content", + downloadPath: "/api/attachments/attachment-1/content?download=1", + ...overrides, + }; +} + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("IssueAttachmentsSection", () => { + let container: HTMLDivElement; + let root: Root; + let queryClient: QueryClient; + let fetchSpy: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve("# Imported plan\n\n- Use the document renderer"), + }); + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("renders markdown attachments with the document markdown presentation", async () => { + const attachment = makeAttachment({ + id: "markdown-attachment", + originalFilename: "plan.md", + contentType: "text/plain", + contentPath: "/api/attachments/markdown-attachment/content", + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(fetchSpy).toHaveBeenCalledWith( + "/api/attachments/markdown-attachment/content", + expect.objectContaining({ + headers: expect.objectContaining({ Accept: expect.stringContaining("text/markdown") }), + }), + ); + expect(container.querySelector('[data-testid="fold-curtain"]')).toBeTruthy(); + const markdownBody = container.querySelector('[data-testid="markdown-body"]'); + expect(markdownBody?.textContent).toContain("Imported plan"); + expect(markdownBody?.className).toContain("paperclip-edit-in-place-content"); + }); + + it("renders video attachments through the same player used for artifact outputs", async () => { + const attachment = makeAttachment({ + id: "video-attachment", + originalFilename: "demo.webm", + contentType: "video/webm", + contentPath: "/api/attachments/video-attachment/content", + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + const video = container.querySelector("video"); + expect(video?.getAttribute("src")).toBe("/api/attachments/video-attachment/content"); + expect(video?.getAttribute("controls")).not.toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("keeps generic attachments as compact file rows with open and download actions", async () => { + const attachment = makeAttachment({ + id: "pdf-attachment", + originalFilename: "report.pdf", + contentType: "application/pdf", + contentPath: "/api/attachments/pdf-attachment/content", + openPath: "/api/attachments/pdf-attachment/content", + downloadPath: "/api/attachments/pdf-attachment/content?download=1", + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("report.pdf"); + expect(container.textContent).toContain("application/pdf"); + expect(container.querySelector('a[aria-label="Open report.pdf"]')?.getAttribute("href")).toBe( + "/api/attachments/pdf-attachment/content", + ); + expect(container.querySelector('a[aria-label="Download report.pdf"]')?.getAttribute("href")).toBe( + "/api/attachments/pdf-attachment/content?download=1", + ); + }); +}); diff --git a/ui/src/components/IssueAttachmentsSection.tsx b/ui/src/components/IssueAttachmentsSection.tsx new file mode 100644 index 00000000..b5607c08 --- /dev/null +++ b/ui/src/components/IssueAttachmentsSection.tsx @@ -0,0 +1,372 @@ +import { useMemo, useState, type DragEvent, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { IssueAttachment } from "@paperclipai/shared"; +import { Download, ExternalLink, FileText, Paperclip, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { FoldCurtain } from "./FoldCurtain"; +import { MarkdownBody } from "./MarkdownBody"; +import { OutputFileTile } from "./issue-output/OutputFileTile"; +import { OutputVideoPlayer } from "./issue-output/OutputVideoPlayer"; +import { formatBytes } from "@/lib/issue-output"; +import { + attachmentDownloadPath, + attachmentFilename, + attachmentOpenPath, + isImageAttachment, + isMarkdownAttachment, + isVideoAttachment, +} from "@/lib/issue-attachments"; +import { queryKeys } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; + +interface IssueAttachmentsSectionProps { + attachments: IssueAttachment[]; + uploadButton?: ReactNode; + error?: string | null; + dragActive?: boolean; + deletePending?: boolean; + onDelete: (attachmentId: string) => void; + onImageClick: (attachment: IssueAttachment) => void; + onDragEnter?: (evt: DragEvent) => void; + onDragOver?: (evt: DragEvent) => void; + onDragLeave?: (evt: DragEvent) => void; + onDrop?: (evt: DragEvent) => void; +} + +async function fetchAttachmentText(attachment: IssueAttachment) { + const response = await fetch(attachment.contentPath, { + headers: { Accept: "text/markdown,text/plain;q=0.9,*/*;q=0.1" }, + }); + if (!response.ok) { + throw new Error(`Unable to load attachment preview (${response.status})`); + } + return response.text(); +} + +function AttachmentActions({ + attachment, + onDelete, + deletePending, +}: { + attachment: IssueAttachment; + onDelete: (attachmentId: string) => void; + deletePending?: boolean; +}) { + const filename = attachmentFilename(attachment); + return ( +
+ + + +
+ ); +} + +function AttachmentMeta({ attachment }: { attachment: IssueAttachment }) { + return ( +

+ Attachment · {attachment.contentType} · {formatBytes(attachment.byteSize)} +

+ ); +} + +function MarkdownAttachmentCard({ + attachment, + onDelete, + deletePending, +}: { + attachment: IssueAttachment; + onDelete: (attachmentId: string) => void; + deletePending?: boolean; +}) { + const filename = attachmentFilename(attachment); + const { data, isLoading, error } = useQuery({ + queryKey: queryKeys.issues.attachmentPreview(attachment.id), + queryFn: () => fetchAttachmentText(attachment), + }); + + return ( +
+
+
+
+ + {filename} +
+ +
+ +
+
+ {isLoading ? ( +

Loading preview...

+ ) : error ? ( +

Could not load markdown preview.

+ ) : ( + + + {data ?? ""} + + + )} +
+
+ ); +} + +function VideoAttachmentCard({ + attachment, + onDelete, + deletePending, +}: { + attachment: IssueAttachment; + onDelete: (attachmentId: string) => void; + deletePending?: boolean; +}) { + const filename = attachmentFilename(attachment); + return ( +
+ +
+
+

{filename}

+ +
+ +
+
+ ); +} + +function GenericAttachmentRow({ + attachment, + onDelete, + deletePending, +}: { + attachment: IssueAttachment; + onDelete: (attachmentId: string) => void; + deletePending?: boolean; +}) { + const filename = attachmentFilename(attachment); + return ( +
+ +
+ + {filename} + +

+ Attachment · {attachment.contentType} · {formatBytes(attachment.byteSize)} +

+
+ +
+ ); +} + +export function IssueAttachmentsSection({ + attachments, + uploadButton, + error, + dragActive = false, + deletePending = false, + onDelete, + onImageClick, + onDragEnter, + onDragOver, + onDragLeave, + onDrop, +}: IssueAttachmentsSectionProps) { + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const { imageAttachments, markdownAttachments, videoAttachments, genericAttachments } = useMemo(() => { + const images: IssueAttachment[] = []; + const markdown: IssueAttachment[] = []; + const videos: IssueAttachment[] = []; + const generic: IssueAttachment[] = []; + + for (const attachment of attachments) { + if (isImageAttachment(attachment)) images.push(attachment); + else if (isMarkdownAttachment(attachment)) markdown.push(attachment); + else if (isVideoAttachment(attachment)) videos.push(attachment); + else generic.push(attachment); + } + + return { + imageAttachments: images, + markdownAttachments: markdown, + videoAttachments: videos, + genericAttachments: generic, + }; + }, [attachments]); + + const requestDelete = (attachmentId: string) => setConfirmDeleteId(attachmentId); + const confirmDelete = (attachmentId: string) => { + onDelete(attachmentId); + setConfirmDeleteId(null); + }; + + return ( +
+
+
+
+ {uploadButton} +
+ + {error && ( +

{error}

+ )} + + {imageAttachments.length > 0 && ( +
+ {imageAttachments.map((attachment) => ( +
onImageClick(attachment)} + > + {attachment.originalFilename +
+ {confirmDeleteId === attachment.id ? ( +
event.stopPropagation()} + > +

Delete?

+
+ + +
+
+ ) : ( + + )} +
+ ))} +
+ )} + + {markdownAttachments.length > 0 && ( +
+ {markdownAttachments.map((attachment) => ( + + ))} +
+ )} + + {videoAttachments.length > 0 && ( +
+ {videoAttachments.map((attachment) => ( + + ))} +
+ )} + + {genericAttachments.length > 0 && ( +
+ {genericAttachments.map((attachment) => ( + + ))} +
+ )} + + {confirmDeleteId && !imageAttachments.some((attachment) => attachment.id === confirmDeleteId) ? ( +
+

Delete this attachment? This cannot be undone.

+
+ + +
+
+ ) : null} +
+ ); +} diff --git a/ui/src/lib/issue-attachments.ts b/ui/src/lib/issue-attachments.ts new file mode 100644 index 00000000..b9dc1565 --- /dev/null +++ b/ui/src/lib/issue-attachments.ts @@ -0,0 +1,47 @@ +import type { IssueAttachment } from "@paperclipai/shared"; +import { isVideoContentType } from "./issue-output"; + +function normalizedContentType(attachment: Pick) { + return attachment.contentType.toLowerCase().split(";")[0]?.trim() ?? ""; +} + +export function attachmentFilename(attachment: Pick) { + return attachment.originalFilename ?? attachment.id; +} + +export function attachmentOpenPath( + attachment: Pick, +) { + return attachment.openPath ?? attachment.contentPath; +} + +export function attachmentDownloadPath( + attachment: Pick, +) { + return attachment.downloadPath ?? `${attachment.contentPath}?download=1`; +} + +export function isImageAttachment(attachment: Pick) { + return normalizedContentType(attachment).startsWith("image/"); +} + +export function isVideoAttachment(attachment: Pick) { + return isVideoContentType(normalizedContentType(attachment)); +} + +export function isMarkdownAttachment( + attachment: Pick, +) { + const contentType = normalizedContentType(attachment); + if ( + contentType === "text/markdown" || + contentType === "text/x-markdown" || + contentType === "application/markdown" || + contentType === "application/x-markdown" + ) { + return true; + } + + const filename = (attachment.originalFilename ?? "").toLowerCase(); + return filename.endsWith(".md") || filename.endsWith(".markdown"); +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index c590b0e1..a5517f34 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -67,6 +67,7 @@ export const queryKeys = { ? (["issues", "cost-summary", issueId, "exclude-root"] as const) : (["issues", "cost-summary", issueId] as const), attachments: (issueId: string) => ["issues", "attachments", issueId] as const, + attachmentPreview: (attachmentId: string) => ["issues", "attachment-preview", attachmentId] as const, documents: (issueId: string) => ["issues", "documents", issueId] as const, document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const, documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const, diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 54b468f1..ef55a0d7 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -13,6 +13,7 @@ const mockIssuesApi = vi.hoisted(() => ({ listAcceptedPlanDecompositions: vi.fn(), listComments: vi.fn(), listAttachments: vi.fn(), + listWorkProducts: vi.fn(), listFeedbackVotes: vi.fn(), markRead: vi.fn(), update: vi.fn(), @@ -801,6 +802,7 @@ describe("IssueDetail", () => { mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.listComments.mockResolvedValue([]); mockIssuesApi.listAttachments.mockResolvedValue([]); + mockIssuesApi.listWorkProducts.mockResolvedValue([]); mockIssuesApi.listFeedbackVotes.mockResolvedValue([]); mockIssuesApi.markRead.mockResolvedValue({ id: "issue-1", lastReadAt: new Date().toISOString() }); mockIssuesApi.getTreeControlState.mockResolvedValue({ activePauseHold: null }); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 5c7d4607..df0f9b65 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -65,10 +65,11 @@ import { ApprovalCard } from "../components/ApprovalCard"; import { InlineEditor } from "../components/InlineEditor"; import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread"; import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff"; +import { IssueAttachmentsSection } from "../components/IssueAttachmentsSection"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; import { IssuePlanDecompositionsSection } from "../components/IssuePlanDecompositionsSection"; import { IssueOutputSection } from "../components/issue-output/IssueOutputSection"; -import { formatBytes } from "../lib/issue-output"; +import { isImageAttachment } from "../lib/issue-attachments"; import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation"; import { IssuesList } from "../components/IssuesList"; import { AgentIcon } from "../components/AgentIconPicker"; @@ -137,7 +138,6 @@ import { Plus, Repeat, SlidersHorizontal, - Trash2, XCircle, } from "lucide-react"; import { @@ -1248,7 +1248,6 @@ export function IssueDetail() { approvalId: string; action: "approve" | "reject"; } | null>(null); - const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [attachmentError, setAttachmentError] = useState(null); const [attachmentDragActive, setAttachmentDragActive] = useState(false); const [galleryOpen, setGalleryOpen] = useState(false); @@ -2828,10 +2827,8 @@ export function IssueDetail() { commentComposerRef.current?.focus(); }, [detailTab, pendingCommentComposerFocusKey]); - const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/"); const attachmentList = attachments ?? []; const imageAttachments = attachmentList.filter(isImageAttachment); - const nonImageAttachments = attachmentList.filter((a) => !isImageAttachment(a)); const handleChatImageClick = useCallback( (src: string) => { @@ -3772,133 +3769,32 @@ export function IssueDetail() { {attachmentsInitialLoading ? ( ) : hasAttachments ? ( -
{ - evt.preventDefault(); - setAttachmentDragActive(true); - }} - onDragOver={(evt) => { - evt.preventDefault(); - setAttachmentDragActive(true); - }} - onDragLeave={(evt) => { - if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return; - setAttachmentDragActive(false); - }} - onDrop={(evt) => void handleAttachmentDrop(evt)} - > -
-

Attachments

- {attachmentUploadButton} -
- - {attachmentError && ( -

{attachmentError}

- )} - - {imageAttachments.length > 0 && ( -
- {imageAttachments.map((attachment) => ( -
{ - const idx = imageAttachments.findIndex((a) => a.id === attachment.id); - setGalleryIndex(idx >= 0 ? idx : 0); - setGalleryOpen(true); - }} - > - {attachment.originalFilename -
- {confirmDeleteId === attachment.id ? ( -
e.stopPropagation()} - > -

Delete?

-
- - -
-
- ) : ( - - )} -
- ))} -
- )} - - {nonImageAttachments.length > 0 && ( -
- {nonImageAttachments.map((attachment) => ( -
-
- - {attachment.originalFilename ?? attachment.id} - - -
-

- {attachment.contentType} · {formatBytes(attachment.byteSize)} -

-
- ))} -
- )} -
+ deleteAttachment.mutate(attachmentId)} + onImageClick={(attachment) => { + const idx = imageAttachments.findIndex((a) => a.id === attachment.id); + setGalleryIndex(idx >= 0 ? idx : 0); + setGalleryOpen(true); + }} + onDragEnter={(evt) => { + evt.preventDefault(); + setAttachmentDragActive(true); + }} + onDragOver={(evt) => { + evt.preventDefault(); + setAttachmentDragActive(true); + }} + onDragLeave={(evt) => { + if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return; + setAttachmentDragActive(false); + }} + onDrop={(evt) => void handleAttachmentDrop(evt)} + /> ) : null}