Render rich issue attachment previews

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-06-01 17:40:00 +00:00
parent 96feaa331a
commit e86d000c7b
6 changed files with 640 additions and 132 deletions

View file

@ -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 }) => (
<div className={className} data-testid="markdown-body">{children}</div>
),
}));
vi.mock("./FoldCurtain", () => ({
FoldCurtain: ({ children }: { children?: ReactNode }) => <div data-testid="fold-curtain">{children}</div>,
}));
vi.mock("@/components/ui/button", () => ({
Button: ({
asChild,
children,
onClick,
type = "button",
...props
}: ComponentProps<"button"> & { asChild?: boolean }) => {
if (asChild) return <>{children}</>;
return <button type={type} onClick={onClick} {...props}>{children}</button>;
},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function makeAttachment(overrides: Partial<IssueAttachment> = {}): 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<typeof vi.fn>;
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(
<QueryClientProvider client={queryClient}>
<IssueAttachmentsSection
attachments={[attachment]}
onDelete={vi.fn()}
onImageClick={vi.fn()}
/>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<IssueAttachmentsSection
attachments={[attachment]}
onDelete={vi.fn()}
onImageClick={vi.fn()}
/>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<IssueAttachmentsSection
attachments={[attachment]}
onDelete={vi.fn()}
onImageClick={vi.fn()}
/>
</QueryClientProvider>,
);
});
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",
);
});
});

View file

