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 (
+
+
setExpanded((open) => !open)}
+ className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-sm hover:bg-accent/50"
+ aria-expanded={expanded}
+ >
+
+ {documentDisplayTitle(doc)}
+
+ {`Rev ${doc.latestRevisionNumber ?? 1}`}
+
+
+
+ {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 =
+ "\nSee [notes.txt](/api/attachments/n/content)\n";
+ 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 = "\n";
+ 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);
+}