d8e1004551
## 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>
377 lines
13 KiB
TypeScript
377 lines
13 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
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;
|
|
|
|
export const ARTIFACT_KIND_FILTERS: { value: ArtifactKindFilter; label: string }[] = [
|
|
{ value: "all", label: "All" },
|
|
{ value: "image", label: "Images" },
|
|
{ value: "video", label: "Videos" },
|
|
{ value: "document", label: "Documents" },
|
|
{ value: "text", label: "Text" },
|
|
{ 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 [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);
|
|
|
|
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(() => {
|
|
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, 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,
|
|
isLoading,
|
|
isFetching,
|
|
isFetchingNextPage,
|
|
hasNextPage,
|
|
fetchNextPage,
|
|
error,
|
|
} = useInfiniteQuery({
|
|
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,
|
|
}),
|
|
enabled: !!selectedCompanyId,
|
|
initialPageParam: undefined as string | undefined,
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const target = loadMoreRef.current;
|
|
if (!target || !hasNextPage || isFetchingNextPage) return;
|
|
const observer = new IntersectionObserver((entries) => {
|
|
if (entries.some((entry) => entry.isIntersecting)) {
|
|
void fetchNextPage();
|
|
}
|
|
}, { rootMargin: "320px 0px" });
|
|
observer.observe(target);
|
|
return () => observer.disconnect();
|
|
}, [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">
|
|
<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={draftQuery}
|
|
onChange={(event) => setDraftQuery(event.currentTarget.value)}
|
|
placeholder="Search artifacts..."
|
|
aria-label="Search artifacts"
|
|
className="h-9 pl-9 pr-9 text-sm"
|
|
/>
|
|
{draftQuery.length > 0 ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setDraftQuery("")}
|
|
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"
|
|
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" />
|
|
) : 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">
|
|
{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
|
|
? "Loading more artifacts..."
|
|
: hasNextPage
|
|
? null
|
|
: isFetching
|
|
? "Updating artifacts..."
|
|
: null}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|