mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Render rich issue attachment previews
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
96feaa331a
commit
e86d000c7b
6 changed files with 640 additions and 132 deletions
190
ui/src/components/IssueAttachmentsSection.test.tsx
Normal file
190
ui/src/components/IssueAttachmentsSection.test.tsx
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
372
ui/src/components/IssueAttachmentsSection.tsx
Normal file
372
ui/src/components/IssueAttachmentsSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
ui/src/lib/issue-attachments.ts
Normal file
47
ui/src/lib/issue-attachments.ts
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue