Files
paperclip/ui/storybook/stories/artifacts.stories.tsx
T
Dotta d8e1004551 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>
2026-06-06 10:22:47 -05:00

659 lines
23 KiB
TypeScript

import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import { ArrowLeft, Check, 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { cn, formatDate } from "@/lib/utils";
import type { CompanyArtifact } from "@/api/artifacts";
import {
ARTIFACT_GROUP_OPTIONS,
ARTIFACT_KIND_FILTERS,
artifactGroupByLabel,
} from "@/pages/Artifacts";
/**
* 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 StoryArtifactKindFilter = (typeof ARTIFACT_KIND_FILTERS)[number]["value"];
type StoryArtifactGroupBy = (typeof ARTIFACT_GROUP_OPTIONS)[number]["value"];
const SAMPLE_IMAGE =
"data:image/svg+xml;utf8," +
encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' width='480' height='270'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#6366f1'/><stop offset='1' stop-color='#22d3ee'/></linearGradient></defs><rect width='480' height='270' fill='url(#g)'/><text x='50%' y='52%' font-family='sans-serif' font-size='28' fill='white' text-anchor='middle'>Hero render.png</text></svg>`,
);
const SAMPLE_IMAGE_TEAL =
"data:image/svg+xml;utf8," +
encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' width='480' height='270'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#0ea5e9'/><stop offset='1' stop-color='#14b8a6'/></linearGradient></defs><rect width='480' height='270' fill='url(#g)'/><text x='50%' y='52%' font-family='sans-serif' font-size='24' fill='white' text-anchor='middle'>nav-revised.png</text></svg>`,
);
const SAMPLE_IMAGE_AMBER =
"data:image/svg+xml;utf8," +
encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' width='480' height='270'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#f59e0b'/><stop offset='1' stop-color='#ef4444'/></linearGradient></defs><rect width='480' height='270' fill='url(#g)'/><text x='50%' y='52%' font-family='sans-serif' font-size='22' fill='white' text-anchor='middle'>hero-warm.png</text></svg>`,
);
function makeArtifact(overrides: Partial<CompanyArtifact>): CompanyArtifact {
return {
id: "art",
source: "attachment",
mediaKind: "image",
title: "Artifact",
previewText: null,
contentType: null,
contentPath: null,
openPath: null,
downloadPath: null,
issue: { id: "issue-1", identifier: "PAP-10306", title: "Landing visuals refresh" },
project: { id: "proj-1", name: "Paperclip App" },
createdByAgent: { id: "agent-1", name: "ClaudeCoder" },
updatedAt: new Date("2026-06-04T12:00:00Z").toISOString(),
href: "/issues/PAP-10306#attachment-art",
...overrides,
};
}
const ARTIFACTS: CompanyArtifact[] = [
makeArtifact({
id: "wp-video",
source: "work_product",
mediaKind: "video",
title: "Product demo — primary cut.mp4",
contentType: "video/mp4",
contentPath: null,
openPath: "/files/demo.mp4",
downloadPath: "/files/demo.mp4?download=1",
issue: { id: "issue-2", identifier: "PAP-10205", title: "Record the launch walkthrough" },
href: "/issues/PAP-10205#work-product-wp-video",
}),
makeArtifact({
id: "img-hero",
mediaKind: "image",
title: "Hero render.png",
contentType: "image/png",
contentPath: SAMPLE_IMAGE,
openPath: SAMPLE_IMAGE,
downloadPath: SAMPLE_IMAGE,
}),
makeArtifact({
id: "doc-plan",
source: "document",
mediaKind: "document",
title: "Artifacts Page Plan",
previewText:
"Build a company-level Artifacts page at /{companyPrefix}/artifacts, with a sidebar item below Goals and a three-column artifact grid. The page should make agent-produced work easy to find without becoming another attachment dump.",
contentType: "text/markdown",
issue: { id: "issue-3", identifier: "PAP-10341", title: "Draft the rollout plan" },
createdByAgent: { id: "agent-2", name: "CodexCoder" },
href: "/issues/PAP-10341#document-plan",
}),
makeArtifact({
id: "txt-notes",
mediaKind: "text",
title: "review-notes.txt",
previewText:
"Reviewed the primary cut. Color grade looks good; trim the first 1.2s of dead air. Re-export at 1080p and attach the final to the issue.",
contentType: "text/plain",
openPath: "/files/review-notes.txt",
downloadPath: "/files/review-notes.txt?download=1",
issue: { id: "issue-2", identifier: "PAP-10205", title: "Record the launch walkthrough" },
}),
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",
issue: { id: "issue-1", identifier: "PAP-10306", title: "Landing visuals refresh" },
}),
makeArtifact({
id: "img-broken",
mediaKind: "image",
title: "missing-preview.png (broken source)",
contentType: "image/png",
contentPath: "/files/does-not-exist.png",
openPath: "/files/does-not-exist.png",
downloadPath: "/files/does-not-exist.png?download=1",
}),
];
function ArtifactsGrid({ artifacts }: { artifacts: CompanyArtifact[] }) {
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
{artifacts.map((artifact) => (
<ArtifactCard key={`${artifact.source}:${artifact.id}`} artifact={artifact} />
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Grouping mock components (PAP-10442). These mirror the production artifact
// grouping controls so Storybook stays useful for visual review.
// ---------------------------------------------------------------------------
/**
* 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: StoryArtifactKindFilter;
onKindChange: (value: StoryArtifactKindFilter) => void;
groupBy: StoryArtifactGroupBy;
onGroupByChange: (value: StoryArtifactGroupBy) => void;
}) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => onQueryChange(event.currentTarget.value)}
placeholder="Search artifacts..."
aria-label="Search artifacts"
className="h-9 pl-9 pr-9 text-sm"
/>
{query.length > 0 ? (
<button
type="button"
onClick={() => 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"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className={cn("h-8 w-8 shrink-0", groupBy !== "none" && "bg-accent")}
title="Group artifacts"
aria-label={`Group artifacts (currently ${artifactGroupByLabel(groupBy)})`}
>
<Layers className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel>Group by</DropdownMenuLabel>
{ARTIFACT_GROUP_OPTIONS.map(({ value, label }) => (
<DropdownMenuItem
key={value}
aria-selected={groupBy === value}
onSelect={() => onGroupByChange(value)}
className="justify-between"
>
{label}
{groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex flex-wrap items-center gap-1.5" role="tablist" aria-label="Filter artifacts by type">
{ARTIFACT_KIND_FILTERS.map((filter) => (
<button
key={filter.value}
type="button"
role="tab"
aria-selected={kind === filter.value}
onClick={() => 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}
</button>
))}
</div>
</div>
</div>
);
}
interface MockGroup {
id: string;
groupBy: Exclude<StoryArtifactGroupBy, "none">;
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 (
<div className="relative">
{isStacked ? (
<>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 translate-x-[8px] translate-y-[8px] rounded-[8px] border border-border bg-muted/40 shadow-sm"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 translate-x-[4px] translate-y-[4px] rounded-[8px] border border-border bg-muted/70 shadow-sm"
/>
</>
) : null}
<a
href={group.href}
data-testid="artifact-stack-card"
data-stack-count={group.count}
className="group relative flex flex-col overflow-hidden rounded-[8px] border border-border bg-card transition-colors hover:border-foreground/20"
>
<div className="relative aspect-video w-full overflow-hidden bg-accent/20">
{group.preview.contentPath ? (
<img
src={group.preview.contentPath}
alt=""
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-accent/15 text-muted-foreground/40">
<Layers className="h-8 w-8" aria-hidden="true" />
</div>
)}
<div className="absolute right-2 top-2 inline-flex items-center gap-1 rounded-full bg-background/85 px-2 py-0.5 text-[11px] font-medium text-foreground/90 shadow-sm backdrop-blur">
<Layers className="h-3 w-3" aria-hidden="true" />
{group.count}
</div>
</div>
<div className="flex flex-1 flex-col gap-1 p-3">
<div className="flex h-7 items-center gap-2">
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
{group.issueIdentifier}
</span>
<h3
className="min-w-0 flex-1 truncate text-sm font-medium leading-7 text-foreground/85"
title={group.issueTitle}
>
{group.issueTitle}
</h3>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-muted-foreground/65">
<span>{group.count} artifacts</span>
<span className="text-muted-foreground/50">·</span>
<span>Updated {formatDate(group.updatedAt)}</span>
</div>
</div>
</a>
</div>
);
}
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",
};
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: () => {
const [query, setQuery] = useState("");
const [kind, setKind] = useState<StoryArtifactKindFilter>("all");
const [groupBy, setGroupBy] = useState<StoryArtifactGroupBy>("none");
return (
<div className="mx-auto w-full max-w-6xl space-y-5 p-6">
<ArtifactsToolbar
query={query}
onQueryChange={setQuery}
kind={kind}
onKindChange={setKind}
groupBy={groupBy}
onGroupByChange={setGroupBy}
/>
<ArtifactsGrid artifacts={ARTIFACTS} />
</div>
);
},
};
/**
* 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<StoryArtifactKindFilter>("all");
const [groupBy, setGroupBy] = useState<StoryArtifactGroupBy>("task");
return (
<div className="mx-auto w-full max-w-6xl space-y-5 p-6">
<ArtifactsToolbar
query={query}
onQueryChange={setQuery}
kind={kind}
onKindChange={setKind}
groupBy={groupBy}
onGroupByChange={setGroupBy}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
{TASK_GROUPS.map((group) => (
<ArtifactStackCard key={group.id} group={group} />
))}
</div>
</div>
);
},
};
/**
* 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<StoryArtifactKindFilter>("all");
const [groupBy, setGroupBy] = useState<StoryArtifactGroupBy>("parent_task");
return (
<div className="mx-auto w-full max-w-6xl space-y-5 p-6">
<ArtifactsToolbar
query={query}
onQueryChange={setQuery}
kind={kind}
onKindChange={setKind}
groupBy={groupBy}
onGroupByChange={setGroupBy}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
{PARENT_TASK_GROUPS.map((group) => (
<ArtifactStackCard key={group.id} group={group} />
))}
</div>
</div>
);
},
};
/**
* 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<StoryArtifactKindFilter>("all");
const [groupBy, setGroupBy] = useState<StoryArtifactGroupBy>("task");
return (
<div className="mx-auto w-full max-w-6xl space-y-5 p-6">
<ArtifactsToolbar
query={query}
onQueryChange={setQuery}
kind={kind}
onKindChange={setKind}
groupBy={groupBy}
onGroupByChange={setGroupBy}
/>
<div className="flex flex-col gap-2 border-b border-border pb-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 min-w-0">
<a
href="/PAP/artifacts?groupBy=task"
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
All stacks
</a>
<span className="text-muted-foreground/40" aria-hidden="true">
/
</span>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">PAP-10306</span>
<span className="min-w-0 truncate text-sm font-medium text-foreground/90">
Landing visuals refresh
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground/70">
<Layers className="h-3 w-3" aria-hidden="true" />
<span>{SELECTED_GROUP_ARTIFACTS.length} artifacts in this stack</span>
</div>
</div>
<ArtifactsGrid artifacts={SELECTED_GROUP_ARTIFACTS} />
</div>
);
},
};
/**
* 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<StoryArtifactKindFilter>("all");
const [groupBy, setGroupBy] = useState<StoryArtifactGroupBy>("task");
return (
<div className="mx-auto w-full max-w-md space-y-5 p-4">
<ArtifactsToolbar
query={query}
onQueryChange={setQuery}
kind={kind}
onKindChange={setKind}
groupBy={groupBy}
onGroupByChange={setGroupBy}
/>
<div className="grid grid-cols-1 gap-6">
{TASK_GROUPS.slice(0, 3).map((group) => (
<ArtifactStackCard key={group.id} group={group} />
))}
</div>
</div>
);
},
};
export const Empty: Story = {
render: () => (
<div className="mx-auto max-w-6xl p-6">
<EmptyState
icon={Package}
message="No artifacts yet. Agent-produced documents, media, and files will appear here."
/>
</div>
),
};