Add artifact file chips for work products

This commit is contained in:
Dotta committed 2026-06-09 21:35:48 +00:00
1 parent ebe6b9723e
commit 6724a8fbd0
7 files changed
+263 -1

No files matched your search

+1
View File
@@ -1022,6 +1022,7 @@ export {
linkIssueApprovalSchema,
createIssueAttachmentMetadataSchema,
createIssueWorkProductSchema,
issueWorkProductMetadataSchema,
updateIssueWorkProductSchema,
attachmentArtifactWorkProductMetadataSchema,
issueWorkProductTypeSchema,
+2
View File
@@ -320,12 +320,14 @@ export {
export {
createIssueWorkProductSchema,
issueWorkProductMetadataSchema,
updateIssueWorkProductSchema,
attachmentArtifactWorkProductMetadataSchema,
issueWorkProductTypeSchema,
issueWorkProductStatusSchema,
issueWorkProductReviewStateSchema,
type CreateIssueWorkProduct,
type IssueWorkProductMetadata,
type UpdateIssueWorkProduct,
} from "./work-product.js";
+10 -1
View File
@@ -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<typeof attachmentArtifactWorkProductMetadataSchema>;
export const issueWorkProductMetadataSchema = z
.object({
resourceRef: workspaceFileRefSchema.optional().nullable(),
})
.passthrough();
export type IssueWorkProductMetadata = z.infer<typeof issueWorkProductMetadataSchema>;
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(),
});
+137
View File
@@ -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();
});
});
+76
View File
@@ -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<HTMLAnchorElement>) => {
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 (
<a
href="#"
role={canOpen ? "button" : undefined}
data-artifact-file-chip="true"
data-workspace-file-path={workspaceFileRef.relativePath}
aria-label={ariaLabel}
title={tooltip}
className={cn(
"paperclip-artifact-file-chip inline-flex items-center gap-1 rounded-sm border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-xs leading-tight text-foreground/90 align-baseline no-underline hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
className,
)}
onClick={handleClick}
>
{showIcon ? <FileCode2 aria-hidden="true" className="h-3 w-3 shrink-0 opacity-70" /> : null}
<span className="truncate max-w-[38ch]">{display}</span>
</a>
);
}
Binary file not shown.
+37
View File
@@ -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<string, unknown> | null {
return value as Record<string, unknown>;
}
function extractWorkspaceFileRefFromWorkProduct(
workProduct: { metadata: Record<string, unknown> | 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<string, unknown> | 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 (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Artifacts</h3>
</div>
<div className="flex flex-wrap gap-2">
{workProductsWithFileRefs.map(({ product, fileRef }) => (
<ArtifactFileChip
key={product.id}
workspaceFileRef={fileRef!}
title={product.title}
/>
))}
</div>
</div>
);
})()}
<Separator />
<Tabs value={detailTab} onValueChange={setDetailTab} className="space-y-3">