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>
This commit is contained in:
@@ -125,6 +125,76 @@ import { PageSkeleton } from "@/components/PageSkeleton";
|
||||
import { Identity } from "@/components/Identity";
|
||||
import { IssueReferencePill } from "@/components/IssueReferencePill";
|
||||
import { MembershipAction } from "@/components/MembershipAction";
|
||||
import { IssueOutputSection } from "@/components/issue-output/IssueOutputSection";
|
||||
import type { IssueWorkProduct } from "@paperclipai/shared";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sample data for the Issue Output surface showcase */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function sampleOutput(
|
||||
id: string,
|
||||
attachmentId: string,
|
||||
contentType: string,
|
||||
filename: string,
|
||||
opts: { byteSize: number; isPrimary?: boolean; createdAt: string },
|
||||
): IssueWorkProduct {
|
||||
const contentPath = `/api/attachments/${attachmentId}/content`;
|
||||
return {
|
||||
id,
|
||||
companyId: "demo-company",
|
||||
projectId: null,
|
||||
issueId: "demo-issue",
|
||||
executionWorkspaceId: null,
|
||||
runtimeServiceId: null,
|
||||
type: "artifact",
|
||||
provider: "paperclip",
|
||||
externalId: null,
|
||||
title: filename,
|
||||
url: null,
|
||||
status: "active",
|
||||
reviewState: "none",
|
||||
isPrimary: Boolean(opts.isPrimary),
|
||||
healthStatus: "unknown",
|
||||
summary: null,
|
||||
createdByRunId: null,
|
||||
createdAt: new Date(opts.createdAt),
|
||||
updatedAt: new Date(opts.createdAt),
|
||||
metadata: {
|
||||
attachmentId,
|
||||
contentType,
|
||||
byteSize: opts.byteSize,
|
||||
contentPath,
|
||||
openPath: contentPath,
|
||||
downloadPath: `${contentPath}?download=1`,
|
||||
originalFilename: filename,
|
||||
},
|
||||
} as IssueWorkProduct;
|
||||
}
|
||||
|
||||
const DESIGN_GUIDE_OUTPUTS: IssueWorkProduct[] = [
|
||||
sampleOutput("wp-vid", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "video/mp4", "q3-summary.mp4", {
|
||||
byteSize: 19_293_798,
|
||||
isPrimary: true,
|
||||
createdAt: "2026-05-30T12:00:00Z",
|
||||
}),
|
||||
sampleOutput("wp-pdf", "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "application/pdf", "talking-points.pdf", {
|
||||
byteSize: 421_888,
|
||||
createdAt: "2026-05-30T11:52:00Z",
|
||||
}),
|
||||
];
|
||||
|
||||
const DESIGN_GUIDE_DEGRADED_OUTPUTS: IssueWorkProduct[] = [
|
||||
{
|
||||
...sampleOutput("wp-broken", "cccccccc-cccc-4ccc-8ccc-cccccccccccc", "video/mp4", "corrupt-output.mp4", {
|
||||
byteSize: 0,
|
||||
isPrimary: true,
|
||||
createdAt: "2026-05-30T12:01:00Z",
|
||||
}),
|
||||
// Strip the path metadata so it fails the shared artifact schema.
|
||||
metadata: { attachmentId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", contentType: "video/mp4" },
|
||||
} as IssueWorkProduct,
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Section wrapper */
|
||||
@@ -1402,6 +1472,21 @@ export function DesignGuide() {
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Issue Output Surface">
|
||||
<SubSection title="Multiple outputs (primary video + 'Also produced')">
|
||||
<IssueOutputSection workProducts={DESIGN_GUIDE_OUTPUTS} />
|
||||
</SubSection>
|
||||
<SubSection title="Degraded output (invalid / failed attachment metadata)">
|
||||
<IssueOutputSection workProducts={DESIGN_GUIDE_DEGRADED_OUTPUTS} />
|
||||
</SubSection>
|
||||
<SubSection title="Empty state">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When an issue has produced no artifact work products, the Output section renders nothing
|
||||
at all (no placeholder card).
|
||||
</p>
|
||||
</SubSection>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ import { IssueChatThread, type IssueChatComposerHandle } from "../components/Iss
|
||||
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
import { IssuePlanDecompositionsSection } from "../components/IssuePlanDecompositionsSection";
|
||||
import { IssueOutputSection } from "../components/issue-output/IssueOutputSection";
|
||||
import { formatBytes } from "../lib/issue-output";
|
||||
import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
@@ -150,6 +152,7 @@ import {
|
||||
type Issue,
|
||||
type IssueAttachment,
|
||||
type IssueComment,
|
||||
type IssueWorkProduct,
|
||||
type IssueWorkMode,
|
||||
type IssueThreadInteraction,
|
||||
type RequestConfirmationInteraction,
|
||||
@@ -1339,6 +1342,13 @@ export function IssueDetail() {
|
||||
placeholderData: keepPreviousDataForSameQueryTail<IssueAttachment[]>(issueId ?? "pending"),
|
||||
});
|
||||
|
||||
const { data: workProducts } = useQuery({
|
||||
queryKey: queryKeys.issues.workProducts(issueId!),
|
||||
queryFn: () => issuesApi.listWorkProducts(issueId!),
|
||||
enabled: !!issueId,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<IssueWorkProduct[]>(issueId ?? "pending"),
|
||||
});
|
||||
|
||||
const { data: liveRunCount = 0 } = useQuery<LiveRunForIssue[], Error, number>({
|
||||
queryKey: queryKeys.issues.liveRuns(issueId!),
|
||||
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!),
|
||||
@@ -3757,6 +3767,8 @@ export function IssueDetail() {
|
||||
userProfileMap={userProfileMap}
|
||||
/>
|
||||
|
||||
<IssueOutputSection workProducts={workProducts} />
|
||||
|
||||
{attachmentsInitialLoading ? (
|
||||
<IssueSectionSkeleton titleWidth="w-24" rows={2} />
|
||||
) : hasAttachments ? (
|
||||
@@ -3880,7 +3892,7 @@ export function IssueDetail() {
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
|
||||
{attachment.contentType} · {formatBytes(attachment.byteSize)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user