Merge pull request #7361 from paperclipai/pap-10195-attachment-previews

[codex] Render rich issue attachment previews
This commit is contained in:
Dotta
2026-06-01 15:32:45 -10:00
committed by GitHub
8 changed files with 834 additions and 135 deletions
@@ -412,6 +412,25 @@ describe("issue attachment routes", () => {
);
});
it("serves mp4 attachments inline when stored with a generic binary content type", async () => {
const storage = createStorageService(Buffer.from("abcdef"));
mockIssueService.getAttachmentById.mockResolvedValue({
...makeAttachment("application/octet-stream", "clip.mp4"),
byteSize: 6,
});
const app = await createApp(storage);
const res = await request(app)
.get("/api/attachments/attachment-1/content")
.set("Range", "bytes=1-3");
expect(res.status).toBe(206);
expect(res.headers["content-type"]).toContain("video/mp4");
expect(res.headers["content-disposition"]).toBe('inline; filename="clip.mp4"');
expect(res.headers["content-range"]).toBe("bytes 1-3/6");
expect(Buffer.from(res.body).toString("utf8")).toBe("bcd");
});
it("forces video downloads when the download path is requested", async () => {
const storage = createStorageService();
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("video/webm", "clip.webm"));
+29 -1
View File
@@ -185,6 +185,30 @@ function buildAttachmentContentPath(attachmentId: string): string {
return `/api/attachments/${attachmentId}/content`;
}
const GENERIC_ATTACHMENT_CONTENT_TYPES = new Set([
"application/octet-stream",
"binary/octet-stream",
"application/x-binary",
]);
function inferVideoContentTypeFromFilename(filename: string | null | undefined): string | null {
const lower = (filename ?? "").toLowerCase();
if (lower.endsWith(".mp4") || lower.endsWith(".m4v")) return "video/mp4";
if (lower.endsWith(".webm")) return "video/webm";
if (lower.endsWith(".mov") || lower.endsWith(".qt") || lower.endsWith(".quicktime")) return "video/quicktime";
return null;
}
function resolveAttachmentResponseContentType(input: {
storedContentType: string | null | undefined;
objectContentType?: string | null;
originalFilename?: string | null;
}) {
const storedContentType = normalizeContentType(input.storedContentType || input.objectContentType);
if (!GENERIC_ATTACHMENT_CONTENT_TYPES.has(storedContentType)) return storedContentType;
return inferVideoContentTypeFromFilename(input.originalFilename) ?? storedContentType;
}
function requiresPaperclipAttachmentMetadata(input: {
type?: unknown;
provider?: unknown;
@@ -6267,7 +6291,11 @@ export function issueRoutes(
attachment.objectKey,
range.kind === "range" ? { range: { start: range.start, end: range.end } } : undefined,
);
const responseContentType = normalizeContentType(attachment.contentType || object.contentType);
const responseContentType = resolveAttachmentResponseContentType({
storedContentType: attachment.contentType,
objectContentType: object.contentType,
originalFilename: attachment.originalFilename,
});
res.setHeader("Content-Type", responseContentType);
res.setHeader("Cache-Control", "private, max-age=60");
res.setHeader("X-Content-Type-Options", "nosniff");
@@ -0,0 +1,300 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { IssueAttachment } from "@paperclipai/shared";
import type { ComponentProps, ReactNode } from "react";
import { flushSync } from "react-dom";
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;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
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: "att-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));
});
}
async function waitForAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let index = 0; index < attempts; index += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await flushReact();
}
}
throw lastError;
}
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") }),
}),
);
await waitForAssertion(() => {
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("does not promote specific non-markdown content types by filename alone", async () => {
const attachment = makeAttachment({
id: "zip-markdown",
originalFilename: "report.md",
contentType: "application/zip",
contentPath: "/api/attachments/zip-markdown/content",
openPath: "/api/attachments/zip-markdown/content",
downloadPath: "/api/attachments/zip-markdown/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.querySelector('[data-testid="markdown-body"]')).toBeNull();
expect(container.textContent).toContain("report.md");
expect(container.textContent).toContain("application/zip");
expect(fetchSpy).not.toHaveBeenCalled();
});
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("treats mp4 filenames as playable videos even with a generic binary content type", async () => {
const attachment = makeAttachment({
id: "misclassified-mp4",
originalFilename: "demo.mp4",
contentType: "application/octet-stream",
contentPath: "/api/attachments/misclassified-mp4/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/misclassified-mp4/content");
expect(container.textContent).toContain("application/octet-stream");
expect(fetchSpy).not.toHaveBeenCalled();
});
it("does not promote specific non-video content types by filename alone", async () => {
const attachment = makeAttachment({
id: "zip-mp4",
originalFilename: "bundle.mp4",
contentType: "application/zip",
contentPath: "/api/attachments/zip-mp4/content",
openPath: "/api/attachments/zip-mp4/content",
downloadPath: "/api/attachments/zip-mp4/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.querySelector("video")).toBeNull();
expect(container.textContent).toContain("bundle.mp4");
expect(container.textContent).toContain("application/zip");
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",
);
});
});
@@ -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>
);
}
+68
View File
@@ -0,0 +1,68 @@
import type { IssueAttachment } from "@paperclipai/shared";
import { isVideoContentType } from "./issue-output";
const GENERIC_ATTACHMENT_CONTENT_TYPES = new Set([
"application/octet-stream",
"binary/octet-stream",
"application/x-binary",
]);
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" | "originalFilename">,
) {
const contentType = normalizedContentType(attachment);
if (isVideoContentType(contentType)) return true;
if (!GENERIC_ATTACHMENT_CONTENT_TYPES.has(contentType)) return false;
const filename = (attachment.originalFilename ?? "").toLowerCase();
return (
filename.endsWith(".mp4") ||
filename.endsWith(".m4v") ||
filename.endsWith(".webm") ||
filename.endsWith(".mov") ||
filename.endsWith(".qt") ||
filename.endsWith(".quicktime")
);
}
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();
if (!filename.endsWith(".md") && !filename.endsWith(".markdown")) return false;
return contentType === "text/plain" || GENERIC_ATTACHMENT_CONTENT_TYPES.has(contentType);
}
+1
View File
@@ -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,
+17 -2
View File
@@ -2,7 +2,8 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent, Issue, IssueTreeControlPreview, IssueTreeHold } from "@paperclipai/shared";
import { act, type AnchorHTMLAttributes, type ButtonHTMLAttributes, type ReactNode } from "react";
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { canBoardResolveRecoveryAction, IssueDetail } from "./IssueDetail";
@@ -13,6 +14,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(),
@@ -356,6 +358,14 @@ vi.mock("@/components/ui/textarea", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((innerResolve) => {
@@ -801,6 +811,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 });
@@ -861,7 +872,11 @@ describe("IssueDetail", () => {
expect(container.textContent).toContain("Issue detail smoke");
expect(container.textContent).toContain("Chat thread");
expect(consoleErrorSpy).not.toHaveBeenCalled();
expect(
consoleErrorSpy.mock.calls.some((call) =>
String(call[0]).includes("React has detected a change in the order of Hooks"),
),
).toBe(false);
});
it("hides the plan decomposition panel by default", async () => {
+28 -132
View File
@@ -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