From 96b98c58e895cd4ad7ef5c9685de474f674dea15 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 9 Apr 2026 23:35:45 -0500 Subject: [PATCH] Add issue work product deliverables view --- packages/shared/src/index.ts | 7 + packages/shared/src/types/index.ts | 9 + .../shared/src/types/issue-deliverables.ts | 82 +++++ .../issue-activity-events-routes.test.ts | 3 + .../src/__tests__/issue-deliverables.test.ts | 210 +++++++++++ .../issue-dependency-wakeups-routes.test.ts | 3 + .../issue-document-restore-routes.test.ts | 3 + .../issue-execution-policy-routes.test.ts | 3 + ...issue-update-comment-wakeup-routes.test.ts | 3 + .../issues-goal-context-routes.test.ts | 3 + server/src/routes/issues.ts | 14 + server/src/services/index.ts | 1 + server/src/services/issue-deliverables.ts | 334 ++++++++++++++++++ ui/src/api/issues.test.ts | 6 + ui/src/api/issues.ts | 2 + .../IssueDeliverablesSummaryChips.tsx | 67 ++++ ui/src/components/IssueDocumentsSection.tsx | 1 + .../components/IssueWorkProductTab.test.tsx | 243 +++++++++++++ ui/src/components/IssueWorkProductTab.tsx | 241 +++++++++++++ ui/src/lib/queryKeys.ts | 1 + ui/src/pages/IssueDetail.tsx | 25 ++ 21 files changed, 1261 insertions(+) create mode 100644 packages/shared/src/types/issue-deliverables.ts create mode 100644 server/src/__tests__/issue-deliverables.test.ts create mode 100644 server/src/services/issue-deliverables.ts create mode 100644 ui/src/components/IssueDeliverablesSummaryChips.tsx create mode 100644 ui/src/components/IssueWorkProductTab.test.tsx create mode 100644 ui/src/components/IssueWorkProductTab.tsx diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dc8562926d8..06fa797afd8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -267,6 +267,13 @@ export type { IssueWorkProductProvider, IssueWorkProductStatus, IssueWorkProductReviewState, + IssueDeliverableSourceType, + IssueDeliverableKind, + IssueDeliverableItem, + IssueDeliverablesRuntimeServiceSummary, + IssueDeliverablesWorkspaceSummary, + IssueDeliverablesSummary, + IssueDeliverablesResponse, Issue, IssueAssigneeAdapterOverrides, IssueRelation, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 600d97a80b5..5000b6f83da 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -99,6 +99,15 @@ export type { IssueWorkProductStatus, IssueWorkProductReviewState, } from "./work-product.js"; +export type { + IssueDeliverableSourceType, + IssueDeliverableKind, + IssueDeliverableItem, + IssueDeliverablesRuntimeServiceSummary, + IssueDeliverablesWorkspaceSummary, + IssueDeliverablesSummary, + IssueDeliverablesResponse, +} from "./issue-deliverables.js"; export type { Issue, IssueAssigneeAdapterOverrides, diff --git a/packages/shared/src/types/issue-deliverables.ts b/packages/shared/src/types/issue-deliverables.ts new file mode 100644 index 00000000000..d25ebdb601d --- /dev/null +++ b/packages/shared/src/types/issue-deliverables.ts @@ -0,0 +1,82 @@ +import type { ExecutionWorkspaceProviderType, ExecutionWorkspaceStatus } from "./workspace-runtime.js"; +import type { IssueWorkProductType } from "./work-product.js"; + +export type IssueDeliverableSourceType = + | "work_product" + | "document" + | "attachment" + | "legacy_plan_document"; + +export type IssueDeliverableKind = IssueWorkProductType | "attachment"; + +export interface IssueDeliverableItem { + id: string; + sourceType: IssueDeliverableSourceType; + kind: IssueDeliverableKind; + title: string; + url: string | null; + summary: string | null; + status: string | null; + reviewState: string | null; + healthStatus: "unknown" | "healthy" | "unhealthy" | null; + provider: string | null; + updatedAt: Date; + createdAt: Date | null; + isPrimary: boolean; + metadata: Record | null; + documentKey: string | null; + revisionNumber: number | null; + contentType: string | null; + byteSize: number | null; +} + +export interface IssueDeliverablesRuntimeServiceSummary { + id: string; + serviceName: string; + status: string; + healthStatus: "unknown" | "healthy" | "unhealthy"; + url: string | null; +} + +export interface IssueDeliverablesWorkspaceSummary { + id: string; + projectId: string; + projectWorkspaceId: string | null; + name: string; + mode: string; + status: ExecutionWorkspaceStatus; + providerType: ExecutionWorkspaceProviderType; + branchName: string | null; + baseRef: string | null; + lastUsedAt: Date; + runtimeServiceCount: number; + runtimeServiceHealth: "unknown" | "healthy" | "unhealthy" | null; + runtimeServices: IssueDeliverablesRuntimeServiceSummary[]; +} + +export interface IssueDeliverablesSummary { + hasAny: boolean; + previewCount: number; + pullRequestCount: number; + branchCount: number; + commitCount: number; + documentCount: number; + fileCount: number; + workspaceMode: string | null; + workspaceStatus: ExecutionWorkspaceStatus | null; + previewHealth: "unknown" | "healthy" | "unhealthy" | null; + pullRequestStatus: string | null; + pullRequestReviewState: string | null; +} + +export interface IssueDeliverablesResponse { + workspace: IssueDeliverablesWorkspaceSummary | null; + summary: IssueDeliverablesSummary; + primaryItem: IssueDeliverableItem | null; + previews: IssueDeliverableItem[]; + pullRequests: IssueDeliverableItem[]; + branches: IssueDeliverableItem[]; + commits: IssueDeliverableItem[]; + documents: IssueDeliverableItem[]; + files: IssueDeliverableItem[]; +} diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts index 36a64bd32e5..ca6b3d9d3ec 100644 --- a/server/src/__tests__/issue-activity-events-routes.test.ts +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -49,6 +49,9 @@ vi.mock("../services/index.js", () => ({ listCompanyIds: vi.fn(async () => ["company-1"]), }), issueApprovalService: () => ({}), + issueDeliverableService: () => ({ + getForIssue: vi.fn(async () => null), + }), issueService: () => mockIssueService, logActivity: mockLogActivity, projectService: () => ({}), diff --git a/server/src/__tests__/issue-deliverables.test.ts b/server/src/__tests__/issue-deliverables.test.ts new file mode 100644 index 00000000000..23ae11fd98f --- /dev/null +++ b/server/src/__tests__/issue-deliverables.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from "vitest"; +import type { ExecutionWorkspace, IssueDocument, IssueWorkProduct } from "@paperclipai/shared"; +import { buildIssueDeliverables } from "../services/issue-deliverables.ts"; + +function createIssueDocument(overrides: Partial = {}): IssueDocument { + return { + id: "document-1", + companyId: "company-1", + issueId: "issue-1", + key: "plan", + title: null, + format: "markdown", + body: "# Plan\n\nShip the thing.", + latestRevisionId: "revision-1", + latestRevisionNumber: 1, + createdByAgentId: null, + createdByUserId: "user-1", + updatedByAgentId: null, + updatedByUserId: "user-1", + createdAt: new Date("2026-04-01T12:00:00.000Z"), + updatedAt: new Date("2026-04-01T12:00:00.000Z"), + ...overrides, + }; +} + +function createWorkProduct(overrides: Partial = {}): IssueWorkProduct { + return { + id: "work-product-1", + companyId: "company-1", + projectId: "project-1", + issueId: "issue-1", + executionWorkspaceId: "workspace-1", + runtimeServiceId: null, + type: "pull_request", + provider: "github", + externalId: null, + title: "PR 42", + url: "https://example.com/pr/42", + status: "ready_for_review", + reviewState: "needs_board_review", + isPrimary: false, + healthStatus: "unknown", + summary: "Ready for board review.", + metadata: null, + createdByRunId: null, + createdAt: new Date("2026-04-01T13:00:00.000Z"), + updatedAt: new Date("2026-04-01T13:00:00.000Z"), + ...overrides, + }; +} + +function createWorkspace(overrides: Partial = {}): ExecutionWorkspace { + return { + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: "project-workspace-1", + sourceIssueId: "issue-1", + mode: "adapter_managed", + strategyType: "adapter_managed", + name: "Remote sandbox", + status: "active", + cwd: null, + repoUrl: "https://github.com/paperclipai/paperclip", + baseRef: "main", + branchName: "pap-1280-work-product", + providerType: "adapter_managed", + providerRef: "sandbox-42", + derivedFromExecutionWorkspaceId: null, + lastUsedAt: new Date("2026-04-01T14:00:00.000Z"), + openedAt: new Date("2026-04-01T12:00:00.000Z"), + closedAt: null, + cleanupEligibleAt: null, + cleanupReason: null, + config: null, + metadata: null, + runtimeServices: [ + { + id: "service-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: "project-workspace-1", + executionWorkspaceId: "workspace-1", + issueId: "issue-1", + scopeType: "execution_workspace", + scopeId: "workspace-1", + serviceName: "Preview", + status: "running", + lifecycle: "ephemeral", + reuseKey: null, + command: null, + cwd: null, + port: null, + url: "https://preview.example.com", + provider: "adapter_managed", + providerRef: "preview-1", + ownerAgentId: null, + startedByRunId: null, + lastUsedAt: new Date("2026-04-01T14:00:00.000Z"), + startedAt: new Date("2026-04-01T12:30:00.000Z"), + stoppedAt: null, + stopPolicy: null, + healthStatus: "healthy", + createdAt: new Date("2026-04-01T12:30:00.000Z"), + updatedAt: new Date("2026-04-01T14:00:00.000Z"), + }, + ], + createdAt: new Date("2026-04-01T12:00:00.000Z"), + updatedAt: new Date("2026-04-01T14:00:00.000Z"), + ...overrides, + }; +} + +describe("buildIssueDeliverables", () => { + it("pins the plan first in the documents list while choosing the newest document as primary", () => { + const response = buildIssueDeliverables({ + 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: [ + createIssueDocument(), + createIssueDocument({ + id: "document-2", + key: "report", + title: "Weekly report", + body: "Latest findings.", + updatedAt: new Date("2026-04-01T16:00:00.000Z"), + }), + ], + legacyPlanDocument: null, + attachments: [], + }); + + expect(response.summary.documentCount).toBe(2); + expect(response.documents[0]?.documentKey).toBe("plan"); + expect(response.primaryItem?.title).toBe("Weekly report"); + }); + + it("prefers explicit primary work products and merges artifacts with attachments into files", () => { + const response = buildIssueDeliverables({ + issue: { + id: "issue-1", + projectId: "project-1", + description: null, + executionWorkspaceId: "workspace-1", + createdAt: new Date("2026-04-01T12:00:00.000Z"), + updatedAt: new Date("2026-04-01T15:00:00.000Z"), + }, + workspace: createWorkspace(), + workProducts: [ + createWorkProduct({ isPrimary: true }), + createWorkProduct({ + id: "work-product-2", + type: "preview_url", + title: "Preview deploy", + provider: "vercel", + url: "https://preview.example.com", + healthStatus: "healthy", + status: "active", + reviewState: "none", + updatedAt: new Date("2026-04-01T14:30:00.000Z"), + }), + createWorkProduct({ + id: "work-product-3", + type: "artifact", + title: "Run report.pdf", + provider: "paperclip", + url: "/api/attachments/artifact/content", + status: "ready_for_review", + reviewState: "none", + metadata: { contentType: "application/pdf", byteSize: 2048 }, + }), + ], + documents: [], + legacyPlanDocument: null, + attachments: [ + { + id: "attachment-1", + companyId: "company-1", + issueId: "issue-1", + issueCommentId: null, + assetId: "asset-1", + provider: "paperclip", + objectKey: "attachments/build-log.txt", + contentType: "text/plain", + byteSize: 512, + sha256: "abc", + originalFilename: "build-log.txt", + createdByAgentId: null, + createdByUserId: "user-1", + createdAt: new Date("2026-04-01T15:00:00.000Z"), + updatedAt: new Date("2026-04-01T15:00:00.000Z"), + }, + ], + }); + + expect(response.primaryItem?.id).toBe("work-product-1"); + expect(response.summary.previewCount).toBe(1); + expect(response.summary.fileCount).toBe(2); + expect(response.workspace?.runtimeServiceCount).toBe(1); + expect(response.workspace?.runtimeServiceHealth).toBe("healthy"); + }); +}); diff --git a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts index 896253eb773..b884db65581 100644 --- a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts +++ b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts @@ -45,6 +45,9 @@ vi.mock("../services/index.js", () => ({ listCompanyIds: vi.fn(), }), issueApprovalService: () => ({}), + issueDeliverableService: () => ({ + getForIssue: vi.fn(async () => null), + }), issueService: () => mockIssueService, logActivity: vi.fn(async () => undefined), projectService: () => ({ diff --git a/server/src/__tests__/issue-document-restore-routes.test.ts b/server/src/__tests__/issue-document-restore-routes.test.ts index a0caa578b3a..b127587da37 100644 --- a/server/src/__tests__/issue-document-restore-routes.test.ts +++ b/server/src/__tests__/issue-document-restore-routes.test.ts @@ -42,6 +42,9 @@ vi.mock("../services/index.js", () => ({ getGeneral: vi.fn(async () => ({ feedbackDataSharingPreference: "prompt" })), }), issueApprovalService: () => ({}), + issueDeliverableService: () => ({ + getForIssue: vi.fn(async () => null), + }), issueService: () => mockIssueService, logActivity: mockLogActivity, projectService: () => ({}), diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index d3c279775c9..03e4b12b460 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -50,6 +50,9 @@ function registerModuleMocks() { listCompanyIds: vi.fn(async () => ["company-1"]), }), issueApprovalService: () => ({}), + issueDeliverableService: () => ({ + getForIssue: vi.fn(async () => null), + }), issueService: () => mockIssueService, logActivity: vi.fn(async () => undefined), projectService: () => ({}), diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index 1b32f8fca5b..b147c176114 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -53,6 +53,9 @@ vi.mock("../services/index.js", () => ({ listCompanyIds: vi.fn(async () => ["company-1"]), }), issueApprovalService: () => ({}), + issueDeliverableService: () => ({ + getForIssue: vi.fn(async () => null), + }), issueService: () => mockIssueService, logActivity: vi.fn(async () => undefined), projectService: () => ({}), diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index d20f5916ea8..458df226fb1 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -59,6 +59,9 @@ vi.mock("../services/index.js", () => ({ listCompanyIds: vi.fn(async () => ["company-1"]), }), issueApprovalService: () => ({}), + issueDeliverableService: () => ({ + getForIssue: vi.fn(async () => null), + }), issueService: () => mockIssueService, logActivity: vi.fn(async () => undefined), projectService: () => mockProjectService, diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 03a35a47b9a..5f9321c6445 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -42,6 +42,7 @@ import { issueApprovalService, ISSUE_LIST_DEFAULT_LIMIT, ISSUE_LIST_MAX_LIMIT, + issueDeliverableService, issueService, clampIssueListLimit, documentService, @@ -313,6 +314,7 @@ export function issueRoutes( const issueApprovalsSvc = issueApprovalService(db); const executionWorkspacesSvc = executionWorkspaceService(db); const workProductsSvc = workProductService(db); + const issueDeliverablesSvc = issueDeliverableService(db); const documentsSvc = documentService(db); const routinesSvc = routineService(db); const feedbackExportService = opts?.feedbackExportService; @@ -2195,6 +2197,18 @@ export function issueRoutes( res.json(released); }); + router.get("/issues/:id/deliverables", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + const deliverables = await issueDeliverablesSvc.getForIssue(issue); + res.json(deliverables); + }); + router.get("/issues/:id/comments", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 6536f893adf..b4d3a34a088 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -40,6 +40,7 @@ export { companyPortabilityService } from "./company-portability.js"; export { executionWorkspaceService } from "./execution-workspaces.js"; export { workspaceOperationService } from "./workspace-operations.js"; export { workProductService } from "./work-products.js"; +export { issueDeliverableService, buildIssueDeliverables } from "./issue-deliverables.js"; export { logActivity, type LogActivityInput } from "./activity-log.js"; export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js"; export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js"; diff --git a/server/src/services/issue-deliverables.ts b/server/src/services/issue-deliverables.ts new file mode 100644 index 00000000000..5e026146174 --- /dev/null +++ b/server/src/services/issue-deliverables.ts @@ -0,0 +1,334 @@ +import type { + ExecutionWorkspace, + IssueDeliverableItem, + IssueDeliverablesResponse, + IssueDeliverablesWorkspaceSummary, + IssueDocument, + IssueWorkProduct, + LegacyPlanDocument, +} from "@paperclipai/shared"; +import type { Db } from "@paperclipai/db"; +import { documentService, extractLegacyPlanBody } from "./documents.js"; +import { executionWorkspaceService } from "./execution-workspaces.js"; +import { issueService } from "./issues.js"; +import { workProductService } from "./work-products.js"; + +type AttachmentInput = Awaited["listAttachments"]>>[number]; + +type IssueInput = { + id: string; + projectId: string | null; + description: string | null; + executionWorkspaceId: string | null; + createdAt: Date; + updatedAt: Date; +}; + +type BuildIssueDeliverablesInput = { + issue: IssueInput; + workspace: ExecutionWorkspace | null; + workProducts: IssueWorkProduct[]; + documents: IssueDocument[]; + legacyPlanDocument: LegacyPlanDocument | null; + attachments: AttachmentInput[]; +}; + +function compareItemsByRecency(a: IssueDeliverableItem, b: IssueDeliverableItem) { + if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); +} + +function summarizeMarkdown(body: string, maxLength = 180) { + const normalized = body + .replace(/```[\s\S]*?```/g, " ") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[[^\]]*\]\([^)]+\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[#>*_~-]/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!normalized) return null; + if (normalized.length <= maxLength) return normalized; + return `${normalized.slice(0, maxLength - 1)}...`; +} + +function formatDocumentTitle(document: Pick) { + if (document.key === "plan") return "Plan"; + if (document.title?.trim()) return document.title.trim(); + return document.key + .split(/[-_]+/g) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function toWorkProductItem(product: IssueWorkProduct): IssueDeliverableItem { + const metadata = product.metadata ?? null; + const contentType = typeof metadata?.contentType === "string" ? metadata.contentType : null; + const byteSize = typeof metadata?.byteSize === "number" ? metadata.byteSize : null; + return { + id: product.id, + sourceType: "work_product", + kind: product.type, + title: product.title, + url: product.url, + summary: product.summary, + status: product.status ?? null, + reviewState: product.reviewState ?? null, + healthStatus: product.healthStatus ?? null, + provider: product.provider, + updatedAt: product.updatedAt, + createdAt: product.createdAt, + isPrimary: product.isPrimary, + metadata, + documentKey: null, + revisionNumber: null, + contentType, + byteSize, + }; +} + +function toDocumentItem(document: IssueDocument): IssueDeliverableItem { + return { + id: document.id, + sourceType: "document", + kind: "document", + title: formatDocumentTitle(document), + url: `#document-${encodeURIComponent(document.key)}`, + summary: summarizeMarkdown(document.body), + status: null, + reviewState: null, + healthStatus: null, + provider: "paperclip", + updatedAt: document.updatedAt, + createdAt: document.createdAt, + isPrimary: false, + metadata: null, + documentKey: document.key, + revisionNumber: document.latestRevisionNumber, + contentType: "text/markdown", + byteSize: null, + }; +} + +function toLegacyPlanItem(issue: IssueInput, legacyPlanDocument: LegacyPlanDocument): IssueDeliverableItem { + return { + id: `legacy-plan:${issue.id}`, + sourceType: "legacy_plan_document", + kind: "document", + title: "Plan", + url: "#document-plan", + summary: summarizeMarkdown(legacyPlanDocument.body), + status: null, + reviewState: null, + healthStatus: null, + provider: "paperclip", + updatedAt: issue.updatedAt, + createdAt: issue.createdAt, + isPrimary: false, + metadata: { source: legacyPlanDocument.source }, + documentKey: legacyPlanDocument.key, + revisionNumber: null, + contentType: "text/markdown", + byteSize: null, + }; +} + +function toAttachmentItem(attachment: AttachmentInput): IssueDeliverableItem { + return { + id: attachment.id, + sourceType: "attachment", + kind: "attachment", + title: attachment.originalFilename ?? attachment.objectKey, + url: `/api/attachments/${attachment.id}/content`, + summary: null, + status: null, + reviewState: null, + healthStatus: null, + provider: attachment.provider, + updatedAt: attachment.updatedAt, + createdAt: attachment.createdAt, + isPrimary: false, + metadata: null, + documentKey: null, + revisionNumber: null, + contentType: attachment.contentType, + byteSize: attachment.byteSize, + }; +} + +function summarizeRuntimeServiceHealth( + workspace: ExecutionWorkspace | null, +): IssueDeliverablesWorkspaceSummary["runtimeServiceHealth"] { + if (!workspace?.runtimeServices || workspace.runtimeServices.length === 0) return null; + if (workspace.runtimeServices.some((service) => service.healthStatus === "unhealthy" || service.status === "failed")) { + return "unhealthy"; + } + if (workspace.runtimeServices.every((service) => service.healthStatus === "healthy" || service.status === "running")) { + return "healthy"; + } + return "unknown"; +} + +function summarizeWorkspace(workspace: ExecutionWorkspace | null): IssueDeliverablesWorkspaceSummary | null { + if (!workspace) return null; + return { + id: workspace.id, + projectId: workspace.projectId, + projectWorkspaceId: workspace.projectWorkspaceId, + name: workspace.name, + mode: workspace.mode, + status: workspace.status, + providerType: workspace.providerType, + branchName: workspace.branchName, + baseRef: workspace.baseRef, + lastUsedAt: workspace.lastUsedAt, + runtimeServiceCount: workspace.runtimeServices?.length ?? 0, + runtimeServiceHealth: summarizeRuntimeServiceHealth(workspace), + runtimeServices: (workspace.runtimeServices ?? []).map((service) => ({ + id: service.id, + serviceName: service.serviceName, + status: service.status, + healthStatus: service.healthStatus, + url: service.url, + })), + }; +} + +function sortDocuments(items: IssueDeliverableItem[]) { + return [...items].sort((a, b) => { + if (a.documentKey === "plan" && b.documentKey !== "plan") return -1; + if (a.documentKey !== "plan" && b.documentKey === "plan") return 1; + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }); +} + +function pickPrimaryItem(groups: { + workProducts: IssueWorkProduct[]; + previews: IssueDeliverableItem[]; + pullRequests: IssueDeliverableItem[]; + documents: IssueDeliverableItem[]; + files: IssueDeliverableItem[]; +}) { + const explicitPrimary = [...groups.workProducts] + .filter((product) => product.isPrimary) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0]; + if (explicitPrimary) return toWorkProductItem(explicitPrimary); + + const preview = [...groups.previews] + .filter((item) => item.kind === "preview_url") + .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]; + 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; +} + +export function buildIssueDeliverables(input: BuildIssueDeliverablesInput): IssueDeliverablesResponse { + const workspace = summarizeWorkspace(input.workspace); + const workProductItems = input.workProducts.map(toWorkProductItem); + const previews = workProductItems + .filter((item) => item.kind === "preview_url" || item.kind === "runtime_service") + .sort(compareItemsByRecency); + const pullRequests = workProductItems + .filter((item) => item.kind === "pull_request") + .sort(compareItemsByRecency); + const branches = workProductItems + .filter((item) => item.kind === "branch") + .sort(compareItemsByRecency); + const commits = workProductItems + .filter((item) => item.kind === "commit") + .sort(compareItemsByRecency); + + const documentItems = [ + ...workProductItems.filter((item) => item.kind === "document"), + ...input.documents.map(toDocumentItem), + ...(input.legacyPlanDocument ? [toLegacyPlanItem(input.issue, input.legacyPlanDocument)] : []), + ]; + + const fileItems = [ + ...workProductItems.filter((item) => item.kind === "artifact"), + ...input.attachments.map(toAttachmentItem), + ].sort(compareItemsByRecency); + + const primaryItem = pickPrimaryItem({ + workProducts: input.workProducts, + previews, + pullRequests, + documents: documentItems, + files: fileItems, + }); + + return { + workspace, + summary: { + hasAny: Boolean( + workspace || + previews.length || + pullRequests.length || + branches.length || + commits.length || + documentItems.length || + fileItems.length + ), + previewCount: previews.length, + pullRequestCount: pullRequests.length, + branchCount: branches.length, + commitCount: commits.length, + documentCount: documentItems.length, + fileCount: fileItems.length, + workspaceMode: workspace?.mode ?? null, + workspaceStatus: workspace?.status ?? null, + previewHealth: previews.length === 1 ? previews[0]?.healthStatus ?? null : null, + pullRequestStatus: pullRequests.length === 1 ? pullRequests[0]?.status ?? null : null, + pullRequestReviewState: pullRequests.length === 1 ? pullRequests[0]?.reviewState ?? null : null, + }, + primaryItem, + previews, + pullRequests, + branches, + commits, + documents: sortDocuments(documentItems), + files: fileItems, + }; +} + +export function issueDeliverableService(db: Db) { + const issuesSvc = issueService(db); + const workProductsSvc = workProductService(db); + const documentsSvc = documentService(db); + const executionWorkspacesSvc = executionWorkspaceService(db); + + return { + getForIssue: async (issue: IssueInput) => { + const legacyPlanBody = extractLegacyPlanBody(issue.description); + const [workspace, workProducts, documents, attachments] = await Promise.all([ + issue.executionWorkspaceId ? executionWorkspacesSvc.getById(issue.executionWorkspaceId) : Promise.resolve(null), + workProductsSvc.listForIssue(issue.id), + documentsSvc.listIssueDocuments(issue.id).then((rows) => rows as IssueDocument[]), + issuesSvc.listAttachments(issue.id), + ]); + + return buildIssueDeliverables({ + issue, + workspace, + workProducts, + documents, + legacyPlanDocument: legacyPlanBody + ? { + key: "plan", + body: legacyPlanBody, + source: "issue_description", + } + : null, + attachments, + }); + }, + }; +} diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index d0b3fab06f5..fd409f3eb04 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -23,4 +23,10 @@ describe("issuesApi.list", () => { "/companies/company-1/issues?parentId=issue-parent-1&limit=25", ); }); + + it("calls the deliverables endpoint for an issue", async () => { + await issuesApi.getDeliverables("issue-1"); + + expect(mockApi.get).toHaveBeenCalledWith("/issues/issue-1/deliverables"); + }); }); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 7fbaab0e35c..84bdafe4af5 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -7,6 +7,7 @@ import type { Issue, IssueAttachment, IssueComment, + IssueDeliverablesResponse, IssueDocument, IssueLabel, IssueWorkProduct, @@ -163,6 +164,7 @@ export const issuesApi = { api.post(`/issues/${id}/approvals`, { approvalId }), unlinkApproval: (id: string, approvalId: string) => api.delete<{ ok: true }>(`/issues/${id}/approvals/${approvalId}`), + getDeliverables: (id: string) => api.get(`/issues/${id}/deliverables`), listWorkProducts: (id: string) => api.get(`/issues/${id}/work-products`), createWorkProduct: (id: string, data: Record) => api.post(`/issues/${id}/work-products`, data), diff --git a/ui/src/components/IssueDeliverablesSummaryChips.tsx b/ui/src/components/IssueDeliverablesSummaryChips.tsx new file mode 100644 index 00000000000..dc21a2c6d74 --- /dev/null +++ b/ui/src/components/IssueDeliverablesSummaryChips.tsx @@ -0,0 +1,67 @@ +import type { IssueDeliverablesSummary } from "@paperclipai/shared"; +import { Badge } from "@/components/ui/badge"; + +function labelize(value: string) { + return value.replace(/_/g, " "); +} + +function workspaceModeLabel(mode: string) { + switch (mode) { + case "shared_workspace": + return "Shared"; + case "isolated_workspace": + return "Isolated"; + case "operator_branch": + return "Operator branch"; + case "adapter_managed": + return "Adapter managed"; + case "cloud_sandbox": + return "Cloud sandbox"; + default: + return labelize(mode); + } +} + +export function IssueDeliverablesSummaryChips({ summary }: { summary: IssueDeliverablesSummary | null | undefined }) { + if (!summary) return null; + + const chips: string[] = []; + if (summary.workspaceMode) { + chips.push(`Workspace: ${workspaceModeLabel(summary.workspaceMode)}`); + } + 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"}`); + } else { + chips.push(`PRs: ${summary.pullRequestCount}`); + } + } + if (summary.previewCount > 0) { + if (summary.previewCount === 1 && summary.previewHealth) { + chips.push(`Preview: ${labelize(summary.previewHealth)}`); + } else { + chips.push(`Previews: ${summary.previewCount}`); + } + } + if (summary.documentCount > 0) { + chips.push(`Docs: ${summary.documentCount}`); + } + if (summary.fileCount > 0) { + chips.push(`Files: ${summary.fileCount}`); + } + + if (chips.length === 0) return null; + + return ( +
+ {chips.map((chip) => ( + + {chip} + + ))} +
+ ); +} diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 0bb66002634..bd9aa668930 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -194,6 +194,7 @@ export function IssueDocumentsSection({ const invalidateIssueDocuments = useCallback(() => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.deliverables(issue.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) }); queryClient.invalidateQueries({ predicate: (query) => diff --git a/ui/src/components/IssueWorkProductTab.test.tsx b/ui/src/components/IssueWorkProductTab.test.tsx new file mode 100644 index 00000000000..b0dd241e845 --- /dev/null +++ b/ui/src/components/IssueWorkProductTab.test.tsx @@ -0,0 +1,243 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { IssueDeliverablesResponse } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { IssueWorkProductTab } from "./IssueWorkProductTab"; + +vi.mock("@/lib/router", () => ({ + Link: ({ to, children }: { to: string; children: React.ReactNode }) => {children}, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createDeliverables(): IssueDeliverablesResponse { + return { + workspace: { + id: "workspace-1", + projectId: "project-1", + projectWorkspaceId: "project-workspace-1", + name: "Remote sandbox", + mode: "adapter_managed", + status: "active", + providerType: "adapter_managed", + branchName: "pap-1280-work-product", + baseRef: "main", + lastUsedAt: new Date("2026-04-01T14:00:00.000Z"), + runtimeServiceCount: 1, + runtimeServiceHealth: "healthy", + runtimeServices: [ + { + id: "service-1", + serviceName: "Preview", + status: "running", + healthStatus: "healthy", + url: "https://preview.example.com", + }, + ], + }, + summary: { + hasAny: true, + previewCount: 1, + pullRequestCount: 1, + branchCount: 0, + commitCount: 0, + documentCount: 1, + fileCount: 1, + workspaceMode: "adapter_managed", + workspaceStatus: "active", + previewHealth: "healthy", + pullRequestStatus: "ready_for_review", + pullRequestReviewState: "needs_board_review", + }, + primaryItem: { + id: "pr-1", + sourceType: "work_product", + kind: "pull_request", + title: "PR 42", + url: "https://example.com/pr/42", + summary: "Ready for board review.", + status: "ready_for_review", + reviewState: "needs_board_review", + healthStatus: null, + provider: "github", + updatedAt: new Date("2026-04-01T15:00:00.000Z"), + createdAt: new Date("2026-04-01T14:00:00.000Z"), + isPrimary: true, + metadata: null, + documentKey: null, + revisionNumber: null, + contentType: null, + byteSize: null, + }, + previews: [ + { + id: "preview-1", + sourceType: "work_product", + kind: "preview_url", + title: "Preview deploy", + url: "https://preview.example.com", + summary: null, + status: "active", + reviewState: "none", + healthStatus: "healthy", + provider: "vercel", + updatedAt: new Date("2026-04-01T15:00:00.000Z"), + createdAt: new Date("2026-04-01T14:00:00.000Z"), + isPrimary: false, + metadata: null, + documentKey: null, + revisionNumber: null, + contentType: null, + byteSize: null, + }, + ], + pullRequests: [ + { + id: "pr-1", + sourceType: "work_product", + kind: "pull_request", + title: "PR 42", + url: "https://example.com/pr/42", + summary: "Ready for board review.", + status: "ready_for_review", + reviewState: "needs_board_review", + healthStatus: null, + provider: "github", + updatedAt: new Date("2026-04-01T15:00:00.000Z"), + createdAt: new Date("2026-04-01T14:00:00.000Z"), + isPrimary: true, + metadata: null, + documentKey: null, + revisionNumber: null, + contentType: null, + byteSize: null, + }, + ], + branches: [], + commits: [], + documents: [ + { + id: "document-1", + sourceType: "document", + kind: "document", + title: "Plan", + url: "#document-plan", + summary: "Ship the thing.", + 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: "plan", + revisionNumber: 2, + contentType: "text/markdown", + byteSize: null, + }, + ], + files: [ + { + id: "attachment-1", + sourceType: "attachment", + kind: "attachment", + title: "build-log.txt", + 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: "text/plain", + byteSize: 512, + }, + ], + }; +} + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +describe("IssueWorkProductTab", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders grouped deliverables and document deep links", async () => { + const root = createRoot(container); + await act(async () => { + root.render( + , + ); + }); + await flush(); + + 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(); + }); + + it("renders the empty state when nothing has been produced", async () => { + const root = createRoot(container); + await act(async () => { + root.render( + , + ); + }); + await flush(); + + expect(container.textContent).toContain("No deliverables are registered for this issue yet."); + }); +}); diff --git a/ui/src/components/IssueWorkProductTab.tsx b/ui/src/components/IssueWorkProductTab.tsx new file mode 100644 index 00000000000..b519fc72116 --- /dev/null +++ b/ui/src/components/IssueWorkProductTab.tsx @@ -0,0 +1,241 @@ +import type { IssueDeliverableItem, IssueDeliverablesResponse } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { projectWorkspaceUrl, relativeTime } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { FileText, FolderOpen, GitBranch, GitCommitHorizontal, Link2, MonitorUp, Package } from "lucide-react"; + +function labelize(value: string | null | undefined) { + if (!value) return null; + return value.replace(/_/g, " "); +} + +function formatByteSize(byteSize: number | null) { + if (!byteSize || byteSize <= 0) return null; + if (byteSize < 1024 * 1024) return `${(byteSize / 1024).toFixed(1)} KB`; + return `${(byteSize / (1024 * 1024)).toFixed(1)} MB`; +} + +function itemActionLabel(item: IssueDeliverableItem) { + if (!item.url) return null; + if (item.kind === "document" && item.url.startsWith("#document-")) return "Jump to document"; + if (item.kind === "preview_url" || item.kind === "runtime_service") return "Open preview"; + if (item.kind === "pull_request") return "Open PR"; + if (item.kind === "branch") return "View branch"; + if (item.kind === "commit") return "View commit"; + if (item.kind === "artifact" || item.kind === "attachment") return "Download"; + return "Open"; +} + +function itemIcon(item: IssueDeliverableItem) { + switch (item.kind) { + case "preview_url": + case "runtime_service": + return ; + case "pull_request": + case "branch": + return ; + case "commit": + return ; + case "document": + return ; + default: + return ; + } +} + +function DeliverableAction({ href, label }: { href: string | null; label: string | null }) { + if (!href || !label) return null; + if (href.startsWith("#")) { + return ( + + ); + } + return ( + + ); +} + +function ItemCard({ item }: { item: IssueDeliverableItem }) { + const actionLabel = itemActionLabel(item); + const sizeLabel = formatByteSize(item.byteSize); + + return ( +
+
+
+
+ {itemIcon(item)} +
+

{item.title}

+

+ updated {relativeTime(item.updatedAt)} +

+
+
+
+ {labelize(item.kind) ?? item.kind} + {item.provider ? {item.provider} : null} + {item.status ? {labelize(item.status)} : null} + {item.reviewState && item.reviewState !== "none" ? ( + {labelize(item.reviewState)} + ) : null} + {item.healthStatus && item.healthStatus !== "unknown" ? ( + {labelize(item.healthStatus)} + ) : null} + {item.documentKey ? {item.documentKey} : null} + {item.contentType ? {item.contentType} : null} + {sizeLabel ? {sizeLabel} : null} +
+ {item.summary ?

{item.summary}

: null} +
+ +
+
+ ); +} + +function DeliverableSection({ + title, + items, +}: { + title: string; + items: IssueDeliverableItem[]; +}) { + if (items.length === 0) return null; + + return ( +
+
+

{title}

+

{items.length} item{items.length === 1 ? "" : "s"}

+
+
+ {items.map((item) => )} +
+
+ ); +} + +function workspaceDetailLink(input: { + deliverables: IssueDeliverablesResponse; + projectId: string | null; + projectWorkspaceId: string | null; +}) { + const workspace = input.deliverables.workspace; + if (!workspace) return null; + const linkedProjectWorkspaceId = workspace.projectWorkspaceId ?? input.projectWorkspaceId ?? null; + if (workspace.mode === "shared_workspace" && input.projectId && linkedProjectWorkspaceId) { + return projectWorkspaceUrl({ id: input.projectId }, linkedProjectWorkspaceId); + } + return `/execution-workspaces/${workspace.id}`; +} + +export function IssueWorkProductTab({ + deliverables, + projectId, + projectWorkspaceId, + isLoading = false, +}: { + deliverables: IssueDeliverablesResponse | null | undefined; + projectId: string | null; + projectWorkspaceId: string | null; + isLoading?: boolean; +}) { + if (isLoading && !deliverables) { + return
Loading work product…
; + } + + if (!deliverables) { + return
Work product is unavailable right now.
; + } + + const workspaceLink = workspaceDetailLink({ deliverables, projectId, projectWorkspaceId }); + + return ( +
+ {deliverables.primaryItem ? ( +
+
+ +
+

Primary output

+

The current best deliverable for this issue.

+
+
+ +
+ ) : null} + + {deliverables.workspace ? ( +
+
+ +
+

Current workspace

+

Where the latest execution context lives.

+
+
+
+
+ {deliverables.workspace.name} + {labelize(deliverables.workspace.mode)} + {deliverables.workspace.providerType} + {labelize(deliverables.workspace.status)} + {deliverables.workspace.runtimeServiceCount > 0 ? ( + + runtime services: {deliverables.workspace.runtimeServiceCount} + + ) : null} +
+
+ {deliverables.workspace.branchName ? branch {deliverables.workspace.branchName} : null} + {deliverables.workspace.baseRef ? base {deliverables.workspace.baseRef} : null} + last used {relativeTime(deliverables.workspace.lastUsedAt)} +
+ {deliverables.workspace.runtimeServices.length > 0 ? ( +
+ {deliverables.workspace.runtimeServices.map((service) => ( + + {service.serviceName}: {labelize(service.status)} + + ))} +
+ ) : null} +
+ {workspaceLink ? ( + + ) : null} +
+ ) : null} + + + + + + + + + {!deliverables.summary.hasAny ? ( +
+ No deliverables are registered for this issue yet. Documents, uploaded files, previews, PRs, and other work + product will appear here when they are created. +
+ ) : null} + + {deliverables.summary.hasAny && !deliverables.primaryItem ? ( +
+ Outputs exist for this issue, but there is no single primary deliverable yet. +
+ ) : null} +
+ ); +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index a55f1d86c55..fce846c72c4 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -50,6 +50,7 @@ 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, activity: (issueId: string) => ["issues", "activity", issueId] as const, runs: (issueId: string) => ["issues", "runs", issueId] as const, approvals: (issueId: string) => ["issues", "approvals", issueId] as const, diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index dddfde4d102..f8affceac70 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -61,10 +61,12 @@ import { ApprovalCard } from "../components/ApprovalCard"; import { InlineEditor } from "../components/InlineEditor"; import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread"; import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff"; +import { IssueDeliverablesSummaryChips } from "../components/IssueDeliverablesSummaryChips"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; import { IssuesList } from "../components/IssuesList"; import { IssueProperties } from "../components/IssueProperties"; import { IssueRunLedger } from "../components/IssueRunLedger"; +import { IssueWorkProductTab } from "../components/IssueWorkProductTab"; import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard"; import type { MentionOption } from "../components/MarkdownEditor"; import { ImageGalleryModal } from "../components/ImageGalleryModal"; @@ -987,6 +989,12 @@ export function IssueDetail() { enabled: !!issueId, placeholderData: keepPreviousDataForSameQueryTail(issueId ?? "pending"), }); + const { data: deliverables, isLoading: deliverablesLoading } = useQuery({ + queryKey: queryKeys.issues.deliverables(issueId!), + queryFn: () => issuesApi.getDeliverables(issueId!), + enabled: !!issueId, + placeholderData: keepPreviousData, + }); const { data: liveRunCount = 0 } = useQuery({ queryKey: queryKeys.issues.liveRuns(issueId!), @@ -1167,10 +1175,12 @@ export function IssueDetail() { const invalidateIssueDetail = useCallback(() => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.deliverables(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }); }, [issueId, queryClient]); const invalidateIssueThreadLazily = useCallback(() => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!), refetchType: "inactive" }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.deliverables(issueId!), refetchType: "inactive" }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!), refetchType: "inactive" }); }, [issueId, queryClient]); @@ -2406,6 +2416,8 @@ export function IssueDetail() { className="text-xl font-bold" /> + + updateIssue.mutateAsync({ description })} @@ -2670,6 +2682,10 @@ export function IssueDetail() { Chat + + + Work Product + Activity @@ -2719,6 +2735,15 @@ export function IssueDetail() { ) : null} + + + + {detailTab === "activity" ? (