diff --git a/ui/src/components/issue-output/IssueOutputSection.test.tsx b/ui/src/components/issue-output/IssueOutputSection.test.tsx new file mode 100644 index 00000000..94c8fc60 --- /dev/null +++ b/ui/src/components/issue-output/IssueOutputSection.test.tsx @@ -0,0 +1,137 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { IssueWorkProduct } from "@paperclipai/shared"; +import { IssueOutputSection } from "./IssueOutputSection"; + +function makeWorkProduct(overrides: Partial & { id: string }): IssueWorkProduct { + return { + companyId: "company-1", + projectId: null, + issueId: "issue-1", + executionWorkspaceId: null, + runtimeServiceId: null, + type: "artifact", + provider: "paperclip", + externalId: null, + title: "output", + url: null, + status: "active", + reviewState: "none", + isPrimary: false, + healthStatus: "unknown", + summary: null, + metadata: null, + createdByRunId: null, + createdAt: new Date("2026-05-30T12:00:00Z"), + updatedAt: new Date("2026-05-30T12:00:00Z"), + ...overrides, + } as IssueWorkProduct; +} + +const UUIDS: Record = { + "att-1": "11111111-1111-4111-8111-111111111111", + "att-vid": "22222222-2222-4222-8222-222222222222", + "att-pdf": "33333333-3333-4333-8333-333333333333", +}; + +function metadata(key: string, contentType: string, filename: string) { + const attachmentId = UUIDS[key] ?? key; + return { + attachmentId, + contentType, + byteSize: 19_293_798, + contentPath: `/api/attachments/${attachmentId}/content`, + openPath: `/api/attachments/${attachmentId}/content`, + downloadPath: `/api/attachments/${attachmentId}/content?download=1`, + originalFilename: filename, + }; +} + +describe("IssueOutputSection", () => { + it("renders a playable, downloadable video as the primary output", () => { + const markup = renderToStaticMarkup( + , + ); + + // Native video player present + expect(markup).toContain(" { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toBe(""); + }); + + it("renders the primary card plus an Also produced list for multiple outputs", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Also produced"); + expect(markup).toContain("summary.mp4"); + expect(markup).toContain("talking-points.pdf"); + // PDF glyph tile label appears for the secondary row + expect(markup).toContain("PDF"); + }); + + it("surfaces an output with failed/invalid attachment metadata without crashing", () => { + const markup = renderToStaticMarkup( + , + }), + ]} + />, + ); + + expect(markup).toContain("broken-output.mp4"); + expect(markup).toContain("metadata is unavailable"); + // No video element and no download link can be built from invalid metadata + expect(markup).not.toContain(" string | null; +} + +/** + * Issue Output surface (PAP-10162 Phase 3). + * + * Renders attachment-backed artifact work products as first-class issue + * outputs: a full-width primary card (video player / image / generic file) with + * Open + Download, plus compact rows for any additional outputs. The section is + * omitted entirely when the issue has produced no outputs — we never show a + * permanent empty card. + */ +export function IssueOutputSection({ workProducts, resolveCreatorName }: IssueOutputSectionProps) { + const { primary, rest, count } = getIssueOutputs(workProducts); + + if (!primary) return null; + + const creatorFor = (item: IssueOutputItem) => resolveCreatorName?.(item) ?? null; + + return ( +
+
+
+ + + + {rest.length > 0 && ( +
+

Also produced

+ {rest.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/ui/src/components/issue-output/OutputFileTile.tsx b/ui/src/components/issue-output/OutputFileTile.tsx new file mode 100644 index 00000000..13ad442f --- /dev/null +++ b/ui/src/components/issue-output/OutputFileTile.tsx @@ -0,0 +1,35 @@ +import { cn } from "@/lib/utils"; +import { getOutputFileGlyph, type OutputFileTone } from "@/lib/issue-output"; + +const TONE_CLASSES: Record = { + video: "bg-indigo-500/15 text-indigo-300", + pdf: "bg-red-500/15 text-red-300", + zip: "bg-amber-500/15 text-amber-300", + image: "bg-emerald-500/15 text-emerald-300", + bin: "bg-muted text-muted-foreground", +}; + +interface OutputFileTileProps { + contentType: string | null | undefined; + className?: string; + /** Tailwind size classes for the square tile. Defaults to a 32×32 tile. */ + sizeClassName?: string; +} + +/** Square file-type tile showing a short MIME-derived label, colorised by tone. */ +export function OutputFileTile({ contentType, className, sizeClassName = "h-8 w-8" }: OutputFileTileProps) { + const glyph = getOutputFileGlyph(contentType); + return ( + + ); +} diff --git a/ui/src/components/issue-output/OutputPrimaryCard.tsx b/ui/src/components/issue-output/OutputPrimaryCard.tsx new file mode 100644 index 00000000..c0a6ca79 --- /dev/null +++ b/ui/src/components/issue-output/OutputPrimaryCard.tsx @@ -0,0 +1,96 @@ +import { Download, ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn, relativeTime } from "@/lib/utils"; +import { + formatBytes, + isImageContentType, + isVideoContentType, + outputFilename, + type IssueOutputItem, +} from "@/lib/issue-output"; +import { OutputVideoPlayer } from "./OutputVideoPlayer"; +import { OutputFileTile } from "./OutputFileTile"; + +interface OutputPrimaryCardProps { + item: IssueOutputItem; + creatorName?: string | null; +} + +/** + * Full-width primary output card: media region (video / image / generic file) + * over a metadata strip with Open + Download actions. The layout stacks on + * mobile and uses a single horizontal meta row on desktop. + */ +export function OutputPrimaryCard({ item, creatorName }: OutputPrimaryCardProps) { + const meta = item.metadata; + const filename = outputFilename(item); + const contentType = meta?.contentType; + + return ( +
+ {/* Media region */} + {meta && isVideoContentType(contentType) ? ( + + ) : meta && isImageContentType(contentType) ? ( + + {filename} + + ) : ( +
+ +
+ )} + + {/* Metadata strip */} +
+
+

{filename}

+ {item.degraded ? ( +

+ Output metadata is unavailable — this file can’t be played or downloaded here. +

+ ) : ( +
+ {item.isPrimary && ( + + Primary + + )} + {meta && {meta.contentType}} + {meta && } + {meta && {formatBytes(meta.byteSize)}} + {creatorName && } + {creatorName && {creatorName}} + + {relativeTime(item.createdAt)} +
+ )} +
+ + {meta ? ( + + ) : null} +
+
+ ); +} diff --git a/ui/src/components/issue-output/OutputRow.tsx b/ui/src/components/issue-output/OutputRow.tsx new file mode 100644 index 00000000..dc4687eb --- /dev/null +++ b/ui/src/components/issue-output/OutputRow.tsx @@ -0,0 +1,57 @@ +import { Download, ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn, relativeTime } from "@/lib/utils"; +import { formatBytes, outputFilename, type IssueOutputItem } from "@/lib/issue-output"; +import { OutputFileTile } from "./OutputFileTile"; + +interface OutputRowProps { + item: IssueOutputItem; + creatorName?: string | null; +} + +/** Compact row for a non-primary output ("ALSO PRODUCED"). */ +export function OutputRow({ item, creatorName }: OutputRowProps) { + const filename = outputFilename(item); + const meta = item.metadata; + + const metaBits: string[] = []; + if (meta) { + metaBits.push(meta.contentType); + metaBits.push(formatBytes(meta.byteSize)); + } + if (creatorName) metaBits.push(creatorName); + metaBits.push(relativeTime(item.createdAt)); + + return ( +
+ +
+

+ {filename} +

+

+ {item.degraded ? "File details unavailable" : metaBits.join(" · ")} +

+
+ {meta ? ( +
+ + +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/issue-output/OutputVideoPlayer.tsx b/ui/src/components/issue-output/OutputVideoPlayer.tsx new file mode 100644 index 00000000..ec286dc6 --- /dev/null +++ b/ui/src/components/issue-output/OutputVideoPlayer.tsx @@ -0,0 +1,33 @@ +import { cn } from "@/lib/utils"; + +interface OutputVideoPlayerProps { + src: string; + poster?: string | null; + className?: string; + /** Accessible label, typically the filename. */ + title?: string; +} + +/** + * Thin wrapper around the native HTML5 `