diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts
index 7b1f57a5..4bcb2410 100644
--- a/server/src/__tests__/issue-attachment-routes.test.ts
+++ b/server/src/__tests__/issue-attachment-routes.test.ts
@@ -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"));
diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts
index ba07190f..85492638 100644
--- a/server/src/routes/issues.ts
+++ b/server/src/routes/issues.ts
@@ -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");
diff --git a/ui/src/components/IssueAttachmentsSection.test.tsx b/ui/src/components/IssueAttachmentsSection.test.tsx
new file mode 100644
index 00000000..d9c3ff1b
--- /dev/null
+++ b/ui/src/components/IssueAttachmentsSection.test.tsx
@@ -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 }) => (
+
{children}
+ ),
+}));
+
+vi.mock("./FoldCurtain", () => ({
+ FoldCurtain: ({ children }: { children?: ReactNode }) => {children}
,
+}));
+
+vi.mock("@/components/ui/button", () => ({
+ Button: ({
+ asChild,
+ children,
+ onClick,
+ type = "button",
+ ...props
+ }: ComponentProps<"button"> & { asChild?: boolean }) => {
+ if (asChild) return <>{children}>;
+ return ;
+ },
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+async function act(callback: () => void | Promise) {
+ let result: void | Promise = undefined;
+ flushSync(() => {
+ result = callback();
+ });
+ await result;
+}
+
+function makeAttachment(overrides: Partial = {}): 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;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+ });
+ fetchSpy = vi.fn().mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve("# Imported plan\n\n- Use the document renderer"),
+ });
+ vi.stubGlobal("fetch", fetchSpy);
+ });
+
+ afterEach(async () => {
+ await act(async () => {
+ root.unmount();
+ });
+ queryClient.clear();
+ container.remove();
+ vi.unstubAllGlobals();
+ });
+
+ it("renders markdown attachments with the document markdown presentation", async () => {
+ const attachment = makeAttachment({
+ id: "markdown-attachment",
+ originalFilename: "plan.md",
+ contentType: "text/plain",
+ contentPath: "/api/attachments/markdown-attachment/content",
+ });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+
+ expect(fetchSpy).toHaveBeenCalledWith(
+ "/api/attachments/markdown-attachment/content",
+ expect.objectContaining({
+ headers: expect.objectContaining({ Accept: expect.stringContaining("text/markdown") }),
+ }),
+ );
+ 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(
+
+
+ ,
+ );
+ });
+ 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(
+
+
+ ,
+ );
+ });
+ 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(
+
+
+ ,
+ );
+ });
+ 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(
+
+
+ ,
+ );
+ });
+ 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(
+
+
+ ,
+ );
+ });
+ await flushReact();
+
+ expect(container.textContent).toContain("report.pdf");
+ expect(container.textContent).toContain("application/pdf");
+ expect(container.querySelector('a[aria-label="Open report.pdf"]')?.getAttribute("href")).toBe(
+ "/api/attachments/pdf-attachment/content",
+ );
+ expect(container.querySelector('a[aria-label="Download report.pdf"]')?.getAttribute("href")).toBe(
+ "/api/attachments/pdf-attachment/content?download=1",
+ );
+ });
+});
diff --git a/ui/src/components/IssueAttachmentsSection.tsx b/ui/src/components/IssueAttachmentsSection.tsx
new file mode 100644
index 00000000..b5607c08
--- /dev/null
+++ b/ui/src/components/IssueAttachmentsSection.tsx
@@ -0,0 +1,372 @@
+import { useMemo, useState, type DragEvent, type ReactNode } from "react";
+import { useQuery } from "@tanstack/react-query";
+import type { IssueAttachment } from "@paperclipai/shared";
+import { Download, ExternalLink, FileText, Paperclip, Trash2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { FoldCurtain } from "./FoldCurtain";
+import { MarkdownBody } from "./MarkdownBody";
+import { OutputFileTile } from "./issue-output/OutputFileTile";
+import { OutputVideoPlayer } from "./issue-output/OutputVideoPlayer";
+import { formatBytes } from "@/lib/issue-output";
+import {
+ attachmentDownloadPath,
+ attachmentFilename,
+ attachmentOpenPath,
+ isImageAttachment,
+ isMarkdownAttachment,
+ isVideoAttachment,
+} from "@/lib/issue-attachments";
+import { queryKeys } from "@/lib/queryKeys";
+import { cn } from "@/lib/utils";
+
+interface IssueAttachmentsSectionProps {
+ attachments: IssueAttachment[];
+ uploadButton?: ReactNode;
+ error?: string | null;
+ dragActive?: boolean;
+ deletePending?: boolean;
+ onDelete: (attachmentId: string) => void;
+ onImageClick: (attachment: IssueAttachment) => void;
+ onDragEnter?: (evt: DragEvent) => void;
+ onDragOver?: (evt: DragEvent) => void;
+ onDragLeave?: (evt: DragEvent) => void;
+ onDrop?: (evt: DragEvent) => void;
+}
+
+async function fetchAttachmentText(attachment: IssueAttachment) {
+ const response = await fetch(attachment.contentPath, {
+ headers: { Accept: "text/markdown,text/plain;q=0.9,*/*;q=0.1" },
+ });
+ if (!response.ok) {
+ throw new Error(`Unable to load attachment preview (${response.status})`);
+ }
+ return response.text();
+}
+
+function AttachmentActions({
+ attachment,
+ onDelete,
+ deletePending,
+}: {
+ attachment: IssueAttachment;
+ onDelete: (attachmentId: string) => void;
+ deletePending?: boolean;
+}) {
+ const filename = attachmentFilename(attachment);
+ return (
+
+
+
+
+
+ );
+}
+
+function AttachmentMeta({ attachment }: { attachment: IssueAttachment }) {
+ return (
+
+ Attachment · {attachment.contentType} · {formatBytes(attachment.byteSize)}
+
+ );
+}
+
+function MarkdownAttachmentCard({
+ attachment,
+ onDelete,
+ deletePending,
+}: {
+ attachment: IssueAttachment;
+ onDelete: (attachmentId: string) => void;
+ deletePending?: boolean;
+}) {
+ const filename = attachmentFilename(attachment);
+ const { data, isLoading, error } = useQuery({
+ queryKey: queryKeys.issues.attachmentPreview(attachment.id),
+ queryFn: () => fetchAttachmentText(attachment),
+ });
+
+ return (
+
+
+
+ {isLoading ? (
+
Loading preview...
+ ) : error ? (
+
Could not load markdown preview.
+ ) : (
+
+
+ {data ?? ""}
+
+
+ )}
+
+
+ );
+}
+
+function VideoAttachmentCard({
+ attachment,
+ onDelete,
+ deletePending,
+}: {
+ attachment: IssueAttachment;
+ onDelete: (attachmentId: string) => void;
+ deletePending?: boolean;
+}) {
+ const filename = attachmentFilename(attachment);
+ return (
+
+ );
+}
+
+function GenericAttachmentRow({
+ attachment,
+ onDelete,
+ deletePending,
+}: {
+ attachment: IssueAttachment;
+ onDelete: (attachmentId: string) => void;
+ deletePending?: boolean;
+}) {
+ const filename = attachmentFilename(attachment);
+ return (
+
+
+
+
+ {filename}
+
+
+ Attachment · {attachment.contentType} · {formatBytes(attachment.byteSize)}
+
+
+
+
+ );
+}
+
+export function IssueAttachmentsSection({
+ attachments,
+ uploadButton,
+ error,
+ dragActive = false,
+ deletePending = false,
+ onDelete,
+ onImageClick,
+ onDragEnter,
+ onDragOver,
+ onDragLeave,
+ onDrop,
+}: IssueAttachmentsSectionProps) {
+ const [confirmDeleteId, setConfirmDeleteId] = useState(null);
+ const { imageAttachments, markdownAttachments, videoAttachments, genericAttachments } = useMemo(() => {
+ const images: IssueAttachment[] = [];
+ const markdown: IssueAttachment[] = [];
+ const videos: IssueAttachment[] = [];
+ const generic: IssueAttachment[] = [];
+
+ for (const attachment of attachments) {
+ if (isImageAttachment(attachment)) images.push(attachment);
+ else if (isMarkdownAttachment(attachment)) markdown.push(attachment);
+ else if (isVideoAttachment(attachment)) videos.push(attachment);
+ else generic.push(attachment);
+ }
+
+ return {
+ imageAttachments: images,
+ markdownAttachments: markdown,
+ videoAttachments: videos,
+ genericAttachments: generic,
+ };
+ }, [attachments]);
+
+ const requestDelete = (attachmentId: string) => setConfirmDeleteId(attachmentId);
+ const confirmDelete = (attachmentId: string) => {
+ onDelete(attachmentId);
+ setConfirmDeleteId(null);
+ };
+
+ return (
+
+
+
+
+
Attachments
+
{attachments.length}
+
+ {uploadButton}
+
+
+ {error && (
+
{error}
+ )}
+
+ {imageAttachments.length > 0 && (
+
+ {imageAttachments.map((attachment) => (
+
onImageClick(attachment)}
+ >
+

+
+ {confirmDeleteId === attachment.id ? (
+
event.stopPropagation()}
+ >
+
Delete?
+
+
+
+
+
+ ) : (
+
+ )}
+
+ ))}
+
+ )}
+
+ {markdownAttachments.length > 0 && (
+
+ {markdownAttachments.map((attachment) => (
+
+ ))}
+
+ )}
+
+ {videoAttachments.length > 0 && (
+
+ {videoAttachments.map((attachment) => (
+
+ ))}
+
+ )}
+
+ {genericAttachments.length > 0 && (
+
+ {genericAttachments.map((attachment) => (
+
+ ))}
+
+ )}
+
+ {confirmDeleteId && !imageAttachments.some((attachment) => attachment.id === confirmDeleteId) ? (
+
+
Delete this attachment? This cannot be undone.
+
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/ui/src/lib/issue-attachments.ts b/ui/src/lib/issue-attachments.ts
new file mode 100644
index 00000000..8c6859da
--- /dev/null
+++ b/ui/src/lib/issue-attachments.ts
@@ -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) {
+ return attachment.contentType.toLowerCase().split(";")[0]?.trim() ?? "";
+}
+
+export function attachmentFilename(attachment: Pick) {
+ return attachment.originalFilename ?? attachment.id;
+}
+
+export function attachmentOpenPath(
+ attachment: Pick,
+) {
+ return attachment.openPath ?? attachment.contentPath;
+}
+
+export function attachmentDownloadPath(
+ attachment: Pick,
+) {
+ return attachment.downloadPath ?? `${attachment.contentPath}?download=1`;
+}
+
+export function isImageAttachment(attachment: Pick) {
+ return normalizedContentType(attachment).startsWith("image/");
+}
+
+export function isVideoAttachment(
+ attachment: Pick,
+) {
+ 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,
+) {
+ 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);
+}
diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts
index c590b0e1..a5517f34 100644
--- a/ui/src/lib/queryKeys.ts
+++ b/ui/src/lib/queryKeys.ts
@@ -67,6 +67,7 @@ export const queryKeys = {
? (["issues", "cost-summary", issueId, "exclude-root"] as const)
: (["issues", "cost-summary", issueId] as const),
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
+ attachmentPreview: (attachmentId: string) => ["issues", "attachment-preview", attachmentId] as const,
documents: (issueId: string) => ["issues", "documents", issueId] as const,
document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const,
documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const,
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx
index 54b468f1..9c44ebca 100644
--- a/ui/src/pages/IssueDetail.test.tsx
+++ b/ui/src/pages/IssueDetail.test.tsx
@@ -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) {
+ let result: void | Promise = undefined;
+ flushSync(() => {
+ result = callback();
+ });
+ await result;
+}
+
function createDeferred() {
let resolve!: (value: T) => void;
const promise = new Promise((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 () => {
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index 5c7d4607..df0f9b65 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -65,10 +65,11 @@ import { ApprovalCard } from "../components/ApprovalCard";
import { InlineEditor } from "../components/InlineEditor";
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
+import { IssueAttachmentsSection } from "../components/IssueAttachmentsSection";
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
import { IssuePlanDecompositionsSection } from "../components/IssuePlanDecompositionsSection";
import { IssueOutputSection } from "../components/issue-output/IssueOutputSection";
-import { formatBytes } from "../lib/issue-output";
+import { isImageAttachment } from "../lib/issue-attachments";
import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation";
import { IssuesList } from "../components/IssuesList";
import { AgentIcon } from "../components/AgentIconPicker";
@@ -137,7 +138,6 @@ import {
Plus,
Repeat,
SlidersHorizontal,
- Trash2,
XCircle,
} from "lucide-react";
import {
@@ -1248,7 +1248,6 @@ export function IssueDetail() {
approvalId: string;
action: "approve" | "reject";
} | null>(null);
- const [confirmDeleteId, setConfirmDeleteId] = useState(null);
const [attachmentError, setAttachmentError] = useState(null);
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
const [galleryOpen, setGalleryOpen] = useState(false);
@@ -2828,10 +2827,8 @@ export function IssueDetail() {
commentComposerRef.current?.focus();
}, [detailTab, pendingCommentComposerFocusKey]);
- const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
const attachmentList = attachments ?? [];
const imageAttachments = attachmentList.filter(isImageAttachment);
- const nonImageAttachments = attachmentList.filter((a) => !isImageAttachment(a));
const handleChatImageClick = useCallback(
(src: string) => {
@@ -3772,133 +3769,32 @@ export function IssueDetail() {
{attachmentsInitialLoading ? (
) : hasAttachments ? (
- {
- evt.preventDefault();
- setAttachmentDragActive(true);
- }}
- onDragOver={(evt) => {
- evt.preventDefault();
- setAttachmentDragActive(true);
- }}
- onDragLeave={(evt) => {
- if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return;
- setAttachmentDragActive(false);
- }}
- onDrop={(evt) => void handleAttachmentDrop(evt)}
- >
-
-
Attachments
- {attachmentUploadButton}
-
-
- {attachmentError && (
-
{attachmentError}
- )}
-
- {imageAttachments.length > 0 && (
-
- {imageAttachments.map((attachment) => (
-
{
- const idx = imageAttachments.findIndex((a) => a.id === attachment.id);
- setGalleryIndex(idx >= 0 ? idx : 0);
- setGalleryOpen(true);
- }}
- >
-

-
- {confirmDeleteId === attachment.id ? (
-
e.stopPropagation()}
- >
-
Delete?
-
-
-
-
-
- ) : (
-
- )}
-
- ))}
-
- )}
-
- {nonImageAttachments.length > 0 && (
-
- {nonImageAttachments.map((attachment) => (
-
-
-
- {attachment.contentType} · {formatBytes(attachment.byteSize)}
-
-
- ))}
-
- )}
-
+ deleteAttachment.mutate(attachmentId)}
+ onImageClick={(attachment) => {
+ const idx = imageAttachments.findIndex((a) => a.id === attachment.id);
+ setGalleryIndex(idx >= 0 ? idx : 0);
+ setGalleryOpen(true);
+ }}
+ onDragEnter={(evt) => {
+ evt.preventDefault();
+ setAttachmentDragActive(true);
+ }}
+ onDragOver={(evt) => {
+ evt.preventDefault();
+ setAttachmentDragActive(true);
+ }}
+ onDragLeave={(evt) => {
+ if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return;
+ setAttachmentDragActive(false);
+ }}
+ onDrop={(evt) => void handleAttachmentDrop(evt)}
+ />
) : null}