diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index edc325024c..be48124d89 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -14,6 +14,8 @@ import { executionWorkspacesApi } from "../../api/execution-workspaces"; import { instanceSettingsApi } from "../../api/instanceSettings"; import { issuesApi } from "../../api/issues"; import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument"; +import { useIssueDocuments } from "@/hooks/useIssueDocuments"; +import { selectAgentArtifactAttachments } from "@/lib/issue-artifacts"; import { projectsApi } from "../../api/projects"; import { useCompany } from "../../context/CompanyContext"; import { queryKeys } from "../../lib/queryKeys"; @@ -206,11 +208,25 @@ export function IssueProperties({ queryFn: () => issuesApi.listAttachments(issue.id), enabled: taskChatShellEnabled, }); + const { data: paneTabWorkProducts } = useQuery({ + queryKey: queryKeys.issues.workProducts(issue.id), + queryFn: () => issuesApi.listWorkProducts(issue.id), + enabled: taskChatShellEnabled, + }); + const { data: paneTabDocuments } = useIssueDocuments(taskChatShellEnabled ? issue.id : null); const hasPlanTab = Boolean(paneTabPlanDocument) || (paneTabAcceptedPlans?.length ?? 0) > 0 + || (paneTabDocuments?.length ?? 0) > 0 || issue.workMode === "planning"; - const hasArtifactsTab = (paneTabAttachments?.length ?? 0) > 0; + // Artifacts covers the same three sources the tab body composes: work + // products, documents (redundant with the Plan tab, intentionally), and + // agent-created attachments. User comment uploads stay thread-only and + // no longer summon the tab. + const hasArtifactsTab = + (paneTabWorkProducts?.length ?? 0) > 0 + || (paneTabDocuments?.length ?? 0) > 0 + || selectAgentArtifactAttachments(paneTabAttachments, paneTabWorkProducts).length > 0; const [paneTab, setPaneTab] = useState("properties"); // Once a plan document exists, surface it: switch the pane to the Plan tab so // the write-up is exposed alongside the plan-approval card, instead of leaving diff --git a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx index 47a7f47aef..2612d3080a 100644 --- a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx +++ b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx @@ -1,8 +1,31 @@ +import { useState } from "react"; +import type { CSSProperties } from "react"; import { useQuery } from "@tanstack/react-query"; -import type { Issue } from "@paperclipai/shared"; -import { Paperclip } from "lucide-react"; +import type { Issue, IssueDocument, IssueWorkProduct } from "@paperclipai/shared"; +import { + ChevronDown, + ChevronRight, + ExternalLink, + FileText, + GitBranch, + GitCommit, + Globe, + Package, + Paperclip, + Server, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; import { issuesApi } from "@/api/issues"; import { queryKeys } from "@/lib/queryKeys"; +import { useIssueDocuments } from "@/hooks/useIssueDocuments"; +import { + documentDisplayTitle, + selectAgentArtifactAttachments, + workProductHref, +} from "@/lib/issue-artifacts"; +import { attachmentOpenPath } from "@/lib/issue-attachments"; +import { MarkdownBody } from "@/components/MarkdownBody"; +import { cn } from "@/lib/utils"; interface IssuePropertiesArtifactsTabProps { issue: Issue; @@ -14,44 +37,196 @@ function formatBytes(n: number): string { return `${(n / (1024 * 1024)).toFixed(1)} MB`; } +function workProductIcon(type: string): LucideIcon { + switch (type) { + case "document": return FileText; + case "pull_request": return GitBranch; + case "branch": return GitBranch; + case "commit": return GitCommit; + case "preview_url": return Globe; + case "runtime_service": return Server; + default: return Package; + } +} + +/** Work-product status → label + `--status-task-*` base-hue var for `.status-chip`. */ +function workProductStatusBadge(status: string): { label: string; cssVar: string } | null { + switch (status) { + case "active": + case "draft": + return { label: "In progress", cssVar: "--status-task-in_progress" }; + case "ready_for_review": + return { label: "For review", cssVar: "--status-task-in_review" }; + case "approved": + case "merged": + return { label: "Done", cssVar: "--status-task-done" }; + case "changes_requested": + return { label: "Changes requested", cssVar: "--status-task-todo" }; + case "failed": + return { label: "Failed", cssVar: "--status-task-blocked" }; + default: + return null; + } +} + +function SectionHeading({ children }: { children: string }) { + return ( +

