Files
paperclip/ui/src/components/issue-output/OutputRow.tsx
T
Dotta 96d266109b Add issue Output UI for artifact playback (PAP-10168)
Surface attachment-backed artifact work products as a first-class
Output section on the issue detail page so cloud users can watch and
download agent-generated videos without host filesystem access.

- ui/src/lib/issue-output.ts: formatBytes/formatDuration/getOutputFileGlyph
  helpers + getIssueOutputs selector that validates the Phase-2 attachment
  artifact metadata contract and tolerates malformed metadata (degraded).
- issue-output components: IssueOutputSection, OutputPrimaryCard (native
  <video>/image/generic), OutputRow, OutputVideoPlayer, OutputFileTile.
- IssueDetail: fetch work products and render the Output section between
  Documents and Attachments; reuse formatBytes in the attachments list.
- DesignGuide: showcase multiple-output, degraded, and empty states.
- Focused tests for video output, empty state, multiple outputs, and
  failed attachment metadata (15 tests).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-30 20:40:35 +00:00

58 lines
2.0 KiB
TypeScript

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 (
<div className="flex items-center gap-2.5 rounded-md border border-border bg-card p-2">
<OutputFileTile contentType={meta?.contentType} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground" title={filename}>
{filename}
</p>
<p
className={cn(
"truncate text-[11px]",
item.degraded ? "text-destructive" : "text-muted-foreground",
)}
>
{item.degraded ? "File details unavailable" : metaBits.join(" · ")}
</p>
</div>
{meta ? (
<div className="flex shrink-0 items-center gap-1">
<Button asChild variant="ghost" size="icon-sm" title="Open in new tab">
<a href={meta.openPath} 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={meta.downloadPath} aria-label={`Download ${filename}`}>
<Download className="h-4 w-4" />
</a>
</Button>
</div>
) : null}
</div>
);
}