diff --git a/doc/plans/2026-10-22-artifacts-mvp-v1.md b/doc/plans/2026-10-22-artifacts-mvp-v1.md new file mode 100644 index 00000000000..fcae7a0e209 --- /dev/null +++ b/doc/plans/2026-10-22-artifacts-mvp-v1.md @@ -0,0 +1,141 @@ +# Artifacts MVP V1 — shipping the lab's direction into the product + +Date: 2026-10-22. Status: all four slices on `claude/artifacts-mvp-v1` (draft PR #17228); a browser pass on live data is the remaining check before the PR leaves draft. + +## Role of this document + +The artifact directions lab (`ui/src/artifact-directions`, merged in #17213) settled a +presentation for artifacts on three surfaces: the Artifacts page, the right panel's +Artifacts tab and the task thread. The lab draws it from fixtures on static mocks of the +chrome. This plan turns that reference into product code, one surface at a time, behind an +experimental setting, without changing the artifact data model where the lab's numbers do +not need it. + +The lab stays the visual reference: every measurement below is the one the lab verified +against the PCLP-Core mocks (node 2129:133457, 2096:87691, 2096:86523, 2205:138266). + +## Locked decisions + +1. **Gated by `/instance/settings > Experimental > Artifacts V1`** (`enableArtifactsV1`, + default off), the same plumbing as `enableCombinedInboxTasks`: shared type, validator, + feature catalog, server defaults, managed-key list, UI hook, settings toggle. The old + presentation stays the fallback until the setting is removed in a later cleanup. +2. **Token-only.** Every colour, radius, shadow, size and font value the lab kept in its + scoped `tokens.css` becomes an `--artifact-*` token in `ui/src/index.css`, with the light + theme's values as overrides, so the token gates pass and the theme editor can reach them. + Artwork geometry (sheet 180×216, glyph at (58, 58), bars on a 26px pitch, stack centres) + lives in one constants module, not in class names. +3. **Reuse the lab's code, not its types.** The primitives move to + `ui/src/components/artifacts/v1/` and take `CompanyArtifact`, `IssueWorkProduct`, + issue documents and attachments through small adapters, the way the lab's Today kit + adapted the other direction. The lab keeps its own copies; it is a reference, not a + dependency of product code. +4. **No new API fields for the first three slices.** The Artifacts page tiles show title, + identifier, count and "Updated …"; the panel and thread rows get sizes from the issue + work-product and attachment payloads they already have. A picture's aspect ratio comes + from the loaded image (`naturalWidth / naturalHeight`), 4:3 until it loads. +5. **Reduced motion is respected**: the hover movement (page turn, 6px rise) applies only + under `prefers-reduced-motion: no-preference`; colour fades stay. + +## Slices + +### Slice 1 — foundation (done) + +- Experimental setting `enableArtifactsV1` end to end, with `useArtifactsV1Enabled()`. +- `--artifact-*` tokens in `index.css` (paper, ink, faint ink, sheet and print radii, sheet + and print shadows with light overrides, grid column minimum, frame and tile heights, tile + hover fill and radius, row fill, hover, radius and gap, play scrim, hover easing). +- Primitives under `ui/src/components/artifacts/v1/`: kind helpers (`treatmentOf`, + `labelOf`, `glyphOf`, `mediaKindOf`), `ArtifactSheet` (preview and icon faces), + `ArtifactPrint` (bordered picture, fitted to a box, play disc for video), + `ArtifactStack` (the two backings), `ArtifactFront`, `ArtifactTile` (stack and single + tiles with the caption and the Paper Mono pill), `ArtifactRow` (the 40px-icon row), + date helpers, and the hover rules. Unit tests ported from the lab. +- Nothing mounted yet. Storybook stories under `documents-artifacts/` show the primitives on + fixtures so reviewers can see them without the flag. + +### Slice 2 — Artifacts page (done) + +- With the flag on, `Artifacts.tsx` renders the V1 tiles: stacks when grouped by task + (the default), single tiles otherwise, 28px gaps, 370px cells, hover fill with the + 12px radius. +- Filters become Tasks (default; grouped by task) · All files · Images · Videos · + Documents · Text · Files. The group-by menu and search stay. +- Opened stack: the `← All stacks / ACM-3 Title` row above the single tiles. +- Page tests updated for both flag states. + +### Slice 3 — right panel Artifacts tab (done) + +What the shipped tab does that the V1 rows must keep (read 2026-10-22, +`IssuePropertiesArtifactsTab.tsx`): + +- Three sources merged: work products (`useIssueWorkProducts`), task documents + (`useIssueDocuments`, minus `artifact-review-*` proxies) and attachments not + already promoted to work products (`selectArtifactAttachments`). +- Rows are grouped by run with an agent-name header and a time; V1 flattens to + one list, newest first (the lab's decision), and drops the headers. +- A Markdown work product expands in place into the document review surface + (`MarkdownWorkProductRow`, backed by the `artifact-review-` document) with + raw open and download as secondary actions; a task document expands in place + (`DocumentRow`) or opens in its own pane tab when `onOpenDocument` is given; + a text attachment opens in a text tab (`openTextAttachment`), other files in a + new tab; `documentDeepLink` expands or opens the requested document; the + upload area (`ArtifactUploadContent`) wraps the list. +- Work products carry a status badge (`workProductStatusBadge`: in progress, + for review, done, changes requested, failed). + +Approach: the V1 row is the *face* of each item. Give `DocumentRow` and +`MarkdownWorkProductRow` a `face` render prop so, with the flag on, their +header is the V1 row (name over size · date, status badge and the open / +download controls in the row's trailing slot) while their expansion, deep-link +and review behaviour stay untouched; attachments and other work products render +the V1 row directly with the same destinations the shipped cards use. + +- `IssuePropertiesArtifactsTab` renders the V1 row list when the flag is on: newest first, + 12px apart, 40px document icon or thumbnail with a play disc, name truncating before its + extension over "size · date" with the panel's date form (Today / Yesterday / 2D ago / + July 23). Existing actions (open, download, review) move into the row's menu. + +### Slice 4 — task thread (done) + +- Work products and documents between turns render as one full-width row 12px under the + "Worked" row (`TaskChatProtocolCard.tsx`, `ResourceCard`): a registered file is named after + the file with the deliverable's state beside it; a document after its key; the destination + is the card's. A message's files render as the same rows inline under the footer, 12px + below it, 8px apart at equal widths, wrapping past three (280px minimum), in place of the + media grid and file chips (`TaskChatBubble.tsx`): pictures and video open the task's + gallery, text opens a text tab when the panel offers one, anything else a new tab. +- Not carried over: the "Images · N / Files · N" labels and the four-up image grid; the + rows name every file instead. + +### Later + +- Remove the flag and the old cards once the three surfaces have been in use. +- The lab's parked surfaces (media preview overlay, panel closeup) follow the same path if + adopted. + +## Verification per slice + +Done (slices 1–4): `cd ui && pnpm typecheck` (catalog check and `tsc -b`, clean apart from +the five pre-existing `PluginOrganizationSwitcher` errors), `pnpm check:token-gates`, +`node scripts/check-maps.mjs`, and the Vitest suites for `components/artifacts/v1`, the +Artifacts page, the panel's Artifacts tab, the task-chat bubble and protocol card (each in +both flag states), the hook, the experimental settings page, the shared feature catalog and +the server instance settings. Not yet done: a browser pass with the flag on against a dev +instance with real artifacts on all three surfaces; the artwork was measured in the lab and +the wiring is covered by tests. + +- `cd ui && pnpm typecheck` (catalog check and `tsc -b`), `pnpm check:token-gates`, + `npx vitest run src/components/artifacts/v1` plus the touched pages' tests. +- A Playwright pass on the dev instance with the flag on, measuring what the lab measured: + 58px rows, 8px/12px gaps, 370px cells, 5° turn and 6px rise, Paper Mono pills. +- Storybook stories for the primitives, catalogued under `documents-artifacts`. + +## Risks + +- The Artifacts page tests and the task-chat tests assert the current markup; the flag keeps + the default path unchanged, and the V1 path gets its own tests. +- Pictures without known dimensions render at 4:3 until they load; the fitted print then + resizes once. The stack frame's height is fixed, so nothing below it moves. +- Token count grows by about thirty `--artifact-*` entries; they are namespaced and + documented in the token block. diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index d66bbd9cacc..48e282e7691 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -199,6 +199,14 @@ export const INSTANCE_FEATURE_CATALOG: Record { enableMuse: false, enableGrokBot: false, enableCombinedInboxTasks: false, + enableArtifactsV1: false, enableChatConnectors: false, enableGitHubReviewBots: false, enableMemoryConnectors: false, diff --git a/server/src/services/cloud-instance.ts b/server/src/services/cloud-instance.ts index 5cde4dd8ddc..afddae33956 100644 --- a/server/src/services/cloud-instance.ts +++ b/server/src/services/cloud-instance.ts @@ -7,6 +7,7 @@ const CLOUD_STAGING_EXPERIMENTAL_KEYS: ReadonlySet = new Set([ "enableBetaSkills", "enableBuiltInAgents", "enableCases", + "enableArtifactsV1", "enableChatConnectors", "enableClassicTaskInterface", "enableCombinedInboxTasks", diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 23c66dd35ac..1d2395ffebe 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -247,6 +247,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableCases: parsed.data.enableCases ?? false, enableAgentChat: parsed.data.enableAgentChat ?? false, enableCombinedInboxTasks: parsed.data.enableCombinedInboxTasks ?? false, + enableArtifactsV1: parsed.data.enableArtifactsV1 ?? false, enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false, enableClassicTaskInterface: parsed.data.enableClassicTaskInterface ?? false, enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false, @@ -298,6 +299,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableCases: false, enableAgentChat: false, enableCombinedInboxTasks: false, + enableArtifactsV1: false, enableConferenceRoomChat: false, enableClassicTaskInterface: false, enableIssuePlanDecompositions: false, diff --git a/ui/src/components/artifacts/ArtifactsToolbar.tsx b/ui/src/components/artifacts/ArtifactsToolbar.tsx index 0a3c65dff5f..3ca187eded7 100644 --- a/ui/src/components/artifacts/ArtifactsToolbar.tsx +++ b/ui/src/components/artifacts/ArtifactsToolbar.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { Check, Layers } from "lucide-react"; import type { ArtifactGroupBy, ArtifactKindFilter } from "@/api/artifacts"; import { TabFilters, type TabSize } from "@/components/TabBar"; @@ -37,10 +38,12 @@ export interface ArtifactsToolbarProps { onGroupByChange: (value: ArtifactGroupBy) => void; kind: ArtifactKindFilter; onKindChange: (value: ArtifactKindFilter) => void; + /** Replaces the kind filter row (Artifacts V1 shows its own view tabs). */ + filters?: ReactNode; } /** Shared shipped Artifacts controls. Canonical proposal: Components / Toolbars / Artifacts. */ -export function ArtifactsToolbar({ query: draftQuery, onQueryChange: setDraftQuery, groupBy, onGroupByChange: selectGroupBy, kind, onKindChange: selectKind }: ArtifactsToolbarProps) { +export function ArtifactsToolbar({ query: draftQuery, onQueryChange: setDraftQuery, groupBy, onGroupByChange: selectGroupBy, kind, onKindChange: selectKind, filters }: ArtifactsToolbarProps) { const grouping = groupBy !== "none"; return (
@@ -80,7 +83,7 @@ export function ArtifactsToolbar({ query: draftQuery, onQueryChange: setDraftQue
- + {filters ?? } ); } diff --git a/ui/src/components/artifacts/v1/ArtifactFileRow.test.tsx b/ui/src/components/artifacts/v1/ArtifactFileRow.test.tsx new file mode 100644 index 00000000000..baf017a27c0 --- /dev/null +++ b/ui/src/components/artifacts/v1/ArtifactFileRow.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { IssueGalleryContext } from "@/context/IssueGalleryContext"; +import { TextAttachmentContext } from "@/context/TextAttachmentContext"; +import { ArtifactFileRow } from "./ArtifactFileRow"; +import { testSubject } from "./test-subject"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const csvLoader = vi.hoisted(() => vi.fn(async () => ({ columns: ["units", "unit_price"], rows: [[24, 24], [48, 21]], truncated: false }))); +vi.mock("@/lib/artifact-card-data", async () => { + const actual = await vi.importActual("@/lib/artifact-card-data"); + return { ...actual, loadArtifactCsv: csvLoader }; +}); +vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompany: null, selectedCompanyId: null, companies: [] }) })); + +const ATTACHMENT_ID = "00000000-0000-4000-8000-000000000001"; +const source = (overrides: Partial[0]["source"]> = {}) => ({ + id: "f1", + attachmentId: ATTACHMENT_ID, + filename: "pricing.csv", + contentType: "text/csv", + contentPath: `/api/attachments/${ATTACHMENT_ID}/content`, + openPath: `/api/attachments/${ATTACHMENT_ID}/content?inline=1`, + downloadPath: `/api/attachments/${ATTACHMENT_ID}/content?download=1`, + byteSize: 2380, + ...overrides, +}); + +async function flush() { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("ArtifactFileRow", () => { + let container: HTMLDivElement; + let root: Root; + const mount = (ui: React.ReactNode) => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + act(() => root.render({ui})); + }; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + csvLoader.mockClear(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("keeps the CSV data preview: Preview data loads the table under the row", async () => { + mount(); + const preview = Array.from(container.querySelectorAll("button")).find((el) => el.textContent === "Preview data"); + expect(preview).toBeDefined(); + act(() => preview!.click()); + for (let i = 0; i < 20 && !container.textContent?.includes("unit_price"); i += 1) await act(flush); + expect(csvLoader).toHaveBeenCalledWith(`/api/attachments/${ATTACHMENT_ID}/content`, expect.anything()); + expect(container.textContent).toContain("unit_price"); + expect(container.querySelector(`a[aria-label="Download pricing.csv"]`)?.getAttribute("href")).toContain("download=1"); + }); + + it("opens text in the text tab on a plain click while the row stays a link", () => { + const openText = vi.fn(); + mount( + + + , + ); + const link = container.querySelector('a[data-testid="artifact-v1-row-open"]')!; + expect(link.getAttribute("href")).toContain("inline=1"); + act(() => { link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); }); + expect(openText).toHaveBeenCalledWith(ATTACHMENT_ID, "notes.md"); + }); + + it("opens a picture in the task's gallery and keeps open raw and download beside it", () => { + const openGallery = vi.fn(() => true); + mount( + + + , + ); + act(() => container.querySelector('button[data-testid="artifact-v1-row-open"]')!.click()); + expect(openGallery).toHaveBeenCalledWith("/api/attachments/x/content"); + expect(container.querySelector('a[aria-label="Open raw hero.png"]')).not.toBeNull(); + expect(container.querySelector('a[aria-label="Download hero.png"]')).not.toBeNull(); + }); +}); diff --git a/ui/src/components/artifacts/v1/ArtifactFileRow.tsx b/ui/src/components/artifacts/v1/ArtifactFileRow.tsx new file mode 100644 index 00000000000..abd68621414 --- /dev/null +++ b/ui/src/components/artifacts/v1/ArtifactFileRow.tsx @@ -0,0 +1,111 @@ +import { Download, ExternalLink } from "lucide-react"; +import { useContext, useState, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { IssueGalleryContext } from "@/context/IssueGalleryContext"; +import { TextAttachmentContext } from "@/context/TextAttachmentContext"; +import { ImageGalleryModal } from "@/components/ImageGalleryModal"; +import { DataCard } from "@/components/artifacts/RichArtifactCards"; +import { Button } from "@/components/ui/button"; +import { CSV_PREVIEW_MAX_BYTES, loadArtifactCsv } from "@/lib/artifact-card-data"; +import { isTextAttachment } from "@/lib/issue-attachments"; +import { isImageLikeOutput, isVideoLikeOutput } from "@/lib/issue-output"; +import type { FileRowSource } from "./adapters"; +import { ArtifactRow } from "./ArtifactRow"; +import type { ArtifactSubject } from "./kinds"; + +/** A small control beside a row, as the shipped rows draw theirs. */ +export const ROW_ACTION_CLASS = "shrink-0 rounded-md px-1.5 py-1.5 text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"; + +/** + * A file (an attachment or an attachment-backed work product) as a V1 row + * that opens the way the shipped card does: pictures and video in the task's + * gallery (or a gallery of their own), text in a text tab when the panel + * offers one — through an anchor that keeps its address for modifier clicks + * and copying — and anything else in a new tab. Open raw and download stay + * beside the name, and an eligible CSV keeps its "Preview data" table. + */ +export function ArtifactFileRow({ + subject, + source, + statusBadge, + updatedAtLabel = "", + now, + testId, +}: { + subject: ArtifactSubject; + source: FileRowSource; + statusBadge?: ReactNode; + /** The data preview's header line, as the shipped card formats it. */ + updatedAtLabel?: string; + now?: Date; + testId?: string; +}) { + const { id, attachmentId, filename, contentType, contentPath, openPath, downloadPath, byteSize } = source; + const openGallery = useContext(IssueGalleryContext); + const openTextAttachment = useContext(TextAttachmentContext); + const [galleryOpen, setGalleryOpen] = useState(false); + const [csvRequested, setCsvRequested] = useState(false); + const picture = Boolean(contentPath) && (isImageLikeOutput(contentType, filename) || isVideoLikeOutput(contentType, filename)); + const textTab = Boolean(openTextAttachment && attachmentId && isTextAttachment({ contentType, originalFilename: filename })); + const csv = contentType.split(";")[0] === "text/csv" || /\.csv$/i.test(filename); + const tooLarge = byteSize !== null && byteSize > CSV_PREVIEW_MAX_BYTES; + const localCsv = csv && /^\/api\/attachments\/[a-zA-Z0-9-]+\/content$/.test(contentPath); + const data = useQuery({ + queryKey: ["artifact-csv", id, contentPath, updatedAtLabel], + queryFn: ({ signal }) => loadArtifactCsv(contentPath, signal), + enabled: localCsv && !tooLarge && csvRequested, + retry: false, + staleTime: Infinity, + }); + + const trailing = ( + <> + {statusBadge} + {localCsv && !tooLarge && !data.data && !data.isError ? ( + + ) : null} + {openPath ? ( + + + + ) : null} + {downloadPath ? ( + + + + ) : null} + + ); + + const row = picture ? ( + { if (!openGallery?.(contentPath)) setGalleryOpen(true); }} trailing={trailing} now={now} testId={testId} /> + ) : textTab ? ( + openTextAttachment?.(attachmentId!, filename)} trailing={trailing} now={now} testId={testId} /> + ) : ( + + ); + + return ( +
+ {row} + {picture && galleryOpen ? ( + + ) : null} + {localCsv && data.data ? ( + + ) : null} + {csv && (tooLarge || data.isError) ? ( +
+ {tooLarge ? "CSV is too large to preview. Download the file to view it." : data.error?.message} + {data.isError ? ( + + ) : null} +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/artifacts/v1/ArtifactFront.tsx b/ui/src/components/artifacts/v1/ArtifactFront.tsx new file mode 100644 index 00000000000..a86c6e35921 --- /dev/null +++ b/ui/src/components/artifacts/v1/ArtifactFront.tsx @@ -0,0 +1,45 @@ +import type { CSSProperties, SyntheticEvent } from "react"; +import { ArtifactPrint, type PrintSize } from "./ArtifactPrint"; +import { IconSheet, PreviewSheet } from "./ArtifactSheet"; +import { treatmentOf, type ArtifactSubject } from "./kinds"; + +/** + * The artifact's own representation: one of the mock's four treatments. A + * print is sized by the caller (a stack fits it to its own box); on its own + * it fits the single-artifact box. The root carries data-artifact-front so + * a hovered tile can lift it (index.css). + */ +export function ArtifactFront({ + subject, + print, + onMediaLoad, + style, +}: { + subject: ArtifactSubject; + print?: PrintSize; + onMediaLoad?: (event: SyntheticEvent) => void; + style?: CSSProperties; +}) { + switch (treatmentOf(subject)) { + case "preview": + return ( +
+ +
+ ); + case "media": + return ; + case "media-fallback": + return ( +
+ +
+ ); + default: + return ( +
+ +
+ ); + } +} diff --git a/ui/src/components/artifacts/v1/ArtifactPrint.test.ts b/ui/src/components/artifacts/v1/ArtifactPrint.test.ts new file mode 100644 index 00000000000..0f23cd375f7 --- /dev/null +++ b/ui/src/components/artifacts/v1/ArtifactPrint.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { fitPrint } from "./ArtifactPrint"; +import { SINGLE_BOX, STACK_BOX } from "./geometry"; + +describe("fitPrint", () => { + it("fits a landscape print to the stack box by width and a portrait one by height", () => { + expect(fitPrint(4 / 3, STACK_BOX)).toEqual({ width: 245, height: 183.75, border: 3.3 }); + expect(fitPrint(3 / 4, STACK_BOX)).toEqual({ width: 165, height: 220, border: 3.3 }); + }); + + it("never lets a print grow past the box", () => { + for (const aspect of [0.5, 1, 2, 16 / 9]) { + const print = fitPrint(aspect, STACK_BOX); + expect(print.width).toBeLessThanOrEqual(STACK_BOX.maxWidth); + expect(print.height).toBeLessThanOrEqual(STACK_BOX.maxHeight); + expect(print.width / print.height).toBeCloseTo(aspect, 2); + } + }); + + it("assumes 4:3 until the picture's ratio is known", () => { + expect(fitPrint(null, SINGLE_BOX)).toEqual({ width: 240, height: 180, border: 4 }); + expect(fitPrint(0, SINGLE_BOX)).toEqual(fitPrint(undefined, SINGLE_BOX)); + }); +}); diff --git a/ui/src/components/artifacts/v1/ArtifactPrint.tsx b/ui/src/components/artifacts/v1/ArtifactPrint.tsx new file mode 100644 index 00000000000..6fed91aae3c --- /dev/null +++ b/ui/src/components/artifacts/v1/ArtifactPrint.tsx @@ -0,0 +1,94 @@ +import { Play } from "lucide-react"; +import { useCallback, useState, type CSSProperties, type SyntheticEvent } from "react"; +import { DEFAULT_ASPECT, PRINT_PLAY, SINGLE_BOX, type FitBox } from "./geometry"; +import type { ArtifactSubject } from "./kinds"; + +/** + * A photo print: the picture inside a white border with a 6px radius and a + * soft shadow (`.artifact-print`, index.css). A print is sized to fit a box + * while keeping the picture's ratio, so a leading picture never grows past + * the stack it stands in. The ratio comes with the subject when known and + * from the loaded picture otherwise; until then the print is 4:3. + */ +export interface PrintSize { + width: number; + height: number; + border: number; +} + +/** The largest print with the picture's ratio that fits the box, border included. */ +export function fitPrint(aspect: number | null | undefined, box: FitBox): PrintSize { + const ratio = aspect && aspect > 0 ? aspect : DEFAULT_ASPECT; + const scale = Math.min(box.maxWidth / ratio, box.maxHeight); + return { width: Math.round(scale * ratio * 100) / 100, height: Math.round(scale * 100) / 100, border: box.border }; +} + +/** The picture's ratio: the known one, else the loaded picture's. */ +export function useImageAspect(known: number | null | undefined): [number | null, (event: SyntheticEvent) => void] { + const [loaded, setLoaded] = useState(null); + const onLoad = useCallback((event: SyntheticEvent) => { + const el = event.currentTarget; + const width = el instanceof HTMLVideoElement ? el.videoWidth : el.naturalWidth; + const height = el instanceof HTMLVideoElement ? el.videoHeight : el.naturalHeight; + if (width > 0 && height > 0) setLoaded(width / height); + }, []); + return [known && known > 0 ? known : loaded, onLoad]; +} + +export function ArtifactPrint({ + subject, + size, + onMediaLoad, + style, +}: { + subject: ArtifactSubject; + /** Sized by the caller (a stack fits it to its own box); on its own it fits the single box. */ + size?: PrintSize; + onMediaLoad?: (event: SyntheticEvent) => void; + style?: CSSProperties; +}) { + const [ownAspect, onOwnLoad] = useImageAspect(subject.aspect); + const print = size ?? fitPrint(ownAspect, SINGLE_BOX); + return ( +
+ {subject.kind === "video" && !subject.thumbnailUrl ? ( + // No poster: the print shows the video's own first frame, as the shipped video card does. +
+ ); +} diff --git a/ui/src/components/artifacts/v1/ArtifactRow.test.tsx b/ui/src/components/artifacts/v1/ArtifactRow.test.tsx new file mode 100644 index 00000000000..9ad01901305 --- /dev/null +++ b/ui/src/components/artifacts/v1/ArtifactRow.test.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ArtifactRow, ArtifactRowGroup, inlineRowStyle, splitName } from "./ArtifactRow"; +import { testSubject } from "./test-subject"; + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompany: null, selectedCompanyId: null, companies: [] }), +})); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const NOW = new Date("2026-09-28T18:00:00"); + +describe("splitName", () => { + it("keeps the extension apart so the name can truncate", () => { + expect(splitName("acmehats-interface-craft.pdf")).toEqual(["acmehats-interface-craft", ".pdf"]); + expect(splitName("Storefront preview")).toEqual(["Storefront preview", ""]); + }); +}); + +describe("ArtifactRow", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("names the file over its size and date, with the document icon's type label", () => { + act(() => root.render()); + const row = container.querySelector('[data-testid="artifact-v1-row"]'); + expect(row?.querySelector('button[data-testid="artifact-v1-row-open"]')?.getAttribute("aria-label")).toBe("Open deck.pdf"); + expect(row?.textContent).toContain("57.7 KB"); + expect(row?.textContent).toContain("Yesterday"); + expect(row?.textContent).toContain("PDF"); + }); + + it("is a link when it has somewhere to go, and shows a thumbnail for a picture", () => { + act(() => + root.render( + + + , + ), + ); + const link = container.querySelector('a[data-testid="artifact-v1-row-open"]'); + expect(link?.getAttribute("href")).toBe("/artifacts/a1"); + expect(link?.querySelector("img")?.getAttribute("src")).toBe("/sheet.png"); + }); + + it("keeps an anchor's address while a plain click runs the handler and a modifier click follows the link", () => { + const onClick = vi.fn(); + act(() => root.render()); + const link = container.querySelector('a[data-testid="artifact-v1-row-open"]')!; + expect(link.getAttribute("href")).toBe("/api/attachments/n/content"); + const plain = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }); + act(() => { link.dispatchEvent(plain); }); + expect(onClick).toHaveBeenCalledTimes(1); + expect(plain.defaultPrevented).toBe(true); + const withModifier = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, metaKey: true }); + act(() => { link.dispatchEvent(withModifier); }); + expect(onClick).toHaveBeenCalledTimes(1); + expect(withModifier.defaultPrevented).toBe(false); + }); + + it("keeps trailing controls beside the row's own control, never inside it", () => { + act(() => root.render(dl} now={NOW} />)); + const row = container.querySelector('[data-testid="artifact-v1-row"]'); + expect(row?.querySelector('button[data-testid="artifact-v1-row-open"] a')).toBeNull(); + expect(row?.querySelector('a[aria-label="Download deck.pdf"]')).not.toBeNull(); + }); + + it("groups several rows inline at equal widths that wrap past 280px each", () => { + act(() => + root.render( + + {[1, 2, 3].map((i) => ( + + ))} + , + ), + ); + const rows = container.querySelectorAll('[data-testid="artifact-v1-row"]'); + expect(rows).toHaveLength(3); + for (const row of rows) expect(row.style.flex).toBe("1 0 280px"); + }); +}); diff --git a/ui/src/components/artifacts/v1/ArtifactRow.tsx b/ui/src/components/artifacts/v1/ArtifactRow.tsx new file mode 100644 index 00000000000..df462d830c9 --- /dev/null +++ b/ui/src/components/artifacts/v1/ArtifactRow.tsx @@ -0,0 +1,195 @@ +import type { CSSProperties, ReactNode } from "react"; +import type { To } from "react-router-dom"; +import { Link } from "@/lib/router"; +import { cn } from "@/lib/utils"; +import panelDoc from "./assets/panel-doc.svg"; +import panelDocCorner from "./assets/panel-doc-corner.svg"; +import panelGlyphAudio from "./assets/panel-glyph-audio.svg"; +import panelGlyphSlides from "./assets/panel-glyph-slides.svg"; +import panelPlay from "./assets/panel-play.svg"; +import { ROW_ICON, ROW_PLAY } from "./geometry"; +import { glyphOf, isAudio, labelOf, treatmentOf, type ArtifactSubject } from "./kinds"; +import { panelDateLabel } from "./when"; + +/** + * One row, from the panel mock (node 2096:87691) and reused in the thread: + * 8px padding, a 40px icon, 8px, then the file name (14/20 medium, + * truncating before its extension) over "size · date" (12/16 muted), 6px + * apart. The icon is the mock's grey document shape with its folded corner, + * a 14px white glyph at (5, 5) and the type in 11px bold at (5, 22); PDF and + * DOC use the mock's three bars, PPTX and audio the mock's own glyphs, other + * types their lucide icon. Pictures and video show a 40px thumbnail instead, + * video with a 32px play disc. + */ + +const ICON_LABEL: CSSProperties = { + left: ROW_ICON.label.left, + top: ROW_ICON.label.top, + fontSize: "var(--artifact-row-icon-label-size)", + lineHeight: "var(--artifact-row-icon-label-leading)", + color: "var(--artifact-row-ink)", +}; + +function Bars() { + return ( + + {ROW_ICON.bars.widths.map((width, i) => ( + + ))} + + ); +} + +function IconGlyph({ subject }: { subject: ArtifactSubject }) { + const { left, top, size } = ROW_ICON.glyph; + if (isAudio(subject)) return ; + if (subject.kind === "slides") return ; + const glyph = glyphOf(subject); + if (glyph === "bars" || subject.kind === "document") return ; + const Icon = glyph; + return ; +} + +/** The mock labels Markdown documents DOC; every other type is its extension. */ +const iconLabel = (subject: ArtifactSubject) => (subject.kind === "document" ? "DOC" : labelOf(subject)); + +export function ArtifactRowIcon({ subject }: { subject: ArtifactSubject }) { + return ( + + + + + + {iconLabel(subject)} + + + ); +} + +function Thumbnail({ subject }: { subject: ArtifactSubject }) { + return ( + + {subject.kind === "video" && !subject.thumbnailUrl ? ( + + ); +} + +/** Splits "name.ext" so the name can truncate while the extension stays. */ +export function splitName(name: string): [string, string] { + const match = name.match(/^(.*?)(\.[a-z0-9]+)$/i); + return match ? [match[1], match[2]] : [name, ""]; +} + +/** The row's fill, radius and padding; the inner control and the trailing controls sit inside it. */ +export const ARTIFACT_ROW_CLASS = "flex w-full items-center gap-2 rounded-(--artifact-row-radius) bg-(--artifact-row) py-2 pl-2 pr-3 transition-colors hover:bg-(--artifact-row-hover)"; +const CONTROL_CLASS = "flex min-w-0 flex-1 items-center gap-2 rounded-(--artifact-row-radius) text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"; + +export interface ArtifactRowProps { + subject: ArtifactSubject; + to?: To; + href?: string; + /** With `href`: open in a new tab (a file) rather than in place (an in-app location). */ + external?: boolean; + /** + * Without `href`: the control is a button. With `href`: an ordinary click + * runs this instead of following the link, while modifier clicks, the + * context menu and copying the address keep working. + */ + onClick?: () => void; + /** Whether the row is expanded in place (the control reports it). */ + expanded?: boolean; + /** Controls beside the name: a status, open, download, a menu. They sit outside the row's own control. */ + trailing?: ReactNode; + className?: string; + style?: CSSProperties; + now?: Date; + testId?: string; +} + +/** + * The row: the icon and the name over its facts are one control (a link, an + * anchor or a button, as the caller needs); trailing controls stand beside + * it, so nothing interactive nests. + */ +export function ArtifactRow({ subject, to, href, external = false, onClick, expanded, trailing, className, style, now, testId = "artifact-v1-row" }: ArtifactRowProps) { + const name = subject.filename ?? subject.title; + const [stem, ext] = splitName(name); + const facts = [subject.sizeLabel, subject.updatedAt ? panelDateLabel(subject.updatedAt, now) : null].filter((fact): fact is string => Boolean(fact)); + const content = ( + <> + {treatmentOf(subject) === "media" ? : } + + + {stem} + {ext} + + + {facts.map((fact, i) => ( + + {i > 0 ? · : null} + {fact} + + ))} + + + + ); + const label = `Open ${name}`; + const control = to ? ( + + {content} + + ) : href ? ( + { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return; + event.preventDefault(); + onClick(); + } + : undefined + } + > + {content} + + ) : ( + + ); + return ( +
+ {control} + {trailing ? {trailing} : null} +
+ ); +} + +/** Several rows from one message, inline at equal widths 8px apart; a row needs 280px, so they wrap past three in a 960px column. */ +export const ARTIFACT_ROW_MIN_WIDTH = 280; + +export function ArtifactRowGroup({ children, testId = "artifact-v1-row-group" }: { children: ReactNode; testId?: string }) { + return ( +
+ {children} +
+ ); +} + +export const inlineRowStyle: CSSProperties = { flex: `1 0 ${ARTIFACT_ROW_MIN_WIDTH}px`, minWidth: 1 }; diff --git a/ui/src/components/artifacts/v1/ArtifactSheet.tsx b/ui/src/components/artifacts/v1/ArtifactSheet.tsx new file mode 100644 index 00000000000..062a5762dbf --- /dev/null +++ b/ui/src/components/artifacts/v1/ArtifactSheet.tsx @@ -0,0 +1,103 @@ +import { Play } from "lucide-react"; +import type { CSSProperties } from "react"; +import sheet from "./assets/sheet-preview.svg"; +import { ICON_BARS, ICON_GLYPH, ICON_LABEL, ICON_PLAY, PREVIEW_BAR_ROWS, PREVIEW_BARS, PREVIEW_TITLE, SHEET } from "./geometry"; +import { glyphOf, labelOf, type ArtifactSubject } from "./kinds"; + +/** + * The 180×216 front sheets. PreviewSheet sets the title on the paper over + * four rows of placeholder text; IconSheet centres a faint glyph and the + * type in caps. Both stand on the same white sheet; the inner stroke and + * the shadow are the `.artifact-sheet` rules (index.css). + */ + +const TITLE: CSSProperties = { + ...PREVIEW_TITLE, + fontSize: "var(--artifact-sheet-title-size)", + lineHeight: "var(--artifact-sheet-title-leading)", + letterSpacing: "var(--artifact-sheet-title-tracking)", + color: "var(--artifact-paper-ink)", +}; + +function TextBars() { + return ( +
+ {PREVIEW_BAR_ROWS.map((row, r) => + row.map(([x, width], i) => ( + + )), + )} +
+ ); +} + +function Paper() { + return ; +} + +export function PreviewSheet({ subject, title = subject.title }: { subject: ArtifactSubject; title?: string }) { + return ( +
+ +

+ {title} +

+ +
+ ); +} + +const GLYPH_BOX: CSSProperties = { left: ICON_GLYPH.left, top: ICON_GLYPH.top, width: ICON_GLYPH.size, height: ICON_GLYPH.size }; + +function BarsGlyph() { + return ( +
+ {ICON_BARS.widths.map((width, i) => ( + + ))} +
+ ); +} + +function Glyph({ subject }: { subject: ArtifactSubject }) { + const glyph = glyphOf(subject); + if (glyph === "bars") return ; + const Icon = glyph; + return ; +} + +const LABEL: CSSProperties = { + left: 0, + top: ICON_LABEL.top, + width: SHEET.width, + height: ICON_LABEL.height, + fontSize: "var(--artifact-icon-label-size)", + lineHeight: "var(--artifact-icon-label-leading)", + textAlign: "center", + color: "var(--artifact-ink)", +}; + +export function IconSheet({ subject, play = false }: { subject: ArtifactSubject; play?: boolean }) { + return ( +
+ + + + {labelOf(subject)} + + {play ? ( + + + + ) : null} +
+ ); +} diff --git a/ui/src/components/artifacts/v1/ArtifactStack.tsx b/ui/src/components/artifacts/v1/ArtifactStack.tsx new file mode 100644 index 00000000000..be950613fb2 --- /dev/null +++ b/ui/src/components/artifacts/v1/ArtifactStack.tsx @@ -0,0 +1,91 @@ +import type { CSSProperties } from "react"; +import { ArtifactFront } from "./ArtifactFront"; +import { fitPrint, useImageAspect } from "./ArtifactPrint"; +import backingShort from "./assets/backing-short.svg"; +import backingTallA from "./assets/backing-tall-a.svg"; +import backingTallB from "./assets/backing-tall-b.svg"; +import { LANDSCAPE_SHEET, MEDIA_ANCHOR, MEDIA_BACKING, MEDIA_COMPOSITION, SHEET, SHEET_BACKING, SHEET_COMPOSITION, SHEET_FRONT, STACK_BOX, type BackingPlacement } from "./geometry"; +import { isMedia, isPortrait, treatmentOf, type ArtifactSubject } from "./kinds"; + +/** + * A stack: the task's newest artifact as the first page, in front of one of + * the mock's two backings. Text and documents share the sheet backing; + * pictures, video and audio share the media backing. Both compositions are + * 240–241px tall and stand on the frame's bottom edge, so the tops of every + * stack in a row line up; a leading picture is fitted inside that height. + * + * The backings are the mock's own sheet shapes (each file is 30px larger than + * its sheet on every side, room the mock's shadow needed; the shadow itself + * is the `.artifact-backing` rule). Each sheet is placed by the centre of its + * rotated box and turned about its own centre; the hover fan is in index.css. + */ +const FILES = { short: backingShort, tallA: backingTallA, tallB: backingTallB } as const; + +function Backing({ file, width, height, sheet, centre, rotate, spread }: BackingPlacement) { + const originX = sheet.left + sheet.width / 2; + const originY = sheet.top + sheet.height / 2; + const style = { + left: centre.x - originX, + top: centre.y - originY, + width, + height, + transformOrigin: `${originX}px ${originY}px`, + "--artifact-base": `${rotate}deg`, + } as CSSProperties; + return ( + + ); +} + +export function ArtifactStack({ front }: { front: ArtifactSubject }) { + const [aspect, onMediaLoad] = useImageAspect(front.aspect); + if (isMedia(front)) { + const print = treatmentOf(front) === "media" ? fitPrint(aspect, STACK_BOX) : null; + const size = print ?? { width: SHEET.width, height: SHEET.height }; + const anchor = print && !isPortrait(aspect) ? MEDIA_ANCHOR.landscape : MEDIA_ANCHOR.portrait; + const left = anchor.x - size.width / 2; + const top = Math.min(Math.max(anchor.y - size.height / 2, 0), MEDIA_COMPOSITION.height - size.height); + return ( +
+ {MEDIA_BACKING.map((sheet) => ( + + ))} + +
+ ); + } + return ( +
+ {SHEET_BACKING.map((sheet) => ( + + ))} +
+ +
+ ); +} diff --git a/ui/src/components/artifacts/v1/ArtifactTile.test.tsx b/ui/src/components/artifacts/v1/ArtifactTile.test.tsx new file mode 100644 index 00000000000..54fc2da6bad --- /dev/null +++ b/ui/src/components/artifacts/v1/ArtifactTile.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ArtifactSingleTile, ArtifactStackTile, ArtifactTileGrid } from "./ArtifactTile"; +import { testSubject } from "./test-subject"; + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompany: null, selectedCompanyId: null, companies: [] }), +})); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const NOW = new Date("2026-09-28T18:00:00"); + +describe("the Artifacts page tiles", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("renders a stack as a link with the task's pill, its count and when it changed", () => { + act(() => + root.render( + + + + + , + ), + ); + const link = container.querySelector('a[data-testid="artifact-v1-stack-tile"]'); + expect(link?.getAttribute("href")).toBe("/artifacts?issue=i3"); + expect(link?.className).toContain("artifact-tile"); + expect(container.querySelector("[data-artifact-pill]")?.textContent).toBe("ACM-3"); + expect(link?.textContent).toContain("21 Artifacts"); + expect(link?.textContent).toContain("Updated 11:07 AM"); + expect(container.querySelector('[data-artifact-stack="sheet"]')).not.toBeNull(); + expect(container.querySelectorAll("[data-artifact-spread]")).toHaveLength(2); + }); + + it("renders a picture stack on the media backing with a fitted print", () => { + act(() => + root.render( + + + + + , + ), + ); + expect(container.querySelector('[data-artifact-stack="media"]')).not.toBeNull(); + const print = container.querySelector("[data-artifact-print]"); + expect(print?.style.width).toBe("245px"); + expect(print?.style.height).toBe("163.33px"); + }); + + it("plays a video's own stream in the print when it has no poster, never an image tag on the video", () => { + act(() => + root.render( + + + + + , + ), + ); + const print = container.querySelector("[data-artifact-print]"); + expect(print?.querySelector("video")?.getAttribute("src")).toBe("/api/files/reel.webm"); + expect(print?.querySelector("img")).toBeNull(); + }); + + it("renders a single artifact as a button named after its file, with the sheet treatment", () => { + act(() => + root.render( + + + + + , + ), + ); + const button = container.querySelector('button[data-testid="artifact-v1-single-tile"]'); + expect(button?.getAttribute("aria-label")).toBe("Open brand-strategy-brief.md"); + expect(container.querySelector('[data-artifact-sheet="preview"]')?.textContent).toContain("Brand strategy"); + expect(container.querySelector("[data-artifact-front]")).not.toBeNull(); + }); +}); diff --git a/ui/src/components/artifacts/v1/ArtifactTile.tsx b/ui/src/components/artifacts/v1/ArtifactTile.tsx new file mode 100644 index 00000000000..3c7ea66cb37 --- /dev/null +++ b/ui/src/components/artifacts/v1/ArtifactTile.tsx @@ -0,0 +1,109 @@ +import type { ReactNode } from "react"; +import type { To } from "react-router-dom"; +import { Link } from "@/lib/router"; +import { ArtifactFront } from "./ArtifactFront"; +import { ArtifactStack } from "./ArtifactStack"; +import type { ArtifactSubject } from "./kinds"; +import { countLabel, updatedLabel } from "./when"; + +/** + * One cell of the Artifacts page grid: a 254px frame holding the artifact or + * stack, then the caption — title, the task's identifier in a pill, and a + * line of facts. From the mock: 20px side margins, 20px between frame and + * caption, 12px between the title and the pill row. The artwork stands on + * the frame's bottom edge, so the space from artwork to title is the same in + * every cell. The cell is a fixed 370px tall whether the title takes one line + * or two (its most before truncating), so the hover fill is the same size in + * every cell. The pill is Paper Mono regular with 12px of side padding. + */ +export function ArtifactCaption({ title, identifier, facts }: { title: string; identifier?: string | null; facts: string[] }) { + return ( +
+

+ {title} +

+
+ {identifier ? ( + + {identifier} + + ) : null} + + {facts.map((fact, i) => ( + + {i > 0 ? · : null} + {fact} + + ))} + +
+
+ ); +} + +const TILE = + "artifact-tile flex h-(--artifact-tile-h) w-full flex-col items-center gap-5 rounded-(--artifact-tile-radius) px-5 text-left outline-none transition-colors hover:bg-(--artifact-tile-hover) focus-visible:bg-(--artifact-tile-hover) focus-visible:ring-2 focus-visible:ring-ring"; + +/** The cell: a link when it has somewhere to go, a button otherwise. text-left keeps a sheet's title from centring. */ +export function ArtifactTile({ label, to, onClick, children, caption, testId }: { label: string; to?: To; onClick?: () => void; children: ReactNode; caption: ReactNode; testId?: string }) { + const body = ( + <> +
{children}
+ {caption} + + ); + return ( +
  • + {to ? ( + // design-allow(card-pattern): navigation cell, as ArtifactGroupCard; Card renders a div and would break anchor semantics. + + {body} + + ) : ( + + )} +
  • + ); +} + +export interface StackTileProps { + identifier: string; + title: string; + count: number; + updatedAt: string; + /** The newest artifact, shown as the first page. */ + front: ArtifactSubject; + to?: To; + onClick?: () => void; + now?: Date; +} + +/** A task with several artifacts: its newest one on top of a stack, the task's title underneath. */ +export function ArtifactStackTile({ identifier, title, count, updatedAt, front, to, onClick, now }: StackTileProps) { + return ( + }> + + + ); +} + +/** One artifact on its own: its file name (or title) underneath. */ +export function ArtifactSingleTile({ subject, to, onClick, now }: { subject: ArtifactSubject; to?: To; onClick?: () => void; now?: Date }) { + const name = subject.filename ?? subject.title; + return ( + }> + + + ); +} + +/** The grid: cells 28px apart in both directions, columns at least 330px wide. */ +export function ArtifactTileGrid({ children, testId = "artifact-v1-grid" }: { children: ReactNode; testId?: string }) { + return ( +
      + {children} +
    + ); +} diff --git a/ui/src/components/artifacts/v1/ArtifactsV1Filters.tsx b/ui/src/components/artifacts/v1/ArtifactsV1Filters.tsx new file mode 100644 index 00000000000..e24f6c89104 --- /dev/null +++ b/ui/src/components/artifacts/v1/ArtifactsV1Filters.tsx @@ -0,0 +1,26 @@ +import type { ArtifactGroupBy, ArtifactKindFilter } from "@/api/artifacts"; +import { TabFilters, type TabBarItem } from "@/components/TabBar"; + +/** + * The Artifacts page's one row of views (mock node 2129:133457): Tasks — + * one tile per task, the default — then every file flat, by type. Grouping + * and the kind filter are still two URL parameters; a view maps onto both. + */ +export type ArtifactsV1View = "tasks" | Exclude; + +export const ARTIFACTS_V1_VIEWS: TabBarItem[] = [ + { value: "tasks", label: "Tasks" }, + { value: "all", label: "All files" }, + { value: "image", label: "Images" }, + { value: "video", label: "Videos" }, + { value: "document", label: "Documents" }, + { value: "text", label: "Text" }, + { value: "file", label: "Files" }, +]; + +/** Any grouping reads as the Tasks view; flat views are named by their kind. */ +export const artifactsV1View = (groupBy: ArtifactGroupBy, kind: ArtifactKindFilter): ArtifactsV1View => (groupBy !== "none" ? "tasks" : kind); + +export function ArtifactsV1Filters({ view, onViewChange }: { view: ArtifactsV1View; onViewChange: (view: ArtifactsV1View) => void }) { + return ; +} diff --git a/ui/src/components/artifacts/v1/WorkProductState.tsx b/ui/src/components/artifacts/v1/WorkProductState.tsx new file mode 100644 index 00000000000..f35410c548e --- /dev/null +++ b/ui/src/components/artifacts/v1/WorkProductState.tsx @@ -0,0 +1,18 @@ +import type { IssueWorkProduct } from "@paperclipai/shared"; +import type { ReactNode } from "react"; +import { Badge } from "@/components/ui/badge"; +import { stateChipFor } from "@/components/task-chat/RichWorkProductCard"; + +/** + * What a row says beside a work product's name: "Unhealthy" first — a + * service can be active and broken at once, and the shipped cards warn the + * same way — otherwise the caller's label, else the state chip's. + */ +export function workProductStateBadge( + workProduct: Pick, + label?: string | null, +): ReactNode { + if (workProduct.healthStatus === "unhealthy") return Unhealthy; + const text = label ?? stateChipFor(workProduct.type, workProduct.status, workProduct.reviewState)?.label; + return text ? {text} : undefined; +} diff --git a/ui/src/components/artifacts/v1/adapters.ts b/ui/src/components/artifacts/v1/adapters.ts new file mode 100644 index 00000000000..747e1944f81 --- /dev/null +++ b/ui/src/components/artifacts/v1/adapters.ts @@ -0,0 +1,164 @@ +import type { IssueAttachment, IssueDocument, IssueDocumentSummary, IssueWorkProduct } from "@paperclipai/shared"; +import type { AttachmentRef } from "@/components/task-chat/task-chat-attachments"; +import { getAttachmentArtifactWorkProductMetadata } from "@paperclipai/shared"; +import { artifactUrl } from "@/lib/artifact-card-data"; +import { documentDisplayTitle, workProductHref } from "@/lib/issue-artifacts"; +import { attachmentDownloadPath, attachmentOpenPath } from "@/lib/issue-attachments"; +import { extOf, kindFor, type ArtifactSubject } from "./kinds"; +import { sizeLabel } from "./when"; + +/** + * The panel's and the thread's payloads as the artwork sees them. Dates come + * as Date objects from the types and as strings over the wire; both are + * normalised to ISO strings. + */ +const iso = (value: Date | string) => new Date(value).toISOString(); + +const text = (metadata: Record | null, key: string): string | null => { + const value = metadata?.[key]; + return typeof value === "string" && value.trim() ? value : null; +}; +const num = (metadata: Record | null, key: string): number | null => { + const value = metadata?.[key]; + return typeof value === "number" && Number.isFinite(value) ? value : null; +}; + +/** A task document: the sheet with its title set on it when it has text, named after its key. */ +export function subjectFromIssueDocument(doc: IssueDocument): ArtifactSubject { + return { + kind: "document", + title: documentDisplayTitle(doc), + filename: `${doc.key}.md`, + ext: "md", + contentType: "text/markdown", + hasText: doc.body.trim().length > 0, + isTaskDocument: true, + updatedAt: iso(doc.updatedAt), + }; +} + +/** An attached file: named after its original file name, sized, with the picture when it is one. */ +export function subjectFromAttachment(attachment: IssueAttachment): ArtifactSubject { + const filename = attachment.originalFilename ?? attachment.objectKey; + const ext = extOf({ filename }); + const kind = kindFor(ext, attachment.contentType); + const source = kind === "image" || kind === "video" ? artifactUrl(attachment.contentPath) || null : null; + return { + kind, + title: filename, + filename, + ext, + contentType: attachment.contentType, + thumbnailUrl: kind === "image" ? source : null, + videoUrl: kind === "video" ? source : null, + sizeLabel: sizeLabel(attachment.byteSize), + updatedAt: iso(attachment.updatedAt), + }; +} + +/** + * A work product: an attachment-backed artifact reads like the file it wraps; + * any other kind (a pull request, a commit, a preview URL, a branch, a + * service) is a link named by its title. + */ +export function subjectFromWorkProduct(workProduct: IssueWorkProduct): ArtifactSubject { + const attachment = getAttachmentArtifactWorkProductMetadata(workProduct); + const metadata = workProduct.metadata; + const updatedAt = iso(workProduct.updatedAt); + if (attachment || workProduct.type === "artifact") { + const filename = attachment?.originalFilename ?? text(metadata, "originalFilename") ?? workProduct.title; + const contentType = attachment?.contentType ?? text(metadata, "contentType"); + const contentPath = artifactUrl(attachment?.contentPath ?? text(metadata, "contentPath") ?? workProductHref(workProduct)); + const ext = extOf({ filename }); + const kind = kindFor(ext, contentType); + const source = (kind === "image" || kind === "video") && contentPath ? contentPath : null; + return { + kind, + title: workProduct.title, + filename, + ext, + contentType, + thumbnailUrl: kind === "image" ? source : null, + videoUrl: kind === "video" ? source : null, + sizeLabel: sizeLabel(attachment?.byteSize ?? num(metadata, "byteSize") ?? num(metadata, "size")), + updatedAt, + }; + } + return { kind: "link", title: workProduct.title, updatedAt }; +} + +/** A document named in the thread (a summary: no body), as a row. */ +export function subjectFromDocumentSummary(doc: IssueDocumentSummary): ArtifactSubject { + return { + kind: "document", + title: documentDisplayTitle(doc), + filename: `${doc.key}.md`, + ext: "md", + contentType: "text/markdown", + isTaskDocument: true, + updatedAt: doc.updatedAt ? iso(doc.updatedAt) : null, + }; +} + +/** A file a message refers to (`[name](/api/attachments/…/content)` or a bound upload); it carries no date of its own. */ +export function subjectFromAttachmentRef(ref: AttachmentRef): ArtifactSubject { + const ext = extOf({ filename: ref.name }); + const kind = kindFor(ext, ref.contentType); + const source = kind === "image" || kind === "video" ? artifactUrl(ref.openPath ?? ref.url) || null : null; + return { + kind, + title: ref.name, + filename: ref.name, + ext, + contentType: ref.contentType ?? null, + thumbnailUrl: kind === "image" ? source : null, + videoUrl: kind === "video" ? source : null, + sizeLabel: sizeLabel(ref.byteSize ?? null), + }; +} + +/** What a file row needs to open and offer a file the way the shipped card does. */ +export interface FileRowSource { + id: string; + attachmentId?: string; + filename: string; + contentType: string; + contentPath: string; + openPath: string; + downloadPath: string; + byteSize: number | null; +} + +/** An attached file's paths. */ +export function fileRowSourceFromAttachment(attachment: IssueAttachment): FileRowSource { + const filename = attachment.originalFilename ?? attachment.objectKey; + return { + id: attachment.id, + attachmentId: attachment.id, + filename, + contentType: attachment.contentType, + contentPath: artifactUrl(attachment.contentPath), + openPath: artifactUrl(attachmentOpenPath(attachment)), + downloadPath: artifactUrl(attachmentDownloadPath(attachment)), + byteSize: attachment.byteSize, + }; +} + +/** A registered file's paths, or null when the work product is not a file (a pull request, a service, …). */ +export function fileRowSourceFromWorkProduct(workProduct: IssueWorkProduct): FileRowSource | null { + if (workProduct.type !== "artifact") return null; + const attachment = getAttachmentArtifactWorkProductMetadata(workProduct); + const metadata = workProduct.metadata; + const contentPath = artifactUrl(attachment?.contentPath ?? text(metadata, "contentPath") ?? workProductHref(workProduct)); + if (!attachment && !contentPath) return null; + return { + id: workProduct.id, + attachmentId: attachment?.attachmentId, + filename: attachment?.originalFilename ?? text(metadata, "originalFilename") ?? workProduct.title, + contentType: attachment?.contentType ?? text(metadata, "contentType") ?? "", + contentPath, + openPath: artifactUrl(attachment?.openPath ?? text(metadata, "openPath")) || contentPath, + downloadPath: artifactUrl(attachment?.downloadPath ?? text(metadata, "downloadPath")) || attachmentDownloadPath({ contentPath }), + byteSize: attachment?.byteSize ?? num(metadata, "byteSize") ?? num(metadata, "size"), + }; +} diff --git a/ui/src/components/artifacts/v1/assets/backing-short.svg b/ui/src/components/artifacts/v1/assets/backing-short.svg new file mode 100644 index 00000000000..c86038dede8 --- /dev/null +++ b/ui/src/components/artifacts/v1/assets/backing-short.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/src/components/artifacts/v1/assets/backing-tall-a.svg b/ui/src/components/artifacts/v1/assets/backing-tall-a.svg new file mode 100644 index 00000000000..4427b507ed4 --- /dev/null +++ b/ui/src/components/artifacts/v1/assets/backing-tall-a.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/src/components/artifacts/v1/assets/backing-tall-b.svg b/ui/src/components/artifacts/v1/assets/backing-tall-b.svg new file mode 100644 index 00000000000..7d0d9fd6e0a --- /dev/null +++ b/ui/src/components/artifacts/v1/assets/backing-tall-b.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/src/components/artifacts/v1/assets/panel-doc-corner.svg b/ui/src/components/artifacts/v1/assets/panel-doc-corner.svg new file mode 100644 index 00000000000..e138e6df445 --- /dev/null +++ b/ui/src/components/artifacts/v1/assets/panel-doc-corner.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/src/components/artifacts/v1/assets/panel-doc.svg b/ui/src/components/artifacts/v1/assets/panel-doc.svg new file mode 100644 index 00000000000..6395c599063 --- /dev/null +++ b/ui/src/components/artifacts/v1/assets/panel-doc.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/src/components/artifacts/v1/assets/panel-glyph-audio.svg b/ui/src/components/artifacts/v1/assets/panel-glyph-audio.svg new file mode 100644 index 00000000000..d296a5a5c56 --- /dev/null +++ b/ui/src/components/artifacts/v1/assets/panel-glyph-audio.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui/src/components/artifacts/v1/assets/panel-glyph-slides.svg b/ui/src/components/artifacts/v1/assets/panel-glyph-slides.svg new file mode 100644 index 00000000000..0df4bcb0bf6 --- /dev/null +++ b/ui/src/components/artifacts/v1/assets/panel-glyph-slides.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui/src/components/artifacts/v1/assets/panel-play.svg b/ui/src/components/artifacts/v1/assets/panel-play.svg new file mode 100644 index 00000000000..a29d874f866 --- /dev/null +++ b/ui/src/components/artifacts/v1/assets/panel-play.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/src/components/artifacts/v1/assets/sheet-preview.svg b/ui/src/components/artifacts/v1/assets/sheet-preview.svg new file mode 100644 index 00000000000..3de6c930920 --- /dev/null +++ b/ui/src/components/artifacts/v1/assets/sheet-preview.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/src/components/artifacts/v1/geometry.ts b/ui/src/components/artifacts/v1/geometry.ts new file mode 100644 index 00000000000..ce728950cde --- /dev/null +++ b/ui/src/components/artifacts/v1/geometry.ts @@ -0,0 +1,70 @@ +/** + * The V1 artwork's measurements, from the PCLP-Core mocks (node + * 2129:133457, 2205:138266) as verified in the artifact directions lab. + * Positions are in CSS pixels inside the pieces they belong to; colours, + * radii, shadows and type are tokens (index.css, --artifact-*). + */ + +/** A front sheet: 180 × 216. */ +export const SHEET = { width: 180, height: 216 } as const; + +/** "Text Preview": the title block and the placeholder text bars. */ +export const PREVIEW_TITLE = { left: 20, top: 28, width: 140.69, height: 54 } as const; +export const PREVIEW_BARS = { left: 20, top: 98, width: 132, height: 52, pitch: 15, barHeight: 7 } as const; +/** Each row of bars: [x, width] within the block. */ +export const PREVIEW_BAR_ROWS: ReadonlyArray> = [ + [[0, 31], [36, 54], [95, 10]], + [[0, 52], [57, 18], [80, 36]], + [[0, 43], [49, 52], [106, 26]], + [[0, 63], [68, 24], [97, 28]], +]; + +/** "Icon": a 64px glyph box centred at (58, 58), the type centred across the sheet at y 138. */ +export const ICON_GLYPH = { left: 58, top: 58, size: 64 } as const; +/** The mock's PDF glyph: three bars, 12px tall on a 26px pitch, widths 35/64/64. */ +export const ICON_BARS = { widths: [35, 64, 64] as const, height: 12, pitch: 26 } as const; +export const ICON_LABEL = { top: 138, height: 54 } as const; +/** Audio's play disc, top-right, clear of the centred glyph. */ +export const ICON_PLAY = { inset: 20, size: 44, icon: 20 } as const; + +/** A print fits a box while keeping its picture's ratio, border included. */ +export interface FitBox { maxWidth: number; maxHeight: number; border: number } +/** On its own in the 254px frame (mock: 240×180 and 180×240). */ +export const SINGLE_BOX: FitBox = { maxWidth: 240, maxHeight: 240, border: 4 }; +/** As the first page of a stack (mock: 245×183 and 167×220). */ +export const STACK_BOX: FitBox = { maxWidth: 245, maxHeight: 220, border: 3.3 }; +export const DEFAULT_ASPECT = 4 / 3; +export const PRINT_PLAY = { size: 44, icon: 20 } as const; + +/** A backing page: the SVG file, where the sheet sits inside it, the centre of its rotated box, its angle, and its side when fanned. */ +export interface BackingPlacement { + file: "short" | "tallA" | "tallB"; + width: number; + height: number; + sheet: { left: number; top: number; width: number; height: number }; + centre: { x: number; y: number }; + rotate: number; + spread: "left" | "right"; +} + +// Sheet backing (text and documents): composition 254.25 × 241.04. +export const SHEET_COMPOSITION = { width: 254.253, height: 241.044 } as const; +export const SHEET_BACKING: BackingPlacement[] = [ + { file: "short", width: 240.409, height: 247.814, sheet: { left: 30, top: 26.6, width: 180.409, height: 187.814 }, centre: { x: 7.13 + 220.486 / 2, y: 225.88 / 2 }, rotate: -14, spread: "left" }, +]; +/** Its second layer is a plain landscape sheet, 239.5 × 179.6, turned −5°. */ +export const LANDSCAPE_SHEET = { width: 239.509, height: 179.632, centre: { x: 254.253 / 2, y: 33.5 + 199.823 / 2 }, rotate: -5, spread: "right" as const }; +export const SHEET_FRONT = { left: 37.13, top: 25.04 } as const; + +// Media backing (pictures, video, audio): two tall sheets, painted right first then left. +export const MEDIA_BACKING: BackingPlacement[] = [ + { file: "tallA", width: 240.409, height: 276.151, sheet: { left: 30, top: 26.6, width: 180.409, height: 216.151 }, centre: { x: 55.73 + 195.048 / 2, y: 12.13 + 228.209 / 2 + 2 }, rotate: 4, spread: "right" }, + { file: "tallB", width: 240.409, height: 276.151, sheet: { left: 30, top: 26.6, width: 180.409, height: 216.151 }, centre: { x: 207.544 / 2, y: 238.217 / 2 }, rotate: -7.64, spread: "left" }, +]; +export const MEDIA_COMPOSITION = { width: 55.73 + 195.048, height: 240.337 } as const; +/** Where the first page is centred: landscape prints on one point, portrait prints and sheets on another. */ +export const MEDIA_ANCHOR = { landscape: { x: 16.76 + 245.003 / 2, y: 35.32 + 183.002 / 2 }, portrait: { x: 44.39 + 167 / 2, y: 11.34 + 220 / 2 } } as const; + +/** The row's 40px document icon: the folded corner and the glyph and label positions. */ +export const ROW_ICON = { size: 40, corner: { left: 30.41, top: 0.41, size: 9.195 }, glyph: { left: 5, top: 5, size: 14 }, label: { left: 5, top: 22 }, bars: { widths: [7, 14, 14] as const, height: 2 } } as const; +export const ROW_PLAY = { inset: 4, size: 32, icon: 16 } as const; diff --git a/ui/src/components/artifacts/v1/index.ts b/ui/src/components/artifacts/v1/index.ts new file mode 100644 index 00000000000..a1835e08818 --- /dev/null +++ b/ui/src/components/artifacts/v1/index.ts @@ -0,0 +1,13 @@ +export * from "./kinds"; +export * from "./when"; +export * from "./geometry"; +export { ArtifactPrint, fitPrint, useImageAspect, type PrintSize } from "./ArtifactPrint"; +export { IconSheet, PreviewSheet } from "./ArtifactSheet"; +export { ArtifactFront } from "./ArtifactFront"; +export { ArtifactStack } from "./ArtifactStack"; +export { ArtifactCaption, ArtifactSingleTile, ArtifactStackTile, ArtifactTile, ArtifactTileGrid, type StackTileProps } from "./ArtifactTile"; +export { ArtifactFileRow, ROW_ACTION_CLASS } from "./ArtifactFileRow"; +export { workProductStateBadge } from "./WorkProductState"; +export { fileRowSourceFromAttachment, fileRowSourceFromWorkProduct, subjectFromAttachment, subjectFromAttachmentRef, subjectFromDocumentSummary, subjectFromIssueDocument, subjectFromWorkProduct, type FileRowSource } from "./adapters"; +export { ARTIFACTS_V1_VIEWS, ArtifactsV1Filters, artifactsV1View, type ArtifactsV1View } from "./ArtifactsV1Filters"; +export { ArtifactRow, ArtifactRowGroup, ArtifactRowIcon, ARTIFACT_ROW_CLASS, ARTIFACT_ROW_MIN_WIDTH, inlineRowStyle, splitName, type ArtifactRowProps } from "./ArtifactRow"; diff --git a/ui/src/components/artifacts/v1/kinds.test.ts b/ui/src/components/artifacts/v1/kinds.test.ts new file mode 100644 index 00000000000..1e67741df40 --- /dev/null +++ b/ui/src/components/artifacts/v1/kinds.test.ts @@ -0,0 +1,88 @@ +import { Music } from "lucide-react"; +import type { CompanyArtifact } from "@paperclipai/shared"; +import { describe, expect, it } from "vitest"; +import { glyphOf, isPortrait, kindFor, labelOf, mediaKindOf, subjectFromCompanyArtifact, treatmentOf } from "./kinds"; +import { testSubject } from "./test-subject"; + +describe("treatmentOf", () => { + it("prints media that has a preview and falls back when it has none", () => { + expect(treatmentOf(testSubject({ kind: "image", thumbnailUrl: "/cap.png" }))).toBe("media"); + expect(treatmentOf(testSubject({ kind: "video", videoUrl: "/reel.webm" }))).toBe("media"); + expect(treatmentOf(testSubject({ kind: "video" }))).toBe("media-fallback"); + expect(treatmentOf(testSubject({ kind: "file", ext: "mp3" }))).toBe("media-fallback"); + }); + + it("previews a document whose text is known and shows the sheet otherwise", () => { + expect(treatmentOf(testSubject({ kind: "document", hasText: true }))).toBe("preview"); + expect(treatmentOf(testSubject({ kind: "document" }))).toBe("icon"); + expect(treatmentOf(testSubject({ kind: "pdf" }))).toBe("icon"); + }); +}); + +describe("labelOf and glyphOf", () => { + it("takes the type from the extension, keeps links as URL, draws PDF as bars and audio with the music glyph", () => { + expect(labelOf(testSubject({ kind: "slides", filename: "deck.pptx" }))).toBe("PPTX"); + expect(labelOf(testSubject({ kind: "link" }))).toBe("URL"); + expect(labelOf(testSubject({ kind: "document" }))).toBe("MD"); + expect(glyphOf(testSubject({ kind: "pdf" }))).toBe("bars"); + expect(glyphOf(testSubject({ kind: "file", ext: "wav" }))).toBe(Music); + }); +}); + +describe("isPortrait", () => { + it("stands only prints that are taller than wide; a square lies", () => { + expect(isPortrait(4 / 5)).toBe(true); + expect(isPortrait(1)).toBe(false); + expect(isPortrait(undefined)).toBe(false); + }); +}); + +describe("mediaKindOf", () => { + it("follows the server's rule: task documents are Documents, files go by content type", () => { + expect(mediaKindOf(testSubject({ kind: "document", isTaskDocument: true, contentType: "text/markdown" }))).toBe("document"); + expect(mediaKindOf(testSubject({ kind: "document", filename: "deck-guide.md", contentType: "text/markdown" }))).toBe("text"); + expect(mediaKindOf(testSubject({ kind: "sheet", contentType: "text/csv" }))).toBe("text"); + expect(mediaKindOf(testSubject({ kind: "sheet", contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }))).toBe("file"); + expect(mediaKindOf(testSubject({ kind: "image", contentType: "image/png" }))).toBe("image"); + expect(mediaKindOf(testSubject({ kind: "video" }))).toBe("video"); + }); +}); + +describe("kindFor", () => { + it("names the artwork kind from the extension first and the content type second", () => { + expect(kindFor("pdf", "application/pdf")).toBe("pdf"); + expect(kindFor("pptx", null)).toBe("slides"); + expect(kindFor("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")).toBe("sheet"); + expect(kindFor("md", "text/markdown")).toBe("document"); + expect(kindFor(undefined, "text/html")).toBe("code"); + expect(kindFor("png", "image/png")).toBe("image"); + expect(kindFor(undefined, null, "video")).toBe("video"); + expect(kindFor("otf", "font/otf")).toBe("file"); + }); +}); + +describe("subjectFromCompanyArtifact", () => { + const base: CompanyArtifact = { + id: "a1", source: "work_product", mediaKind: "file", title: "Interface Craft deck", previewText: null, + contentType: "application/pdf", contentPath: "/api/files/acmehats-interface-craft.pdf", openPath: null, downloadPath: null, + issue: { id: "i1", identifier: "ACM-3", title: "Create visual directions" }, project: null, createdByAgent: null, + updatedAt: "2026-09-28T11:07:00", href: "/artifacts/a1", + }; + + it("reads the file name and kind from the content path and type", () => { + const subject = subjectFromCompanyArtifact(base); + expect(subject).toMatchObject({ kind: "pdf", filename: "acmehats-interface-craft.pdf", ext: "pdf", taskIdentifier: "ACM-3", isTaskDocument: false }); + }); + + it("treats a task document as a document with known text when it has a preview", () => { + const subject = subjectFromCompanyArtifact({ ...base, source: "document", mediaKind: "document", contentType: null, contentPath: null, previewText: "Goal: a clear brand direction" }); + expect(subject).toMatchObject({ kind: "document", isTaskDocument: true, hasText: true, filename: null }); + }); + + it("gives pictures and video their content path as the picture", () => { + const subject = subjectFromCompanyArtifact({ ...base, mediaKind: "image", contentType: "image/png", contentPath: "/api/files/contact-sheet.png" }); + expect(subject).toMatchObject({ kind: "image", thumbnailUrl: "/api/files/contact-sheet.png", filename: "contact-sheet.png" }); + const video = subjectFromCompanyArtifact({ ...base, mediaKind: "video", contentType: "video/webm", contentPath: "/api/files/reel.webm" }); + expect(video).toMatchObject({ kind: "video", videoUrl: "/api/files/reel.webm", thumbnailUrl: null }); + }); +}); diff --git a/ui/src/components/artifacts/v1/kinds.ts b/ui/src/components/artifacts/v1/kinds.ts new file mode 100644 index 00000000000..148d38c0012 --- /dev/null +++ b/ui/src/components/artifacts/v1/kinds.ts @@ -0,0 +1,175 @@ +import { Archive, CodeXml, File, FileText, Film, Globe, Image, Music, Presentation, Table2, type LucideIcon } from "lucide-react"; +import type { CompanyArtifact } from "@paperclipai/shared"; + +/** + * What the V1 artwork knows about an artifact. Each surface adapts its own + * payload (company artifacts, work products, documents, attachments) into + * this; the artwork never reads an API type directly. + */ +export type ArtifactKind = "document" | "pdf" | "slides" | "sheet" | "archive" | "code" | "file" | "link" | "image" | "video"; + +export interface ArtifactSubject { + kind: ArtifactKind; + title: string; + filename?: string | null; + ext?: string | null; + contentType?: string | null; + /** A picture, a video poster or a link preview. */ + thumbnailUrl?: string | null; + videoUrl?: string | null; + /** Width / height when known; otherwise read from the loaded picture. */ + aspect?: number | null; + /** Whether a document's text is known (a preview sheet can be set from it). */ + hasText?: boolean; + /** A versioned task document, as opposed to an attached Markdown file. */ + isTaskDocument?: boolean; + sizeLabel?: string | null; + /** ISO timestamp; a thread attachment has none of its own. */ + updatedAt?: string | null; + taskIdentifier?: string | null; +} + +/** + * Which of the mock's four treatments an artifact gets. + * + * preview a text file whose text is known: the white sheet with the + * title set on it + * icon any other file or document: the white sheet with a faint + * centred glyph and the type in caps + * media an image or video with a preview: the print with a white border + * media-fallback media with nothing to show (audio, a video without a poster): + * the sheet with the media glyph and a play button + */ +export type Treatment = "preview" | "icon" | "media" | "media-fallback"; + +const AUDIO = /^(mp3|wav|m4a|aac|flac|ogg|aiff?)$/i; + +export const extOf = (subject: Pick): string | undefined => + (subject.ext ?? subject.filename?.match(/\.([a-z0-9]+)$/i)?.[1])?.toLowerCase(); + +export const isAudio = (subject: ArtifactSubject) => subject.kind === "file" && AUDIO.test(extOf(subject) ?? ""); + +export const isMedia = (subject: ArtifactSubject) => subject.kind === "image" || subject.kind === "video" || isAudio(subject); + +/** A picture to print: the thumbnail, or for video its own stream (a poster is optional). */ +export const previewSourceOf = (subject: ArtifactSubject): string | null => + subject.thumbnailUrl ?? (subject.kind === "video" ? subject.videoUrl ?? null : null); + +export function treatmentOf(subject: ArtifactSubject): Treatment { + if (subject.kind === "image" || subject.kind === "video") return previewSourceOf(subject) ? "media" : "media-fallback"; + if (isAudio(subject)) return "media-fallback"; + if (subject.kind === "document" && subject.hasText) return "preview"; + return "icon"; +} + +/** Portrait prints stand; everything else lies. A square counts as landscape. */ +export const isPortrait = (aspect: number | null | undefined) => (aspect ?? 1) < 1; + +const FALLBACK_LABEL: Record = { + document: "MD", + pdf: "PDF", + slides: "PPTX", + sheet: "CSV", + archive: "ZIP", + code: "CODE", + file: "FILE", + link: "URL", + image: "IMG", + video: "VIDEO", +}; + +/** The type in caps, from the real extension when there is one. */ +export function labelOf(subject: ArtifactSubject): string { + if (subject.kind === "link") return "URL"; + return (extOf(subject) ?? FALLBACK_LABEL[subject.kind]).toUpperCase(); +} + +/** The glyph: the mock draws PDF with three bars; every other type takes its lucide icon. */ +export type Glyph = "bars" | LucideIcon; + +const ICON: Record = { + pdf: "bars", + document: FileText, + slides: Presentation, + sheet: Table2, + archive: Archive, + code: CodeXml, + file: File, + link: Globe, + image: Image, + video: Film, +}; + +export function glyphOf(subject: ArtifactSubject): Glyph { + if (isAudio(subject)) return Music; + return ICON[subject.kind]; +} + +/** The product's media kinds, which the Artifacts page's filter tabs are named after. */ +export type MediaKind = "image" | "video" | "document" | "text" | "file"; + +const TEXT_TYPES = new Set(["application/json", "application/xml", "application/markdown"]); + +/** + * The server's rule (server/src/services/company-artifacts.ts, + * `classifyMediaKind`): a task document is a Document; every file goes by its + * content type — images and video by theirs, text, JSON, XML and Markdown are + * Text, anything else is a File. + */ +export function mediaKindOf(subject: ArtifactSubject): MediaKind { + if (subject.kind === "document" && subject.isTaskDocument) return "document"; + const type = (subject.contentType ?? "").toLowerCase(); + if (type.startsWith("image/")) return "image"; + if (type.startsWith("video/")) return "video"; + if (type.startsWith("text/") || TEXT_TYPES.has(type) || type.endsWith("+json") || type.endsWith("+xml")) return "text"; + if (!type && (subject.kind === "image" || subject.kind === "video")) return subject.kind; + return "file"; +} + +const KIND_BY_EXT: Record = { + md: "document", markdown: "document", txt: "document", + pdf: "pdf", + ppt: "slides", pptx: "slides", key: "slides", + csv: "sheet", tsv: "sheet", xls: "sheet", xlsx: "sheet", numbers: "sheet", + zip: "archive", tar: "archive", gz: "archive", tgz: "archive", "7z": "archive", rar: "archive", + html: "code", htm: "code", css: "code", js: "code", ts: "code", tsx: "code", jsx: "code", json: "code", xml: "code", yml: "code", yaml: "code", py: "code", rb: "code", go: "code", rs: "code", sh: "code", sql: "code", +}; + +/** The artwork kind for a file, from its extension first and its content type second. */ +export function kindFor(ext: string | undefined, contentType: string | null | undefined, mediaKind?: CompanyArtifact["mediaKind"]): ArtifactKind { + const type = (contentType ?? "").toLowerCase(); + if (type.startsWith("image/") || mediaKind === "image") return "image"; + if (type.startsWith("video/") || mediaKind === "video") return "video"; + if (ext && KIND_BY_EXT[ext]) return KIND_BY_EXT[ext]; + if (type === "application/pdf") return "pdf"; + if (type.startsWith("text/") || TEXT_TYPES.has(type) || type.endsWith("+json") || type.endsWith("+xml")) return "code"; + return "file"; +} + +const filenameOf = (artifact: CompanyArtifact): string | null => { + const path = artifact.contentPath ?? artifact.downloadPath ?? artifact.openPath; + const last = path?.split("?")[0].split("/").pop(); + return last && /\.[a-z0-9]+$/i.test(last) ? decodeURIComponent(last) : null; +}; + +/** A company artifact (the Artifacts page) as the artwork sees it. */ +export function subjectFromCompanyArtifact(artifact: CompanyArtifact): ArtifactSubject { + const isTaskDocument = artifact.source === "document"; + const filename = isTaskDocument ? null : filenameOf(artifact) ?? (/\.[a-z0-9]+$/i.test(artifact.title) ? artifact.title : null); + const ext = extOf({ filename }); + const kind: ArtifactKind = isTaskDocument ? "document" : kindFor(ext, artifact.contentType, artifact.mediaKind); + return { + kind, + title: artifact.title, + filename, + ext, + contentType: artifact.contentType, + // A video has no poster of its own in this payload; the print plays its stream instead. + thumbnailUrl: kind === "image" ? artifact.contentPath : null, + videoUrl: kind === "video" ? artifact.contentPath : null, + hasText: Boolean(artifact.previewText?.trim()), + isTaskDocument, + updatedAt: artifact.updatedAt, + taskIdentifier: artifact.issue.identifier, + }; +} diff --git a/ui/src/components/artifacts/v1/test-subject.ts b/ui/src/components/artifacts/v1/test-subject.ts new file mode 100644 index 00000000000..b32d44eb64b --- /dev/null +++ b/ui/src/components/artifacts/v1/test-subject.ts @@ -0,0 +1,5 @@ +import type { ArtifactSubject } from "./kinds"; + +export function testSubject(overrides: Partial & Pick): ArtifactSubject { + return { title: "An artifact", updatedAt: "2026-09-28T11:07:00", taskIdentifier: "ACM-3", ...overrides }; +} diff --git a/ui/src/components/artifacts/v1/when.test.ts b/ui/src/components/artifacts/v1/when.test.ts new file mode 100644 index 00000000000..f188a1251d3 --- /dev/null +++ b/ui/src/components/artifacts/v1/when.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { countLabel, panelDateLabel, sizeLabel, updatedLabel } from "./when"; + +// Monday 2026-09-28, 6:00 PM local time. +const NOW = new Date("2026-09-28T18:00:00"); + +describe("updatedLabel", () => { + it("gives the time today, Yesterday, the weekday within a week, then the date", () => { + expect(updatedLabel("2026-09-28T16:05:00", NOW)).toBe("4:05 PM"); + expect(updatedLabel("2026-09-27T23:30:00", NOW)).toBe("Yesterday"); + expect(updatedLabel("2026-09-23T09:00:00", NOW)).toBe("Wednesday"); + expect(updatedLabel("2026-09-12T09:00:00", NOW)).toBe("Sep 12"); + }); +}); + +describe("panelDateLabel", () => { + it("gives Today, Yesterday, a day count within a week, then the long date", () => { + expect(panelDateLabel("2026-09-28T08:00:00", NOW)).toBe("Today"); + expect(panelDateLabel("2026-09-27T08:00:00", NOW)).toBe("Yesterday"); + expect(panelDateLabel("2026-09-25T08:00:00", NOW)).toBe("3D ago"); + expect(panelDateLabel("2026-07-23T08:00:00", NOW)).toBe("July 23"); + }); +}); + +describe("countLabel and sizeLabel", () => { + it("pluralise and format", () => { + expect(countLabel(1)).toBe("1 Artifact"); + expect(countLabel(21)).toBe("21 Artifacts"); + expect(sizeLabel(512)).toBe("512 B"); + expect(sizeLabel(59085)).toBe("57.7 KB"); + expect(sizeLabel(1289748)).toBe("1.2 MB"); + expect(sizeLabel(null)).toBeNull(); + }); +}); diff --git a/ui/src/components/artifacts/v1/when.ts b/ui/src/components/artifacts/v1/when.ts new file mode 100644 index 00000000000..f56cb875ca3 --- /dev/null +++ b/ui/src/components/artifacts/v1/when.ts @@ -0,0 +1,32 @@ +const DAY = 86_400_000; + +const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + +/** The Artifacts page's form: "4:05 PM" · "Yesterday" · "Monday" · "Sep 12". */ +export function updatedLabel(iso: string, now: Date = new Date()): string { + const date = new Date(iso); + const days = Math.round((startOfDay(now) - startOfDay(date)) / DAY); + if (days <= 0) return date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); + if (days === 1) return "Yesterday"; + if (days < 7) return date.toLocaleDateString("en-US", { weekday: "long" }); + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +/** The panel row's form: "Today" · "Yesterday" · "2D ago" · "July 23". */ +export function panelDateLabel(iso: string, now: Date = new Date()): string { + const date = new Date(iso); + const days = Math.round((startOfDay(now) - startOfDay(date)) / DAY); + if (days <= 0) return "Today"; + if (days === 1) return "Yesterday"; + if (days < 7) return `${days}D ago`; + return date.toLocaleDateString("en-US", { month: "long", day: "numeric" }); +} + +export const countLabel = (count: number) => `${count} ${count === 1 ? "Artifact" : "Artifacts"}`; + +export function sizeLabel(bytes: number | null | undefined): string | null { + if (bytes == null) return null; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx index bc573fba519..d9b9476c9ba 100644 --- a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx +++ b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx @@ -25,6 +25,7 @@ import { useIssueWorkProducts } from "@/hooks/useIssueWorkProducts"; import { documentDisplayTitle, selectArtifactAttachments, + workProductHref, } from "@/lib/issue-artifacts"; import { DocumentCard } from "@/components/artifacts/RichArtifactCards"; import { @@ -42,6 +43,9 @@ import { } from "@/components/IssueDocumentAnnotations"; import { formatDateTime } from "@/lib/utils"; import { useLocation } from "@/lib/router"; +import { useArtifactsV1Enabled } from "@/hooks/useArtifactsV1Enabled"; +import { ArtifactFileRow, ArtifactRow, ROW_ACTION_CLASS, fileRowSourceFromAttachment, fileRowSourceFromWorkProduct, subjectFromAttachment, subjectFromIssueDocument, subjectFromWorkProduct, workProductStateBadge, type ArtifactSubject } from "@/components/artifacts/v1"; +import { artifactUrl } from "@/lib/artifact-card-data"; interface IssuePropertiesArtifactsTabProps { issue: Issue; @@ -75,6 +79,33 @@ function workProductStatusBadge( } } +/** + * Artifacts V1 (doc/plans/2026-10-22-artifacts-mvp-v1.md): with the setting + * on, the two expandable rows below render a V1 row as their face — the + * document icon, the name over its facts — and keep their expansion, deep + * link and review behaviour; the face receives what the card would have. + */ +type ArtifactFace = (props: { onOpen: () => void; expanded?: boolean; actions?: ReactNode; statusBadge?: ReactNode }) => ReactNode; + +const v1Face = (subject: ArtifactSubject): ArtifactFace => + function V1Face({ onOpen, expanded, actions, statusBadge }) { + return ( + + {statusBadge} + {actions} + + ) : undefined + } + /> + ); + }; + /** * Work-product row for an eligible Markdown artifact (LOOA-1533 gap): expands * in place into the shipped document review surface backed by the @@ -89,6 +120,7 @@ function MarkdownWorkProductRow({ reviewDoc, author, openRequestId, + face, }: { issueId: string; workProduct: IssueWorkProduct; @@ -96,6 +128,7 @@ function MarkdownWorkProductRow({ reviewDoc: IssueDocument | undefined; author: string; openRequestId?: number; + face?: ArtifactFace; }) { const openTextAttachment = useContext(TextAttachmentContext); const [expanded, setExpanded] = useState(false); @@ -204,27 +237,12 @@ function MarkdownWorkProductRow({ ); } - return ( -
    -
    - - {badge.label} - - ) : undefined - } - actions={ + const statusBadge = badge ? ( + + {badge.label} + + ) : undefined; + const actions = ( <> {reviewDoc ? ( openTextAttachment(metadata.attachmentId, metadata.originalFilename ?? workProduct.title)} aria-label={`Open in tab: ${workProduct.title}`} title="Open in tab" - className="shrink-0 px-1.5 py-1.5 text-muted-foreground hover:text-foreground" + className={face ? ROW_ACTION_CLASS : "shrink-0 px-1.5 py-1.5 text-muted-foreground hover:text-foreground"} > @@ -254,7 +272,7 @@ function MarkdownWorkProductRow({ rel="noreferrer" aria-label={`Open raw ${workProduct.title}`} title="Open raw" - className="shrink-0 px-1.5 py-1.5 text-muted-foreground hover:text-foreground" + className={face ? ROW_ACTION_CLASS : "shrink-0 px-1.5 py-1.5 text-muted-foreground hover:text-foreground"} > @@ -262,13 +280,32 @@ function MarkdownWorkProductRow({ href={metadata.downloadPath} aria-label={`Download ${workProduct.title}`} title="Download" - className="shrink-0 py-1.5 pr-2 pl-0.5 text-muted-foreground hover:text-foreground" + className={face ? ROW_ACTION_CLASS : "shrink-0 py-1.5 pr-2 pl-0.5 text-muted-foreground hover:text-foreground"} > - } - /> + ); + return ( +
    +
    + {face ? ( + face({ onOpen: handleToggle, expanded, actions, statusBadge }) + ) : ( + + )}
    {expanded ? (
    {expandedBody}
    @@ -283,12 +320,14 @@ function DocumentRow({ author, openRequestId, onOpen, + face, }: { issueId: string; doc: IssueDocument; author: string; openRequestId?: number; onOpen?: () => void; + face?: ArtifactFace; }) { const [expanded, setExpanded] = useState(false); const [annotationPanelOpen, setAnnotationPanelOpen] = useState(false); @@ -302,8 +341,23 @@ function DocumentRow({ if (onOpen || openRequestId === undefined || !expanded) return; headerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); }, [expanded, onOpen, openRequestId]); + const open = onOpen ?? (() => setExpanded((open) => !open)); + const actions = !onOpen ? ( + { + setExpanded(true); + setAnnotationPanelOpen((open) => !open); + }} + /> + ) : undefined; return (
    + {face ? ( + face({ onOpen: open, expanded: onOpen ? undefined : expanded, actions }) + ) : ( setExpanded((open) => !open))} + onOpen={open} expanded={onOpen ? undefined : expanded} - actions={ - !onOpen ? ( - { - setExpanded(true); - setAnnotationPanelOpen((open) => !open); - }} - /> - ) : undefined - } + actions={actions} /> + )} {expanded ? (
    {doc.body.trim().length > 0 ? ( @@ -368,6 +411,7 @@ export function IssuePropertiesArtifactsTab({ onOpenDocument, uploadState, }: IssuePropertiesArtifactsTabProps) { + const { enabled: v1 } = useArtifactsV1Enabled(); const { data: attachments, isPending: attachmentsPending, @@ -533,6 +577,80 @@ export function IssuePropertiesArtifactsTab({ ); } + if (v1) { + // Artifacts V1: one flat list, newest first, no run sections; each item is a V1 row. + const rows = [...allRows].sort((a, b) => b.date.getTime() - a.date.getTime()); + return withUploads( +
    + {feedback} +
      + {rows.map((row) => { + if (row.kind === "work_product") { + const wp = row.value; + const agent = row.runId ? agentsById.get(runsById.get(row.runId)?.agentId ?? "") : null; + const subject = subjectFromWorkProduct(wp); + const markdownMetadata = getMarkdownWorkProductAttachmentMetadata(wp); + if (markdownMetadata) { + const reviewKey = artifactReviewDocumentKey(wp.id); + return ( +
    • + +
    • + ); + } + // "Unhealthy" outranks the status, as the shipped cards warn. + const statusBadge = workProductStateBadge(wp, workProductStatusBadge(wp.status)?.label ?? null); + const file = fileRowSourceFromWorkProduct(wp); + if (file) { + return ( +
    • + +
    • + ); + } + // A pull request, a preview, a service: a link that opens in a new tab, as the cards do. + const href = artifactUrl(workProductHref(wp)); + return ( +
    • + +
    • + ); + } + if (row.kind === "document") { + const doc = row.value; + return ( +
    • + onOpenDocument(doc) : undefined} + openRequestId={documentDeepLink?.documentKey === doc.key ? documentDeepLink.requestId : undefined} + face={v1Face(subjectFromIssueDocument(doc))} + /> +
    • + ); + } + const attachment = row.value; + return ( +
    • + +
    • + ); + })} +
    +
    , + ); + } + return withUploads(
    {feedback} diff --git a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.v1.test.tsx b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.v1.test.tsx new file mode 100644 index 00000000000..5cb0117aab2 --- /dev/null +++ b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.v1.test.tsx @@ -0,0 +1,226 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Issue, IssueAttachment, IssueDocument, IssueWorkProduct } from "@paperclipai/shared"; +import { IssuePropertiesArtifactsTab } from "./IssuePropertiesArtifactsTab"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const mockIssuesApi = vi.hoisted(() => ({ + listAttachments: vi.fn(async (): Promise => []), + listWorkProducts: vi.fn(async (_issueId: string, _options?: { refreshPullRequests?: boolean }): Promise => []), + ensureWorkProductReviewDocument: vi.fn(async (): Promise => ({})), +})); +vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi })); +vi.mock("@/api/activity", () => ({ activityApi: { runsForIssue: vi.fn(async (): Promise => []) } })); +vi.mock("@/api/agents", () => ({ agentsApi: { list: vi.fn(async (): Promise => []) } })); +const mockUseIssueDocuments = vi.hoisted(() => vi.fn((): { data: IssueDocument[] } => ({ data: [] }))); +vi.mock("@/hooks/useIssueDocuments", () => ({ useIssueDocuments: mockUseIssueDocuments })); +vi.mock("@/hooks/useArtifactsV1Enabled", () => ({ useArtifactsV1Enabled: () => ({ enabled: true, loaded: true }) })); +vi.mock("@/lib/router", () => ({ + Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => {children}, + useLocation: () => ({ hash: "" }), +})); +vi.mock("@/components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: string }) =>
    {children}
    })); +vi.mock("@/components/IssueDocumentAnnotations", () => ({ + DocumentAnnotationsCountChip: ({ docKey }: { docKey: string }) => , + IssueDocumentAnnotations: ({ children }: { children: React.ReactNode }) =>
    {children}
    , +})); + +const issue = { id: "issue-1", companyId: "company-1", identifier: "ACM-3", workMode: "standard" } as Issue; +const ATTACHMENT_ID = "00000000-0000-4000-8000-000000000001"; +const IMAGE_ID = "00000000-0000-4000-8000-000000000002"; + +const pdfWorkProduct = { + id: "wp-pdf", + companyId: "company-1", + projectId: null, + issueId: "issue-1", + executionWorkspaceId: null, + runtimeServiceId: null, + type: "artifact", + provider: "paperclip", + externalId: null, + title: "Interface Craft revision — PDF", + url: null, + status: "ready_for_review", + reviewState: "none", + isPrimary: false, + healthStatus: "unknown", + summary: null, + metadata: { + attachmentId: ATTACHMENT_ID, + contentType: "application/pdf", + byteSize: 59085, + contentPath: `/api/attachments/${ATTACHMENT_ID}/content`, + openPath: `/api/attachments/${ATTACHMENT_ID}/content?inline=1`, + downloadPath: `/api/attachments/${ATTACHMENT_ID}/content?download=1`, + originalFilename: "acmehats-interface-craft.pdf", + }, + createdByRunId: null, + createdAt: new Date("2026-09-26T10:00:00Z"), + updatedAt: new Date("2026-09-26T10:00:00Z"), +} as unknown as IssueWorkProduct; + +const pullRequest = { + ...pdfWorkProduct, + id: "wp-pr", + type: "pull_request", + provider: "github", + externalId: "42", + title: "Ship rich work-product cards", + url: "https://github.com/paperclipai/paperclip/pull/42", + status: "merged", + metadata: { repo: "paperclipai/paperclip", number: 42, state: "merged" }, + createdAt: new Date("2026-09-25T10:00:00Z"), + updatedAt: new Date("2026-09-25T10:00:00Z"), +} as unknown as IssueWorkProduct; + +const brokenService = { + ...pdfWorkProduct, + id: "wp-service", + type: "runtime_service", + provider: "paperclip", + title: "Preview server", + url: "https://preview.example.test", + status: "active", + healthStatus: "unhealthy", + metadata: null, + createdAt: new Date("2026-09-24T10:00:00Z"), + updatedAt: new Date("2026-09-24T10:00:00Z"), +} as unknown as IssueWorkProduct; + +const planDocument = { + id: "doc-plan", + companyId: "company-1", + issueId: "issue-1", + key: "plan", + title: "acmehats branding strategy — proposed plan", + format: "markdown", + body: "## Goal\n\nA clear first brand direction.", + latestRevisionNumber: 1, + createdByAgentId: null, + updatedByAgentId: null, + createdAt: new Date("2026-09-27T08:36:00Z"), + updatedAt: new Date("2026-09-27T08:36:00Z"), +} as unknown as IssueDocument; + +const heroAttachment = { + id: IMAGE_ID, + companyId: "company-1", + issueId: "issue-1", + issueCommentId: null, + assetId: "asset-2", + provider: "local", + objectKey: "hero.png", + contentType: "image/png", + byteSize: 433050, + originalFilename: "contact-sheet.png", + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date("2026-09-28T11:07:00Z"), + updatedAt: new Date("2026-09-28T11:07:00Z"), + contentPath: `/api/attachments/${IMAGE_ID}/content`, +} as unknown as IssueAttachment; + +async function flush() { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function waitFor(assertion: () => void, attempts = 50) { + let lastError: unknown; + for (let i = 0; i < attempts; i += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await act(flush); + } + } + throw lastError; +} + +describe("IssuePropertiesArtifactsTab with Artifacts V1 on", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + mockIssuesApi.listAttachments.mockResolvedValue([heroAttachment]); + mockIssuesApi.listWorkProducts.mockResolvedValue([pdfWorkProduct]); + mockUseIssueDocuments.mockReturnValue({ data: [planDocument] }); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); + }); + + function render(props: { onOpenDocument?: (document: IssueDocument) => void } = {}) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + flushSync(() => { + root.render( + + + , + ); + }); + } + + it("lists every artifact as one flat set of V1 rows, newest first, with no run sections", async () => { + render(); + await waitFor(() => { + expect(container.querySelectorAll('[data-testid="artifact-v1-row"]')).toHaveLength(3); + }); + const names = Array.from(container.querySelectorAll('[data-testid="artifact-v1-row-open"]')).map((el) => el.getAttribute("aria-label")); + expect(names).toEqual(["Open contact-sheet.png", "Open plan.md", "Open acmehats-interface-craft.pdf"]); + expect(container.querySelector("header")).toBeNull(); + expect(container.querySelector('[data-testid="artifacts-v1-list"]')).not.toBeNull(); + }); + + it("shows a file's type and size, its picture when it has one, and keeps open and download beside the name", async () => { + render(); + await waitFor(() => { + expect(container.querySelectorAll('[data-testid="artifact-v1-row"]')).toHaveLength(3); + }); + const rows = container.querySelectorAll('[data-testid="artifact-v1-row"]'); + expect(rows[2].textContent).toContain("PDF"); + expect(rows[2].textContent).toContain("57.7 KB"); + expect(rows[2].textContent).toContain("For review"); + expect(rows[2].querySelector('a[aria-label="Download acmehats-interface-craft.pdf"]')?.getAttribute("href")).toContain("download=1"); + expect(rows[0].querySelector("img")?.getAttribute("src")).toBe(`/api/attachments/${IMAGE_ID}/content`); + }); + + it("opens other work products in a new tab and warns about an unhealthy service before anything else", async () => { + mockIssuesApi.listWorkProducts.mockResolvedValue([pdfWorkProduct, pullRequest, brokenService]); + render(); + await waitFor(() => { + expect(container.querySelectorAll('[data-testid="artifact-v1-row"]')).toHaveLength(5); + }); + const pr = container.querySelector('a[aria-label="Open Ship rich work-product cards"]'); + expect(pr?.getAttribute("href")).toBe("https://github.com/paperclipai/paperclip/pull/42"); + expect(pr?.getAttribute("target")).toBe("_blank"); + const service = container.querySelector('a[aria-label="Open Preview server"]')?.closest('[data-testid="artifact-v1-row"]'); + expect(service?.textContent).toContain("Unhealthy"); + expect(service?.textContent).not.toContain("Running"); + }); + + it("opens a task document the way the panel asks, through onOpenDocument", async () => { + const onOpenDocument = vi.fn(); + render({ onOpenDocument }); + await waitFor(() => { + expect(container.querySelector('[aria-label="Open plan.md"]')).not.toBeNull(); + }); + act(() => container.querySelector('[aria-label="Open plan.md"]')!.click()); + expect(onOpenDocument).toHaveBeenCalledWith(planDocument); + }); +}); diff --git a/ui/src/components/issue-properties/IssuePropertiesDocumentAnnotations.test.tsx b/ui/src/components/issue-properties/IssuePropertiesDocumentAnnotations.test.tsx index 77a38ca2293..f99bb0b8ec6 100644 --- a/ui/src/components/issue-properties/IssuePropertiesDocumentAnnotations.test.tsx +++ b/ui/src/components/issue-properties/IssuePropertiesDocumentAnnotations.test.tsx @@ -30,6 +30,8 @@ const issueDocument: IssueDocument = { const issue = { id: "issue-1", identifier: "PAP-522", workMode: "standard" } as Issue; +// The Artifacts V1 setting hook reads QueryClientContext, which the partial react-query mock below omits. +vi.mock("@/hooks/useArtifactsV1Enabled", () => ({ useArtifactsV1Enabled: () => ({ enabled: false, loaded: true }) })); vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: [] }) })); vi.mock("@/hooks/useIssuePlanDocument", () => ({ useIssuePlanDocument: () => ({ data: { ...issueDocument, key: "plan" }, isLoading: false }), diff --git a/ui/src/components/task-chat/TaskChatBubble.artifactsV1.test.tsx b/ui/src/components/task-chat/TaskChatBubble.artifactsV1.test.tsx new file mode 100644 index 00000000000..1c5ad679398 --- /dev/null +++ b/ui/src/components/task-chat/TaskChatBubble.artifactsV1.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment jsdom + +import type { IssueAttachment } from "@paperclipai/shared"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ThemeProvider } from "@/context/ThemeContext"; +import { IssueGalleryContext } from "@/context/IssueGalleryContext"; +import { TextAttachmentContext } from "@/context/TextAttachmentContext"; +import { TaskChatBubble } from "./TaskChatBubble"; + +vi.mock("@/hooks/useArtifactsV1Enabled", () => ({ useArtifactsV1Enabled: () => ({ enabled: true, loaded: true }) })); + +const attachment = (overrides: Partial): IssueAttachment => + ({ + id: "a", + companyId: "company-1", + issueId: "issue-1", + issueCommentId: null, + assetId: "asset", + provider: "local", + objectKey: "file", + contentType: "application/octet-stream", + byteSize: 1024, + originalFilename: "file", + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date("2026-09-28T11:07:00Z"), + updatedAt: new Date("2026-09-28T11:07:00Z"), + contentPath: "/api/attachments/a/content", + ...overrides, + }) as IssueAttachment; + +describe("TaskChatBubble with Artifacts V1 on", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + flushSync(() => root.unmount()); + container.remove(); + }); + + it("hangs a message's files under its footer as one group of rows, in place of the media grid and file chips", () => { + const openGallery = vi.fn(() => true); + const image = attachment({ id: "img", originalFilename: "contact-sheet.png", contentType: "image/png", byteSize: 433050, contentPath: "/api/attachments/img/content" }); + const pdf = attachment({ id: "r1", originalFilename: "report.pdf", contentType: "application/pdf", byteSize: 59085, contentPath: "/api/attachments/r1/content", openPath: "/api/attachments/r1/content?inline=1" }); + flushSync(() => + root.render( + + + + + , + ), + ); + const group = container.querySelector('[data-testid="task-chat-bubble-artifacts-v1"]'); + expect(group).not.toBeNull(); + expect(container.querySelector('[data-testid="task-chat-bubble-media"]')).toBeNull(); + expect(container.querySelector('[data-testid="task-chat-bubble-attachments"]')).toBeNull(); + const rows = group!.querySelectorAll('[data-testid="artifact-v1-row"]'); + expect(rows).toHaveLength(2); + expect(rows[0].textContent).toContain("contact-sheet.png"); + expect(rows[0].textContent).toContain("422.9 KB"); + expect(rows[1].textContent).toContain("report.pdf"); + expect(rows[1].textContent).toContain("PDF"); + // The group follows the footer line. + const timestamp = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "11:08 AM"); + expect(timestamp).toBeDefined(); + expect(timestamp!.compareDocumentPosition(group!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + // Pictures open the task's gallery; files open in a new tab. + flushSync(() => rows[0].querySelector('[data-testid="artifact-v1-row-open"]')!.click()); + expect(openGallery).toHaveBeenCalledWith("/api/attachments/img/content"); + const fileLink = rows[1].querySelector('a[data-testid="artifact-v1-row-open"]'); + expect(fileLink?.getAttribute("href")).toBe("/api/attachments/r1/content?inline=1"); + expect(fileLink?.getAttribute("target")).toBe("_blank"); + }); + + it("keeps a text file's link while a plain click opens it in the text tab", () => { + const openText = vi.fn(); + const notes = attachment({ id: "n1", originalFilename: "notes.md", contentType: "text/markdown", byteSize: 512, contentPath: "/api/attachments/n1/content" }); + flushSync(() => + root.render( + + + + + , + ), + ); + const link = container.querySelector('a[data-testid="artifact-v1-row-open"]')!; + expect(link.getAttribute("href")).toBe("/api/attachments/n1/content"); + flushSync(() => { link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); }); + expect(openText).toHaveBeenCalledWith("n1", "notes.md"); + }); + + it("renders no group when a message has no files", () => { + flushSync(() => + root.render( + + + , + ), + ); + expect(container.querySelector('[data-testid="task-chat-bubble-artifacts-v1"]')).toBeNull(); + expect(container.textContent).toContain("Just words."); + }); +}); diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index 848db884a4f..349e35c3005 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -1,4 +1,6 @@ import { TextAttachmentContext } from "@/context/TextAttachmentContext"; +import { useArtifactsV1Enabled } from "@/hooks/useArtifactsV1Enabled"; +import { ArtifactRow, ArtifactRowGroup, inlineRowStyle, subjectFromAttachmentRef } from "@/components/artifacts/v1"; import { isTextAttachment } from "@/lib/issue-attachments"; import { ArtifactPreview } from "@/components/artifacts/ArtifactCard"; import { isVideoLikeOutput } from "@/lib/issue-output"; @@ -144,6 +146,7 @@ function TaskChatBubbleContent({ tryAgainNoLiveExecutionPathPending, }: TaskChatBubbleProps) { const streamlined = useStreamlinedTaskChatPresentation(); + const { enabled: artifactsV1 } = useArtifactsV1Enabled(); const openText = useContext(TextAttachmentContext); // Task attachments share the page gallery; standalone images retain the bubble viewer. const openIssueGallery = useContext(IssueGalleryContext); @@ -271,7 +274,7 @@ function TaskChatBubbleContent({
    ) : null} - {imageRefs.length > 0 ? ( + {!artifactsV1 && imageRefs.length > 0 ? (
    ) : null} - {attachmentRefs.length > 0 ? ( + {!artifactsV1 && attachmentRefs.length > 0 ? (
    Files · {attachmentRefs.length} @@ -430,6 +433,24 @@ function TaskChatBubbleContent({ {item.timestamp} ) : null} + {artifactsV1 && imageRefs.length + attachmentRefs.length > 0 ? ( + // Artifacts V1 (doc/plans/2026-10-22-artifacts-mvp-v1.md): the files a message produced hang under its + // footer, 12px below, as rows inline at equal widths, wrapping past three. Pictures and video open the + // gallery, text opens a text tab when the panel offers one, anything else opens in a new tab. +
    + + {[...imageRefs, ...attachmentRefs].map((ref) => { + const subject = subjectFromAttachmentRef(ref); + const picture = isImageAttachment(ref) || isVideoLikeOutput(ref.contentType, ref.name); + const textTab = Boolean(openText && ref.id && isTextAttachment({ contentType: ref.contentType ?? "", originalFilename: ref.name })); + if (picture) return openImage(ref.url)} style={inlineRowStyle} />; + // A text file keeps its link (modifier clicks, copying the address) while a plain click opens the text tab. + if (textTab) return openText?.(ref.id!, ref.name)} style={inlineRowStyle} />; + return ; + })} + +
    + ) : null} {lightboxSrc !== null && lightboxIndex >= 0 ? ( ({ useArtifactsV1Enabled: () => ({ enabled: true, loaded: true }) })); + +const ATTACHMENT_ID = "00000000-0000-4000-8000-000000000001"; + +const deckPdf = { + id: "work-product-1", + companyId: "company-1", + projectId: null, + issueId: "issue-1", + executionWorkspaceId: null, + runtimeServiceId: null, + type: "artifact", + provider: "paperclip", + externalId: null, + title: "Interface Craft revision — PDF", + url: null, + status: "ready_for_review", + reviewState: "none", + isPrimary: false, + healthStatus: "unknown", + summary: null, + metadata: { + attachmentId: ATTACHMENT_ID, + contentType: "application/pdf", + byteSize: 59085, + contentPath: `/api/attachments/${ATTACHMENT_ID}/content`, + openPath: `/api/attachments/${ATTACHMENT_ID}/content?inline=1`, + downloadPath: `/api/attachments/${ATTACHMENT_ID}/content?download=1`, + originalFilename: "acmehats-interface-craft.pdf", + }, + createdByRunId: null, + createdAt: new Date("2026-09-28T11:07:00Z"), + updatedAt: new Date("2026-09-28T11:07:00Z"), +} as unknown as IssueWorkProduct; + +describe("TaskChatProtocolCard resources with Artifacts V1 on", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + flushSync(() => root.unmount()); + container.remove(); + }); + + const render = (item: TaskChatProtocolItem) => + flushSync(() => + root.render( + + + + + + + , + ), + ); + + it("renders a registered file as one row named after the file, with its size and state, opening where the card did", () => { + render({ id: "resource:deliverable:work-product-1", kind: "protocol", surface: "resource", resourceKind: "deliverable", title: deckPdf.title, subtitle: "file · ready for review", href: `/api/attachments/${ATTACHMENT_ID}/content?inline=1`, workProduct: deckPdf }); + const row = container.querySelector('[data-testid="task-chat-resource-deliverable"]'); + expect(row).not.toBeNull(); + expect(row?.textContent).toContain("acmehats-interface-craft.pdf"); + expect(row?.textContent).toContain("57.7 KB"); + expect(row?.textContent).toContain("PDF"); + const open = row?.querySelector('a[data-testid="task-chat-resource-deliverable-open"]'); + expect(open?.getAttribute("href")).toBe(`/api/attachments/${ATTACHMENT_ID}/content?inline=1`); + expect(open?.getAttribute("target")).toBe("_blank"); + expect(row?.querySelector('a[aria-label="Download acmehats-interface-craft.pdf"]')?.getAttribute("href")).toContain("download=1"); + expect(container.querySelector('[data-testid="task-chat-rich-work-product-artifact"]')).toBeNull(); + }); + + it("renders a pull request as a link row that opens in a new tab with its state", () => { + const pr = { ...deckPdf, id: "wp-pr", type: "pull_request", provider: "github", title: "Ship rich cards", url: "https://github.com/paperclipai/paperclip/pull/42", status: "merged", metadata: { number: 42 } } as unknown as IssueWorkProduct; + render({ id: "resource:deliverable:wp-pr", kind: "protocol", surface: "resource", resourceKind: "deliverable", title: pr.title, subtitle: "pull request · merged", href: pr.url, workProduct: pr }); + const open = container.querySelector('a[data-testid="task-chat-resource-deliverable-open"]'); + expect(open?.getAttribute("href")).toBe("https://github.com/paperclipai/paperclip/pull/42"); + expect(open?.getAttribute("target")).toBe("_blank"); + }); + + it("renders a task document as a row named after its key that stays in the app", () => { + render({ + id: "resource:document:plan", + kind: "protocol", + surface: "resource", + resourceKind: "document", + title: "Launch plan", + subtitle: "Document · rev 1", + href: "#document-plan", + document: { id: "doc-plan", issueId: "issue-1", key: "plan", title: "Launch plan", format: "markdown", latestRevisionNumber: 1, createdByAgentId: null, updatedByAgentId: null, createdAt: new Date("2026-09-27T08:36:00Z"), updatedAt: new Date("2026-09-27T08:36:00Z") } as never, + }); + const row = container.querySelector('[data-testid="task-chat-resource-document"]'); + expect(row?.textContent).toContain("plan.md"); + expect(row?.textContent).toContain("DOC"); + const link = row?.querySelector('a[data-testid="task-chat-resource-document-open"]'); + expect(link?.getAttribute("href")).toBe("#document-plan"); + expect(link?.getAttribute("target")).toBeNull(); + }); +}); diff --git a/ui/src/components/task-chat/TaskChatProtocolCard.tsx b/ui/src/components/task-chat/TaskChatProtocolCard.tsx index 22408fb2277..62b4358eecf 100644 --- a/ui/src/components/task-chat/TaskChatProtocolCard.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolCard.tsx @@ -47,6 +47,8 @@ import type { import { QuestionForm, QuestionResponseSummary } from "./QuestionForm"; import { TaskChatComposerTakeoverHeader } from "./TaskChatComposerTakeoverContext"; import { RichWorkProductCard } from "./RichWorkProductCard"; +import { useArtifactsV1Enabled } from "@/hooks/useArtifactsV1Enabled"; +import { ArtifactFileRow, ArtifactRow, fileRowSourceFromAttachment, fileRowSourceFromWorkProduct, subjectFromAttachment, subjectFromDocumentSummary, subjectFromWorkProduct, workProductStateBadge } from "@/components/artifacts/v1"; export interface TaskChatProtocolCardProps { item: TaskChatProtocolItem; @@ -1018,6 +1020,30 @@ function ResourceCard({ }: { item: Extract; }) { + const { enabled: artifactsV1 } = useArtifactsV1Enabled(); + if (artifactsV1) { + // Artifacts V1 (doc/plans/2026-10-22-artifacts-mvp-v1.md): one full-width row named after the file. A + // registered file keeps the shipped card's actions (gallery, text tab, open raw, download, CSV preview); + // any other deliverable is a link that opens in a new tab with its state — "Unhealthy" first — beside it. + const href = item.href ?? undefined; + const testId = `task-chat-resource-${item.resourceKind}`; + if (item.resourceKind === "deliverable" && item.workProduct) { + const workProduct = item.workProduct; + const statusBadge = workProductStateBadge(workProduct); + const file = fileRowSourceFromWorkProduct(workProduct); + if (file) { + return ; + } + return ; + } + if (item.resourceKind === "attachment" && item.attachment) { + return ; + } + const subject = item.document + ? subjectFromDocumentSummary(item.document) + : { kind: item.resourceKind === "document" ? ("document" as const) : ("file" as const), title: item.title }; + return ; + } if (item.resourceKind === "deliverable" && item.workProduct) { return ; } diff --git a/ui/src/hooks/useArtifactsV1Enabled.test.ts b/ui/src/hooks/useArtifactsV1Enabled.test.ts new file mode 100644 index 00000000000..8c18307f129 --- /dev/null +++ b/ui/src/hooks/useArtifactsV1Enabled.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { resolveArtifactsV1Enabled } from "./useArtifactsV1Enabled"; + +describe("resolveArtifactsV1Enabled", () => { + it("is off until an instance opts in", () => { + expect(resolveArtifactsV1Enabled(undefined)).toBe(false); + expect(resolveArtifactsV1Enabled(null)).toBe(false); + expect(resolveArtifactsV1Enabled({})).toBe(false); + expect(resolveArtifactsV1Enabled({ enableArtifactsV1: false })).toBe(false); + }); + + it("is on when the setting is", () => { + expect(resolveArtifactsV1Enabled({ enableArtifactsV1: true })).toBe(true); + }); +}); diff --git a/ui/src/hooks/useArtifactsV1Enabled.ts b/ui/src/hooks/useArtifactsV1Enabled.ts new file mode 100644 index 00000000000..96490ebd5e7 --- /dev/null +++ b/ui/src/hooks/useArtifactsV1Enabled.ts @@ -0,0 +1,41 @@ +import { useContext } from "react"; +import type { InstanceExperimentalSettings } from "@paperclipai/shared"; +import { QueryClient, QueryClientContext, useQuery } from "@tanstack/react-query"; +import { instanceSettingsApi } from "@/api/instanceSettings"; +import { queryKeys } from "@/lib/queryKeys"; + +export function resolveArtifactsV1Enabled(settings: Partial> | null | undefined): boolean { + return settings?.enableArtifactsV1 === true; +} + +let detachedClient: QueryClient | null = null; +function getDetachedClient(): QueryClient { + detachedClient ??= new QueryClient(); + return detachedClient; +} + +/** + * Artifacts V1 (Settings > Experimental): the artifact directions lab's + * presentation on the Artifacts page, the panel's Artifacts tab and the task + * thread. Off by default — and off when rendered outside a query client, as + * the app root is in some harnesses — so every surface renders as before + * until an instance opts in. See doc/plans/2026-10-22-artifacts-mvp-v1.md. + */ +export function useArtifactsV1Enabled(): { enabled: boolean; loaded: boolean } { + const contextClient = useContext(QueryClientContext); + const query = useQuery( + { + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + enabled: contextClient != null, + }, + contextClient ?? getDetachedClient(), + ); + + if (!contextClient) return { enabled: false, loaded: true }; + + return { + enabled: resolveArtifactsV1Enabled(query.data), + loaded: query.isFetched, + }; +} diff --git a/ui/src/index.css b/ui/src/index.css index 97f725ddb74..a094db7de0c 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -492,6 +492,43 @@ --side-panel-streamlined-tab-hover-bg: oklch(0.9675 0 0); --side-panel-streamlined-tab-active-bg: oklch(0.9581 0 0); --radius-task-composer: 16px; + /* Artifacts V1 (doc/plans/2026-10-22-artifacts-mvp-v1.md): the artifact + directions lab's measurements, from the PCLP-Core mocks. Light values + here; .dark overrides the fills and shadows. */ + --artifact-font-mono: var(--font-paper-mono); + --artifact-paper: #fafafa; /* the sheet and the print's border, both themes */ + --artifact-paper-ink: #262626; /* a preview sheet's title */ + --artifact-ink: #333333; /* an icon sheet's type label, the play disc */ + --artifact-ink-faint: #d2d2d2; /* 20% ink over the paper, kept opaque so stroked glyphs do not darken where they overlap */ + --artifact-sheet-radius: 12px; + --artifact-sheet-stroke: rgba(26, 26, 26, 0.1); /* 1px inner stroke on sheets and backings */ + --artifact-sheet-inset-stroke: inset 0 0 0 1px var(--artifact-sheet-stroke); + --artifact-print-radius: 6px; + --artifact-shadow-sheet: drop-shadow(0 3.406px 10px rgba(26, 26, 26, 0.0924)); + --artifact-shadow-print: 0 3.406px 20px rgba(26, 26, 26, 0.0924); + --artifact-sheet-title-size: 15.248px; + --artifact-sheet-title-leading: 18px; + --artifact-sheet-title-tracking: -0.6172px; + --artifact-icon-label-size: 24px; + --artifact-icon-label-leading: 54px; + --artifact-grid-min: 330px; + --artifact-grid: repeat(auto-fill, minmax(min(100%, var(--artifact-grid-min)), 1fr)); /* one column still fits a 320px phone */ + --artifact-frame-h: 254px; + --artifact-tile-h: 370px; + --artifact-tile-radius: 12px; + --artifact-tile-hover: var(--accent); + --artifact-pill: var(--muted); + --artifact-pill-hover: var(--border); /* the light hover fill is --muted's value, so the pill steps down while hovered */ + --artifact-pill-ink: var(--foreground); + --artifact-row: var(--muted); + --artifact-row-hover: #eeeeee; + --artifact-row-radius: 6.4px; + --artifact-row-gap: 12px; + --artifact-row-ink: #fafafa; /* the type label and glyph on the row's document icon */ + --artifact-row-icon-label-size: 11px; + --artifact-row-icon-label-leading: 15px; + --artifact-play-scrim: rgba(0, 0, 0, 0.55); + --artifact-hover-ease: 300ms cubic-bezier(0.3, 0, 0.1, 1); --tc-interaction-card-max-h: 50dvh; --shadow-task-composer: 0 2px 8px -2px color-mix(in oklab, var(--foreground) 7%, transparent), 0 12px 32px -8px color-mix(in oklab, var(--foreground) 12%, transparent); @@ -554,6 +591,13 @@ .dark { --background: oklch(0.205 0 0); --foreground: oklch(0.985 0 0); + --artifact-shadow-sheet: drop-shadow(0 3.406px 10px rgba(26, 26, 26, 0.18)); + --artifact-shadow-print: 0 3.406px 20px rgba(26, 26, 26, 0.18); + --artifact-tile-hover: #202020; + --artifact-pill: #333333; + --artifact-pill-hover: #333333; + --artifact-row: #262626; + --artifact-row-hover: #2c2c2c; --side-panel-bg: var(--background); --side-panel-fg: oklch(0.985 0 0); --streamlined-task-detail-bg: var(--background); @@ -4725,3 +4769,56 @@ html:has(.design-proposal-theme) [data-canonical-toolbar] .collection-toolbar-ta border-radius: var(--collection-toolbar-radius); border-color: var(--collection-toolbar-border); } + +/* ── Artifacts V1 component rules (ui/src/components/artifacts/v1) ────────── + Sheets, prints, the task pill and the hover movement. Movement only for + people who have not asked for reduced motion; colour fades stay. */ +.artifact-sheet { + width: 180px; + height: 216px; + border-radius: var(--artifact-sheet-radius); + filter: var(--artifact-shadow-sheet); +} +.artifact-sheet::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: var(--artifact-sheet-inset-stroke); + pointer-events: none; +} +.artifact-print { + border-style: solid; + border-color: var(--artifact-paper); + border-radius: var(--artifact-print-radius); + background: var(--artifact-paper); + box-shadow: var(--artifact-shadow-print); +} +.artifact-backing { + filter: var(--artifact-shadow-sheet); +} +.artifact-pill { + font-family: var(--artifact-font-mono); + background: var(--artifact-pill); + color: var(--artifact-pill-ink); +} +.artifact-tile:is(:hover, :focus-visible) .artifact-pill { + background: var(--artifact-pill-hover); +} +[data-artifact-rotate] { + transform: rotate(var(--artifact-base, 0deg)); +} +@media (prefers-reduced-motion: no-preference) { + .artifact-tile :is([data-artifact-rotate][data-artifact-spread], [data-artifact-front]) { + transition: transform var(--artifact-hover-ease); + } + .artifact-tile:hover [data-artifact-front] { + transform: translateY(-6px); + } + .artifact-tile:hover [data-artifact-spread="left"] { + transform: rotate(calc(var(--artifact-base, 0deg) + 5deg)); + } + .artifact-tile:hover [data-artifact-spread="right"] { + transform: rotate(calc(var(--artifact-base, 0deg) - 5deg)); + } +} diff --git a/ui/src/pages/Artifacts.test.tsx b/ui/src/pages/Artifacts.test.tsx index 73f27e3a976..cc2b7707e08 100644 --- a/ui/src/pages/Artifacts.test.tsx +++ b/ui/src/pages/Artifacts.test.tsx @@ -20,6 +20,12 @@ const artifactsApiMock = vi.hoisted(() => ({ list: vi.fn(), })); +const artifactsV1Flag = vi.hoisted(() => ({ enabled: false, loaded: true })); + +vi.mock("@/hooks/useArtifactsV1Enabled", () => ({ + useArtifactsV1Enabled: () => artifactsV1Flag, +})); + vi.mock("../context/CompanyContext", () => ({ useCompany: () => companyState, })); @@ -161,6 +167,7 @@ describe("Artifacts page", () => { document.body.appendChild(container); breadcrumbState.setBreadcrumbs.mockReset(); artifactsApiMock.list.mockReset(); + artifactsV1Flag.enabled = false; latestObserverCallback = null; originalIntersectionObserver = window.IntersectionObserver; window.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver; @@ -427,3 +434,105 @@ describe("Artifacts page", () => { }); }); }); + +describe("Artifacts page — Artifacts V1 (Settings > Experimental)", () => { + let container: HTMLDivElement; + let originalIntersectionObserver: typeof IntersectionObserver | undefined; + + const radio = (label: string) => Array.from(container.querySelectorAll('[role="radio"]')).find((el) => el.textContent?.trim() === label) ?? null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + breadcrumbState.setBreadcrumbs.mockReset(); + artifactsApiMock.list.mockReset(); + artifactsV1Flag.enabled = true; + latestObserverCallback = null; + originalIntersectionObserver = window.IntersectionObserver; + window.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver; + }); + + afterEach(() => { + window.IntersectionObserver = originalIntersectionObserver as typeof IntersectionObserver; + artifactsV1Flag.enabled = false; + container.remove(); + }); + + it("shows the Tasks view by default: a stack tile per task with the identifier pill, count and link to the stack", async () => { + artifactsApiMock.list.mockResolvedValue({ artifacts: [], groups: [sampleGroup()], nextCursor: null }); + const { root } = renderArtifacts(container); + await waitForAssertion(() => { + expect(container.querySelector('[data-testid="artifact-v1-stack-tile"]')).not.toBeNull(); + }); + const tile = container.querySelector('[data-testid="artifact-v1-stack-tile"]'); + expect(tile?.getAttribute("href")).toContain("groupIssueId=issue-1"); + expect(tile?.querySelector("[data-artifact-pill]")?.textContent).toBe("PAP-42"); + expect(tile?.textContent).toContain("3 Artifacts"); + expect(radio("Tasks")?.getAttribute("aria-checked")).toBe("true"); + expect(container.querySelector('[data-testid="artifact-group-card"]')).toBeNull(); + expect(artifactsApiMock.list).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ groupBy: "task", kind: "all" })); + flushSync(() => root.unmount()); + }); + + it("shows a task with one artifact as a single tile that opens the artifact", async () => { + artifactsApiMock.list.mockResolvedValue({ artifacts: [], groups: [sampleGroup({ count: 1 })], nextCursor: null }); + const { root } = renderArtifacts(container); + await waitForAssertion(() => { + expect(container.querySelector('[data-testid="artifact-v1-single-tile"]')).not.toBeNull(); + }); + expect(container.querySelector('[data-testid="artifact-v1-single-tile"]')?.getAttribute("href")).toBe("/PAP/issues/PAP-42#document-brief"); + expect(container.querySelector('[data-testid="artifact-v1-stack-tile"]')).toBeNull(); + flushSync(() => root.unmount()); + }); + + it("switches to a flat view by kind from the view tabs", async () => { + artifactsApiMock.list + .mockResolvedValueOnce({ artifacts: [], groups: [sampleGroup()], nextCursor: null }) + .mockResolvedValueOnce({ artifacts: [sampleArtifact({ id: "img-1", source: "work_product", mediaKind: "image", contentType: "image/png", contentPath: "/files/hero.png", title: "Hero" })], nextCursor: null }); + const { root } = renderArtifacts(container); + await waitForAssertion(() => { + expect(radio("Images")).not.toBeNull(); + }); + flushSync(() => radio("Images")!.click()); + await waitForAssertion(() => { + expect(artifactsApiMock.list).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ groupBy: "none", kind: "image" })); + expect(container.querySelector('[data-testid="artifact-v1-single-tile"]')).not.toBeNull(); + }); + expect(radio("Images")?.getAttribute("aria-checked")).toBe("true"); + expect(container.querySelector("[data-artifact-print]")).not.toBeNull(); + flushSync(() => root.unmount()); + }); + + it("never hides an active kind under the Tasks view: a kind in the URL is a flat view, and grouping clears it", async () => { + artifactsApiMock.list.mockResolvedValue({ artifacts: [sampleArtifact({ id: "img-1", source: "work_product", mediaKind: "image", contentType: "image/png", contentPath: "/files/hero.png" })], nextCursor: null }); + const { root } = renderArtifacts(container, ["/artifacts?kind=image&groupBy=task"]); + await waitForAssertion(() => { + expect(artifactsApiMock.list).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ groupBy: "none", kind: "image" })); + }); + expect(radio("Images")?.getAttribute("aria-checked")).toBe("true"); + expect(radio("Tasks")?.getAttribute("aria-checked")).toBe("false"); + artifactsApiMock.list.mockResolvedValue({ artifacts: [], groups: [sampleGroup()], nextCursor: null }); + flushSync(() => container.querySelector('[data-testid="artifact-group-option-task"]')!.click()); + await waitForAssertion(() => { + expect(artifactsApiMock.list).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ groupBy: "task", kind: "all" })); + }); + expect(radio("Tasks")?.getAttribute("aria-checked")).toBe("true"); + flushSync(() => root.unmount()); + }); + + it("renders an open stack as single tiles behind the All stacks row", async () => { + artifactsApiMock.list.mockResolvedValue({ + artifacts: [sampleArtifact(), sampleArtifact({ id: "artifact-2", title: "Deck", href: "/PAP/issues/PAP-42#deck" })], + groups: [], + selectedGroup: sampleGroup(), + nextCursor: null, + }); + const { root } = renderArtifacts(container, ["/artifacts?groupIssueId=issue-1"]); + await waitForAssertion(() => { + expect(container.querySelectorAll('[data-testid="artifact-v1-single-tile"]')).toHaveLength(2); + }); + expect(container.querySelector('[data-testid="artifact-stack-back"]')).not.toBeNull(); + expect(radio("Tasks")?.getAttribute("aria-checked")).toBe("true"); + flushSync(() => root.unmount()); + }); +}); diff --git a/ui/src/pages/Artifacts.tsx b/ui/src/pages/Artifacts.tsx index c1dd952f02f..2707cfcacdd 100644 --- a/ui/src/pages/Artifacts.tsx +++ b/ui/src/pages/Artifacts.tsx @@ -16,6 +16,8 @@ import { ArtifactCard } from "../components/artifacts/ArtifactCard"; import { ArtifactGroupCard } from "../components/artifacts/ArtifactGroupCard"; import { useSearchParams, Link } from "@/lib/router"; import { ArtifactsToolbar, ARTIFACT_KIND_FILTERS } from "@/components/artifacts/ArtifactsToolbar"; +import { ArtifactSingleTile, ArtifactStackTile, ArtifactTileGrid, ArtifactsV1Filters, artifactsV1View, subjectFromCompanyArtifact, type ArtifactsV1View } from "@/components/artifacts/v1"; +import { useArtifactsV1Enabled } from "@/hooks/useArtifactsV1Enabled"; export { ARTIFACT_KIND_FILTERS, ARTIFACT_GROUP_OPTIONS, artifactGroupByLabel } from "@/components/artifacts/ArtifactsToolbar"; const ARTIFACTS_PAGE_SIZE = 30; @@ -38,10 +40,13 @@ export function Artifacts() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const [searchParams, setSearchParams] = useSearchParams(); - + const { enabled: v1 } = useArtifactsV1Enabled(); const kind = parseKind(searchParams.get("kind")); const query = searchParams.get("q") ?? ""; - const groupBy = parseGroupBy(searchParams.get("groupBy")); + const urlGroupBy = parseGroupBy(searchParams.get("groupBy")); + // Artifacts V1 has one row of views: a kind is always a flat view, so a kind in the URL + // overrides a grouping there — the view row then says what the page shows. + const groupBy: ArtifactGroupBy = v1 && kind !== "all" ? "none" : urlGroupBy; const groupIssueId = searchParams.get("groupIssueId") ?? undefined; const [draftQuery, setDraftQuery] = useState(query); @@ -97,6 +102,25 @@ export function Artifacts() { [updateParams], ); + // Artifacts V1: one row of views. Tasks is the task grouping; every other + // view is the flat list, filtered by kind. Either way the open stack closes. + const selectV1View = useCallback( + (view: ArtifactsV1View) => { + updateParams((next) => { + next.delete("groupIssueId"); + if (view === "tasks") { + next.delete("groupBy"); + next.delete("kind"); + return; + } + next.set("groupBy", "none"); + if (view === "all") next.delete("kind"); + else next.set("kind", view); + }); + }, + [updateParams], + ); + const selectGroupBy = useCallback( (value: ArtifactGroupBy) => { updateParams((next) => { @@ -104,9 +128,11 @@ export function Artifacts() { next.delete("groupIssueId"); if (value === "task") next.delete("groupBy"); else next.set("groupBy", value); + // Artifacts V1: a grouping is the Tasks view, which carries no kind filter. + if (v1 && value !== "none") next.delete("kind"); }); }, - [updateParams], + [updateParams, v1], ); // Build a relative `To` that preserves the active filters/search while @@ -220,7 +246,15 @@ export function Artifacts() { return (
    - + : undefined} + /> {viewingSelectedStack ? (
    @@ -247,6 +281,37 @@ export function Artifacts() { ) : items.length === 0 ? ( + ) : v1 ? ( + <> + {/* Artifacts V1 (doc/plans/2026-10-22-artifacts-mvp-v1.md): a task with several artifacts is a stack + whose newest artifact is the first page; a task with one, and every flat view, is a single tile. */} + + {showGroupCards + ? groups.map((group) => { + const preview = group.previewArtifacts[0]; + if (group.count > 1 || !preview) { + return ( + + ); + } + return ; + }) + : artifacts.map((artifact) => ( + + ))} + +
    + {isFetchingNextPage ? "Loading more artifacts..." : hasNextPage ? null : isFetching ? "Updating artifacts..." : null} +
    + ) : ( <>
    diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 073d1a85b47..66b2242587e 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -96,6 +96,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { enableCases: false, enableAgentChat: false, enableCombinedInboxTasks: false, + enableArtifactsV1: false, enableConferenceRoomChat: false, enableClassicTaskInterface: false, enableIssuePlanDecompositions: false, diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 6b0270986c5..1f61e25e8e3 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -306,6 +306,18 @@ export function InstanceExperimentalSettings() { ariaLabel="Toggle agent chat experimental setting" /> + toggleMutation.mutate({ enableArtifactsV1: checked })} + disabled={toggleMutation.isPending} + settingKey="enableArtifactsV1" + managed={managedKeys.enableArtifactsV1} + ariaLabel="Toggle artifacts V1 experimental setting" + /> + Experimental > Artifacts V1) on + * fixtures: the page's stack and single tiles, and the row the panel and the + * thread share. Pictures are the artifact directions lab's drawings. + */ + +const NOW = new Date("2026-09-28T18:00:00"); + +const subject = (overrides: Partial & Pick): ArtifactSubject => ({ + updatedAt: "2026-09-28T11:07:00", + taskIdentifier: "ACM-3", + ...overrides, +}); + +const deckPdf = subject({ kind: "pdf", title: "Interface Craft revision — PDF", filename: "acmehats-interface-craft.pdf", contentType: "application/pdf", sizeLabel: "57.7 KB" }); +const deckPptx = subject({ kind: "slides", title: "Interface Craft revision — editable PowerPoint", filename: "acmehats-interface-craft.pptx", sizeLabel: "90.1 KB" }); +const guideMd = subject({ kind: "document", title: "Interface Craft deck guide", filename: "deck-guide.md", contentType: "text/markdown", sizeLabel: "5.9 KB" }); +const critique = subject({ kind: "document", title: "Interface Craft critique and implemented changes", hasText: true, isTaskDocument: true }); +const sheetPng = subject({ kind: "image", title: "Slide overview", filename: "contact-sheet.png", contentType: "image/png", thumbnailUrl: contactSheet, aspect: 4 / 3, sizeLabel: "422.9 KB" }); +const forest = subject({ kind: "image", title: "Forest green — studio hero", filename: "cap-forest-hero.jpg", contentType: "image/jpeg", thumbnailUrl: capForest, aspect: 3 / 2, sizeLabel: "1.2 MB", taskIdentifier: "ACM-5", updatedAt: "2026-09-27T15:12:00" }); +const black = subject({ kind: "image", title: "Black and cream — stacked, portrait", filename: "cap-black-portrait.jpg", contentType: "image/jpeg", thumbnailUrl: capBlack, aspect: 4 / 5, sizeLabel: "1.0 MB", taskIdentifier: "ACM-5", updatedAt: "2026-09-27T14:58:00" }); +const reel = subject({ kind: "video", title: "Signal orange — 360° turntable", filename: "signal-orange-360.webm", contentType: "video/webm", thumbnailUrl: reelPoster, aspect: 16 / 9, sizeLabel: "3.4 MB", taskIdentifier: "ACM-5", updatedAt: "2026-09-27T14:44:00" }); +const sting = subject({ kind: "file", title: "Brand sting — 12 second loop", filename: "acmehats-sting.mp3", contentType: "audio/mpeg", sizeLabel: "403.0 KB", taskIdentifier: "ACM-5" }); +const zip = subject({ kind: "archive", title: "Editing source", filename: "editing-source.zip", contentType: "application/zip", sizeLabel: "184.6 KB" }); +const storefront = subject({ kind: "link", title: "Storefront preview", taskIdentifier: "ACM-1", updatedAt: "2026-09-27T09:00:00" }); + +function Stage({ width, children }: { width: number; children: ReactNode }) { + return ( +
    +
    {children}
    +
    + ); +} + +const meta = { + id: "components-artifact-v1-primitives", + title: "Tasks and conversations/Documents, attachments, and work products/Components/Artifact V1 primitives", + parameters: { + layout: "fullscreen", + docs: { + description: { + component: + "The Artifacts V1 primitives behind Settings > Experimental > Artifacts V1: stack and single tiles for the Artifacts page, and the row the panel's Artifacts tab and the task thread share. Fixtures only.", + }, + }, + paperclip: { + capabilities: ["documents-artifacts/library", "documents-artifacts/work-products", "documents-artifacts/attachments"], + status: "proposed", + implementation: "production", + boundary: "fixtures", + notes: "Mounts the production ArtifactStackTile, ArtifactSingleTile, ArtifactRow and ArtifactRowGroup from ui/src/components/artifacts/v1 on fixture subjects with the lab's drawings as pictures. Hover a tile to see the page fan and the artwork rise (not under reduced motion). No API requests; links go nowhere.", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Stacks: Story = { + name: "Stack tiles", + render: () => ( + + + + + + + + + + + ), +}; + +export const Singles: Story = { + name: "Single tiles", + render: () => ( + + + + + + + + + + + + + ), +}; + +export const Rows: Story = { + name: "Rows (panel)", + render: () => ( + +
      + {[zip, deckPptx, guideMd, critique, sheetPng, deckPdf, reel, sting, storefront].map((item) => ( +
    • + +
    • + ))} +
    +
    + ), +}; + +export const GroupedRows: Story = { + name: "Rows grouped inline (thread)", + render: () => ( + + + {[sheetPng, deckPptx, deckPdf, guideMd, zip].map((item) => ( + + ))} + + + ), +};