+ {children} +

+ ); +} + +const ROW_CLASS = + "flex items-center gap-2 rounded-md border border-border bg-card/50 px-2.5 py-1.5 text-sm"; + +function WorkProductRow({ workProduct }: { workProduct: IssueWorkProduct }) { + const Icon = workProductIcon(workProduct.type); + const badge = workProductStatusBadge(workProduct.status); + const href = workProductHref(workProduct); + const body = ( + <> + + {workProduct.title} + {badge ? ( + + {badge.label} + + ) : null} + {href ? ( + + ) : null} + + ); + if (href) { + return ( + + {body} + + ); + } + return
{body}
; +} + +function DocumentRow({ doc }: { doc: IssueDocument }) { + const [expanded, setExpanded] = useState(false); + const Chevron = expanded ? ChevronDown : ChevronRight; + return ( +
+ + {expanded ? ( +
+ {doc.body.trim().length > 0 ? ( + {doc.body} + ) : ( +

Document is empty.

+ )} +
+ ) : null} +
+ ); +} + /** - * Artifacts tab of the properties pane. + * Artifacts tab of the properties pane (PAP-491). * - * A read-only gallery of the task's attachments / work products. Uploads, - * previews, and deletes stay on the existing attachment surfaces for the - * baseline; this tab consolidates the "what did this task produce" view. + * A read-only "what did this task produce" view composed from three sources: + * work products, issue documents (also readable in the Plan tab — the + * redundancy is intentional), and agent-created attachments. Attachments + * already promoted to attachment-backed work products are deduped out, and + * user uploads are excluded — those stay first-class in the conversation + * thread. */ export function IssuePropertiesArtifactsTab({ issue }: IssuePropertiesArtifactsTabProps) { - const { data } = useQuery({ + const { data: attachments } = useQuery({ queryKey: queryKeys.issues.attachments(issue.id), queryFn: () => issuesApi.listAttachments(issue.id), }); - const attachments = data ?? []; + const { data: workProducts } = useQuery({ + queryKey: queryKeys.issues.workProducts(issue.id), + queryFn: () => issuesApi.listWorkProducts(issue.id), + }); + const { data: documents } = useIssueDocuments(issue.id); - if (attachments.length === 0) { + const workProductRows = workProducts ?? []; + const documentRows = documents ?? []; + const fileRows = selectAgentArtifactAttachments(attachments, workProducts); + + if (workProductRows.length === 0 && documentRows.length === 0 && fileRows.length === 0) { return (
- No artifacts yet. Attachments and work products will appear here. + No artifacts yet. Work products, documents, and agent-produced files will appear here.
); } return ( - +
+ {workProductRows.length > 0 ? ( + <> + Work products +
    + {workProductRows.map((wp) => ( +
  • + +
  • + ))} +
+ + ) : null} + {documentRows.length > 0 ? ( + <> + Documents +
    + {documentRows.map((doc) => ( +
  • + +
  • + ))} +
+ + ) : null} + {fileRows.length > 0 ? ( + <> + Files + + + ) : null} +
); } diff --git a/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx b/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx index a01b9e129f..48379670c8 100644 --- a/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx +++ b/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx @@ -7,6 +7,8 @@ import { IssuePlanDecompositionsSection } from "@/components/IssuePlanDecomposit import { MarkdownBody } from "@/components/MarkdownBody"; import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "@/components/IssueDocumentAnnotations"; import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument"; +import { useIssueDocuments } from "@/hooks/useIssueDocuments"; +import { documentDisplayTitle } from "@/lib/issue-artifacts"; import { useLocation } from "@/lib/router"; interface IssuePropertiesPlansTabProps { @@ -47,10 +49,14 @@ export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps) queryKey: queryKeys.issues.interactions(issue.id), queryFn: () => issuesApi.listInteractions(issue.id), }); + const { data: documents } = useIssueDocuments(issue.id); const hasPlans = (data?.length ?? 0) > 0; const pendingPlanConfirmation = hasPendingPlanConfirmation(interactions); + // Every other non-system document (e.g. `synthesis`) renders below the plan; + // the `plan` doc itself stays on its dedicated annotated surface above. + const otherDocuments = (documents ?? []).filter((doc) => doc.key !== "plan"); - if (!planDocument && !hasPlans) { + if (!planDocument && !hasPlans && otherDocuments.length === 0) { return (
{planDocumentLoading ? ( @@ -113,6 +119,22 @@ export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps) ) : null} + {otherDocuments.map((doc) => ( +
+
+

{documentDisplayTitle(doc)}

+ + {`Revision ${doc.latestRevisionNumber ?? 1} · updated ${new Date(doc.updatedAt).toLocaleString([], { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })}`} + +
+ {doc.body} +
+ ))} {hasPlans ? ( ) : null} diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index 89584df89f..6da7a74318 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -1,6 +1,7 @@ -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { cn } from "@/lib/utils"; import { MarkdownBody } from "@/components/MarkdownBody"; +import { ImageGalleryModal, type GalleryMediaItem } from "@/components/ImageGalleryModal"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { AgentIcon } from "@/components/AgentIconPicker"; import { CommentAttributionChip } from "@/components/CommentAttributionChip"; @@ -13,7 +14,7 @@ import { AttachmentTitle, AttachmentTrigger, } from "@/components/ui/attachment"; -import { extractAttachmentRefs, fileKindForName } from "./task-chat-attachments"; +import { extractAttachmentRefs, extractImageRefs, fileKindForName } from "./task-chat-attachments"; import { TaskChatSystemNotice } from "./TaskChatSystemNotice"; import type { TaskChatMessageItem } from "./task-chat-model"; @@ -49,7 +50,21 @@ function initialsForName(name: string) { * bubble with an avatar author header (the agent's assigned icon + name · mode * chip); system notices are centered and recede. */ +function galleryItemForImage(src: string, name?: string): GalleryMediaItem { + return { + id: src, + contentPath: src, + // The modal only inspects contentType/filename to spot videos; embedded + // markdown images are always images, so an empty type is safe here. + contentType: "", + originalFilename: name?.trim() ? name : "image", + }; +} + export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubbleProps) { + // Clicking an embedded image opens the full-screen lightbox (with download); + // arrow keys walk across the other images in the same bubble. + const [lightboxSrc, setLightboxSrc] = useState(null); if (item.interstitial) { // Interstitial updates are ephemeral (PAP-361): while streaming the text // lives on the live parent row's line (TaskChatStatusItem.selfTalk), and @@ -67,6 +82,16 @@ export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubblePr // Non-image file references ("[name](/api/attachments/…/content)") render as // attachment chips under the bubble; link-only lines leave the body text. const { refs: attachmentRefs, text: bodyText } = extractAttachmentRefs(item.text); + const imageRefs = extractImageRefs(bodyText); + const galleryItems: GalleryMediaItem[] = + lightboxSrc !== null && !imageRefs.some((ref) => ref.url === lightboxSrc) + // A clicked image the extractor missed (e.g. inline HTML) still gets a + // single-item lightbox rather than nothing. + ? [galleryItemForImage(lightboxSrc)] + : imageRefs.map((ref) => galleryItemForImage(ref.url, ref.name)); + const lightboxIndex = lightboxSrc === null + ? -1 + : Math.max(0, galleryItems.findIndex((galleryItem) => galleryItem.contentPath === lightboxSrc)); return (
{!isHuman && item.authorName ? ( @@ -114,6 +139,7 @@ export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubblePr className={isHuman ? "paperclip-markdown-on-accent" : undefined} softBreaks linkIssueReferences + onImageClick={setLightboxSrc} > {bodyText} @@ -175,6 +201,16 @@ export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubblePr {item.timestamp} ) : null} + {lightboxSrc !== null && lightboxIndex >= 0 ? ( + { + if (!open) setLightboxSrc(null); + }} + /> + ) : null}
); } diff --git a/ui/src/components/task-chat/task-chat-attachments.test.ts b/ui/src/components/task-chat/task-chat-attachments.test.ts index 9a7ee240cb..1fb9cebe20 100644 --- a/ui/src/components/task-chat/task-chat-attachments.test.ts +++ b/ui/src/components/task-chat/task-chat-attachments.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { extractAttachmentRefs, + extractImageRefs, fileKindForName, formatFileSize, isImageFilename, @@ -86,3 +87,30 @@ describe("extractAttachmentRefs", () => { ]); }); }); + +describe("extractImageRefs", () => { + it("collects image embeds in order, skipping plain links", () => { + const body = + "![a.png](/api/attachments/a/content)\nSee [notes.txt](/api/attachments/n/content)\n![b.jpg](https://example.com/b.jpg)"; + expect(extractImageRefs(body)).toEqual([ + { name: "a.png", url: "/api/attachments/a/content" }, + { name: "b.jpg", url: "https://example.com/b.jpg" }, + ]); + }); + + it("dedupes repeated embeds and tolerates empty alt text", () => { + const body = "![](/api/attachments/a/content)\n![again](/api/attachments/a/content)"; + expect(extractImageRefs(body)).toEqual([{ name: "", url: "/api/attachments/a/content" }]); + }); + + it("unescapes bracket-escaped alt text", () => { + const body = String.raw`![shot \[1\].png](/api/attachments/s/content)`; + expect(extractImageRefs(body)).toEqual([ + { name: "shot [1].png", url: "/api/attachments/s/content" }, + ]); + }); + + it("returns nothing for bodies without images", () => { + expect(extractImageRefs("just text and a [link](/api/attachments/x/content)")).toEqual([]); + }); +}); diff --git a/ui/src/components/task-chat/task-chat-attachments.ts b/ui/src/components/task-chat/task-chat-attachments.ts index 5746cae43f..7701117f4d 100644 --- a/ui/src/components/task-chat/task-chat-attachments.ts +++ b/ui/src/components/task-chat/task-chat-attachments.ts @@ -115,6 +115,26 @@ export interface AttachmentRef { const ATTACHMENT_LINK_RE = /(? src picks the initial index. + */ +export function extractImageRefs(body: string): AttachmentRef[] { + const refs: AttachmentRef[] = []; + const seen = new Set(); + for (const match of body.matchAll(IMAGE_EMBED_RE)) { + const [, name, url] = match; + if (seen.has(url)) continue; + seen.add(url); + refs.push({ name: name.replace(/\\([[\]])/g, "$1"), url }); + } + return refs; +} + export interface ExtractedAttachmentRefs { refs: AttachmentRef[]; /** Body with lines that were nothing but extracted links removed. */ diff --git a/ui/src/hooks/useIssueDocuments.ts b/ui/src/hooks/useIssueDocuments.ts new file mode 100644 index 0000000000..04abd58576 --- /dev/null +++ b/ui/src/hooks/useIssueDocuments.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; +import type { IssueDocument } from "@paperclipai/shared"; +import { issuesApi } from "@/api/issues"; +import { queryKeys } from "@/lib/queryKeys"; + +/** + * All of the issue's non-system documents, bodies included (the list endpoint + * filters system keys server-side and returns full documents). Shared by the + * properties pane's tab gating and the Plan/Artifacts tab bodies so they all + * consume one cached fetch. Keyed under queryKeys.issues.documents so + * document-scope invalidations refresh it alongside the single-doc queries. + */ +export function useIssueDocuments(issueId: string | null | undefined) { + return useQuery({ + queryKey: [...queryKeys.issues.documents(issueId ?? ""), "list"], + enabled: Boolean(issueId), + queryFn: () => issuesApi.listDocuments(issueId!), + }); +} diff --git a/ui/src/lib/issue-artifacts.test.ts b/ui/src/lib/issue-artifacts.test.ts new file mode 100644 index 0000000000..026cff4739 --- /dev/null +++ b/ui/src/lib/issue-artifacts.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; +import type { IssueAttachment, IssueWorkProduct } from "@paperclipai/shared"; +import { + documentDisplayTitle, + isAgentAttachment, + selectAgentArtifactAttachments, + workProductHref, +} from "./issue-artifacts"; + +function makeAttachment(overrides: Partial & { id: string }): IssueAttachment { + return { + companyId: "company-1", + issueId: "issue-1", + issueCommentId: null, + assetId: "asset-1", + provider: "local", + objectKey: `objects/${overrides.id}`, + contentType: "image/png", + byteSize: 1024, + sha256: "0".repeat(64), + originalFilename: "shot.png", + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date("2026-08-01T12:00:00Z"), + updatedAt: new Date("2026-08-01T12:00:00Z"), + contentPath: `/api/attachments/${overrides.id}/content`, + ...overrides, + } as IssueAttachment; +} + +function makePromotingWorkProduct(attachmentId: string): IssueWorkProduct { + return { + id: `wp-${attachmentId}`, + companyId: "company-1", + projectId: null, + issueId: "issue-1", + executionWorkspaceId: null, + runtimeServiceId: null, + type: "artifact", + provider: "paperclip", + externalId: null, + title: "output.png", + url: null, + status: "active", + reviewState: "none", + isPrimary: false, + healthStatus: "unknown", + summary: null, + metadata: { + attachmentId, + contentType: "image/png", + byteSize: 1024, + contentPath: `/api/attachments/${attachmentId}/content`, + openPath: `/api/attachments/${attachmentId}/content`, + downloadPath: `/api/attachments/${attachmentId}/content?download=1`, + originalFilename: "output.png", + }, + createdByRunId: null, + createdAt: new Date("2026-08-01T12:00:00Z"), + updatedAt: new Date("2026-08-01T12:00:00Z"), + } as IssueWorkProduct; +} + +describe("isAgentAttachment", () => { + it("accepts agent-authored rows regardless of comment binding", () => { + expect(isAgentAttachment(makeAttachment({ id: "a", createdByAgentId: "agent-1" }))).toBe(true); + expect( + isAgentAttachment( + makeAttachment({ id: "b", createdByAgentId: "agent-1", issueCommentId: "comment-1" }), + ), + ).toBe(true); + }); + + it("rejects user uploads", () => { + expect(isAgentAttachment(makeAttachment({ id: "c", createdByUserId: "user-1" }))).toBe(false); + expect( + isAgentAttachment( + makeAttachment({ id: "d", createdByUserId: "user-1", issueCommentId: "comment-1" }), + ), + ).toBe(false); + }); + + it("treats authorless rows as agent output only when not comment-bound", () => { + expect(isAgentAttachment(makeAttachment({ id: "e" }))).toBe(true); + expect(isAgentAttachment(makeAttachment({ id: "f", issueCommentId: "comment-1" }))).toBe(false); + }); +}); + +describe("selectAgentArtifactAttachments", () => { + it("keeps agent attachments and drops user uploads", () => { + const agent = makeAttachment({ id: "agent-file", createdByAgentId: "agent-1" }); + const user = makeAttachment({ + id: "user-file", + createdByUserId: "user-1", + issueCommentId: "comment-1", + }); + expect(selectAgentArtifactAttachments([agent, user], [])).toEqual([agent]); + }); + + it("dedupes attachments already promoted to work products", () => { + // The promotion metadata schema requires a UUID attachmentId. + const promotedId = "00000000-0000-4000-8000-000000000001"; + const promoted = makeAttachment({ id: promotedId, createdByAgentId: "agent-1" }); + const loose = makeAttachment({ id: "loose", createdByAgentId: "agent-1" }); + const result = selectAgentArtifactAttachments( + [promoted, loose], + [makePromotingWorkProduct(promotedId)], + ); + expect(result.map((a) => a.id)).toEqual(["loose"]); + }); + + it("tolerates missing inputs", () => { + expect(selectAgentArtifactAttachments(null, null)).toEqual([]); + }); +}); + +describe("workProductHref", () => { + it("prefers the top-level url", () => { + expect( + workProductHref({ url: "https://example.com/pr/1", metadata: { openPath: "/api/x" } }), + ).toBe("https://example.com/pr/1"); + }); + + it("falls back to metadata.openPath, then metadata.url", () => { + expect( + workProductHref({ url: null, metadata: { openPath: "/api/attachments/a/content" } }), + ).toBe("/api/attachments/a/content"); + expect( + workProductHref({ url: null, metadata: { url: "https://tunnel.here.now/x" } }), + ).toBe("https://tunnel.here.now/x"); + expect( + workProductHref({ + url: null, + metadata: { openPath: "/api/attachments/a/content", url: "https://tunnel.here.now/x" }, + }), + ).toBe("/api/attachments/a/content"); + }); + + it("ignores non-string and blank metadata values", () => { + expect(workProductHref({ url: null, metadata: { openPath: 42, url: " " } })).toBeNull(); + }); + + it("returns null without url or metadata links", () => { + expect(workProductHref({ url: null, metadata: null })).toBeNull(); + expect(workProductHref({ url: null, metadata: { attachmentId: "a" } })).toBeNull(); + }); +}); + +describe("documentDisplayTitle", () => { + it("prefers the stored title", () => { + expect(documentDisplayTitle({ key: "synthesis", title: "Findings Synthesis" })).toBe( + "Findings Synthesis", + ); + }); + + it("humanizes the key when no title is set", () => { + expect(documentDisplayTitle({ key: "synthesis", title: null })).toBe("Synthesis"); + expect(documentDisplayTitle({ key: "design_notes-v2", title: " " })).toBe("Design notes v2"); + }); +}); diff --git a/ui/src/lib/issue-artifacts.ts b/ui/src/lib/issue-artifacts.ts new file mode 100644 index 0000000000..7563638c49 --- /dev/null +++ b/ui/src/lib/issue-artifacts.ts @@ -0,0 +1,64 @@ +import type { IssueAttachment, IssueDocumentSummary, IssueWorkProduct } from "@paperclipai/shared"; +import { getPromotedOutputAttachmentIds } from "./issue-output"; + +/** + * Selectors for the properties pane's Artifacts tab (PAP-491): which + * attachments count as agent-produced artifacts, as opposed to user uploads + * that live in the conversation thread. + */ + +/** + * An attachment authored by an agent. Rows with no author at all are treated + * as agent output when they are not bound to a comment — legacy agent uploads + * predate attribution, while user uploads always arrive through a comment. + */ +export function isAgentAttachment( + attachment: Pick, +): boolean { + if (attachment.createdByAgentId) return true; + return !attachment.createdByUserId && !attachment.issueCommentId; +} + +/** + * Agent-authored attachments minus the ones already promoted to + * attachment-backed work products (`metadata.attachmentId`), so the Artifacts + * tab lists each file once. + */ +export function selectAgentArtifactAttachments( + attachments: IssueAttachment[] | null | undefined, + workProducts: IssueWorkProduct[] | null | undefined, +): IssueAttachment[] { + const promoted = getPromotedOutputAttachmentIds(workProducts); + return (attachments ?? []).filter( + (attachment) => isAgentAttachment(attachment) && !promoted.has(attachment.id), + ); +} + +/** + * Where a work-product row should link. Many rows carry `url: null` with the + * usable link only in metadata — `openPath` for attachment-backed artifacts + * (same field OutputRow opens) or `url` for provider-specific links — so fall + * back to those before rendering the row inert. Expired links are still + * returned; titles announce expiry where the producer recorded it. + */ +export function workProductHref( + workProduct: Pick, +): string | null { + if (workProduct.url) return workProduct.url; + const metadata = workProduct.metadata; + if (!metadata) return null; + for (const key of ["openPath", "url"]) { + const value = metadata[key]; + if (typeof value === "string" && value.trim().length > 0) return value; + } + return null; +} + +/** Display title for an issue document: its title, else its key humanized. */ +export function documentDisplayTitle( + doc: Pick, +): string { + if (doc.title?.trim()) return doc.title; + const words = doc.key.replace(/[-_]+/g, " ").trim(); + return words.charAt(0).toUpperCase() + words.slice(1); +}