From 5bb2490b862488515f3f6d4f2a9a3912cdcc302f Mon Sep 17 00:00:00 2001 From: scotttong Date: Tue, 11 Aug 2026 12:09:58 -0700 Subject: [PATCH] feat(ui): surface all issue documents and agent artifacts in chat-style sidebar (#11226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The task detail page uses a chat-style thread with a right sidebar; the sidebar has a Plan tab and an Artifacts tab (#11101 made this UI the default) > - The Plan tab only showed the one issue document named `plan`, and the Artifacts tab only listed formal work products; other agent-authored documents (for example a `synthesis` doc) and agent-attached files were invisible in the sidebar > - Users could see an agent mention a document in the thread but had no way to find that document in the sidebar, which breaks trust in the task view as the record of the work > - This pull request surfaces every non-system issue document in the Plan tab, composes the Artifacts tab from work products, documents, and agent-created attachments, and gives thread images a full-screen lightbox with download > - The benefit is that anything an agent produces on a task is now reachable from the sidebar, while user uploads stay with their comments in the thread ## Linked Issues or Issue Description Refs #11101 (chat-style task UI default — this PR extends its sidebar). **Subsystem affected** Task detail UI (chat-style thread sidebar): Plan tab, Artifacts tab, and thread attachment rendering in `ui/src`. **Current behavior** The Plan tab renders only the issue document literally named `plan`. The Artifacts tab renders only formal work products. Agent-authored documents with any other name, and files agents attach to comments, do not appear anywhere in the sidebar. Thread images open as bare links. **Proposed behavior** The Plan tab lists every non-system issue document, with the `plan` document first and the others rendered inline below it. The Artifacts tab composes three sources — work products, issue documents, and agent-created comment attachments — deduplicated against attachment-backed work products via `metadata.attachmentId`, and shows whenever any source is non-empty. Work-product rows without a resolvable attachment or document fall back to links found in their metadata so they stay clickable. Images in the thread open a shared full-screen lightbox with a download action. Files uploaded by users stay thread-only and are not mixed into the Artifacts tab. **Reason and benefit** Agents routinely produce documents that are not named `plan` and attach files to their comments. Users reading the thread must be able to find every one of those outputs from the sidebar. Redundant surfacing is acceptable; an unfindable document is not. **Breaking changes** None. This is additive rendering; no schema or API changes. ## What Changed - `IssuePropertiesPlansTab.tsx`: renders all non-system issue documents, `plan` primary, others inline below via `MarkdownBody` - `IssuePropertiesArtifactsTab.tsx`: composes work products + documents + agent-created attachments with dedupe; rows without an attachment/document target fall back to `metadata` links - `IssueProperties.tsx`: Artifacts tab visibility now derives from the composed source set - New `ui/src/lib/issue-artifacts.ts`: pure composition/dedupe logic, unit-tested - New `ui/src/components/task-chat/task-chat-attachments.ts`: splits agent vs user comment attachments, unit-tested - `TaskChatBubble.tsx`: thread images open the shared full-screen lightbox with download - `useIssueDocuments.ts`: hook now exposes the full issue-document list ## Verification - `pnpm typecheck` — passes across the workspace - `pnpm check:token-gates` — 3/3 CLEAN - `cd ui && pnpm vitest run src/lib/issue-artifacts.test.ts src/components/task-chat/task-chat-attachments.test.ts src/pages/IssueDetail.test.tsx` — 74 tests pass - Manual: open a task whose agent created a document not named `plan` (for example `synthesis`); confirm it appears in the Plan tab below the plan and in the Artifacts tab; confirm an image the agent attached appears under Artifacts; confirm a user-uploaded image stays only in the thread and opens full screen with a download button Snapshot baselines are intentionally not updated for this visual change, per the `doc/design/DECISION-SHEET.md` entry "Per-change snapshot verification demoted to dormant (Jul 13 2026)". ## Risks - Low risk: rendering-only change scoped to the task sidebar and thread bubbles; composition logic is pure and unit-tested - Dedupe relies on `metadata.attachmentId` linkage; a work product with malformed metadata would render as a duplicate row (cosmetic only) ## Model Used - Claude (Anthropic), model id `claude-fable-5`, extended thinking enabled, agentic tool use via Claude Agent SDK (Claude Code harness) ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Fable 5 --- .../issue-properties/IssueProperties.tsx | 18 +- .../IssuePropertiesArtifactsTab.tsx | 227 ++++++++++++++++-- .../IssuePropertiesPlansTab.tsx | 24 +- .../components/task-chat/TaskChatBubble.tsx | 40 ++- .../task-chat/task-chat-attachments.test.ts | 28 +++ .../task-chat/task-chat-attachments.ts | 20 ++ ui/src/hooks/useIssueDocuments.ts | 19 ++ ui/src/lib/issue-artifacts.test.ts | 160 ++++++++++++ ui/src/lib/issue-artifacts.ts | 64 +++++ 9 files changed, 570 insertions(+), 30 deletions(-) create mode 100644 ui/src/hooks/useIssueDocuments.ts create mode 100644 ui/src/lib/issue-artifacts.test.ts create mode 100644 ui/src/lib/issue-artifacts.ts 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); +}