From f76513e3d5d70c9b04f062ffb98246214dc72b8e Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 5 Jun 2026 18:08:29 +0000 Subject: [PATCH] Polish artifacts card metadata --- .../artifacts/ArtifactCard.test.tsx | 42 +++- ui/src/components/artifacts/ArtifactCard.tsx | 26 +- ui/src/pages/Artifacts.test.tsx | 231 ++++++++++++++++++ ui/src/pages/Artifacts.tsx | 151 +++++++++--- 4 files changed, 389 insertions(+), 61 deletions(-) create mode 100644 ui/src/pages/Artifacts.test.tsx diff --git a/ui/src/components/artifacts/ArtifactCard.test.tsx b/ui/src/components/artifacts/ArtifactCard.test.tsx index ea9b979db9e..1516721bee0 100644 --- a/ui/src/components/artifacts/ArtifactCard.test.tsx +++ b/ui/src/components/artifacts/ArtifactCard.test.tsx @@ -3,8 +3,17 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; vi.mock("@/lib/router", () => ({ - Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => ( - + Link: ({ + to, + children, + disableIssueQuicklook, + ...props + }: { + to: string; + children: ReactNode; + disableIssueQuicklook?: boolean; + }) => ( + {children} ), @@ -37,11 +46,33 @@ describe("ArtifactCard", () => { it("renders an image preview with cover image and links to the issue anchor", () => { const markup = renderToStaticMarkup(); expect(markup).toContain('href="/issues/PAP-10306#attachment-art-1"'); + expect(markup).toContain('data-disable-issue-quicklook="true"'); expect(markup).toContain('data-media-kind="image"'); + expect(markup).toContain("rounded-[8px]"); expect(markup).toContain('src="/files/art-1.png"'); expect(markup).toContain("object-cover"); expect(markup).toContain("Hero shot"); - expect(markup).toContain("PAP-10306"); + expect(markup).toContain("Last edited Jun 1, 2026"); + expect(markup).not.toContain("Landing visuals"); + expect(markup).not.toContain("Edited "); + }); + + it("renders only the artifact subject and absolute metadata under the preview", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Social launch clip"); + expect(markup).toContain("Last edited Oct 8, 2025"); + expect(markup).not.toContain("Make artifact page look like this"); + expect(markup).not.toContain(">PAP-10370<"); }); it("renders a video preview with a video element and play glyph", () => { @@ -77,6 +108,11 @@ describe("ArtifactCard", () => { ); expect(markup).toContain('data-media-kind="document"'); expect(markup).toContain("This is the plan preview excerpt."); + expect(markup).toContain("text-base"); + expect(markup).toContain("leading-6"); + expect(markup).toContain("max-h-full"); + expect(markup).toContain("overflow-hidden"); + expect(markup).not.toContain('data-lucide="file-text"'); }); it("renders a placeholder for empty artifacts without an image or video", () => { diff --git a/ui/src/components/artifacts/ArtifactCard.tsx b/ui/src/components/artifacts/ArtifactCard.tsx index 3c856b21071..cacdd834b1a 100644 --- a/ui/src/components/artifacts/ArtifactCard.tsx +++ b/ui/src/components/artifacts/ArtifactCard.tsx @@ -1,8 +1,8 @@ import { useState } from "react"; -import { Download, ExternalLink, FileText, Paperclip, Play } from "lucide-react"; +import { Download, ExternalLink, Paperclip, Play } from "lucide-react"; import type { CompanyArtifact } from "@/api/artifacts"; import { Link } from "@/lib/router"; -import { cn, relativeTime } from "@/lib/utils"; +import { cn, formatDate } from "@/lib/utils"; interface ArtifactCardProps { artifact: CompanyArtifact; @@ -88,14 +88,11 @@ function TextPreview({ artifact }: { artifact: CompanyArtifact }) { return (
-

+

{preview}

-
-
); } @@ -146,15 +143,16 @@ export function ArtifactCard({ artifact }: ArtifactCardProps) { return (
-

+

{artifact.title}

@@ -171,16 +169,8 @@ export function ArtifactCard({ artifact }: ArtifactCardProps) {
-
- {artifact.issue.identifier} - · - - {artifact.issue.title} - -
- -
- Edited {relativeTime(artifact.updatedAt)} +
+ Last edited {formatDate(artifact.updatedAt)} {artifact.createdByAgent ? ( <> · diff --git a/ui/src/pages/Artifacts.test.tsx b/ui/src/pages/Artifacts.test.tsx new file mode 100644 index 00000000000..eabc37bf8c8 --- /dev/null +++ b/ui/src/pages/Artifacts.test.tsx @@ -0,0 +1,231 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Artifacts } from "./Artifacts"; +import type { CompanyArtifact } from "../api/artifacts"; + +const companyState = vi.hoisted(() => ({ + selectedCompanyId: "company-1", +})); + +const breadcrumbState = vi.hoisted(() => ({ + setBreadcrumbs: vi.fn(), +})); + +const artifactsApiMock = vi.hoisted(() => ({ + list: vi.fn(), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => companyState, +})); + +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => breadcrumbState, +})); + +vi.mock("../api/artifacts", () => ({ + artifactsApi: artifactsApiMock, +})); + +vi.mock("../components/artifacts/ArtifactCard", () => ({ + ArtifactCard: ({ artifact }: { artifact: CompanyArtifact }) => ( +
{artifact.title}
+ ), +})); + +type ObserverCallback = IntersectionObserverCallback; + +let latestObserverCallback: ObserverCallback | null = null; + +class MockIntersectionObserver { + readonly root = null; + readonly rootMargin = ""; + readonly thresholds = []; + + constructor(callback: ObserverCallback) { + latestObserverCallback = callback; + } + + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(() => []); +} + +function sampleArtifact(overrides: Partial = {}): CompanyArtifact { + return { + id: "artifact-1", + source: "document", + mediaKind: "document", + title: "Launch Brief", + previewText: "launch brief preview", + contentType: "text/markdown", + contentPath: null, + openPath: null, + downloadPath: null, + issue: { id: "issue-1", identifier: "PAP-42", title: "Ship launch" }, + project: null, + createdByAgent: null, + updatedAt: "2026-06-01T00:00:00.000Z", + href: "/PAP/issues/PAP-42#document-brief", + ...overrides, + }; +} + +async function flush() { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function waitForAssertion(assertion: () => void, attempts = 50) { + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await flush(); + } + } + throw lastError; +} + +function renderArtifacts(container: HTMLDivElement) { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + flushSync(() => { + root.render( + + + , + ); + }); + return { root, queryClient }; +} + +describe("Artifacts page", () => { + let container: HTMLDivElement; + let originalIntersectionObserver: typeof IntersectionObserver | undefined; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + breadcrumbState.setBreadcrumbs.mockReset(); + artifactsApiMock.list.mockReset(); + latestObserverCallback = null; + originalIntersectionObserver = window.IntersectionObserver; + window.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver; + }); + + afterEach(() => { + window.IntersectionObserver = originalIntersectionObserver as typeof IntersectionObserver; + container.remove(); + }); + + it("debounces artifact search into the artifacts API", async () => { + artifactsApiMock.list + .mockResolvedValueOnce({ artifacts: [sampleArtifact()], nextCursor: null }) + .mockResolvedValueOnce({ artifacts: [], nextCursor: null }); + + const { root } = renderArtifacts(container); + + await waitForAssertion(() => { + expect(artifactsApiMock.list).toHaveBeenCalledWith("company-1", { + kind: "all", + q: undefined, + limit: 30, + cursor: undefined, + }); + }); + + const input = container.querySelector('input[aria-label="Search artifacts"]') as HTMLInputElement; + expect(input).not.toBeNull(); + + flushSync(() => { + const nativeSetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!; + nativeSetter.call(input, "launch"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + await waitForAssertion(() => { + expect(artifactsApiMock.list).toHaveBeenLastCalledWith("company-1", { + kind: "all", + q: "launch", + limit: 30, + cursor: undefined, + }); + }); + + flushSync(() => { + root.unmount(); + }); + }); + + it("keeps the artifacts grid max-width constrained and left aligned", async () => { + artifactsApiMock.list.mockResolvedValue({ artifacts: [sampleArtifact()], nextCursor: null }); + + const { root } = renderArtifacts(container); + + await waitForAssertion(() => { + expect(container.querySelector('[data-testid="artifact-card"]')).not.toBeNull(); + }); + + const pageShell = container.firstElementChild as HTMLElement | null; + expect(pageShell?.className).toContain("max-w-6xl"); + expect(pageShell?.className).not.toContain("mx-auto"); + + flushSync(() => { + root.unmount(); + }); + }); + + it("fetches the next artifact page when the sentinel intersects", async () => { + artifactsApiMock.list + .mockResolvedValueOnce({ + artifacts: [sampleArtifact({ id: "artifact-1", title: "First Artifact" })], + nextCursor: "cursor-2", + }) + .mockResolvedValueOnce({ + artifacts: [sampleArtifact({ id: "artifact-2", title: "Second Artifact" })], + nextCursor: null, + }); + + const { root } = renderArtifacts(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("First Artifact"); + expect(latestObserverCallback).not.toBeNull(); + }); + + latestObserverCallback?.( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ); + + await waitForAssertion(() => { + expect(artifactsApiMock.list).toHaveBeenLastCalledWith("company-1", { + kind: "all", + q: undefined, + limit: 30, + cursor: "cursor-2", + }); + expect(container.textContent).toContain("Second Artifact"); + }); + + flushSync(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/Artifacts.tsx b/ui/src/pages/Artifacts.tsx index 76e62141520..50e7e944252 100644 --- a/ui/src/pages/Artifacts.tsx +++ b/ui/src/pages/Artifacts.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { Package } from "lucide-react"; +import { 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 { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; @@ -9,6 +9,10 @@ import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { ArtifactCard } from "../components/artifacts/ArtifactCard"; import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; + +const ARTIFACTS_PAGE_SIZE = 30; +const SEARCH_DEBOUNCE_MS = 250; const KIND_FILTERS: { value: ArtifactKindFilter; label: string }[] = [ { value: "all", label: "All" }, @@ -23,49 +27,103 @@ export function Artifacts() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const [kind, setKind] = useState("all"); + const [draftQuery, setDraftQuery] = useState(""); + const [query, setQuery] = useState(""); + const loadMoreRef = useRef(null); useEffect(() => { setBreadcrumbs([{ label: "Artifacts" }]); }, [setBreadcrumbs]); - const { data, isLoading, error } = useQuery({ - queryKey: queryKeys.artifacts.list(selectedCompanyId!, kind), - queryFn: () => artifactsApi.list(selectedCompanyId!, { kind }), + useEffect(() => { + const handle = window.setTimeout(() => setQuery(draftQuery.trim()), SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(handle); + }, [draftQuery]); + + const { + data, + isLoading, + isFetching, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + error, + } = useInfiniteQuery({ + queryKey: queryKeys.artifacts.list(selectedCompanyId!, kind, query), + queryFn: ({ pageParam }) => + artifactsApi.list(selectedCompanyId!, { + kind, + q: query || undefined, + 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 searching = query.length > 0; + if (!selectedCompanyId) { return ; } - const artifacts = data?.artifacts ?? []; - return ( -
-
-

- Work your agents have produced — documents, media, and files — across this company's issues. -

-
+
+
+
+ + setDraftQuery(event.currentTarget.value)} + placeholder="Search artifacts..." + aria-label="Search artifacts" + className="h-9 pl-9 pr-9 text-sm" + /> + {draftQuery.length > 0 ? ( + + ) : null} +
-
- {KIND_FILTERS.map((filter) => ( - - ))} +
+ {KIND_FILTERS.map((filter) => ( + + ))} +
{error &&

{error.message}

} @@ -76,17 +134,30 @@ export function Artifacts() { ) : ( -
- {artifacts.map((artifact) => ( - - ))} -
+ <> +
+ {artifacts.map((artifact) => ( + + ))} +
+
+ {isFetchingNextPage + ? "Loading more artifacts..." + : hasNextPage + ? null + : isFetching + ? "Updating artifacts..." + : null} +
+ )}
);