From 6724a8fbd09ec655a78f2dd1e6e5fe759b5c0c3d Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 24 Apr 2026 17:05:01 -0500 Subject: [PATCH] Add artifact file chips for work products --- packages/shared/src/index.ts | 1 + packages/shared/src/validators/index.ts | 2 + .../shared/src/validators/work-product.ts | 11 +- packages/shared/src/work-product.test.ts | 137 ++++++++++++++++++ ui/src/components/ArtifactFileChip.tsx | 76 ++++++++++ ui/src/lib/workspace-file-parser.test.ts | Bin 3148 -> 3149 bytes ui/src/pages/IssueDetail.tsx | 37 +++++ 7 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 packages/shared/src/work-product.test.ts create mode 100644 ui/src/components/ArtifactFileChip.tsx diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c2f01f7fe59..4d63f70e77c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1022,6 +1022,7 @@ export { linkIssueApprovalSchema, createIssueAttachmentMetadataSchema, createIssueWorkProductSchema, + issueWorkProductMetadataSchema, updateIssueWorkProductSchema, attachmentArtifactWorkProductMetadataSchema, issueWorkProductTypeSchema, diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index f504ff4c169..076204f1ffa 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -320,12 +320,14 @@ export { export { createIssueWorkProductSchema, + issueWorkProductMetadataSchema, updateIssueWorkProductSchema, attachmentArtifactWorkProductMetadataSchema, issueWorkProductTypeSchema, issueWorkProductStatusSchema, issueWorkProductReviewStateSchema, type CreateIssueWorkProduct, + type IssueWorkProductMetadata, type UpdateIssueWorkProduct, } from "./work-product.js"; diff --git a/packages/shared/src/validators/work-product.ts b/packages/shared/src/validators/work-product.ts index ac31b4764e1..3ed27ba6d60 100644 --- a/packages/shared/src/validators/work-product.ts +++ b/packages/shared/src/validators/work-product.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { workspaceFileRefSchema } from "./workspace-file-resource.js"; function attachmentContentPath(attachmentId: string): string { return `/api/attachments/${attachmentId}/content`; @@ -68,6 +69,14 @@ export const attachmentArtifactWorkProductMetadataSchema = z.object({ export type AttachmentArtifactWorkProductMetadata = z.infer; +export const issueWorkProductMetadataSchema = z + .object({ + resourceRef: workspaceFileRefSchema.optional().nullable(), + }) + .passthrough(); + +export type IssueWorkProductMetadata = z.infer; + export const createIssueWorkProductSchema = z.object({ projectId: z.string().uuid().optional().nullable(), executionWorkspaceId: z.string().uuid().optional().nullable(), @@ -82,7 +91,7 @@ export const createIssueWorkProductSchema = z.object({ isPrimary: z.boolean().optional().default(false), healthStatus: z.enum(["unknown", "healthy", "unhealthy"]).optional().default("unknown"), summary: z.string().optional().nullable(), - metadata: z.record(z.string(), z.unknown()).optional().nullable(), + metadata: issueWorkProductMetadataSchema.optional().nullable(), createdByRunId: z.string().uuid().optional().nullable(), }); diff --git a/packages/shared/src/work-product.test.ts b/packages/shared/src/work-product.test.ts new file mode 100644 index 00000000000..0fdfb875549 --- /dev/null +++ b/packages/shared/src/work-product.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { + issueWorkProductMetadataSchema, + createIssueWorkProductSchema, +} from "./validators/work-product.js"; + +describe("work product metadata with resource ref", () => { + it("accepts valid workspace file ref in metadata", () => { + const result = issueWorkProductMetadataSchema.parse({ + resourceRef: { + kind: "workspace_file", + issueId: "123e4567-e89b-12d3-a456-426614174000", + workspaceKind: "execution_workspace", + workspaceId: "223e4567-e89b-12d3-a456-426614174001", + relativePath: "src/components/App.tsx", + line: 42, + column: 10, + displayPath: "src/components/App.tsx:42:10", + }, + }); + + expect(result.resourceRef?.kind).toBe("workspace_file"); + expect(result.resourceRef?.issueId).toBe("123e4567-e89b-12d3-a456-426614174000"); + expect(result.resourceRef?.relativePath).toBe("src/components/App.tsx"); + }); + + it("accepts workspace file ref without line/column", () => { + const result = issueWorkProductMetadataSchema.parse({ + resourceRef: { + kind: "workspace_file", + issueId: "123e4567-e89b-12d3-a456-426614174000", + workspaceKind: "project_workspace", + workspaceId: "223e4567-e89b-12d3-a456-426614174001", + relativePath: "README.md", + displayPath: "README.md", + }, + }); + + expect(result.resourceRef?.line).toBeUndefined(); + expect(result.resourceRef?.column).toBeUndefined(); + }); + + it("accepts null resourceRef", () => { + const result = issueWorkProductMetadataSchema.parse({ + resourceRef: null, + }); + + expect(result.resourceRef).toBeNull(); + }); + + it("accepts undefined resourceRef", () => { + const result = issueWorkProductMetadataSchema.parse({}); + + expect(result.resourceRef).toBeUndefined(); + }); + + it("rejects invalid resourceRef missing kind", () => { + expect(() => { + issueWorkProductMetadataSchema.parse({ + resourceRef: { + issueId: "123e4567-e89b-12d3-a456-426614174000", + workspaceKind: "execution_workspace", + workspaceId: "223e4567-e89b-12d3-a456-426614174001", + relativePath: "src/components/App.tsx", + displayPath: "src/components/App.tsx", + }, + }); + }).toThrow(); + }); + + it("rejects invalid workspaceKind", () => { + expect(() => { + issueWorkProductMetadataSchema.parse({ + resourceRef: { + kind: "workspace_file", + issueId: "123e4567-e89b-12d3-a456-426614174000", + workspaceKind: "invalid_kind", + workspaceId: "223e4567-e89b-12d3-a456-426614174001", + relativePath: "src/components/App.tsx", + displayPath: "src/components/App.tsx", + }, + }); + }).toThrow(); + }); + + it("rejects invalid issueId (not UUID)", () => { + expect(() => { + issueWorkProductMetadataSchema.parse({ + resourceRef: { + kind: "workspace_file", + issueId: "not-a-uuid", + workspaceKind: "execution_workspace", + workspaceId: "223e4567-e89b-12d3-a456-426614174001", + relativePath: "src/components/App.tsx", + displayPath: "src/components/App.tsx", + }, + }); + }).toThrow(); + }); +}); + +describe("create issue work product with resource ref", () => { + it("accepts work product with resourceRef in metadata", () => { + const result = createIssueWorkProductSchema.parse({ + type: "artifact", + provider: "paperclip", + title: "Build output", + status: "active", + metadata: { + resourceRef: { + kind: "workspace_file", + issueId: "123e4567-e89b-12d3-a456-426614174000", + workspaceKind: "execution_workspace", + workspaceId: "223e4567-e89b-12d3-a456-426614174001", + relativePath: "dist/index.js", + displayPath: "dist/index.js", + }, + }, + }); + + expect((result.metadata as any)?.resourceRef?.kind).toBe("workspace_file"); + }); + + it("accepts work product without resourceRef in metadata", () => { + const result = createIssueWorkProductSchema.parse({ + type: "pull_request", + provider: "github", + title: "Feature PR", + status: "active", + metadata: { + prNumber: 123, + }, + }); + + expect(result.metadata?.resourceRef).toBeUndefined(); + }); +}); diff --git a/ui/src/components/ArtifactFileChip.tsx b/ui/src/components/ArtifactFileChip.tsx new file mode 100644 index 00000000000..e5fe34d902a --- /dev/null +++ b/ui/src/components/ArtifactFileChip.tsx @@ -0,0 +1,76 @@ +import type { MouseEvent, ReactNode } from "react"; +import { FileCode2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { WorkspaceFileRef } from "@paperclipai/shared"; +import { useFileViewer } from "@/context/FileViewerContext"; + +export interface ArtifactFileChipProps { + workspaceFileRef: WorkspaceFileRef; + /** Override the rendered label. Defaults to the display path. */ + label?: ReactNode; + className?: string; + /** Optional override if the consumer wants to customize activation. */ + onOpen?: (ref: WorkspaceFileRef) => void; + showIcon?: boolean; + title?: string; +} + +export function ArtifactFileChip({ + workspaceFileRef, + label, + className, + onOpen, + showIcon = true, + title, +}: ArtifactFileChipProps) { + const viewer = useFileViewer(); + const display = typeof label !== "undefined" ? label : workspaceFileRef.displayPath; + const canOpen = !!(onOpen || viewer); + const lineSuffix = workspaceFileRef.line + ? ` line ${workspaceFileRef.line}${workspaceFileRef.column ? ` column ${workspaceFileRef.column}` : ""}` + : ""; + const ariaLabel = canOpen + ? `Open ${workspaceFileRef.displayPath}${lineSuffix} in the file viewer` + : `Workspace file ${workspaceFileRef.displayPath}${lineSuffix}`; + const tooltip = title ?? (canOpen + ? `Open ${workspaceFileRef.displayPath}${lineSuffix} in the file viewer` + : `Workspace file ${workspaceFileRef.displayPath}${lineSuffix}`); + + const handleClick = (event: MouseEvent) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + if (event.button !== 0) return; + if (!canOpen) return; + event.preventDefault(); + if (onOpen) onOpen(workspaceFileRef); + else { + const workspace = workspaceFileRef.workspaceKind === "execution_workspace" ? "execution" : "project"; + viewer?.open({ + path: workspaceFileRef.relativePath, + line: workspaceFileRef.line ?? null, + column: workspaceFileRef.column ?? null, + workspace, + }); + } + }; + + return ( + + {showIcon ? + ); +} diff --git a/ui/src/lib/workspace-file-parser.test.ts b/ui/src/lib/workspace-file-parser.test.ts index bbfeb856e6775a6a1f4b7621eb2b74347805674b..d8d469f150b764818c3ec300eef005a87d03e838 100644 GIT binary patch delta 27 jcmX>jaaLl(A9kh~gUJWjB`443=ACT7#=cpLLx}|dk%0)G delta 26 icmX>raYkapA9hBD$p_daC(q&Loov9yzFC_?i3I?I%?K0# diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 8ac320406e8..4cba719afd4 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -89,6 +89,7 @@ import type { MentionOption } from "../components/MarkdownEditor"; import { ImageGalleryModal } from "../components/ImageGalleryModal"; import { FileViewerProvider, useRequiredFileViewer } from "../context/FileViewerContext"; import { FileViewerSheet } from "../components/FileViewerSheet"; +import { ArtifactFileChip } from "../components/ArtifactFileChip"; import { ScrollToBottom } from "../components/ScrollToBottom"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; @@ -167,6 +168,8 @@ import { type RequestConfirmationInteraction, type SuggestTasksInteraction, type IssueTreeControlMode, + type WorkspaceFileRef, + workspaceFileRefSchema, } from "@paperclipai/shared"; type StopAndFinalizeRunError = Error & { @@ -301,6 +304,15 @@ function asRecord(value: unknown): Record | null { return value as Record; } +function extractWorkspaceFileRefFromWorkProduct( + workProduct: { metadata: Record | null }, +): WorkspaceFileRef | null { + const metadata = asRecord(workProduct.metadata); + if (!metadata) return null; + const parsed = workspaceFileRefSchema.safeParse(metadata.resourceRef); + return parsed.success ? parsed.data : null; +} + function usageNumber(usage: Record | null, ...keys: string[]) { if (!usage) return 0; for (const key of keys) { @@ -4030,6 +4042,31 @@ export function IssueDetail() { onUpdate={(data) => updateIssue.mutate(data)} /> + {issue.workProducts && issue.workProducts.length > 0 && (() => { + const workProductsWithFileRefs = issue.workProducts + .map((product) => ({ product, fileRef: extractWorkspaceFileRefFromWorkProduct(product) })) + .filter(({ fileRef }) => fileRef !== null); + + if (workProductsWithFileRefs.length === 0) return null; + + return ( +
+
+

Artifacts

+
+
+ {workProductsWithFileRefs.map(({ product, fileRef }) => ( + + ))} +
+
+ ); + })()} +