From 87d05e194b643810d16d20612115acd01d735d43 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:27:54 -0500 Subject: [PATCH] feat(work-products): add rich cards and run artifact inventory (#12717) ## Thinking Path > - Paperclip is the control plane for AI-agent companies. > - Agent outputs must remain visible after a run and easy to inspect from a task. > - The thread and artifact inventory need one consistent rich-card vocabulary. > - Run uploads also need durable artifact registration and producing-run context. > - Reviewers need deterministic examples for each rich-card kind and state. > - This pull request adds the shared presentation, registration, inventory, and Storybook review coverage. > - The benefit is a complete output path that reviewers can inspect without seeded data. ## Linked Issues or Issue Description **What existing behavior does this improve?** This change improves work-product presentation in task threads and the task Artifacts tab. **Subsystem affected** The change affects shared work-product contracts, the runner diff path, server attachment and work-product services, GitHub metadata refresh, the React board UI, and Storybook. **Current behavior** The thread used generic cards. Some files uploaded by a run existed only as message attachments. The Artifacts tab showed a flat list without run context or filters. Storybook showed only one resting card per kind. **Proposed behavior** The thread uses rich cards for supported work-product types. Each run-produced file registers one attachment-backed artifact work product. The Artifacts tab groups outputs by run and supports filters. Storybook shows every kind and requested state, PR lifecycle states, stats variants, truncation, mobile layout, and message-tail media. **Reason and benefit** Users can identify outputs quickly. Reviewers can inspect all card permutations without creating task data. **Breaking changes** None. The metadata fields and automatic artifact registration are additive. Existing attachments and work products keep their current behavior. ## What Changed - Added a shared rich work-product card with kind-specific content and a compact inventory variant. - Added pull-request and commit diff metadata plus bounded GitHub state refresh. - Added media strips and typed file chips to message-tail attachments. - Registered each run-produced attachment as an artifact work product in the same server transaction. - Grouped task artifacts by run with agent and timestamp headings. - Added type and run filters, image thumbnails, compact cards, and a company Artifacts link. - Added a Storybook kind-by-state matrix with stats variants for all eight visual kinds. - Added PR open, draft, merged, and closed examples, long-title truncation, an exact 375-pixel viewport, and message-tail overflow coverage. - Closed reconciled runtime work products when the linked runtime stops or disappears, so the card shows `Stopped` instead of `Unhealthy`. ### Screenshots Before: one resting card per kind. ![Previous rich-card inventory](https://pages.paperclip.ing/rich-work-product-storybook-20260902/before-inventory.png) After: the kind and state matrix. ![Rich-card kind and state matrix](https://pages.paperclip.ing/rich-work-product-storybook-20260902/after-kind-state-matrix.png) After: message-tail media at 375 pixels. ![Message-tail thumbnails and typed chips](https://pages.paperclip.ing/rich-work-product-storybook-20260902/after-message-tail.png) [Open the Storybook evidence viewer](https://pages.paperclip.ing/rich-work-product-storybook-20260902/). The earlier artifact inventory comparison remains available in the [artifact inventory viewer](https://pages.paperclip.ing/rich-artifacts-inventory-proof-20260902/). ## Verification - `pnpm --filter @paperclipai/ui typecheck` passed. - `pnpm check:token-gates` passed. - `pnpm build-storybook` passed. - `pnpm exec vitest run server/src/__tests__/work-product-runtime-reconciliation.test.ts` passed with 5 tests. - Chromium visual checks passed at desktop and 375-pixel widths. - All 30 latest-head GitHub checks passed. One unrelated annotation test was flaky and passed on its single retry. - Greptile passed at 5/5 with zero unresolved threads. ## Risks - Low risk. The Storybook change adds review fixtures only. The runtime fix changes read-time reconciliation without database writes. - The matrix is intentionally large so every permutation stays visible in one review surface. > I checked `ROADMAP.md`. This work does not duplicate planned core work. ## Model Used - OpenAI Codex with GPT-5 and GPT-5.6-sol across this pull request. Reasoning, tool use, and code execution were enabled. The context-window size is not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My public branch name describes the change and contains no internal task id - [x] I have run tests locally and the changed-path tests pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../drivers/codex/codex-session-workspace.ts | 38 +-- .../src/drivers/codex/codex-turn-diff.test.ts | 13 +- .../src/drivers/codex/codex-turn-diff.ts | 17 + packages/paperclip-runner/src/index.ts | 2 +- packages/shared/src/index.ts | 3 + packages/shared/src/types/index.ts | 3 + packages/shared/src/types/work-product.ts | 23 ++ .../storybook-viewport-config.test.mjs | 30 ++ .../__tests__/github-commit-details.test.ts | 67 ++++ ...ue-agent-mutation-ownership-routes.test.ts | 75 ++++ server/src/__tests__/issues-service.test.ts | 80 +++++ ...ork-product-runtime-reconciliation.test.ts | 7 +- server/src/__tests__/work-products.test.ts | 100 +++++- server/src/routes/issues.ts | 60 +++- server/src/routes/openapi.ts | 5 +- server/src/services/github-commit-details.ts | 131 +++++++ .../github-external-object-provider.ts | 10 + .../src/services/github-pull-request-merge.ts | 22 +- server/src/services/index.ts | 5 +- server/src/services/issues.ts | 43 +++ server/src/services/work-products.ts | 176 ++++++++-- ui/src/api/issues.ts | 5 +- ui/src/components/TaskChatThread.tsx | 1 + .../IssuePropertiesArtifactsTab.tsx | 321 ++++++++++-------- ...ssuePropertiesDocumentAnnotations.test.tsx | 5 +- ...ssuePropertiesMarkdownWorkProduct.test.tsx | 88 ++++- .../task-chat/RichWorkProductCard.tsx | 248 ++++++++++++++ .../task-chat/TaskChatBubble.test.tsx | 95 +++++- .../components/task-chat/TaskChatBubble.tsx | 162 ++++++--- .../task-chat/TaskChatProtocolCard.test.tsx | 222 ++++++++++++ .../task-chat/TaskChatProtocolCard.tsx | 30 +- .../task-chat/TaskChatThreadView.tsx | 19 ++ .../task-chat/task-chat-attachments.test.ts | 94 ++++- .../task-chat/task-chat-attachments.ts | 117 ++++++- ui/src/pages/IssueDetail.tsx | 3 +- ui/storybook/.storybook/preview.tsx | 4 +- ui/storybook/stories/artifacts.stories.tsx | 2 +- .../stories/blocked-inbox.stories.tsx | 2 +- .../stories/document-annotations.stories.tsx | 10 +- ui/storybook/stories/file-viewer.stories.tsx | 6 +- .../stories/issue-management.stories.tsx | 2 +- .../issue-thread-interactions.stories.tsx | 8 +- .../rich-work-product-cards.stories.tsx | 305 +++++++++++++++++ .../workspace-service-control-bar.stories.tsx | 4 +- 44 files changed, 2366 insertions(+), 297 deletions(-) create mode 100644 scripts/__tests__/storybook-viewport-config.test.mjs create mode 100644 server/src/__tests__/github-commit-details.test.ts create mode 100644 server/src/services/github-commit-details.ts create mode 100644 ui/src/components/task-chat/RichWorkProductCard.tsx create mode 100644 ui/storybook/stories/rich-work-product-cards.stories.tsx 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 = {