diff --git a/packages/shared/src/types/issue-deliverables.ts b/packages/shared/src/types/issue-deliverables.ts index b8827f7b865..dc5c984cc1f 100644 --- a/packages/shared/src/types/issue-deliverables.ts +++ b/packages/shared/src/types/issue-deliverables.ts @@ -28,6 +28,7 @@ export interface IssueDeliverableItem { revisionNumber: number | null; contentType: string | null; byteSize: number | null; + isOperatorContext: boolean; } export interface IssueDeliverablesRuntimeServiceSummary { diff --git a/server/src/__tests__/issue-deliverables.test.ts b/server/src/__tests__/issue-deliverables.test.ts index cbe1e754073..09410033c4c 100644 --- a/server/src/__tests__/issue-deliverables.test.ts +++ b/server/src/__tests__/issue-deliverables.test.ts @@ -117,7 +117,7 @@ function createWorkspace(overrides: Partial = {}): Execution } describe("buildIssueDeliverables", () => { - it("pins the plan first in the documents list while choosing the newest document as primary", () => { + it("pins the plan first in the documents list without promoting documents as primary work product", () => { const response = buildIssueDeliverables({ issue: { id: "issue-1", @@ -145,7 +145,7 @@ describe("buildIssueDeliverables", () => { expect(response.summary.documentCount).toBe(2); expect(response.documents[0]?.documentKey).toBe("plan"); - expect(response.primaryItem?.title).toBe("Weekly report"); + expect(response.primaryItem).toBeNull(); }); it("prefers explicit primary work products and merges artifacts with attachments into files", () => { @@ -212,6 +212,66 @@ describe("buildIssueDeliverables", () => { expect(response.workspace?.runtimeServiceCount).toBe(1); expect(response.workspace?.runtimeServiceHealth).toBe("healthy"); }); + + it("excludes comment attachments by default and includes them with operator context", () => { + const baseInput = { + issue: { + id: "issue-1", + projectId: "project-1", + description: null, + executionWorkspaceId: null, + createdAt: new Date("2026-04-01T12:00:00.000Z"), + updatedAt: new Date("2026-04-01T15:00:00.000Z"), + }, + workspace: null, + workProducts: [], + documents: [], + legacyPlanDocument: null, + attachments: [ + { + id: "attachment-output", + companyId: "company-1", + issueId: "issue-1", + issueCommentId: null, + assetId: "asset-1", + provider: "paperclip", + objectKey: "attachments/report.txt", + contentType: "text/plain", + byteSize: 512, + sha256: "abc", + originalFilename: "report.txt", + createdByAgentId: null, + createdByUserId: "user-1", + createdAt: new Date("2026-04-01T15:00:00.000Z"), + updatedAt: new Date("2026-04-01T15:00:00.000Z"), + }, + { + id: "attachment-comment", + companyId: "company-1", + issueId: "issue-1", + issueCommentId: "comment-1", + assetId: "asset-2", + provider: "paperclip", + objectKey: "attachments/chat-screenshot.png", + contentType: "image/png", + byteSize: 1024, + sha256: "def", + originalFilename: "chat-screenshot.png", + createdByAgentId: null, + createdByUserId: "user-1", + createdAt: new Date("2026-04-01T16:00:00.000Z"), + updatedAt: new Date("2026-04-01T16:00:00.000Z"), + }, + ], + }; + + const defaultResponse = buildIssueDeliverables(baseInput); + expect(defaultResponse.files.map((item) => item.id)).toEqual(["attachment-output"]); + + const operatorResponse = buildIssueDeliverables({ ...baseInput, includeOperatorContext: true }); + expect(operatorResponse.files.map((item) => item.id)).toEqual(["attachment-comment", "attachment-output"]); + expect(operatorResponse.files.find((item) => item.id === "attachment-comment")?.isOperatorContext).toBe(true); + }); }); describe("buildCompanyDeliverables", () => { @@ -291,10 +351,53 @@ describe("buildCompanyDeliverables", () => { expect(response.summary.issueCount).toBe(2); const kinds = response.items.map((item) => item.kind); - expect(kinds).toEqual(["preview_url", "attachment", "document", "document"]); + expect(kinds).toEqual(["preview_url", "document", "document", "attachment"]); const legacyPlan = response.items.find((item): item is CompanyDeliverableItem => item.id === "legacy-plan:issue-1"); expect(legacyPlan?.issueIdentifier).toBe("PAP-10"); expect(legacyPlan?.issueTitle).toBe("Write launch plan"); }); + + it("excludes comment attachments from the company feed unless operator context is requested", () => { + const input = { + issues: [ + { + id: "issue-1", + identifier: "PAP-10", + title: "Review screenshot", + status: "in_progress", + projectId: "project-1", + description: null, + createdAt: new Date("2026-04-01T09:00:00.000Z"), + updatedAt: new Date("2026-04-01T15:00:00.000Z"), + }, + ], + workProducts: [], + documents: [], + attachments: [ + { + id: "attachment-comment", + companyId: "company-1", + issueId: "issue-1", + issueCommentId: "comment-1", + assetId: "asset-1", + provider: "paperclip", + objectKey: "attachments/chat-screenshot.png", + contentType: "image/png", + byteSize: 1024, + sha256: "abc", + originalFilename: "chat-screenshot.png", + createdByAgentId: null, + createdByUserId: "user-1", + createdAt: new Date("2026-04-01T16:30:00.000Z"), + updatedAt: new Date("2026-04-01T16:30:00.000Z"), + }, + ], + }; + + expect(buildCompanyDeliverables(input).summary.totalCount).toBe(0); + const response = buildCompanyDeliverables({ ...input, includeOperatorContext: true }); + expect(response.items.map((item) => item.id)).toEqual(["attachment-comment"]); + expect(response.items[0]?.isOperatorContext).toBe(true); + }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 7638b500dce..419dc7573d7 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -898,7 +898,8 @@ export function issueRoutes( router.get("/companies/:companyId/deliverables", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const deliverables = await issueDeliverablesSvc.listForCompany(companyId); + const includeOperatorContext = req.query.includeOperatorContext === "true"; + const deliverables = await issueDeliverablesSvc.listForCompany(companyId, { includeOperatorContext }); res.json(deliverables); }); @@ -2239,7 +2240,8 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); - const deliverables = await issueDeliverablesSvc.getForIssue(issue); + const includeOperatorContext = req.query.includeOperatorContext === "true"; + const deliverables = await issueDeliverablesSvc.getForIssue(issue, { includeOperatorContext }); res.json(deliverables); }); diff --git a/server/src/services/issue-deliverables.ts b/server/src/services/issue-deliverables.ts index bfa23278c19..b714fb44d43 100644 --- a/server/src/services/issue-deliverables.ts +++ b/server/src/services/issue-deliverables.ts @@ -38,6 +38,7 @@ type BuildIssueDeliverablesInput = { documents: IssueDocument[]; legacyPlanDocument: LegacyPlanDocument | null; attachments: AttachmentInput[]; + includeOperatorContext?: boolean; }; type BuildCompanyDeliverablesInput = { @@ -45,10 +46,34 @@ type BuildCompanyDeliverablesInput = { workProducts: IssueWorkProduct[]; documents: IssueDocument[]; attachments: AttachmentInput[]; + includeOperatorContext?: boolean; }; -function compareItemsByRecency(a: IssueDeliverableItem, b: IssueDeliverableItem) { +function kindBucket(kind: IssueDeliverableItem["kind"]) { + switch (kind) { + case "pull_request": + return 0; + case "preview_url": + case "runtime_service": + return 1; + case "branch": + case "commit": + return 2; + case "document": + return 3; + case "artifact": + return 4; + case "attachment": + return 5; + default: + return 99; + } +} + +function compareDeliverableItems(a: IssueDeliverableItem, b: IssueDeliverableItem) { if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; + const bucketDelta = kindBucket(a.kind) - kindBucket(b.kind); + if (bucketDelta !== 0) return bucketDelta; return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); } @@ -99,6 +124,7 @@ function toWorkProductItem(product: IssueWorkProduct): IssueDeliverableItem { revisionNumber: null, contentType, byteSize, + isOperatorContext: false, }; } @@ -122,6 +148,7 @@ function toDocumentItem(document: IssueDocument): IssueDeliverableItem { revisionNumber: document.latestRevisionNumber, contentType: "text/markdown", byteSize: null, + isOperatorContext: false, }; } @@ -145,6 +172,7 @@ function toLegacyPlanItem(issue: LegacyPlanIssueInput, legacyPlanDocument: Legac revisionNumber: null, contentType: "text/markdown", byteSize: null, + isOperatorContext: false, }; } @@ -168,6 +196,7 @@ function toAttachmentItem(attachment: AttachmentInput): IssueDeliverableItem { revisionNumber: null, contentType: attachment.contentType, byteSize: attachment.byteSize, + isOperatorContext: Boolean(attachment.issueCommentId), }; } @@ -235,8 +264,6 @@ function pickPrimaryItem(groups: { workProducts: IssueWorkProduct[]; previews: IssueDeliverableItem[]; pullRequests: IssueDeliverableItem[]; - documents: IssueDeliverableItem[]; - files: IssueDeliverableItem[]; }) { const explicitPrimary = [...groups.workProducts] .filter((product) => product.isPrimary) @@ -248,31 +275,30 @@ function pickPrimaryItem(groups: { .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0]; if (preview) return preview; - const pullRequest = [...groups.pullRequests].sort(compareItemsByRecency)[0]; + const pullRequest = [...groups.pullRequests].sort(compareDeliverableItems)[0]; if (pullRequest) return pullRequest; - const document = [...groups.documents] - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0]; - if (document) return document; - - return [...groups.files].sort(compareItemsByRecency)[0] ?? null; + return null; } export function buildIssueDeliverables(input: BuildIssueDeliverablesInput): IssueDeliverablesResponse { const workspace = summarizeWorkspace(input.workspace); const workProductItems = input.workProducts.map(toWorkProductItem); + const attachments = input.includeOperatorContext + ? input.attachments + : input.attachments.filter((attachment) => !attachment.issueCommentId); const previews = workProductItems .filter((item) => item.kind === "preview_url" || item.kind === "runtime_service") - .sort(compareItemsByRecency); + .sort(compareDeliverableItems); const pullRequests = workProductItems .filter((item) => item.kind === "pull_request") - .sort(compareItemsByRecency); + .sort(compareDeliverableItems); const branches = workProductItems .filter((item) => item.kind === "branch") - .sort(compareItemsByRecency); + .sort(compareDeliverableItems); const commits = workProductItems .filter((item) => item.kind === "commit") - .sort(compareItemsByRecency); + .sort(compareDeliverableItems); const documentItems = [ ...workProductItems.filter((item) => item.kind === "document"), @@ -282,15 +308,13 @@ export function buildIssueDeliverables(input: BuildIssueDeliverablesInput): Issu const fileItems = [ ...workProductItems.filter((item) => item.kind === "artifact"), - ...input.attachments.map(toAttachmentItem), - ].sort(compareItemsByRecency); + ...attachments.map(toAttachmentItem), + ].sort(compareDeliverableItems); const primaryItem = pickPrimaryItem({ workProducts: input.workProducts, previews, pullRequests, - documents: documentItems, - files: fileItems, }); return { @@ -352,7 +376,11 @@ export function buildCompanyDeliverables(input: BuildCompanyDeliverablesInput): items.push(toCompanyDeliverableItem(toDocumentItem(document), issue)); } - for (const attachment of input.attachments) { + const attachments = input.includeOperatorContext + ? input.attachments + : input.attachments.filter((attachment) => !attachment.issueCommentId); + + for (const attachment of attachments) { const issue = issueMap.get(attachment.issueId); if (!issue) continue; items.push(toCompanyDeliverableItem(toAttachmentItem(attachment), issue)); @@ -374,7 +402,7 @@ export function buildCompanyDeliverables(input: BuildCompanyDeliverablesInput): ); } - const sortedItems = [...items].sort(compareItemsByRecency); + const sortedItems = [...items].sort(compareDeliverableItems); const issueIds = new Set(); let primaryCount = 0; let previewCount = 0; @@ -418,7 +446,7 @@ export function issueDeliverableService(db: Db) { const executionWorkspacesSvc = executionWorkspaceService(db); return { - listForCompany: async (companyId: string) => { + listForCompany: async (companyId: string, options?: { includeOperatorContext?: boolean }) => { const [issues, workProducts, documents, attachments] = await Promise.all([ issuesSvc.list(companyId), workProductsSvc.listForCompany(companyId), @@ -440,10 +468,11 @@ export function issueDeliverableService(db: Db) { workProducts, documents, attachments, + includeOperatorContext: options?.includeOperatorContext ?? false, }); }, - getForIssue: async (issue: IssueInput) => { + getForIssue: async (issue: IssueInput, options?: { includeOperatorContext?: boolean }) => { const legacyPlanBody = extractLegacyPlanBody(issue.description); const [workspace, workProducts, documents, attachments] = await Promise.all([ issue.executionWorkspaceId ? executionWorkspacesSvc.getById(issue.executionWorkspaceId) : Promise.resolve(null), @@ -465,6 +494,7 @@ export function issueDeliverableService(db: Db) { } : null, attachments, + includeOperatorContext: options?.includeOperatorContext ?? false, }); }, }; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 3e25479471b..5ebcd4e950d 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -107,7 +107,8 @@ function boardRoutes() { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> @@ -301,6 +302,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index 12aed0025ac..42e2593ec76 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -25,9 +25,9 @@ describe("issuesApi.list", () => { }); it("calls the deliverables endpoint for an issue", async () => { - await issuesApi.getDeliverables("issue-1"); + await issuesApi.getDeliverables("issue-1", { includeOperatorContext: true }); - expect(mockApi.get).toHaveBeenCalledWith("/issues/issue-1/deliverables"); + expect(mockApi.get).toHaveBeenCalledWith("/issues/issue-1/deliverables?includeOperatorContext=true"); }); it("calls the company work products endpoint with filters", async () => { @@ -39,8 +39,8 @@ describe("issuesApi.list", () => { }); it("calls the company deliverables endpoint", async () => { - await issuesApi.listCompanyDeliverables("company-1"); + await issuesApi.listCompanyDeliverables("company-1", { includeOperatorContext: true }); - expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/deliverables"); + expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/deliverables?includeOperatorContext=true"); }); }); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 0b2b8303e07..b3bc0a4fbff 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -165,9 +165,18 @@ export const issuesApi = { api.post(`/issues/${id}/approvals`, { approvalId }), unlinkApproval: (id: string, approvalId: string) => api.delete<{ ok: true }>(`/issues/${id}/approvals/${approvalId}`), - getDeliverables: (id: string) => api.get(`/issues/${id}/deliverables`), - listCompanyDeliverables: (companyId: string) => - api.get(`/companies/${companyId}/deliverables`), + getDeliverables: (id: string, filters?: { includeOperatorContext?: boolean }) => { + const params = new URLSearchParams(); + if (filters?.includeOperatorContext) params.set("includeOperatorContext", "true"); + const qs = params.toString(); + return api.get(`/issues/${id}/deliverables${qs ? `?${qs}` : ""}`); + }, + listCompanyDeliverables: (companyId: string, filters?: { includeOperatorContext?: boolean }) => { + const params = new URLSearchParams(); + if (filters?.includeOperatorContext) params.set("includeOperatorContext", "true"); + const qs = params.toString(); + return api.get(`/companies/${companyId}/deliverables${qs ? `?${qs}` : ""}`); + }, listCompanyWorkProducts: ( companyId: string, filters?: { diff --git a/ui/src/components/IssueDeliverablesSummaryChips.tsx b/ui/src/components/IssueDeliverablesSummaryChips.tsx index dc21a2c6d74..9667b97cfeb 100644 --- a/ui/src/components/IssueDeliverablesSummaryChips.tsx +++ b/ui/src/components/IssueDeliverablesSummaryChips.tsx @@ -22,35 +22,69 @@ function workspaceModeLabel(mode: string) { } } -export function IssueDeliverablesSummaryChips({ summary }: { summary: IssueDeliverablesSummary | null | undefined }) { +type Chip = { + key: string; + label: string; + section: string; +}; + +export function IssueDeliverablesSummaryChips({ + summary, + onOpenDocuments, + onOpenWorkProduct, +}: { + summary: IssueDeliverablesSummary | null | undefined; + onOpenDocuments?: () => void; + onOpenWorkProduct?: (sectionId: string) => void; +}) { if (!summary) return null; - const chips: string[] = []; + const chips: Chip[] = []; if (summary.workspaceMode) { - chips.push(`Workspace: ${workspaceModeLabel(summary.workspaceMode)}`); + chips.push({ + key: "workspace", + label: `Workspace: ${workspaceModeLabel(summary.workspaceMode)}`, + section: "issue-work-product-workspace", + }); } if (summary.pullRequestCount > 0) { if (summary.pullRequestCount === 1) { const prState = summary.pullRequestReviewState && summary.pullRequestReviewState !== "none" ? summary.pullRequestReviewState : summary.pullRequestStatus; - chips.push(`PR: ${prState ? labelize(prState) : "1"}`); + chips.push({ + key: "pull-requests", + label: `PR: ${prState ? labelize(prState) : "1"}`, + section: "issue-work-product-pull-requests", + }); } else { - chips.push(`PRs: ${summary.pullRequestCount}`); + chips.push({ + key: "pull-requests", + label: `PRs: ${summary.pullRequestCount}`, + section: "issue-work-product-pull-requests", + }); } } if (summary.previewCount > 0) { if (summary.previewCount === 1 && summary.previewHealth) { - chips.push(`Preview: ${labelize(summary.previewHealth)}`); + chips.push({ + key: "previews", + label: `Preview: ${labelize(summary.previewHealth)}`, + section: "issue-work-product-previews", + }); } else { - chips.push(`Previews: ${summary.previewCount}`); + chips.push({ + key: "previews", + label: `Previews: ${summary.previewCount}`, + section: "issue-work-product-previews", + }); } } if (summary.documentCount > 0) { - chips.push(`Docs: ${summary.documentCount}`); + chips.push({ key: "documents", label: `Docs: ${summary.documentCount}`, section: "issue-documents" }); } if (summary.fileCount > 0) { - chips.push(`Files: ${summary.fileCount}`); + chips.push({ key: "files", label: `Files: ${summary.fileCount}`, section: "issue-work-product-files" }); } if (chips.length === 0) return null; @@ -58,8 +92,20 @@ export function IssueDeliverablesSummaryChips({ summary }: { summary: IssueDeliv return (
{chips.map((chip) => ( - - {chip} + + ))}
diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index bd9aa668930..66fb0b94da0 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -685,7 +685,7 @@ export function IssueDocumentsSection({ }; return ( -
+
{isEmpty && !draft?.isNew ? (
{extraActions} diff --git a/ui/src/components/IssueWorkProductTab.test.tsx b/ui/src/components/IssueWorkProductTab.test.tsx index b0dd241e845..dba920f857e 100644 --- a/ui/src/components/IssueWorkProductTab.test.tsx +++ b/ui/src/components/IssueWorkProductTab.test.tsx @@ -71,6 +71,7 @@ function createDeliverables(): IssueDeliverablesResponse { revisionNumber: null, contentType: null, byteSize: null, + isOperatorContext: false, }, previews: [ { @@ -92,6 +93,7 @@ function createDeliverables(): IssueDeliverablesResponse { revisionNumber: null, contentType: null, byteSize: null, + isOperatorContext: false, }, ], pullRequests: [ @@ -114,6 +116,7 @@ function createDeliverables(): IssueDeliverablesResponse { revisionNumber: null, contentType: null, byteSize: null, + isOperatorContext: false, }, ], branches: [], @@ -138,6 +141,7 @@ function createDeliverables(): IssueDeliverablesResponse { revisionNumber: 2, contentType: "text/markdown", byteSize: null, + isOperatorContext: false, }, ], files: [ @@ -160,6 +164,7 @@ function createDeliverables(): IssueDeliverablesResponse { revisionNumber: null, contentType: "text/plain", byteSize: 512, + isOperatorContext: false, }, ], }; @@ -199,8 +204,8 @@ describe("IssueWorkProductTab", () => { expect(container.textContent).toContain("Primary output"); expect(container.textContent).toContain("Current workspace"); expect(container.textContent).toContain("Pull requests"); - expect(container.textContent).toContain("Documents"); - expect(container.querySelector('a[href="#document-plan"]')).not.toBeNull(); + expect(container.textContent).not.toContain("Documents"); + expect(container.querySelector('a[href="#document-plan"]')).toBeNull(); }); it("renders the empty state when nothing has been produced", async () => { @@ -238,6 +243,6 @@ describe("IssueWorkProductTab", () => { }); await flush(); - expect(container.textContent).toContain("No deliverables are registered for this issue yet."); + expect(container.textContent).toContain("No work product is registered for this issue yet."); }); }); diff --git a/ui/src/components/IssueWorkProductTab.tsx b/ui/src/components/IssueWorkProductTab.tsx index b519fc72116..7a428860438 100644 --- a/ui/src/components/IssueWorkProductTab.tsx +++ b/ui/src/components/IssueWorkProductTab.tsx @@ -1,8 +1,11 @@ import type { IssueDeliverableItem, IssueDeliverablesResponse } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { projectWorkspaceUrl, relativeTime } from "@/lib/utils"; +import { EmptyState } from "./EmptyState"; +import { PageSkeleton } from "./PageSkeleton"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { FileText, FolderOpen, GitBranch, GitCommitHorizontal, Link2, MonitorUp, Package } from "lucide-react"; function labelize(value: string | null | undefined) { @@ -91,6 +94,7 @@ function ItemCard({ item }: { item: IssueDeliverableItem }) { ) : null} {item.documentKey ? {item.documentKey} : null} {item.contentType ? {item.contentType} : null} + {item.isOperatorContext ? Operator context : null} {sizeLabel ? {sizeLabel} : null}
{item.summary ?

{item.summary}

: null} @@ -102,16 +106,18 @@ function ItemCard({ item }: { item: IssueDeliverableItem }) { } function DeliverableSection({ + id, title, items, }: { + id: string; title: string; items: IssueDeliverableItem[]; }) { if (items.length === 0) return null; return ( -
+

{title}

{items.length} item{items.length === 1 ? "" : "s"}

@@ -142,14 +148,18 @@ export function IssueWorkProductTab({ projectId, projectWorkspaceId, isLoading = false, + showOperatorContext = false, + onShowOperatorContextChange, }: { deliverables: IssueDeliverablesResponse | null | undefined; projectId: string | null; projectWorkspaceId: string | null; isLoading?: boolean; + showOperatorContext?: boolean; + onShowOperatorContextChange?: (checked: boolean) => void; }) { if (isLoading && !deliverables) { - return
Loading work product…
; + return ; } if (!deliverables) { @@ -161,12 +171,12 @@ export function IssueWorkProductTab({ return (
{deliverables.primaryItem ? ( -
+

Primary output

-

The current best deliverable for this issue.

+

The current best Work Product for this issue.

@@ -174,7 +184,7 @@ export function IssueWorkProductTab({ ) : null} {deliverables.workspace ? ( -
+
@@ -217,23 +227,31 @@ export function IssueWorkProductTab({
) : null} - - - - - - +
+ Show operator context + onShowOperatorContextChange?.(checked)} + aria-label="Show operator context" + /> +
+ + + + + + {!deliverables.summary.hasAny ? ( -
- No deliverables are registered for this issue yet. Documents, uploaded files, previews, PRs, and other work - product will appear here when they are created. -
+ ) : null} {deliverables.summary.hasAny && !deliverables.primaryItem ? (
- Outputs exist for this issue, but there is no single primary deliverable yet. + Outputs exist for this issue, but no primary Work Product has been promoted yet.
) : null}
diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 1c8197498ac..362755419e2 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -105,7 +105,7 @@ export function Sidebar() { {showWorkspacesLink ? ( ) : null} - + diff --git a/ui/src/lib/company-routes.test.ts b/ui/src/lib/company-routes.test.ts index d6dc2668378..44071388a19 100644 --- a/ui/src/lib/company-routes.test.ts +++ b/ui/src/lib/company-routes.test.ts @@ -20,4 +20,11 @@ describe("company routes", () => { "/execution-workspaces/workspace-123", ); }); + + it("keeps artifacts compatibility paths company scoped", () => { + expect(isBoardPathWithoutPrefix("/artifacts")).toBe(true); + expect(extractCompanyPrefixFromPath("/artifacts")).toBeNull(); + expect(applyCompanyPrefix("/artifacts", "PAP")).toBe("/PAP/artifacts"); + expect(applyCompanyPrefix("/work-product?filter=files", "PAP")).toBe("/PAP/work-product?filter=files"); + }); }); diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index 04659a61a3a..173853c92a2 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -7,6 +7,8 @@ const BOARD_ROUTE_ROOTS = new Set([ "agents", "projects", "workspaces", + "work-product", + "artifacts", "execution-workspaces", "issues", "routines", diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 02e304334fa..9328776dbe8 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -50,7 +50,8 @@ export const queryKeys = { documents: (issueId: string) => ["issues", "documents", issueId] as const, document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const, documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const, - deliverables: (issueId: string) => ["issues", "deliverables", issueId] as const, + deliverables: (issueId: string, includeOperatorContext = false) => + ["issues", "deliverables", issueId, includeOperatorContext] as const, activity: (issueId: string) => ["issues", "activity", issueId] as const, runs: (issueId: string) => ["issues", "runs", issueId] as const, approvals: (issueId: string) => ["issues", "approvals", issueId] as const, @@ -82,7 +83,8 @@ export const queryKeys = { detail: (id: string) => ["goals", "detail", id] as const, }, artifacts: { - list: (companyId: string) => ["artifacts", companyId] as const, + list: (companyId: string, includeOperatorContext = false) => + ["work-product", companyId, includeOperatorContext] as const, }, budgets: { overview: (companyId: string) => ["budgets", "overview", companyId] as const, diff --git a/ui/src/pages/Artifacts.test.tsx b/ui/src/pages/Artifacts.test.tsx new file mode 100644 index 00000000000..d7fb2486a89 --- /dev/null +++ b/ui/src/pages/Artifacts.test.tsx @@ -0,0 +1,188 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import type { CompanyDeliverablesResponse } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Artifacts } from "./Artifacts"; + +const listCompanyDeliverablesMock = vi.hoisted(() => vi.fn()); +const listProjectsMock = vi.hoisted(() => vi.fn()); +const setBreadcrumbsMock = vi.hoisted(() => vi.fn()); + +vi.mock("../api/issues", () => ({ + issuesApi: { + listCompanyDeliverables: listCompanyDeliverablesMock, + }, +})); + +vi.mock("../api/projects", () => ({ + projectsApi: { + list: listProjectsMock, + }, +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + }), +})); + +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ + setBreadcrumbs: setBreadcrumbsMock, + }), +})); + +vi.mock("@/lib/router", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + Link: ({ to, children, className }: { to: string; children: ReactNode; className?: string }) => ( + {children} + ), + }; +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createWorkProductResponse(items: CompanyDeliverablesResponse["items"]): CompanyDeliverablesResponse { + return { + items, + summary: { + totalCount: items.length, + issueCount: new Set(items.map((item) => item.issueId)).size, + primaryCount: items.filter((item) => item.isPrimary).length, + previewCount: items.filter((item) => item.kind === "preview_url" || item.kind === "runtime_service").length, + pullRequestCount: items.filter((item) => item.kind === "pull_request").length, + branchCount: items.filter((item) => item.kind === "branch").length, + commitCount: items.filter((item) => item.kind === "commit").length, + documentCount: items.filter((item) => item.kind === "document").length, + fileCount: items.filter((item) => item.kind === "artifact" || item.kind === "attachment").length, + }, + }; +} + +function createItem(overrides: Partial = {}): CompanyDeliverablesResponse["items"][number] { + return { + id: "item-1", + sourceType: "attachment", + kind: "attachment", + title: "mockup.png", + url: "/api/attachments/attachment-1/content", + summary: null, + status: null, + reviewState: null, + healthStatus: null, + provider: "paperclip", + updatedAt: new Date("2026-04-01T12:00:00.000Z"), + createdAt: new Date("2026-04-01T12:00:00.000Z"), + isPrimary: false, + metadata: null, + documentKey: null, + revisionNumber: null, + contentType: "image/png", + byteSize: 1024, + isOperatorContext: false, + issueId: "issue-1", + issueIdentifier: "PAP-10", + issueTitle: "Create mockup", + issueStatus: "done", + projectId: "project-1", + ...overrides, + }; +} + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +describe("Artifacts", () => { + let container: HTMLDivElement; + let queryClient: QueryClient; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + listProjectsMock.mockResolvedValue([{ id: "project-1", name: "Paperclip App" }]); + listCompanyDeliverablesMock.mockResolvedValue( + createWorkProductResponse([ + createItem(), + createItem({ + id: "doc-1", + sourceType: "document", + kind: "document", + title: "Launch brief", + url: "#document-brief", + contentType: "text/markdown", + byteSize: null, + documentKey: "brief", + }), + ]), + ); + }); + + afterEach(() => { + container.remove(); + vi.clearAllMocks(); + }); + + it("hydrates search, filter, project, and operator-context state from the URL", async () => { + const root = createRoot(container); + await act(async () => { + root.render( + + + + + , + ); + }); + await flush(); + + expect(listCompanyDeliverablesMock).toHaveBeenCalledWith("company-1", { includeOperatorContext: true }); + expect(setBreadcrumbsMock).toHaveBeenCalledWith([{ label: "Work Product" }]); + expect(container.textContent).toContain("mockup.png"); + expect(container.textContent).not.toContain("Launch brief"); + expect((container.querySelector('input[placeholder="Search titles, issues, providers..."]') as HTMLInputElement).value).toBe("mock"); + }); + + it("does not render Spotlight when the filtered set has no primary or auto-promoted output", async () => { + listCompanyDeliverablesMock.mockResolvedValue( + createWorkProductResponse([ + createItem({ + id: "doc-1", + sourceType: "document", + kind: "document", + title: "Launch brief", + url: "#document-brief", + contentType: "text/markdown", + byteSize: null, + documentKey: "brief", + }), + ]), + ); + + const root = createRoot(container); + await act(async () => { + root.render( + + + + + , + ); + }); + await flush(); + + expect(container.textContent).not.toContain("Spotlight"); + expect(container.textContent).toContain("Latest Work Product"); + }); +}); diff --git a/ui/src/pages/Artifacts.tsx b/ui/src/pages/Artifacts.tsx index 0a737c337f4..2fccdf26c2e 100644 --- a/ui/src/pages/Artifacts.tsx +++ b/ui/src/pages/Artifacts.tsx @@ -1,6 +1,6 @@ import { useDeferredValue, useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Link } from "@/lib/router"; +import { Link, useSearchParams } from "@/lib/router"; import type { CompanyDeliverableItem } from "@paperclipai/shared"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; @@ -11,6 +11,7 @@ import { PageSkeleton } from "../components/PageSkeleton"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { queryKeys } from "../lib/queryKeys"; import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; import { projectUrl, relativeTime } from "../lib/utils"; @@ -81,48 +82,71 @@ function deliverableMeta(item: CompanyDeliverableItem) { return { label: "Document", icon: FileText, - accentClass: "from-amber-200/80 via-amber-50 to-background", - badgeClass: "bg-amber-500/10 text-amber-700 border-amber-500/20", }; case "preview_url": case "runtime_service": return { label: "Preview", icon: MonitorUp, - accentClass: "from-emerald-200/70 via-emerald-50/60 to-background", - badgeClass: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20", }; case "pull_request": return { label: "Pull request", icon: GitPullRequestArrow, - accentClass: "from-sky-200/70 via-sky-50/60 to-background", - badgeClass: "bg-sky-500/10 text-sky-700 border-sky-500/20", }; case "branch": return { label: "Branch", icon: GitBranch, - accentClass: "from-violet-200/70 via-violet-50/60 to-background", - badgeClass: "bg-violet-500/10 text-violet-700 border-violet-500/20", }; case "commit": return { label: "Commit", icon: GitCommitHorizontal, - accentClass: "from-fuchsia-200/70 via-fuchsia-50/60 to-background", - badgeClass: "bg-fuchsia-500/10 text-fuchsia-700 border-fuchsia-500/20", }; default: return { label: "File", icon: isImageItem(item) ? FileImage : Package, - accentClass: "from-zinc-200/70 via-zinc-50/60 to-background", - badgeClass: "bg-zinc-500/10 text-zinc-700 border-zinc-500/20", }; } } +function isSpotlightItem(item: CompanyDeliverableItem) { + return item.isPrimary || item.kind === "pull_request" || item.kind === "preview_url" || item.kind === "runtime_service"; +} + +function ImagePreview({ + item, + fallbackIcon: FallbackIcon, +}: { + item: CompanyDeliverableItem; + fallbackIcon: typeof FileImage; +}) { + const [failed, setFailed] = useState(false); + const imagePreview = !failed && isImageItem(item) && item.url ? item.url : null; + + if (!imagePreview) { + return ( +
+ +
+ ); + } + + return ( +
+ {item.title} setFailed(true)} + /> +
+ ); +} + function ArtifactCard({ item, projectName, @@ -135,42 +159,31 @@ function ArtifactCard({ const meta = deliverableMeta(item); const href = itemHref(item); const sizeLabel = formatByteSize(item.byteSize); - const imagePreview = isImageItem(item) && item.url ? item.url : null; const MetaIcon = meta.icon; const isInternalDocLink = Boolean(href?.startsWith("/issues/")); return ( -
- {imagePreview ? ( -
- {item.title} -
+
+ {isImageItem(item) ? ( + ) : ( -
-
-
- - {meta.label} +
+ + + {meta.label} + + {item.kind === "document" ? ( +

+ {item.summary ?? "This document does not have a preview snippet yet."} +

+ ) : ( +
+ +

+ {item.summary ?? item.contentType ?? labelize(item.kind) ?? "Work product"} +

- {item.kind === "document" ? ( -
-

- {item.summary ?? "This document does not have a preview snippet yet."} -

-
- ) : ( -
- -

- {item.summary ?? item.contentType ?? labelize(item.kind) ?? "Deliverable"} -

-
- )} -
+ )}
)} @@ -184,6 +197,7 @@ function ArtifactCard({ {labelize(item.reviewState)} ) : null} {item.isPrimary ? Primary : null} + {item.isOperatorContext ? Operator context : null} {item.documentKey ? {item.documentKey} : null} {item.revisionNumber ? v{item.revisionNumber} : null} {sizeLabel ? {sizeLabel} : null} @@ -201,7 +215,7 @@ function ArtifactCard({ ) : null}
-
+
@@ -271,17 +285,35 @@ function ArtifactCard({ export function Artifacts() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); - const [search, setSearch] = useState(""); - const [filter, setFilter] = useState("all"); + const [searchParams, setSearchParams] = useSearchParams(); + const search = searchParams.get("q") ?? ""; + const rawFilter = searchParams.get("filter") ?? "all"; + const filter: DeliverableFilter = ["all", "documents", "files", "previews", "pullRequests", "code"].includes(rawFilter) + ? (rawFilter as DeliverableFilter) + : "all"; + const selectedProjectId = searchParams.get("projectId") ?? "all"; + const showOperatorContext = searchParams.get("operatorContext") === "true"; const deferredSearch = useDeferredValue(search); useEffect(() => { - setBreadcrumbs([{ label: "Artifacts" }]); + setBreadcrumbs([{ label: "Work Product" }]); }, [setBreadcrumbs]); - const { data: deliverables, isLoading, error } = useQuery({ - queryKey: queryKeys.artifacts.list(selectedCompanyId!), - queryFn: () => issuesApi.listCompanyDeliverables(selectedCompanyId!), + function updateParam(key: string, value: string | null) { + setSearchParams((current) => { + const next = new URLSearchParams(current); + if (!value || value === "all" || value === "false") { + next.delete(key); + } else { + next.set(key, value); + } + return next; + }, { replace: true }); + } + + const { data: deliverables, isLoading, error, refetch } = useQuery({ + queryKey: queryKeys.artifacts.list(selectedCompanyId!, showOperatorContext), + queryFn: () => issuesApi.listCompanyDeliverables(selectedCompanyId!, { includeOperatorContext: showOperatorContext }), enabled: !!selectedCompanyId, }); const { data: projects } = useQuery({ @@ -302,6 +334,7 @@ export function Artifacts() { const query = deferredSearch.trim().toLowerCase(); return (deliverables?.items ?? []).filter((item) => { if (!filterMatches(item, filter)) return false; + if (selectedProjectId !== "all" && item.projectId !== selectedProjectId) return false; if (!query) return true; const haystack = [ item.title, @@ -315,11 +348,10 @@ export function Artifacts() { .toLowerCase(); return haystack.includes(query); }); - }, [deferredSearch, deliverables?.items, filter]); + }, [deferredSearch, deliverables?.items, filter, selectedProjectId]); const featuredItems = useMemo(() => { - const candidates = filteredItems.filter((item) => item.isPrimary); - return (candidates.length > 0 ? candidates : filteredItems).slice(0, 3); + return filteredItems.filter(isSpotlightItem).slice(0, 3); }, [filteredItems]); const featuredIds = useMemo(() => new Set(featuredItems.map((item) => item.id)), [featuredItems]); @@ -327,7 +359,7 @@ export function Artifacts() { () => filteredItems.filter((item) => !featuredIds.has(item.id)), [featuredIds, filteredItems], ); - const showSpotlight = filter === "all" && !deferredSearch.trim() && featuredItems.length > 0; + const showSpotlight = featuredItems.length > 0; const feedItems = showSpotlight && remainingItems.length > 0 ? remainingItems : filteredItems; const filterOptions = [ @@ -344,7 +376,7 @@ export function Artifacts() { ]; if (!selectedCompanyId) { - return ; + return ; } if (isLoading) { @@ -353,38 +385,38 @@ export function Artifacts() { return (
-
+
- Company deliverables + Company Work Product

Documents, files, previews, and other work product in one place.

Browse what agents have actually produced across the company. This view mixes issue documents, - uploaded files, previews, pull requests, branches, commits, and artifact records into one operator-facing gallery. + uploaded files, previews, pull requests, branches, and commits into one operator-facing gallery.

-
+

Items

{deliverables?.summary.totalCount ?? 0}

-

Mixed deliverables in the feed

+

Mixed output in the feed

-
+

Issues

{deliverables?.summary.issueCount ?? 0}

Tasks with visible output

-
+

Docs

{deliverables?.summary.documentCount ?? 0}

Plans, briefs, reports, notes

-
+

Primary

{deliverables?.summary.primaryCount ?? 0}

Explicitly promoted outputs

@@ -393,12 +425,19 @@ export function Artifacts() {
- {error ?

{error.message}

: null} + {error ? ( + void refetch()} + /> + ) : null} {!isLoading && (deliverables?.summary.totalCount ?? 0) === 0 ? ( ) : null} @@ -411,7 +450,7 @@ export function Artifacts() {
-
- - setSearch(event.target.value)} - placeholder="Search titles, issues, providers..." - className="pl-9" - /> +
+ + +
+ + updateParam("q", event.target.value)} + placeholder="Search titles, issues, providers..." + className="pl-9" + /> +
@@ -446,7 +506,7 @@ export function Artifacts() {

Recent output worth opening first

- Primary items and the freshest deliverables float to the top. + Primary items, previews, and PRs float to the top.

@@ -470,15 +530,15 @@ export function Artifacts() {

Feed

- {deferredSearch.trim() || filter !== "all" ? "Filtered deliverables" : "Latest deliverables"} + {deferredSearch.trim() || filter !== "all" || selectedProjectId !== "all" ? "Filtered Work Product" : "Latest Work Product"}

{filteredItems.length} visible item{filteredItems.length === 1 ? "" : "s"}

{filteredItems.length === 0 ? ( -
-

No deliverables match the current filter.

+
+

No Work Product matches the current filter.

Try another filter or clear the search to see more issue output.

diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index ba70bb9cdee..5a65eccf601 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -989,9 +989,10 @@ export function IssueDetail() { enabled: !!issueId, placeholderData: keepPreviousDataForSameQueryTail(issueId ?? "pending"), }); + const [showOperatorContextWorkProduct, setShowOperatorContextWorkProduct] = useState(false); const { data: deliverables, isLoading: deliverablesLoading } = useQuery({ - queryKey: queryKeys.issues.deliverables(issueId!), - queryFn: () => issuesApi.getDeliverables(issueId!), + queryKey: queryKeys.issues.deliverables(issueId!, showOperatorContextWorkProduct), + queryFn: () => issuesApi.getDeliverables(issueId!, { includeOperatorContext: showOperatorContextWorkProduct }), enabled: !!issueId, placeholderData: keepPreviousDataForSameQueryTail>>( issueId ?? "pending", @@ -2418,7 +2419,18 @@ export function IssueDetail() { className="text-xl font-bold" /> - + { + document.getElementById("issue-documents")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }} + onOpenWorkProduct={(sectionId) => { + setDetailTab("work-product"); + window.setTimeout(() => { + document.getElementById(sectionId)?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, 0); + }} + />