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:
+257
-45
@@ -1,20 +1,35 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Package, Search, X } from "lucide-react";
|
||||
import { artifactsApi, type ArtifactKindFilter } from "../api/artifacts";
|
||||
import { ArrowLeft, Check, Layers, Package, Search, X } from "lucide-react";
|
||||
import type { To } from "react-router-dom";
|
||||
import {
|
||||
artifactsApi,
|
||||
type ArtifactGroupBy,
|
||||
type ArtifactKindFilter,
|
||||
} from "../api/artifacts";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { ArtifactCard } from "../components/artifacts/ArtifactCard";
|
||||
import { ArtifactGroupCard } from "../components/artifacts/ArtifactGroupCard";
|
||||
import { useSearchParams, Link } from "@/lib/router";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} 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;
|
||||
|
||||
const KIND_FILTERS: { value: ArtifactKindFilter; label: string }[] = [
|
||||
export const ARTIFACT_KIND_FILTERS: { value: ArtifactKindFilter; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "image", label: "Images" },
|
||||
{ value: "video", label: "Videos" },
|
||||
@@ -23,22 +38,135 @@ const KIND_FILTERS: { value: ArtifactKindFilter; label: string }[] = [
|
||||
{ value: "file", label: "Files" },
|
||||
];
|
||||
|
||||
export const ARTIFACT_GROUP_OPTIONS: { value: ArtifactGroupBy; label: string }[] = [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "task", label: "Task" },
|
||||
{ value: "parent_task", label: "Parent task" },
|
||||
];
|
||||
|
||||
const KIND_VALUES = new Set(ARTIFACT_KIND_FILTERS.map((filter) => filter.value));
|
||||
|
||||
function parseGroupBy(value: string | null): ArtifactGroupBy {
|
||||
if (value === "none" || value === "task" || value === "parent_task") return value;
|
||||
return "task";
|
||||
}
|
||||
|
||||
function parseKind(value: string | null): ArtifactKindFilter {
|
||||
return value && KIND_VALUES.has(value as ArtifactKindFilter)
|
||||
? (value as ArtifactKindFilter)
|
||||
: "all";
|
||||
}
|
||||
|
||||
export function artifactGroupByLabel(value: ArtifactGroupBy): string {
|
||||
return ARTIFACT_GROUP_OPTIONS.find((option) => option.value === value)?.label ?? "None";
|
||||
}
|
||||
|
||||
export function Artifacts() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [kind, setKind] = useState<ArtifactKindFilter>("all");
|
||||
const [draftQuery, setDraftQuery] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const kind = parseKind(searchParams.get("kind"));
|
||||
const query = searchParams.get("q") ?? "";
|
||||
const groupBy = parseGroupBy(searchParams.get("groupBy"));
|
||||
const groupIssueId = searchParams.get("groupIssueId") ?? undefined;
|
||||
|
||||
const [draftQuery, setDraftQuery] = useState(query);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Artifacts" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
const grouping = groupBy !== "none";
|
||||
const viewingStackList = grouping && !groupIssueId;
|
||||
const viewingSelectedStack = grouping && !!groupIssueId;
|
||||
|
||||
// Keep the search box in sync when the committed query changes from outside
|
||||
// (e.g. back/forward navigation or a shared URL), without clobbering in-flight
|
||||
// typing (which leaves `query` unchanged until the debounce commits).
|
||||
useEffect(() => {
|
||||
const handle = window.setTimeout(() => setQuery(draftQuery.trim()), SEARCH_DEBOUNCE_MS);
|
||||
setDraftQuery((prev) => (prev.trim() === query ? prev : query));
|
||||
}, [query]);
|
||||
|
||||
// Debounce the search box into the `q` URL param so searches are shareable.
|
||||
useEffect(() => {
|
||||
const trimmed = draftQuery.trim();
|
||||
if (trimmed === query) return;
|
||||
const handle = window.setTimeout(() => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (trimmed) next.set("q", trimmed);
|
||||
else next.delete("q");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [draftQuery]);
|
||||
}, [draftQuery, query, setSearchParams]);
|
||||
|
||||
const updateParams = useCallback(
|
||||
(mutate: (next: URLSearchParams) => void) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
mutate(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const selectKind = useCallback(
|
||||
(value: ArtifactKindFilter) => {
|
||||
updateParams((next) => {
|
||||
if (value === "all") next.delete("kind");
|
||||
else next.set("kind", value);
|
||||
});
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const selectGroupBy = useCallback(
|
||||
(value: ArtifactGroupBy) => {
|
||||
updateParams((next) => {
|
||||
// Switching the grouping mode always returns to the stack list.
|
||||
next.delete("groupIssueId");
|
||||
if (value === "task") next.delete("groupBy");
|
||||
else next.set("groupBy", value);
|
||||
});
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
// Build a relative `To` that preserves the active filters/search while
|
||||
// changing only the grouping selection. A bare query string keeps the current
|
||||
// pathname (the company-prefixed /artifacts route) and stays linkable.
|
||||
const buildTo = useCallback(
|
||||
(mutate: (next: URLSearchParams) => void): To => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
mutate(next);
|
||||
const serialized = next.toString();
|
||||
return serialized ? `?${serialized}` : "?";
|
||||
},
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const stackTo = useCallback(
|
||||
(issueId: string): To =>
|
||||
buildTo((next) => {
|
||||
if (groupBy === "task") next.delete("groupBy");
|
||||
else if (groupBy !== "none") next.set("groupBy", groupBy);
|
||||
next.set("groupIssueId", issueId);
|
||||
}),
|
||||
[buildTo, groupBy],
|
||||
);
|
||||
|
||||
const backToStacksTo = useMemo<To>(
|
||||
() =>
|
||||
buildTo((next) => {
|
||||
if (groupBy === "task") next.delete("groupBy");
|
||||
next.delete("groupIssueId");
|
||||
}),
|
||||
[buildTo, groupBy],
|
||||
);
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -49,11 +177,13 @@ export function Artifacts() {
|
||||
fetchNextPage,
|
||||
error,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKeys.artifacts.list(selectedCompanyId!, kind, query),
|
||||
queryKey: queryKeys.artifacts.list(selectedCompanyId!, kind, query, groupBy, groupIssueId),
|
||||
queryFn: ({ pageParam }) =>
|
||||
artifactsApi.list(selectedCompanyId!, {
|
||||
kind,
|
||||
q: query || undefined,
|
||||
groupBy,
|
||||
groupIssueId,
|
||||
limit: ARTIFACTS_PAGE_SIZE,
|
||||
cursor: pageParam,
|
||||
}),
|
||||
@@ -75,12 +205,46 @@ export function Artifacts() {
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const artifacts = useMemo(() => data?.pages.flatMap((page) => page.artifacts) ?? [], [data]);
|
||||
const groups = useMemo(
|
||||
() => data?.pages.flatMap((page) => page.groups ?? []) ?? [],
|
||||
[data],
|
||||
);
|
||||
const selectedGroup = useMemo(
|
||||
() => data?.pages.map((page) => page.selectedGroup).find(Boolean) ?? null,
|
||||
[data],
|
||||
);
|
||||
const searching = query.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (viewingSelectedStack && selectedGroup) {
|
||||
setBreadcrumbs([
|
||||
{ label: "Artifacts", href: "/artifacts" },
|
||||
{ label: `${selectedGroup.issue.identifier} · ${selectedGroup.title}` },
|
||||
]);
|
||||
} else {
|
||||
setBreadcrumbs([{ label: "Artifacts" }]);
|
||||
}
|
||||
}, [setBreadcrumbs, viewingSelectedStack, selectedGroup]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={Package} message="Select a company to view artifacts." />;
|
||||
}
|
||||
|
||||
const showGroupCards = viewingStackList;
|
||||
const items = showGroupCards ? groups : artifacts;
|
||||
|
||||
const emptyMessage = showGroupCards
|
||||
? searching
|
||||
? "No artifact stacks match this search."
|
||||
: "No artifact stacks yet."
|
||||
: searching
|
||||
? "No artifacts match this search."
|
||||
: viewingSelectedStack
|
||||
? "No artifacts in this stack match the current filters."
|
||||
: kind === "all"
|
||||
? "No artifacts yet. Outputs attached to issues will appear here."
|
||||
: "No artifacts of this type yet.";
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl space-y-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -105,48 +269,96 @@ export function Artifacts() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5" role="tablist" aria-label="Filter artifacts by type">
|
||||
{KIND_FILTERS.map((filter) => (
|
||||
<button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={kind === filter.value}
|
||||
onClick={() => setKind(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 className="flex flex-wrap items-center gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={`Group artifacts (currently ${artifactGroupByLabel(groupBy)})`}
|
||||
title="Group artifacts"
|
||||
data-testid="artifact-group-control"
|
||||
data-group-by={groupBy}
|
||||
className={cn("h-8 w-8 shrink-0", grouping && "bg-accent")}
|
||||
>
|
||||
<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((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
data-testid={`artifact-group-option-${option.value}`}
|
||||
aria-selected={groupBy === option.value}
|
||||
onSelect={() => selectGroupBy(option.value)}
|
||||
className="justify-between"
|
||||
>
|
||||
{option.label}
|
||||
{groupBy === option.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={() => selectKind(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>
|
||||
|
||||
{viewingSelectedStack ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<Link
|
||||
to={backToStacksTo}
|
||||
data-testid="artifact-stack-back"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 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
|
||||
</Link>
|
||||
{selectedGroup ? (
|
||||
<span className="truncate text-muted-foreground">
|
||||
<span className="text-foreground/80">{selectedGroup.issue.identifier}</span>{" "}
|
||||
{selectedGroup.title}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{isLoading ? (
|
||||
<PageSkeleton variant="list" />
|
||||
) : artifacts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
message={
|
||||
searching
|
||||
? "No artifacts match this search."
|
||||
: kind === "all"
|
||||
? "No artifacts yet. Outputs attached to issues will appear here."
|
||||
: "No artifacts of this type yet."
|
||||
}
|
||||
/>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState icon={showGroupCards ? Layers : Package} message={emptyMessage} />
|
||||
) : (
|
||||
<>
|
||||
<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} />
|
||||
))}
|
||||
{showGroupCards
|
||||
? groups.map((group) => (
|
||||
<ArtifactGroupCard key={group.id} group={group} to={stackTo(group.issue.id)} />
|
||||
))
|
||||
: artifacts.map((artifact) => (
|
||||
<ArtifactCard key={`${artifact.source}:${artifact.id}`} artifact={artifact} />
|
||||
))}
|
||||
</div>
|
||||
<div ref={loadMoreRef} className="flex min-h-10 items-center justify-center pb-2 text-xs text-muted-foreground">
|
||||
{isFetchingNextPage
|
||||
|
||||
Reference in New Issue
Block a user