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 ? (
+
+

+
+ ) : (
+
+
+ {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}
+
+
+
+
+
+ );
+}
+
+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}
+
+ );
+}