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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-02 15:27:54 -05:00 committed by GitHub
parent 141c5b1340
commit 87d05e194b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 2366 additions and 297 deletions

View File

@ -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<string, unknown>[] => {
.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);

View File

@ -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",

View File

@ -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<ParsedCodexTurnDiffFile, "additions" | "deletions">[],
): 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;

View File

@ -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,

View File

@ -1049,6 +1049,9 @@ export type {
DocumentTextRange,
UpdateDocumentAnnotationThreadRequest,
AttachmentArtifactWorkProductMetadata,
PullRequestWorkProductState,
PullRequestWorkProductMetadata,
CommitWorkProductMetadata,
ExternalObject,
ExternalObjectMention,
ExternalObjectMentionGroup,

View File

@ -616,6 +616,9 @@ export type {
IssueWorkProductStatus,
IssueWorkProductReviewState,
AttachmentArtifactWorkProductMetadata,
PullRequestWorkProductState,
PullRequestWorkProductMetadata,
CommitWorkProductMetadata,
} from "./work-product.js";
export type {
CompanyArtifact,

View File

@ -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;
}

View File

@ -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",
);
});

View File

@ -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: '<https://api.github.com/repos/acme/app/commits/abc1234?per_page=100&page=2>; 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();
});
});

View File

@ -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<string, unknown> | 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());

View File

@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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();

View File

@ -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");
});

View File

@ -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<Record<string, unknown>> = {}) {
const now = new Date("2026-03-17T00:00:00.000Z");
@ -29,6 +34,99 @@ function createWorkProductRow(overrides: Partial<Record<string, unknown>> = {})
}
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 }));

View File

@ -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<string, unknown> | 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) => {

View File

@ -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 },
});

View File

@ -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<Response>;
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<GitHubCommitDiffDetails | null>;
export interface GitHubCommitDetailsResolverOptions {
fetch?: FetchLike;
tokenProvider?: (companyId: string) => Promise<string | null> | 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<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: 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<string, string> = {
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;
}
};
}

View File

@ -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<string, unknown>, 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<string
const headSha = asNestedString(body, "head", "sha");
const baseRef = asNestedString(body, "base", "ref");
const reviewDecision = asString(body.review_decision);
const additions = asNonNegativeInteger(body.additions);
const deletions = asNonNegativeInteger(body.deletions);
const changedFiles = asNonNegativeInteger(body.changed_files);
let statusKey = state;
let statusLabel = state === "open" ? "Open" : state === "closed" ? "Closed" : "Unknown";
@ -256,6 +263,9 @@ function pullRequestSnapshot(identity: GitHubObjectIdentity, body: Record<string
...(headSha ? { headSha } : {}),
...(baseRef ? { baseRef } : {}),
...(reviewDecision ? { reviewDecision } : {}),
...(additions !== null ? { additions } : {}),
...(deletions !== null ? { deletions } : {}),
...(changedFiles !== null ? { changedFiles } : {}),
},
};
}

View File

@ -14,6 +14,12 @@ export type PullRequestMergeDetails = {
state: PullRequestMergeState;
headRef: string | null;
headSha: string | null;
workProductState?: "open" | "draft" | "merged" | "closed";
draft?: boolean;
baseRef?: string | null;
additions?: number | null;
deletions?: number | null;
changedFiles?: number | null;
};
export type PullRequestMergeStateResolver = (
@ -95,12 +101,24 @@ export function createPullRequestMergeDetailsResolver(db: Db): PullRequestMergeD
});
if (!result.ok) return { state: "unknown", headRef: null, headSha: null };
const data = readRecord(result.snapshot.data);
const statusKey = result.snapshot.statusKey;
const workProductState = statusKey === "open" || statusKey === "draft" || statusKey === "merged" || statusKey === "closed"
? statusKey
: undefined;
return {
state: result.snapshot.statusKey === "merged" || data?.merged === true
state: statusKey === "merged" || data?.merged === true
? "merged"
: "open",
: statusKey === "open" || statusKey === "draft" || statusKey === "closed"
? "open"
: "unknown",
headRef: typeof data?.headRef === "string" ? data.headRef : null,
headSha: typeof data?.headSha === "string" ? data.headSha : null,
...(workProductState ? { workProductState } : {}),
draft: data?.draft === true,
baseRef: typeof data?.baseRef === "string" ? data.baseRef : null,
additions: typeof data?.additions === "number" ? data.additions : null,
deletions: typeof data?.deletions === "number" ? data.deletions : null,
changedFiles: typeof data?.changedFiles === "number" ? data.changedFiles : null,
};
};
}

