PAP-10440: group artifacts by task stacks (#7654)
## 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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -333,6 +333,8 @@ export {
|
||||
COMPANY_ARTIFACTS_DEFAULT_LIMIT,
|
||||
COMPANY_ARTIFACTS_MAX_LIMIT,
|
||||
COMPANY_ARTIFACTS_MAX_QUERY_LENGTH,
|
||||
companyArtifactGroupBySchema,
|
||||
companyArtifactGroupSchema,
|
||||
companyArtifactMediaKindSchema,
|
||||
companyArtifactSchema,
|
||||
companyArtifactSourceSchema,
|
||||
|
||||
Reference in New Issue
Block a user