feat(ui): surface all issue documents and agent artifacts in chat-style sidebar (#11226)

## 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 <noreply@anthropic.com>
This commit is contained in:
scotttong 2026-08-11 12:09:58 -07:00 committed by GitHub
parent 0044fa8904
commit 5bb2490b86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 570 additions and 30 deletions

View File

@ -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

View File

@ -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 (
<h3 className="px-1 pt-1 text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
{children}
</h3>
);
}
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 = (
<>
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{workProduct.title}</span>
{badge ? (
<span
className="status-chip inline-flex shrink-0 items-center rounded-full border px-1.5 py-0.5 text-(length:--text-nano) leading-none whitespace-nowrap"
style={{ "--sc": `var(${badge.cssVar})` } as CSSProperties}
>
{badge.label}
</span>
) : null}
{href ? (
<ExternalLink className="h-3 w-3 shrink-0 text-muted-foreground" />
) : null}
</>
);
if (href) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className={cn(ROW_CLASS, "hover:bg-accent/50")}
>
{body}
</a>
);
}
return <div className={ROW_CLASS}>{body}</div>;
}
function DocumentRow({ doc }: { doc: IssueDocument }) {
const [expanded, setExpanded] = useState(false);
const Chevron = expanded ? ChevronDown : ChevronRight;
return (
<div className="rounded-md border border-border bg-card/50">
<button
type="button"
onClick={() => 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}
>
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{documentDisplayTitle(doc)}</span>
<span className="shrink-0 text-(length:--text-micro) text-muted-foreground">
{`Rev ${doc.latestRevisionNumber ?? 1}`}
</span>
<Chevron className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
{expanded ? (
<div className="border-t border-border px-2.5 py-2">
{doc.body.trim().length > 0 ? (
<MarkdownBody>{doc.body}</MarkdownBody>
) : (
<p className="text-sm text-muted-foreground">Document is empty.</p>
)}
</div>
) : null}
</div>
);
}
/**
* 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 (
<div className="px-1 py-6 text-sm text-muted-foreground">
No artifacts yet. Attachments and work products will appear here.
No artifacts yet. Work products, documents, and agent-produced files will appear here.
</div>
);
}
return (
<ul className="flex flex-col gap-1 py-2">
{attachments.map((a) => (
<li key={a.id}>
<a
href={a.contentPath}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-md border border-border bg-card/50 px-2.5 py-1.5 text-sm hover:bg-accent/50"
>
<Paperclip className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{a.originalFilename ?? a.objectKey}</span>
<span className="shrink-0 text-(length:--text-micro) text-muted-foreground">{formatBytes(a.byteSize)}</span>
</a>
</li>
))}
</ul>
<div className="flex flex-col gap-2 py-2">
{workProductRows.length > 0 ? (
<>
<SectionHeading>Work products</SectionHeading>
<ul className="flex flex-col gap-1">
{workProductRows.map((wp) => (
<li key={wp.id}>
<WorkProductRow workProduct={wp} />
</li>
))}
</ul>
</>
) : null}
{documentRows.length > 0 ? (
<>
<SectionHeading>Documents</SectionHeading>
<ul className="flex flex-col gap-1">
{documentRows.map((doc) => (
<li key={doc.key}>
<DocumentRow doc={doc} />
</li>
))}
</ul>
</>
) : null}
{fileRows.length > 0 ? (
<>
<SectionHeading>Files</SectionHeading>
<ul className="flex flex-col gap-1">
{fileRows.map((a) => (
<li key={a.id}>
<a
href={attachmentOpenPath(a)}
target="_blank"
rel="noreferrer"
className={cn(ROW_CLASS, "hover:bg-accent/50")}
>
<Paperclip className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{a.originalFilename ?? a.objectKey}</span>
<span className="shrink-0 text-(length:--text-micro) text-muted-foreground">{formatBytes(a.byteSize)}</span>
</a>
</li>
))}
</ul>
</>
) : null}
</div>
);
}

View File

@ -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 (
<div className="px-1 py-6 text-sm text-muted-foreground">
{planDocumentLoading ? (
@ -113,6 +119,22 @@ export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps)
</IssueDocumentAnnotations>
</section>
) : null}
{otherDocuments.map((doc) => (
<section key={doc.key} data-testid="issue-other-document" className="space-y-2 border-t border-border pt-4 first:border-t-0 first:pt-0">
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold">{documentDisplayTitle(doc)}</h3>
<span className="shrink-0 text-xs text-muted-foreground">
{`Revision ${doc.latestRevisionNumber ?? 1} · updated ${new Date(doc.updatedAt).toLocaleString([], {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}`}
</span>
</div>
<MarkdownBody>{doc.body}</MarkdownBody>
</section>
))}
{hasPlans ? (
<IssuePlanDecompositionsSection issueId={issue.id} issueIdentifier={issue.identifier} />
) : null}

View File

@ -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<string | null>(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 (
<div className={cn("tc-enter-bubble flex w-full flex-col gap-1", isHuman ? "items-end" : "items-start")}>
{!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}
</MarkdownBody>
@ -175,6 +201,16 @@ export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubblePr
{item.timestamp}
</span>
) : null}
{lightboxSrc !== null && lightboxIndex >= 0 ? (
<ImageGalleryModal
items={galleryItems}
initialIndex={lightboxIndex}
open
onOpenChange={(open) => {
if (!open) setLightboxSrc(null);
}}
/>
) : null}
</div>
);
}

View File

@ -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([]);
});
});

View File

@ -115,6 +115,26 @@ export interface AttachmentRef {
const ATTACHMENT_LINK_RE =
/(?<!!)\[((?:\\.|[^\]\\])+)\]\((\/api\/(?:attachments|assets)\/[^()\s]+\/content(?:\?[^()\s]*)?)\)/g;
/** Markdown image embeds (`![name](url)`), same escaped-label grammar. */
const IMAGE_EMBED_RE = /!\[((?:\\.|[^\]\\])*)\]\(([^()\s]+)\)/g;
/**
* Every image embedded in a message body, in document order, deduped by URL.
* Feeds the bubble's lightbox: the refs become the gallery items and the
* clicked <img> src picks the initial index.
*/
export function extractImageRefs(body: string): AttachmentRef[] {
const refs: AttachmentRef[] = [];
const seen = new Set<string>();
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. */

View File

@ -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<IssueDocument[]>({
queryKey: [...queryKeys.issues.documents(issueId ?? ""), "list"],
enabled: Boolean(issueId),
queryFn: () => issuesApi.listDocuments(issueId!),
});
}

View File

@ -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<IssueAttachment> & { 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");
});
});

View File

@ -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<IssueAttachment, "createdByAgentId" | "createdByUserId" | "issueCommentId">,
): 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<IssueWorkProduct, "url" | "metadata">,
): 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<IssueDocumentSummary, "key" | "title">,
): string {
if (doc.title?.trim()) return doc.title;
const words = doc.key.replace(/[-_]+/g, " ").trim();
return words.charAt(0).toUpperCase() + words.slice(1);
}