View File

@ -183,7 +183,10 @@ export {
workspaceGitOperationScheduler,
type WorkspaceGitSchedulerSnapshot,
} from "./workspace-git-operation-scheduler.js";
export { workProductService } from "./work-products.js";
export {
enrichWorkProductMetadataWithDiff,
workProductService,
} from "./work-products.js";
export {
logActivity,
persistActivity,

View File

@ -28,6 +28,7 @@ import {
issueRelations,
issueComments,
issueDocuments,
issueWorkProducts,
issueReadStates,
issueThreadInteractions,
issues,
@ -9090,6 +9091,7 @@ export function issueService(db: Db) {
originalFilename?: string | null;
createdByAgentId?: string | null;
createdByUserId?: string | null;
createdByRunId?: string | null;
}) => {
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,
};
});
},

View File

@ -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<string, unknown>;
const wrappedEvent = outer.prpEvent && typeof outer.prpEvent === "object" && !Array.isArray(outer.prpEvent)
? outer.prpEvent as Record<string, unknown>
: 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<string, unknown>).totals;
if (!totals || typeof totals !== "object" || Array.isArray(totals)) return null;
const values = totals as Record<string, unknown>;
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<string, unknown> | null | undefined,
summary: WorkProductDiffSummary | null,
): Record<string, unknown> | 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<IssueWorkProduct[]> {
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<WorkProductDiffSummary | null> => {
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<string, unknown> | null },
): Promise<WorkProductDiffSummary | null> => {
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) => {

View File

@ -377,7 +377,10 @@ export const issuesApi = {
api.post<Approval[]>(`/issues/${id}/approvals`, { approvalId }),
unlinkApproval: (id: string, approvalId: string) =>
api.delete<{ ok: true }>(`/issues/${id}/approvals/${approvalId}`),
listWorkProducts: (id: string) => api.get<IssueWorkProduct[]>(`/issues/${id}/work-products`),
listWorkProducts: (id: string, options?: { refreshPullRequests?: boolean }) =>
api.get<IssueWorkProduct[]>(
`/issues/${id}/work-products${options?.refreshPullRequests ? "?refreshPullRequests=true" : ""}`,
),
ensureWorkProductReviewDocument: (id: string, workProductId: string) =>
api.post<IssueDocument>(`/issues/${id}/work-products/${workProductId}/review-document`, {}),
createWorkProduct: (id: string, data: Record<string, unknown>) =>

View File

@ -2199,6 +2199,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
) : (
<TaskChatThreadView
items={items}
attachments={attachments}
header={threadHeaderWithBlockers}
renderInteraction={renderInteraction}
renderBrief={

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
@ -19,16 +19,12 @@ import {
Download,
ExternalLink,
FileText,
GitBranch,
GitCommit,
Globe,
Package,
Paperclip,
Server,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { ApiError } from "@/api/client";
import { issuesApi } from "@/api/issues";
import { activityApi } from "@/api/activity";
import { agentsApi } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys";
import { useIssueDocuments } from "@/hooks/useIssueDocuments";
import {
@ -38,9 +34,10 @@ import {
} from "@/lib/issue-artifacts";
import { attachmentOpenPath } from "@/lib/issue-attachments";
import { MarkdownBody } from "@/components/MarkdownBody";
import { RichWorkProductCard } from "@/components/task-chat/RichWorkProductCard";
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "@/components/IssueDocumentAnnotations";
import { cn } from "@/lib/utils";
import { useLocation } from "@/lib/router";
import { cn, formatDateTime } from "@/lib/utils";
import { Link, useLocation } from "@/lib/router";
interface IssuePropertiesArtifactsTabProps {
issue: Issue;
@ -57,18 +54,6 @@ function formatBytes(n: number): string {
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function workProductIcon(type: string): LucideIcon {
switch (type) {
case "document": return FileText;
case "pull_request": return GitBranch;
case "branch": return GitBranch;
case "commit": return GitCommit;
case "preview_url": return Globe;
case "runtime_service": return Server;
default: return Package;
}
}
/** Work-product status → label + `--status-task-*` base-hue var for `.status-chip`. */
function workProductStatusBadge(status: string): { label: string; cssVar: string } | null {
switch (status) {
@ -89,53 +74,9 @@ function workProductStatusBadge(status: string): { label: string; cssVar: string
}
}
function SectionHeading({ children }: { children: string }) {
return (
<h3 className="px-1 pt-1 text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
{children}
</h3>
);
}
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 = (
<>
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{workProduct.title}</span>
{badge ? (
<span
className="status-chip inline-flex shrink-0 items-center rounded-full border px-1.5 py-0.5 text-(length:--text-nano) leading-none whitespace-nowrap"
style={{ "--sc": `var(${badge.cssVar})` } as CSSProperties}
>
{badge.label}
</span>
) : null}
{href ? (
<ExternalLink className="h-3 w-3 shrink-0 text-muted-foreground" />
) : null}
</>
);
if (href) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className={cn(ROW_CLASS, "hover:bg-accent/50")}
>
{body}
</a>
);
}
return <div className={ROW_CLASS}>{body}</div>;
}
/**
* 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<typeof fileRows>[number] };
const allRows = useMemo<ArtifactRow[]>(() => [
...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<string, ArtifactRow[]>())]
.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 (
<div className="flex flex-col gap-2 py-2">
{workProductRows.length > 0 ? (
<>
<SectionHeading>Work products</SectionHeading>
<ul className="flex flex-col gap-1">
{workProductRows.map((wp) => {
const markdownMetadata = getMarkdownWorkProductAttachmentMetadata(wp);
if (!markdownMetadata) {
return (
<li key={wp.id}>
<WorkProductRow workProduct={wp} />
</li>
);
}
const reviewKey = artifactReviewDocumentKey(wp.id);
<div className="flex flex-col gap-3 py-2">
<div className="flex items-center gap-2 px-1">
<label className="min-w-0 flex-1 text-(length:--text-micro) text-muted-foreground">
<span className="sr-only">Filter artifacts by type</span>
<select
aria-label="Filter artifacts by type"
value={typeFilter}
onChange={(event) => setTypeFilter(event.target.value)}
className="w-full rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground"
>
<option value="all">All types</option>
<option value="image">Images</option>
<option value="file">Files</option>
<option value="pull_request">Pull requests</option>
<option value="commit">Commits</option>
<option value="branch">Branches</option>
<option value="document">Documents</option>
<option value="preview_url">Previews</option>
<option value="runtime_service">Runtime services</option>
</select>
</label>
<label className="min-w-0 flex-1 text-(length:--text-micro) text-muted-foreground">
<span className="sr-only">Filter artifacts by run</span>
<select
aria-label="Filter artifacts by run"
value={runFilter}
onChange={(event) => setRunFilter(event.target.value)}
className="w-full rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground"
>
<option value="all">All runs</option>
{runOptions.map((runId) => {
const run = runsById.get(runId);
const agent = run ? agentsById.get(run.agentId) : null;
const runDate = run?.startedAt ?? allRows.find((row) => row.runId === runId)?.date;
return (
<li key={wp.id}>
<MarkdownWorkProductRow
issueId={issue.id}
workProduct={wp}
metadata={markdownMetadata}
reviewDoc={reviewDocsByKey.get(reviewKey)}
openRequestId={documentDeepLink?.documentKey === reviewKey
? documentDeepLink.requestId
: undefined}
/>
</li>
<option key={runId} value={runId}>
{`${agent?.name ?? `Run ${runId.slice(0, 8)}`}${runDate ? ` · ${formatDateTime(runDate)}` : ""}`}
</option>
);
})}
</ul>
</>
) : null}
{documentRows.length > 0 ? (
<>
<SectionHeading>Documents</SectionHeading>
<ul className="flex flex-col gap-1">
{documentRows.map((doc) => (
<li key={doc.key}>
<DocumentRow
issueId={issue.id}
doc={doc}
onOpen={onOpenDocument ? () => onOpenDocument(doc) : undefined}
openRequestId={documentDeepLink?.documentKey === doc.key
? documentDeepLink.requestId
: undefined}
/>
</li>
))}
</ul>
</>
) : null}
{fileRows.length > 0 ? (
<>
<SectionHeading>Files</SectionHeading>
<ul className="flex flex-col gap-1">
{fileRows.map((a) => (
<li key={a.id}>
<a
href={attachmentOpenPath(a)}
target="_blank"
rel="noreferrer"
className={cn(ROW_CLASS, "hover:bg-accent/50")}
>
<Paperclip className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{a.originalFilename ?? a.objectKey}</span>
<span className="shrink-0 text-(length:--text-micro) text-muted-foreground">{formatBytes(a.byteSize)}</span>
</a>
</li>
))}
</ul>
</>
) : null}
{allRows.some((row) => row.runId === null) ? <option value="other">Other artifacts</option> : null}
</select>
</label>
</div>
{groupedRows.length === 0 ? (
<p className="px-1 py-6 text-sm text-muted-foreground">No artifacts match these filters.</p>
) : groupedRows.map((group) => {
const run = group.runId === "other" ? null : runsById.get(group.runId);
const agent = run ? agentsById.get(run.agentId) : null;
return (
<section key={group.runId} className="flex flex-col gap-1.5">
<header className="flex items-baseline justify-between gap-2 px-1">
<h3 className="truncate text-xs font-medium text-foreground">
{group.runId === "other" ? "Other artifacts" : agent?.name ?? `Run ${group.runId.slice(0, 8)}`}
</h3>
<time className="shrink-0 text-(length:--text-micro) text-muted-foreground" dateTime={group.date.toISOString()}>
{formatDateTime(group.date)}
</time>
</header>
<ul className="flex flex-col gap-1">
{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 (
<li key={row.id}>
<MarkdownWorkProductRow
issueId={issue.id}
workProduct={wp}
metadata={markdownMetadata}
reviewDoc={reviewDocsByKey.get(reviewKey)}
openRequestId={documentDeepLink?.documentKey === reviewKey ? documentDeepLink.requestId : undefined}
/>
</li>
);
}
return (
<li key={row.id}>
<RichWorkProductCard workProduct={wp} href={workProductHref(wp)} variant="compact" />
</li>
);
}
if (row.kind === "document") {
const doc = row.value;
return (
<li key={row.id}>
<DocumentRow
issueId={issue.id}
doc={doc}
onOpen={onOpenDocument ? () => onOpenDocument(doc) : undefined}
openRequestId={documentDeepLink?.documentKey === doc.key ? documentDeepLink.requestId : undefined}
/>
</li>
);
}
const attachment = row.value;
return (
<li key={row.id}>
<a href={attachmentOpenPath(attachment)} target="_blank" rel="noreferrer" className={cn(ROW_CLASS, "hover:bg-accent/50")}>
<Paperclip className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{attachment.originalFilename ?? attachment.objectKey}</span>
<span className="shrink-0 text-(length:--text-micro) text-muted-foreground">{formatBytes(attachment.byteSize)}</span>
</a>
</li>
);
})}
</ul>
</section>
);
})}
<Link to="/artifacts" className="mx-1 border-t border-border pt-2 text-xs font-medium text-foreground hover:underline">
View all in company Artifacts
</Link>
</div>
);
}

View File

@ -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 }) => <a href={to}>{children}</a>,
useLocation: () => ({ hash: "" }),
}));
vi.mock("@/components/IssuePlanDecompositionsSection", () => ({ IssuePlanDecompositionsSection: () => null }));
vi.mock("@/components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: string }) => <div>{children}</div> }));
vi.mock("@/components/IssueDocumentAnnotations", () => ({

View File

@ -15,12 +15,19 @@ const mockIssuesApi = vi.hoisted(() => ({
ensureWorkProductReviewDocument: vi.fn(async (): Promise<unknown> => ({})),
}));
vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi }));
const mockActivityApi = vi.hoisted(() => ({ runsForIssue: vi.fn(async (): Promise<unknown[]> => []) }));
vi.mock("@/api/activity", () => ({ activityApi: mockActivityApi }));
const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn(async (): Promise<unknown[]> => []) }));
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 }) => <a href={to} {...props}>{children}</a>,
useLocation: () => ({ hash: "" }),
}));
vi.mock("@/components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: string }) => (
<div data-testid="markdown-body">{children}</div>
@ -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> = {}): 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");
});
});

View File

@ -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<string, unknown> | 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<string, unknown> | 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 (
<span
className={cn(
"status-chip inline-flex shrink-0 items-center rounded-full border px-2 py-1 text-(length:--text-nano) font-medium leading-none",
chip.dashed && "border-dashed",
)}
style={{ "--sc": `var(${cssVar})` } as CSSProperties}
>
{chip.label}
</span>
);
}
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<string | null> = [];
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 (
<article
className={cn(
"@container flex min-w-0 rounded-md border border-border bg-card/60",
compact ? "items-center gap-2 px-2.5 py-1.5" : "items-start gap-3 px-3 py-2.5",
)}
data-testid={`task-chat-rich-work-product-${workProduct.type}`}
data-variant={variant}
>
<div className={cn(
"flex shrink-0 items-center justify-center overflow-hidden rounded-sm bg-muted/60 text-muted-foreground",
compact ? "h-8 w-8" : "h-10 w-10",
)}>
{imagePath ? (
<img src={imagePath} alt="" className="h-full w-full object-cover" />
) : (
<Icon aria-hidden className={compact ? "h-4 w-4" : "h-5 w-5"} />
)}
</div>
<div className="min-w-0 flex-1">
<strong className="block truncate text-sm font-medium text-foreground">{workProduct.title}</strong>
{visibleMeta.length > 0 ? <p className="mt-1 truncate text-xs text-muted-foreground">{visibleMeta.join(" · ")}</p> : null}
{statsLabel ? <p className="mt-1 whitespace-nowrap text-xs text-muted-foreground">{statsLabel}</p> : null}
</div>
<div className={cn("flex shrink-0 items-center", compact ? "gap-1.5" : "gap-2")}>
{chip ? <Chip chip={chip} /> : null}
{href ? (
<a href={href} aria-label={`${action}: ${workProduct.title}`} className="inline-flex items-center gap-1 text-xs font-medium text-foreground hover:underline" target={href.startsWith("http") ? "_blank" : undefined} rel={href.startsWith("http") ? "noreferrer" : undefined}>
{compact ? null : <span className="hidden @sm:inline">{action}</span>}<ExternalLink aria-hidden className="h-3 w-3" />
</a>
) : null}
</div>
</article>
);
}

View File

@ -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(
<ThemeProvider>
<TaskChatBubble item={item} />
<TaskChatBubble item={item} attachments={attachments} />
</ThemeProvider>,
),
);
@ -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>): 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;

View File

@ -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<typeof hydrateAttachmentRefs>[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 (
<div
className={cn(
@ -219,35 +239,87 @@ export function TaskChatBubble({
</MarkdownBody>
</div>
) : null}
{attachmentRefs.length > 0 ? (
<AttachmentGroup
className="max-w-(--pct-85)"
data-testid="task-chat-bubble-attachments"
{imageRefs.length > 0 ? (
<div
className="flex max-w-(--pct-85) flex-col gap-2"
data-testid="task-chat-bubble-media"
>
{attachmentRefs.map((ref) => {
const kind = fileKindForName(ref.name);
const KindIcon = kind.icon;
return (
<Attachment key={ref.url} size="sm">
<AttachmentMedia>
<KindIcon aria-hidden />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle className="max-w-48">
{ref.name}
</AttachmentTitle>
<AttachmentDescription className="max-w-48">
{kind.label}
</AttachmentDescription>
</AttachmentContent>
<AttachmentTrigger
aria-label={`Open ${ref.name}`}
render={<a href={ref.url} target="_blank" rel="noreferrer" />}
/>
</Attachment>
);
})}
</AttachmentGroup>
<span className="text-xs text-muted-foreground">
Screenshots · {imageRefs.length}
</span>
<div className="grid grid-cols-4 gap-2">
{imageRefs
.slice(0, imageRefs.length > 4 ? 3 : 4)
.map((ref, index) => (
<button
key={ref.url}
type="button"
className="group aspect-video min-w-0 overflow-hidden rounded-md bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Open ${ref.name || `image ${index + 1}`}`}
onClick={() => setLightboxSrc(ref.url)}
>
<img
src={ref.openPath ?? ref.url}
alt={ref.name}
loading="lazy"
className="h-full w-full object-cover transition-transform group-hover:scale-(--s-1_02)"
/>
</button>
))}
{imageRefs.length > 4 ? (
<button
type="button"
className="aspect-video min-w-0 rounded-md bg-muted text-sm font-semibold text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Open ${imageRefs.length - 3} more screenshots`}
onClick={() => setLightboxSrc(imageRefs[3].url)}
>
+{imageRefs.length - 3}
</button>
) : null}
</div>
<span className="truncate text-xs text-muted-foreground">
{imageRefs.map((ref) => ref.name || "image").join(" · ")}
</span>
</div>
) : null}
{attachmentRefs.length > 0 ? (
<div className="flex max-w-(--pct-85) flex-col gap-2">
<span className="text-xs text-muted-foreground">
Files · {attachmentRefs.length}
</span>
<AttachmentGroup data-testid="task-chat-bubble-attachments">
{attachmentRefs.map((ref) => {
const kind = fileKindForAttachment(ref);
const KindIcon = kind.icon;
const size = formatFileSize(ref.byteSize);
return (
<Attachment key={ref.url} size="sm">
<AttachmentMedia>
<KindIcon aria-hidden />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle className="max-w-48">
{ref.name}
</AttachmentTitle>
<AttachmentDescription className="max-w-48">
{size ? `${kind.label} · ${size}` : kind.label}
</AttachmentDescription>
</AttachmentContent>
<AttachmentTrigger
aria-label={`Open ${ref.name}`}
render={
<a
href={ref.openPath ?? ref.url}
target="_blank"
rel="noreferrer"
/>
}
/>
</Attachment>
);
})}
</AttachmentGroup>
</div>
) : null}
{!isHuman && item.verificationCaveats?.length ? (
<div

View File

@ -12,6 +12,34 @@ import type {
TaskChatProviderActivityFamily,
TaskChatRuntimeRequestDecision,
} from "./task-chat-model";
import type { IssueWorkProduct } from "@paperclipai/shared";
import { stateChipFor } from "./RichWorkProductCard";
function workProduct(overrides: Partial<IssueWorkProduct> = {}): 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",

View File

@ -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({
<strong className="min-w-0 truncate text-sm font-medium text-foreground">
{title}
</strong>
{status !== "pending" ? (
{status && status !== "pending" ? (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 text-xs capitalize",
@ -205,15 +206,17 @@ function CardShell({
<strong className="text-sm font-medium text-foreground">
{title}
</strong>
<span
className={cn(
"inline-flex items-center gap-1 text-xs capitalize",
statusTone(status),
)}
>
<StatusIcon status={status} className="h-3.5 w-3.5" />
{status.replaceAll("_", " ")}
</span>
{status ? (
<span
className={cn(
"inline-flex items-center gap-1 text-xs capitalize",
statusTone(status),
)}
>
<StatusIcon status={status} className="h-3.5 w-3.5" />
{status.replaceAll("_", " ")}
</span>
) : null}
</div>
{summary ? (
<p className="mt-1 text-sm text-muted-foreground">{summary}</p>
@ -1015,6 +1018,9 @@ function ResourceCard({
}: {
item: Extract<TaskChatProtocolItem, { surface: "resource" }>;
}) {
if (item.resourceKind === "deliverable" && item.workProduct) {
return <RichWorkProductCard workProduct={item.workProduct} href={item.href} />;
}
const Icon =
item.resourceKind === "document"
? FileText
@ -1025,7 +1031,7 @@ function ResourceCard({
<CardShell
icon={Icon}
title={item.title}
status="completed"
status={null}
summary={item.subtitle}
testId={`task-chat-resource-${item.resourceKind}`}
/>

View File

@ -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> | 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,
)}
</div>
))}

View File

@ -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.");
});
});

View File

@ -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 <img> src picks the initial index.
*/
export function extractImageRefs(body: string): AttachmentRef[] {
const refs: AttachmentRef[] = [];
const seen = new Set<string>();
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<string>();
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 });

View File

@ -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<IssueWorkProduct[]>(
issueId ?? "pending",
),

View File

@ -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" },

View File

@ -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<StoryArtifactKindFilter>("all");

View File

@ -292,7 +292,7 @@ export const DesktopWithSearch: Story = {
};
export const MobileLayout: Story = {
parameters: { viewport: { defaultViewport: "mobile1" } },
globals: { viewport: { value: "mobile1" } },
render: () => <BlockedTabSurfaceMobile />,
};

View File

@ -526,29 +526,29 @@ type Story = StoryObj<typeof meta>;
// ---------------------------------------------------------------------------
export const IntegratedDesktopOpen: Story = {
parameters: { viewport: { defaultViewport: "responsive" } },
globals: { viewport: { value: "100pct-100pct" } },
render: () => <IntegratedSurface focusedThreadId="open-1" initialPanelOpen />,
};
export const IntegratedDesktopZeroComments: Story = {
parameters: { viewport: { defaultViewport: "responsive" } },
globals: { viewport: { value: "100pct-100pct" } },
render: () => <IntegratedSurface threads={[]} initialPanelOpen={false} focusedThreadId={null} />,
};
export const IntegratedDesktopEditMode: Story = {
parameters: { viewport: { defaultViewport: "responsive" } },
globals: { viewport: { value: "100pct-100pct" } },
render: () => (
<IntegratedSurface focusedThreadId="open-1" initialPanelOpen beginEditOnMount />
),
};
export const IntegratedDesktopDirtyDraft: Story = {
parameters: { viewport: { defaultViewport: "responsive" } },
globals: { viewport: { value: "100pct-100pct" } },
render: () => <DirtyDraftWithIntegratedHeader />,
};
export const IntegratedMobileBottomSheet: Story = {
parameters: { viewport: { defaultViewport: "mobile1" } },
globals: { viewport: { value: "mobile1" } },
render: () => <IntegratedSurface focusedThreadId="open-1" initialPanelOpen />,
};

View File

@ -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);

View File

@ -850,5 +850,5 @@ export const IssuePropertiesModelOverride: Story = {
export const IssuePropertiesMobileBlockerActions: Story = {
name: "IssueProperties - mobile blocker actions open",
render: () => <IssuePropertiesMobileBlockerActionsPane />,
parameters: { viewport: { defaultViewport: "mobile1" } },
globals: { viewport: { value: "mobile1" } },
};

View File

@ -871,9 +871,7 @@ export const ConnectionIntentSetupDialogMobile: Story = {
<OpenConnectionIntentDialogStory />
</StoryFrame>
),
parameters: {
viewport: { defaultViewport: "mobile" },
},
globals: { viewport: { value: "mobile" } },
};
// ---------------------------------------------------------------------------
@ -1301,9 +1299,7 @@ export const ToolActionMobile: Story = {
</ScenarioCard>
</StoryFrame>
),
parameters: {
viewport: { defaultViewport: "mobile1" },
},
globals: { viewport: { value: "mobile1" } },
};
export const CheckboxConfirmationPending: Story = {

View File

@ -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<typeof RichWorkProductCard>;
export default meta;
type Story = StoryObj<typeof meta>;
type CardKind = {
id: string;
label: string;
type: IssueWorkProduct["type"];
provider: string;
title: string;
url: string;
metadata: Record<string, unknown>;
};
type CardState = {
id: string;
label: string;
status: string;
reviewState?: IssueWorkProduct["reviewState"];
healthStatus?: IssueWorkProduct["healthStatus"];
};
const IMAGE_PREVIEW =
"data:image/svg+xml;utf8," +
encodeURIComponent(
"<svg xmlns='http://www.w3.org/2000/svg' width='640' height='360'><rect width='640' height='360' fill='#3158d4'/><circle cx='320' cy='180' r='72' fill='#f5f7ff'/></svg>",
);
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 (
<div className="flex w-(--container-4xl) max-w-full flex-col gap-8 p-6">
{kinds.map((kind) => (
<section key={kind.id} className="flex flex-col gap-3" aria-labelledby={`${kind.id}-heading`}>
<h2 id={`${kind.id}-heading`} className="text-sm font-semibold text-foreground">{kind.label}</h2>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{states.flatMap((state) => [false, true].map((withStats) => {
const workProduct = product(kind, state, withStats);
return (
<div key={workProduct.id} className="flex min-w-0 flex-col gap-1">
<span className="text-xs text-muted-foreground">{state.label} · {withStats ? "with stats" : "without stats"}</span>
<RichWorkProductCard workProduct={workProduct} href={workProduct.url} />
</div>
);
}))}
</div>
</section>
))}
</div>
);
}
export const KindByStateMatrix: Story = {
args: { workProduct: product(KINDS[0], STATES[0]), href: KINDS[0].url },
render: () => <Matrix />,
};
/** 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: () => (
<div className="flex w-(--container-md) max-w-full flex-col gap-3 p-6">
{KINDS.map((kind) => {
const workProduct = product(kind, STATES[0]);
return <RichWorkProductCard key={kind.id} workProduct={workProduct} href={workProduct.url} />;
})}
</div>
),
};
export const PullRequestLifecycle: Story = {
args: { workProduct: product(KINDS[0], PR_STATES[0]), href: KINDS[0].url },
render: () => <Matrix kinds={[KINDS[0]]} states={PR_STATES} />,
};
export const RuntimeServiceLifecycle: Story = {
args: { workProduct: product(KINDS[7], RUNTIME_STATES[0]), href: KINDS[7].url },
render: () => <Matrix kinds={[KINDS[7]]} states={RUNTIME_STATES} />,
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 (
<div className="w-(--container-sm) max-w-full p-6">
<RichWorkProductCard
workProduct={{ ...product(KINDS[0], STATES[3], true), title: longTitle }}
href={KINDS[0].url}
/>
</div>
);
},
};
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: () => (
<div className="flex w-full flex-col gap-3 p-4">
{KINDS.map((kind) => {
const workProduct = product(kind, kind.type === "pull_request" ? PR_STATES[0] : STATES[3], true);
return <RichWorkProductCard key={kind.id} workProduct={workProduct} href={workProduct.url} />;
})}
</div>
),
};
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: () => (
<div className="mx-auto w-full max-w-2xl p-6">
<TaskChatBubble item={MESSAGE_ITEM} attachments={MESSAGE_ATTACHMENTS} animateEntry={false} />
</div>
),
};

View File

@ -188,9 +188,7 @@ export const MobileWidth: Story = {
),
],
args: { services: [entry()] },
parameters: {
viewport: { defaultViewport: "mobile1" },
},
globals: { viewport: { value: "mobile1" } },
};
export const AllStates: Story = {