diff --git a/ui/src/components/issue-output/IssueOutputSection.test.tsx b/ui/src/components/issue-output/IssueOutputSection.test.tsx index 94c8fc60..9ea78581 100644 --- a/ui/src/components/issue-output/IssueOutputSection.test.tsx +++ b/ui/src/components/issue-output/IssueOutputSection.test.tsx @@ -87,6 +87,23 @@ describe("IssueOutputSection", () => { expect(markup).toBe(""); }); + it("renders nothing for markdown-only artifact work products", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toBe(""); + }); + it("renders the primary card plus an Also produced list for multiple outputs", () => { const markup = renderToStaticMarkup( & { id: string }): IssueWorkProduct { @@ -50,6 +52,14 @@ function videoMetadata(attachmentId = uuid()) { }; } +function artifactMetadata(contentType: string, originalFilename: string, attachmentId = uuid()) { + return { + ...videoMetadata(attachmentId), + contentType, + originalFilename, + }; +} + describe("formatBytes", () => { it("renders bytes below 1KB as whole bytes", () => { expect(formatBytes(0)).toBe("0 B"); @@ -83,6 +93,7 @@ describe("formatDuration", () => { describe("getOutputFileGlyph", () => { it("maps known mime types to tone + label", () => { expect(getOutputFileGlyph("video/mp4")).toEqual({ label: "MP4", tone: "video" }); + expect(getOutputFileGlyph("video/mp4; charset=binary")).toEqual({ label: "MP4", tone: "video" }); expect(getOutputFileGlyph("video/quicktime")).toEqual({ label: "MOV", tone: "video" }); expect(getOutputFileGlyph("application/pdf")).toEqual({ label: "PDF", tone: "pdf" }); expect(getOutputFileGlyph("application/zip")).toEqual({ label: "ZIP", tone: "zip" }); @@ -93,6 +104,39 @@ describe("getOutputFileGlyph", () => { expect(getOutputFileGlyph("application/octet-stream")).toEqual({ label: "BIN", tone: "bin" }); expect(getOutputFileGlyph(undefined)).toEqual({ label: "BIN", tone: "bin" }); }); + + it("labels document-like fallbacks defensively", () => { + expect(getOutputFileGlyph("text/plain")).toEqual({ label: "TXT", tone: "bin" }); + expect(getOutputFileGlyph("text/markdown")).toEqual({ label: "MD", tone: "bin" }); + expect(getOutputFileGlyph("application/json; charset=utf-8")).toEqual({ label: "JSON", tone: "bin" }); + expect(getOutputFileGlyph("application/wasm")).toEqual({ label: "WASM", tone: "bin" }); + }); +}); + +describe("isOutputEligibleContentType", () => { + it("keeps media, pdf, zip, and true generic binary outputs eligible", () => { + expect(isOutputEligibleContentType("video/mp4; charset=binary")).toBe(true); + expect(isOutputEligibleContentType("image/png")).toBe(true); + expect(isOutputEligibleContentType("image/svg+xml")).toBe(true); + expect(isOutputEligibleContentType("application/pdf")).toBe(true); + expect(isOutputEligibleContentType("application/vnd.example.bundle+zip")).toBe(true); + expect(isOutputEligibleContentType("application/wasm")).toBe(true); + expect(isOutputEligibleContentType("application/octet-stream")).toBe(true); + expect(isOutputEligibleContentType("application/octet-stream", "build.bin")).toBe(true); + }); + + it("filters document-like and source formats out of outputs", () => { + expect(isOutputEligibleContentType("text/markdown")).toBe(false); + expect(isOutputEligibleContentType("text/plain")).toBe(false); + expect(isOutputEligibleContentType("application/json")).toBe(false); + expect(isOutputEligibleContentType("application/vnd.api+json")).toBe(false); + expect(isOutputEligibleContentType("text/html")).toBe(false); + expect(isOutputEligibleContentType("application/xml")).toBe(false); + expect(isOutputEligibleContentType("text/csv")).toBe(false); + expect(isOutputEligibleContentType("application/x-yaml")).toBe(false); + expect(isOutputEligibleContentType("application/octet-stream", "report.md")).toBe(false); + expect(isOutputEligibleContentType("application/octet-stream", "notes.txt")).toBe(false); + }); }); describe("getIssueOutputs", () => { @@ -118,6 +162,30 @@ describe("getIssueOutputs", () => { expect(result.rest).toEqual([]); }); + it("ignores markdown and text artifact metadata instead of promoting them to outputs", () => { + const result = getIssueOutputs([ + makeWorkProduct({ id: "markdown", metadata: artifactMetadata("text/markdown", "report.md") }), + makeWorkProduct({ id: "text", metadata: artifactMetadata("text/plain", "notes.txt") }), + makeWorkProduct({ id: "json", metadata: artifactMetadata("application/json", "summary.json") }), + makeWorkProduct({ id: "generic-markdown", metadata: artifactMetadata("application/octet-stream", "legacy-report.md") }), + ]); + expect(result.count).toBe(0); + expect(result.primary).toBeNull(); + }); + + it("keeps video, image, pdf, zip, and binary artifact metadata as outputs", () => { + const result = getIssueOutputs([ + makeWorkProduct({ id: "video", metadata: artifactMetadata("video/mp4", "demo.mp4") }), + makeWorkProduct({ id: "image", metadata: artifactMetadata("image/png", "screenshot.png") }), + makeWorkProduct({ id: "svg", metadata: artifactMetadata("image/svg+xml", "diagram.svg") }), + makeWorkProduct({ id: "pdf", metadata: artifactMetadata("application/pdf", "brief.pdf") }), + makeWorkProduct({ id: "zip", metadata: artifactMetadata("application/zip; charset=binary", "bundle.zip") }), + makeWorkProduct({ id: "wasm", metadata: artifactMetadata("application/wasm", "module.wasm") }), + makeWorkProduct({ id: "binary", metadata: artifactMetadata("application/octet-stream", "build.bin") }), + ]); + expect(result.items.map((item) => item.id)).toEqual(["video", "image", "svg", "pdf", "zip", "wasm", "binary"]); + }); + it("orders the explicit primary first, then most recent", () => { const result = getIssueOutputs([ makeWorkProduct({ @@ -153,3 +221,17 @@ describe("getIssueOutputs", () => { expect(result.primary?.metadata).toBeNull(); }); }); + +describe("getPromotedOutputAttachmentIds", () => { + it("returns backing attachment ids only for work products promoted to outputs", () => { + const videoAttachmentId = uuid(); + const markdownAttachmentId = uuid(); + const ids = getPromotedOutputAttachmentIds([ + makeWorkProduct({ id: "video", metadata: videoMetadata(videoAttachmentId) }), + makeWorkProduct({ id: "markdown", metadata: artifactMetadata("text/markdown", "report.md", markdownAttachmentId) }), + makeWorkProduct({ id: "broken", metadata: { attachmentId: "bad" } as Record }), + ]); + + expect(Array.from(ids)).toEqual([videoAttachmentId]); + }); +}); diff --git a/ui/src/lib/issue-output.ts b/ui/src/lib/issue-output.ts index b3ae947f..57e3f1d6 100644 --- a/ui/src/lib/issue-output.ts +++ b/ui/src/lib/issue-output.ts @@ -58,30 +58,151 @@ export function formatDuration(seconds: number): string { return `${mins}:${pad(secs)}`; } +const GENERIC_BINARY_CONTENT_TYPES = new Set([ + "application/octet-stream", + "binary/octet-stream", + "application/x-binary", +]); + +const BINARY_OUTPUT_APPLICATION_TYPES = new Set([ + "application/wasm", +]); + +const ZIP_CONTENT_TYPES = new Set([ + "application/zip", + "application/x-zip", + "application/x-zip-compressed", +]); + +const MARKDOWN_CONTENT_TYPES = new Set([ + "text/markdown", + "text/x-markdown", + "application/markdown", + "application/x-markdown", +]); + +const DOCUMENT_LIKE_APPLICATION_TYPES = new Set([ + "application/csv", + "application/ecmascript", + "application/graphql", + "application/html", + "application/javascript", + "application/json", + "application/ld+json", + "application/sql", + "application/toml", + "application/x-httpd-php", + "application/x-javascript", + "application/x-python", + "application/x-sh", + "application/x-yaml", + "application/xhtml+xml", + "application/xml", + "application/yaml", +]); + +const DOCUMENT_LIKE_FILENAME_EXTENSIONS = [ + ".csv", + ".css", + ".graphql", + ".htm", + ".html", + ".js", + ".json", + ".jsx", + ".log", + ".markdown", + ".md", + ".mdx", + ".php", + ".py", + ".sh", + ".sql", + ".toml", + ".ts", + ".tsx", + ".txt", + ".xml", + ".yaml", + ".yml", +]; + +export function normalizeOutputContentType(contentType: string | null | undefined): string { + return (contentType ?? "").toLowerCase().split(";")[0]?.trim() ?? ""; +} + +function hasDocumentLikeFilename(originalFilename: string | null | undefined): boolean { + const filename = (originalFilename ?? "").trim().toLowerCase(); + if (!filename) return false; + return DOCUMENT_LIKE_FILENAME_EXTENSIONS.some((extension) => filename.endsWith(extension)); +} + +function isZipContentType(contentType: string): boolean { + return ZIP_CONTENT_TYPES.has(contentType) || contentType.endsWith("+zip"); +} + +function isDocumentLikeOutputContentType(contentType: string): boolean { + if (!contentType) return false; + if (contentType.startsWith("text/")) return true; + if (MARKDOWN_CONTENT_TYPES.has(contentType)) return true; + if (DOCUMENT_LIKE_APPLICATION_TYPES.has(contentType)) return true; + if (contentType.startsWith("application/") && (contentType.endsWith("+json") || contentType.endsWith("+xml"))) return true; + return false; +} + +export function isOutputEligibleContentType( + contentType: string | null | undefined, + originalFilename?: string | null | undefined, +): boolean { + const type = normalizeOutputContentType(contentType); + if (!type) return false; + if (isDocumentLikeOutputContentType(type)) return false; + if (GENERIC_BINARY_CONTENT_TYPES.has(type) && hasDocumentLikeFilename(originalFilename)) return false; + return ( + type.startsWith("video/") || + type.startsWith("image/") || + type === "application/pdf" || + BINARY_OUTPUT_APPLICATION_TYPES.has(type) || + isZipContentType(type) || + GENERIC_BINARY_CONTENT_TYPES.has(type) + ); +} + /** * Map a MIME type to a short label + tone for the 32×32 file-type tile. */ export function getOutputFileGlyph(contentType: string | null | undefined): OutputFileGlyph { - const type = (contentType ?? "").toLowerCase(); + const type = normalizeOutputContentType(contentType); if (type.startsWith("video/")) { const subtype = type.slice("video/".length); if (subtype === "quicktime") return { label: "MOV", tone: "video" }; return { label: (subtype || "vid").toUpperCase().slice(0, 4), tone: "video" }; } if (type === "application/pdf") return { label: "PDF", tone: "pdf" }; - if (type === "application/zip" || type === "application/x-zip-compressed" || type.endsWith("+zip")) { + if (isZipContentType(type)) { return { label: "ZIP", tone: "zip" }; } if (type.startsWith("image/")) return { label: "IMG", tone: "image" }; + if (MARKDOWN_CONTENT_TYPES.has(type)) return { label: "MD", tone: "bin" }; + if (type === "text/plain") return { label: "TXT", tone: "bin" }; + if (type === "text/csv" || type === "application/csv") return { label: "CSV", tone: "bin" }; + if (type === "text/html" || type === "application/html" || type === "application/xhtml+xml") { + return { label: "HTML", tone: "bin" }; + } + if (type === "application/json" || type.endsWith("+json")) return { label: "JSON", tone: "bin" }; + if (type === "application/xml" || type === "text/xml" || type.endsWith("+xml")) { + return { label: "XML", tone: "bin" }; + } + if (type === "application/wasm") return { label: "WASM", tone: "bin" }; return { label: "BIN", tone: "bin" }; } export function isVideoContentType(contentType: string | null | undefined): boolean { - return (contentType ?? "").toLowerCase().startsWith("video/"); + return normalizeOutputContentType(contentType).startsWith("video/"); } export function isImageContentType(contentType: string | null | undefined): boolean { - return (contentType ?? "").toLowerCase().startsWith("image/"); + return normalizeOutputContentType(contentType).startsWith("image/"); } /** @@ -125,9 +246,12 @@ function toTime(value: string | Date): number { export function getIssueOutputs(workProducts: IssueWorkProduct[] | null | undefined): IssueOutputs { const artifacts = (workProducts ?? []).filter((wp) => wp.type === "artifact" && wp.provider === "paperclip"); - const items: IssueOutputItem[] = artifacts.map((wp) => { + const items: IssueOutputItem[] = artifacts.flatMap((wp) => { const parsed = attachmentArtifactWorkProductMetadataSchema.safeParse(wp.metadata); - return { + if (parsed.success && !isOutputEligibleContentType(parsed.data.contentType, parsed.data.originalFilename)) { + return []; + } + return [{ id: wp.id, title: wp.title, status: typeof wp.status === "string" ? wp.status : "active", @@ -136,7 +260,7 @@ export function getIssueOutputs(workProducts: IssueWorkProduct[] | null | undefi metadata: parsed.success ? parsed.data : null, degraded: !parsed.success, workProduct: wp, - }; + }]; }); items.sort((a, b) => { @@ -156,3 +280,12 @@ export function getIssueOutputs(workProducts: IssueWorkProduct[] | null | undefi export function outputFilename(item: IssueOutputItem): string { return item.metadata?.originalFilename || item.title || "output"; } + +export function getPromotedOutputAttachmentIds(workProducts: IssueWorkProduct[] | null | undefined): Set { + return new Set( + getIssueOutputs(workProducts).items.flatMap((item) => { + const attachmentId = item.metadata?.attachmentId; + return attachmentId ? [attachmentId] : []; + }), + ); +} diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 9c44ebca..fb92ea33 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { Agent, Issue, IssueTreeControlPreview, IssueTreeHold } from "@paperclipai/shared"; +import type { Agent, Issue, IssueAttachment, IssueTreeControlPreview, IssueTreeHold, IssueWorkProduct } from "@paperclipai/shared"; import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "react"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; @@ -73,6 +73,7 @@ const mockSetMobileToolbar = vi.hoisted(() => vi.fn()); const mockPushToast = vi.hoisted(() => vi.fn()); const mockIssuesListRender = vi.hoisted(() => vi.fn()); const mockIssueChatThreadRender = vi.hoisted(() => vi.fn()); +const mockImageGalleryRender = vi.hoisted(() => vi.fn()); vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi, @@ -239,6 +240,10 @@ vi.mock("../components/IssueDocumentsSection", () => ({ IssueDocumentsSection: () =>
Documents
, })); +vi.mock("../components/MarkdownBody", () => ({ + MarkdownBody: ({ children }: { children?: ReactNode }) =>
{children}
, +})); + vi.mock("../components/IssuesList", () => ({ IssuesList: (props: { issueBadgeById?: Map }) => { mockIssuesListRender(props); @@ -266,7 +271,10 @@ vi.mock("../components/IssueWorkspaceCard", () => ({ })); vi.mock("../components/ImageGalleryModal", () => ({ - ImageGalleryModal: () => null, + ImageGalleryModal: (props: { images: IssueAttachment[]; initialIndex: number; open: boolean }) => { + mockImageGalleryRender(props); + return null; + }, })); vi.mock("../components/ScrollToBottom", () => ({ @@ -423,6 +431,74 @@ function createIssue(overrides: Partial = {}): Issue { } as Issue; } +function createAttachment(overrides: Partial & { id: string }): IssueAttachment { + const { id, ...attachmentOverrides } = overrides; + return { + id, + companyId: "company-1", + issueId: "issue-1", + issueCommentId: null, + assetId: `asset-${id}`, + provider: "local_disk", + objectKey: `attachments/${id}`, + contentType: overrides.contentType ?? "application/octet-stream", + byteSize: overrides.byteSize ?? 4096, + sha256: "sha256", + originalFilename: overrides.originalFilename ?? null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date("2026-04-21T00:00:00.000Z"), + updatedAt: new Date("2026-04-21T00:00:00.000Z"), + contentPath: overrides.contentPath ?? `/api/attachments/${id}/content`, + openPath: overrides.openPath ?? `/api/attachments/${id}/content`, + downloadPath: overrides.downloadPath ?? `/api/attachments/${id}/content?download=1`, + ...attachmentOverrides, + }; +} + +function createArtifactWorkProduct( + overrides: Partial & { + id: string; + attachmentId: string; + contentType: string; + originalFilename: string; + }, +): IssueWorkProduct { + const { id, attachmentId, contentType, originalFilename, ...workProductOverrides } = overrides; + const contentPath = `/api/attachments/${attachmentId}/content`; + return { + id, + companyId: "company-1", + projectId: null, + issueId: "issue-1", + executionWorkspaceId: null, + runtimeServiceId: null, + type: "artifact", + provider: "paperclip", + externalId: null, + title: overrides.title ?? originalFilename, + url: null, + status: "active", + reviewState: "none", + isPrimary: false, + healthStatus: "unknown", + summary: null, + metadata: { + attachmentId, + contentType, + byteSize: 4096, + contentPath, + openPath: contentPath, + downloadPath: `${contentPath}?download=1`, + originalFilename, + }, + createdByRunId: null, + createdAt: new Date("2026-04-21T00:00:00.000Z"), + updatedAt: new Date("2026-04-21T00:00:00.000Z"), + ...workProductOverrides, + } as IssueWorkProduct; +} + function createAgent(overrides: Partial = {}): Agent { return { id: "agent-1", @@ -807,6 +883,10 @@ describe("IssueDetail", () => { }); consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + text: async () => "# Attachment preview", + } as Response); mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.listComments.mockResolvedValue([]); @@ -842,6 +922,7 @@ describe("IssueDetail", () => { mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]); mockIssuesListRender.mockClear(); mockIssueChatThreadRender.mockClear(); + mockImageGalleryRender.mockClear(); }); afterEach(async () => { @@ -1416,6 +1497,68 @@ describe("IssueDetail", () => { localStorage.removeItem("paperclip:issue-comment-draft:issue-1"); }); + it("hides attachments backing promoted outputs while keeping filtered markdown artifacts visible", async () => { + const issue = createIssue(); + const videoAttachment = createAttachment({ + id: "11111111-1111-4111-8111-111111111111", + contentType: "video/mp4", + originalFilename: "demo.mp4", + }); + const imageAttachment = createAttachment({ + id: "33333333-3333-4333-8333-333333333333", + contentType: "image/png", + originalFilename: "screenshot.png", + }); + const markdownAttachment = createAttachment({ + id: "22222222-2222-4222-8222-222222222222", + contentType: "text/markdown", + originalFilename: "report.md", + }); + mockIssuesApi.get.mockResolvedValue(issue); + mockIssuesApi.listAttachments.mockResolvedValue([videoAttachment, imageAttachment, markdownAttachment]); + mockIssuesApi.listWorkProducts.mockResolvedValue([ + createArtifactWorkProduct({ + id: "wp-video", + attachmentId: videoAttachment.id, + contentType: "video/mp4", + originalFilename: "demo.mp4", + isPrimary: true, + }), + createArtifactWorkProduct({ + id: "wp-image", + attachmentId: imageAttachment.id, + contentType: "image/png", + originalFilename: "screenshot.png", + }), + createArtifactWorkProduct({ + id: "wp-markdown", + attachmentId: markdownAttachment.id, + contentType: "text/markdown", + originalFilename: "report.md", + }), + ]); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("Output"); + expect(container.textContent).toContain("demo.mp4"); + expect(container.textContent).toContain("Attachments"); + expect(container.textContent).toContain("report.md"); + expect(container.textContent).toContain("Attachments1"); + expect(container.querySelectorAll("video")).toHaveLength(1); + expect(mockImageGalleryRender.mock.calls.at(-1)?.[0].images.map((attachment: IssueAttachment) => attachment.id)).toEqual([ + imageAttachment.id, + ]); + }); + it("renders Paused by board distinctly and defaults leaf resume to wake the assignee", async () => { const activeHold = createPauseHold(); const releasedHold = createPauseHold({ diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 7e41c4ca..e7294973 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -70,6 +70,7 @@ import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; import { IssuePlanDecompositionsSection } from "../components/IssuePlanDecompositionsSection"; import { IssueOutputSection } from "../components/issue-output/IssueOutputSection"; import { isImageAttachment } from "../lib/issue-attachments"; +import { getPromotedOutputAttachmentIds } from "../lib/issue-output"; import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation"; import { IssuesList } from "../components/IssuesList"; import { AgentIcon } from "../components/AgentIconPicker"; @@ -2877,8 +2878,12 @@ export function IssueDetail() { commentComposerRef.current?.focus(); }, [detailTab, pendingCommentComposerFocusKey]); - const attachmentList = attachments ?? []; - const imageAttachments = attachmentList.filter(isImageAttachment); + const promotedOutputAttachmentIds = useMemo(() => getPromotedOutputAttachmentIds(workProducts), [workProducts]); + const attachmentList = useMemo( + () => (attachments ?? []).filter((attachment) => !promotedOutputAttachmentIds.has(attachment.id)), + [attachments, promotedOutputAttachmentIds], + ); + const imageAttachments = useMemo(() => (attachments ?? []).filter(isImageAttachment), [attachments]); const handleChatImageClick = useCallback( (src: string) => {