@ -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<HTMLDivElement>) => void;
onDragOver?: (evt: DragEvent<HTMLDivElement>) => void;
onDragLeave?: (evt: DragEvent<HTMLDivElement>) => void;
onDrop?: (evt: DragEvent<HTMLDivElement>) => 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 (
<div className="flex shrink-0 items-center gap-1">
<Button asChild variant="ghost" size="icon-sm" title="Open in new tab">
<a href={attachmentOpenPath(attachment)} target="_blank" rel="noreferrer" aria-label={`Open ${filename}`}>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
<Button asChild variant="ghost" size="icon-sm" title="Download">
<a href={attachmentDownloadPath(attachment)} aria-label={`Download ${filename}`}>
<Download className="h-4 w-4" />
</a>
</Button>
<Button
variant="ghost"
size="icon-sm"
title="Delete attachment"
className="text-muted-foreground hover:text-destructive"
onClick={() => onDelete(attachment.id)}
disabled={deletePending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
}
function AttachmentMeta({ attachment }: { attachment: IssueAttachment }) {
return (
<p className="mt-0.5 text-[11px] text-muted-foreground">
Attachment · {attachment.contentType} · {formatBytes(attachment.byteSize)}
</p>
);
}
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 (
<div className="rounded-lg border border-border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium" title={filename}>{filename}</span>
</div>
<AttachmentMeta attachment={attachment} />
</div>
<AttachmentActions attachment={attachment} onDelete={onDelete} deletePending={deletePending} />
</div>
<div className="mt-3 rounded-md hover:bg-accent/10">
{isLoading ? (
<p className="px-1 py-2 text-xs text-muted-foreground">Loading preview...</p>
) : error ? (
<p className="px-1 py-2 text-xs text-destructive">Could not load markdown preview.</p>
) : (
<FoldCurtain>
<MarkdownBody className="paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7" softBreaks={false}>
{data ?? ""}
</MarkdownBody>
</FoldCurtain>
)}
</div>
</div>
);
}
function VideoAttachmentCard({
attachment,
onDelete,
deletePending,
}: {
attachment: IssueAttachment;
onDelete: (attachmentId: string) => void;
deletePending?: boolean;
}) {
const filename = attachmentFilename(attachment);
return (
<div className="overflow-hidden rounded-md border border-border bg-card">
<OutputVideoPlayer src={attachment.contentPath} title={filename} />
<div className="flex flex-col gap-2 p-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<p className="break-words text-sm font-semibold text-foreground">{filename}</p>
<AttachmentMeta attachment={attachment} />
</div>
<AttachmentActions attachment={attachment} onDelete={onDelete} deletePending={deletePending} />
</div>
</div>
);
}
function GenericAttachmentRow({
attachment,
onDelete,
deletePending,
}: {
attachment: IssueAttachment;
onDelete: (attachmentId: string) => void;
deletePending?: boolean;
}) {
const filename = attachmentFilename(attachment);
return (
<div className="flex items-center gap-2.5 rounded-md border border-border bg-card p-2">
<OutputFileTile contentType={attachment.contentType} />
<div className="min-w-0 flex-1">
<a
href={attachmentOpenPath(attachment)}
target="_blank"
rel="noreferrer"
className="block truncate text-sm font-medium text-foreground hover:underline"
title={filename}
>
{filename}
</a>
<p className="truncate text-[11px] text-muted-foreground">
Attachment · {attachment.contentType} · {formatBytes(attachment.byteSize)}
</p>
</div>
<AttachmentActions attachment={attachment} onDelete={onDelete} deletePending={deletePending} />
</div>
);
}
export function IssueAttachmentsSection({
attachments,
uploadButton,
error,
dragActive = false,
deletePending = false,
onDelete,
onImageClick,
onDragEnter,
onDragOver,
onDragLeave,
onDrop,
}: IssueAttachmentsSectionProps) {
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(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 (
<div
className={cn(
"space-y-3 rounded-lg transition-colors",
dragActive && "bg-primary/5",
)}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Paperclip className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3>
<span className="text-xs text-muted-foreground">{attachments.length}</span>
</div>
{uploadButton}
</div>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
{imageAttachments.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{imageAttachments.map((attachment) => (
<div
key={attachment.id}
className="group relative aspect-square cursor-pointer overflow-hidden rounded-lg border border-border bg-accent/10"
onClick={() => onImageClick(attachment)}
>
<img
src={attachment.contentPath}
alt={attachment.originalFilename ?? "attachment"}
className="h-full w-full object-cover"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/30" />
{confirmDeleteId === attachment.id ? (
<div
className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/60"
onClick={(event) => event.stopPropagation()}
>
<p className="text-xs font-medium text-white">Delete?</p>
<div className="flex gap-1.5">
<button
type="button"
className="rounded bg-destructive px-2 py-0.5 text-xs text-white hover:bg-destructive/80"
onClick={(event) => {
event.stopPropagation();
confirmDelete(attachment.id);
}}
disabled={deletePending}
>
Yes
</button>
<button
type="button"
className="rounded bg-muted px-2 py-0.5 text-xs hover:bg-muted/80"
onClick={(event) => {
event.stopPropagation();
setConfirmDeleteId(null);
}}
>
No
</button>
</div>
</div>
) : (
<button
type="button"
className="absolute right-1.5 top-1.5 rounded-md bg-black/50 p-1 text-white opacity-0 transition-opacity hover:bg-destructive group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
requestDelete(attachment.id);
}}
title="Delete attachment"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
)}
{markdownAttachments.length > 0 && (
<div className="space-y-3">
{markdownAttachments.map((attachment) => (
<MarkdownAttachmentCard
key={attachment.id}
attachment={attachment}
onDelete={requestDelete}
deletePending={deletePending}
/>
))}
</div>
)}
{videoAttachments.length > 0 && (
<div className="space-y-3">
{videoAttachments.map((attachment) => (
<VideoAttachmentCard
key={attachment.id}
attachment={attachment}
onDelete={requestDelete}
deletePending={deletePending}
/>
))}
</div>
)}
{genericAttachments.length > 0 && (
<div className="space-y-2">
{genericAttachments.map((attachment) => (
<GenericAttachmentRow
key={attachment.id}
attachment={attachment}
onDelete={requestDelete}
deletePending={deletePending}
/>
))}
</div>
)}
{confirmDeleteId && !imageAttachments.some((attachment) => attachment.id === confirmDeleteId) ? (
<div className="flex items-center justify-between gap-3 rounded-md border border-destructive/20 bg-destructive/5 px-4 py-3">
<p className="text-sm font-medium text-destructive">Delete this attachment? This cannot be undone.</p>
<div className="flex shrink-0 items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setConfirmDeleteId(null)} disabled={deletePending}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={() => confirmDelete(confirmDeleteId)} disabled={deletePending}>
{deletePending ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,47 @@
import type { IssueAttachment } from "@paperclipai/shared";
import { isVideoContentType } from "./issue-output";
function normalizedContentType(attachment: Pick<IssueAttachment, "contentType">) {
return attachment.contentType.toLowerCase().split(";")[0]?.trim() ?? "";
}
export function attachmentFilename(attachment: Pick<IssueAttachment, "id" | "originalFilename">) {
return attachment.originalFilename ?? attachment.id;
}
export function attachmentOpenPath(
attachment: Pick<IssueAttachment, "contentPath" | "openPath">,
) {
return attachment.openPath ?? attachment.contentPath;
}
export function attachmentDownloadPath(
attachment: Pick<IssueAttachment, "contentPath" | "downloadPath">,
) {
return attachment.downloadPath ?? `${attachment.contentPath}?download=1`;
}
export function isImageAttachment(attachment: Pick<IssueAttachment, "contentType">) {
return normalizedContentType(attachment).startsWith("image/");
}
export function isVideoAttachment(attachment: Pick<IssueAttachment, "contentType">) {
return isVideoContentType(normalizedContentType(attachment));
}
export function isMarkdownAttachment(
attachment: Pick<IssueAttachment, "contentType" | "originalFilename">,
) {
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");
}

View file

@ -67,6 +67,7 @@ export const queryKeys = {
? (["issues", "cost-summary", issueId, "exclude-root"] as const) ? (["issues", "cost-summary", issueId, "exclude-root"] as const)
: (["issues", "cost-summary", issueId] as const), : (["issues", "cost-summary", issueId] as const),
attachments: (issueId: string) => ["issues", "attachments", 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, documents: (issueId: string) => ["issues", "documents", issueId] as const,
document: (issueId: string, key: string) => ["issues", "document", issueId, key] 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, documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const,

View file

@ -13,6 +13,7 @@ const mockIssuesApi = vi.hoisted(() => ({
listAcceptedPlanDecompositions: vi.fn(), listAcceptedPlanDecompositions: vi.fn(),
listComments: vi.fn(), listComments: vi.fn(),
listAttachments: vi.fn(), listAttachments: vi.fn(),
listWorkProducts: vi.fn(),
listFeedbackVotes: vi.fn(), listFeedbackVotes: vi.fn(),
markRead: vi.fn(), markRead: vi.fn(),
update: vi.fn(), update: vi.fn(),
@ -801,6 +802,7 @@ describe("IssueDetail", () => {
mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.listComments.mockResolvedValue([]); mockIssuesApi.listComments.mockResolvedValue([]);
mockIssuesApi.listAttachments.mockResolvedValue([]); mockIssuesApi.listAttachments.mockResolvedValue([]);
mockIssuesApi.listWorkProducts.mockResolvedValue([]);
mockIssuesApi.listFeedbackVotes.mockResolvedValue([]); mockIssuesApi.listFeedbackVotes.mockResolvedValue([]);
mockIssuesApi.markRead.mockResolvedValue({ id: "issue-1", lastReadAt: new Date().toISOString() }); mockIssuesApi.markRead.mockResolvedValue({ id: "issue-1", lastReadAt: new Date().toISOString() });
mockIssuesApi.getTreeControlState.mockResolvedValue({ activePauseHold: null }); mockIssuesApi.getTreeControlState.mockResolvedValue({ activePauseHold: null });

View file

@ -65,10 +65,11 @@ import { ApprovalCard } from "../components/ApprovalCard";
import { InlineEditor } from "../components/InlineEditor"; import { InlineEditor } from "../components/InlineEditor";
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread"; import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff"; import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
import { IssueAttachmentsSection } from "../components/IssueAttachmentsSection";
import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
import { IssuePlanDecompositionsSection } from "../components/IssuePlanDecompositionsSection"; import { IssuePlanDecompositionsSection } from "../components/IssuePlanDecompositionsSection";
import { IssueOutputSection } from "../components/issue-output/IssueOutputSection"; 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 { IssueSiblingNavigation } from "../components/IssueSiblingNavigation";
import { IssuesList } from "../components/IssuesList"; import { IssuesList } from "../components/IssuesList";
import { AgentIcon } from "../components/AgentIconPicker"; import { AgentIcon } from "../components/AgentIconPicker";
@ -137,7 +138,6 @@ import {
Plus, Plus,
Repeat, Repeat,
SlidersHorizontal, SlidersHorizontal,
Trash2,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { import {
@ -1248,7 +1248,6 @@ export function IssueDetail() {
approvalId: string; approvalId: string;
action: "approve" | "reject"; action: "approve" | "reject";
} | null>(null); } | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [attachmentError, setAttachmentError] = useState<string | null>(null); const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [attachmentDragActive, setAttachmentDragActive] = useState(false); const [attachmentDragActive, setAttachmentDragActive] = useState(false);
const [galleryOpen, setGalleryOpen] = useState(false); const [galleryOpen, setGalleryOpen] = useState(false);
@ -2828,10 +2827,8 @@ export function IssueDetail() {
commentComposerRef.current?.focus(); commentComposerRef.current?.focus();
}, [detailTab, pendingCommentComposerFocusKey]); }, [detailTab, pendingCommentComposerFocusKey]);
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
const attachmentList = attachments ?? []; const attachmentList = attachments ?? [];
const imageAttachments = attachmentList.filter(isImageAttachment); const imageAttachments = attachmentList.filter(isImageAttachment);
const nonImageAttachments = attachmentList.filter((a) => !isImageAttachment(a));
const handleChatImageClick = useCallback( const handleChatImageClick = useCallback(
(src: string) => { (src: string) => {
@ -3772,133 +3769,32 @@ export function IssueDetail() {
{attachmentsInitialLoading ? ( {attachmentsInitialLoading ? (
<IssueSectionSkeleton titleWidth="w-24" rows={2} /> <IssueSectionSkeleton titleWidth="w-24" rows={2} />
) : hasAttachments ? ( ) : hasAttachments ? (
<div <IssueAttachmentsSection
className={cn( attachments={attachmentList}
"space-y-3 rounded-lg transition-colors", uploadButton={attachmentUploadButton}
)} error={attachmentError}
onDragEnter={(evt) => { dragActive={attachmentDragActive}
evt.preventDefault(); deletePending={deleteAttachment.isPending}
setAttachmentDragActive(true); onDelete={(attachmentId) => deleteAttachment.mutate(attachmentId)}
}} onImageClick={(attachment) => {
onDragOver={(evt) => { const idx = imageAttachments.findIndex((a) => a.id === attachment.id);
evt.preventDefault(); setGalleryIndex(idx >= 0 ? idx : 0);
setAttachmentDragActive(true); setGalleryOpen(true);
}} }}
onDragLeave={(evt) => { onDragEnter={(evt) => {
if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return; evt.preventDefault();
setAttachmentDragActive(false); setAttachmentDragActive(true);
}} }}
onDrop={(evt) => void handleAttachmentDrop(evt)} onDragOver={(evt) => {
> evt.preventDefault();
<div className="flex items-center justify-between gap-2"> setAttachmentDragActive(true);
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3> }}
{attachmentUploadButton} onDragLeave={(evt) => {
</div> if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return;
setAttachmentDragActive(false);
{attachmentError && ( }}
<p className="text-xs text-destructive">{attachmentError}</p> onDrop={(evt) => void handleAttachmentDrop(evt)}
)} />
{imageAttachments.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{imageAttachments.map((attachment) => (
<div
key={attachment.id}
className="group relative aspect-square rounded-lg overflow-hidden border border-border bg-accent/10 cursor-pointer"
onClick={() => {
const idx = imageAttachments.findIndex((a) => a.id === attachment.id);
setGalleryIndex(idx >= 0 ? idx : 0);
setGalleryOpen(true);
}}
>
<img
src={attachment.contentPath}
alt={attachment.originalFilename ?? "attachment"}
className="h-full w-full object-cover"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors" />
{confirmDeleteId === attachment.id ? (
<div
className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/60"
onClick={(e) => e.stopPropagation()}
>
<p className="text-xs text-white font-medium">Delete?</p>
<div className="flex gap-1.5">
<button
type="button"
className="rounded bg-destructive px-2 py-0.5 text-xs text-white hover:bg-destructive/80"
onClick={(e) => {
e.stopPropagation();
deleteAttachment.mutate(attachment.id);
setConfirmDeleteId(null);
}}
disabled={deleteAttachment.isPending}
>
Yes
</button>
<button
type="button"
className="rounded bg-muted px-2 py-0.5 text-xs hover:bg-muted/80"
onClick={(e) => {
e.stopPropagation();
setConfirmDeleteId(null);
}}
>
No
</button>
</div>
</div>
) : (
<button
type="button"
className="absolute top-1.5 right-1.5 rounded-md bg-black/50 p-1 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive"
onClick={(e) => {
e.stopPropagation();
setConfirmDeleteId(attachment.id);
}}
title="Delete attachment"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
)}
{nonImageAttachments.length > 0 && (
<div className="space-y-2">
{nonImageAttachments.map((attachment) => (
<div key={attachment.id} className="border border-border rounded-md p-2">
<div className="flex items-center justify-between gap-2">
<a
href={attachment.contentPath}
target="_blank"
rel="noreferrer"
className="text-xs hover:underline truncate"
title={attachment.originalFilename ?? attachment.id}
>
{attachment.originalFilename ?? attachment.id}
</a>
<button
type="button"
className="text-muted-foreground hover:text-destructive"
onClick={() => deleteAttachment.mutate(attachment.id)}
disabled={deleteAttachment.isPending}
title="Delete attachment"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<p className="text-[11px] text-muted-foreground">
{attachment.contentType} · {formatBytes(attachment.byteSize)}
</p>
</div>
))}
</div>
)}
</div>
) : null} ) : null}
<ImageGalleryModal <ImageGalleryModal