mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Turn artifacts into deliverables gallery
This commit is contained in:
1 parent
4e9f878805
commit
a8fee349bd
11 files changed
+687
-120
No files matched your search
@@ -270,6 +270,9 @@ export type {
|
||||
IssueDeliverableSourceType,
|
||||
IssueDeliverableKind,
|
||||
IssueDeliverableItem,
|
||||
CompanyDeliverableItem,
|
||||
CompanyDeliverablesSummary,
|
||||
CompanyDeliverablesResponse,
|
||||
IssueDeliverablesRuntimeServiceSummary,
|
||||
IssueDeliverablesWorkspaceSummary,
|
||||
IssueDeliverablesSummary,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user