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 (
-
-
-
);
}
@@ -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}
+
+ >
)}
);