Remediate Work Product UX

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-21 12:05:49 -05:00
1 parent 21eafdca5c
commit 6573e9f056
18 files changed
+639 -149

No files matched your search

@@ -28,6 +28,7 @@ export interface IssueDeliverableItem {
revisionNumber: number | null;
contentType: string | null;
byteSize: number | null;
isOperatorContext: boolean;
}
export interface IssueDeliverablesRuntimeServiceSummary {
+106 -3
View File
@@ -117,7 +117,7 @@ function createWorkspace(overrides: Partial<ExecutionWorkspace> = {}): Execution
}
describe("buildIssueDeliverables", () => {
it("pins the plan first in the documents list while choosing the newest document as primary", () => {
it("pins the plan first in the documents list without promoting documents as primary work product", () => {
const response = buildIssueDeliverables({
issue: {
id: "issue-1",
@@ -145,7 +145,7 @@ describe("buildIssueDeliverables", () => {
expect(response.summary.documentCount).toBe(2);
expect(response.documents[0]?.documentKey).toBe("plan");
expect(response.primaryItem?.title).toBe("Weekly report");
expect(response.primaryItem).toBeNull();
});
it("prefers explicit primary work products and merges artifacts with attachments into files", () => {
@@ -212,6 +212,66 @@ describe("buildIssueDeliverables", () => {
expect(response.workspace?.runtimeServiceCount).toBe(1);
expect(response.workspace?.runtimeServiceHealth).toBe("healthy");
});
it("excludes comment attachments by default and includes them with operator context", () => {
const baseInput = {
issue: {
id: "issue-1",
projectId: "project-1",
description: null,
executionWorkspaceId: null,
createdAt: new Date("2026-04-01T12:00:00.000Z"),
updatedAt: new Date("2026-04-01T15:00:00.000Z"),
},
workspace: null,
workProducts: [],
documents: [],
legacyPlanDocument: null,
attachments: [
{
id: "attachment-output",
companyId: "company-1",
issueId: "issue-1",
issueCommentId: null,
assetId: "asset-1",
provider: "paperclip",
objectKey: "attachments/report.txt",
contentType: "text/plain",
byteSize: 512,
sha256: "abc",
originalFilename: "report.txt",
createdByAgentId: null,
createdByUserId: "user-1",
createdAt: new Date("2026-04-01T15:00:00.000Z"),
updatedAt: new Date("2026-04-01T15:00:00.000Z"),
},
{
id: "attachment-comment",
companyId: "company-1",
issueId: "issue-1",
issueCommentId: "comment-1",
assetId: "asset-2",
provider: "paperclip",
objectKey: "attachments/chat-screenshot.png",
contentType: "image/png",
byteSize: 1024,
sha256: "def",
originalFilename: "chat-screenshot.png",
createdByAgentId: null,
createdByUserId: "user-1",
createdAt: new Date("2026-04-01T16:00:00.000Z"),
updatedAt: new Date("2026-04-01T16:00:00.000Z"),
},
],
};
const defaultResponse = buildIssueDeliverables(baseInput);
expect(defaultResponse.files.map((item) => item.id)).toEqual(["attachment-output"]);
const operatorResponse = buildIssueDeliverables({ ...baseInput, includeOperatorContext: true });
expect(operatorResponse.files.map((item) => item.id)).toEqual(["attachment-comment", "attachment-output"]);
expect(operatorResponse.files.find((item) => item.id === "attachment-comment")?.isOperatorContext).toBe(true);
});
});
describe("buildCompanyDeliverables", () => {
@@ -291,10 +351,53 @@ describe("buildCompanyDeliverables", () => {
expect(response.summary.issueCount).toBe(2);
const kinds = response.items.map((item) => item.kind);
expect(kinds).toEqual(["preview_url", "attachment", "document", "document"]);
expect(kinds).toEqual(["preview_url", "document", "document", "attachment"]);
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");
});
it("excludes comment attachments from the company feed unless operator context is requested", () => {
const input = {
issues: [
{
id: "issue-1",
identifier: "PAP-10",
title: "Review screenshot",
status: "in_progress",
projectId: "project-1",
description: null,
createdAt: new Date("2026-04-01T09:00:00.000Z"),
updatedAt: new Date("2026-04-01T15:00:00.000Z"),
},
],
workProducts: [],
documents: [],
attachments: [
{
id: "attachment-comment",
companyId: "company-1",
issueId: "issue-1",
issueCommentId: "comment-1",
assetId: "asset-1",
provider: "paperclip",
objectKey: "attachments/chat-screenshot.png",
contentType: "image/png",
byteSize: 1024,
sha256: "abc",
originalFilename: "chat-screenshot.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(buildCompanyDeliverables(input).summary.totalCount).toBe(0);
const response = buildCompanyDeliverables({ ...input, includeOperatorContext: true });
expect(response.items.map((item) => item.id)).toEqual(["attachment-comment"]);
expect(response.items[0]?.isOperatorContext).toBe(true);
});
});
+4 -2
View File
@@ -898,7 +898,8 @@ export function issueRoutes(
router.get("/companies/:companyId/deliverables", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const deliverables = await issueDeliverablesSvc.listForCompany(companyId);
const includeOperatorContext = req.query.includeOperatorContext === "true";
const deliverables = await issueDeliverablesSvc.listForCompany(companyId, { includeOperatorContext });
res.json(deliverables);
});
@@ -2239,7 +2240,8 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
const deliverables = await issueDeliverablesSvc.getForIssue(issue);
const includeOperatorContext = req.query.includeOperatorContext === "true";
const deliverables = await issueDeliverablesSvc.getForIssue(issue, { includeOperatorContext });
res.json(deliverables);
});
+51 -21
View File
@@ -38,6 +38,7 @@ type BuildIssueDeliverablesInput = {
documents: IssueDocument[];
legacyPlanDocument: LegacyPlanDocument | null;
attachments: AttachmentInput[];
includeOperatorContext?: boolean;
};
type BuildCompanyDeliverablesInput = {
@@ -45,10 +46,34 @@ type BuildCompanyDeliverablesInput = {
workProducts: IssueWorkProduct[];
documents: IssueDocument[];
attachments: AttachmentInput[];
includeOperatorContext?: boolean;
};
function compareItemsByRecency(a: IssueDeliverableItem, b: IssueDeliverableItem) {
function kindBucket(kind: IssueDeliverableItem["kind"]) {
switch (kind) {
case "pull_request":
return 0;
case "preview_url":
case "runtime_service":
return 1;
case "branch":
case "commit":
return 2;
case "document":
return 3;
case "artifact":
return 4;
case "attachment":
return 5;
default:
return 99;
}
}
function compareDeliverableItems(a: IssueDeliverableItem, b: IssueDeliverableItem) {
if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1;
const bucketDelta = kindBucket(a.kind) - kindBucket(b.kind);
if (bucketDelta !== 0) return bucketDelta;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
}
@@ -99,6 +124,7 @@ function toWorkProductItem(product: IssueWorkProduct): IssueDeliverableItem {
revisionNumber: null,
contentType,
byteSize,
isOperatorContext: false,
};
}
@@ -122,6 +148,7 @@ function toDocumentItem(document: IssueDocument): IssueDeliverableItem {
revisionNumber: document.latestRevisionNumber,
contentType: "text/markdown",
byteSize: null,
isOperatorContext: false,
};
}
@@ -145,6 +172,7 @@ function toLegacyPlanItem(issue: LegacyPlanIssueInput, legacyPlanDocument: Legac
revisionNumber: null,
contentType: "text/markdown",
byteSize: null,
isOperatorContext: false,
};
}
@@ -168,6 +196,7 @@ function toAttachmentItem(attachment: AttachmentInput): IssueDeliverableItem {
revisionNumber: null,
contentType: attachment.contentType,
byteSize: attachment.byteSize,
isOperatorContext: Boolean(attachment.issueCommentId),
};
}
@@ -235,8 +264,6 @@ function pickPrimaryItem(groups: {
workProducts: IssueWorkProduct[];
previews: IssueDeliverableItem[];
pullRequests: IssueDeliverableItem[];
documents: IssueDeliverableItem[];
files: IssueDeliverableItem[];
}) {
const explicitPrimary = [...groups.workProducts]
.filter((product) => product.isPrimary)
@@ -248,31 +275,30 @@ function pickPrimaryItem(groups: {
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0];
if (preview) return preview;
const pullRequest = [...groups.pullRequests].sort(compareItemsByRecency)[0];
const pullRequest = [...groups.pullRequests].sort(compareDeliverableItems)[0];
if (pullRequest) return pullRequest;
const document = [...groups.documents]
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0];
if (document) return document;
return [...groups.files].sort(compareItemsByRecency)[0] ?? null;
return null;
}
export function buildIssueDeliverables(input: BuildIssueDeliverablesInput): IssueDeliverablesResponse {
const workspace = summarizeWorkspace(input.workspace);
const workProductItems = input.workProducts.map(toWorkProductItem);
const attachments = input.includeOperatorContext
? input.attachments
: input.attachments.filter((attachment) => !attachment.issueCommentId);
const previews = workProductItems
.filter((item) => item.kind === "preview_url" || item.kind === "runtime_service")
.sort(compareItemsByRecency);
.sort(compareDeliverableItems);
const pullRequests = workProductItems
.filter((item) => item.kind === "pull_request")
.sort(compareItemsByRecency);
.sort(compareDeliverableItems);
const branches = workProductItems
.filter((item) => item.kind === "branch")
.sort(compareItemsByRecency);
.sort(compareDeliverableItems);
const commits = workProductItems
.filter((item) => item.kind === "commit")
.sort(compareItemsByRecency);
.sort(compareDeliverableItems);
const documentItems = [
...workProductItems.filter((item) => item.kind === "document"),
@@ -282,15 +308,13 @@ export function buildIssueDeliverables(input: BuildIssueDeliverablesInput): Issu
const fileItems = [
...workProductItems.filter((item) => item.kind === "artifact"),
...input.attachments.map(toAttachmentItem),
].sort(compareItemsByRecency);
...attachments.map(toAttachmentItem),
].sort(compareDeliverableItems);
const primaryItem = pickPrimaryItem({
workProducts: input.workProducts,
previews,
pullRequests,
documents: documentItems,
files: fileItems,
});
return {
@@ -352,7 +376,11 @@ export function buildCompanyDeliverables(input: BuildCompanyDeliverablesInput):
items.push(toCompanyDeliverableItem(toDocumentItem(document), issue));
}
for (const attachment of input.attachments) {
const attachments = input.includeOperatorContext
? input.attachments
: input.attachments.filter((attachment) => !attachment.issueCommentId);
for (const attachment of attachments) {
const issue = issueMap.get(attachment.issueId);
if (!issue) continue;
items.push(toCompanyDeliverableItem(toAttachmentItem(attachment), issue));
@@ -374,7 +402,7 @@ export function buildCompanyDeliverables(input: BuildCompanyDeliverablesInput):
);
}
const sortedItems = [...items].sort(compareItemsByRecency);
const sortedItems = [...items].sort(compareDeliverableItems);
const issueIds = new Set<string>();
let primaryCount = 0;
let previewCount = 0;
@@ -418,7 +446,7 @@ export function issueDeliverableService(db: Db) {
const executionWorkspacesSvc = executionWorkspaceService(db);
return {
listForCompany: async (companyId: string) => {
listForCompany: async (companyId: string, options?: { includeOperatorContext?: boolean }) => {
const [issues, workProducts, documents, attachments] = await Promise.all([
issuesSvc.list(companyId),
workProductsSvc.listForCompany(companyId),
@@ -440,10 +468,11 @@ export function issueDeliverableService(db: Db) {
workProducts,
documents,
attachments,
includeOperatorContext: options?.includeOperatorContext ?? false,
});
},
getForIssue: async (issue: IssueInput) => {
getForIssue: async (issue: IssueInput, options?: { includeOperatorContext?: boolean }) => {
const legacyPlanBody = extractLegacyPlanBody(issue.description);
const [workspace, workProducts, documents, attachments] = await Promise.all([
issue.executionWorkspaceId ? executionWorkspacesSvc.getById(issue.executionWorkspaceId) : Promise.resolve(null),
@@ -465,6 +494,7 @@ export function issueDeliverableService(db: Db) {
}
: null,
attachments,
includeOperatorContext: options?.includeOperatorContext ?? false,
});
},
};
+4 -1
View File
@@ -107,7 +107,8 @@ 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="work-product" element={<Artifacts />} />
<Route path="artifacts" element={<Navigate to="/work-product" replace />} />
<Route path="goals/:goalId" element={<GoalDetail />} />
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
<Route path="approvals/pending" element={<Approvals />} />
@@ -301,6 +302,8 @@ export function App() {
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="workspaces" element={<UnprefixedBoardRedirect />} />
<Route path="work-product" element={<UnprefixedBoardRedirect />} />
<Route path="artifacts" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedBoardRedirect />} />
+4 -4
View File
@@ -25,9 +25,9 @@ describe("issuesApi.list", () => {
});
it("calls the deliverables endpoint for an issue", async () => {
await issuesApi.getDeliverables("issue-1");
await issuesApi.getDeliverables("issue-1", { includeOperatorContext: true });
expect(mockApi.get).toHaveBeenCalledWith("/issues/issue-1/deliverables");
expect(mockApi.get).toHaveBeenCalledWith("/issues/issue-1/deliverables?includeOperatorContext=true");
});
it("calls the company work products endpoint with filters", async () => {
@@ -39,8 +39,8 @@ describe("issuesApi.list", () => {
});
it("calls the company deliverables endpoint", async () => {
await issuesApi.listCompanyDeliverables("company-1");
await issuesApi.listCompanyDeliverables("company-1", { includeOperatorContext: true });
expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/deliverables");
expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/deliverables?includeOperatorContext=true");
});
});
+12 -3
View File
@@ -165,9 +165,18 @@ export const issuesApi = {
api.post<Approval[]>(`/issues/${id}/approvals`, { approvalId }),
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`),
getDeliverables: (id: string, filters?: { includeOperatorContext?: boolean }) => {
const params = new URLSearchParams();
if (filters?.includeOperatorContext) params.set("includeOperatorContext", "true");
const qs = params.toString();
return api.get<IssueDeliverablesResponse>(`/issues/${id}/deliverables${qs ? `?${qs}` : ""}`);
},
listCompanyDeliverables: (companyId: string, filters?: { includeOperatorContext?: boolean }) => {
const params = new URLSearchParams();
if (filters?.includeOperatorContext) params.set("includeOperatorContext", "true");
const qs = params.toString();
return api.get<CompanyDeliverablesResponse>(`/companies/${companyId}/deliverables${qs ? `?${qs}` : ""}`);
},
listCompanyWorkProducts: (
companyId: string,
filters?: {
@@ -22,35 +22,69 @@ function workspaceModeLabel(mode: string) {
}
}
export function IssueDeliverablesSummaryChips({ summary }: { summary: IssueDeliverablesSummary | null | undefined }) {
type Chip = {
key: string;
label: string;
section: string;
};
export function IssueDeliverablesSummaryChips({
summary,
onOpenDocuments,
onOpenWorkProduct,
}: {
summary: IssueDeliverablesSummary | null | undefined;
onOpenDocuments?: () => void;
onOpenWorkProduct?: (sectionId: string) => void;
}) {
if (!summary) return null;
const chips: string[] = [];
const chips: Chip[] = [];
if (summary.workspaceMode) {
chips.push(`Workspace: ${workspaceModeLabel(summary.workspaceMode)}`);
chips.push({
key: "workspace",
label: `Workspace: ${workspaceModeLabel(summary.workspaceMode)}`,
section: "issue-work-product-workspace",
});
}
if (summary.pullRequestCount > 0) {
if (summary.pullRequestCount === 1) {
const prState = summary.pullRequestReviewState && summary.pullRequestReviewState !== "none"
? summary.pullRequestReviewState
: summary.pullRequestStatus;
chips.push(`PR: ${prState ? labelize(prState) : "1"}`);
chips.push({
key: "pull-requests",
label: `PR: ${prState ? labelize(prState) : "1"}`,
section: "issue-work-product-pull-requests",
});
} else {
chips.push(`PRs: ${summary.pullRequestCount}`);
chips.push({
key: "pull-requests",
label: `PRs: ${summary.pullRequestCount}`,
section: "issue-work-product-pull-requests",
});
}
}
if (summary.previewCount > 0) {
if (summary.previewCount === 1 && summary.previewHealth) {
chips.push(`Preview: ${labelize(summary.previewHealth)}`);
chips.push({
key: "previews",
label: `Preview: ${labelize(summary.previewHealth)}`,
section: "issue-work-product-previews",
});
} else {
chips.push(`Previews: ${summary.previewCount}`);
chips.push({
key: "previews",
label: `Previews: ${summary.previewCount}`,
section: "issue-work-product-previews",
});
}
}
if (summary.documentCount > 0) {
chips.push(`Docs: ${summary.documentCount}`);
chips.push({ key: "documents", label: `Docs: ${summary.documentCount}`, section: "issue-documents" });
}
if (summary.fileCount > 0) {
chips.push(`Files: ${summary.fileCount}`);
chips.push({ key: "files", label: `Files: ${summary.fileCount}`, section: "issue-work-product-files" });
}
if (chips.length === 0) return null;
@@ -58,8 +92,20 @@ export function IssueDeliverablesSummaryChips({ summary }: { summary: IssueDeliv
return (
<div className="flex flex-wrap gap-2">
{chips.map((chip) => (
<Badge key={chip} variant="secondary" className="text-[11px] font-medium">
{chip}
<Badge key={chip.key} variant="secondary" className="text-[11px] font-medium">
<button
type="button"
className="hover:underline"
onClick={() => {
if (chip.section === "issue-documents") {
onOpenDocuments?.();
return;
}
onOpenWorkProduct?.(chip.section);
}}
>
{chip.label}
</button>
</Badge>
))}
</div>
+1 -1
View File
@@ -685,7 +685,7 @@ export function IssueDocumentsSection({
};
return (
<div className="space-y-3">
<div id="issue-documents" className="scroll-mt-24 space-y-3">
{isEmpty && !draft?.isNew ? (
<div className="flex flex-wrap items-center justify-end gap-2 min-w-0">
{extraActions}
@@ -71,6 +71,7 @@ function createDeliverables(): IssueDeliverablesResponse {
revisionNumber: null,
contentType: null,
byteSize: null,
isOperatorContext: false,
},
previews: [
{
@@ -92,6 +93,7 @@ function createDeliverables(): IssueDeliverablesResponse {
revisionNumber: null,
contentType: null,
byteSize: null,
isOperatorContext: false,
},
],
pullRequests: [
@@ -114,6 +116,7 @@ function createDeliverables(): IssueDeliverablesResponse {
revisionNumber: null,
contentType: null,
byteSize: null,
isOperatorContext: false,
},
],
branches: [],
@@ -138,6 +141,7 @@ function createDeliverables(): IssueDeliverablesResponse {
revisionNumber: 2,
contentType: "text/markdown",
byteSize: null,
isOperatorContext: false,
},
],
files: [
@@ -160,6 +164,7 @@ function createDeliverables(): IssueDeliverablesResponse {
revisionNumber: null,
contentType: "text/plain",
byteSize: 512,
isOperatorContext: false,
},
],
};
@@ -199,8 +204,8 @@ describe("IssueWorkProductTab", () => {
expect(container.textContent).toContain("Primary output");
expect(container.textContent).toContain("Current workspace");
expect(container.textContent).toContain("Pull requests");
expect(container.textContent).toContain("Documents");
expect(container.querySelector('a[href="#document-plan"]')).not.toBeNull();
expect(container.textContent).not.toContain("Documents");
expect(container.querySelector('a[href="#document-plan"]')).toBeNull();
});
it("renders the empty state when nothing has been produced", async () => {
@@ -238,6 +243,6 @@ describe("IssueWorkProductTab", () => {
});
await flush();
expect(container.textContent).toContain("No deliverables are registered for this issue yet.");
expect(container.textContent).toContain("No work product is registered for this issue yet.");
});
});
+34 -16
View File
@@ -1,8 +1,11 @@
import type { IssueDeliverableItem, IssueDeliverablesResponse } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { projectWorkspaceUrl, relativeTime } from "@/lib/utils";
import { EmptyState } from "./EmptyState";
import { PageSkeleton } from "./PageSkeleton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { FileText, FolderOpen, GitBranch, GitCommitHorizontal, Link2, MonitorUp, Package } from "lucide-react";
function labelize(value: string | null | undefined) {
@@ -91,6 +94,7 @@ function ItemCard({ item }: { item: IssueDeliverableItem }) {
) : null}
{item.documentKey ? <Badge variant="secondary">{item.documentKey}</Badge> : null}
{item.contentType ? <Badge variant="secondary">{item.contentType}</Badge> : null}
{item.isOperatorContext ? <Badge variant="secondary">Operator context</Badge> : null}
{sizeLabel ? <Badge variant="secondary">{sizeLabel}</Badge> : null}
</div>
{item.summary ? <p className="text-sm text-muted-foreground">{item.summary}</p> : null}
@@ -102,16 +106,18 @@ function ItemCard({ item }: { item: IssueDeliverableItem }) {
}
function DeliverableSection({
id,
title,
items,
}: {
id: string;
title: string;
items: IssueDeliverableItem[];
}) {
if (items.length === 0) return null;
return (
<section className="space-y-3">
<section id={id} className="scroll-mt-24 space-y-3">
<div>
<h3 className="text-sm font-semibold">{title}</h3>
<p className="text-xs text-muted-foreground">{items.length} item{items.length === 1 ? "" : "s"}</p>
@@ -142,14 +148,18 @@ export function IssueWorkProductTab({
projectId,
projectWorkspaceId,
isLoading = false,
showOperatorContext = false,
onShowOperatorContextChange,
}: {
deliverables: IssueDeliverablesResponse | null | undefined;
projectId: string | null;
projectWorkspaceId: string | null;
isLoading?: boolean;
showOperatorContext?: boolean;
onShowOperatorContextChange?: (checked: boolean) => void;
}) {
if (isLoading && !deliverables) {
return <div className="rounded-lg border border-border p-4 text-sm text-muted-foreground">Loading work product…</div>;
return <PageSkeleton variant="list" />;
}
if (!deliverables) {
@@ -161,12 +171,12 @@ export function IssueWorkProductTab({
return (
<div className="space-y-4">
{deliverables.primaryItem ? (
<section className="space-y-3 rounded-lg border border-border bg-muted/20 p-4">
<section id="issue-work-product-primary" className="scroll-mt-24 space-y-3 rounded-lg border border-border bg-muted/20 p-4">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-semibold">Primary output</h3>
<p className="text-xs text-muted-foreground">The current best deliverable for this issue.</p>
<p className="text-xs text-muted-foreground">The current best Work Product for this issue.</p>
</div>
</div>
<ItemCard item={deliverables.primaryItem} />
@@ -174,7 +184,7 @@ export function IssueWorkProductTab({
) : null}
{deliverables.workspace ? (
<section className="space-y-3 rounded-lg border border-border p-4">
<section id="issue-work-product-workspace" className="scroll-mt-24 space-y-3 rounded-lg border border-border p-4">
<div className="flex items-center gap-2">
<FolderOpen className="h-4 w-4 text-muted-foreground" />
<div>
@@ -217,23 +227,31 @@ export function IssueWorkProductTab({
</section>
) : null}
<DeliverableSection title="Previews" items={deliverables.previews} />
<DeliverableSection title="Pull requests" items={deliverables.pullRequests} />
<DeliverableSection title="Branches" items={deliverables.branches} />
<DeliverableSection title="Commits" items={deliverables.commits} />
<DeliverableSection title="Documents" items={deliverables.documents} />
<DeliverableSection title="Files" items={deliverables.files} />
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-muted-foreground">Show operator context</span>
<ToggleSwitch
checked={showOperatorContext}
onCheckedChange={(checked) => onShowOperatorContextChange?.(checked)}
aria-label="Show operator context"
/>
</div>
<DeliverableSection id="issue-work-product-previews" title="Previews" items={deliverables.previews} />
<DeliverableSection id="issue-work-product-pull-requests" title="Pull requests" items={deliverables.pullRequests} />
<DeliverableSection id="issue-work-product-branches" title="Branches" items={deliverables.branches} />
<DeliverableSection id="issue-work-product-commits" title="Commits" items={deliverables.commits} />
<DeliverableSection id="issue-work-product-files" title="Files" items={deliverables.files} />
{!deliverables.summary.hasAny ? (
<div className="rounded-lg border border-dashed border-border p-5 text-sm text-muted-foreground">
No deliverables are registered for this issue yet. Documents, uploaded files, previews, PRs, and other work
product will appear here when they are created.
</div>
<EmptyState
icon={Package}
message="No work product is registered for this issue yet. Previews, PRs, files, and other output will appear here when they are created."
/>
) : null}
{deliverables.summary.hasAny && !deliverables.primaryItem ? (
<div className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
Outputs exist for this issue, but there is no single primary deliverable yet.
Outputs exist for this issue, but no primary Work Product has been promoted yet.
</div>
) : null}
</div>
+1 -1
View File
@@ -105,7 +105,7 @@ export function Sidebar() {
{showWorkspacesLink ? (
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
) : null}
<SidebarNavItem to="/artifacts" label="Artifacts" icon={Package} />
<SidebarNavItem to="/work-product" label="Work Product" icon={Package} />
</SidebarSection>
<SidebarProjects />
+7
View File
@@ -20,4 +20,11 @@ describe("company routes", () => {
"/execution-workspaces/workspace-123",
);
});
it("keeps artifacts compatibility paths company scoped", () => {
expect(isBoardPathWithoutPrefix("/artifacts")).toBe(true);
expect(extractCompanyPrefixFromPath("/artifacts")).toBeNull();
expect(applyCompanyPrefix("/artifacts", "PAP")).toBe("/PAP/artifacts");
expect(applyCompanyPrefix("/work-product?filter=files", "PAP")).toBe("/PAP/work-product?filter=files");
});
});
+2
View File
@@ -7,6 +7,8 @@ const BOARD_ROUTE_ROOTS = new Set([
"agents",
"projects",
"workspaces",
"work-product",
"artifacts",
"execution-workspaces",
"issues",
"routines",
+4 -2
View File
@@ -50,7 +50,8 @@ export const queryKeys = {
documents: (issueId: string) => ["issues", "documents", issueId] as const,
document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const,
documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const,
deliverables: (issueId: string) => ["issues", "deliverables", issueId] as const,
deliverables: (issueId: string, includeOperatorContext = false) =>
["issues", "deliverables", issueId, includeOperatorContext] as const,
activity: (issueId: string) => ["issues", "activity", issueId] as const,
runs: (issueId: string) => ["issues", "runs", issueId] as const,
approvals: (issueId: string) => ["issues", "approvals", issueId] as const,
@@ -82,7 +83,8 @@ export const queryKeys = {
detail: (id: string) => ["goals", "detail", id] as const,
},
artifacts: {
list: (companyId: string) => ["artifacts", companyId] as const,
list: (companyId: string, includeOperatorContext = false) =>
["work-product", companyId, includeOperatorContext] as const,
},
budgets: {
overview: (companyId: string) => ["budgets", "overview", companyId] as const,
+188
View File
@@ -0,0 +1,188 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import type { CompanyDeliverablesResponse } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Artifacts } from "./Artifacts";
const listCompanyDeliverablesMock = vi.hoisted(() => vi.fn());
const listProjectsMock = vi.hoisted(() => vi.fn());
const setBreadcrumbsMock = vi.hoisted(() => vi.fn());
vi.mock("../api/issues", () => ({
issuesApi: {
listCompanyDeliverables: listCompanyDeliverablesMock,
},
}));
vi.mock("../api/projects", () => ({
projectsApi: {
list: listProjectsMock,
},
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
}),
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({
setBreadcrumbs: setBreadcrumbsMock,
}),
}));
vi.mock("@/lib/router", async () => {
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
return {
...actual,
Link: ({ to, children, className }: { to: string; children: ReactNode; className?: string }) => (
<a href={to} className={className}>{children}</a>
),
};
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function createWorkProductResponse(items: CompanyDeliverablesResponse["items"]): CompanyDeliverablesResponse {
return {
items,
summary: {
totalCount: items.length,
issueCount: new Set(items.map((item) => item.issueId)).size,
primaryCount: items.filter((item) => item.isPrimary).length,
previewCount: items.filter((item) => item.kind === "preview_url" || item.kind === "runtime_service").length,
pullRequestCount: items.filter((item) => item.kind === "pull_request").length,
branchCount: items.filter((item) => item.kind === "branch").length,
commitCount: items.filter((item) => item.kind === "commit").length,
documentCount: items.filter((item) => item.kind === "document").length,
fileCount: items.filter((item) => item.kind === "artifact" || item.kind === "attachment").length,
},
};
}
function createItem(overrides: Partial<CompanyDeliverablesResponse["items"][number]> = {}): CompanyDeliverablesResponse["items"][number] {
return {
id: "item-1",
sourceType: "attachment",
kind: "attachment",
title: "mockup.png",
url: "/api/attachments/attachment-1/content",
summary: null,
status: null,
reviewState: null,
healthStatus: null,
provider: "paperclip",
updatedAt: new Date("2026-04-01T12:00:00.000Z"),
createdAt: new Date("2026-04-01T12:00:00.000Z"),
isPrimary: false,
metadata: null,
documentKey: null,
revisionNumber: null,
contentType: "image/png",
byteSize: 1024,
isOperatorContext: false,
issueId: "issue-1",
issueIdentifier: "PAP-10",
issueTitle: "Create mockup",
issueStatus: "done",
projectId: "project-1",
...overrides,
};
}
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
describe("Artifacts", () => {
let container: HTMLDivElement;
let queryClient: QueryClient;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
listProjectsMock.mockResolvedValue([{ id: "project-1", name: "Paperclip App" }]);
listCompanyDeliverablesMock.mockResolvedValue(
createWorkProductResponse([
createItem(),
createItem({
id: "doc-1",
sourceType: "document",
kind: "document",
title: "Launch brief",
url: "#document-brief",
contentType: "text/markdown",
byteSize: null,
documentKey: "brief",
}),
]),
);
});
afterEach(() => {
container.remove();
vi.clearAllMocks();
});
it("hydrates search, filter, project, and operator-context state from the URL", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/PAP/work-product?filter=files&q=mock&projectId=project-1&operatorContext=true"]}>
<QueryClientProvider client={queryClient}>
<Artifacts />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flush();
expect(listCompanyDeliverablesMock).toHaveBeenCalledWith("company-1", { includeOperatorContext: true });
expect(setBreadcrumbsMock).toHaveBeenCalledWith([{ label: "Work Product" }]);
expect(container.textContent).toContain("mockup.png");
expect(container.textContent).not.toContain("Launch brief");
expect((container.querySelector('input[placeholder="Search titles, issues, providers..."]') as HTMLInputElement).value).toBe("mock");
});
it("does not render Spotlight when the filtered set has no primary or auto-promoted output", async () => {
listCompanyDeliverablesMock.mockResolvedValue(
createWorkProductResponse([
createItem({
id: "doc-1",
sourceType: "document",
kind: "document",
title: "Launch brief",
url: "#document-brief",
contentType: "text/markdown",
byteSize: null,
documentKey: "brief",
}),
]),
);
const root = createRoot(container);
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/PAP/work-product"]}>
<QueryClientProvider client={queryClient}>
<Artifacts />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flush();
expect(container.textContent).not.toContain("Spotlight");
expect(container.textContent).toContain("Latest Work Product");
});
});
+138 -78
View File
@@ -1,6 +1,6 @@
import { useDeferredValue, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@/lib/router";
import { Link, useSearchParams } from "@/lib/router";
import type { CompanyDeliverableItem } from "@paperclipai/shared";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
@@ -11,6 +11,7 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { queryKeys } from "../lib/queryKeys";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { projectUrl, relativeTime } from "../lib/utils";
@@ -81,48 +82,71 @@ function deliverableMeta(item: CompanyDeliverableItem) {
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 isSpotlightItem(item: CompanyDeliverableItem) {
return item.isPrimary || item.kind === "pull_request" || item.kind === "preview_url" || item.kind === "runtime_service";
}
function ImagePreview({
item,
fallbackIcon: FallbackIcon,
}: {
item: CompanyDeliverableItem;
fallbackIcon: typeof FileImage;
}) {
const [failed, setFailed] = useState(false);
const imagePreview = !failed && isImageItem(item) && item.url ? item.url : null;
if (!imagePreview) {
return (
<div className="flex aspect-[16/10] items-center justify-center border-b border-border bg-muted/30">
<FallbackIcon className="h-10 w-10 text-muted-foreground" />
</div>
);
}
return (
<div className="aspect-[16/10] overflow-hidden border-b border-border bg-muted/30">
<img
src={imagePreview}
alt={item.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
onError={() => setFailed(true)}
/>
</div>
);
}
function ArtifactCard({
item,
projectName,
@@ -135,42 +159,31 @@ function ArtifactCard({
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-[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/80 bg-muted/40">
<img
src={imagePreview}
alt={item.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
/>
</div>
<article className="group overflow-hidden rounded-lg border border-border bg-card transition-colors duration-200 hover:border-foreground/30">
{isImageItem(item) ? (
<ImagePreview item={item} fallbackIcon={MetaIcon} />
) : (
<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 className="flex aspect-[16/10] flex-col justify-between border-b border-border bg-muted/20 p-5">
<Badge variant="outline" className="w-fit gap-2">
<MetaIcon className="h-3.5 w-3.5" />
{meta.label}
</Badge>
{item.kind === "document" ? (
<p className="line-clamp-4 rounded-md border border-border bg-background p-3 text-sm leading-6 text-foreground">
{item.summary ?? "This document does not have a preview snippet yet."}
</p>
) : (
<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) ?? "Work product"}
</p>
</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>
) : (
<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>
)}
</div>
)}
</div>
)}
@@ -184,6 +197,7 @@ function ArtifactCard({
<Badge variant="secondary">{labelize(item.reviewState)}</Badge>
) : null}
{item.isPrimary ? <Badge variant="secondary">Primary</Badge> : null}
{item.isOperatorContext ? <Badge variant="secondary">Operator context</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}
@@ -201,7 +215,7 @@ function ArtifactCard({
) : null}
</div>
<div className="space-y-3 rounded-2xl border border-border/70 bg-muted/20 p-3.5">
<div className="space-y-3 rounded-lg border border-border 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">
@@ -271,17 +285,35 @@ function ArtifactCard({
export function Artifacts() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<DeliverableFilter>("all");
const [searchParams, setSearchParams] = useSearchParams();
const search = searchParams.get("q") ?? "";
const rawFilter = searchParams.get("filter") ?? "all";
const filter: DeliverableFilter = ["all", "documents", "files", "previews", "pullRequests", "code"].includes(rawFilter)
? (rawFilter as DeliverableFilter)
: "all";
const selectedProjectId = searchParams.get("projectId") ?? "all";
const showOperatorContext = searchParams.get("operatorContext") === "true";
const deferredSearch = useDeferredValue(search);
useEffect(() => {
setBreadcrumbs([{ label: "Artifacts" }]);
setBreadcrumbs([{ label: "Work Product" }]);
}, [setBreadcrumbs]);
const { data: deliverables, isLoading, error } = useQuery({
queryKey: queryKeys.artifacts.list(selectedCompanyId!),
queryFn: () => issuesApi.listCompanyDeliverables(selectedCompanyId!),
function updateParam(key: string, value: string | null) {
setSearchParams((current) => {
const next = new URLSearchParams(current);
if (!value || value === "all" || value === "false") {
next.delete(key);
} else {
next.set(key, value);
}
return next;
}, { replace: true });
}
const { data: deliverables, isLoading, error, refetch } = useQuery({
queryKey: queryKeys.artifacts.list(selectedCompanyId!, showOperatorContext),
queryFn: () => issuesApi.listCompanyDeliverables(selectedCompanyId!, { includeOperatorContext: showOperatorContext }),
enabled: !!selectedCompanyId,
});
const { data: projects } = useQuery({
@@ -302,6 +334,7 @@ export function Artifacts() {
const query = deferredSearch.trim().toLowerCase();
return (deliverables?.items ?? []).filter((item) => {
if (!filterMatches(item, filter)) return false;
if (selectedProjectId !== "all" && item.projectId !== selectedProjectId) return false;
if (!query) return true;
const haystack = [
item.title,
@@ -315,11 +348,10 @@ export function Artifacts() {
.toLowerCase();
return haystack.includes(query);
});
}, [deferredSearch, deliverables?.items, filter]);
}, [deferredSearch, deliverables?.items, filter, selectedProjectId]);
const featuredItems = useMemo(() => {
const candidates = filteredItems.filter((item) => item.isPrimary);
return (candidates.length > 0 ? candidates : filteredItems).slice(0, 3);
return filteredItems.filter(isSpotlightItem).slice(0, 3);
}, [filteredItems]);
const featuredIds = useMemo(() => new Set(featuredItems.map((item) => item.id)), [featuredItems]);
@@ -327,7 +359,7 @@ export function Artifacts() {
() => filteredItems.filter((item) => !featuredIds.has(item.id)),
[featuredIds, filteredItems],
);
const showSpotlight = filter === "all" && !deferredSearch.trim() && featuredItems.length > 0;
const showSpotlight = featuredItems.length > 0;
const feedItems = showSpotlight && remainingItems.length > 0 ? remainingItems : filteredItems;
const filterOptions = [
@@ -344,7 +376,7 @@ export function Artifacts() {
];
if (!selectedCompanyId) {
return <EmptyState icon={Package} message="Select a company to view artifacts." />;
return <EmptyState icon={Package} message="Select a company to view Work Product." />;
}
if (isLoading) {
@@ -353,38 +385,38 @@ export function Artifacts() {
return (
<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)]">
<section className="overflow-hidden rounded-lg border border-border bg-card">
<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>
<Badge variant="outline">Company Work Product</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.
uploaded files, previews, pull requests, branches, and commits 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">
<div className="rounded-lg border border-border bg-background 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>
<p className="mt-1 text-xs text-muted-foreground">Mixed output in the feed</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/70 p-4">
<div className="rounded-lg border border-border bg-background 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">
<div className="rounded-lg border border-border bg-background 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">
<div className="rounded-lg border border-border bg-background 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>
@@ -393,12 +425,19 @@ export function Artifacts() {
</div>
</section>
{error ? <p className="text-sm text-destructive">{error.message}</p> : null}
{error ? (
<EmptyState
icon={Package}
message={error.message}
action="Retry"
onAction={() => void refetch()}
/>
) : null}
{!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."
message="No Work Product yet. Issue documents, uploaded files, previews, and other output will appear here."
/>
) : null}
@@ -411,7 +450,7 @@ export function Artifacts() {
<button
key={option.id}
type="button"
onClick={() => setFilter(option.id)}
onClick={() => updateParam("filter", 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"
@@ -426,14 +465,35 @@ export function Artifacts() {
))}
</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 className="flex w-full flex-col gap-2 lg:w-auto lg:flex-row lg:items-center">
<select
value={selectedProjectId}
onChange={(event) => updateParam("projectId", event.target.value)}
className="h-9 rounded-md border border-border bg-background px-3 text-sm"
aria-label="Filter Work Product by project"
>
<option value="all">All projects</option>
{(projects ?? []).map((project) => (
<option key={project.id} value={project.id}>{project.name}</option>
))}
</select>
<label className="flex h-9 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm text-muted-foreground">
<span>Show operator context</span>
<ToggleSwitch
checked={showOperatorContext}
onCheckedChange={(checked) => updateParam("operatorContext", String(checked))}
aria-label="Show operator context"
/>
</label>
<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) => updateParam("q", event.target.value)}
placeholder="Search titles, issues, providers..."
className="pl-9"
/>
</div>
</div>
</div>
</section>
@@ -446,7 +506,7 @@ export function Artifacts() {
<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.
Primary items, previews, and PRs float to the top.
</p>
</div>
<div className="grid gap-4 xl:grid-cols-3">
@@ -470,15 +530,15 @@ export function Artifacts() {
<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"}
{deferredSearch.trim() || filter !== "all" || selectedProjectId !== "all" ? "Filtered Work Product" : "Latest Work Product"}
</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>
<div className="rounded-lg border border-dashed border-border px-6 py-12 text-center">
<p className="text-base font-medium">No Work Product matches 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>
+17 -3
View File
@@ -989,9 +989,10 @@ export function IssueDetail() {
enabled: !!issueId,
placeholderData: keepPreviousDataForSameQueryTail<IssueAttachment[]>(issueId ?? "pending"),
});
const [showOperatorContextWorkProduct, setShowOperatorContextWorkProduct] = useState(false);
const { data: deliverables, isLoading: deliverablesLoading } = useQuery({
queryKey: queryKeys.issues.deliverables(issueId!),
queryFn: () => issuesApi.getDeliverables(issueId!),
queryKey: queryKeys.issues.deliverables(issueId!, showOperatorContextWorkProduct),
queryFn: () => issuesApi.getDeliverables(issueId!, { includeOperatorContext: showOperatorContextWorkProduct }),
enabled: !!issueId,
placeholderData: keepPreviousDataForSameQueryTail<Awaited<ReturnType<typeof issuesApi.getDeliverables>>>(
issueId ?? "pending",
@@ -2418,7 +2419,18 @@ export function IssueDetail() {
className="text-xl font-bold"
/>
<IssueDeliverablesSummaryChips summary={deliverables?.summary} />
<IssueDeliverablesSummaryChips
summary={deliverables?.summary}
onOpenDocuments={() => {
document.getElementById("issue-documents")?.scrollIntoView({ behavior: "smooth", block: "start" });
}}
onOpenWorkProduct={(sectionId) => {
setDetailTab("work-product");
window.setTimeout(() => {
document.getElementById(sectionId)?.scrollIntoView({ behavior: "smooth", block: "start" });
}, 0);
}}
/>
<InlineEditor
value={issue.description ?? ""}
@@ -2743,6 +2755,8 @@ export function IssueDetail() {
projectId={issue.projectId}
projectWorkspaceId={issue.projectWorkspaceId}
isLoading={deliverablesLoading}
showOperatorContext={showOperatorContextWorkProduct}
onShowOperatorContextChange={setShowOperatorContextWorkProduct}
/>
</TabsContent>