diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 06fa797afd8..90d74ae8449 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -270,6 +270,9 @@ export type { IssueDeliverableSourceType, IssueDeliverableKind, IssueDeliverableItem, + CompanyDeliverableItem, + CompanyDeliverablesSummary, + CompanyDeliverablesResponse, IssueDeliverablesRuntimeServiceSummary, IssueDeliverablesWorkspaceSummary, IssueDeliverablesSummary, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 5000b6f83da..d89c36509fc 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -103,6 +103,9 @@ export type { IssueDeliverableSourceType, IssueDeliverableKind, IssueDeliverableItem, + CompanyDeliverableItem, + CompanyDeliverablesSummary, + CompanyDeliverablesResponse, IssueDeliverablesRuntimeServiceSummary, IssueDeliverablesWorkspaceSummary, IssueDeliverablesSummary, diff --git a/packages/shared/src/types/issue-deliverables.ts b/packages/shared/src/types/issue-deliverables.ts index d25ebdb601d..b8827f7b865 100644 --- a/packages/shared/src/types/issue-deliverables.ts +++ b/packages/shared/src/types/issue-deliverables.ts @@ -80,3 +80,28 @@ export interface IssueDeliverablesResponse { documents: IssueDeliverableItem[]; files: IssueDeliverableItem[]; } + +export interface CompanyDeliverableItem extends IssueDeliverableItem { + issueId: string; + issueIdentifier: string | null; + issueTitle: string; + issueStatus: string; + projectId: string | null; +} + +export interface CompanyDeliverablesSummary { + totalCount: number; + issueCount: number; + primaryCount: number; + previewCount: number; + pullRequestCount: number; + branchCount: number; + commitCount: number; + documentCount: number; + fileCount: number; +} + +export interface CompanyDeliverablesResponse { + items: CompanyDeliverableItem[]; + summary: CompanyDeliverablesSummary; +} diff --git a/server/src/__tests__/issue-deliverables.test.ts b/server/src/__tests__/issue-deliverables.test.ts index 23ae11fd98f..cbe1e754073 100644 --- a/server/src/__tests__/issue-deliverables.test.ts +++ b/server/src/__tests__/issue-deliverables.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import type { ExecutionWorkspace, IssueDocument, IssueWorkProduct } from "@paperclipai/shared"; -import { buildIssueDeliverables } from "../services/issue-deliverables.ts"; +import type { + CompanyDeliverableItem, + ExecutionWorkspace, + IssueDocument, + IssueWorkProduct, +} from "@paperclipai/shared"; +import { buildCompanyDeliverables, buildIssueDeliverables } from "../services/issue-deliverables.ts"; function createIssueDocument(overrides: Partial = {}): IssueDocument { return { @@ -208,3 +213,88 @@ describe("buildIssueDeliverables", () => { expect(response.workspace?.runtimeServiceHealth).toBe("healthy"); }); }); + +describe("buildCompanyDeliverables", () => { + it("returns documents, attachments, and work products in one company feed", () => { + const response = buildCompanyDeliverables({ + issues: [ + { + id: "issue-1", + identifier: "PAP-10", + title: "Write launch plan", + status: "in_progress", + projectId: "project-1", + description: "Legacy plan body", + createdAt: new Date("2026-04-01T09:00:00.000Z"), + updatedAt: new Date("2026-04-01T15:00:00.000Z"), + }, + { + id: "issue-2", + identifier: "PAP-11", + title: "Ship preview", + status: "done", + projectId: "project-1", + description: null, + createdAt: new Date("2026-04-01T10:00:00.000Z"), + updatedAt: new Date("2026-04-01T16:00:00.000Z"), + }, + ], + workProducts: [ + createWorkProduct({ + id: "preview-1", + issueId: "issue-2", + type: "preview_url", + title: "Preview deploy", + provider: "vercel", + url: "https://preview.example.com", + status: "active", + reviewState: "none", + healthStatus: "healthy", + updatedAt: new Date("2026-04-01T17:00:00.000Z"), + }), + ], + documents: [ + createIssueDocument({ + id: "doc-1", + issueId: "issue-1", + key: "brief", + title: "Launch brief", + body: "Narrative and milestones.", + updatedAt: new Date("2026-04-01T14:00:00.000Z"), + }), + ], + attachments: [ + { + id: "attachment-1", + companyId: "company-1", + issueId: "issue-2", + issueCommentId: null, + assetId: "asset-1", + provider: "paperclip", + objectKey: "artifacts/mockup.png", + contentType: "image/png", + byteSize: 1024, + sha256: "abc", + originalFilename: "mockup.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(response.summary.totalCount).toBe(4); + expect(response.summary.documentCount).toBe(2); + expect(response.summary.fileCount).toBe(1); + expect(response.summary.previewCount).toBe(1); + expect(response.summary.issueCount).toBe(2); + + const kinds = response.items.map((item) => item.kind); + expect(kinds).toEqual(["preview_url", "attachment", "document", "document"]); + + 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"); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 44b606a676f..7638b500dce 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -895,6 +895,13 @@ export function issueRoutes( res.json(workProducts); }); + router.get("/companies/:companyId/deliverables", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const deliverables = await issueDeliverablesSvc.listForCompany(companyId); + res.json(deliverables); + }); + router.get("/issues/:id/documents", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); diff --git a/server/src/services/documents.ts b/server/src/services/documents.ts index 5c5ee74761a..ef271ae7258 100644 --- a/server/src/services/documents.ts +++ b/server/src/services/documents.ts @@ -132,6 +132,16 @@ export function documentService(db: Db) { return filterSystemDocuments(rows, options.includeSystem ?? false).map((row) => mapIssueDocumentRow(row, true)); }, + listCompanyIssueDocuments: async (companyId: string) => { + const rows = await db + .select(issueDocumentSelect) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(eq(issueDocuments.companyId, companyId)) + .orderBy(desc(documents.updatedAt), asc(issueDocuments.key)); + return rows.map((row) => mapIssueDocumentRow(row, true)); + }, + getIssueDocumentByKey: async (issueId: string, rawKey: string) => { const key = normalizeDocumentKey(rawKey); const row = await db diff --git a/server/src/services/issue-deliverables.ts b/server/src/services/issue-deliverables.ts index 5e026146174..bfa23278c19 100644 --- a/server/src/services/issue-deliverables.ts +++ b/server/src/services/issue-deliverables.ts @@ -1,4 +1,6 @@ import type { + CompanyDeliverableItem, + CompanyDeliverablesResponse, ExecutionWorkspace, IssueDeliverableItem, IssueDeliverablesResponse, @@ -14,6 +16,10 @@ import { issueService } from "./issues.js"; import { workProductService } from "./work-products.js"; type AttachmentInput = Awaited["listAttachments"]>>[number]; +type CompanyIssueInput = Pick< + Awaited["list"]>>[number], + "id" | "identifier" | "title" | "status" | "projectId" | "description" | "createdAt" | "updatedAt" +>; type IssueInput = { id: string; @@ -23,6 +29,7 @@ type IssueInput = { createdAt: Date; updatedAt: Date; }; +type LegacyPlanIssueInput = Pick; type BuildIssueDeliverablesInput = { issue: IssueInput; @@ -33,6 +40,13 @@ type BuildIssueDeliverablesInput = { attachments: AttachmentInput[]; }; +type BuildCompanyDeliverablesInput = { + issues: CompanyIssueInput[]; + workProducts: IssueWorkProduct[]; + documents: IssueDocument[]; + attachments: AttachmentInput[]; +}; + function compareItemsByRecency(a: IssueDeliverableItem, b: IssueDeliverableItem) { if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); @@ -111,7 +125,7 @@ function toDocumentItem(document: IssueDocument): IssueDeliverableItem { }; } -function toLegacyPlanItem(issue: IssueInput, legacyPlanDocument: LegacyPlanDocument): IssueDeliverableItem { +function toLegacyPlanItem(issue: LegacyPlanIssueInput, legacyPlanDocument: LegacyPlanDocument): IssueDeliverableItem { return { id: `legacy-plan:${issue.id}`, sourceType: "legacy_plan_document", @@ -157,6 +171,20 @@ function toAttachmentItem(attachment: AttachmentInput): IssueDeliverableItem { }; } +function toCompanyDeliverableItem( + item: IssueDeliverableItem, + issue: CompanyIssueInput, +): CompanyDeliverableItem { + return { + ...item, + issueId: issue.id, + issueIdentifier: issue.identifier ?? null, + issueTitle: issue.title, + issueStatus: issue.status, + projectId: issue.projectId, + }; +} + function summarizeRuntimeServiceHealth( workspace: ExecutionWorkspace | null, ): IssueDeliverablesWorkspaceSummary["runtimeServiceHealth"] { @@ -299,6 +327,90 @@ export function buildIssueDeliverables(input: BuildIssueDeliverablesInput): Issu }; } +export function buildCompanyDeliverables(input: BuildCompanyDeliverablesInput): CompanyDeliverablesResponse { + const issueMap = new Map(); + for (const issue of input.issues) { + issueMap.set(issue.id, issue); + } + + const explicitPlanIssueIds = new Set( + input.documents + .filter((document) => document.key === "plan") + .map((document) => document.issueId), + ); + + const items: CompanyDeliverableItem[] = []; + for (const product of input.workProducts) { + const issue = issueMap.get(product.issueId); + if (!issue) continue; + items.push(toCompanyDeliverableItem(toWorkProductItem(product), issue)); + } + + for (const document of input.documents) { + const issue = issueMap.get(document.issueId); + if (!issue) continue; + items.push(toCompanyDeliverableItem(toDocumentItem(document), issue)); + } + + for (const attachment of input.attachments) { + const issue = issueMap.get(attachment.issueId); + if (!issue) continue; + items.push(toCompanyDeliverableItem(toAttachmentItem(attachment), issue)); + } + + for (const issue of input.issues) { + if (explicitPlanIssueIds.has(issue.id)) continue; + const legacyPlanBody = extractLegacyPlanBody(issue.description); + if (!legacyPlanBody) continue; + items.push( + toCompanyDeliverableItem( + toLegacyPlanItem(issue, { + key: "plan", + body: legacyPlanBody, + source: "issue_description", + }), + issue, + ), + ); + } + + const sortedItems = [...items].sort(compareItemsByRecency); + const issueIds = new Set(); + let primaryCount = 0; + let previewCount = 0; + let pullRequestCount = 0; + let branchCount = 0; + let commitCount = 0; + let documentCount = 0; + let fileCount = 0; + + for (const item of sortedItems) { + issueIds.add(item.issueId); + if (item.isPrimary) primaryCount += 1; + if (item.kind === "preview_url" || item.kind === "runtime_service") previewCount += 1; + else if (item.kind === "pull_request") pullRequestCount += 1; + else if (item.kind === "branch") branchCount += 1; + else if (item.kind === "commit") commitCount += 1; + else if (item.kind === "document") documentCount += 1; + else fileCount += 1; + } + + return { + items: sortedItems, + summary: { + totalCount: sortedItems.length, + issueCount: issueIds.size, + primaryCount, + previewCount, + pullRequestCount, + branchCount, + commitCount, + documentCount, + fileCount, + }, + }; +} + export function issueDeliverableService(db: Db) { const issuesSvc = issueService(db); const workProductsSvc = workProductService(db); @@ -306,6 +418,31 @@ export function issueDeliverableService(db: Db) { const executionWorkspacesSvc = executionWorkspaceService(db); return { + listForCompany: async (companyId: string) => { + const [issues, workProducts, documents, attachments] = await Promise.all([ + issuesSvc.list(companyId), + workProductsSvc.listForCompany(companyId), + documentsSvc.listCompanyIssueDocuments(companyId).then((rows) => rows as IssueDocument[]), + issuesSvc.listAttachmentsForCompany(companyId), + ]); + + return buildCompanyDeliverables({ + issues: issues.map((issue) => ({ + id: issue.id, + identifier: issue.identifier ?? null, + title: issue.title, + status: issue.status, + projectId: issue.projectId, + description: issue.description, + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + })), + workProducts, + documents, + attachments, + }); + }, + getForIssue: async (issue: IssueInput) => { const legacyPlanBody = extractLegacyPlanBody(issue.description); const [workspace, workProducts, documents, attachments] = await Promise.all([ diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 11344d7b5e8..1096ba1fc09 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -2506,6 +2506,30 @@ export function issueService(db: Db) { .where(eq(issueAttachments.issueId, issueId)) .orderBy(desc(issueAttachments.createdAt)), + listAttachmentsForCompany: async (companyId: string) => + db + .select({ + id: issueAttachments.id, + companyId: issueAttachments.companyId, + issueId: issueAttachments.issueId, + issueCommentId: issueAttachments.issueCommentId, + assetId: issueAttachments.assetId, + provider: assets.provider, + objectKey: assets.objectKey, + contentType: assets.contentType, + byteSize: assets.byteSize, + sha256: assets.sha256, + originalFilename: assets.originalFilename, + createdByAgentId: assets.createdByAgentId, + createdByUserId: assets.createdByUserId, + createdAt: issueAttachments.createdAt, + updatedAt: issueAttachments.updatedAt, + }) + .from(issueAttachments) + .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) + .where(eq(issueAttachments.companyId, companyId)) + .orderBy(desc(issueAttachments.updatedAt)), + getAttachmentById: async (id: string) => db .select({ diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index 570d8b7c80b..12aed0025ac 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -37,4 +37,10 @@ describe("issuesApi.list", () => { "/companies/company-1/work-products?type=artifact&limit=24", ); }); + + it("calls the company deliverables endpoint", async () => { + await issuesApi.listCompanyDeliverables("company-1"); + + expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/deliverables"); + }); }); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 1ec345991cd..0b2b8303e07 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -1,5 +1,6 @@ import type { Approval, + CompanyDeliverablesResponse, DocumentRevision, FeedbackTargetType, FeedbackTrace, @@ -165,6 +166,8 @@ export const issuesApi = { 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`), listCompanyWorkProducts: ( companyId: string, filters?: { diff --git a/ui/src/pages/Artifacts.tsx b/ui/src/pages/Artifacts.tsx index 54322a226ae..0a737c337f4 100644 --- a/ui/src/pages/Artifacts.tsx +++ b/ui/src/pages/Artifacts.tsx @@ -1,7 +1,7 @@ -import { useEffect, useMemo } from "react"; +import { useDeferredValue, useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Link } from "@/lib/router"; -import type { IssueWorkProduct } from "@paperclipai/shared"; +import type { CompanyDeliverableItem } from "@paperclipai/shared"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; @@ -10,120 +10,221 @@ import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { queryKeys } from "../lib/queryKeys"; import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; import { projectUrl, relativeTime } from "../lib/utils"; -import { ArrowUpRight, CircleDot, Download, FileImage, Hexagon, Package } from "lucide-react"; +import { + ArrowUpRight, + ExternalLink, + FileImage, + FileText, + GitBranch, + GitCommitHorizontal, + GitPullRequestArrow, + Hexagon, + MonitorUp, + Package, + Search, +} from "lucide-react"; + +type DeliverableFilter = "all" | "documents" | "files" | "previews" | "pullRequests" | "code"; function labelize(value: string | null | undefined) { if (!value) return null; return value.replace(/_/g, " "); } -function readMetadataString(product: IssueWorkProduct, key: string) { - const value = product.metadata?.[key]; +function readMetadataString(item: CompanyDeliverableItem, key: string) { + const value = item.metadata?.[key]; return typeof value === "string" ? value : null; } -function readMetadataNumber(product: IssueWorkProduct, key: string) { - const value = product.metadata?.[key]; - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - function formatByteSize(byteSize: number | null) { if (!byteSize || byteSize <= 0) return null; if (byteSize < 1024 * 1024) return `${(byteSize / 1024).toFixed(1)} KB`; return `${(byteSize / (1024 * 1024)).toFixed(1)} MB`; } -function isImageArtifact(product: IssueWorkProduct) { - const contentType = readMetadataString(product, "contentType"); +function isImageItem(item: CompanyDeliverableItem) { + const contentType = item.contentType ?? readMetadataString(item, "contentType"); return Boolean(contentType?.startsWith("image/")); } +function itemHref(item: CompanyDeliverableItem) { + if (item.kind === "document" && item.documentKey) { + return `${createIssueDetailPath(item.issueIdentifier ?? item.issueId)}#document-${encodeURIComponent(item.documentKey)}`; + } + return item.url; +} + +function actionLabel(item: CompanyDeliverableItem) { + if (item.kind === "document") return "Open doc"; + if (item.kind === "preview_url" || item.kind === "runtime_service") return "Open preview"; + if (item.kind === "pull_request") return "Open PR"; + if (item.kind === "branch" || item.kind === "commit") return "Open reference"; + return isImageItem(item) ? "Open file" : "Download"; +} + +function filterMatches(item: CompanyDeliverableItem, filter: DeliverableFilter) { + if (filter === "all") return true; + if (filter === "documents") return item.kind === "document"; + if (filter === "files") return item.kind === "artifact" || item.kind === "attachment"; + if (filter === "previews") return item.kind === "preview_url" || item.kind === "runtime_service"; + if (filter === "pullRequests") return item.kind === "pull_request"; + return item.kind === "branch" || item.kind === "commit"; +} + +function deliverableMeta(item: CompanyDeliverableItem) { + switch (item.kind) { + case "document": + 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 ArtifactCard({ - artifact, - issueTitle, - issueRef, - projectTitle, + item, + projectName, projectHref, }: { - artifact: IssueWorkProduct; - issueTitle: string; - issueRef: string; - projectTitle: string | null; + item: CompanyDeliverableItem; + projectName: string | null; projectHref: string | null; }) { - const contentType = readMetadataString(artifact, "contentType"); - const sizeLabel = formatByteSize(readMetadataNumber(artifact, "byteSize")); - const href = artifact.url; - const imagePreview = isImageArtifact(artifact) && href ? href : null; + 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 ? ( -
+
{artifact.title}
) : ( -
-
- {contentType?.startsWith("image/") ? ( - +
+
+
+ + {meta.label} +
+ {item.kind === "document" ? ( +
+

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

+
) : ( - +
+ +

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

+
)} -

- {contentType ?? "Artifact"} -

)} -
+
- Artifact - {artifact.provider} - {artifact.status ? {labelize(artifact.status)} : null} - {artifact.isPrimary ? Primary : null} + {meta.label} + {item.provider ? {item.provider} : null} + {item.status ? {labelize(item.status)} : null} + {item.reviewState && item.reviewState !== "none" ? ( + {labelize(item.reviewState)} + ) : null} + {item.isPrimary ? Primary : null} + {item.documentKey ? {item.documentKey} : null} + {item.revisionNumber ? v{item.revisionNumber} : null} {sizeLabel ? {sizeLabel} : null}
-

{artifact.title}

+

{item.title}

- Updated {relativeTime(artifact.updatedAt)} + Updated {relativeTime(item.updatedAt)}

- {artifact.summary ? ( -

{artifact.summary}

+ {item.summary && item.kind !== "document" ? ( +

{item.summary}

) : null}
-
-
- +
+
+
-

Issue

- - {issueTitle} +

Issue

+ + {item.issueTitle} + {item.issueIdentifier ? ( +

{item.issueIdentifier}

+ ) : null}
- {projectHref && projectTitle ? ( -
+ {projectHref && projectName ? ( +
-

Project

- - {projectTitle} +

Project

+ + {projectName}
@@ -132,15 +233,34 @@ function ArtifactCard({
{href ? ( - + isInternalDocLink ? ( + + ) : href.startsWith("/") ? ( + + ) : ( + + ) ) : null}
@@ -151,19 +271,17 @@ function ArtifactCard({ export function Artifacts() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + const deferredSearch = useDeferredValue(search); useEffect(() => { setBreadcrumbs([{ label: "Artifacts" }]); }, [setBreadcrumbs]); - const { data: artifacts, isLoading, error } = useQuery({ + const { data: deliverables, isLoading, error } = useQuery({ queryKey: queryKeys.artifacts.list(selectedCompanyId!), - queryFn: () => issuesApi.listCompanyWorkProducts(selectedCompanyId!, { type: "artifact" }), - enabled: !!selectedCompanyId, - }); - const { data: issues } = useQuery({ - queryKey: queryKeys.issues.list(selectedCompanyId!), - queryFn: () => issuesApi.list(selectedCompanyId!), + queryFn: () => issuesApi.listCompanyDeliverables(selectedCompanyId!), enabled: !!selectedCompanyId, }); const { data: projects } = useQuery({ @@ -172,28 +290,59 @@ export function Artifacts() { enabled: !!selectedCompanyId, }); - const issueMap = useMemo(() => { - const map = new Map(); - for (const issue of issues ?? []) { - map.set(issue.id, { - title: issue.title, - ref: issue.identifier ?? issue.id, - }); - } - return map; - }, [issues]); - const projectMap = useMemo(() => { const map = new Map(); for (const project of projects ?? []) { - map.set(project.id, { - title: project.name, - href: projectUrl(project), - }); + map.set(project.id, { title: project.name, href: projectUrl(project) }); } return map; }, [projects]); + const filteredItems = useMemo(() => { + const query = deferredSearch.trim().toLowerCase(); + return (deliverables?.items ?? []).filter((item) => { + if (!filterMatches(item, filter)) return false; + if (!query) return true; + const haystack = [ + item.title, + item.summary ?? "", + item.issueTitle, + item.issueIdentifier ?? "", + item.documentKey ?? "", + item.provider ?? "", + ] + .join("\n") + .toLowerCase(); + return haystack.includes(query); + }); + }, [deferredSearch, deliverables?.items, filter]); + + const featuredItems = useMemo(() => { + const candidates = filteredItems.filter((item) => item.isPrimary); + return (candidates.length > 0 ? candidates : filteredItems).slice(0, 3); + }, [filteredItems]); + + const featuredIds = useMemo(() => new Set(featuredItems.map((item) => item.id)), [featuredItems]); + const remainingItems = useMemo( + () => filteredItems.filter((item) => !featuredIds.has(item.id)), + [featuredIds, filteredItems], + ); + const showSpotlight = filter === "all" && !deferredSearch.trim() && featuredItems.length > 0; + const feedItems = showSpotlight && remainingItems.length > 0 ? remainingItems : filteredItems; + + const filterOptions = [ + { id: "all" as const, label: "All", count: deliverables?.summary.totalCount ?? 0 }, + { id: "documents" as const, label: "Docs", count: deliverables?.summary.documentCount ?? 0 }, + { id: "files" as const, label: "Files", count: deliverables?.summary.fileCount ?? 0 }, + { id: "previews" as const, label: "Previews", count: deliverables?.summary.previewCount ?? 0 }, + { id: "pullRequests" as const, label: "PRs", count: deliverables?.summary.pullRequestCount ?? 0 }, + { + id: "code" as const, + label: "Branches + commits", + count: (deliverables?.summary.branchCount ?? 0) + (deliverables?.summary.commitCount ?? 0), + }, + ]; + if (!selectedCompanyId) { return ; } @@ -203,44 +352,154 @@ export function Artifacts() { } return ( -
-
-
-

Artifacts

-

- Generated issue outputs across the company, sorted by most recent update. -

+
+
+
+
+ Company deliverables +
+

+ 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. +

+
+
+ +
+
+

Items

+

{deliverables?.summary.totalCount ?? 0}

+

Mixed deliverables 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

+
+
- {artifacts && artifacts.length > 0 ? ( - - {artifacts.length} artifact{artifacts.length === 1 ? "" : "s"} - - ) : null} -
+ {error ?

{error.message}

: null} - {!isLoading && (artifacts?.length ?? 0) === 0 ? ( - + {!isLoading && (deliverables?.summary.totalCount ?? 0) === 0 ? ( + ) : null} - {artifacts && artifacts.length > 0 ? ( -
- {artifacts.map((artifact) => { - const issue = issueMap.get(artifact.issueId); - const project = artifact.projectId ? projectMap.get(artifact.projectId) ?? null : null; - return ( - - ); - })} -
+ {deliverables && deliverables.summary.totalCount > 0 ? ( + <> +
+
+
+ {filterOptions.map((option) => ( + + ))} +
+ +
+ + setSearch(event.target.value)} + placeholder="Search titles, issues, providers..." + className="pl-9" + /> +
+
+
+ + {showSpotlight ? ( +
+
+
+

Spotlight

+

Recent output worth opening first

+
+

+ Primary items and the freshest deliverables float to the top. +

+
+
+ {featuredItems.map((item) => { + const project = item.projectId ? projectMap.get(item.projectId) ?? null : null; + return ( + + ); + })} +
+
+ ) : null} + +
+
+
+

Feed

+

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

+
+

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

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

No deliverables match the current filter.

+

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

+
+ ) : ( +
+ {feedItems.map((item) => { + const project = item.projectId ? projectMap.get(item.projectId) ?? null : null; + return ( + + ); + })} +
+ )} +
+ ) : null}
);