Add company artifacts gallery

This commit is contained in:
Dotta committed 2026-04-20 08:19:17 -05:00
1 parent 96b98c58e8
commit 4e9f878805
10 files changed
+370

No files matched your search

@@ -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"]);
});
});
+27
View File
@@ -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);
+26
View File
@@ -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()
+2
View File
@@ -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() {
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
<Route path="goals" element={<Goals />} />
<Route path="artifacts" element={<Artifacts />} />
<Route path="goals/:goalId" element={<GoalDetail />} />
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
<Route path="approvals/pending" element={<Approvals />} />
+8
View File
@@ -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",
);
});
});
+13
View File
@@ -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<IssueDeliverablesResponse>(`/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<IssueWorkProduct[]>(`/companies/${companyId}/work-products${qs ? `?${qs}` : ""}`);
},
listWorkProducts: (id: string) => api.get<IssueWorkProduct[]>(`/issues/${id}/work-products`),
createWorkProduct: (id: string, data: Record<string, unknown>) =>
api.post<IssueWorkProduct>(`/issues/${id}/work-products`, data),
+21
View File
@@ -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 (
<div className="space-y-5">
<div className="flex items-center justify-between gap-3">
<div className="space-y-2">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-7 w-24" />
</div>
<div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-[28rem] w-full rounded-2xl" />
))}
</div>
</div>
);
}
if (variant === "inbox") {
return (
<div className="space-y-6">
+2
View File
@@ -1,4 +1,5 @@
import {
Package,
Inbox,
CircleDot,
Target,
@@ -104,6 +105,7 @@ export function Sidebar() {
{showWorkspacesLink ? (
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
) : null}
<SidebarNavItem to="/artifacts" label="Artifacts" icon={Package} />
</SidebarSection>
<SidebarProjects />
+3
View File
@@ -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,
},
+247
View File
@@ -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 (
<article className="group overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md">
{imagePreview ? (
<div className="aspect-[16/10] overflow-hidden border-b border-border bg-muted/40">
<img
src={imagePreview}
alt={artifact.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
/>
</div>
) : (
<div className="flex aspect-[16/10] items-center justify-center border-b border-border bg-gradient-to-br from-muted/70 via-background to-muted/40">
<div className="flex flex-col items-center gap-3 text-center">
{contentType?.startsWith("image/") ? (
<FileImage className="h-10 w-10 text-muted-foreground" />
) : (
<Package className="h-10 w-10 text-muted-foreground" />
)}
<p className="max-w-[14rem] text-xs text-muted-foreground">
{contentType ?? "Artifact"}
</p>
</div>
</div>
)}
<div className="space-y-4 p-4">
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<Badge variant="outline">Artifact</Badge>
<Badge variant="secondary">{artifact.provider}</Badge>
{artifact.status ? <Badge variant="secondary">{labelize(artifact.status)}</Badge> : null}
{artifact.isPrimary ? <Badge variant="secondary">Primary</Badge> : null}
{sizeLabel ? <Badge variant="secondary">{sizeLabel}</Badge> : null}
</div>
<div>
<h3 className="line-clamp-2 text-base font-semibold">{artifact.title}</h3>
<p className="mt-1 text-xs text-muted-foreground">
Updated {relativeTime(artifact.updatedAt)}
</p>
</div>
{artifact.summary ? (
<p className="line-clamp-3 text-sm text-muted-foreground">{artifact.summary}</p>
) : null}
</div>
<div className="space-y-2 rounded-xl border border-border/70 bg-muted/20 p-3">
<div className="flex items-start gap-2 text-sm">
<CircleDot className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Issue</p>
<Link to={createIssueDetailPath(issueRef)} className="line-clamp-2 font-medium hover:underline">
{issueTitle}
</Link>
</div>
</div>
{projectHref && projectTitle ? (
<div className="flex items-start gap-2 text-sm">
<Hexagon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Project</p>
<Link to={projectHref} className="line-clamp-1 font-medium hover:underline">
{projectTitle}
</Link>
</div>
</div>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{href ? (
<Button asChild size="sm" variant="outline">
<a href={href} target="_blank" rel="noreferrer">
{imagePreview ? <ArrowUpRight className="mr-1.5 h-4 w-4" /> : <Download className="mr-1.5 h-4 w-4" />}
{imagePreview ? "Open" : "Download"}
</a>
</Button>
) : null}
<Button asChild size="sm" variant="ghost">
<Link to={createIssueDetailPath(issueRef)}>Open issue</Link>
</Button>
</div>
</div>
</article>
);
}
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<string, { title: string; ref: string }>();
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<string, { title: string; href: string }>();
for (const project of projects ?? []) {
map.set(project.id, {
title: project.name,
href: projectUrl(project),
});
}
return map;
}, [projects]);
if (!selectedCompanyId) {
return <EmptyState icon={Package} message="Select a company to view artifacts." />;
}
if (isLoading) {
return <PageSkeleton variant="grid" />;
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold tracking-tight">Artifacts</h2>
<p className="text-sm text-muted-foreground">
Generated issue outputs across the company, sorted by most recent update.
</p>
</div>
{artifacts && artifacts.length > 0 ? (
<Badge variant="secondary" className="h-fit px-3 py-1 text-xs font-medium">
{artifacts.length} artifact{artifacts.length === 1 ? "" : "s"}
</Badge>
) : null}
</div>
{error ? <p className="text-sm text-destructive">{error.message}</p> : null}
{!isLoading && (artifacts?.length ?? 0) === 0 ? (
<EmptyState icon={Package} message="No artifacts yet." />
) : null}
{artifacts && artifacts.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
{artifacts.map((artifact) => {
const issue = issueMap.get(artifact.issueId);
const project = artifact.projectId ? projectMap.get(artifact.projectId) ?? null : null;
return (
<ArtifactCard
key={artifact.id}
artifact={artifact}
issueTitle={issue?.title ?? artifact.issueId.slice(0, 8)}
issueRef={issue?.ref ?? artifact.issueId}
projectTitle={project?.title ?? null}
projectHref={project?.href ?? null}
/>
);
})}
</div>
) : null}
</div>
);
}