mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Remediate Work Product UX
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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 />} />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ const BOARD_ROUTE_ROOTS = new Set([
|
||||
"agents",
|
||||
"projects",
|
||||
"workspaces",
|
||||
"work-product",
|
||||
"artifacts",
|
||||
"execution-workspaces",
|
||||
"issues",
|
||||
"routines",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user