diff --git a/packages/paperclip-runner/src/drivers/codex/codex-session-workspace.ts b/packages/paperclip-runner/src/drivers/codex/codex-session-workspace.ts index 8036589c18..c5c35f2014 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-session-workspace.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-session-workspace.ts @@ -1,4 +1,8 @@ -import { parseCodexTurnDiff, type ParsedCodexTurnDiffFile } from "./codex-turn-diff.js"; +import { + parseCodexTurnDiff, + summarizeCodexTurnDiff, + type ParsedCodexTurnDiffFile, +} from "./codex-turn-diff.js"; import { boundedCodexWorkspaceStat as boundedWorkspaceStat, codexWorkspaceRelativePath as workspaceRelativePath } from "./codex-thread-normalization.js"; import type { CodexSessionState } from "./codex-session-state.js"; import { record, text } from "./codex-driver-values.js"; @@ -10,9 +14,9 @@ export function recordWorkspaceChanges( complete: boolean, ): void { const changes = Array.isArray(value) ? value : []; - const files = changes + const files: ParsedCodexTurnDiffFile[] = changes .slice(0, 2_000) - .flatMap((candidate): Record[] => { + .flatMap((candidate): ParsedCodexTurnDiffFile[] => { const change = record(candidate); const path = text(change.path).replaceAll("\\", "/"); if (!path || path.startsWith("/") || path.split("/").includes("..")) @@ -26,7 +30,7 @@ export function recordWorkspaceChanges( const update = record(kindRecord.update ?? change.update); const previousPath = text(update.move_path, text(update.movePath)) || null; - const operation = previousPath + const operation: ParsedCodexTurnDiffFile["operation"] = previousPath ? "rename" : kindText.toLowerCase().includes("add") ? "create" @@ -58,9 +62,6 @@ export function recordWorkspaceChanges( ]; }); if (files.length === 0) return; - const unknown = files.some( - (file) => file.additions === null || file.deletions === null, - ); const payload = { schema: "paperclip.workspace.diff.v1", changeSetId: `${turnId}:workspace`, @@ -70,15 +71,7 @@ export function recordWorkspaceChanges( source: "harness_reported", complete, files, - totals: { - files: files.length, - additions: unknown - ? null - : files.reduce((sum, file) => sum + Number(file.additions), 0), - deletions: unknown - ? null - : files.reduce((sum, file) => sum + Number(file.deletions), 0), - }, + totals: summarizeCodexTurnDiff(files), patchArtifactRef: null, }; state.workspaceChangesByTurn.set(turnId, payload); @@ -166,9 +159,6 @@ function recordWorkspaceSnapshot( JSON.stringify(record(previous).files) === JSON.stringify(files) && record(previous).patchArtifactRef === patchArtifactRef ) return; - const unknown = files.some( - (file) => file.additions === null || file.deletions === null, - ); const priorRevision = Number(record(previous).revision ?? 0); const incomingRevision = typeof requestedRevision === "number" && @@ -183,15 +173,7 @@ function recordWorkspaceSnapshot( source: "harness_reported", complete: false, files, - totals: { - files: files.length, - additions: unknown - ? null - : files.reduce((sum, file) => sum + Number(file.additions), 0), - deletions: unknown - ? null - : files.reduce((sum, file) => sum + Number(file.deletions), 0), - }, + totals: summarizeCodexTurnDiff(files), patchArtifactRef, }; state.workspaceChangesByTurn.set(turnId, payload); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-turn-diff.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-turn-diff.test.ts index be19c053b6..c7cc22e166 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-turn-diff.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-turn-diff.test.ts @@ -1,8 +1,19 @@ import { describe, expect, it } from "vitest"; -import { parseCodexTurnDiff } from "./codex-turn-diff.js"; +import { parseCodexTurnDiff, summarizeCodexTurnDiff } from "./codex-turn-diff.js"; describe("Codex turn diff parser", () => { + it("summarizes parsed files for work-product metadata", () => { + expect(summarizeCodexTurnDiff([ + { path: "a.ts", operation: "modify", previousPath: null, additions: 4, deletions: 2, binary: false, diff: "patch" }, + { path: "b.ts", operation: "create", previousPath: null, additions: 3, deletions: 0, binary: false, diff: "patch" }, + ])).toEqual({ files: 2, additions: 7, deletions: 2 }); + + expect(summarizeCodexTurnDiff([ + { path: "logo.png", operation: "modify", previousPath: null, additions: null, deletions: null, binary: true, diff: null }, + ])).toEqual({ files: 1, additions: null, deletions: null }); + }); + it("parses a complete snapshot with bounded file statistics", () => { expect(parseCodexTurnDiff([ "diff --git a/src/old.ts b/src/new.ts", diff --git a/packages/paperclip-runner/src/drivers/codex/codex-turn-diff.ts b/packages/paperclip-runner/src/drivers/codex/codex-turn-diff.ts index c92bf713f0..279acd408e 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-turn-diff.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-turn-diff.ts @@ -8,6 +8,23 @@ export interface ParsedCodexTurnDiffFile { diff: string | null; } +export interface CodexTurnDiffSummary { + files: number; + additions: number | null; + deletions: number | null; +} + +export function summarizeCodexTurnDiff( + files: readonly Pick[], +): CodexTurnDiffSummary { + const unknown = files.some((file) => file.additions === null || file.deletions === null); + return { + files: files.length, + additions: unknown ? null : files.reduce((sum, file) => sum + (file.additions ?? 0), 0), + deletions: unknown ? null : files.reduce((sum, file) => sum + (file.deletions ?? 0), 0), + }; +} + const MAX_TURN_DIFF_FILES = 2_000; const MAX_TURN_DIFF_CHARS_PER_FILE = 256 * 1024; diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index 28bc775425..acc4c4f190 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -20,7 +20,7 @@ export { OpenCodeServerDriver, type OpenCodeServerDriverOptions, } from "./drivers/opencode/opencode-server-driver.js"; -export { parseCodexTurnDiff } from "./drivers/codex/codex-turn-diff.js"; +export { parseCodexTurnDiff, summarizeCodexTurnDiff } from "./drivers/codex/codex-turn-diff.js"; export * from "./native-session-runtime.js"; export { DurablePrpControlPlane, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e39c5dbcf4..bd898d4f16 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1049,6 +1049,9 @@ export type { DocumentTextRange, UpdateDocumentAnnotationThreadRequest, AttachmentArtifactWorkProductMetadata, + PullRequestWorkProductState, + PullRequestWorkProductMetadata, + CommitWorkProductMetadata, ExternalObject, ExternalObjectMention, ExternalObjectMentionGroup, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 3fa7eb50d2..1655b79236 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -616,6 +616,9 @@ export type { IssueWorkProductStatus, IssueWorkProductReviewState, AttachmentArtifactWorkProductMetadata, + PullRequestWorkProductState, + PullRequestWorkProductMetadata, + CommitWorkProductMetadata, } from "./work-product.js"; export type { CompanyArtifact, diff --git a/packages/shared/src/types/work-product.ts b/packages/shared/src/types/work-product.ts index 8193de44d9..26f9129f60 100644 --- a/packages/shared/src/types/work-product.ts +++ b/packages/shared/src/types/work-product.ts @@ -64,3 +64,26 @@ export interface AttachmentArtifactWorkProductMetadata { downloadPath: string; originalFilename?: string | null; } + +export type PullRequestWorkProductState = "open" | "draft" | "merged" | "closed"; + +export interface PullRequestWorkProductMetadata { + repo: string; + number: number; + baseRef: string; + headRef: string; + additions: number; + deletions: number; + changedFiles: number; + state: PullRequestWorkProductState; + draft: boolean; +} + +export interface CommitWorkProductMetadata { + repo: string; + sha: string; + branch: string; + additions: number; + deletions: number; + changedFiles: number; +} diff --git a/scripts/__tests__/storybook-viewport-config.test.mjs b/scripts/__tests__/storybook-viewport-config.test.mjs new file mode 100644 index 0000000000..ff8042241e --- /dev/null +++ b/scripts/__tests__/storybook-viewport-config.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { extname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const storybookRoot = fileURLToPath(new URL("../../ui/storybook/", import.meta.url)); + +function sourceFiles(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return [".js", ".jsx", ".ts", ".tsx"].includes(extname(entry.name)) ? [path] : []; + }); +} + +test("Storybook viewport config uses the Storybook 10 parameter shape", () => { + const legacyViewportKeys = []; + + for (const path of sourceFiles(storybookRoot)) { + const source = readFileSync(path, "utf8"); + if (/\b(?:defaultViewport|viewports)\s*:/.test(source)) legacyViewportKeys.push(path); + } + + assert.deepEqual( + legacyViewportKeys, + [], + "Storybook 10 uses globals.viewport.value for selection and parameters.viewport.options for definitions", + ); +}); diff --git a/server/src/__tests__/github-commit-details.test.ts b/server/src/__tests__/github-commit-details.test.ts new file mode 100644 index 0000000000..1e09e7839e --- /dev/null +++ b/server/src/__tests__/github-commit-details.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createGitHubCommitDiffDetailsResolver, + extractGitHubCommitReference, +} from "../services/github-commit-details.js"; + +describe("GitHub commit details", () => { + it("extracts commit references from URLs and metadata shorthand", () => { + expect(extractGitHubCommitReference([ + "https://github.com/paperclipai/paperclip/commit/9c12ae7b41e5", + ])).toEqual({ + host: "github.com", + owner: "paperclipai", + repo: "paperclip", + sha: "9c12ae7b41e5", + }); + expect(extractGitHubCommitReference(["paperclipai/paperclip@9c12ae7b41e5"])?.sha) + .toBe("9c12ae7b41e5"); + }); + + it("resolves stats and counts files across GitHub response pages", async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ + stats: { additions: 18, deletions: 5 }, + files: [{ filename: "a.ts" }, { filename: "b.ts" }], + }), { + status: 200, + headers: { link: '; rel="next"' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + stats: { additions: 18, deletions: 5 }, + files: [{ filename: "c.ts" }], + }), { status: 200 })); + const resolve = createGitHubCommitDiffDetailsResolver({} as any, { + fetch, + tokenProvider: async () => "secret-token", + }); + + await expect(resolve("company-1", { + host: "github.com", + owner: "acme", + repo: "app", + sha: "abc1234", + })).resolves.toEqual({ additions: 18, deletions: 5, changedFiles: 3 }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls.map(([url]) => url)).toEqual([ + "https://api.github.com/repos/acme/app/commits/abc1234?per_page=100&page=1", + "https://api.github.com/repos/acme/app/commits/abc1234?per_page=100&page=2", + ]); + expect(fetch.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ + headers: expect.objectContaining({ authorization: "Bearer secret-token" }), + })); + }); + + it("returns null for unavailable or malformed commit details", async () => { + const resolve = createGitHubCommitDiffDetailsResolver({} as any, { + fetch: async () => new Response(JSON.stringify({ files: [] }), { status: 200 }), + tokenProvider: null, + }); + await expect(resolve("company-1", { + host: "github.com", + owner: "acme", + repo: "app", + sha: "abc1234", + })).resolves.toBeNull(); + }); +}); diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 05ee67b55b..e966b7bff6 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -66,6 +66,8 @@ const mockDocumentService = vi.hoisted(() => ({ const mockWorkProductService = vi.hoisted(() => ({ createForIssue: vi.fn(), getById: vi.fn(), + latestRunDiffSummary: vi.fn(), + resolveCommitDiffSummary: vi.fn(), remove: vi.fn(), update: vi.fn(), })); @@ -209,6 +211,15 @@ function registerRouteMocks() { companyService: () => mockCompanyService, documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => mockDocumentService, + enrichWorkProductMetadataWithDiff: ( + metadata: Record | null | undefined, + summary: { additions: number | null; deletions: number | null; changedFiles: number } | null, + ) => summary ? { + ...(metadata ?? {}), + ...(summary.additions === null ? {} : { additions: summary.additions }), + ...(summary.deletions === null ? {} : { deletions: summary.deletions }), + changedFiles: summary.changedFiles, + } : metadata ?? null, executionWorkspaceService: () => ({}), feedbackService: () => ({ listIssueVotesForUser: vi.fn(async () => []), @@ -554,6 +565,10 @@ describe("agent issue mutation checkout ownership", () => { mockObserveCrossIssueInfluence.mockResolvedValue(null); mockDocumentService.upsertIssueDocument.mockReset(); mockWorkProductService.createForIssue.mockReset(); + mockWorkProductService.latestRunDiffSummary.mockReset(); + mockWorkProductService.latestRunDiffSummary.mockResolvedValue(null); + mockWorkProductService.resolveCommitDiffSummary.mockReset(); + mockWorkProductService.resolveCommitDiffSummary.mockResolvedValue(null); mockExternalObjectService.getIssueSummaries.mockClear(); mockExternalObjectService.getIssueSummary.mockClear(); mockExternalObjectService.getProjectSummary.mockClear(); @@ -1080,6 +1095,66 @@ describe("agent issue mutation checkout ownership", () => { ); }); + it("adds the authenticated run diff summary to PR work products", async () => { + mockWorkProductService.latestRunDiffSummary.mockResolvedValue({ + additions: 17, + deletions: 5, + changedFiles: 3, + }); + const app = await createApp(ownerActor()); + + await request(app).post(`/api/issues/${issueId}/work-products`).send({ + type: "pull_request", + provider: "github", + title: "PR 42", + url: "https://github.com/paperclipai/paperclip/pull/42", + metadata: { repo: "paperclipai/paperclip", number: 42 }, + }).expect(201); + + expect(mockWorkProductService.latestRunDiffSummary).toHaveBeenCalledWith(ownerRunId); + expect(mockWorkProductService.createForIssue).toHaveBeenCalledWith( + issueId, + companyId, + expect.objectContaining({ + createdByRunId: ownerRunId, + metadata: expect.objectContaining({ + additions: 17, + deletions: 5, + changedFiles: 3, + }), + }), + ); + }); + + it("falls back to GitHub commit stats when the authenticated run has no diff event", async () => { + mockWorkProductService.resolveCommitDiffSummary.mockResolvedValue({ + additions: 11, + deletions: 3, + changedFiles: 2, + }); + const app = await createApp(ownerActor()); + + await request(app).post(`/api/issues/${issueId}/work-products`).send({ + type: "commit", + provider: "github", + title: "Commit 9c12ae7", + url: "https://github.com/paperclipai/paperclip/commit/9c12ae7b41e5", + metadata: { repo: "paperclipai/paperclip", sha: "9c12ae7b41e5" }, + }).expect(201); + + expect(mockWorkProductService.resolveCommitDiffSummary).toHaveBeenCalledWith( + companyId, + expect.objectContaining({ type: "commit", provider: "github" }), + ); + expect(mockWorkProductService.createForIssue).toHaveBeenCalledWith( + issueId, + companyId, + expect.objectContaining({ + metadata: expect.objectContaining({ additions: 11, deletions: 3, changedFiles: 2 }), + }), + ); + }); + it("rejects agent-created work products with a forged run id", async () => { const app = await createApp(ownerActor()); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 168ed901f0..e90b0f1a4c 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -21,6 +21,7 @@ import { issueReadStates, issueRelations, issueThreadInteractions, + issueWorkProducts, issues, projectWorkspaces, projects, @@ -65,6 +66,85 @@ describe("issue list limit helpers", () => { }); }); +describeEmbeddedPostgres("issueService run attachment artifacts", () => { + let tempDb: Awaited> | null = null; + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("registers a run-produced attachment as an attachment-backed artifact work product", async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-run-attachment-artifact-"); + const db = createDb(tempDb.connectionString); + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const runId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "ART", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "ArtifactAgent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Artifact registration", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + }); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running" }); + + const attachment = await issueService(db).createAttachment({ + issueId, + issueCommentId: null, + provider: "local_disk", + objectKey: "issues/artifact/screenshot.png", + contentType: "image/png", + byteSize: 128, + sha256: "a".repeat(64), + originalFilename: "screenshot.png", + createdByAgentId: agentId, + createdByRunId: runId, + }); + + const artifact = await db + .select() + .from(issueWorkProducts) + .where(eq(issueWorkProducts.externalId, attachment.id)) + .then((rows) => rows[0]); + expect(artifact).toMatchObject({ + companyId, + issueId, + type: "artifact", + provider: "paperclip", + title: "screenshot.png", + createdByRunId: runId, + metadata: { + attachmentId: attachment.id, + contentType: "image/png", + byteSize: 128, + contentPath: `/api/attachments/${attachment.id}/content`, + openPath: `/api/attachments/${attachment.id}/content`, + downloadPath: `/api/attachments/${attachment.id}/content?download=1`, + originalFilename: "screenshot.png", + }, + }); + }, 20_000); +}); + describe("deriveIssueCommentRunLogAttribution", () => { it("recovers agent attribution from run logs that printed the posted comment id", () => { const commentId = randomUUID(); diff --git a/server/src/__tests__/work-product-runtime-reconciliation.test.ts b/server/src/__tests__/work-product-runtime-reconciliation.test.ts index 345d7fcf5c..8bf7d9a447 100644 --- a/server/src/__tests__/work-product-runtime-reconciliation.test.ts +++ b/server/src/__tests__/work-product-runtime-reconciliation.test.ts @@ -39,23 +39,26 @@ describe("reconcileRuntimeServiceWorkProducts", () => { expect(reconciled!.healthStatus).toBe("healthy"); }); - it("marks a stopped or unhealthy runtime unhealthy instead of advertising it", () => { + it("closes a stopped runtime but keeps a running unhealthy runtime active", () => { const [stopped] = reconcileRuntimeServiceWorkProducts( [workProduct()], [{ id: "runtime-1", url: "https://workspace.example.ts.net:42013/", status: "stopped", healthStatus: "healthy" }], ); + expect(stopped!.status).toBe("closed"); expect(stopped!.healthStatus).toBe("unhealthy"); const [unhealthy] = reconcileRuntimeServiceWorkProducts( [workProduct()], [{ id: "runtime-1", url: "https://workspace.example.ts.net:42013/", status: "running", healthStatus: "unhealthy" }], ); + expect(unhealthy!.status).toBe("open"); expect(unhealthy!.healthStatus).toBe("unhealthy"); }); - it("keeps the recorded URL when the runtime row is gone, but stops calling it healthy", () => { + it("keeps the recorded URL when the runtime row is gone and closes the work product", () => { const [reconciled] = reconcileRuntimeServiceWorkProducts([workProduct()], []); expect(reconciled!.url).toBe("https://workspace.example.ts.net:42013/"); + expect(reconciled!.status).toBe("closed"); expect(reconciled!.healthStatus).toBe("unhealthy"); }); diff --git a/server/src/__tests__/work-products.test.ts b/server/src/__tests__/work-products.test.ts index 93e4a0fc89..c7f80f5394 100644 --- a/server/src/__tests__/work-products.test.ts +++ b/server/src/__tests__/work-products.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import { workProductService } from "../services/work-products.ts"; +import { + enrichWorkProductMetadataWithDiff, + refreshPullRequestWorkProductMetadata, + workProductDiffSummaryFromEventPayload, + workProductService, +} from "../services/work-products.ts"; function createWorkProductRow(overrides: Partial> = {}) { const now = new Date("2026-03-17T00:00:00.000Z"); @@ -29,6 +34,99 @@ function createWorkProductRow(overrides: Partial> = {}) } describe("workProductService", () => { + it("extracts runner totals and enriches work-product metadata", () => { + const summary = workProductDiffSummaryFromEventPayload({ + schema: "paperclip.workspace.diff.v1", + totals: { files: 3, additions: 17, deletions: 5 }, + }); + + expect(summary).toEqual({ changedFiles: 3, additions: 17, deletions: 5 }); + expect(enrichWorkProductMetadataWithDiff({ repo: "paperclipai/paperclip" }, summary)).toEqual({ + repo: "paperclipai/paperclip", + changedFiles: 3, + additions: 17, + deletions: 5, + }); + + const prpEvent = { + schema: "paperclip.prp.event.v1", + payload: { totals: { files: 2, additions: 9, deletions: 4 } }, + }; + expect(workProductDiffSummaryFromEventPayload({ prpEvent })).toEqual({ + changedFiles: 2, + additions: 9, + deletions: 4, + }); + expect(workProductDiffSummaryFromEventPayload(prpEvent)).toEqual({ + changedFiles: 2, + additions: 9, + deletions: 4, + }); + }); + + it("refreshes pull-request state without mutating the stored work product", async () => { + const product = createWorkProductRow({ + companyId: "company-1", + url: "https://github.com/paperclipai/paperclip/pull/42", + metadata: { + repo: "paperclipai/paperclip", + number: 42, + additions: 17, + deletions: 5, + changedFiles: 3, + state: "open", + draft: false, + }, + }) as any; + const resolve = vi.fn(async () => ({ + state: "open" as const, + workProductState: "merged" as const, + draft: false, + headRef: "feature/rich-cards", + headSha: "abc123", + baseRef: "master", + additions: 20, + deletions: 7, + changedFiles: 4, + })); + + const [refreshed] = await refreshPullRequestWorkProductMetadata([product], resolve); + + expect(resolve).toHaveBeenCalledWith("company-1", { + host: "github.com", + owner: "paperclipai", + repo: "paperclip", + number: 42, + }); + expect(refreshed?.metadata).toMatchObject({ + state: "merged", + draft: false, + baseRef: "master", + headRef: "feature/rich-cards", + additions: 20, + deletions: 7, + changedFiles: 4, + }); + expect(product.metadata.state).toBe("open"); + }); + + it("resolves GitHub commit stats when runner diff events are unavailable", async () => { + const resolveCommitDetails = vi.fn(async () => ({ additions: 13, deletions: 2, changedFiles: 3 })); + const svc = workProductService({} as any, { resolveCommitDetails }); + + await expect(svc.resolveCommitDiffSummary("company-1", { + provider: "github", + url: "https://github.com/paperclipai/paperclip/commit/9c12ae7b41e5", + metadata: null, + })).resolves.toEqual({ additions: 13, deletions: 2, changedFiles: 3 }); + expect(resolveCommitDetails).toHaveBeenCalledWith("company-1", { + host: "github.com", + owner: "paperclipai", + repo: "paperclip", + sha: "9c12ae7b41e5", + }); + }); + it("uses a transaction when creating a new primary work product", async () => { const updatedWhere = vi.fn(async () => undefined); const updateSet = vi.fn(() => ({ where: updatedWhere })); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 1928360045..5d3a61eb93 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -122,6 +122,7 @@ import { companyService, companySearchService, executionWorkspaceService, + enrichWorkProductMetadataWithDiff, goalService, heartbeatService, issueApprovalService, @@ -7636,7 +7637,9 @@ export function issueRoutes( const issue = await getAccessibleResource(req, res, getIssueById(req, id), "Issue not found"); if (!issue) return; if (!(await assertIssueReadAllowed(req, res, issue))) return; - const workProducts = await workProductsSvc.listForIssue(issue.id); + const workProducts = await workProductsSvc.listForIssue(issue.id, { + refreshPullRequests: req.query.refreshPullRequests === "true", + }); res.json(workProducts); }); @@ -8388,13 +8391,42 @@ export function issueRoutes( const createdByRunId = await resolveWorkProductCreatedByRunId(req, res, issue.companyId, req.body, "create"); if (createdByRunId === undefined) return; createInput.createdByRunId = createdByRunId; + if (createdByRunId && (createInput.type === "pull_request" || createInput.type === "commit")) { + const runDiffSummary = await workProductsSvc.latestRunDiffSummary(createdByRunId); + createInput.metadata = enrichWorkProductMetadataWithDiff( + createInput.metadata, + runDiffSummary ?? (createInput.type === "commit" + ? await workProductsSvc.resolveCommitDiffSummary(issue.companyId, createInput) + : null), + ); + } if (requiresPaperclipAttachmentMetadata(createInput)) { createInput.metadata = await canonicalizePaperclipArtifactMetadata({ issue, metadata: req.body.metadata ?? null, }); } - const product = await workProductsSvc.createForIssue(issue.id, issue.companyId, createInput); + const attachmentId = createInput.type === "artifact" && createInput.provider === "paperclip" + ? (createInput.metadata as Record | null)?.attachmentId + : null; + const existingRunAttachmentProduct = typeof attachmentId === "string" && createdByRunId + ? await db + .select({ id: issueWorkProducts.id }) + .from(issueWorkProducts) + .where(and( + eq(issueWorkProducts.companyId, issue.companyId), + eq(issueWorkProducts.issueId, issue.id), + eq(issueWorkProducts.type, "artifact"), + eq(issueWorkProducts.provider, "paperclip"), + eq(issueWorkProducts.externalId, attachmentId), + eq(issueWorkProducts.createdByRunId, createdByRunId), + )) + .limit(1) + .then((rows) => rows[0] ?? null) + : null; + const product = existingRunAttachmentProduct + ? await workProductsSvc.update(existingRunAttachmentProduct.id, createInput) + : await workProductsSvc.createForIssue(issue.id, issue.companyId, createInput); if (!product) { res.status(422).json({ error: "Invalid work product payload" }); return; @@ -14134,6 +14166,7 @@ export function issueRoutes( originalFilename: stored.originalFilename, createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, + createdByRunId: actor.runId, }); await logActivity(db, { @@ -14154,7 +14187,28 @@ export function issueRoutes( }, }); - res.status(201).json(withContentPath(attachment)); + if (attachment.artifactWorkProductId) { + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + agentApiKeyId: actor.agentApiKeyId, + action: "issue.work_product_created", + entityType: "issue", + entityId: issueId, + details: { + workProductId: attachment.artifactWorkProductId, + type: "artifact", + provider: "paperclip", + source: "run_attachment_upload", + }, + }); + } + + const { artifactWorkProductId: _artifactWorkProductId, ...attachmentResponse } = attachment; + res.status(201).json(withContentPath(attachmentResponse)); }); router.get("/attachments/:attachmentId/content", async (req, res, next) => { diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 61aa6bf713..90bb0ba9a2 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -2407,7 +2407,10 @@ registry.registerPath({ path: "/api/issues/{id}/work-products", tags: ["issues"], summary: "List issue work products", - request: { params: z.object({ id: z.string() }) }, + request: { + params: z.object({ id: z.string() }), + query: z.object({ refreshPullRequests: z.enum(["true"]).optional() }), + }, responses: { 200: r.ok(), 401: r.unauthorized }, }); diff --git a/server/src/services/github-commit-details.ts b/server/src/services/github-commit-details.ts new file mode 100644 index 0000000000..b5e64b55e7 --- /dev/null +++ b/server/src/services/github-commit-details.ts @@ -0,0 +1,131 @@ +import type { Db } from "@paperclipai/db"; +import { DEFAULT_GITHUB_TOKEN_SECRET_NAMES } from "./git-credentials.js"; +import { ghFetch, gitHubApiBase } from "./github-fetch.js"; +import { secretService } from "./secrets.js"; + +type FetchLike = (url: string, init?: RequestInit) => Promise; + +export type GitHubCommitReference = { + host: "github.com"; + owner: string; + repo: string; + sha: string; +}; + +export type GitHubCommitDiffDetails = { + additions: number; + deletions: number; + changedFiles: number; +}; + +export type GitHubCommitDiffDetailsResolver = ( + companyId: string, + reference: GitHubCommitReference, +) => Promise; + +export interface GitHubCommitDetailsResolverOptions { + fetch?: FetchLike; + tokenProvider?: (companyId: string) => Promise | string | null; + secretNames?: readonly string[]; +} + +const GITHUB_COMMIT_URL_PATTERN = /https:\/\/(?:www\.)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/commit\/([0-9a-f]{7,64})\b/i; +const GITHUB_COMMIT_SHORTHAND_PATTERN = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)@([0-9a-f]{7,64})$/i; + +function nonNegativeInteger(value: unknown): number | null { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +export function extractGitHubCommitReference(values: readonly unknown[]): GitHubCommitReference | null { + for (const value of values) { + if (typeof value !== "string" || value.length === 0) continue; + const match = GITHUB_COMMIT_URL_PATTERN.exec(value) ?? GITHUB_COMMIT_SHORTHAND_PATTERN.exec(value); + if (!match) continue; + return { + host: "github.com", + owner: match[1]!, + repo: match[2]!, + sha: match[3]!, + }; + } + return null; +} + +async function defaultTokenProvider(db: Db, companyId: string, secretNames: readonly string[]) { + const secrets = secretService(db); + for (const secretName of secretNames) { + const secret = await secrets.getByName(companyId, secretName); + if (!secret) continue; + const token = await secrets.resolveSecretValue(companyId, secret.id, "latest"); + const trimmed = token.trim(); + if (trimmed) return trimmed; + } + return null; +} + +function hasNextPage(response: Response): boolean { + const link = response.headers.get("link"); + if (!link) return false; + for (const part of link.split(",")) { + const match = /^\s*<[^>]+>;\s*rel="([^"]+)"\s*$/.exec(part); + if (match?.[1]?.split(/\s+/).includes("next")) return true; + } + return false; +} + +export function createGitHubCommitDiffDetailsResolver( + db: Db, + opts: GitHubCommitDetailsResolverOptions = {}, +): GitHubCommitDiffDetailsResolver { + const fetchImpl = opts.fetch ?? ghFetch; + const secretNames = opts.secretNames ?? DEFAULT_GITHUB_TOKEN_SECRET_NAMES; + const tokenProvider = Object.prototype.hasOwnProperty.call(opts, "tokenProvider") && opts.tokenProvider !== undefined + ? opts.tokenProvider + : ((companyId: string) => defaultTokenProvider(db, companyId, secretNames)); + + return async (companyId, reference) => { + try { + const token = (typeof tokenProvider === "function" ? await tokenProvider(companyId) : tokenProvider)?.trim() || null; + const headers: Record = { + accept: "application/vnd.github+json", + "user-agent": "paperclip-work-product-resolver", + "x-github-api-version": "2022-11-28", + }; + if (token) headers.authorization = `Bearer ${token}`; + + const commitUrl = `${gitHubApiBase(reference.host)}/repos/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repo)}/commits/${encodeURIComponent(reference.sha)}`; + let additions: number | null = null; + let deletions: number | null = null; + let changedFiles = 0; + let pages = 0; + let morePages = true; + while (morePages && pages < 30) { + const url = `${commitUrl}?per_page=100&page=${pages + 1}`; + const response = await fetchImpl(url, { headers }); + if (!response.ok) return null; + const body = record(await response.json()); + if (!body) return null; + if (pages === 0) { + const stats = record(body.stats); + additions = nonNegativeInteger(stats?.additions); + deletions = nonNegativeInteger(stats?.deletions); + if (additions === null || deletions === null) return null; + } + if (!Array.isArray(body.files)) return null; + changedFiles += body.files.length; + morePages = hasNextPage(response); + pages += 1; + } + if (morePages || additions === null || deletions === null) return null; + return { additions, deletions, changedFiles }; + } catch { + return null; + } + }; +} diff --git a/server/src/services/github-external-object-provider.ts b/server/src/services/github-external-object-provider.ts index 734a093447..928e8501f5 100644 --- a/server/src/services/github-external-object-provider.ts +++ b/server/src/services/github-external-object-provider.ts @@ -47,6 +47,10 @@ function asBoolean(value: unknown) { return typeof value === "boolean" ? value : null; } +function asNonNegativeInteger(value: unknown) { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null; +} + function asNestedString(record: Record, key: string, nestedKey: string) { const nested = asRecord(record[key]); return nested ? asString(nested[nestedKey]) : null; @@ -198,6 +202,9 @@ function pullRequestSnapshot(identity: GitHubObjectIdentity, body: Record { const issue = await db .select({ id: issues.id, companyId: issues.companyId }) @@ -9136,6 +9138,46 @@ export function issueService(db: Db) { }) .returning(); + const registeredRunId = input.createdByRunId && isUuidLike(input.createdByRunId) + ? await tx + .select({ id: heartbeatRuns.id }) + .from(heartbeatRuns) + .where(and( + eq(heartbeatRuns.id, input.createdByRunId), + eq(heartbeatRuns.companyId, issue.companyId), + ...(input.createdByAgentId ? [eq(heartbeatRuns.agentId, input.createdByAgentId)] : []), + )) + .then((rows) => rows[0]?.id ?? null) + : null; + const contentPath = `/api/attachments/${attachment.id}/content`; + const [artifactWorkProduct] = registeredRunId + ? await tx + .insert(issueWorkProducts) + .values({ + companyId: issue.companyId, + issueId: issue.id, + type: "artifact", + provider: "paperclip", + externalId: attachment.id, + title: asset.originalFilename ?? "Attachment", + status: "active", + reviewState: "none", + isPrimary: false, + healthStatus: "unknown", + metadata: { + attachmentId: attachment.id, + contentType: asset.contentType, + byteSize: asset.byteSize, + contentPath, + openPath: contentPath, + downloadPath: `${contentPath}?download=1`, + originalFilename: asset.originalFilename, + }, + createdByRunId: registeredRunId, + }) + .returning({ id: issueWorkProducts.id }) + : []; + return { id: attachment.id, companyId: attachment.companyId, @@ -9152,6 +9194,7 @@ export function issueService(db: Db) { createdByUserId: asset.createdByUserId, createdAt: attachment.createdAt, updatedAt: attachment.updatedAt, + artifactWorkProductId: artifactWorkProduct?.id ?? null, }; }); }, diff --git a/server/src/services/work-products.ts b/server/src/services/work-products.ts index 8266a5174a..763e57de77 100644 --- a/server/src/services/work-products.ts +++ b/server/src/services/work-products.ts @@ -1,12 +1,104 @@ import { and, desc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { issueWorkProducts, workspaceRuntimeServices } from "@paperclipai/db"; +import { heartbeatRunEvents, issueWorkProducts, workspaceRuntimeServices } from "@paperclipai/db"; import type { IssueWorkProduct } from "@paperclipai/shared"; import { insertRowsInChunks } from "./batch-insert.js"; +import { + createPullRequestMergeDetailsResolver, + extractGitHubPullRequestReferences, + type PullRequestMergeDetailsResolver, +} from "./github-pull-request-merge.js"; +import { + createGitHubCommitDiffDetailsResolver, + extractGitHubCommitReference, + type GitHubCommitDiffDetailsResolver, +} from "./github-commit-details.js"; import type { ImportIssueWorkProductRow } from "./import-write-types.js"; type IssueWorkProductRow = typeof issueWorkProducts.$inferSelect; +export interface WorkProductDiffSummary { + additions: number | null; + deletions: number | null; + changedFiles: number; +} + +function nonNegativeInteger(value: unknown): number | null { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +export function workProductDiffSummaryFromEventPayload(payload: unknown): WorkProductDiffSummary | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const outer = payload as Record; + const wrappedEvent = outer.prpEvent && typeof outer.prpEvent === "object" && !Array.isArray(outer.prpEvent) + ? outer.prpEvent as Record + : null; + const eventPayload = wrappedEvent?.payload ?? (outer.schema === "paperclip.prp.event.v1" ? outer.payload : outer); + if (!eventPayload || typeof eventPayload !== "object" || Array.isArray(eventPayload)) return null; + const totals = (eventPayload as Record).totals; + if (!totals || typeof totals !== "object" || Array.isArray(totals)) return null; + const values = totals as Record; + const changedFiles = nonNegativeInteger(values.files); + if (changedFiles === null) return null; + return { + additions: values.additions === null ? null : nonNegativeInteger(values.additions), + deletions: values.deletions === null ? null : nonNegativeInteger(values.deletions), + changedFiles, + }; +} + +export function enrichWorkProductMetadataWithDiff( + metadata: Record | null | undefined, + summary: WorkProductDiffSummary | null, +): Record | null { + if (!summary) return metadata ?? null; + return { + ...(metadata ?? {}), + ...(summary.additions === null ? {} : { additions: summary.additions }), + ...(summary.deletions === null ? {} : { deletions: summary.deletions }), + changedFiles: summary.changedFiles, + }; +} + +export async function refreshPullRequestWorkProductMetadata( + products: IssueWorkProduct[], + resolvePullRequestDetails: PullRequestMergeDetailsResolver, +): Promise { + return await Promise.all(products.map(async (product) => { + if (product.type !== "pull_request") return product; + const metadata = product.metadata ?? {}; + const repo = typeof metadata.repo === "string" ? metadata.repo : null; + const number = nonNegativeInteger(metadata.number); + const references = extractGitHubPullRequestReferences([ + product.url, + repo && number ? `${repo}#${number}` : null, + ]); + const reference = references[0]; + if (!reference) return product; + try { + const details = await resolvePullRequestDetails(product.companyId, reference); + if (!details.workProductState) return product; + return { + ...product, + metadata: { + ...metadata, + repo: repo ?? `${reference.owner}/${reference.repo}`, + number: number ?? reference.number, + ...(details.baseRef ? { baseRef: details.baseRef } : {}), + ...(details.headRef ? { headRef: details.headRef } : {}), + ...(nonNegativeInteger(details.additions) === null ? {} : { additions: details.additions }), + ...(nonNegativeInteger(details.deletions) === null ? {} : { deletions: details.deletions }), + ...(nonNegativeInteger(details.changedFiles) === null ? {} : { changedFiles: details.changedFiles }), + state: details.workProductState, + draft: details.workProductState === "draft" || details.draft === true, + }, + }; + } catch { + return product; + } + })); +} + function toIssueWorkProduct(row: IssueWorkProductRow): IssueWorkProduct { return { id: row.id, @@ -43,8 +135,8 @@ function toIssueWorkProduct(row: IssueWorkProductRow): IssueWorkProduct { * the authoritative publication record, so it wins over the stored copy. * * Read-path only and deliberately non-destructive: a work product whose runtime - * row is gone keeps its recorded URL and is reported unhealthy rather than - * silently blanked, so the history of what was published survives. + * row is gone keeps its recorded URL and is reported closed rather than silently + * blanked, so the history of what was published survives. */ export function reconcileRuntimeServiceWorkProducts( products: IssueWorkProduct[], @@ -61,19 +153,30 @@ export function reconcileRuntimeServiceWorkProducts( if (product.type !== "runtime_service" || !product.runtimeServiceId) return product; const live = liveById.get(product.runtimeServiceId); if (!live) { - return product.healthStatus === "unhealthy" ? product : { ...product, healthStatus: "unhealthy" }; + return product.status === "closed" && product.healthStatus === "unhealthy" + ? product + : { ...product, status: "closed", healthStatus: "unhealthy" }; } const isServing = live.status === "running" && live.healthStatus === "healthy"; const url = live.url ?? product.url; + const status = live.status === "running" ? product.status : "closed"; const healthStatus: IssueWorkProduct["healthStatus"] = isServing ? "healthy" : "unhealthy"; - if (product.url === url && product.healthStatus === healthStatus) return product; - return { ...product, url, healthStatus }; + if (product.url === url && product.status === status && product.healthStatus === healthStatus) return product; + return { ...product, url, status, healthStatus }; }); } -export function workProductService(db: Db) { +export function workProductService( + db: Db, + opts: { + resolvePullRequestDetails?: PullRequestMergeDetailsResolver; + resolveCommitDetails?: GitHubCommitDiffDetailsResolver; + } = {}, +) { + const resolvePullRequestDetails = opts.resolvePullRequestDetails ?? createPullRequestMergeDetailsResolver(db); + const resolveCommitDetails = opts.resolveCommitDetails ?? createGitHubCommitDiffDetailsResolver(db); return { - listForIssue: async (issueId: string) => { + listForIssue: async (issueId: string, options: { refreshPullRequests?: boolean } = {}) => { const rows = await db .select() .from(issueWorkProducts) @@ -83,17 +186,52 @@ export function workProductService(db: Db) { const runtimeServiceIds = products .map((product) => (product.type === "runtime_service" ? product.runtimeServiceId : null)) .filter((value): value is string => Boolean(value)); - if (runtimeServiceIds.length === 0) return products; - const liveRuntimeServices = await db - .select({ - id: workspaceRuntimeServices.id, - url: workspaceRuntimeServices.url, - status: workspaceRuntimeServices.status, - healthStatus: workspaceRuntimeServices.healthStatus, - }) - .from(workspaceRuntimeServices) - .where(inArray(workspaceRuntimeServices.id, [...new Set(runtimeServiceIds)])); - return reconcileRuntimeServiceWorkProducts(products, liveRuntimeServices); + const reconciled = runtimeServiceIds.length === 0 + ? products + : reconcileRuntimeServiceWorkProducts(products, await db + .select({ + id: workspaceRuntimeServices.id, + url: workspaceRuntimeServices.url, + status: workspaceRuntimeServices.status, + healthStatus: workspaceRuntimeServices.healthStatus, + }) + .from(workspaceRuntimeServices) + .where(inArray(workspaceRuntimeServices.id, [...new Set(runtimeServiceIds)]))); + return options.refreshPullRequests + ? refreshPullRequestWorkProductMetadata(reconciled, resolvePullRequestDetails) + : reconciled; + }, + + latestRunDiffSummary: async (runId: string): Promise => { + const rows = await db + .select({ payload: heartbeatRunEvents.payload }) + .from(heartbeatRunEvents) + .where(and( + eq(heartbeatRunEvents.runId, runId), + inArray(heartbeatRunEvents.eventType, ["workspace.change.updated", "workspace.diff.recorded"]), + )) + .orderBy(desc(heartbeatRunEvents.seq)) + .limit(20); + for (const row of rows) { + const summary = workProductDiffSummaryFromEventPayload(row.payload); + if (summary) return summary; + } + return null; + }, + + resolveCommitDiffSummary: async ( + companyId: string, + input: { provider: string; url?: string | null; metadata?: Record | null }, + ): Promise => { + if (input.provider.toLowerCase() !== "github") return null; + const metadata = input.metadata ?? {}; + const repo = typeof metadata.repo === "string" ? metadata.repo : null; + const sha = typeof metadata.sha === "string" ? metadata.sha : null; + const reference = extractGitHubCommitReference([ + input.url, + repo && sha ? `${repo}@${sha}` : null, + ]); + return reference ? await resolveCommitDetails(companyId, reference) : null; }, getById: async (id: string) => { diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index e412614d75..9c6f8c2195 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -377,7 +377,10 @@ export const issuesApi = { api.post(`/issues/${id}/approvals`, { approvalId }), unlinkApproval: (id: string, approvalId: string) => api.delete<{ ok: true }>(`/issues/${id}/approvals/${approvalId}`), - listWorkProducts: (id: string) => api.get(`/issues/${id}/work-products`), + listWorkProducts: (id: string, options?: { refreshPullRequests?: boolean }) => + api.get( + `/issues/${id}/work-products${options?.refreshPullRequests ? "?refreshPullRequests=true" : ""}`, + ), ensureWorkProductReviewDocument: (id: string, workProductId: string) => api.post(`/issues/${id}/work-products/${workProductId}/review-document`, {}), createWorkProduct: (id: string, data: Record) => diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 6e6aeb1443..df3feff483 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -2199,6 +2199,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { ) : ( - {children} - - ); -} - const ROW_CLASS = "flex items-center gap-2 rounded-md border border-border bg-card/50 px-2.5 py-1.5 text-sm"; -function WorkProductRow({ workProduct }: { workProduct: IssueWorkProduct }) { - const Icon = workProductIcon(workProduct.type); - const badge = workProductStatusBadge(workProduct.status); - const href = workProductHref(workProduct); - const body = ( - <> - - {workProduct.title} - {badge ? ( - - {badge.label} - - ) : null} - {href ? ( - - ) : null} - - ); - if (href) { - return ( - - {body} - - ); - } - return
{body}
; -} - /** * Work-product row for an eligible Markdown artifact (LOOA-1533 gap): expands * in place into the shipped document review surface backed by the @@ -416,6 +357,8 @@ function DocumentRow({ * thread. */ export function IssuePropertiesArtifactsTab({ issue, documentDeepLink, onOpenDocument }: IssuePropertiesArtifactsTabProps) { + const [typeFilter, setTypeFilter] = useState("all"); + const [runFilter, setRunFilter] = useState("all"); const { data: attachments } = useQuery({ queryKey: queryKeys.issues.attachments(issue.id), queryFn: () => issuesApi.listAttachments(issue.id), @@ -425,6 +368,14 @@ export function IssuePropertiesArtifactsTab({ issue, documentDeepLink, onOpenDoc queryFn: () => issuesApi.listWorkProducts(issue.id), }); const { data: documents } = useIssueDocuments(issue.id); + const { data: runs } = useQuery({ + queryKey: queryKeys.issues.runs(issue.id), + queryFn: () => activityApi.runsForIssue(issue.id), + }); + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(issue.companyId), + queryFn: () => agentsApi.list(issue.companyId), + }); const workProductRows = workProducts ?? []; // Proxy review documents (`artifact-review-*`) present only through their @@ -432,6 +383,63 @@ export function IssuePropertiesArtifactsTab({ issue, documentDeepLink, onOpenDoc const documentRows = (documents ?? []).filter((doc) => !isArtifactReviewDocumentKey(doc.key)); const reviewDocsByKey = new Map((documents ?? []).map((doc) => [doc.key, doc])); const fileRows = selectAgentArtifactAttachments(attachments, workProducts); + const runsById = useMemo(() => new Map((runs ?? []).map((run) => [run.runId, run])), [runs]); + const agentsById = useMemo(() => new Map((agents ?? []).map((agent) => [agent.id, agent])), [agents]); + + type ArtifactRow = + | { kind: "work_product"; id: string; runId: string | null; date: Date; type: string; value: IssueWorkProduct } + | { kind: "document"; id: string; runId: null; date: Date; type: "document"; value: IssueDocument } + | { kind: "attachment"; id: string; runId: null; date: Date; type: "file" | "image"; value: NonNullable[number] }; + + const allRows = useMemo(() => [ + ...workProductRows.map((value): ArtifactRow => ({ + kind: "work_product", + id: value.id, + runId: value.createdByRunId, + date: new Date(value.createdAt), + type: value.type === "artifact" && typeof value.metadata?.contentType === "string" && value.metadata.contentType.startsWith("image/") + ? "image" + : value.type === "artifact" ? "file" : value.type, + value, + })), + ...documentRows.map((value): ArtifactRow => ({ + kind: "document", + id: value.id, + runId: null, + date: new Date(value.createdAt), + type: "document", + value, + })), + ...fileRows.map((value): ArtifactRow => ({ + kind: "attachment", + id: value.id, + runId: null, + date: new Date(value.createdAt), + type: value.contentType.startsWith("image/") ? "image" : "file", + value, + })), + ], [documentRows, fileRows, workProductRows]); + + const filteredRows = allRows.filter((row) => + (typeFilter === "all" || row.type === typeFilter) && + (runFilter === "all" || (runFilter === "other" ? row.runId === null : row.runId === runFilter)), + ); + const groupedRows = [...filteredRows.reduce((groups, row) => { + const key = row.runId ?? "other"; + const group = groups.get(key) ?? []; + group.push(row); + groups.set(key, group); + return groups; + }, new Map())] + .map(([runId, rows]) => ({ + runId, + rows: rows.sort((a, b) => b.date.getTime() - a.date.getTime()), + date: runId === "other" + ? rows[0]?.date ?? new Date(0) + : new Date(runsById.get(runId)?.startedAt ?? rows[0]?.date ?? 0), + })) + .sort((a, b) => b.date.getTime() - a.date.getTime()); + const runOptions = [...new Set(allRows.flatMap((row) => row.runId ? [row.runId] : []))]; if (workProductRows.length === 0 && documentRows.length === 0 && fileRows.length === 0) { return ( @@ -442,78 +450,123 @@ export function IssuePropertiesArtifactsTab({ issue, documentDeepLink, onOpenDoc } return ( -
- {workProductRows.length > 0 ? ( - <> - Work products -
    - {workProductRows.map((wp) => { - const markdownMetadata = getMarkdownWorkProductAttachmentMetadata(wp); - if (!markdownMetadata) { - return ( -
  • - -
  • - ); - } - const reviewKey = artifactReviewDocumentKey(wp.id); +
    +
    + + +
    + + {groupedRows.length === 0 ? ( +

    No artifacts match these filters.

    + ) : groupedRows.map((group) => { + const run = group.runId === "other" ? null : runsById.get(group.runId); + const agent = run ? agentsById.get(run.agentId) : null; + return ( +
    +
    +

    + {group.runId === "other" ? "Other artifacts" : agent?.name ?? `Run ${group.runId.slice(0, 8)}`} +

    + +
    +
      + {group.rows.map((row) => { + if (row.kind === "work_product") { + const wp = row.value; + const markdownMetadata = getMarkdownWorkProductAttachmentMetadata(wp); + if (markdownMetadata) { + const reviewKey = artifactReviewDocumentKey(wp.id); + return ( +
    • + +
    • + ); + } + return ( +
    • + +
    • + ); + } + if (row.kind === "document") { + const doc = row.value; + return ( +
    • + onOpenDocument(doc) : undefined} + openRequestId={documentDeepLink?.documentKey === doc.key ? documentDeepLink.requestId : undefined} + /> +
    • + ); + } + const attachment = row.value; + return ( +
    • + + + {attachment.originalFilename ?? attachment.objectKey} + {formatBytes(attachment.byteSize)} + +
    • + ); + })} +
    +
    + ); + })} + + + View all in company Artifacts → +
    ); } diff --git a/ui/src/components/issue-properties/IssuePropertiesDocumentAnnotations.test.tsx b/ui/src/components/issue-properties/IssuePropertiesDocumentAnnotations.test.tsx index 38bfee19be..ea3b53e283 100644 --- a/ui/src/components/issue-properties/IssuePropertiesDocumentAnnotations.test.tsx +++ b/ui/src/components/issue-properties/IssuePropertiesDocumentAnnotations.test.tsx @@ -35,7 +35,10 @@ vi.mock("@/hooks/useIssuePlanDocument", () => ({ useIssuePlanDocument: () => ({ data: { ...issueDocument, key: "plan" }, isLoading: false }), })); vi.mock("@/hooks/useIssueDocuments", () => ({ useIssueDocuments: () => ({ data: [issueDocument] }) })); -vi.mock("@/lib/router", () => ({ useLocation: () => ({ hash: "" }) })); +vi.mock("@/lib/router", () => ({ + Link: ({ children, to }: { children: React.ReactNode; to: string }) => {children}, + useLocation: () => ({ hash: "" }), +})); vi.mock("@/components/IssuePlanDecompositionsSection", () => ({ IssuePlanDecompositionsSection: () => null })); vi.mock("@/components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: string }) =>
    {children}
    })); vi.mock("@/components/IssueDocumentAnnotations", () => ({ diff --git a/ui/src/components/issue-properties/IssuePropertiesMarkdownWorkProduct.test.tsx b/ui/src/components/issue-properties/IssuePropertiesMarkdownWorkProduct.test.tsx index b8c640daf6..88ab0bdd51 100644 --- a/ui/src/components/issue-properties/IssuePropertiesMarkdownWorkProduct.test.tsx +++ b/ui/src/components/issue-properties/IssuePropertiesMarkdownWorkProduct.test.tsx @@ -15,12 +15,19 @@ const mockIssuesApi = vi.hoisted(() => ({ ensureWorkProductReviewDocument: vi.fn(async (): Promise => ({})), })); vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi })); +const mockActivityApi = vi.hoisted(() => ({ runsForIssue: vi.fn(async (): Promise => []) })); +vi.mock("@/api/activity", () => ({ activityApi: mockActivityApi })); +const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn(async (): Promise => []) })); +vi.mock("@/api/agents", () => ({ agentsApi: mockAgentsApi })); const mockUseIssueDocuments = vi.hoisted(() => vi.fn((): { data: IssueDocument[] } => ({ data: [] })), ); vi.mock("@/hooks/useIssueDocuments", () => ({ useIssueDocuments: mockUseIssueDocuments })); -vi.mock("@/lib/router", () => ({ useLocation: () => ({ hash: "" }) })); +vi.mock("@/lib/router", () => ({ + Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => {children}, + useLocation: () => ({ hash: "" }), +})); vi.mock("@/components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: string }) => (
    {children}
    @@ -39,7 +46,7 @@ const ATTACHMENT_ID = "00000000-0000-4000-8000-000000000001"; const WORK_PRODUCT_ID = "11111111-1111-4111-8111-111111111111"; const REVIEW_KEY = artifactReviewDocumentKey(WORK_PRODUCT_ID); -const issue = { id: "issue-1", identifier: "PAP-1534", workMode: "standard" } as Issue; +const issue = { id: "issue-1", companyId: "company-1", identifier: "PAP-1534", workMode: "standard" } as Issue; function makeMarkdownWorkProduct(overrides: Partial = {}): IssueWorkProduct { const contentPath = `/api/attachments/${ATTACHMENT_ID}/content`; @@ -160,6 +167,8 @@ describe("markdown work product review row", () => { Element.prototype.scrollIntoView = vi.fn(); mockIssuesApi.listAttachments.mockResolvedValue([]); mockIssuesApi.listWorkProducts.mockResolvedValue([makeMarkdownWorkProduct()]); + mockActivityApi.runsForIssue.mockResolvedValue([]); + mockAgentsApi.list.mockResolvedValue([]); mockUseIssueDocuments.mockReturnValue({ data: [] }); }); @@ -172,7 +181,10 @@ describe("markdown work product review row", () => { container.remove(); }); - async function renderTab(props: { documentDeepLink?: { requestId: number; documentKey: string } | null } = {}) { + async function renderTab( + props: { documentDeepLink?: { requestId: number; documentKey: string } | null } = {}, + expectedText = "Verification report", + ) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); @@ -186,7 +198,7 @@ describe("markdown work product review row", () => { ), ); await waitForAssertion(() => { - expect(container.textContent).toContain("Verification report"); + expect(container.textContent).toContain(expectedText); }); } @@ -211,7 +223,7 @@ describe("markdown work product review row", () => { // The proxy document maps onto the work-product row instead of a // standalone Documents row. - expect(container.textContent).not.toContain("Documents"); + expect(container.querySelectorAll(`[data-testid="annotation-surface-${REVIEW_KEY}"]`)).toHaveLength(0); expect(container.querySelector(`[data-testid="annotation-count-${REVIEW_KEY}"]`)).not.toBeNull(); await act(async () => expandButton().click()); @@ -333,12 +345,68 @@ describe("markdown work product review row", () => { await renderTab(); await waitForAssertion(() => { - const row = Array.from(container.querySelectorAll("a")).find( - (anchor) => anchor.textContent?.includes("Verification report"), - ); - expect(row?.getAttribute("href")).toBe(contentPath); - expect(row?.getAttribute("target")).toBe("_blank"); + const row = container.querySelector('[data-testid="task-chat-rich-work-product-artifact"]'); + const link = row?.querySelector("a"); + expect(row?.textContent).toContain("Verification report"); + expect(link?.getAttribute("href")).toBe(contentPath); }); expect(container.querySelector("button[aria-expanded]")).toBeNull(); }); + + it("groups compact rows by producing run and filters by type", async () => { + const runOne = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const runTwo = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + const imagePath = `/api/attachments/${ATTACHMENT_ID}/content`; + mockIssuesApi.listWorkProducts.mockResolvedValue([ + makeMarkdownWorkProduct({ + id: "22222222-2222-4222-8222-222222222222", + type: "pull_request", + provider: "github", + title: "Artifact grouping PR", + url: "https://github.com/paperclipai/paperclip/pull/1", + createdByRunId: runOne, + metadata: { repo: "paperclipai/paperclip", number: 1, baseRef: "master", headRef: "artifacts" }, + }), + makeMarkdownWorkProduct({ + id: "33333333-3333-4333-8333-333333333333", + title: "Artifacts screenshot", + createdByRunId: runTwo, + metadata: { + attachmentId: ATTACHMENT_ID, + contentType: "image/png", + byteSize: 64, + contentPath: imagePath, + openPath: imagePath, + downloadPath: `${imagePath}?download=1`, + }, + }), + ]); + mockActivityApi.runsForIssue.mockResolvedValue([ + { runId: runOne, agentId: "agent-1", startedAt: "2026-09-02T10:00:00Z", createdAt: "2026-09-02T10:00:00Z" }, + { runId: runTwo, agentId: "agent-2", startedAt: "2026-09-02T11:00:00Z", createdAt: "2026-09-02T11:00:00Z" }, + ]); + mockAgentsApi.list.mockResolvedValue([ + { id: "agent-1", name: "CodexCoder" }, + { id: "agent-2", name: "DesignCoder" }, + ]); + + await renderTab({}, "Artifact grouping PR"); + + await waitForAssertion(() => { + expect(container.textContent).toContain("CodexCoder"); + expect(container.textContent).toContain("DesignCoder"); + expect(container.querySelector('article[data-variant="compact"]')).not.toBeNull(); + expect(container.querySelector(`img[src="${imagePath}"]`)).not.toBeNull(); + expect(container.querySelector('a[aria-label="Open on GitHub: Artifact grouping PR"]')).not.toBeNull(); + expect(container.querySelector('a[aria-label="Open gallery: Artifacts screenshot"]')).not.toBeNull(); + }); + + const typeSelect = container.querySelector('select[aria-label="Filter artifacts by type"]') as HTMLSelectElement; + await act(async () => { + typeSelect.value = "pull_request"; + typeSelect.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(container.textContent).toContain("Artifact grouping PR"); + expect(container.textContent).not.toContain("Artifacts screenshot"); + }); }); diff --git a/ui/src/components/task-chat/RichWorkProductCard.tsx b/ui/src/components/task-chat/RichWorkProductCard.tsx new file mode 100644 index 0000000000..40bcc024be --- /dev/null +++ b/ui/src/components/task-chat/RichWorkProductCard.tsx @@ -0,0 +1,248 @@ +import type { CSSProperties } from "react"; +import type { IssueWorkProduct } from "@paperclipai/shared"; +import { + ExternalLink, + File, + FileText, + Film, + GitBranch, + GitCommit, + Globe, + Image, + Server, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { GithubIcon } from "@/components/icons/github-icon"; +import { cn } from "@/lib/utils"; + +type StateChip = { + label: string; + tone: "progress" | "failure" | "review" | "success" | "neutral"; + dashed?: boolean; +}; + +/** Resting states stay quiet. A completed work product never gets a chip. */ +export function stateChipFor( + kind: IssueWorkProduct["type"], + status: string | null | undefined, + reviewState: IssueWorkProduct["reviewState"] | string | null | undefined, +): StateChip | null { + if (reviewState === "changes_requested" || status === "changes_requested") { + return { label: "Changes requested", tone: "failure" }; + } + if (reviewState === "needs_board_review" || status === "ready_for_review") { + return { label: "Review", tone: "review" }; + } + if (["failed", "unhealthy", "down"].includes(status ?? "")) { + return { label: "Failed", tone: "failure" }; + } + if (["pending", "opening"].includes(status ?? "")) { + return { label: status === "opening" ? "Opening" : "Pending", tone: "progress", dashed: true }; + } + if (kind === "pull_request" && (status === "active" || status === "open")) { + return { label: "Open", tone: "progress" }; + } + if (kind === "pull_request" && status === "draft") { + return { label: "Draft", tone: "review" }; + } + if (kind === "pull_request" && status === "merged") { + return { label: "Merged", tone: "success" }; + } + if (kind === "pull_request" && status === "closed") { + return { label: "Closed", tone: "neutral" }; + } + if (kind === "runtime_service" && status === "active") { + return { label: "Running", tone: "progress" }; + } + if (kind === "runtime_service" && status === "closed") { + return { label: "Stopped", tone: "failure" }; + } + return null; +} + +function stringMeta(metadata: Record | null, ...keys: string[]): string | null { + for (const key of keys) { + const value = metadata?.[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + if (typeof value === "number") return String(value); + } + return null; +} + +function numberMeta(metadata: Record | null, ...keys: string[]): number | null { + for (const key of keys) { + const value = metadata?.[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + return null; +} + +function formatBytes(value: number): string { + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / (1024 * 1024)).toFixed(1)} MB`; +} + +function urlLabel(url: string | null): string | null { + if (!url) return null; + try { + const parsed = new URL(url, typeof window === "undefined" ? "http://localhost" : window.location.origin); + return `${parsed.host}${parsed.pathname === "/" ? "" : parsed.pathname}`; + } catch { + return url; + } +} + +function Chip({ chip }: { chip: StateChip }) { + const cssVar = chip.tone === "failure" + ? "--status-task-blocked" + : chip.tone === "success" + ? "--status-task-done" + : chip.tone === "neutral" + ? "--status-task-cancelled" + : chip.tone === "review" + ? "--status-task-in_review" + : "--status-task-in_progress"; + return ( + + {chip.label} + + ); +} + +export interface RichWorkProductCardProps { + workProduct: IssueWorkProduct; + href: string | null; + variant?: "card" | "compact"; +} + +export function RichWorkProductCard({ workProduct, href, variant = "card" }: RichWorkProductCardProps) { + const metadata = workProduct.metadata; + const contentType = stringMeta(metadata, "contentType") ?? ""; + const isImage = contentType.startsWith("image/"); + const isVideo = contentType.startsWith("video/"); + let Icon: LucideIcon = File; + let meta: Array = []; + let action = "Open preview"; + + switch (workProduct.type) { + case "pull_request": { + Icon = GithubIcon; + const repository = stringMeta(metadata, "repository", "repo", "repositoryName"); + const number = stringMeta(metadata, "number", "pullRequestNumber"); + const base = stringMeta(metadata, "baseRef", "base", "baseBranch"); + const head = stringMeta(metadata, "headRef", "head", "headBranch", "branch"); + meta = [repository, number ? `#${number.replace(/^#/, "")}` : null, base && head ? `${base} ← ${head}` : null, urlLabel(workProduct.url)]; + action = "Open on GitHub"; + break; + } + case "commit": + Icon = GitCommit; + meta = [stringMeta(metadata, "shortSha", "sha")?.slice(0, 8) ?? workProduct.externalId?.slice(0, 8) ?? null, stringMeta(metadata, "branch", "branchName"), urlLabel(workProduct.url)]; + action = "Open on GitHub"; + break; + case "branch": + Icon = GitBranch; + meta = [stringMeta(metadata, "repository", "repo", "repositoryName"), stringMeta(metadata, "branch", "branchName") ?? workProduct.externalId, urlLabel(workProduct.url)]; + action = "Open on GitHub"; + break; + case "artifact": { + Icon = isImage ? Image : isVideo ? Film : File; + const size = numberMeta(metadata, "byteSize", "size"); + meta = [isImage ? "Image" : isVideo ? "Video" : stringMeta(metadata, "kind", "fileType") ?? "File", size === null ? null : formatBytes(size)]; + action = isImage || isVideo ? "Open gallery" : "Open preview"; + break; + } + case "document": + Icon = FileText; + meta = ["Document", stringMeta(metadata, "revision", "revisionNumber") ? `rev ${stringMeta(metadata, "revision", "revisionNumber")}` : null]; + action = "Open document"; + break; + case "preview_url": + Icon = Globe; + meta = [urlLabel(workProduct.url)]; + action = "Open preview"; + break; + case "runtime_service": + Icon = Server; + meta = [stringMeta(metadata, "service", "serviceName") ?? workProduct.provider, stringMeta(metadata, "port") ? `port ${stringMeta(metadata, "port")}` : null]; + action = "Open service"; + break; + } + + const additions = numberMeta(metadata, "additions"); + const deletions = numberMeta(metadata, "deletions"); + const files = numberMeta(metadata, "files", "changedFiles"); + const unhealthyChip = + workProduct.healthStatus === "unhealthy" + ? workProduct.type === "preview_url" + ? { label: "Down", tone: "failure" as const } + : workProduct.type === "runtime_service" && workProduct.status !== "closed" + ? { label: "Unhealthy", tone: "failure" as const } + : null + : null; + const chip = + unhealthyChip ?? + stateChipFor( + workProduct.type, + workProduct.type === "pull_request" + ? stringMeta(metadata, "state") ?? workProduct.status + : workProduct.type === "runtime_service" && + workProduct.status === "active" && + workProduct.healthStatus !== "healthy" + ? null + : workProduct.status, + workProduct.reviewState, + ); + const visibleMeta = meta.filter((value): value is string => Boolean(value)); + const changeCounts = [additions === null ? null : `+${additions}`, deletions === null ? null : `−${deletions}`] + .filter(Boolean) + .join(" "); + const fileCount = files === null ? null : `${files} ${files === 1 ? "file" : "files"}`; + const statsLabel = [changeCounts || null, fileCount].filter(Boolean).join(" · "); + const compact = variant === "compact"; + const imagePath = isImage + ? stringMeta(metadata, "openPath", "contentPath") ?? href + : null; + + return ( +
    +
    + {imagePath ? ( + + ) : ( + + )} +
    +
    + {workProduct.title} + {visibleMeta.length > 0 ?

    {visibleMeta.join(" · ")}

    : null} + {statsLabel ?

    {statsLabel}

    : null} +
    +
    + {chip ? : null} + {href ? ( + + {compact ? null : {action}} + + ) : null} +
    +
    + ); +} diff --git a/ui/src/components/task-chat/TaskChatBubble.test.tsx b/ui/src/components/task-chat/TaskChatBubble.test.tsx index 58480c5a9f..c1801981fb 100644 --- a/ui/src/components/task-chat/TaskChatBubble.test.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import type { ReactNode } from "react"; +import type { IssueAttachment } from "@paperclipai/shared"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -24,12 +25,16 @@ describe("TaskChatBubble attachment chips", () => { container.remove(); }); - function renderMessage(text: string, author: TaskChatMessageItem["author"] = "human") { + function renderMessage( + text: string, + author: TaskChatMessageItem["author"] = "human", + attachments: IssueAttachment[] = [], + ) { const item: TaskChatMessageItem = { id: "m1", kind: "message", author, text }; flushSync(() => root!.render( - + , ), ); @@ -64,8 +69,94 @@ describe("TaskChatBubble attachment chips", () => { expect(container.querySelector('[data-testid="task-chat-bubble-attachments"]')).toBeNull(); expect(container.textContent).toContain("Just words"); }); + + it("renders an extensionless PNG attachment as a thumbnail", () => { + renderMessage( + "Done.\n\n[desktop Default](/api/attachments/img/content)", + "agent", + [ + attachment({ + id: "img", + originalFilename: "desktop Default", + contentType: "image/png", + byteSize: 4096, + }), + ], + ); + + const media = container.querySelector('[data-testid="task-chat-bubble-media"]'); + expect(media).not.toBeNull(); + expect(media?.querySelector("img")?.getAttribute("alt")).toBe("desktop Default"); + expect(container.querySelector('[data-testid="task-chat-bubble-attachments"]')).toBeNull(); + }); + + it("shows three thumbnails and an overflow tile for five images", () => { + const links = Array.from( + { length: 5 }, + (_, index) => `[shot ${index + 1}](/api/attachments/img${index + 1}/content)`, + ).join("\n"); + const attachments = Array.from({ length: 5 }, (_, index) => + attachment({ + id: `img${index + 1}`, + originalFilename: `shot ${index + 1}`, + contentType: "image/png", + }), + ); + renderMessage(links, "agent", attachments); + + const media = container.querySelector('[data-testid="task-chat-bubble-media"]'); + expect(media?.querySelectorAll("img")).toHaveLength(3); + expect(media?.textContent).toContain("+2"); + + const secondThumbnail = media?.querySelectorAll("button")[1]; + flushSync(() => secondThumbnail?.click()); + expect(document.body.textContent).toContain("2 / 5"); + }); + + it("shows a typed file chip with its stored size", () => { + renderMessage( + "[verification.log](/api/attachments/log/content)", + "agent", + [ + attachment({ + id: "log", + originalFilename: "verification.log", + contentType: "text/plain", + byteSize: 14 * 1024, + }), + ], + ); + + const group = container.querySelector('[data-testid="task-chat-bubble-attachments"]'); + expect(group?.textContent).toContain("Log · 14.0 KB"); + }); }); +function attachment(overrides: Partial): IssueAttachment { + const id = overrides.id ?? "attachment"; + return { + id, + companyId: "company", + issueId: "issue", + issueCommentId: "m1", + assetId: `asset-${id}`, + provider: "paperclip", + objectKey: id, + contentType: "application/octet-stream", + byteSize: 1, + sha256: id, + originalFilename: id, + createdByAgentId: "agent", + createdByUserId: null, + createdAt: new Date("2026-09-02T00:00:00Z"), + updatedAt: new Date("2026-09-02T00:00:00Z"), + contentPath: `/api/attachments/${id}/content`, + openPath: `/api/attachments/${id}/content`, + downloadPath: `/api/attachments/${id}/content?download=1`, + ...overrides, + }; +} + describe("TaskChatBubble accent-bubble text color", () => { let container: HTMLDivElement; let root: Root | null = null; diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index 3d38a0ce0a..08e262b432 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -1,4 +1,5 @@ import { useState, type ReactNode } from "react"; +import type { IssueAttachment } from "@paperclipai/shared"; import { cn } from "@/lib/utils"; import { MarkdownBody } from "@/components/MarkdownBody"; import { @@ -20,7 +21,11 @@ import { import { extractAttachmentRefs, extractImageRefs, - fileKindForName, + fileKindForAttachment, + formatFileSize, + hydrateAttachmentRefs, + isImageAttachment, + stripStandaloneImageEmbeds, } from "./task-chat-attachments"; import { TaskChatSystemNotice } from "./TaskChatSystemNotice"; import type { TaskChatMessageItem } from "./task-chat-model"; @@ -52,6 +57,7 @@ interface TaskChatBubbleProps { * so this bubble skips them. Human/system bubbles pass nothing. */ actions?: ReactNode; + attachments?: IssueAttachment[]; } function initialsForName(name: string) { @@ -106,13 +112,17 @@ export function TaskChatAgentIdentity({ * surface with an avatar author header (the agent's assigned icon + name); * system notices are centered and recede. */ -function galleryItemForImage(src: string, name?: string): GalleryMediaItem { +function galleryItemForImage( + src: string, + name?: string, + attachment?: ReturnType[number], +): GalleryMediaItem { return { - id: src, - contentPath: src, - // The modal only inspects contentType/filename to spot videos; embedded - // markdown images are always images, so an empty type is safe here. - contentType: "", + id: attachment?.id ?? src, + contentPath: attachment?.openPath ?? src, + openPath: attachment?.openPath, + downloadPath: attachment?.downloadPath, + contentType: attachment?.contentType ?? "", originalFilename: name?.trim() ? name : "image", }; } @@ -125,6 +135,7 @@ export function TaskChatBubble({ beforeTurn, hideAgentIdentity = false, actions, + attachments = [], onTryAgainNoLiveExecutionPath, tryAgainNoLiveExecutionPathPending, }: TaskChatBubbleProps) { @@ -153,25 +164,34 @@ export function TaskChatBubble({ const isHuman = item.author === "human"; // Non-image file references ("[name](/api/attachments/…/content)") render as // attachment chips under the bubble; link-only lines leave the body text. - const { refs: attachmentRefs, text: bodyText } = extractAttachmentRefs( - item.text, + const { refs: linkedRefs, text: bodyWithoutAttachmentLinks } = + extractAttachmentRefs(item.text); + const embeddedImageRefs = extractImageRefs(bodyWithoutAttachmentLinks); + const bodyText = stripStandaloneImageEmbeds(bodyWithoutAttachmentLinks); + const hydratedLinkedRefs = hydrateAttachmentRefs(linkedRefs, attachments); + const hydratedEmbeddedRefs = hydrateAttachmentRefs( + embeddedImageRefs, + attachments, + ); + const imageRefs = [ + ...hydratedEmbeddedRefs, + ...hydratedLinkedRefs.filter(isImageAttachment), + ].filter((ref, index, refs) => + refs.findIndex((candidate) => candidate.url === ref.url) === index, + ); + const attachmentRefs = hydratedLinkedRefs.filter( + (ref) => !isImageAttachment(ref), ); - const imageRefs = extractImageRefs(bodyText); const galleryItems: GalleryMediaItem[] = lightboxSrc !== null && !imageRefs.some((ref) => ref.url === lightboxSrc) ? // A clicked image the extractor missed (e.g. inline HTML) still gets a // single-item lightbox rather than nothing. [galleryItemForImage(lightboxSrc)] - : imageRefs.map((ref) => galleryItemForImage(ref.url, ref.name)); + : imageRefs.map((ref) => galleryItemForImage(ref.url, ref.name, ref)); const lightboxIndex = lightboxSrc === null ? -1 - : Math.max( - 0, - galleryItems.findIndex( - (galleryItem) => galleryItem.contentPath === lightboxSrc, - ), - ); + : Math.max(0, imageRefs.findIndex((ref) => ref.url === lightboxSrc)); return (
    ) : null} - {attachmentRefs.length > 0 ? ( - 0 ? ( +
    - {attachmentRefs.map((ref) => { - const kind = fileKindForName(ref.name); - const KindIcon = kind.icon; - return ( - - - - - - - {ref.name} - - - {kind.label} - - - } - /> - - ); - })} - + + Screenshots · {imageRefs.length} + +
    + {imageRefs + .slice(0, imageRefs.length > 4 ? 3 : 4) + .map((ref, index) => ( + + ))} + {imageRefs.length > 4 ? ( + + ) : null} +
    + + {imageRefs.map((ref) => ref.name || "image").join(" · ")} + +
    + ) : null} + {attachmentRefs.length > 0 ? ( +
    + + Files · {attachmentRefs.length} + + + {attachmentRefs.map((ref) => { + const kind = fileKindForAttachment(ref); + const KindIcon = kind.icon; + const size = formatFileSize(ref.byteSize); + return ( + + + + + + + {ref.name} + + + {size ? `${kind.label} · ${size}` : kind.label} + + + + } + /> + + ); + })} + +
    ) : null} {!isHuman && item.verificationCaveats?.length ? (
    = {}): IssueWorkProduct { + return { + id: "work-product-1", + companyId: "company-1", + projectId: null, + issueId: "issue-1", + executionWorkspaceId: null, + runtimeServiceId: null, + type: "pull_request", + provider: "github", + externalId: "42", + title: "Ship rich work-product cards", + url: "https://github.com/paperclipai/paperclip/pull/42", + status: "merged", + reviewState: "none", + isPrimary: true, + healthStatus: "healthy", + summary: null, + metadata: null, + createdByRunId: null, + createdAt: new Date("2026-09-02T00:00:00.000Z"), + updatedAt: new Date("2026-09-02T00:00:00.000Z"), + ...overrides, + }; +} vi.mock("@/components/MarkdownEditor", () => ({ MarkdownEditor: forwardRef(function MockMarkdownEditor( @@ -87,6 +115,200 @@ describe("TaskChatProtocolCard", () => { container.remove(); }); + it("renders a rich deliverable card without a completed chip", () => { + const product = workProduct({ + metadata: { + repo: "paperclipai/paperclip", + number: 42, + baseRef: "master", + headRef: "feat/rich-cards", + additions: 17, + deletions: 5, + changedFiles: 3, + state: "merged", + draft: false, + }, + }); + renderCard(root, { + id: "resource:deliverable:work-product-1", + kind: "protocol", + surface: "resource", + resourceKind: "deliverable", + title: product.title, + subtitle: "pull request · merged", + href: product.url, + workProduct: product, + }); + + expect(container.querySelector('[data-testid="task-chat-rich-work-product-pull_request"]')).not.toBeNull(); + expect(container.textContent).toContain("Open on GitHub"); + expect(container.textContent).toContain("paperclipai/paperclip · #42 · master ← feat/rich-cards"); + expect(container.textContent).toContain("+17 −5 · 3 files"); + expect(container.textContent).toContain("Merged"); + expect(container.textContent).not.toContain("Completed"); + + const action = container.querySelector('a[aria-label="Open on GitHub: Ship rich work-product cards"]'); + expect(action?.querySelector("span")?.className).toContain("hidden @sm:inline"); + const stats = Array.from(container.querySelectorAll("p")).find((node) => node.textContent === "+17 −5 · 3 files"); + expect(stats?.className).toContain("whitespace-nowrap"); + }); + + it("shows pending artifacts with a dashed Pending chip", () => { + const product = workProduct({ + type: "artifact", + provider: "paperclip", + url: "/api/attachments/attachment-1/content", + status: "pending", + title: "demo.png", + metadata: { contentType: "image/png", byteSize: 2048 }, + }); + renderCard(root, { + id: "resource:deliverable:work-product-1", + kind: "protocol", + surface: "resource", + resourceKind: "deliverable", + title: product.title, + subtitle: "artifact · pending", + href: product.url, + workProduct: product, + }); + + const chip = Array.from(container.querySelectorAll("span")).find((node) => node.textContent === "Pending"); + expect(chip?.className).toContain("border-dashed"); + expect(container.textContent).toContain("Image · 2.0 KB"); + expect(container.textContent).toContain("Open gallery"); + }); + + it("keeps completed and approved states out of the state-chip policy", () => { + expect(stateChipFor("commit", "completed", "none")).toBeNull(); + expect(stateChipFor("document", "approved", "approved")).toBeNull(); + expect(stateChipFor("pull_request", "open", "none")).toMatchObject({ label: "Open", tone: "progress" }); + expect(stateChipFor("pull_request", "merged", "none")).toMatchObject({ label: "Merged", tone: "success" }); + expect(stateChipFor("pull_request", "closed", "none")).toMatchObject({ label: "Closed", tone: "neutral" }); + expect(stateChipFor("artifact", "pending", "none")).toMatchObject({ label: "Pending", dashed: true }); + }); + + it("shows the live open pull request state while preserving explicit review-state precedence", () => { + const product = workProduct({ + status: "ready_for_review", + metadata: { state: "open" }, + }); + renderCard(root, { + id: "resource:deliverable:work-product-1", + kind: "protocol", + surface: "resource", + resourceKind: "deliverable", + title: product.title, + subtitle: "pull request · open", + href: product.url, + workProduct: product, + }); + + const chip = Array.from(container.querySelectorAll("span")).find((node) => node.textContent === "Open"); + expect(chip).toBeDefined(); + expect(container.textContent).not.toContain("Review"); + expect(stateChipFor("pull_request", "open", "needs_board_review")).toMatchObject({ + label: "Review", + tone: "review", + }); + }); + + it("shows an unhealthy active runtime as unhealthy", () => { + const product = workProduct({ + type: "runtime_service", + provider: "paperclip", + status: "active", + healthStatus: "unhealthy", + title: "Storybook", + metadata: { service: "storybook", port: 6006 }, + }); + renderCard(root, { + id: "resource:deliverable:work-product-1", + kind: "protocol", + surface: "resource", + resourceKind: "deliverable", + title: product.title, + subtitle: "runtime service · active", + href: product.url, + workProduct: product, + }); + + expect(container.textContent).toContain("Unhealthy"); + expect(container.textContent).not.toContain("Running"); + }); + + it("does not show running for an active runtime with unknown health", () => { + const product = workProduct({ + type: "runtime_service", + provider: "paperclip", + status: "active", + healthStatus: "unknown", + title: "Storybook", + metadata: { service: "storybook", port: 6006 }, + }); + renderCard(root, { + id: "resource:deliverable:work-product-1", + kind: "protocol", + surface: "resource", + resourceKind: "deliverable", + title: product.title, + subtitle: "runtime service · active", + href: product.url, + workProduct: product, + }); + + expect(container.textContent).not.toContain("Running"); + expect(container.textContent).not.toContain("Unhealthy"); + }); + + it("shows an unhealthy runtime with a non-standard open status as unhealthy", () => { + const product = workProduct({ + type: "runtime_service", + provider: "paperclip", + status: "open", + healthStatus: "unhealthy", + title: "Storybook", + metadata: { service: "storybook", port: 6006 }, + }); + renderCard(root, { + id: "resource:deliverable:work-product-1", + kind: "protocol", + surface: "resource", + resourceKind: "deliverable", + title: product.title, + subtitle: "runtime service · open", + href: product.url, + workProduct: product, + }); + + expect(container.textContent).toContain("Unhealthy"); + expect(container.textContent).not.toContain("Running"); + }); + + it("shows a closed runtime as stopped even when its last health check failed", () => { + const product = workProduct({ + type: "runtime_service", + provider: "paperclip", + status: "closed", + healthStatus: "unhealthy", + title: "Storybook", + metadata: { service: "storybook", port: 6006 }, + }); + renderCard(root, { + id: "resource:deliverable:work-product-1", + kind: "protocol", + surface: "resource", + resourceKind: "deliverable", + title: product.title, + subtitle: "runtime service · closed", + href: product.url, + workProduct: product, + }); + + expect(container.textContent).toContain("Stopped"); + expect(container.textContent).not.toContain("Unhealthy"); + }); + it("renders provider plan steps and status", () => { renderCard(root, { id: "provider-plan", diff --git a/ui/src/components/task-chat/TaskChatProtocolCard.tsx b/ui/src/components/task-chat/TaskChatProtocolCard.tsx index 215eac1e93..22408fb227 100644 --- a/ui/src/components/task-chat/TaskChatProtocolCard.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolCard.tsx @@ -46,6 +46,7 @@ import type { } from "./task-chat-model"; import { QuestionForm, QuestionResponseSummary } from "./QuestionForm"; import { TaskChatComposerTakeoverHeader } from "./TaskChatComposerTakeoverContext"; +import { RichWorkProductCard } from "./RichWorkProductCard"; export interface TaskChatProtocolCardProps { item: TaskChatProtocolItem; @@ -139,7 +140,7 @@ function CardShell({ }: { icon: typeof Circle; title: string; - status: string; + status: string | null; summary?: string; children?: React.ReactNode; testId: string; @@ -151,7 +152,7 @@ function CardShell({ {title} - {status !== "pending" ? ( + {status && status !== "pending" ? ( {title} - - - {status.replaceAll("_", " ")} - + {status ? ( + + + {status.replaceAll("_", " ")} + + ) : null}
    {summary ? (

    {summary}

    @@ -1015,6 +1018,9 @@ function ResourceCard({ }: { item: Extract; }) { + if (item.resourceKind === "deliverable" && item.workProduct) { + return ; + } const Icon = item.resourceKind === "document" ? FileText @@ -1025,7 +1031,7 @@ function ResourceCard({ diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index 92226bd1d8..2948c1cc6b 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import type { IssueAttachment } from "@paperclipai/shared"; import { cn } from "@/lib/utils"; import type { TaskChatInteractionItem, @@ -63,6 +64,7 @@ interface TaskChatThreadViewProps { className?: string; /** When false, render the list without the scroll container (e.g. previews). */ scroll?: boolean; + attachments?: IssueAttachment[]; } function renderItem( @@ -80,6 +82,7 @@ function renderItem( onTryAgainNoLiveExecutionPath?: () => Promise | void, tryAgainNoLiveExecutionPathPending = false, retryableMarkerId?: string, + attachments: IssueAttachment[] = [], ) { switch (item.kind) { case "message": { @@ -113,6 +116,10 @@ function renderItem( undefined, onRuntimeRequestDecision, item.attachedTurn?.standaloneHeader ? "runner" : "classic", + undefined, + false, + undefined, + attachments, ) } /> @@ -140,6 +147,7 @@ function renderItem( hideAgentIdentity={Boolean(item.attachedTurn?.standaloneHeader)} onTryAgainNoLiveExecutionPath={onTryAgainNoLiveExecutionPath} tryAgainNoLiveExecutionPathPending={tryAgainNoLiveExecutionPathPending} + attachments={attachments} /> ); } @@ -187,6 +195,11 @@ function renderItem( undefined, undefined, onRuntimeRequestDecision, + activityAppearance, + undefined, + false, + undefined, + attachments, ) ) } @@ -216,6 +229,10 @@ function renderItem( undefined, onRuntimeRequestDecision, item.standaloneHeader ? "runner" : "classic", + undefined, + false, + undefined, + attachments, ) } /> @@ -256,6 +273,7 @@ export function TaskChatThreadView({ contentKey, className, scroll = true, + attachments = [], }: TaskChatThreadViewProps) { const retryableMarkerId = onTryAgainNoLiveExecutionPath ? [...items] @@ -304,6 +322,7 @@ export function TaskChatThreadView({ onTryAgainNoLiveExecutionPath, tryAgainNoLiveExecutionPathPending, retryableMarkerId, + attachments, )}
))} diff --git a/ui/src/components/task-chat/task-chat-attachments.test.ts b/ui/src/components/task-chat/task-chat-attachments.test.ts index 1fb9cebe20..5e588a053b 100644 --- a/ui/src/components/task-chat/task-chat-attachments.test.ts +++ b/ui/src/components/task-chat/task-chat-attachments.test.ts @@ -2,9 +2,13 @@ import { describe, expect, it } from "vitest"; import { extractAttachmentRefs, extractImageRefs, + fileKindForAttachment, fileKindForName, formatFileSize, + hydrateAttachmentRefs, + isImageAttachment, isImageFilename, + stripStandaloneImageEmbeds, } from "./task-chat-attachments"; describe("fileKindForName", () => { @@ -29,6 +33,60 @@ describe("isImageFilename", () => { }); }); +describe("attachment record metadata", () => { + it("detects an extensionless image from its content type", () => { + expect( + isImageAttachment({ + name: "desktop Default", + url: "/api/attachments/screenshot/content", + contentType: "image/png", + }), + ).toBe(true); + }); + + it("falls back to the extension for generic attachment content", () => { + expect( + isImageAttachment({ + name: "shot.png", + url: "/api/attachments/screenshot/content", + contentType: "application/octet-stream", + }), + ).toBe(true); + }); + + it("uses content type for an extensionless file kind", () => { + expect( + fileKindForAttachment({ + name: "report", + url: "/api/attachments/report/content", + contentType: "application/pdf", + }).label, + ).toBe("PDF"); + }); + + it("hydrates refs with the stored type, size, and paths", () => { + const [ref] = hydrateAttachmentRefs( + [{ name: "desktop Default", url: "/api/attachments/a1/content" }], + [ + { + id: "a1", + contentPath: "/api/attachments/a1/content", + openPath: "/api/attachments/a1/content", + downloadPath: "/api/attachments/a1/content?download=1", + contentType: "image/png", + byteSize: 2048, + originalFilename: "desktop Default", + }, + ], + ); + expect(ref).toMatchObject({ + id: "a1", + contentType: "image/png", + byteSize: 2048, + }); + }); +}); + describe("formatFileSize", () => { it("formats byte tiers", () => { expect(formatFileSize(512)).toBe("512 B"); @@ -57,11 +115,14 @@ describe("extractAttachmentRefs", () => { expect(text).toBe(body); }); - it("ignores image embeds and image-named links", () => { + it("ignores image embeds but returns image-named attachment links for classification", () => { const body = "![shot.png](/api/attachments/img/content)\n[photo.jpg](/api/attachments/p/content)\n[notes.txt](/api/attachments/n/content)"; const { refs } = extractAttachmentRefs(body); - expect(refs).toEqual([{ name: "notes.txt", url: "/api/attachments/n/content" }]); + expect(refs).toEqual([ + { name: "photo.jpg", url: "/api/attachments/p/content" }, + { name: "notes.txt", url: "/api/attachments/n/content" }, + ]); }); it("ignores non-attachment links entirely", () => { @@ -113,4 +174,33 @@ describe("extractImageRefs", () => { it("returns nothing for bodies without images", () => { expect(extractImageRefs("just text and a [link](/api/attachments/x/content)")).toEqual([]); }); + + it("promotes standalone embeds but leaves prose-woven embeds inline", () => { + const body = [ + "Standalone embed on its own line:", + "", + "![shot one](/api/attachments/a/content)", + "", + "And here is an image ![inline two](/api/attachments/b/content) woven into this sentence.", + ].join("\n"); + + expect(extractImageRefs(body)).toEqual([ + { name: "shot one", url: "/api/attachments/a/content" }, + ]); + expect(stripStandaloneImageEmbeds(body)).toBe( + [ + "Standalone embed on its own line:", + "", + "And here is an image ![inline two](/api/attachments/b/content) woven into this sentence.", + ].join("\n"), + ); + }); +}); + +describe("stripStandaloneImageEmbeds", () => { + it("removes tail embeds promoted to thumbnails but keeps prose", () => { + expect( + stripStandaloneImageEmbeds("Done.\n\n![desktop Default](/api/attachments/a/content)"), + ).toBe("Done."); + }); }); diff --git a/ui/src/components/task-chat/task-chat-attachments.ts b/ui/src/components/task-chat/task-chat-attachments.ts index 7701117f4d..8c702e4d76 100644 --- a/ui/src/components/task-chat/task-chat-attachments.ts +++ b/ui/src/components/task-chat/task-chat-attachments.ts @@ -15,6 +15,7 @@ import { File as FileIcon, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; +import type { IssueAttachment } from "@paperclipai/shared"; export interface FileKind { icon: LucideIcon; @@ -89,11 +90,55 @@ export function isImageFilename(name: string): boolean { return IMAGE_EXTENSIONS.has(extensionOf(name)); } +type AttachmentRecord = Pick< + IssueAttachment, + | "id" + | "contentPath" + | "openPath" + | "downloadPath" + | "contentType" + | "byteSize" + | "originalFilename" +>; + +function normalizedContentType(contentType: string | undefined): string { + return (contentType ?? "").toLowerCase().split(";")[0]?.trim() ?? ""; +} + +/** MIME type is authoritative; filenames remain a fallback for legacy refs. */ +export function isImageAttachment(ref: AttachmentRef): boolean { + const contentType = normalizedContentType(ref.contentType); + if (contentType.startsWith("image/")) return true; + if (contentType && contentType !== "application/octet-stream") return false; + return isImageFilename(ref.name) || isImageFilename(ref.url.split("?")[0]); +} + /** Kind icon + short label for a filename; unknown extensions get File/"File". */ export function fileKindForName(name: string): FileKind { return KIND_BY_EXTENSION[extensionOf(name)] ?? { icon: FileIcon, label: "File" }; } +export function fileKindForAttachment(ref: AttachmentRef): FileKind { + const byName = fileKindForName(ref.name); + if (byName.label !== "File") return byName; + + const contentType = normalizedContentType(ref.contentType); + if (contentType === "application/pdf") return { icon: FileText, label: "PDF" }; + if (contentType === "application/json" || contentType.endsWith("+json")) { + return { icon: FileCode, label: "JSON" }; + } + if (contentType === "text/csv" || contentType === "application/csv") { + return { icon: FileSpreadsheet, label: "CSV" }; + } + if (contentType.startsWith("text/")) return { icon: FileText, label: "Text" }; + if (contentType.startsWith("audio/")) return { icon: FileAudio, label: "Audio" }; + if (contentType.startsWith("video/")) return { icon: FileVideo, label: "Video" }; + if (contentType.includes("zip") || contentType.includes("archive")) { + return { icon: FileArchive, label: "Archive" }; + } + return byName; +} + /** "2.4 MB"-style size for chip descriptions (same tiers as IssueChatThread). */ export function formatFileSize(bytes: number | undefined): string { if (bytes === undefined || !Number.isFinite(bytes) || bytes <= 0) return ""; @@ -105,6 +150,45 @@ export function formatFileSize(bytes: number | undefined): string { export interface AttachmentRef { name: string; url: string; + id?: string; + contentType?: string; + byteSize?: number; + openPath?: string; + downloadPath?: string; +} + +function attachmentIdFromUrl(url: string): string | null { + return /\/api\/(?:attachments|assets)\/([^/?]+)\/content/.exec(url)?.[1] ?? null; +} + +export function hydrateAttachmentRefs( + refs: AttachmentRef[], + attachments: AttachmentRecord[], +): AttachmentRef[] { + const byId = new Map(attachments.map((attachment) => [attachment.id, attachment])); + const byPath = new Map( + attachments.flatMap((attachment) => [ + [attachment.contentPath, attachment] as const, + ...(attachment.openPath ? [[attachment.openPath, attachment] as const] : []), + ]), + ); + + return refs.map((ref) => { + const cleanUrl = ref.url.split("?")[0]; + const record = + byPath.get(cleanUrl) ?? + byId.get(attachmentIdFromUrl(ref.url) ?? ""); + if (!record) return ref; + return { + ...ref, + id: record.id, + name: record.originalFilename?.trim() || ref.name, + contentType: record.contentType, + byteSize: record.byteSize, + openPath: record.openPath, + downloadPath: record.downloadPath, + }; + }); } /** @@ -118,23 +202,43 @@ const ATTACHMENT_LINK_RE = /** Markdown image embeds (`![name](url)`), same escaped-label grammar. */ const IMAGE_EMBED_RE = /!\[((?:\\.|[^\]\\])*)\]\(([^()\s]+)\)/g; +function isStandaloneImageEmbedLine(line: string): boolean { + return ( + line.replace(IMAGE_EMBED_RE, "").trim().length === 0 && line.trim().length > 0 + ); +} + /** - * Every image embedded in a message body, in document order, deduped by URL. + * Images on standalone lines, in document order, deduped by URL. Images woven + * into prose stay inline so they are not also duplicated in the media strip. * Feeds the bubble's lightbox: the refs become the gallery items and the * clicked src picks the initial index. */ export function extractImageRefs(body: string): AttachmentRef[] { const refs: AttachmentRef[] = []; const seen = new Set(); - for (const match of body.matchAll(IMAGE_EMBED_RE)) { - const [, name, url] = match; - if (seen.has(url)) continue; - seen.add(url); - refs.push({ name: name.replace(/\\([[\]])/g, "$1"), url }); + for (const line of body.split("\n")) { + if (!isStandaloneImageEmbedLine(line)) continue; + for (const match of line.matchAll(IMAGE_EMBED_RE)) { + const [, name, url] = match; + if (seen.has(url)) continue; + seen.add(url); + refs.push({ name: name.replace(/\\([[\]])/g, "$1"), url }); + } } return refs; } +/** Remove standalone image-embed lines after promoting them to the media strip. */ +export function stripStandaloneImageEmbeds(body: string): string { + return body + .split("\n") + .filter((line) => !isStandaloneImageEmbedLine(line)) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + export interface ExtractedAttachmentRefs { refs: AttachmentRef[]; /** Body with lines that were nothing but extracted links removed. */ @@ -152,7 +256,6 @@ export function extractAttachmentRefs(body: string): ExtractedAttachmentRefs { const seen = new Set(); for (const match of body.matchAll(ATTACHMENT_LINK_RE)) { const [, name, url] = match; - if (isImageFilename(name) || isImageFilename(url.split("?")[0])) continue; if (seen.has(url)) continue; seen.add(url); refs.push({ name: name.replace(/\\([[\]])/g, "$1"), url }); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index df0c15c31c..b1e2375913 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -2786,8 +2786,9 @@ export function IssueDetail() { const { data: workProducts } = useQuery({ queryKey: queryKeys.issues.workProducts(issueId!), - queryFn: () => issuesApi.listWorkProducts(issueId!), + queryFn: () => issuesApi.listWorkProducts(issueId!, { refreshPullRequests: true }), enabled: !!issueId, + refetchOnMount: "always", placeholderData: keepPreviousDataForSameQueryTail( issueId ?? "pending", ), diff --git a/ui/storybook/.storybook/preview.tsx b/ui/storybook/.storybook/preview.tsx index 9a816d8965..5e5fe31bbf 100644 --- a/ui/storybook/.storybook/preview.tsx +++ b/ui/storybook/.storybook/preview.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, type ReactNode } from "react"; import type { Preview } from "@storybook/react-vite"; +import { MINIMAL_VIEWPORTS } from "storybook/viewport"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { CONNECTABLE_APP_DEFINITIONS, @@ -766,7 +767,8 @@ const preview: Preview = { }, layout: "fullscreen", viewport: { - viewports: { + options: { + ...MINIMAL_VIEWPORTS, mobile: { name: "Mobile", styles: { width: "390px", height: "844px" }, diff --git a/ui/storybook/stories/artifacts.stories.tsx b/ui/storybook/stories/artifacts.stories.tsx index 185781c924..10a8723c52 100644 --- a/ui/storybook/stories/artifacts.stories.tsx +++ b/ui/storybook/stories/artifacts.stories.tsx @@ -621,7 +621,7 @@ export const SelectedStack: Story = { * below) and that stack cards keep their stack effect at single-column width. */ export const MobileGrouping: Story = { - parameters: { viewport: { defaultViewport: "mobile" } }, + globals: { viewport: { value: "mobile" } }, render: () => { const [query, setQuery] = useState(""); const [kind, setKind] = useState("all"); diff --git a/ui/storybook/stories/blocked-inbox.stories.tsx b/ui/storybook/stories/blocked-inbox.stories.tsx index 6aaf17ed9f..b4a5841cd2 100644 --- a/ui/storybook/stories/blocked-inbox.stories.tsx +++ b/ui/storybook/stories/blocked-inbox.stories.tsx @@ -292,7 +292,7 @@ export const DesktopWithSearch: Story = { }; export const MobileLayout: Story = { - parameters: { viewport: { defaultViewport: "mobile1" } }, + globals: { viewport: { value: "mobile1" } }, render: () => , }; diff --git a/ui/storybook/stories/document-annotations.stories.tsx b/ui/storybook/stories/document-annotations.stories.tsx index a851bf4297..63a18f0c6e 100644 --- a/ui/storybook/stories/document-annotations.stories.tsx +++ b/ui/storybook/stories/document-annotations.stories.tsx @@ -526,29 +526,29 @@ type Story = StoryObj; // --------------------------------------------------------------------------- export const IntegratedDesktopOpen: Story = { - parameters: { viewport: { defaultViewport: "responsive" } }, + globals: { viewport: { value: "100pct-100pct" } }, render: () => , }; export const IntegratedDesktopZeroComments: Story = { - parameters: { viewport: { defaultViewport: "responsive" } }, + globals: { viewport: { value: "100pct-100pct" } }, render: () => , }; export const IntegratedDesktopEditMode: Story = { - parameters: { viewport: { defaultViewport: "responsive" } }, + globals: { viewport: { value: "100pct-100pct" } }, render: () => ( ), }; export const IntegratedDesktopDirtyDraft: Story = { - parameters: { viewport: { defaultViewport: "responsive" } }, + globals: { viewport: { value: "100pct-100pct" } }, render: () => , }; export const IntegratedMobileBottomSheet: Story = { - parameters: { viewport: { defaultViewport: "mobile1" } }, + globals: { viewport: { value: "mobile1" } }, render: () => , }; diff --git a/ui/storybook/stories/file-viewer.stories.tsx b/ui/storybook/stories/file-viewer.stories.tsx index 3d1b6a9007..aa3ddaa437 100644 --- a/ui/storybook/stories/file-viewer.stories.tsx +++ b/ui/storybook/stories/file-viewer.stories.tsx @@ -494,11 +494,7 @@ export const TooLargeToPreview: Story = { export const MobileView: Story = { name: "Mobile — 390×844 highlighted line", - parameters: { - viewport: { - defaultViewport: "mobile", - }, - }, + globals: { viewport: { value: "mobile" } }, render: () => { const resource = buildResource(); const content = buildContent(resource); diff --git a/ui/storybook/stories/issue-management.stories.tsx b/ui/storybook/stories/issue-management.stories.tsx index c5f5071207..06fc95976f 100644 --- a/ui/storybook/stories/issue-management.stories.tsx +++ b/ui/storybook/stories/issue-management.stories.tsx @@ -850,5 +850,5 @@ export const IssuePropertiesModelOverride: Story = { export const IssuePropertiesMobileBlockerActions: Story = { name: "IssueProperties - mobile blocker actions open", render: () => , - parameters: { viewport: { defaultViewport: "mobile1" } }, + globals: { viewport: { value: "mobile1" } }, }; diff --git a/ui/storybook/stories/issue-thread-interactions.stories.tsx b/ui/storybook/stories/issue-thread-interactions.stories.tsx index 571dc7269d..371082cbda 100644 --- a/ui/storybook/stories/issue-thread-interactions.stories.tsx +++ b/ui/storybook/stories/issue-thread-interactions.stories.tsx @@ -871,9 +871,7 @@ export const ConnectionIntentSetupDialogMobile: Story = { ), - parameters: { - viewport: { defaultViewport: "mobile" }, - }, + globals: { viewport: { value: "mobile" } }, }; // --------------------------------------------------------------------------- @@ -1301,9 +1299,7 @@ export const ToolActionMobile: Story = { ), - parameters: { - viewport: { defaultViewport: "mobile1" }, - }, + globals: { viewport: { value: "mobile1" } }, }; export const CheckboxConfirmationPending: Story = { diff --git a/ui/storybook/stories/rich-work-product-cards.stories.tsx b/ui/storybook/stories/rich-work-product-cards.stories.tsx new file mode 100644 index 0000000000..3b4e91f6ae --- /dev/null +++ b/ui/storybook/stories/rich-work-product-cards.stories.tsx @@ -0,0 +1,305 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { IssueAttachment, IssueWorkProduct } from "@paperclipai/shared"; +import { expect } from "storybook/test"; +import { RichWorkProductCard } from "../../src/components/task-chat/RichWorkProductCard"; +import { TaskChatBubble } from "../../src/components/task-chat/TaskChatBubble"; +import type { TaskChatMessageItem } from "../../src/components/task-chat/task-chat-model"; + +const meta = { + title: "Task Chat/Rich Work Product Cards", + component: RichWorkProductCard, + parameters: { layout: "centered" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +type CardKind = { + id: string; + label: string; + type: IssueWorkProduct["type"]; + provider: string; + title: string; + url: string; + metadata: Record; +}; + +type CardState = { + id: string; + label: string; + status: string; + reviewState?: IssueWorkProduct["reviewState"]; + healthStatus?: IssueWorkProduct["healthStatus"]; +}; + +const IMAGE_PREVIEW = + "data:image/svg+xml;utf8," + + encodeURIComponent( + "", + ); + +const KINDS: CardKind[] = [ + { + id: "pull-request", + label: "Pull request", + type: "pull_request", + provider: "github", + title: "Add rich work-product cards", + url: "https://github.com/paperclipai/paperclip/pull/12717", + metadata: { repo: "paperclipai/paperclip", number: 12717, baseRef: "master", headRef: "rich-cards" }, + }, + { + id: "commit", + label: "Commit", + type: "commit", + provider: "github", + title: "Render kind-specific work products", + url: "https://github.com/paperclipai/paperclip/commit/9c12ae7b41e5", + metadata: { sha: "9c12ae7b41e5", branch: "rich-cards" }, + }, + { + id: "branch", + label: "Branch", + type: "branch", + provider: "github", + title: "rich-cards", + url: "https://github.com/paperclipai/paperclip/tree/rich-cards", + metadata: { repository: "paperclipai/paperclip", branch: "rich-cards" }, + }, + { + id: "artifact-file", + label: "Artifact · file", + type: "artifact", + provider: "paperclip", + title: "interaction-map.pdf", + url: "/api/attachments/story-file/content", + metadata: { contentType: "application/pdf", byteSize: 48_120 }, + }, + { + id: "artifact-image", + label: "Artifact · image", + type: "artifact", + provider: "paperclip", + title: "thread-preview.png", + url: IMAGE_PREVIEW, + metadata: { contentType: "image/png", byteSize: 204_800, openPath: IMAGE_PREVIEW }, + }, + { + id: "document", + label: "Document", + type: "document", + provider: "paperclip", + title: "Implementation plan", + url: "/PAP/issues/PAP-18213#document-plan", + metadata: { revisionNumber: 4 }, + }, + { + id: "preview-url", + label: "Preview URL", + type: "preview_url", + provider: "custom", + title: "Rich cards preview", + url: "https://preview.paperclip.ing/rich-cards", + metadata: {}, + }, + { + id: "runtime-service", + label: "Runtime service", + type: "runtime_service", + provider: "paperclip", + title: "Storybook", + url: "http://localhost:6006", + metadata: { service: "storybook", port: 6006 }, + }, +]; + +const STATES: CardState[] = [ + { id: "resting", label: "Resting", status: "approved" }, + { id: "pending", label: "Pending", status: "pending" }, + { id: "failed", label: "Failed", status: "failed" }, + { id: "needs-review", label: "Needs review", status: "ready_for_review", reviewState: "needs_board_review" }, + { id: "changes-requested", label: "Changes requested", status: "changes_requested", reviewState: "changes_requested" }, +]; + +const PR_STATES: CardState[] = [ + { id: "open", label: "Open", status: "open" }, + { id: "draft", label: "Draft", status: "draft" }, + { id: "merged", label: "Merged", status: "merged" }, + { id: "closed", label: "Closed", status: "closed" }, +]; + +const RUNTIME_STATES: CardState[] = [ + { id: "running", label: "Running", status: "active", healthStatus: "healthy" }, + { id: "stopped", label: "Stopped", status: "closed", healthStatus: "healthy" }, + { id: "unhealthy", label: "Unhealthy", status: "active", healthStatus: "unhealthy" }, +]; + +function product(kind: CardKind, state: CardState, withStats = false): IssueWorkProduct { + return { + id: `${kind.id}-${state.id}-${withStats ? "stats" : "plain"}`, + companyId: "company-storybook", + projectId: null, + issueId: "issue-storybook", + executionWorkspaceId: null, + runtimeServiceId: null, + type: kind.type, + provider: kind.provider, + externalId: kind.type === "commit" ? "9c12ae7b41e5" : null, + title: kind.title, + url: kind.url, + status: state.status, + reviewState: state.reviewState ?? "none", + isPrimary: false, + healthStatus: state.healthStatus ?? (state.id === "failed" ? "unhealthy" : "healthy"), + summary: null, + metadata: { + ...kind.metadata, + ...(kind.type === "pull_request" ? { state: state.status, draft: state.status === "draft" } : {}), + ...(withStats ? { additions: 214, deletions: 18, changedFiles: 3 } : {}), + }, + createdByRunId: null, + createdAt: new Date("2026-09-02T00:00:00.000Z"), + updatedAt: new Date("2026-09-02T00:00:00.000Z"), + }; +} + +function Matrix({ kinds = KINDS, states = STATES }: { kinds?: CardKind[]; states?: CardState[] }) { + return ( +
+ {kinds.map((kind) => ( +
+

{kind.label}

+
+ {states.flatMap((state) => [false, true].map((withStats) => { + const workProduct = product(kind, state, withStats); + return ( +
+ {state.label} · {withStats ? "with stats" : "without stats"} + +
+ ); + }))} +
+
+ ))} +
+ ); +} + +export const KindByStateMatrix: Story = { + args: { workProduct: product(KINDS[0], STATES[0]), href: KINDS[0].url }, + render: () => , +}; + +/** The original one-card-per-kind inventory, retained as a compact comparison baseline. */ +export const PreviousInventory: Story = { + args: { workProduct: product(KINDS[0], STATES[0]), href: KINDS[0].url }, + render: () => ( +
+ {KINDS.map((kind) => { + const workProduct = product(kind, STATES[0]); + return ; + })} +
+ ), +}; + +export const PullRequestLifecycle: Story = { + args: { workProduct: product(KINDS[0], PR_STATES[0]), href: KINDS[0].url }, + render: () => , +}; + +export const RuntimeServiceLifecycle: Story = { + args: { workProduct: product(KINDS[7], RUNTIME_STATES[0]), href: KINDS[7].url }, + render: () => , + play: async ({ canvasElement }) => { + const chipLabels = [...canvasElement.querySelectorAll(".status-chip")].map((chip) => chip.textContent); + expect(chipLabels).toEqual(["Running", "Running", "Stopped", "Stopped", "Unhealthy", "Unhealthy"]); + }, +}; + +export const LongTitleTruncation: Story = { + args: { workProduct: product(KINDS[0], STATES[0]), href: KINDS[0].url }, + render: () => { + const longTitle = "Implement the complete rich work-product card inventory with an intentionally long title that must truncate cleanly"; + return ( +
+ +
+ ); + }, +}; + +export const Mobile375: Story = { + args: { workProduct: product(KINDS[0], STATES[0]), href: KINDS[0].url }, + parameters: { + layout: "fullscreen", + viewport: { + options: { mobile375: { name: "Mobile 375", styles: { width: "375px", height: "812px" } } }, + }, + }, + globals: { viewport: { value: "mobile375" } }, + render: () => ( +
+ {KINDS.map((kind) => { + const workProduct = product(kind, kind.type === "pull_request" ? PR_STATES[0] : STATES[3], true); + return ; + })} +
+ ), +}; + +function attachment(id: string, name: string, contentType: string, byteSize: number): IssueAttachment { + return { + id, + companyId: "company-storybook", + issueId: "issue-storybook", + issueCommentId: "message-storybook", + assetId: `asset-${id}`, + provider: "paperclip", + objectKey: id, + contentType, + byteSize, + sha256: id, + originalFilename: name, + createdByAgentId: "agent-storybook", + createdByUserId: null, + createdAt: new Date("2026-09-02T00:00:00.000Z"), + updatedAt: new Date("2026-09-02T00:00:00.000Z"), + contentPath: `/api/attachments/${id}/content`, + openPath: contentType.startsWith("image/") ? IMAGE_PREVIEW : `/api/attachments/${id}/content`, + downloadPath: `/api/attachments/${id}/content?download=1`, + }; +} + +const MESSAGE_ATTACHMENTS = [ + ...Array.from({ length: 5 }, (_, index) => attachment(`shot-${index + 1}`, `desktop ${index + 1}.png`, "image/png", (index + 1) * 18_000)), + attachment("run-log", "verification.log", "text/plain", 14 * 1024), + attachment("patch", "rich-cards.patch", "text/x-diff", 2_640), + attachment("spec", "review-spec.pdf", "application/pdf", 300 * 1024), +]; + +const MESSAGE_ITEM: TaskChatMessageItem = { + id: "message-storybook", + kind: "message", + author: "agent", + authorName: "CodexCoder", + text: [ + "Implemented the review inventory.", + "", + ...MESSAGE_ATTACHMENTS.map((item) => `[${item.originalFilename}](${item.contentPath})`), + ].join("\n"), +}; + +export const MessageTailMediaAndTypedChips: Story = { + args: { workProduct: product(KINDS[0], STATES[0]), href: KINDS[0].url }, + parameters: { layout: "fullscreen" }, + render: () => ( +
+ +
+ ), +}; diff --git a/ui/storybook/stories/workspace-service-control-bar.stories.tsx b/ui/storybook/stories/workspace-service-control-bar.stories.tsx index 616f87b60b..62aef3243a 100644 --- a/ui/storybook/stories/workspace-service-control-bar.stories.tsx +++ b/ui/storybook/stories/workspace-service-control-bar.stories.tsx @@ -188,9 +188,7 @@ export const MobileWidth: Story = { ), ], args: { services: [entry()] }, - parameters: { - viewport: { defaultViewport: "mobile1" }, - }, + globals: { viewport: { value: "mobile1" } }, }; export const AllStates: Story = {