From d8e1004551f159d185570ae789710db998acac80 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:22:47 -0500 Subject: [PATCH] PAP-10440: group artifacts by task stacks (#7654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The artifacts surface is where board users inspect files, media, and documents produced by agents. > - Grouped artifact stacks make that surface easier to scan by task, but the first pass still made grouping feel secondary to media filters. > - The follow-up request was to make grouping the default and give the grouping control the same icon-only outline treatment used on the issues page. > - This pull request keeps the existing artifact grouping API/UI, then polishes the artifacts toolbar state and Storybook review coverage. > - The benefit is that `/artifacts` now opens in the task-stack view by default while preserving explicit flat-mode filtering via `groupBy=none`. ## Linked Issues or Issue Description No public GitHub issue exists for this internal Paperclip task. ### Subsystem affected ui/ — React + Vite board UI. ### Problem or motivation The `/artifacts` grouping affordance was visually placed after the media filters, rendered as a text button, and defaulted to a flat artifact list. Internal follow-up `PAP-10465` requested the grouping icon move left of the filters, become an icon-only outlined button like `/issues`, and make Task grouping the default. ### Proposed solution Default `/artifacts` to grouped Task stacks, keep explicit flat mode available as `groupBy=none`, move the grouping control before the media chips, and restyle it as the shared icon-only outline button pattern. ### Alternatives considered Leaving flat mode as the implicit default was rejected because it does not satisfy the follow-up. Keeping a text label on the grouping trigger was rejected because `/issues` already established the icon-only outline pattern for this class of toolbar control. ### Roadmap alignment This aligns with the `Artifacts & Work Products` roadmap item by making generated outputs easier to inspect and operate from the board UI. ## What Changed - Defaulted the `/artifacts` page to `groupBy=task` when no grouping URL param is present, while keeping explicit flat mode available with `groupBy=none`. - Moved the group control before the media filter chips and changed it to an icon-only outlined button using the shared `Button` pattern. - Updated artifact page tests to cover default Task grouping, explicit flat mode, trigger ordering, and icon-only outline metadata. - Updated the artifact Storybook story so its toolbar mock matches the production ordering and grouped Task is documented as the default mode. ## Verification - `pnpm exec vitest run ui/src/pages/Artifacts.test.tsx ui/src/components/artifacts/ArtifactGroupCard.test.tsx` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm --filter @paperclipai/server typecheck` — passed. - `git diff --check` — passed. - QA visual validation from internal follow-up PAP-10466 passed desktop/mobile scenarios. Screenshot evidence attached there: - Desktop default: http://paperclip-dev:3100/api/attachments/bc81305d-f5de-485c-abeb-9e7c3d9d8539/content - Desktop toolbar close-up: http://paperclip-dev:3100/api/attachments/3375a62b-2110-48f3-bafa-ea98c00f99f7/content - Mobile default: http://paperclip-dev:3100/api/attachments/bfc5642e-9248-431e-9bac-36284dec1c89/content - Mobile toolbar close-up: http://paperclip-dev:3100/api/attachments/ca79401a-5ba8-464d-bc6e-aeffd47fe695/content - GitHub PR checks on head `431964c8b` — passed, including Greptile 5/5. ## Risks Low to medium risk. The main behavior shift is intentional: `/artifacts` now queries grouped Task stacks by default. Existing flat mode remains available through the grouping menu and explicit `groupBy=none` URLs. ## Model Used OpenAI Codex, GPT-5.4 class coding model in this Paperclip heartbeat environment, with shell, git, test, and GitHub CLI tool use. Context window managed by the Codex runtime. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any 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 --- packages/shared/src/index.ts | 4 + packages/shared/src/types/artifact.ts | 16 + packages/shared/src/types/index.ts | 2 + .../shared/src/validators/artifact.test.ts | 86 +++ packages/shared/src/validators/artifact.ts | 22 + packages/shared/src/validators/index.ts | 2 + .../company-artifacts-service.test.ts | 276 ++++++++- server/src/services/company-artifacts.ts | 370 ++++++++++-- ui/src/api/artifacts.test.ts | 35 ++ ui/src/api/artifacts.ts | 20 +- ui/src/components/artifacts/ArtifactCard.tsx | 2 +- .../artifacts/ArtifactGroupCard.test.tsx | 121 ++++ .../artifacts/ArtifactGroupCard.tsx | 87 +++ ui/src/lib/queryKeys.ts | 17 +- ui/src/pages/Artifacts.test.tsx | 216 ++++++- ui/src/pages/Artifacts.tsx | 302 ++++++++-- ui/storybook/stories/artifacts.stories.tsx | 547 +++++++++++++++++- 17 files changed, 2011 insertions(+), 114 deletions(-) create mode 100644 packages/shared/src/validators/artifact.test.ts create mode 100644 ui/src/components/artifacts/ArtifactGroupCard.test.tsx create mode 100644 ui/src/components/artifacts/ArtifactGroupCard.tsx diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a3bfcdad..559e5ddd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -453,6 +453,8 @@ export type { IssueWorkProductReviewState, CompanyArtifact, CompanyArtifactAgentSummary, + CompanyArtifactGroup, + CompanyArtifactGroupBy, CompanyArtifactIssueSummary, CompanyArtifactMediaKind, CompanyArtifactProjectSummary, @@ -1004,6 +1006,8 @@ export { COMPANY_ARTIFACTS_DEFAULT_LIMIT, COMPANY_ARTIFACTS_MAX_LIMIT, COMPANY_ARTIFACTS_MAX_QUERY_LENGTH, + companyArtifactGroupBySchema, + companyArtifactGroupSchema, companyArtifactMediaKindSchema, companyArtifactSchema, companyArtifactSourceSchema, diff --git a/packages/shared/src/types/artifact.ts b/packages/shared/src/types/artifact.ts index 413ed68f..6d022d34 100644 --- a/packages/shared/src/types/artifact.ts +++ b/packages/shared/src/types/artifact.ts @@ -2,6 +2,8 @@ export type CompanyArtifactSource = "document" | "attachment" | "work_product"; export type CompanyArtifactMediaKind = "image" | "video" | "text" | "document" | "file" | "empty"; +export type CompanyArtifactGroupBy = "none" | "task" | "parent_task"; + export interface CompanyArtifactIssueSummary { id: string; identifier: string; @@ -35,7 +37,21 @@ export interface CompanyArtifact { href: string; } +export interface CompanyArtifactGroup { + id: string; + groupBy: Exclude; + issue: CompanyArtifactIssueSummary; + title: string; + count: number; + mediaKinds: CompanyArtifactMediaKind[]; + previewArtifacts: CompanyArtifact[]; + updatedAt: string; + href: string; +} + export interface CompanyArtifactsResponse { artifacts: CompanyArtifact[]; + groups?: CompanyArtifactGroup[]; + selectedGroup?: CompanyArtifactGroup | null; nextCursor: string | null; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index f97f801a..6b72c752 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -217,6 +217,8 @@ export type { export type { CompanyArtifact, CompanyArtifactAgentSummary, + CompanyArtifactGroup, + CompanyArtifactGroupBy, CompanyArtifactIssueSummary, CompanyArtifactMediaKind, CompanyArtifactProjectSummary, diff --git a/packages/shared/src/validators/artifact.test.ts b/packages/shared/src/validators/artifact.test.ts new file mode 100644 index 00000000..2f0c138a --- /dev/null +++ b/packages/shared/src/validators/artifact.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { companyArtifactsQuerySchema, companyArtifactsResponseSchema } from "./artifact.js"; + +const issue = { + id: "11111111-1111-4111-8111-111111111111", + identifier: "PAP-1", + title: "Build artifacts", +}; + +const artifact = { + id: "document:22222222-2222-4222-8222-222222222222", + source: "document", + mediaKind: "document", + title: "Plan", + previewText: "Artifact preview", + contentType: "text/markdown", + contentPath: null, + openPath: null, + downloadPath: null, + issue, + project: null, + createdByAgent: null, + updatedAt: "2026-06-06T12:00:00.000Z", + href: "/PAP/issues/PAP-1#document-plan", +}; + +describe("companyArtifactsQuerySchema", () => { + it("defaults to the existing flat artifact query", () => { + expect(companyArtifactsQuerySchema.parse({})).toMatchObject({ + kind: "all", + groupBy: "none", + limit: 30, + }); + }); + + it("accepts grouped artifact query parameters", () => { + expect( + companyArtifactsQuerySchema.parse({ + groupBy: "parent_task", + groupIssueId: issue.id, + kind: "video", + q: "render", + }), + ).toMatchObject({ + groupBy: "parent_task", + groupIssueId: issue.id, + kind: "video", + q: "render", + }); + }); + + it("rejects invalid grouped artifact query parameters", () => { + expect(() => companyArtifactsQuerySchema.parse({ groupBy: "agent" })).toThrow(); + expect(() => companyArtifactsQuerySchema.parse({ groupIssueId: "PAP-1" })).toThrow(); + }); +}); + +describe("companyArtifactsResponseSchema", () => { + it("accepts grouped artifact responses with selected group metadata", () => { + const group = { + id: `task:${issue.id}`, + groupBy: "task", + issue, + title: issue.title, + count: 1, + mediaKinds: ["document"], + previewArtifacts: [artifact], + updatedAt: "2026-06-06T12:00:00.000Z", + href: `/PAP/artifacts?groupBy=task&groupIssueId=${issue.id}`, + }; + + expect( + companyArtifactsResponseSchema.parse({ + artifacts: [artifact], + groups: [group], + selectedGroup: group, + nextCursor: null, + }), + ).toMatchObject({ + artifacts: [artifact], + groups: [group], + selectedGroup: group, + nextCursor: null, + }); + }); +}); diff --git a/packages/shared/src/validators/artifact.ts b/packages/shared/src/validators/artifact.ts index 72251d41..4d9f32b2 100644 --- a/packages/shared/src/validators/artifact.ts +++ b/packages/shared/src/validators/artifact.ts @@ -8,10 +8,14 @@ export const companyArtifactSourceSchema = z.enum(["document", "attachment", "wo export const companyArtifactMediaKindSchema = z.enum(["image", "video", "text", "document", "file", "empty"]); +export const companyArtifactGroupBySchema = z.enum(["none", "task", "parent_task"]); + export const companyArtifactsQuerySchema = z.object({ kind: z.enum(["image", "video", "text", "document", "file", "all"]).optional().default("all"), projectId: z.string().uuid().optional(), q: z.string().trim().max(COMPANY_ARTIFACTS_MAX_QUERY_LENGTH).optional(), + groupBy: companyArtifactGroupBySchema.optional().default("none"), + groupIssueId: z.string().uuid().optional(), limit: z.coerce .number() .int() @@ -49,8 +53,26 @@ export const companyArtifactSchema = z.object({ href: z.string().min(1), }); +export const companyArtifactGroupSchema = z.object({ + id: z.string().min(1), + groupBy: companyArtifactGroupBySchema.exclude(["none"]), + issue: z.object({ + id: z.string().uuid(), + identifier: z.string(), + title: z.string(), + }), + title: z.string(), + count: z.number().int().min(0), + mediaKinds: z.array(companyArtifactMediaKindSchema), + previewArtifacts: z.array(companyArtifactSchema), + updatedAt: z.string().datetime(), + href: z.string().min(1), +}); + export const companyArtifactsResponseSchema = z.object({ artifacts: z.array(companyArtifactSchema), + groups: z.array(companyArtifactGroupSchema).optional(), + selectedGroup: companyArtifactGroupSchema.nullable().optional(), nextCursor: z.string().nullable(), }); diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 751c757b..1595a680 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -333,6 +333,8 @@ export { COMPANY_ARTIFACTS_DEFAULT_LIMIT, COMPANY_ARTIFACTS_MAX_LIMIT, COMPANY_ARTIFACTS_MAX_QUERY_LENGTH, + companyArtifactGroupBySchema, + companyArtifactGroupSchema, companyArtifactMediaKindSchema, companyArtifactSchema, companyArtifactSourceSchema, diff --git a/server/src/__tests__/company-artifacts-service.test.ts b/server/src/__tests__/company-artifacts-service.test.ts index 429c724b..174c3306 100644 --- a/server/src/__tests__/company-artifacts-service.test.ts +++ b/server/src/__tests__/company-artifacts-service.test.ts @@ -1,5 +1,6 @@ import { Readable } from "node:stream"; import express from "express"; +import { eq } from "drizzle-orm"; import request from "supertest"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { @@ -329,7 +330,7 @@ describeEmbeddedPostgres("companyArtifactsService", () => { updatedAt: new Date("2026-01-02T12:00:00.000Z"), }); - return { companyId, projectId, issueId, otherRunId }; + return { companyId, otherCompanyId, projectId, issueId, secondIssueId, otherIssueId, otherRunId }; } it("projects agent-created documents, direct attachments, and work products while excluding noisy sources", async () => { @@ -495,6 +496,279 @@ describeEmbeddedPostgres("companyArtifactsService", () => { expect(forged?.createdByAgent).toBeNull(); expect(result.artifacts.some((artifact) => artifact.createdByAgent?.name === "Other")).toBe(false); }); + + it("does not leak foreign issue or project metadata through malformed artifact link rows", async () => { + const { companyId, otherCompanyId, otherIssueId } = await seedArtifacts(); + const foreignProjectId = "1b1b1b1b-1b1b-4b1b-8b1b-1b1b1b1b1b1b"; + const malformedAttachmentId = "1c1c1c1c-1c1c-4c1c-8c1c-1c1c1c1c1c1c"; + + await db.insert(projects).values({ + id: foreignProjectId, + companyId: otherCompanyId, + name: "Foreign Project", + status: "in_progress", + }); + await db.update(issues).set({ projectId: foreignProjectId }).where(eq(issues.id, otherIssueId)); + await db.insert(documents).values({ + id: "1d1d1d1d-1d1d-4d1d-8d1d-1d1d1d1d1d1d", + companyId, + title: "Forged Link Document", + latestBody: "This row is company-owned but points at a foreign issue.", + createdByAgentId: "33333333-3333-4333-8333-333333333333", + updatedAt: new Date("2026-01-30T00:00:00.000Z"), + }); + await db.insert(issueDocuments).values({ + companyId, + issueId: otherIssueId, + documentId: "1d1d1d1d-1d1d-4d1d-8d1d-1d1d1d1d1d1d", + key: "forged-link-document", + }); + await db.insert(assets).values({ + id: "1e1e1e1e-1e1e-4e1e-8e1e-1e1e1e1e1e1e", + companyId, + provider: "local_disk", + objectKey: "forged-link.txt", + contentType: "text/plain", + byteSize: 42, + sha256: "sha256-forged-link", + originalFilename: "forged-link.txt", + createdByAgentId: "33333333-3333-4333-8333-333333333333", + }); + await db.insert(issueAttachments).values({ + id: malformedAttachmentId, + companyId, + issueId: otherIssueId, + assetId: "1e1e1e1e-1e1e-4e1e-8e1e-1e1e1e1e1e1e", + updatedAt: new Date("2026-01-29T00:00:00.000Z"), + }); + await db.insert(issueWorkProducts).values({ + id: "1f1f1f1f-1f1f-4f1f-8f1f-1f1f1f1f1f1f", + companyId, + issueId: otherIssueId, + type: "artifact", + provider: "paperclip", + title: "Forged Link Work Product", + status: "ready_for_review", + summary: "This row is company-owned but points at a foreign issue.", + metadata: { contentType: "text/plain" }, + createdByRunId: "99999999-9999-4999-8999-999999999999", + updatedAt: new Date("2026-01-28T00:00:00.000Z"), + }); + + const flat = await companyArtifactsService(db, createStorageService()).list(companyId, { limit: 20 }); + expect(flat.artifacts.map((artifact) => artifact.title)).not.toEqual(expect.arrayContaining([ + "Forged Link Document", + "forged-link.txt", + "Forged Link Work Product", + ])); + expect(flat.artifacts.some((artifact) => artifact.issue.identifier === "OTH-1")).toBe(false); + expect(flat.artifacts.some((artifact) => artifact.issue.title === "Other output")).toBe(false); + expect(flat.artifacts.some((artifact) => artifact.project?.name === "Foreign Project")).toBe(false); + + const grouped = await companyArtifactsService(db, createStorageService()).list(companyId, { + groupBy: "task", + limit: 20, + }); + expect(grouped.groups?.some((group) => group.issue.identifier === "OTH-1")).toBe(false); + expect(grouped.groups?.some((group) => group.issue.title === "Other output")).toBe(false); + expect(grouped.groups?.some((group) => + group.previewArtifacts.some((artifact) => artifact.project?.name === "Foreign Project") + )).toBe(false); + + const selectedForeignGroup = await companyArtifactsService(db, createStorageService()).list(companyId, { + groupBy: "task", + groupIssueId: otherIssueId, + limit: 20, + }); + expect(selectedForeignGroup).toEqual({ + artifacts: [], + selectedGroup: null, + nextCursor: null, + }); + }); + + it("groups artifacts by task after applying media, project, and search filters", async () => { + const { companyId, projectId, issueId } = await seedArtifacts(); + const storage = createStorageService({ "notes.txt": Buffer.from("Searchable notes preview") }); + + const grouped = await companyArtifactsService(db, storage).list(companyId, { + groupBy: "task", + limit: 10, + }); + expect(grouped.artifacts).toEqual([]); + expect(grouped.nextCursor).toBeNull(); + expect(grouped.groups?.map((group) => ({ + issue: group.issue.identifier, + count: group.count, + mediaKinds: group.mediaKinds, + href: group.href, + }))).toEqual([ + { + issue: "PAP-2", + count: 1, + mediaKinds: ["document"], + href: "/PAP/artifacts?groupBy=task&groupIssueId=77777777-7777-4777-8777-777777777777", + }, + { + issue: "PAP-1", + count: 3, + mediaKinds: ["video", "text"], + href: "/PAP/artifacts?groupBy=task&groupIssueId=66666666-6666-4666-8666-666666666666", + }, + ]); + expect(grouped.groups?.find((group) => group.issue.id === issueId)?.previewArtifacts.map((artifact) => artifact.title)) + .toEqual(["direct-video.mp4", "Primary Cut", "notes.txt"]); + + const projectVideos = await companyArtifactsService(db, storage).list(companyId, { + groupBy: "task", + projectId, + kind: "video", + limit: 10, + }); + expect(projectVideos.groups?.map((group) => ({ + issue: group.issue.identifier, + count: group.count, + href: group.href, + }))).toEqual([ + { + issue: "PAP-1", + count: 2, + href: + "/PAP/artifacts?groupBy=task&groupIssueId=66666666-6666-4666-8666-666666666666&kind=video&projectId=55555555-5555-4555-8555-555555555555", + }, + ]); + + const search = await companyArtifactsService(db, storage).list(companyId, { + groupBy: "task", + q: "review document", + limit: 10, + }); + expect(search.groups?.map((group) => ({ issue: group.issue.identifier, count: group.count }))).toEqual([ + { issue: "PAP-2", count: 1 }, + ]); + }); + + it("paginates grouped task lists with the active group cursor", async () => { + const { companyId } = await seedArtifacts(); + + const firstPage = await companyArtifactsService(db, createStorageService()).list(companyId, { + groupBy: "task", + limit: 1, + }); + expect(firstPage.groups?.map((group) => group.issue.identifier)).toEqual(["PAP-2"]); + expect(firstPage.nextCursor).toEqual(expect.any(String)); + + const secondPage = await companyArtifactsService(db, createStorageService()).list(companyId, { + groupBy: "task", + limit: 10, + cursor: firstPage.nextCursor ?? undefined, + }); + expect(secondPage.groups?.map((group) => group.issue.identifier)).toEqual(["PAP-1"]); + expect(secondPage.nextCursor).toBeNull(); + }); + + it("groups parent-task artifacts under the topmost same-company ancestor", async () => { + const { companyId, issueId, secondIssueId } = await seedArtifacts(); + const grandchildIssueId = "21212121-2121-4212-8121-212121212121"; + const grandchildAttachmentId = "23232323-2323-4232-8232-232323232323"; + + await db.update(issues).set({ parentId: issueId }).where(eq(issues.id, secondIssueId)); + await db.insert(issues).values({ + id: grandchildIssueId, + companyId, + parentId: secondIssueId, + identifier: "PAP-3", + title: "Grandchild render", + status: "done", + priority: "medium", + }); + await db.insert(assets).values({ + id: "24242424-2424-4242-8242-242424242424", + companyId, + provider: "local_disk", + objectKey: "grandchild.txt", + contentType: "text/plain", + byteSize: 48, + sha256: "sha256-grandchild", + originalFilename: "grandchild.txt", + createdByAgentId: "33333333-3333-4333-8333-333333333333", + }); + await db.insert(issueAttachments).values({ + id: grandchildAttachmentId, + companyId, + issueId: grandchildIssueId, + assetId: "24242424-2424-4242-8242-242424242424", + updatedAt: new Date("2026-01-05T00:00:00.000Z"), + }); + + const grouped = await companyArtifactsService(db, createStorageService()).list(companyId, { + groupBy: "parent_task", + limit: 10, + }); + + expect(grouped.artifacts).toEqual([]); + expect(grouped.groups?.map((group) => ({ + issue: group.issue.identifier, + count: group.count, + previewTitles: group.previewArtifacts.map((artifact) => artifact.title), + }))).toEqual([ + { + issue: "PAP-1", + count: 5, + previewTitles: ["grandchild.txt", "Review Notes", "direct-video.mp4"], + }, + ]); + }); + + it("returns selected group artifact pages and metadata without leaking foreign group issues", async () => { + const { companyId, issueId, otherIssueId } = await seedArtifacts(); + + const selected = await companyArtifactsService(db, createStorageService()).list(companyId, { + groupBy: "task", + groupIssueId: issueId, + limit: 2, + }); + expect(selected.groups).toBeUndefined(); + expect(selected.selectedGroup).toMatchObject({ + id: `task:${issueId}`, + groupBy: "task", + issue: { identifier: "PAP-1" }, + count: 3, + }); + expect(selected.artifacts.map((artifact) => artifact.title)).toEqual(["direct-video.mp4", "Primary Cut"]); + expect(selected.nextCursor).toEqual(expect.any(String)); + + const selectedSecondPage = await companyArtifactsService(db, createStorageService()).list(companyId, { + groupBy: "task", + groupIssueId: issueId, + limit: 10, + cursor: selected.nextCursor ?? undefined, + }); + expect(selectedSecondPage.artifacts.map((artifact) => artifact.title)).toEqual(["notes.txt"]); + + const selectedEmptyByFilter = await companyArtifactsService(db, createStorageService()).list(companyId, { + groupBy: "task", + groupIssueId: issueId, + q: "does-not-match-this-stack", + limit: 10, + }); + expect(selectedEmptyByFilter.selectedGroup).toMatchObject({ + id: `task:${issueId}`, + count: 0, + }); + expect(selectedEmptyByFilter.artifacts).toEqual([]); + + const foreignSelected = await companyArtifactsService(db, createStorageService()).list(companyId, { + groupBy: "task", + groupIssueId: otherIssueId, + limit: 10, + }); + expect(foreignSelected).toEqual({ + artifacts: [], + selectedGroup: null, + nextCursor: null, + }); + }); }); describe("company artifacts route authorization", () => { diff --git a/server/src/services/company-artifacts.ts b/server/src/services/company-artifacts.ts index 3e787dce..aa06d102 100644 --- a/server/src/services/company-artifacts.ts +++ b/server/src/services/company-artifacts.ts @@ -1,5 +1,5 @@ import { buffer } from "node:stream/consumers"; -import { and, desc, eq, isNotNull, isNull, notInArray, or, sql, type SQL } from "drizzle-orm"; +import { and, desc, eq, inArray, isNotNull, isNull, notInArray, or, sql, type SQL } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import type { Db } from "@paperclipai/db"; import { @@ -20,6 +20,8 @@ import { companyArtifactsQuerySchema, SYSTEM_ISSUE_DOCUMENT_KEYS, type CompanyArtifact, + type CompanyArtifactGroup, + type CompanyArtifactGroupBy, type CompanyArtifactMediaKind, type CompanyArtifactsQuery, type CompanyArtifactsResponse, @@ -29,12 +31,24 @@ import type { StorageService } from "../storage/types.js"; const TEXT_PREVIEW_BYTES = 4096; const PREVIEW_TEXT_MAX_LENGTH = 280; +const GROUP_PREVIEW_ARTIFACT_LIMIT = 3; +const GROUPED_ARTIFACT_FETCH_LIMIT = COMPANY_ARTIFACTS_MAX_LIMIT * 10; type ArtifactCursor = { updatedAt: string; id: string; }; +type ArtifactGroupBy = Exclude; + +type IssueGroupingRow = { + id: string; + parentId: string | null; + identifier: string | null; + title: string; + updatedAt: Date; +}; + function encodeCursor(cursor: ArtifactCursor) { return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); } @@ -61,6 +75,12 @@ function cursorCondition(updatedAt: SQL, artifactId: SQL, cursor: return sql`(${updatedAt} < ${cursor.updatedAt}::timestamptz OR (${updatedAt} = ${cursor.updatedAt}::timestamptz AND ${artifactId} < ${cursor.id}))`; } +function isAfterCursor(item: { updatedAt: string; id: string }, cursor: ArtifactCursor | null) { + if (!cursor) return true; + const dateDiff = Date.parse(item.updatedAt) - Date.parse(cursor.updatedAt); + return dateDiff < 0 || (dateDiff === 0 && item.id < cursor.id); +} + function escapeLikePattern(value: string) { return value.replace(/[\\%_]/g, (match) => `\\${match}`); } @@ -116,6 +136,21 @@ function buildIssueHref(companyPrefix: string, identifier: string, anchor: strin return `/${encodeURIComponent(companyPrefix)}/issues/${encodeURIComponent(identifier)}#${anchor}`; } +function buildArtifactsGroupHref( + companyPrefix: string, + query: CompanyArtifactsQuery, + groupBy: ArtifactGroupBy, + groupIssueId: string, +) { + const params = new URLSearchParams(); + params.set("groupBy", groupBy); + params.set("groupIssueId", groupIssueId); + if (query.kind !== "all") params.set("kind", query.kind); + if (query.projectId) params.set("projectId", query.projectId); + if (query.q) params.set("q", query.q); + return `/${encodeURIComponent(companyPrefix)}/artifacts?${params.toString()}`; +} + function attachmentContentPath(attachmentId: string) { return `/api/attachments/${attachmentId}/content`; } @@ -136,11 +171,154 @@ async function readTextAttachmentPreview( } } +function sortArtifacts(artifacts: CompanyArtifact[]) { + return artifacts.sort((a, b) => { + const dateDiff = Date.parse(b.updatedAt) - Date.parse(a.updatedAt); + if (dateDiff !== 0) return dateDiff; + return b.id.localeCompare(a.id); + }); +} + +function pageByCursor( + items: T[], + limit: number, + cursor: ArtifactCursor | null, +) { + const filtered = items.filter((item) => isAfterCursor(item, cursor)); + const page = filtered.slice(0, limit); + const nextCursor = filtered.length > limit + ? encodeCursor({ id: page[page.length - 1]?.id ?? "", updatedAt: page[page.length - 1]?.updatedAt ?? new Date(0).toISOString() }) + : null; + return { page, nextCursor }; +} + +async function loadIssueGroupingRows(db: Db, companyId: string, seedIssueIds: Iterable) { + const rowsById = new Map(); + let pending = [...new Set(seedIssueIds)]; + + while (pending.length > 0) { + const rows = await db + .select({ + id: issues.id, + parentId: issues.parentId, + identifier: issues.identifier, + title: issues.title, + updatedAt: issues.updatedAt, + }) + .from(issues) + .where(and(eq(issues.companyId, companyId), inArray(issues.id, pending))); + + const nextPending = new Set(); + for (const row of rows) { + rowsById.set(row.id, row); + if (row.parentId && !rowsById.has(row.parentId)) { + nextPending.add(row.parentId); + } + } + pending = [...nextPending]; + } + + return rowsById; +} + +function getIssueSummary(issue: IssueGroupingRow) { + return { + id: issue.id, + identifier: issue.identifier ?? issue.id, + title: issue.title, + }; +} + +function resolveRootIssueId(issueId: string, issueRows: Map) { + let current = issueRows.get(issueId); + if (!current) return issueId; + const seen = new Set(); + while (current.parentId && !seen.has(current.id)) { + seen.add(current.id); + const parent = issueRows.get(current.parentId); + if (!parent) break; + current = parent; + } + return current.id; +} + +function resolveGroupIssueId(groupBy: ArtifactGroupBy, issueId: string, issueRows: Map) { + return groupBy === "task" ? issueId : resolveRootIssueId(issueId, issueRows); +} + +function emptyGroup(input: { + companyPrefix: string; + query: CompanyArtifactsQuery; + groupBy: ArtifactGroupBy; + issue: IssueGroupingRow; +}): CompanyArtifactGroup { + const summary = getIssueSummary(input.issue); + return { + id: `${input.groupBy}:${input.issue.id}`, + groupBy: input.groupBy, + issue: summary, + title: summary.title, + count: 0, + mediaKinds: [], + previewArtifacts: [], + updatedAt: input.issue.updatedAt.toISOString(), + href: buildArtifactsGroupHref(input.companyPrefix, input.query, input.groupBy, input.issue.id), + }; +} + +function buildArtifactGroups(input: { + artifacts: CompanyArtifact[]; + companyPrefix: string; + query: CompanyArtifactsQuery; + groupBy: ArtifactGroupBy; + issueRows: Map; +}) { + const groups = new Map(); + + for (const artifact of input.artifacts) { + const groupIssueId = resolveGroupIssueId(input.groupBy, artifact.issue.id, input.issueRows); + const groupIssue = input.issueRows.get(groupIssueId) ?? { + id: artifact.issue.id, + parentId: null, + identifier: artifact.issue.identifier, + title: artifact.issue.title, + updatedAt: new Date(artifact.updatedAt), + }; + const groupId = `${input.groupBy}:${groupIssueId}`; + const existing = groups.get(groupId); + const group = existing ?? emptyGroup({ + companyPrefix: input.companyPrefix, + query: input.query, + groupBy: input.groupBy, + issue: groupIssue, + }); + if (!existing) groups.set(groupId, group); + + group.count += 1; + if (!group.mediaKinds.includes(artifact.mediaKind)) { + group.mediaKinds.push(artifact.mediaKind); + } + if (group.previewArtifacts.length < GROUP_PREVIEW_ARTIFACT_LIMIT) { + group.previewArtifacts.push(artifact); + } + if (Date.parse(artifact.updatedAt) > Date.parse(group.updatedAt)) { + group.updatedAt = artifact.updatedAt; + } + } + + return [...groups.values()].sort((a, b) => { + const dateDiff = Date.parse(b.updatedAt) - Date.parse(a.updatedAt); + if (dateDiff !== 0) return dateDiff; + return b.id.localeCompare(a.id); + }); +} + export function companyArtifactsService(db: Db, storage?: StorageService) { return { list: async (companyId: string, rawQuery: Partial = {}): Promise => { const query = companyArtifactsQuerySchema.parse(rawQuery); const cursor = decodeCursor(query.cursor); + const groupBy = query.groupBy === "none" ? null : query.groupBy; const company = await db .select({ id: companies.id, issuePrefix: companies.issuePrefix }) .from(companies) @@ -149,6 +327,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { if (!company) throw notFound("Company not found"); const fetchLimit = Math.min(query.limit + 1, COMPANY_ARTIFACTS_MAX_LIMIT + 1); + const sourceFetchLimit = groupBy ? GROUPED_ARTIFACT_FETCH_LIMIT : fetchLimit; const q = query.q ? `%${escapeLikePattern(query.q)}%` : null; const artifacts: CompanyArtifact[] = []; const workProductAttachmentIds = new Set(); @@ -158,12 +337,14 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { const updatedAgent = alias(agents, "document_updated_agent"); const documentArtifactId = sql`concat('document:', ${documents.id})`; const documentConditions: SQL[] = [ + eq(issueDocuments.companyId, companyId), eq(documents.companyId, companyId), or(isNotNull(documents.createdByAgentId), isNotNull(documents.updatedByAgentId))!, notInArray(issueDocuments.key, [...SYSTEM_ISSUE_DOCUMENT_KEYS]), ]; - const documentCursor = cursorCondition(sql`${documents.updatedAt}`, documentArtifactId, cursor); + const documentCursor = groupBy ? undefined : cursorCondition(sql`${documents.updatedAt}`, documentArtifactId, cursor); if (documentCursor) documentConditions.push(documentCursor); + if (groupBy === "task" && query.groupIssueId) documentConditions.push(eq(issues.id, query.groupIssueId)); if (query.projectId) documentConditions.push(eq(issues.projectId, query.projectId)); if (q) { documentConditions.push(sql`( @@ -174,7 +355,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { )`); } - const documentRows = await db + const documentRowsQuery = db .select({ artifactId: documentArtifactId, documentId: documents.id, @@ -191,14 +372,44 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { updatedAt: documents.updatedAt, }) .from(issueDocuments) - .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) - .innerJoin(issues, eq(issueDocuments.issueId, issues.id)) - .leftJoin(projects, eq(issues.projectId, projects.id)) - .leftJoin(createdAgent, eq(documents.createdByAgentId, createdAgent.id)) - .leftJoin(updatedAgent, eq(documents.updatedByAgentId, updatedAgent.id)) + .innerJoin( + documents, + and( + eq(issueDocuments.documentId, documents.id), + eq(documents.companyId, issueDocuments.companyId), + ), + ) + .innerJoin( + issues, + and( + eq(issueDocuments.issueId, issues.id), + eq(issues.companyId, issueDocuments.companyId), + ), + ) + .leftJoin( + projects, + and( + eq(issues.projectId, projects.id), + eq(projects.companyId, issues.companyId), + ), + ) + .leftJoin( + createdAgent, + and( + eq(documents.createdByAgentId, createdAgent.id), + eq(createdAgent.companyId, documents.companyId), + ), + ) + .leftJoin( + updatedAgent, + and( + eq(documents.updatedByAgentId, updatedAgent.id), + eq(updatedAgent.companyId, documents.companyId), + ), + ) .where(and(...documentConditions)) - .orderBy(desc(documents.updatedAt), desc(documentArtifactId)) - .limit(fetchLimit); + .orderBy(desc(documents.updatedAt), desc(documentArtifactId)); + const documentRows = await documentRowsQuery.limit(sourceFetchLimit); for (const row of documentRows) { const identifier = row.issueIdentifier ?? row.issueId; @@ -233,9 +444,16 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { eq(issueWorkProducts.provider, "paperclip"), ]; const workProductConditions: SQL[] = [...workProductBaseConditions]; - const workProductCursor = cursorCondition(sql`${issueWorkProducts.updatedAt}`, workProductArtifactId, cursor); + const workProductCursor = groupBy + ? undefined + : cursorCondition(sql`${issueWorkProducts.updatedAt}`, workProductArtifactId, cursor); const workProductKind = contentTypeKindCondition(workProductContentType, query.kind); if (workProductCursor) workProductConditions.push(workProductCursor); + if (groupBy === "task" && query.groupIssueId) { + const selectedIssueCondition = eq(issues.id, query.groupIssueId); + workProductBaseConditions.push(selectedIssueCondition); + workProductConditions.push(selectedIssueCondition); + } if (workProductKind) { workProductBaseConditions.push(workProductKind); workProductConditions.push(workProductKind); @@ -256,7 +474,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { workProductConditions.push(searchCondition); } - const workProductRows = await db + const workProductRowsQuery = db .select({ artifactId: workProductArtifactId, workProductId: issueWorkProducts.id, @@ -273,8 +491,20 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { updatedAt: issueWorkProducts.updatedAt, }) .from(issueWorkProducts) - .innerJoin(issues, eq(issueWorkProducts.issueId, issues.id)) - .leftJoin(projects, eq(issues.projectId, projects.id)) + .innerJoin( + issues, + and( + eq(issueWorkProducts.issueId, issues.id), + eq(issues.companyId, issueWorkProducts.companyId), + ), + ) + .leftJoin( + projects, + and( + eq(issues.projectId, projects.id), + eq(projects.companyId, issueWorkProducts.companyId), + ), + ) .leftJoin( heartbeatRuns, and( @@ -290,16 +520,23 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { ), ) .where(and(...workProductConditions)) - .orderBy(desc(issueWorkProducts.updatedAt), desc(workProductArtifactId)) - .limit(fetchLimit); + .orderBy(desc(issueWorkProducts.updatedAt), desc(workProductArtifactId)); + const workProductRows = await workProductRowsQuery.limit(sourceFetchLimit); const workProductAttachmentRows = await db .select({ attachmentId: sql`${issueWorkProducts.metadata}->>'attachmentId'`, }) .from(issueWorkProducts) - .innerJoin(issues, eq(issueWorkProducts.issueId, issues.id)) - .where(and(...workProductBaseConditions, sql`${issueWorkProducts.metadata}->>'attachmentId' IS NOT NULL`)); + .innerJoin( + issues, + and( + eq(issueWorkProducts.issueId, issues.id), + eq(issues.companyId, issueWorkProducts.companyId), + ), + ) + .where(and(...workProductBaseConditions, sql`${issueWorkProducts.metadata}->>'attachmentId' IS NOT NULL`)) + .limit(sourceFetchLimit); for (const row of workProductAttachmentRows) { if (row.attachmentId) { @@ -342,9 +579,12 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { isNull(issueAttachments.issueCommentId), isNotNull(assets.createdByAgentId), ]; - const attachmentCursor = cursorCondition(sql`${issueAttachments.updatedAt}`, attachmentArtifactId, cursor); + const attachmentCursor = groupBy + ? undefined + : cursorCondition(sql`${issueAttachments.updatedAt}`, attachmentArtifactId, cursor); const attachmentKind = contentTypeKindCondition(sql`${assets.contentType}`, query.kind); if (attachmentCursor) attachmentConditions.push(attachmentCursor); + if (groupBy === "task" && query.groupIssueId) attachmentConditions.push(eq(issues.id, query.groupIssueId)); if (attachmentKind) attachmentConditions.push(attachmentKind); if (query.projectId) attachmentConditions.push(eq(issues.projectId, query.projectId)); if (q) { @@ -355,7 +595,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { )`); } - const attachmentRows = await db + const attachmentRowsQuery = db .select({ artifactId: attachmentArtifactId, attachmentId: issueAttachments.id, @@ -374,13 +614,37 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { updatedAt: issueAttachments.updatedAt, }) .from(issueAttachments) - .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) - .innerJoin(issues, eq(issueAttachments.issueId, issues.id)) - .leftJoin(projects, eq(issues.projectId, projects.id)) - .leftJoin(attachmentAgent, eq(assets.createdByAgentId, attachmentAgent.id)) + .innerJoin( + assets, + and( + eq(issueAttachments.assetId, assets.id), + eq(assets.companyId, issueAttachments.companyId), + ), + ) + .innerJoin( + issues, + and( + eq(issueAttachments.issueId, issues.id), + eq(issues.companyId, issueAttachments.companyId), + ), + ) + .leftJoin( + projects, + and( + eq(issues.projectId, projects.id), + eq(projects.companyId, issues.companyId), + ), + ) + .leftJoin( + attachmentAgent, + and( + eq(assets.createdByAgentId, attachmentAgent.id), + eq(attachmentAgent.companyId, assets.companyId), + ), + ) .where(and(...attachmentConditions)) - .orderBy(desc(issueAttachments.updatedAt), desc(attachmentArtifactId)) - .limit(fetchLimit); + .orderBy(desc(issueAttachments.updatedAt), desc(attachmentArtifactId)); + const attachmentRows = await attachmentRowsQuery.limit(sourceFetchLimit); const attachmentArtifacts = await Promise.all(attachmentRows.map(async (row): Promise => { if (workProductAttachmentIds.has(row.attachmentId)) return null; @@ -416,18 +680,50 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { artifacts.push(...attachmentArtifacts.filter((artifact): artifact is CompanyArtifact => artifact !== null)); } - const sorted = artifacts - .sort((a, b) => { - const dateDiff = Date.parse(b.updatedAt) - Date.parse(a.updatedAt); - if (dateDiff !== 0) return dateDiff; - return b.id.localeCompare(a.id); - }); - const page = sorted.slice(0, query.limit); - const nextCursor = sorted.length > query.limit - ? encodeCursor({ id: page[page.length - 1]?.id ?? "", updatedAt: page[page.length - 1]?.updatedAt ?? new Date(0).toISOString() }) - : null; + const sorted = sortArtifacts(artifacts); + if (!groupBy) { + const page = sorted.slice(0, query.limit); + const nextCursor = sorted.length > query.limit + ? encodeCursor({ id: page[page.length - 1]?.id ?? "", updatedAt: page[page.length - 1]?.updatedAt ?? new Date(0).toISOString() }) + : null; - return { artifacts: page, nextCursor }; + return { artifacts: page, nextCursor }; + } + + const issueSeedIds = new Set(artifacts.map((artifact) => artifact.issue.id)); + if (query.groupIssueId) issueSeedIds.add(query.groupIssueId); + const issueRows = await loadIssueGroupingRows(db, companyId, issueSeedIds); + const groups = buildArtifactGroups({ + artifacts: sorted, + companyPrefix: company.issuePrefix, + query, + groupBy, + issueRows, + }); + + if (query.groupIssueId) { + const selectedIssue = issueRows.get(query.groupIssueId); + if (!selectedIssue) { + return { artifacts: [], selectedGroup: null, nextCursor: null }; + } + + const selectedGroupIssueId = resolveGroupIssueId(groupBy, selectedIssue.id, issueRows); + const selectedGroup = groups.find((group) => group.issue.id === selectedGroupIssueId) + ?? emptyGroup({ + companyPrefix: company.issuePrefix, + query, + groupBy, + issue: issueRows.get(selectedGroupIssueId) ?? selectedIssue, + }); + const selectedArtifacts = sorted.filter((artifact) => + resolveGroupIssueId(groupBy, artifact.issue.id, issueRows) === selectedGroupIssueId + ); + const { page, nextCursor } = pageByCursor(selectedArtifacts, query.limit, cursor); + return { artifacts: page, selectedGroup, nextCursor }; + } + + const { page, nextCursor } = pageByCursor(groups, query.limit, cursor); + return { artifacts: [], groups: page, nextCursor }; }, }; } diff --git a/ui/src/api/artifacts.test.ts b/ui/src/api/artifacts.test.ts index fffb68b9..89a41bb4 100644 --- a/ui/src/api/artifacts.test.ts +++ b/ui/src/api/artifacts.test.ts @@ -59,6 +59,41 @@ describe("artifactsApi.list", () => { ); }); + it("omits groupBy when grouping is none", async () => { + await artifactsApi.list("company-1", { groupBy: "none" }); + expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/artifacts"); + }); + + it("serializes groupBy and the selected stack issue", async () => { + await artifactsApi.list("company-1", { + groupBy: "parent_task", + groupIssueId: "issue-9", + kind: "image", + }); + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/artifacts?kind=image&groupBy=parent_task&groupIssueId=issue-9", + ); + }); + + it("preserves groups and selectedGroup from the envelope", async () => { + const artifact = sampleArtifact(); + const group = { + id: "task:issue-1", + groupBy: "task" as const, + issue: artifact.issue, + title: "Demo reel", + count: 3, + mediaKinds: ["video" as const], + previewArtifacts: [artifact], + updatedAt: "2026-06-01T00:00:00.000Z", + href: "/PAP/artifacts?groupBy=task&groupIssueId=issue-1", + }; + mockApi.get.mockResolvedValue({ artifacts: [], groups: [group], nextCursor: "next" }); + const result = await artifactsApi.list("company-1", { groupBy: "task" }); + expect(result.groups).toEqual([group]); + expect(result.nextCursor).toBe("next"); + }); + it("returns the envelope shape from the backend", async () => { const artifact = sampleArtifact(); mockApi.get.mockResolvedValue({ artifacts: [artifact], nextCursor: "next" }); diff --git a/ui/src/api/artifacts.ts b/ui/src/api/artifacts.ts index 78508305..e0653245 100644 --- a/ui/src/api/artifacts.ts +++ b/ui/src/api/artifacts.ts @@ -1,12 +1,15 @@ import { api } from "./client"; import type { CompanyArtifact, + CompanyArtifactGroupBy, CompanyArtifactMediaKind, CompanyArtifactsResponse, } from "@paperclipai/shared"; export type { CompanyArtifact, + CompanyArtifactGroup, + CompanyArtifactGroupBy as ArtifactGroupBy, CompanyArtifactMediaKind as ArtifactMediaKind, CompanyArtifactsResponse, CompanyArtifactSource as ArtifactSource, @@ -32,6 +35,10 @@ export interface ListArtifactsParams { kind?: ArtifactKindFilter; projectId?: string; q?: string; + /** Grouping mode. `none` (default) returns the flat artifact grid. */ + groupBy?: CompanyArtifactGroupBy; + /** When grouping, selects a single stack to expand into its artifacts. */ + groupIssueId?: string; limit?: number; cursor?: string; } @@ -41,6 +48,8 @@ function buildArtifactsQuery(params?: ListArtifactsParams): string { if (params?.kind && params.kind !== "all") search.set("kind", params.kind); if (params?.projectId) search.set("projectId", params.projectId); if (params?.q) search.set("q", params.q); + if (params?.groupBy && params.groupBy !== "none") search.set("groupBy", params.groupBy); + if (params?.groupIssueId) search.set("groupIssueId", params.groupIssueId); if (params?.limit != null) search.set("limit", String(params.limit)); if (params?.cursor) search.set("cursor", params.cursor); const qs = search.toString(); @@ -49,8 +58,8 @@ function buildArtifactsQuery(params?: ListArtifactsParams): string { /** * Normalize the endpoint response. The contract is an envelope - * (`{ artifacts, nextCursor }`), but we also tolerate a bare array so the page - * keeps working if the backend ships the simpler shape. + * (`{ artifacts, groups?, selectedGroup?, nextCursor }`), but we also tolerate a + * bare array so the page keeps working if the backend ships the simpler shape. */ function normalizeArtifactsResponse( raw: CompanyArtifactsResponse | CompanyArtifact[], @@ -58,7 +67,12 @@ function normalizeArtifactsResponse( if (Array.isArray(raw)) { return { artifacts: raw, nextCursor: null }; } - return { artifacts: raw.artifacts ?? [], nextCursor: raw.nextCursor ?? null }; + return { + artifacts: raw.artifacts ?? [], + groups: raw.groups, + selectedGroup: raw.selectedGroup, + nextCursor: raw.nextCursor ?? null, + }; } export const artifactsApi = { diff --git a/ui/src/components/artifacts/ArtifactCard.tsx b/ui/src/components/artifacts/ArtifactCard.tsx index 842c0ee6..f5f6d0e7 100644 --- a/ui/src/components/artifacts/ArtifactCard.tsx +++ b/ui/src/components/artifacts/ArtifactCard.tsx @@ -97,7 +97,7 @@ function TextPreview({ artifact }: { artifact: CompanyArtifact }) { ); } -function ArtifactPreview({ artifact }: { artifact: CompanyArtifact }) { +export function ArtifactPreview({ artifact }: { artifact: CompanyArtifact }) { switch (artifact.mediaKind) { case "image": return ; diff --git a/ui/src/components/artifacts/ArtifactGroupCard.test.tsx b/ui/src/components/artifacts/ArtifactGroupCard.test.tsx new file mode 100644 index 00000000..fbff8193 --- /dev/null +++ b/ui/src/components/artifacts/ArtifactGroupCard.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ArtifactGroupCard } from "./ArtifactGroupCard"; +import type { CompanyArtifact, CompanyArtifactGroup } from "@/api/artifacts"; + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompany: null, selectedCompanyId: "company-1" }), +})); + +function sampleArtifact(overrides: Partial = {}): CompanyArtifact { + return { + id: "artifact-1", + source: "attachment", + mediaKind: "image", + title: "Hero shot", + previewText: null, + contentType: "image/png", + contentPath: "/files/hero.png", + openPath: "/files/hero.png", + downloadPath: "/files/hero.png?download=1", + issue: { id: "issue-1", identifier: "PAP-42", title: "Ship launch" }, + project: null, + createdByAgent: null, + updatedAt: "2026-06-01T00:00:00.000Z", + href: "/PAP/issues/PAP-42#attachment-1", + ...overrides, + } as CompanyArtifact; +} + +function sampleGroup(overrides: Partial = {}): CompanyArtifactGroup { + return { + id: "task:issue-1", + groupBy: "task", + issue: { id: "issue-1", identifier: "PAP-42", title: "Ship launch" }, + title: "Ship launch", + count: 3, + mediaKinds: ["image"], + previewArtifacts: [sampleArtifact()], + updatedAt: "2026-06-01T00:00:00.000Z", + href: "/PAP/artifacts?groupBy=task&groupIssueId=issue-1", + ...overrides, + }; +} + +function render(group: CompanyArtifactGroup, to = "?groupBy=task&groupIssueId=issue-1") { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + flushSync(() => { + root.render( + + + , + ); + }); + return { container, root }; +} + +describe("ArtifactGroupCard", () => { + let mounted: { container: HTMLElement; root: ReturnType } | null = null; + + beforeEach(() => { + mounted = null; + }); + + afterEach(() => { + if (mounted) { + flushSync(() => mounted!.root.unmount()); + mounted.container.remove(); + mounted = null; + } + }); + + it("shows a stack effect and plural count when count > 1", () => { + mounted = render(sampleGroup({ count: 3 })); + const card = mounted.container.querySelector('[data-testid="artifact-group-card"]') as HTMLElement; + expect(card).not.toBeNull(); + expect(card.getAttribute("data-stacked")).toBe("true"); + expect(card.getAttribute("data-count")).toBe("3"); + // Two decorative stack layers sit behind the card. + expect(mounted.container.querySelectorAll('[data-testid="artifact-stack-layer"]').length).toBe(2); + expect(mounted.container.textContent).toContain("3 artifacts"); + }); + + it("omits the stack effect and uses singular count when count === 1", () => { + mounted = render(sampleGroup({ count: 1 })); + const card = mounted.container.querySelector('[data-testid="artifact-group-card"]') as HTMLElement; + expect(card.getAttribute("data-stacked")).toBe("false"); + expect(card.getAttribute("data-count")).toBe("1"); + expect(mounted.container.querySelectorAll('[data-testid="artifact-stack-layer"]').length).toBe(0); + expect(mounted.container.textContent).toContain("1 artifact"); + expect(mounted.container.textContent).not.toContain("1 artifacts"); + }); + + it("links to the provided stack destination and shows the task subject", () => { + mounted = render(sampleGroup()); + const anchor = mounted.container.querySelector("a") as HTMLAnchorElement; + expect(anchor).not.toBeNull(); + expect(anchor.getAttribute("href")).toContain("groupIssueId=issue-1"); + expect(mounted.container.textContent).toContain("PAP-42"); + expect(mounted.container.textContent).toContain("Ship launch"); + }); + + it("renders the first preview artifact image", () => { + mounted = render(sampleGroup()); + const img = mounted.container.querySelector("img") as HTMLImageElement; + expect(img).not.toBeNull(); + expect(img.getAttribute("src")).toBe("/files/hero.png"); + }); + + it("falls back to a placeholder when there are no preview artifacts", () => { + mounted = render(sampleGroup({ previewArtifacts: [] })); + expect(mounted.container.querySelector("img")).toBeNull(); + const card = mounted.container.querySelector('[data-testid="artifact-group-card"]') as HTMLElement; + expect(card).not.toBeNull(); + }); +}); diff --git a/ui/src/components/artifacts/ArtifactGroupCard.tsx b/ui/src/components/artifacts/ArtifactGroupCard.tsx new file mode 100644 index 00000000..68544f40 --- /dev/null +++ b/ui/src/components/artifacts/ArtifactGroupCard.tsx @@ -0,0 +1,87 @@ +import { Layers } from "lucide-react"; +import type { To } from "react-router-dom"; +import type { CompanyArtifactGroup } from "@/api/artifacts"; +import { Link } from "@/lib/router"; +import { ArtifactPreview } from "@/components/artifacts/ArtifactCard"; +import { formatDate } from "@/lib/utils"; + +interface ArtifactGroupCardProps { + group: CompanyArtifactGroup; + /** Destination for opening this stack (preserves active filters/search). */ + to: To; +} + +/** + * A stack card rendered in grouped mode. It mirrors the dimensions and preview + * of {@link ArtifactCard} so grouped and flat grids share the same rhythm, and + * layers a subtle "stack" effect behind the card only when it represents more + * than one artifact. + */ +export function ArtifactGroupCard({ group, to }: ArtifactGroupCardProps) { + const stacked = group.count > 1; + const preview = group.previewArtifacts[0]; + const countLabel = `${group.count} artifact${group.count === 1 ? "" : "s"}`; + + return ( +
+ {stacked ? ( + <> +