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(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( () => 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 ; } 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 (
setDraftQuery(event.currentTarget.value)} placeholder="Search artifacts..." aria-label="Search artifacts" className="h-9 pl-9 pr-9 text-sm" /> {draftQuery.length > 0 ? ( ) : null}
Group by {ARTIFACT_GROUP_OPTIONS.map((option) => ( selectGroupBy(option.value)} className="justify-between" > {option.label} {groupBy === option.value ? : null} ))}
{ARTIFACT_KIND_FILTERS.map((filter) => ( ))}
{viewingSelectedStack ? (
) : null} {error &&

{error.message}

} {isLoading ? ( ) : items.length === 0 ? ( ) : ( <>
{showGroupCards ? groups.map((group) => ( )) : artifacts.map((artifact) => ( ))}
{isFetchingNextPage ? "Loading more artifacts..." : hasNextPage ? null : isFetching ? "Updating artifacts..." : null}
)}
); }