diff --git a/ui/src/pages/Artifacts.test.tsx b/ui/src/pages/Artifacts.test.tsx
index 1a1c49f2f04..3e36a498932 100644
--- a/ui/src/pages/Artifacts.test.tsx
+++ b/ui/src/pages/Artifacts.test.tsx
@@ -171,8 +171,8 @@ describe("Artifacts page", () => {
container.remove();
});
- it("requests the flat artifact list with grouping defaults", async () => {
- artifactsApiMock.list.mockResolvedValue({ artifacts: [sampleArtifact()], nextCursor: null });
+ it("requests task-grouped artifact stacks by default", async () => {
+ artifactsApiMock.list.mockResolvedValue({ artifacts: [], groups: [sampleGroup()], nextCursor: null });
const { root } = renderArtifacts(container);
@@ -180,11 +180,20 @@ describe("Artifacts page", () => {
expect(artifactsApiMock.list).toHaveBeenCalledWith("company-1", {
kind: "all",
q: undefined,
- groupBy: "none",
+ groupBy: "task",
groupIssueId: undefined,
limit: 30,
cursor: undefined,
});
+ const groupControl = container.querySelector('[data-testid="artifact-group-control"]') as HTMLButtonElement;
+ const allFilter = [...container.querySelectorAll('[role="tab"]')]
+ .find((element) => element.textContent === "All") as HTMLButtonElement;
+ expect(groupControl).not.toBeNull();
+ expect(groupControl.textContent).toBe("");
+ expect(groupControl.getAttribute("data-variant")).toBe("outline");
+ expect(groupControl.getAttribute("data-size")).toBe("icon");
+ expect(groupControl.getAttribute("data-group-by")).toBe("task");
+ expect(Boolean(groupControl.compareDocumentPosition(allFilter) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
});
flushSync(() => {
@@ -194,8 +203,8 @@ describe("Artifacts page", () => {
it("debounces artifact search into the artifacts API", async () => {
artifactsApiMock.list
- .mockResolvedValueOnce({ artifacts: [sampleArtifact()], nextCursor: null })
- .mockResolvedValueOnce({ artifacts: [], nextCursor: null });
+ .mockResolvedValueOnce({ artifacts: [], groups: [sampleGroup()], nextCursor: null })
+ .mockResolvedValueOnce({ artifacts: [], groups: [], nextCursor: null });
const { root } = renderArtifacts(container);
@@ -217,7 +226,7 @@ describe("Artifacts page", () => {
expect(artifactsApiMock.list).toHaveBeenLastCalledWith("company-1", {
kind: "all",
q: "launch",
- groupBy: "none",
+ groupBy: "task",
groupIssueId: undefined,
limit: 30,
cursor: undefined,
@@ -232,7 +241,7 @@ describe("Artifacts page", () => {
it("keeps the artifacts grid max-width constrained and left aligned", async () => {
artifactsApiMock.list.mockResolvedValue({ artifacts: [sampleArtifact()], nextCursor: null });
- const { root } = renderArtifacts(container);
+ const { root } = renderArtifacts(container, ["/artifacts?groupBy=none"]);
await waitForAssertion(() => {
expect(container.querySelector('[data-testid="artifact-card"]')).not.toBeNull();
@@ -258,7 +267,7 @@ describe("Artifacts page", () => {
nextCursor: null,
});
- const { root } = renderArtifacts(container);
+ const { root } = renderArtifacts(container, ["/artifacts?groupBy=none"]);
await waitForAssertion(() => {
expect(container.textContent).toContain("First Artifact");
@@ -289,38 +298,39 @@ describe("Artifacts page", () => {
it("switches grouping via the group control and refetches stacks", async () => {
artifactsApiMock.list.mockImplementation((_companyId: string, params?: { groupBy?: string }) => {
- if (params?.groupBy === "task") {
- return Promise.resolve({ artifacts: [], groups: [sampleGroup()], nextCursor: null });
+ if (params?.groupBy === "none") {
+ return Promise.resolve({ artifacts: [sampleArtifact()], nextCursor: null });
}
- return Promise.resolve({ artifacts: [sampleArtifact()], nextCursor: null });
+ return Promise.resolve({ artifacts: [], groups: [sampleGroup()], nextCursor: null });
});
const { root } = renderArtifacts(container);
await waitForAssertion(() => {
- expect(container.querySelector('[data-testid="artifact-card"]')).not.toBeNull();
+ expect(container.querySelector('[data-testid="artifact-group-card"]')).not.toBeNull();
});
- const taskOption = container.querySelector(
- '[data-testid="artifact-group-option-task"]',
+ const noneOption = container.querySelector(
+ '[data-testid="artifact-group-option-none"]',
) as HTMLButtonElement;
- expect(taskOption).not.toBeNull();
+ expect(noneOption).not.toBeNull();
flushSync(() => {
- taskOption.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
+ noneOption.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
await waitForAssertion(() => {
expect(artifactsApiMock.list).toHaveBeenLastCalledWith("company-1", {
kind: "all",
q: undefined,
- groupBy: "task",
+ groupBy: "none",
groupIssueId: undefined,
limit: 30,
cursor: undefined,
});
- expect(container.querySelector('[data-testid="artifact-group-card"]')).not.toBeNull();
- expect(container.textContent).toContain("3 artifacts");
+ expect(container.querySelector('[data-testid="artifact-card"]')).not.toBeNull();
+ const groupControl = container.querySelector('[data-testid="artifact-group-control"]') as HTMLElement;
+ expect(groupControl.getAttribute("data-group-by")).toBe("none");
});
flushSync(() => {
diff --git a/ui/src/pages/Artifacts.tsx b/ui/src/pages/Artifacts.tsx
index 4f2bf97f59b..c97557fa410 100644
--- a/ui/src/pages/Artifacts.tsx
+++ b/ui/src/pages/Artifacts.tsx
@@ -24,6 +24,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
const ARTIFACTS_PAGE_SIZE = 30;
const SEARCH_DEBOUNCE_MS = 250;
@@ -46,7 +47,8 @@ const GROUP_OPTIONS: { value: ArtifactGroupBy; label: string }[] = [
const KIND_VALUES = new Set(KIND_FILTERS.map((filter) => filter.value));
function parseGroupBy(value: string | null): ArtifactGroupBy {
- return value === "task" || value === "parent_task" ? value : "none";
+ if (value === "none" || value === "task" || value === "parent_task") return value;
+ return "task";
}
function parseKind(value: string | null): ArtifactKindFilter {
@@ -127,7 +129,7 @@ export function Artifacts() {
updateParams((next) => {
// Switching the grouping mode always returns to the stack list.
next.delete("groupIssueId");
- if (value === "none") next.delete("groupBy");
+ if (value === "task") next.delete("groupBy");
else next.set("groupBy", value);
});
},
@@ -263,6 +265,38 @@ export function Artifacts() {
+
+
+
+
+
+
+
+ Group by
+ {GROUP_OPTIONS.map((option) => (
+ selectGroupBy(option.value)}
+ className="justify-between"
+ >
+ {option.label}
+ {groupBy === option.value ? : null}
+
+ ))}
+
+
+
{KIND_FILTERS.map((filter) => (
))}
-
-
-
-
-
- {grouping ? groupByLabel(groupBy) : "Group"}
-
-
-
- Group by
- {GROUP_OPTIONS.map((option) => (
- selectGroupBy(option.value)}
- className="justify-between"
- >
- {option.label}
- {groupBy === option.value ? : null}
-
- ))}
-
-
diff --git a/ui/storybook/stories/artifacts.stories.tsx b/ui/storybook/stories/artifacts.stories.tsx
index 1336b36fbe9..79b27024acb 100644
--- a/ui/storybook/stories/artifacts.stories.tsx
+++ b/ui/storybook/stories/artifacts.stories.tsx
@@ -1,22 +1,43 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
-import { Package } from "lucide-react";
+import { useState } from "react";
+import { Check, ChevronLeft, Layers, Package, Search, X } from "lucide-react";
import { ArtifactCard } from "@/components/artifacts/ArtifactCard";
import { EmptyState } from "@/components/EmptyState";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { cn, formatDate } from "@/lib/utils";
import type { CompanyArtifact } from "@/api/artifacts";
/**
- * Storybook coverage for the company Artifacts page (PAP-10359). Renders the
- * responsive three-column grid and every preview card variant with mock data so
- * UX/QA can review the layout and capture desktop/mobile screenshots without a
- * live backend.
+ * Storybook coverage for the company Artifacts page. Covers:
+ * - the flat grid (PAP-10359)
+ * - the new group-by control, stack cards, and selected stack view (PAP-10440 / PAP-10442)
+ *
+ * Each story is renderable standalone so UX/QA can capture desktop and mobile
+ * screenshots without booting a live backend.
*/
+type GroupBy = "none" | "task" | "parent_task";
+
const SAMPLE_IMAGE =
"data:image/svg+xml;utf8," +
encodeURIComponent(
`Hero render.png `,
);
+const SAMPLE_IMAGE_TEAL =
+ "data:image/svg+xml;utf8," +
+ encodeURIComponent(
+ `nav-revised.png `,
+ );
+
+const SAMPLE_IMAGE_AMBER =
+ "data:image/svg+xml;utf8," +
+ encodeURIComponent(
+ `hero-warm.png `,
+ );
+
function makeArtifact(overrides: Partial): CompanyArtifact {
return {
id: "art",
@@ -44,7 +65,7 @@ const ARTIFACTS: CompanyArtifact[] = [
mediaKind: "video",
title: "Product demo — primary cut.mp4",
contentType: "video/mp4",
- contentPath: null, // exercises the calm video placeholder + play glyph
+ contentPath: null,
openPath: "/files/demo.mp4",
downloadPath: "/files/demo.mp4?download=1",
issue: { id: "issue-2", identifier: "PAP-10205", title: "Record the launch walkthrough" },
@@ -96,7 +117,7 @@ const ARTIFACTS: CompanyArtifact[] = [
mediaKind: "image",
title: "missing-preview.png (broken source)",
contentType: "image/png",
- contentPath: "/files/does-not-exist.png", // exercises the onError image fallback
+ contentPath: "/files/does-not-exist.png",
openPath: "/files/does-not-exist.png",
downloadPath: "/files/does-not-exist.png?download=1",
}),
@@ -104,7 +125,7 @@ const ARTIFACTS: CompanyArtifact[] = [
function ArtifactsGrid({ artifacts }: { artifacts: CompanyArtifact[] }) {
return (
-
+
{artifacts.map((artifact) => (
))}
@@ -112,6 +133,357 @@ function ArtifactsGrid({ artifacts }: { artifacts: CompanyArtifact[] }) {
);
}
+// ---------------------------------------------------------------------------
+// Grouping mock components (PAP-10442). These mirror the production artifact
+// grouping controls so Storybook stays useful for visual review.
+// ---------------------------------------------------------------------------
+
+const KIND_FILTERS = [
+ { value: "all", label: "All" },
+ { value: "image", label: "Images" },
+ { value: "video", label: "Videos" },
+ { value: "document", label: "Documents" },
+ { value: "text", label: "Text" },
+ { value: "file", label: "Files" },
+] as const;
+
+const GROUP_OPTIONS: { value: GroupBy; label: string }[] = [
+ { value: "none", label: "None" },
+ { value: "task", label: "Task" },
+ { value: "parent_task", label: "Parent task" },
+];
+
+/**
+ * Toolbar replica matching the existing Artifacts page (search + kind filters)
+ * with the group-by icon control placed before the filter chips.
+ */
+function ArtifactsToolbar({
+ query,
+ onQueryChange,
+ kind,
+ onKindChange,
+ groupBy,
+ onGroupByChange,
+}: {
+ query: string;
+ onQueryChange: (value: string) => void;
+ kind: (typeof KIND_FILTERS)[number]["value"];
+ onKindChange: (value: (typeof KIND_FILTERS)[number]["value"]) => void;
+ groupBy: GroupBy;
+ onGroupByChange: (value: GroupBy) => void;
+}) {
+ return (
+
+
+
+ onQueryChange(event.currentTarget.value)}
+ placeholder="Search artifacts..."
+ aria-label="Search artifacts"
+ className="h-9 pl-9 pr-9 text-sm"
+ />
+ {query.length > 0 ? (
+ onQueryChange("")}
+ aria-label="Clear artifact search"
+ className="absolute right-2 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
+ >
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+ Group by
+
+
+ {GROUP_OPTIONS.map(({ value, label }) => (
+ onGroupByChange(value)}
+ >
+ {label}
+ {groupBy === value ? : null}
+
+ ))}
+
+
+
+
+
+ {KIND_FILTERS.map((filter) => (
+ onKindChange(filter.value)}
+ className={cn(
+ "rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
+ kind === filter.value
+ ? "bg-accent text-foreground"
+ : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
+ )}
+ >
+ {filter.label}
+
+ ))}
+
+
+
+ );
+}
+
+interface MockGroup {
+ id: string;
+ groupBy: Exclude
;
+ issueIdentifier: string;
+ issueTitle: string;
+ count: number;
+ preview: CompanyArtifact;
+ updatedAt: string;
+ href: string;
+}
+
+/**
+ * Stack card mock for grouped views.
+ *
+ * Visual rules (matching ArtifactCard footprint):
+ * - Same border, radius (rounded-[8px]), border + bg-card surface, hover treatment.
+ * - Preview frame is the same `aspect-video` PreviewFrame as ArtifactCard.
+ * - Footer block has the same vertical rhythm.
+ * - When `count > 1`, render two stacked sibling layers behind the main card,
+ * offset by 4px / 8px on both axes, with reduced opacity. This produces a
+ * subtle "stack" without competing with the preview content.
+ * - Stack count badge sits in the top-right of the preview as a small pill.
+ * - The body line replaces the artifact title with the issue identifier and
+ * title; metadata line shows the artifact count and most-recent-edit time.
+ */
+function ArtifactStackCard({ group }: { group: MockGroup }) {
+ const isStacked = group.count > 1;
+
+ return (
+
+ );
+}
+
+const TASK_GROUPS: MockGroup[] = [
+ {
+ id: "task:issue-1",
+ groupBy: "task",
+ issueIdentifier: "PAP-10306",
+ issueTitle: "Landing visuals refresh",
+ count: 5,
+ preview: makeArtifact({ mediaKind: "image", contentPath: SAMPLE_IMAGE }),
+ updatedAt: new Date("2026-06-04T12:00:00Z").toISOString(),
+ href: "/PAP/artifacts?groupBy=task&groupIssueId=issue-1",
+ },
+ {
+ id: "task:issue-2",
+ groupBy: "task",
+ issueIdentifier: "PAP-10205",
+ issueTitle: "Record the launch walkthrough",
+ count: 3,
+ preview: makeArtifact({ mediaKind: "video", contentPath: null }),
+ updatedAt: new Date("2026-06-03T09:30:00Z").toISOString(),
+ href: "/PAP/artifacts?groupBy=task&groupIssueId=issue-2",
+ },
+ {
+ id: "task:issue-3",
+ groupBy: "task",
+ issueIdentifier: "PAP-10341",
+ issueTitle: "Draft the rollout plan",
+ count: 2,
+ preview: makeArtifact({ mediaKind: "document", contentPath: null }),
+ updatedAt: new Date("2026-06-02T18:15:00Z").toISOString(),
+ href: "/PAP/artifacts?groupBy=task&groupIssueId=issue-3",
+ },
+ {
+ id: "task:issue-4",
+ groupBy: "task",
+ issueIdentifier: "PAP-10412",
+ issueTitle: "Investigate paywall regression",
+ count: 1,
+ preview: makeArtifact({ mediaKind: "image", contentPath: SAMPLE_IMAGE_AMBER }),
+ updatedAt: new Date("2026-06-02T11:00:00Z").toISOString(),
+ href: "/PAP/artifacts?groupBy=task&groupIssueId=issue-4",
+ },
+ {
+ id: "task:issue-5",
+ groupBy: "task",
+ issueIdentifier: "PAP-10391",
+ issueTitle: "Iterate on nav",
+ count: 4,
+ preview: makeArtifact({ mediaKind: "image", contentPath: SAMPLE_IMAGE_TEAL }),
+ updatedAt: new Date("2026-06-01T16:42:00Z").toISOString(),
+ href: "/PAP/artifacts?groupBy=task&groupIssueId=issue-5",
+ },
+ {
+ id: "task:issue-6",
+ groupBy: "task",
+ issueIdentifier: "PAP-10377",
+ issueTitle: "QA: empty states",
+ count: 1,
+ preview: makeArtifact({ mediaKind: "text", previewText: "All empty states green except onboarding-step-3." }),
+ updatedAt: new Date("2026-05-31T10:00:00Z").toISOString(),
+ href: "/PAP/artifacts?groupBy=task&groupIssueId=issue-6",
+ },
+];
+
+const PARENT_TASK_GROUPS: MockGroup[] = [
+ {
+ id: "parent_task:root-1",
+ groupBy: "parent_task",
+ issueIdentifier: "PAP-10300",
+ issueTitle: "Launch readiness epic",
+ count: 14,
+ preview: makeArtifact({ mediaKind: "image", contentPath: SAMPLE_IMAGE }),
+ updatedAt: new Date("2026-06-04T12:00:00Z").toISOString(),
+ href: "/PAP/artifacts?groupBy=parent_task&groupIssueId=root-1",
+ },
+ {
+ id: "parent_task:root-2",
+ groupBy: "parent_task",
+ issueIdentifier: "PAP-10200",
+ issueTitle: "Marketing site rebuild",
+ count: 9,
+ preview: makeArtifact({ mediaKind: "image", contentPath: SAMPLE_IMAGE_TEAL }),
+ updatedAt: new Date("2026-06-03T14:25:00Z").toISOString(),
+ href: "/PAP/artifacts?groupBy=parent_task&groupIssueId=root-2",
+ },
+ {
+ id: "parent_task:root-3",
+ groupBy: "parent_task",
+ issueIdentifier: "PAP-10180",
+ issueTitle: "Pricing experiment",
+ count: 1,
+ preview: makeArtifact({ mediaKind: "document", previewText: "Decision log" }),
+ updatedAt: new Date("2026-05-30T08:11:00Z").toISOString(),
+ href: "/PAP/artifacts?groupBy=parent_task&groupIssueId=root-3",
+ },
+];
+
+const SELECTED_GROUP_ARTIFACTS: CompanyArtifact[] = [
+ makeArtifact({
+ id: "img-hero",
+ mediaKind: "image",
+ title: "Hero render.png",
+ contentType: "image/png",
+ contentPath: SAMPLE_IMAGE,
+ openPath: SAMPLE_IMAGE,
+ downloadPath: SAMPLE_IMAGE,
+ }),
+ makeArtifact({
+ id: "img-teal",
+ mediaKind: "image",
+ title: "nav-revised.png",
+ contentType: "image/png",
+ contentPath: SAMPLE_IMAGE_TEAL,
+ }),
+ makeArtifact({
+ id: "img-amber",
+ mediaKind: "image",
+ title: "hero-warm.png",
+ contentType: "image/png",
+ contentPath: SAMPLE_IMAGE_AMBER,
+ }),
+ makeArtifact({
+ id: "file-zip",
+ mediaKind: "file",
+ title: "design-assets.zip",
+ contentType: "application/zip",
+ openPath: "/files/design-assets.zip",
+ downloadPath: "/files/design-assets.zip?download=1",
+ }),
+ makeArtifact({
+ id: "txt-spec",
+ mediaKind: "text",
+ title: "design-spec.txt",
+ previewText:
+ "Hero retains pearl gradient. Nav collapses to icon-rail under 640px. Card radius is 8px throughout. Keep accent button consistent with /design-guide.",
+ }),
+];
+
const meta: Meta = {
title: "Pages/Artifacts",
};
@@ -120,15 +492,168 @@ export default meta;
type Story = StoryObj;
+/**
+ * Flat grid (existing behaviour) — group control is set to `None` so the
+ * toolbar shows the new icon in its inert state.
+ */
export const Grid: Story = {
- render: () => (
-
-
- Work your agents have produced — documents, media, and files — across this company's issues.
-
-
-
- ),
+ render: () => {
+ const [query, setQuery] = useState("");
+ const [kind, setKind] = useState<(typeof KIND_FILTERS)[number]["value"]>("all");
+ const [groupBy, setGroupBy] = useState("none");
+ return (
+
+ );
+ },
+};
+
+/**
+ * Grouped by Task — the production default. Every stack is one issue's
+ * artifacts. Counts > 1 show the subtle stack effect; the lone `count = 1`
+ * stacks render flat to keep the grid honest about depth.
+ */
+export const GroupedByTask: Story = {
+ render: () => {
+ const [query, setQuery] = useState("");
+ const [kind, setKind] = useState<(typeof KIND_FILTERS)[number]["value"]>("all");
+ const [groupBy, setGroupBy] = useState("task");
+ return (
+
+
+
+ {TASK_GROUPS.map((group) => (
+
+ ))}
+
+
+ );
+ },
+};
+
+/**
+ * Grouped by Parent task — stacks cluster all descendants under the root
+ * issue identifier. Same visual contract as task grouping.
+ */
+export const GroupedByParentTask: Story = {
+ render: () => {
+ const [query, setQuery] = useState("");
+ const [kind, setKind] = useState<(typeof KIND_FILTERS)[number]["value"]>("all");
+ const [groupBy, setGroupBy] = useState("parent_task");
+ return (
+
+
+
+ {PARENT_TASK_GROUPS.map((group) => (
+
+ ))}
+
+
+ );
+ },
+};
+
+/**
+ * Selected stack — drilled into a single issue's artifacts. The header row
+ * is the back affordance plus the selected-group label; media filter and
+ * search remain available and still apply within the stack.
+ */
+export const SelectedStack: Story = {
+ render: () => {
+ const [query, setQuery] = useState("");
+ const [kind, setKind] = useState<(typeof KIND_FILTERS)[number]["value"]>("all");
+ const [groupBy, setGroupBy] = useState("task");
+ return (
+
+
+
+
+
+
+
+ {SELECTED_GROUP_ARTIFACTS.length} artifacts in this stack
+
+
+
+
+
+ );
+ },
+};
+
+/**
+ * Mobile — confirms the toolbar wrap (search above, group icon + kind chips
+ * below) and that stack cards keep their stack effect at single-column width.
+ */
+export const MobileGrouping: Story = {
+ parameters: { viewport: { defaultViewport: "mobile" } },
+ render: () => {
+ const [query, setQuery] = useState("");
+ const [kind, setKind] = useState<(typeof KIND_FILTERS)[number]["value"]>("all");
+ const [groupBy, setGroupBy] = useState("task");
+ return (
+
+
+
+ {TASK_GROUPS.slice(0, 3).map((group) => (
+
+ ))}
+
+
+ );
+ },
};
export const Empty: Story = {