diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 94522182c9b..6e51901c392 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -22,6 +22,7 @@ import { RoutineDetail } from "./pages/RoutineDetail"; import { UserProfile } from "./pages/UserProfile"; import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail"; import { Goals } from "./pages/Goals"; +import { Artifacts } from "./pages/Artifacts"; import { GoalDetail } from "./pages/GoalDetail"; import { Approvals } from "./pages/Approvals"; import { ApprovalDetail } from "./pages/ApprovalDetail"; @@ -127,6 +128,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -307,6 +309,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/artifacts.test.ts b/ui/src/api/artifacts.test.ts new file mode 100644 index 00000000000..fffb68b96ae --- /dev/null +++ b/ui/src/api/artifacts.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock("./client", () => ({ + api: mockApi, +})); + +import { artifactsApi, type CompanyArtifact } from "./artifacts"; + +function sampleArtifact(overrides: Partial = {}): CompanyArtifact { + return { + id: "wp-1", + source: "work_product", + mediaKind: "video", + title: "Primary cut", + previewText: null, + contentType: "video/mp4", + contentPath: "/files/wp-1.mp4", + openPath: "/files/wp-1.mp4", + downloadPath: "/files/wp-1.mp4?download=1", + issue: { id: "issue-1", identifier: "PAP-10205", title: "Demo reel" }, + project: { id: "proj-1", name: "Paperclip App" }, + createdByAgent: { id: "agent-1", name: "ClaudeCoder" }, + updatedAt: "2026-06-01T00:00:00.000Z", + href: "/issues/PAP-10205#work-product-wp-1", + ...overrides, + }; +} + +describe("artifactsApi.list", () => { + beforeEach(() => { + mockApi.get.mockReset(); + mockApi.get.mockResolvedValue({ artifacts: [], nextCursor: null }); + }); + + it("calls the company-scoped artifacts endpoint with no params", async () => { + await artifactsApi.list("company-1"); + expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/artifacts"); + }); + + it("omits the kind param when filtering by all", async () => { + await artifactsApi.list("company-1", { kind: "all" }); + expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/artifacts"); + }); + + it("serializes kind, project, search, and pagination params", async () => { + await artifactsApi.list("company-1", { + kind: "video", + projectId: "proj-1", + q: "demo reel", + limit: 24, + cursor: "abc", + }); + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/artifacts?kind=video&projectId=proj-1&q=demo+reel&limit=24&cursor=abc", + ); + }); + + it("returns the envelope shape from the backend", async () => { + const artifact = sampleArtifact(); + mockApi.get.mockResolvedValue({ artifacts: [artifact], nextCursor: "next" }); + const result = await artifactsApi.list("company-1"); + expect(result).toEqual({ artifacts: [artifact], nextCursor: "next" }); + }); + + it("normalizes a bare array response into the envelope shape", async () => { + const artifact = sampleArtifact(); + mockApi.get.mockResolvedValue([artifact]); + const result = await artifactsApi.list("company-1"); + expect(result).toEqual({ artifacts: [artifact], nextCursor: null }); + }); +}); diff --git a/ui/src/api/artifacts.ts b/ui/src/api/artifacts.ts new file mode 100644 index 00000000000..bf2ba4dc1cf --- /dev/null +++ b/ui/src/api/artifacts.ts @@ -0,0 +1,105 @@ +import { api } from "./client"; + +/** + * Company-level Artifacts client (PAP-10359). + * + * Talks to the company-scoped artifacts projection endpoint + * (`GET /api/companies/:companyId/artifacts`) defined by the approved + * Artifacts plan (PAP-10353). The endpoint flattens agent-produced issue + * documents, direct attachments, and `artifact` work products into a single + * card-ready list so the UI never has to stitch issue-specific endpoints + * together. + * + * The `CompanyArtifact` shape below is the shared contract from the plan. The + * backend projection task (PAP-10358) is expected to expose the same fields + * (and may also publish them through `@paperclipai/shared`); keeping a local + * definition lets the frontend build and test against the contract + * independently. + */ + +export type ArtifactSource = "document" | "attachment" | "work_product"; + +export type ArtifactMediaKind = "image" | "video" | "text" | "document" | "file" | "empty"; + +export interface CompanyArtifactIssue { + id: string; + identifier: string; + title: string; +} + +export interface CompanyArtifactProject { + id: string; + name: string; +} + +export interface CompanyArtifactAgent { + id: string; + name: string; +} + +export interface CompanyArtifact { + id: string; + source: ArtifactSource; + mediaKind: ArtifactMediaKind; + title: string; + previewText: string | null; + contentType: string | null; + contentPath: string | null; + openPath: string | null; + downloadPath: string | null; + issue: CompanyArtifactIssue; + project: CompanyArtifactProject | null; + createdByAgent: CompanyArtifactAgent | null; + updatedAt: string; + /** Board-relative or prefixed link to the artifact inside its issue context. */ + href: string; +} + +export interface CompanyArtifactsResponse { + artifacts: CompanyArtifact[]; + nextCursor: string | null; +} + +export type ArtifactKindFilter = ArtifactMediaKind | "all"; + +export interface ListArtifactsParams { + kind?: ArtifactKindFilter; + projectId?: string; + q?: string; + limit?: number; + cursor?: string; +} + +function buildArtifactsQuery(params?: ListArtifactsParams): string { + const search = new URLSearchParams(); + if (params?.kind && params.kind !== "all") search.set("kind", params.kind); + if (params?.projectId) search.set("projectId", params.projectId); + if (params?.q) search.set("q", params.q); + if (params?.limit != null) search.set("limit", String(params.limit)); + if (params?.cursor) search.set("cursor", params.cursor); + const qs = search.toString(); + return qs ? `?${qs}` : ""; +} + +/** + * Normalize the endpoint response. The contract is an envelope + * (`{ artifacts, nextCursor }`), but we also tolerate a bare array so the page + * keeps working if the backend ships the simpler shape. + */ +function normalizeArtifactsResponse( + raw: CompanyArtifactsResponse | CompanyArtifact[], +): CompanyArtifactsResponse { + if (Array.isArray(raw)) { + return { artifacts: raw, nextCursor: null }; + } + return { artifacts: raw.artifacts ?? [], nextCursor: raw.nextCursor ?? null }; +} + +export const artifactsApi = { + list: async (companyId: string, params?: ListArtifactsParams): Promise => { + const raw = await api.get( + `/companies/${companyId}/artifacts${buildArtifactsQuery(params)}`, + ); + return normalizeArtifactsResponse(raw); + }, +}; diff --git a/ui/src/components/IssueAttachmentsSection.tsx b/ui/src/components/IssueAttachmentsSection.tsx index b5607c0860d..56658045ec7 100644 --- a/ui/src/components/IssueAttachmentsSection.tsx +++ b/ui/src/components/IssueAttachmentsSection.tsx @@ -103,7 +103,7 @@ function MarkdownAttachmentCard({ }); return ( -
+
@@ -142,7 +142,7 @@ function VideoAttachmentCard({ }) { const filename = attachmentFilename(attachment); return ( -
+
@@ -166,7 +166,7 @@ function GenericAttachmentRow({ }) { const filename = attachmentFilename(attachment); return ( -
+
(
onImageClick(attachment)} > { }); }); + it("shows an Artifacts nav item directly below Goals", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); + const root = await renderSidebar(); + + const artifactsLink = [...container.querySelectorAll("a")].find( + (anchor) => anchor.textContent === "Artifacts", + ); + expect(artifactsLink?.getAttribute("href")).toBe("/artifacts"); + + const navText = container.querySelector("nav")?.textContent ?? ""; + expect(navText).toContain("Goals"); + expect(navText).toContain("Artifacts"); + expect(navText.indexOf("Goals")).toBeLessThan(navText.indexOf("Artifacts")); + + await act(async () => { + root.unmount(); + }); + }); + it("shows the Workspaces link when isolated workspaces are enabled", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); const root = await renderSidebar(); diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index bce1f64aecf..9c4e9a9c7dc 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -11,6 +11,7 @@ import { Boxes, Repeat, GitBranch, + Package, Settings, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; @@ -97,6 +98,7 @@ export function Sidebar() { + {showWorkspacesLink ? ( ) : null} diff --git a/ui/src/components/artifacts/ArtifactCard.test.tsx b/ui/src/components/artifacts/ArtifactCard.test.tsx new file mode 100644 index 00000000000..ea9b979db9e --- /dev/null +++ b/ui/src/components/artifacts/ArtifactCard.test.tsx @@ -0,0 +1,109 @@ +import type { ReactNode } from "react"; +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 }) => ( + + {children} + + ), +})); + +import { ArtifactCard } from "./ArtifactCard"; +import type { CompanyArtifact } from "@/api/artifacts"; + +function makeArtifact(overrides: Partial = {}): CompanyArtifact { + return { + id: "art-1", + source: "attachment", + mediaKind: "image", + title: "Hero shot", + previewText: null, + contentType: "image/png", + contentPath: "/files/art-1.png", + openPath: "/files/art-1.png", + downloadPath: "/files/art-1.png?download=1", + issue: { id: "issue-1", identifier: "PAP-10306", title: "Landing visuals" }, + project: { id: "proj-1", name: "Paperclip App" }, + createdByAgent: { id: "agent-1", name: "ClaudeCoder" }, + updatedAt: "2026-06-01T00:00:00.000Z", + href: "/issues/PAP-10306#attachment-art-1", + ...overrides, + }; +} + +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-media-kind="image"'); + expect(markup).toContain('src="/files/art-1.png"'); + expect(markup).toContain("object-cover"); + expect(markup).toContain("Hero shot"); + expect(markup).toContain("PAP-10306"); + }); + + it("renders a video preview with a video element and play glyph", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('data-media-kind="video"'); + expect(markup).toContain(" { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('data-media-kind="video"'); + expect(markup).not.toContain(" { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('data-media-kind="document"'); + expect(markup).toContain("This is the plan preview excerpt."); + }); + + it("renders a placeholder for empty artifacts without an image or video", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('data-media-kind="empty"'); + expect(markup).not.toContain(" { + const markup = renderToStaticMarkup( + , + ); + expect(markup).not.toContain('aria-label="Download file"'); + expect(markup).not.toContain('aria-label="Open file in new tab"'); + }); +}); diff --git a/ui/src/components/artifacts/ArtifactCard.tsx b/ui/src/components/artifacts/ArtifactCard.tsx new file mode 100644 index 00000000000..3c856b21071 --- /dev/null +++ b/ui/src/components/artifacts/ArtifactCard.tsx @@ -0,0 +1,194 @@ +import { useState } from "react"; +import { Download, ExternalLink, FileText, Paperclip, Play } from "lucide-react"; +import type { CompanyArtifact } from "@/api/artifacts"; +import { Link } from "@/lib/router"; +import { cn, relativeTime } from "@/lib/utils"; + +interface ArtifactCardProps { + artifact: CompanyArtifact; +} + +/** + * Stable, fixed-height preview region shared by every card variant. The fixed + * aspect ratio is what keeps image / video / text / placeholder cards from + * shifting layout as previews load (or fail to load). + */ +function PreviewFrame({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +function PlaceholderPreview({ label }: { label?: string }) { + return ( + +
+
+
+ ); +} + +function ImagePreview({ artifact }: { artifact: CompanyArtifact }) { + const [errored, setErrored] = useState(false); + if (errored || !artifact.contentPath) { + return ; + } + return ( + + {artifact.title} setErrored(true)} + /> + + ); +} + +function VideoPreview({ artifact }: { artifact: CompanyArtifact }) { + const [errored, setErrored] = useState(false); + if (errored || !artifact.contentPath) { + return ( + +
+
+
+ ); + } + return ( + + + ); +} + +function TextPreview({ artifact }: { artifact: CompanyArtifact }) { + const preview = artifact.previewText?.trim(); + if (!preview) { + return ; + } + return ( + +
+

+ {preview} +

+
+
+
+
+ + ); +} + +function ArtifactPreview({ artifact }: { artifact: CompanyArtifact }) { + switch (artifact.mediaKind) { + case "image": + return ; + case "video": + return ; + case "text": + case "document": + return ; + case "file": + return ; + case "empty": + default: + return ; + } +} + +function SecondaryAction({ + href, + download, + title, + children, +}: { + href: string; + download?: boolean; + title: string; + children: React.ReactNode; +}) { + return ( + event.stopPropagation()} + className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + > + {children} + + ); +} + +export function ArtifactCard({ artifact }: ArtifactCardProps) { + return ( + + + +
+
+

+ {artifact.title} +

+
+ {artifact.openPath ? ( + + + + ) : null} + {artifact.downloadPath ? ( + + + + ) : null} +
+
+ +
+ {artifact.issue.identifier} + · + + {artifact.issue.title} + +
+ +
+ Edited {relativeTime(artifact.updatedAt)} + {artifact.createdByAgent ? ( + <> + · + {artifact.createdByAgent.name} + + ) : null} +
+
+ + ); +} diff --git a/ui/src/components/issue-output/IssueOutputSection.tsx b/ui/src/components/issue-output/IssueOutputSection.tsx index 5b7faa0f870..10c25f95683 100644 --- a/ui/src/components/issue-output/IssueOutputSection.tsx +++ b/ui/src/components/issue-output/IssueOutputSection.tsx @@ -34,13 +34,19 @@ export function IssueOutputSection({ workProducts, resolveCreatorName }: IssueOu {count}
- + {/* Stable anchor target so company Artifacts cards can deep-link to a + specific work product inside its issue context (PAP-10359). */} +
+ +
{rest.length > 0 && (

Also produced

{rest.map((item) => ( - +
+ +
))}
)} diff --git a/ui/src/lib/company-routes.test.ts b/ui/src/lib/company-routes.test.ts index 4d22ec5919b..6278224db81 100644 --- a/ui/src/lib/company-routes.test.ts +++ b/ui/src/lib/company-routes.test.ts @@ -64,4 +64,22 @@ describe("company routes", () => { "/teams-catalog/core-exec-team", ); }); + + it("treats /artifacts as a board route that needs a company prefix", () => { + expect(isBoardPathWithoutPrefix("/artifacts")).toBe(true); + expect(extractCompanyPrefixFromPath("/artifacts")).toBeNull(); + expect(applyCompanyPrefix("/artifacts", "PAP")).toBe("/PAP/artifacts"); + expect(toCompanyRelativePath("/PAP/artifacts")).toBe("/artifacts"); + }); + + it("preserves artifact deep-link anchors when applying the company prefix", () => { + expect(applyCompanyPrefix("/issues/PAP-10205#work-product-wp-1", "PAP")).toBe( + "/PAP/issues/PAP-10205#work-product-wp-1", + ); + expect(applyCompanyPrefix("/issues/PAP-10306#attachment-att-1", "PAP")).toBe( + "/PAP/issues/PAP-10306#attachment-att-1", + ); + // Already-prefixed paths are returned untouched. + expect(applyCompanyPrefix("/PAP/artifacts", "PAP")).toBe("/PAP/artifacts"); + }); }); diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index 67197a7655e..27119d6a579 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -12,6 +12,7 @@ const BOARD_ROUTE_ROOTS = new Set([ "issues", "routines", "goals", + "artifacts", "approvals", "costs", "usage", diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 67214b276ce..91206b1f120 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -116,6 +116,10 @@ export const queryKeys = { list: (companyId: string) => ["goals", companyId] as const, detail: (id: string) => ["goals", "detail", id] as const, }, + artifacts: { + list: (companyId: string, kind?: string, q?: string) => + ["artifacts", companyId, kind ?? "all", q ?? ""] as const, + }, budgets: { overview: (companyId: string) => ["budgets", "overview", companyId] as const, }, diff --git a/ui/src/pages/Artifacts.tsx b/ui/src/pages/Artifacts.tsx new file mode 100644 index 00000000000..76e62141520 --- /dev/null +++ b/ui/src/pages/Artifacts.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Package } from "lucide-react"; +import { artifactsApi, 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 { cn } from "@/lib/utils"; + +const 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 function Artifacts() { + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const [kind, setKind] = useState("all"); + + useEffect(() => { + setBreadcrumbs([{ label: "Artifacts" }]); + }, [setBreadcrumbs]); + + const { data, isLoading, error } = useQuery({ + queryKey: queryKeys.artifacts.list(selectedCompanyId!, kind), + queryFn: () => artifactsApi.list(selectedCompanyId!, { kind }), + enabled: !!selectedCompanyId, + }); + + if (!selectedCompanyId) { + return ; + } + + const artifacts = data?.artifacts ?? []; + + return ( +
+
+

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

+
+ +
+ {KIND_FILTERS.map((filter) => ( + + ))} +
+ + {error &&

{error.message}

} + + {isLoading ? ( + + ) : artifacts.length === 0 ? ( + + ) : ( +
+ {artifacts.map((artifact) => ( + + ))} +
+ )} +
+ ); +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index e729497360b..f21e189e477 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -2872,6 +2872,37 @@ export function IssueDetail() { setHandoffFocusSignal((current) => current + 1); }, [location.hash]); + // Scroll + briefly highlight work-product / direct-attachment anchors so the + // company Artifacts page (PAP-10359) can deep-link to a specific artifact in + // its issue context. Retries while the section data loads in. + useEffect(() => { + const match = location.hash.match(/^#(work-product|attachment)-(.+)$/); + if (!match) return; + const targetId = `${match[1]}-${decodeURIComponent(match[2]!)}`; + let cancelled = false; + let attempts = 0; + let timer: ReturnType | undefined; + const tryScroll = () => { + if (cancelled) return; + const element = document.getElementById(targetId); + if (!element) { + if (attempts < 30) { + attempts += 1; + timer = setTimeout(tryScroll, 100); + } + return; + } + element.scrollIntoView({ behavior: "smooth", block: "center" }); + element.classList.add("ring-2", "ring-primary/50", "transition-shadow"); + timer = setTimeout(() => element.classList.remove("ring-2", "ring-primary/50"), 3000); + }; + tryScroll(); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [location.hash, workProducts, attachments]); + useEffect(() => { if (pendingCommentComposerFocusKey === 0) return; if (detailTab !== "chat") return; diff --git a/ui/storybook/stories/artifacts.stories.tsx b/ui/storybook/stories/artifacts.stories.tsx new file mode 100644 index 00000000000..1336b36fbe9 --- /dev/null +++ b/ui/storybook/stories/artifacts.stories.tsx @@ -0,0 +1,143 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Package } from "lucide-react"; +import { ArtifactCard } from "@/components/artifacts/ArtifactCard"; +import { EmptyState } from "@/components/EmptyState"; +import type { CompanyArtifact } from "@/api/artifacts"; + +/** + * Storybook coverage for the company Artifacts page (PAP-10359). Renders the + * responsive three-column grid and every preview card variant with mock data so + * UX/QA can review the layout and capture desktop/mobile screenshots without a + * live backend. + */ + +const SAMPLE_IMAGE = + "data:image/svg+xml;utf8," + + encodeURIComponent( + `Hero render.png`, + ); + +function makeArtifact(overrides: Partial): CompanyArtifact { + return { + id: "art", + source: "attachment", + mediaKind: "image", + title: "Artifact", + previewText: null, + contentType: null, + contentPath: null, + openPath: null, + downloadPath: null, + issue: { id: "issue-1", identifier: "PAP-10306", title: "Landing visuals refresh" }, + project: { id: "proj-1", name: "Paperclip App" }, + createdByAgent: { id: "agent-1", name: "ClaudeCoder" }, + updatedAt: new Date("2026-06-04T12:00:00Z").toISOString(), + href: "/issues/PAP-10306#attachment-art", + ...overrides, + }; +} + +const ARTIFACTS: CompanyArtifact[] = [ + makeArtifact({ + id: "wp-video", + source: "work_product", + mediaKind: "video", + title: "Product demo — primary cut.mp4", + contentType: "video/mp4", + contentPath: null, // exercises the calm video placeholder + play glyph + openPath: "/files/demo.mp4", + downloadPath: "/files/demo.mp4?download=1", + issue: { id: "issue-2", identifier: "PAP-10205", title: "Record the launch walkthrough" }, + href: "/issues/PAP-10205#work-product-wp-video", + }), + makeArtifact({ + id: "img-hero", + mediaKind: "image", + title: "Hero render.png", + contentType: "image/png", + contentPath: SAMPLE_IMAGE, + openPath: SAMPLE_IMAGE, + downloadPath: SAMPLE_IMAGE, + }), + makeArtifact({ + id: "doc-plan", + source: "document", + mediaKind: "document", + title: "Artifacts Page Plan", + previewText: + "Build a company-level Artifacts page at /{companyPrefix}/artifacts, with a sidebar item below Goals and a three-column artifact grid. The page should make agent-produced work easy to find without becoming another attachment dump.", + contentType: "text/markdown", + issue: { id: "issue-3", identifier: "PAP-10341", title: "Draft the rollout plan" }, + createdByAgent: { id: "agent-2", name: "CodexCoder" }, + href: "/issues/PAP-10341#document-plan", + }), + makeArtifact({ + id: "txt-notes", + mediaKind: "text", + title: "review-notes.txt", + previewText: + "Reviewed the primary cut. Color grade looks good; trim the first 1.2s of dead air. Re-export at 1080p and attach the final to the issue.", + contentType: "text/plain", + openPath: "/files/review-notes.txt", + downloadPath: "/files/review-notes.txt?download=1", + issue: { id: "issue-2", identifier: "PAP-10205", title: "Record the launch walkthrough" }, + }), + makeArtifact({ + id: "file-zip", + mediaKind: "file", + title: "design-assets.zip", + contentType: "application/zip", + openPath: "/files/design-assets.zip", + downloadPath: "/files/design-assets.zip?download=1", + issue: { id: "issue-1", identifier: "PAP-10306", title: "Landing visuals refresh" }, + }), + makeArtifact({ + id: "img-broken", + mediaKind: "image", + title: "missing-preview.png (broken source)", + contentType: "image/png", + contentPath: "/files/does-not-exist.png", // exercises the onError image fallback + openPath: "/files/does-not-exist.png", + downloadPath: "/files/does-not-exist.png?download=1", + }), +]; + +function ArtifactsGrid({ artifacts }: { artifacts: CompanyArtifact[] }) { + return ( +
+ {artifacts.map((artifact) => ( + + ))} +
+ ); +} + +const meta: Meta = { + title: "Pages/Artifacts", +}; + +export default meta; + +type Story = StoryObj; + +export const Grid: Story = { + render: () => ( +
+

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

+ +
+ ), +}; + +export const Empty: Story = { + render: () => ( +
+ +
+ ), +};