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:
Dotta
2026-05-30 19:06:15 +00:00
parent 0bd13c23a9
commit 96d266109b
10 changed files with 817 additions and 1 deletions
+13 -1
View File
@@ -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>
))}