mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Add artifact file chips for work products
This commit is contained in:
1 parent
ebe6b9723e
commit
6724a8fbd0
7 files changed
+263
-1
No files matched your search
@@ -1022,6 +1022,7 @@ export {
|
||||
linkIssueApprovalSchema,
|
||||
createIssueAttachmentMetadataSchema,
|
||||
createIssueWorkProductSchema,
|
||||
issueWorkProductMetadataSchema,
|
||||
updateIssueWorkProductSchema,
|
||||
attachmentArtifactWorkProductMetadataSchema,
|
||||
issueWorkProductTypeSchema,
|
||||
|
||||
@@ -320,12 +320,14 @@ export {
|
||||
|
||||
export {
|
||||
createIssueWorkProductSchema,
|
||||
issueWorkProductMetadataSchema,
|
||||
updateIssueWorkProductSchema,
|
||||
attachmentArtifactWorkProductMetadataSchema,
|
||||
issueWorkProductTypeSchema,
|
||||
issueWorkProductStatusSchema,
|
||||
issueWorkProductReviewStateSchema,
|
||||
type CreateIssueWorkProduct,
|
||||
type IssueWorkProductMetadata,
|
||||
type UpdateIssueWorkProduct,
|
||||
} from "./work-product.js";
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user