diff --git a/server/src/__tests__/work-products.test.ts b/server/src/__tests__/work-products.test.ts index 93e4a0fc89e..b1fa149ea21 100644 --- a/server/src/__tests__/work-products.test.ts +++ b/server/src/__tests__/work-products.test.ts @@ -92,4 +92,25 @@ describe("workProductService", () => { expect(txUpdate).toHaveBeenCalledTimes(2); expect(result?.reviewState).toBe("ready_for_review"); }); + + it("lists company work products filtered by type and limit", async () => { + const rows = [ + createWorkProductRow({ id: "artifact-2", type: "artifact", updatedAt: new Date("2026-03-18T00:00:00.000Z") }), + createWorkProductRow({ id: "artifact-1", type: "artifact", updatedAt: new Date("2026-03-17T00:00:00.000Z") }), + ]; + const limit = vi.fn(async () => rows); + const orderBy = vi.fn(() => ({ limit })); + const where = vi.fn(() => ({ orderBy })); + const from = vi.fn(() => ({ where })); + const select = vi.fn(() => ({ from })); + + const svc = workProductService({ select } as any); + const result = await svc.listForCompany("company-1", { type: "artifact", limit: 2 }); + + expect(select).toHaveBeenCalledTimes(1); + expect(where).toHaveBeenCalledTimes(1); + expect(orderBy).toHaveBeenCalledTimes(1); + expect(limit).toHaveBeenCalledWith(2); + expect(result.map((item) => item.id)).toEqual(["artifact-2", "artifact-1"]); + }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 5f9321c6445..44b606a676f 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -16,6 +16,7 @@ import { feedbackTraceStatusSchema, feedbackVoteValueSchema, upsertIssueFeedbackVoteSchema, + issueWorkProductTypeSchema, linkIssueApprovalSchema, issueDocumentKeySchema, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, @@ -868,6 +869,32 @@ export function issueRoutes( res.json(workProducts); }); + router.get("/companies/:companyId/work-products", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + + const typeRaw = typeof req.query.type === "string" ? req.query.type.trim() : ""; + const type = typeRaw + ? issueWorkProductTypeSchema.safeParse(typeRaw) + : { success: true as const, data: undefined }; + if (!type.success) { + res.status(400).json({ error: "Invalid work product type", details: type.error.issues }); + return; + } + + const limitRaw = typeof req.query.limit === "string" ? Number.parseInt(req.query.limit, 10) : null; + if (limitRaw !== null && (!Number.isFinite(limitRaw) || limitRaw <= 0)) { + res.status(400).json({ error: "Invalid limit" }); + return; + } + + const workProducts = await workProductsSvc.listForCompany(companyId, { + type: type.data, + limit: limitRaw ?? undefined, + }); + res.json(workProducts); + }); + 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/work-products.ts b/server/src/services/work-products.ts index 6c5365fd9d6..e1652397c0a 100644 --- a/server/src/services/work-products.ts +++ b/server/src/services/work-products.ts @@ -32,6 +32,32 @@ function toIssueWorkProduct(row: IssueWorkProductRow): IssueWorkProduct { export function workProductService(db: Db) { return { + listForCompany: async ( + companyId: string, + filters?: { + type?: IssueWorkProduct["type"]; + limit?: number; + }, + ) => { + const query = db + .select() + .from(issueWorkProducts) + .where( + filters?.type + ? and( + eq(issueWorkProducts.companyId, companyId), + eq(issueWorkProducts.type, filters.type), + ) + : eq(issueWorkProducts.companyId, companyId), + ) + .orderBy(desc(issueWorkProducts.updatedAt)); + + const rows = filters?.limit && filters.limit > 0 + ? await query.limit(filters.limit) + : await query; + return rows.map(toIssueWorkProduct); + }, + listForIssue: async (issueId: string) => { const rows = await db .select() diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1a82d1a613e..3e25479471b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -18,6 +18,7 @@ import { RoutineDetail } from "./pages/RoutineDetail"; import { UserProfile } from "./pages/UserProfile"; import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail"; import { Goals } from "./pages/Goals"; +import { Artifacts } from "./pages/Artifacts"; import { GoalDetail } from "./pages/GoalDetail"; import { Approvals } from "./pages/Approvals"; import { ApprovalDetail } from "./pages/ApprovalDetail"; @@ -106,6 +107,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index fd409f3eb04..570d8b7c80b 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -29,4 +29,12 @@ describe("issuesApi.list", () => { expect(mockApi.get).toHaveBeenCalledWith("/issues/issue-1/deliverables"); }); + + it("calls the company work products endpoint with filters", async () => { + await issuesApi.listCompanyWorkProducts("company-1", { type: "artifact", limit: 24 }); + + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/work-products?type=artifact&limit=24", + ); + }); }); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 84bdafe4af5..1ec345991cd 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -165,6 +165,19 @@ export const issuesApi = { unlinkApproval: (id: string, approvalId: string) => api.delete<{ ok: true }>(`/issues/${id}/approvals/${approvalId}`), getDeliverables: (id: string) => api.get(`/issues/${id}/deliverables`), + listCompanyWorkProducts: ( + companyId: string, + filters?: { + type?: string; + limit?: number; + }, + ) => { + const params = new URLSearchParams(); + if (filters?.type) params.set("type", filters.type); + if (filters?.limit) params.set("limit", String(filters.limit)); + const qs = params.toString(); + return api.get(`/companies/${companyId}/work-products${qs ? `?${qs}` : ""}`); + }, listWorkProducts: (id: string) => api.get(`/issues/${id}/work-products`), createWorkProduct: (id: string, data: Record) => api.post(`/issues/${id}/work-products`, data), diff --git a/ui/src/components/PageSkeleton.tsx b/ui/src/components/PageSkeleton.tsx index cf2e2abd41e..3de6dfe14a6 100644 --- a/ui/src/components/PageSkeleton.tsx +++ b/ui/src/components/PageSkeleton.tsx @@ -8,6 +8,7 @@ interface PageSkeletonProps { | "dashboard" | "approvals" | "costs" + | "grid" | "inbox" | "org-chart"; } @@ -72,6 +73,26 @@ export function PageSkeleton({ variant = "list" }: PageSkeletonProps) { ); } + if (variant === "grid") { + return ( +
+
+
+ + +
+ +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ); + } + if (variant === "inbox") { return (
diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 28e8957d3f4..1c8197498ac 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -1,4 +1,5 @@ import { + Package, Inbox, CircleDot, Target, @@ -104,6 +105,7 @@ export function Sidebar() { {showWorkspacesLink ? ( ) : null} + diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index fce846c72c4..02e304334fa 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -81,6 +81,9 @@ export const queryKeys = { list: (companyId: string) => ["goals", companyId] as const, detail: (id: string) => ["goals", "detail", id] as const, }, + artifacts: { + list: (companyId: string) => ["artifacts", companyId] as const, + }, budgets: { overview: (companyId: string) => ["budgets", "overview", companyId] as const, }, diff --git a/ui/src/pages/Artifacts.tsx b/ui/src/pages/Artifacts.tsx new file mode 100644 index 00000000000..54322a226ae --- /dev/null +++ b/ui/src/pages/Artifacts.tsx @@ -0,0 +1,247 @@ +import { useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@/lib/router"; +import type { IssueWorkProduct } from "@paperclipai/shared"; +import { issuesApi } from "../api/issues"; +import { projectsApi } from "../api/projects"; +import { useCompany } from "../context/CompanyContext"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { EmptyState } from "../components/EmptyState"; +import { PageSkeleton } from "../components/PageSkeleton"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +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"; + +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]; + 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"); + return Boolean(contentType?.startsWith("image/")); +} + +function ArtifactCard({ + artifact, + issueTitle, + issueRef, + projectTitle, + projectHref, +}: { + artifact: IssueWorkProduct; + issueTitle: string; + issueRef: string; + projectTitle: 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; + + return ( +
+ {imagePreview ? ( +
+ {artifact.title} +
+ ) : ( +
+
+ {contentType?.startsWith("image/") ? ( + + ) : ( + + )} +

+ {contentType ?? "Artifact"} +

+
+
+ )} + +
+
+
+ Artifact + {artifact.provider} + {artifact.status ? {labelize(artifact.status)} : null} + {artifact.isPrimary ? Primary : null} + {sizeLabel ? {sizeLabel} : null} +
+ +
+

{artifact.title}

+

+ Updated {relativeTime(artifact.updatedAt)} +

+
+ + {artifact.summary ? ( +

{artifact.summary}

+ ) : null} +
+ +
+
+ +
+

Issue

+ + {issueTitle} + +
+
+ + {projectHref && projectTitle ? ( +
+ +
+

Project

+ + {projectTitle} + +
+
+ ) : null} +
+ +
+ {href ? ( + + ) : null} + +
+
+
+ ); +} + +export function Artifacts() { + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + + useEffect(() => { + setBreadcrumbs([{ label: "Artifacts" }]); + }, [setBreadcrumbs]); + + const { data: artifacts, 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!), + enabled: !!selectedCompanyId, + }); + const { data: projects } = useQuery({ + queryKey: queryKeys.projects.list(selectedCompanyId!), + queryFn: () => projectsApi.list(selectedCompanyId!), + 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), + }); + } + return map; + }, [projects]); + + if (!selectedCompanyId) { + return ; + } + + if (isLoading) { + return ; + } + + return ( +
+
+
+

Artifacts

+

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

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

{error.message}

: null} + + {!isLoading && (artifacts?.length ?? 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 ( + + ); + })} +
+ ) : null} +
+ ); +}