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] 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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(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 ? (
|
||||
<IssueSectionSkeleton titleWidth="w-24" rows={2} />
|
||||
) : hasAttachments ? (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-3 rounded-lg transition-colors",
|
||||
)}
|
||||
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)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3>
|
||||
{attachmentUploadButton}
|
||||
</div>
|
||||
|
||||
{attachmentError && (
|
||||
<p className="text-xs text-destructive">{attachmentError}</p>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<IssueAttachmentsSection
|
||||
attachments={attachmentList}
|
||||
uploadButton={attachmentUploadButton}
|
||||
error={attachmentError}
|
||||
dragActive={attachmentDragActive}
|
||||
deletePending={deleteAttachment.isPending}
|
||||
onDelete={(attachmentId) => 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}
|
||||
|
||||
<ImageGalleryModal
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue