Turn artifacts into deliverables gallery

This commit is contained in:
Dotta committed 2026-04-20 08:19:17 -05:00
1 parent 4e9f878805
commit a8fee349bd
11 files changed
+687 -120

No files matched your search

+3
View File
@@ -270,6 +270,9 @@ export type {
IssueDeliverableSourceType,
IssueDeliverableKind,
IssueDeliverableItem,
CompanyDeliverableItem,
CompanyDeliverablesSummary,
CompanyDeliverablesResponse,
IssueDeliverablesRuntimeServiceSummary,
IssueDeliverablesWorkspaceSummary,
IssueDeliverablesSummary,
+3
View File
@@ -103,6 +103,9 @@ export type {
IssueDeliverableSourceType,
IssueDeliverableKind,
IssueDeliverableItem,
CompanyDeliverableItem,
CompanyDeliverablesSummary,
CompanyDeliverablesResponse,
IssueDeliverablesRuntimeServiceSummary,
IssueDeliverablesWorkspaceSummary,
IssueDeliverablesSummary,
@@ -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;
}
@@ -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> = {}): 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: "<plan>Legacy plan body</plan>",
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");
});
});
+7
View File
@@ -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);
+10
View File
@@ -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
+138 -1
View File
@@ -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<ReturnType<ReturnType<typeof issueService>["listAttachments"]>>[number];
type CompanyIssueInput = Pick<
Awaited<ReturnType<ReturnType<typeof issueService>["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<IssueInput, "id" | "createdAt" | "updatedAt">;
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<string, CompanyIssueInput>();
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<string>();
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([
+24
View File
@@ -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({
+6
View File
@@ -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");
});
});
+3
View File
@@ -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<IssueDeliverablesResponse>(`/issues/${id}/deliverables`),
listCompanyDeliverables: (companyId: string) =>
api.get<CompanyDeliverablesResponse>(`/companies/${companyId}/deliverables`),
listCompanyWorkProducts: (
companyId: string,
filters?: {
+376 -117
View File
@@ -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 (
<article className="group overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md">
<article className="group overflow-hidden rounded-[1.4rem] border border-border/80 bg-card shadow-[0_20px_60px_-40px_rgba(15,23,42,0.35)] transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_28px_70px_-38px_rgba(15,23,42,0.45)]">
{imagePreview ? (
<div className="aspect-[16/10] overflow-hidden border-b border-border bg-muted/40">
<div className="aspect-[16/10] overflow-hidden border-b border-border/80 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]"
alt={item.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
/>
</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" />
<div className={`border-b border-border/80 bg-gradient-to-br ${meta.accentClass}`}>
<div className="flex aspect-[16/10] flex-col justify-between p-5">
<div className={`inline-flex w-fit items-center gap-2 rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${meta.badgeClass}`}>
<MetaIcon className="h-3.5 w-3.5" />
{meta.label}
</div>
{item.kind === "document" ? (
<div className="rounded-2xl border border-black/5 bg-white/80 p-4 shadow-sm">
<p className="line-clamp-4 text-sm leading-6 text-slate-700">
{item.summary ?? "This document does not have a preview snippet yet."}
</p>
</div>
) : (
<Package className="h-10 w-10 text-muted-foreground" />
<div className="space-y-2">
<MetaIcon className="h-10 w-10 text-foreground/70" />
<p className="max-w-[14rem] text-sm text-muted-foreground">
{item.summary ?? item.contentType ?? labelize(item.kind) ?? "Deliverable"}
</p>
</div>
)}
<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-4 p-5">
<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}
<Badge variant="outline">{meta.label}</Badge>
{item.provider ? <Badge variant="secondary">{item.provider}</Badge> : null}
{item.status ? <Badge variant="secondary">{labelize(item.status)}</Badge> : null}
{item.reviewState && item.reviewState !== "none" ? (
<Badge variant="secondary">{labelize(item.reviewState)}</Badge>
) : null}
{item.isPrimary ? <Badge variant="secondary">Primary</Badge> : null}
{item.documentKey ? <Badge variant="secondary">{item.documentKey}</Badge> : null}
{item.revisionNumber ? <Badge variant="secondary">v{item.revisionNumber}</Badge> : null}
{sizeLabel ? <Badge variant="secondary">{sizeLabel}</Badge> : null}
</div>
<div>
<h3 className="line-clamp-2 text-base font-semibold">{artifact.title}</h3>
<h3 className="line-clamp-2 text-lg font-semibold leading-tight">{item.title}</h3>
<p className="mt-1 text-xs text-muted-foreground">
Updated {relativeTime(artifact.updatedAt)}
Updated {relativeTime(item.updatedAt)}
</p>
</div>
{artifact.summary ? (
<p className="line-clamp-3 text-sm text-muted-foreground">{artifact.summary}</p>
{item.summary && item.kind !== "document" ? (
<p className="line-clamp-3 text-sm text-muted-foreground">{item.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="space-y-3 rounded-2xl border border-border/70 bg-muted/20 p-3.5">
<div className="flex items-start gap-2">
<Package 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}
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Issue</p>
<Link
to={createIssueDetailPath(item.issueIdentifier ?? item.issueId)}
className="line-clamp-2 text-sm font-medium hover:underline"
>
{item.issueTitle}
</Link>
{item.issueIdentifier ? (
<p className="text-xs text-muted-foreground">{item.issueIdentifier}</p>
) : null}
</div>
</div>
{projectHref && projectTitle ? (
<div className="flex items-start gap-2 text-sm">
{projectHref && projectName ? (
<div className="flex items-start gap-2">
<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}
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Project</p>
<Link to={projectHref} className="line-clamp-1 text-sm font-medium hover:underline">
{projectName}
</Link>
</div>
</div>
@@ -132,15 +233,34 @@ function ArtifactCard({
<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>
isInternalDocLink ? (
<Button asChild size="sm" variant="outline">
<Link to={href}>
<ArrowUpRight className="mr-1.5 h-4 w-4" />
{actionLabel(item)}
</Link>
</Button>
) : href.startsWith("/") ? (
<Button asChild size="sm" variant="outline">
<a href={href}>
<ArrowUpRight className="mr-1.5 h-4 w-4" />
{actionLabel(item)}
</a>
</Button>
) : (
<Button asChild size="sm" variant="outline">
<a href={href} target="_blank" rel="noreferrer">
<ArrowUpRight className="mr-1.5 h-4 w-4" />
{actionLabel(item)}
</a>
</Button>
)
) : null}
<Button asChild size="sm" variant="ghost">
<Link to={createIssueDetailPath(issueRef)}>Open issue</Link>
<Link to={createIssueDetailPath(item.issueIdentifier ?? item.issueId)}>
<ExternalLink className="mr-1.5 h-4 w-4" />
Open issue
</Link>
</Button>
</div>
</div>
@@ -151,19 +271,17 @@ function ArtifactCard({
export function Artifacts() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<DeliverableFilter>("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<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),
});
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 <EmptyState icon={Package} message="Select a company to view artifacts." />;
}
@@ -203,44 +352,154 @@ export function Artifacts() {
}
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 className="space-y-8">
<section className="overflow-hidden rounded-[1.75rem] border border-border/80 bg-gradient-to-br from-amber-50 via-background to-sky-50 shadow-[0_30px_80px_-50px_rgba(15,23,42,0.35)]">
<div className="grid gap-6 px-6 py-7 lg:grid-cols-[1.35fr_0.65fr] lg:px-8">
<div className="space-y-4">
<Badge variant="outline" className="bg-background/70">Company deliverables</Badge>
<div className="space-y-2">
<h2 className="max-w-2xl text-3xl font-semibold tracking-tight text-balance">
Documents, files, previews, and other work product in one place.
</h2>
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
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.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Items</p>
<p className="mt-2 text-3xl font-semibold">{deliverables?.summary.totalCount ?? 0}</p>
<p className="mt-1 text-xs text-muted-foreground">Mixed deliverables in the feed</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Issues</p>
<p className="mt-2 text-3xl font-semibold">{deliverables?.summary.issueCount ?? 0}</p>
<p className="mt-1 text-xs text-muted-foreground">Tasks with visible output</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Docs</p>
<p className="mt-2 text-3xl font-semibold">{deliverables?.summary.documentCount ?? 0}</p>
<p className="mt-1 text-xs text-muted-foreground">Plans, briefs, reports, notes</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Primary</p>
<p className="mt-2 text-3xl font-semibold">{deliverables?.summary.primaryCount ?? 0}</p>
<p className="mt-1 text-xs text-muted-foreground">Explicitly promoted outputs</p>
</div>
</div>
</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>
</section>
{error ? <p className="text-sm text-destructive">{error.message}</p> : null}
{!isLoading && (artifacts?.length ?? 0) === 0 ? (
<EmptyState icon={Package} message="No artifacts yet." />
{!isLoading && (deliverables?.summary.totalCount ?? 0) === 0 ? (
<EmptyState
icon={Package}
message="No deliverables yet. Issue documents, uploaded files, previews, and other work product will appear here."
/>
) : 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>
{deliverables && deliverables.summary.totalCount > 0 ? (
<>
<section className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap gap-2">
{filterOptions.map((option) => (
<button
key={option.id}
type="button"
onClick={() => setFilter(option.id)}
className={
option.id === filter
? "inline-flex items-center gap-2 rounded-full border border-foreground/20 bg-foreground px-3.5 py-2 text-sm font-medium text-background"
: "inline-flex items-center gap-2 rounded-full border border-border bg-background px-3.5 py-2 text-sm font-medium text-muted-foreground hover:text-foreground"
}
>
<span>{option.label}</span>
<span className={option.id === filter ? "text-background/75" : "text-muted-foreground"}>
{option.count}
</span>
</button>
))}
</div>
<div className="relative w-full lg:w-[22rem]">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search titles, issues, providers..."
className="pl-9"
/>
</div>
</div>
</section>
{showSpotlight ? (
<section className="space-y-4">
<div className="flex items-end justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Spotlight</p>
<h3 className="text-xl font-semibold">Recent output worth opening first</h3>
</div>
<p className="text-sm text-muted-foreground">
Primary items and the freshest deliverables float to the top.
</p>
</div>
<div className="grid gap-4 xl:grid-cols-3">
{featuredItems.map((item) => {
const project = item.projectId ? projectMap.get(item.projectId) ?? null : null;
return (
<ArtifactCard
key={item.id}
item={item}
projectName={project?.title ?? null}
projectHref={project?.href ?? null}
/>
);
})}
</div>
</section>
) : null}
<section className="space-y-4">
<div className="flex items-end justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Feed</p>
<h3 className="text-xl font-semibold">
{deferredSearch.trim() || filter !== "all" ? "Filtered deliverables" : "Latest deliverables"}
</h3>
</div>
<p className="text-sm text-muted-foreground">{filteredItems.length} visible item{filteredItems.length === 1 ? "" : "s"}</p>
</div>
{filteredItems.length === 0 ? (
<div className="rounded-[1.5rem] border border-dashed border-border px-6 py-12 text-center">
<p className="text-base font-medium">No deliverables match the current filter.</p>
<p className="mt-2 text-sm text-muted-foreground">
Try another filter or clear the search to see more issue output.
</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
{feedItems.map((item) => {
const project = item.projectId ? projectMap.get(item.projectId) ?? null : null;
return (
<ArtifactCard
key={item.id}
item={item}
projectName={project?.title ?? null}
projectHref={project?.href ?? null}
/>
);
})}
</div>
)}
</section>
</>
) : null}
</div>
);