feat(ui): Artifacts V1 behind an experimental setting — Artifacts page, panel tab and task thread (#17228)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents produce artifacts: documents, files, pictures, video and links. The app shows them on the Artifacts page, in the right panel's Artifacts tab and in the task thread > - The artifact directions lab (#17213) settled a presentation for those surfaces, MVP V1, drawn from the PCLP-Core mocks. It is a reference, not product code > - Shipping it means product components on product data, token-only, behind a switch, one surface at a time, so the current presentation stays the default until the new one has been used > - This pull request adds the switch (Settings > Experimental > Artifacts V1), the `--artifact-*` tokens, the V1 primitives as product components, and the V1 presentation on all three surfaces behind the switch: the Artifacts page, the panel's Artifacts tab and the task thread > - The benefit is the redesign in the product, reversible by one setting, with every existing behaviour of those surfaces kept ## Linked Issues or Issue Description No public issue exists. This section follows the feature request template; the plan is `doc/plans/2026-10-22-artifacts-mvp-v1.md`. **Subsystem affected** The web UI (`ui/`): the Artifacts page, the right panel's Artifacts tab and the task thread. The experimental settings contract in `packages/shared` and `server` gains one boolean. **Problem or motivation** The three artifact surfaces grew separately and their artifact presentation is dated and inconsistent. The lab produced a measured direction; nothing in the product uses it yet. **Proposed solution** An experimental setting, `enableArtifactsV1`, default off, with the same plumbing as the other experimental settings. Product primitives under `ui/src/components/artifacts/v1` (kind helpers and adapters, the sheet and print artwork, the stack, the page tiles with the Paper Mono pill, the row the panel and thread will share), driven by `--artifact-*` tokens in `index.css` with light and dark values. With the setting on: the Artifacts page shows one row of views (Tasks, All files, Images, Videos, Documents, Text, Files) and the V1 tiles, a task with several artifacts as a stack whose newest artifact is the first page, everything else as single tiles; the panel's Artifacts tab is one flat list of V1 rows, newest first, with the expandable rows (Markdown review surface, document annotations, deep links) keeping their behaviour behind the row as their face; the thread shows a message's files as rows under its footer and registered deliverables and documents as one row each. The hover movement respects reduced motion. **Alternatives considered** Replacing the current cards outright: no fallback and three surfaces to land at once. Mounting the lab's components: they are typed on lab fixtures and keep lab-local tokens, which the token gates reject. The primitives were ported instead and the lab stays a reference. **Roadmap alignment** Supports the "Artifacts & Work Products" theme in `ROADMAP.md`. Presentation only; no data model change. **Additional context** No new API fields: the tiles show title, identifier, count and "Updated …"; rows take sizes from the work-product and attachment payloads; a picture's aspect ratio comes from the loaded image, 4:3 until it loads. The plan records what each surface keeps and what it drops (run sections in the panel; the "Images · N / Files · N" labels and the four-up grid in the thread). ## What Changed - Add the `enableArtifactsV1` experimental setting: shared type, validator and feature catalog, server defaults and managed keys, the `useArtifactsV1Enabled` hook, and the Settings > Experimental toggle (in its alphabetical place). - Add the `--artifact-*` tokens and the Artifacts V1 component rules to `ui/src/index.css`: light values on `:root`, dark overrides, and the hover movement under `prefers-reduced-motion: no-preference`. - Add `ui/src/components/artifacts/v1/`: `kinds.ts` (kind treatment, labels, glyphs, the server's media-kind rule, adapters from `CompanyArtifact`), `geometry.ts` (the measured constants), `when.ts`, `ArtifactSheet`, `ArtifactPrint`, `ArtifactStack`, `ArtifactFront`, `ArtifactTile` (stack and single tiles, caption, grid), `ArtifactRow` (the row and its inline group), `ArtifactsV1Filters`, with the mock's SVG assets. - Wire the Artifacts page: with the setting on, the toolbar's filter row becomes the V1 views and the grid renders V1 tiles; the toolbar takes an optional `filters` row for this. The old cards and kind filters stay the default. - Wire the panel's Artifacts tab (`IssuePropertiesArtifactsTab`): with the setting on, one flat list of V1 rows, newest first; `DocumentRow` and `MarkdownWorkProductRow` take a `face` render prop so the V1 row fronts their unchanged expansion, review and deep-link behaviour; `PanelFileRow` opens attachments and attachment-backed work products the way the shipped card does (gallery, text tab, new tab) with open and download beside the name; other work products are a link row with their status. - Wire the task thread: `TaskChatBubble` hangs a message's files under its footer as V1 rows inline (pictures and video to the gallery, text to a text tab, files to a new tab) in place of the media grid and file chips; `TaskChatProtocolCard`'s resource rows render deliverables, documents and attachments as one V1 row each, opening where the card did. - `ArtifactRow` keeps trailing controls beside its own control (no nested interactive elements) and can be a link, an in-app anchor, a new-tab anchor or a button; adapters read work products, documents, attachments and message references. - Add a catalogued Storybook story, `Components/Artifact V1 primitives`, under `documents-artifacts`, on fixtures. - Add tests: the primitives (kinds, dates, print fitting, tiles, rows), the hook, the Artifacts page, the panel's Artifacts tab, the task-chat bubble and the protocol card, each with the setting on and off; the settings page and server tests list the new key. - Add `doc/plans/2026-10-22-artifacts-mvp-v1.md`. ## Verification - Run `cd ui && pnpm typecheck`: the Storybook catalog accepts the new story, and `tsc -b` is clean apart from the five pre-existing `PluginOrganizationSwitcher` errors. - Run `pnpm check:token-gates` and `node scripts/check-maps.mjs`: clean. - Run `cd ui && npx vitest run src/components/artifacts src/components/issue-properties src/components/task-chat/TaskChatBubble.test.tsx src/components/task-chat/TaskChatBubble.artifactsV1.test.tsx src/components/task-chat/TaskChatProtocolCard.test.tsx src/components/task-chat/TaskChatProtocolCard.artifactsV1.test.tsx src/pages/Artifacts.test.tsx src/hooks/useArtifactsV1Enabled.test.ts src/pages/InstanceExperimentalSettings.test.tsx`: the suites pass (151 tests in the last run). - Run `cd packages/shared && npx vitest run src/feature-catalog.test.ts` and `cd server && npx vitest run src/__tests__/instance-settings-service.test.ts`: pass. - Manual: open Settings > Experimental, turn on Artifacts V1. On the Artifacts page the view row reads Tasks, All files, Images, Videos, Documents, Text, Files; tasks with several artifacts show as stacks, hovering fans the pages and lifts the artwork (not under reduced motion); Images shows single tiles with bordered prints. Open a task's Artifacts tab: one list of rows, newest first; a Markdown work product still expands into its review surface, a document still opens in its tab. In the thread, a message's files sit under its footer as rows. Turn the setting off: everything returns to the current presentation. - Storybook: `Tasks and conversations / Documents, attachments, and work products / Components / Artifact V1 primitives`. - Not run: the full `pnpm test:run` and `pnpm build` (CI runs them), and a browser pass against a dev instance with real artifacts on the three surfaces; that pass is the remaining step before this leaves draft. ## Risks - Off by default; with the setting off, the only changes on a product path are an optional `filters` prop on the toolbar, an optional `face` prop on two panel rows, a read of the setting in the bubble and the protocol card, and the new token declarations. - With the setting on, the Artifacts page's view tabs set two URL parameters (`groupBy`, `kind`) at once; the group-by menu and search are unchanged. - A picture with unknown dimensions renders at 4:3 until it loads, then the print refits once; the frame's height is fixed so nothing below it moves. - About thirty new `--artifact-*` tokens, namespaced and documented in place. ## Model Used - Claude Fable 5.1 by Anthropic, model ID `claude-fable-5-1`, used through Claude Code in the desktop app. Extended reasoning was on. Capabilities used: tool use (shell, file edits, a headless Playwright browser for the lab's measurements, the Figma MCP server for the mocks during the lab work) and image input. The context window is the Claude Code session's; its exact size is not surfaced to the session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above (#17213 is the lab this builds on) - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (the suites listed above; CI runs the full set) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (the plan document; no command or product behaviour changes outside the setting) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
No files matched your search
@@ -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-<id>` 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.
|
||||
@@ -199,6 +199,14 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableArtifactsV1: {
|
||||
title: "Artifacts V1",
|
||||
description:
|
||||
"The artifact presentation from the artifact directions lab: paper stacks and tiles on the Artifacts page, compact rows in the panel's Artifacts tab, and grouped rows in the task thread.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableConferenceRoomChat: {
|
||||
title: "Conference Room Chat",
|
||||
description:
|
||||
|
||||
@@ -89,6 +89,8 @@ export interface InstanceExperimentalSettings {
|
||||
enableAgentChat: boolean;
|
||||
/** Reorganized left nav, Inbox-as-Tasks-views, and the chat agent rail + task cards. */
|
||||
enableCombinedInboxTasks: boolean;
|
||||
/** The artifact presentation from the artifact directions lab (MVP V1) on the Artifacts page, the panel's Artifacts tab and the task thread. */
|
||||
enableArtifactsV1: boolean;
|
||||
enableConferenceRoomChat: boolean;
|
||||
enableClassicTaskInterface: boolean;
|
||||
enableIssuePlanDecompositions: boolean;
|
||||
|
||||
@@ -64,6 +64,7 @@ export const instanceExperimentalSettingsSchema = z.object({
|
||||
enableCases: z.boolean().default(false),
|
||||
enableAgentChat: z.boolean().default(false),
|
||||
enableCombinedInboxTasks: z.boolean().default(false),
|
||||
enableArtifactsV1: z.boolean().default(false),
|
||||
enableConferenceRoomChat: z.boolean().default(false),
|
||||
enableClassicTaskInterface: z.boolean().default(false),
|
||||
enableIssuePlanDecompositions: z.boolean().default(false),
|
||||
|
||||
@@ -76,6 +76,7 @@ describe("instance settings service", () => {
|
||||
enableMuse: false,
|
||||
enableGrokBot: false,
|
||||
enableCombinedInboxTasks: false,
|
||||
enableArtifactsV1: false,
|
||||
enableChatConnectors: false,
|
||||
enableGitHubReviewBots: false,
|
||||
enableMemoryConnectors: false,
|
||||
|
||||
@@ -7,6 +7,7 @@ const CLOUD_STAGING_EXPERIMENTAL_KEYS: ReadonlySet<string> = new Set([
|
||||
"enableBetaSkills",
|
||||
"enableBuiltInAgents",
|
||||
"enableCases",
|
||||
"enableArtifactsV1",
|
||||
"enableChatConnectors",
|
||||
"enableClassicTaskInterface",
|
||||
"enableCombinedInboxTasks",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<div data-canonical-toolbar="artifacts" role="toolbar" aria-label="Artifacts controls" className="flex flex-col items-start gap-4">
|
||||
@@ -80,7 +83,7 @@ export function ArtifactsToolbar({ query: draftQuery, onQueryChange: setDraftQue
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<ArtifactKindFilters kind={kind} onKindChange={selectKind} size="standard" />
|
||||
{filters ?? <ArtifactKindFilters kind={kind} onKindChange={selectKind} size="standard" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof import("@/lib/artifact-card-data")>("@/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<Parameters<typeof ArtifactFileRow>[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(<QueryClientProvider client={client}>{ui}</QueryClientProvider>));
|
||||
};
|
||||
|
||||
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(<ArtifactFileRow subject={testSubject({ kind: "sheet", filename: "pricing.csv", contentType: "text/csv", sizeLabel: "2.3 KB" })} source={source()} />);
|
||||
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(
|
||||
<TextAttachmentContext.Provider value={openText}>
|
||||
<ArtifactFileRow subject={testSubject({ kind: "document", filename: "notes.md" })} source={source({ filename: "notes.md", contentType: "text/markdown" })} />
|
||||
</TextAttachmentContext.Provider>,
|
||||
);
|
||||
const link = container.querySelector<HTMLAnchorElement>('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(
|
||||
<IssueGalleryContext.Provider value={openGallery}>
|
||||
<ArtifactFileRow subject={testSubject({ kind: "image", filename: "hero.png", thumbnailUrl: "/api/attachments/x/content" })} source={source({ filename: "hero.png", contentType: "image/png", contentPath: "/api/attachments/x/content" })} />
|
||||
</IssueGalleryContext.Provider>,
|
||||
);
|
||||
act(() => container.querySelector<HTMLButtonElement>('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();
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" disabled={data.isFetching} onClick={() => setCsvRequested(true)}>
|
||||
{data.isFetching ? "Loading preview…" : "Preview data"}
|
||||
</Button>
|
||||
) : null}
|
||||
{openPath ? (
|
||||
<a href={openPath} target="_blank" rel="noreferrer" aria-label={`Open raw ${filename}`} title="Open raw" className={ROW_ACTION_CLASS}>
|
||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||
</a>
|
||||
) : null}
|
||||
{downloadPath ? (
|
||||
<a href={downloadPath} download={filename} aria-label={`Download ${filename}`} title="Download" className={ROW_ACTION_CLASS}>
|
||||
<Download className="h-3 w-3" aria-hidden />
|
||||
</a>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const row = picture ? (
|
||||
<ArtifactRow subject={subject} onClick={() => { if (!openGallery?.(contentPath)) setGalleryOpen(true); }} trailing={trailing} now={now} testId={testId} />
|
||||
) : textTab ? (
|
||||
<ArtifactRow subject={subject} href={openPath || contentPath} external onClick={() => openTextAttachment?.(attachmentId!, filename)} trailing={trailing} now={now} testId={testId} />
|
||||
) : (
|
||||
<ArtifactRow subject={subject} href={openPath || contentPath} external trailing={trailing} now={now} testId={testId} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{row}
|
||||
{picture && galleryOpen ? (
|
||||
<ImageGalleryModal items={[{ id, contentPath, contentType, originalFilename: filename, downloadPath }]} initialIndex={0} open onOpenChange={setGalleryOpen} />
|
||||
) : null}
|
||||
{localCsv && data.data ? (
|
||||
<DataCard title={filename} summary="" author="" updatedAt={updatedAtLabel} filename={filename} {...data.data} downloadUrl={downloadPath} />
|
||||
) : null}
|
||||
{csv && (tooLarge || data.isError) ? (
|
||||
<div className="px-2 text-xs text-muted-foreground" role="status">
|
||||
{tooLarge ? "CSV is too large to preview. Download the file to view it." : data.error?.message}
|
||||
{data.isError ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => void data.refetch()}>
|
||||
Retry preview
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLImageElement | HTMLVideoElement>) => void;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
switch (treatmentOf(subject)) {
|
||||
case "preview":
|
||||
return (
|
||||
<div data-artifact-front className="shrink-0" style={style}>
|
||||
<PreviewSheet subject={subject} />
|
||||
</div>
|
||||
);
|
||||
case "media":
|
||||
return <ArtifactPrint subject={subject} size={print} onMediaLoad={onMediaLoad} style={style} />;
|
||||
case "media-fallback":
|
||||
return (
|
||||
<div data-artifact-front className="shrink-0" style={style}>
|
||||
<IconSheet subject={subject} play />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div data-artifact-front className="shrink-0" style={style}>
|
||||
<IconSheet subject={subject} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLImageElement | HTMLVideoElement>) => void] {
|
||||
const [loaded, setLoaded] = useState<number | null>(null);
|
||||
const onLoad = useCallback((event: SyntheticEvent<HTMLImageElement | HTMLVideoElement>) => {
|
||||
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<HTMLImageElement | HTMLVideoElement>) => void;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
const [ownAspect, onOwnLoad] = useImageAspect(subject.aspect);
|
||||
const print = size ?? fitPrint(ownAspect, SINGLE_BOX);
|
||||
return (
|
||||
<div
|
||||
data-artifact-print
|
||||
className="artifact-print relative shrink-0 overflow-hidden"
|
||||
style={{ width: print.width, height: print.height, borderWidth: print.border, ...style }}
|
||||
>
|
||||
{subject.kind === "video" && !subject.thumbnailUrl ? (
|
||||
// No poster: the print shows the video's own first frame, as the shipped video card does.
|
||||
<video
|
||||
src={subject.videoUrl ?? undefined}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
onLoadedMetadata={(event) => {
|
||||
onOwnLoad(event);
|
||||
onMediaLoad?.(event);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={subject.thumbnailUrl ?? undefined}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
onLoad={(event) => {
|
||||
onOwnLoad(event);
|
||||
onMediaLoad?.(event);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{subject.kind === "video" ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full"
|
||||
style={{ width: PRINT_PLAY.size, height: PRINT_PLAY.size, background: "var(--artifact-paper)", color: "var(--artifact-ink)", opacity: 0.9 }}
|
||||
>
|
||||
<Play className="ml-0.5" style={{ width: PRINT_PLAY.icon, height: PRINT_PLAY.icon }} fill="currentColor" strokeWidth={0} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(<ArtifactRow subject={testSubject({ kind: "pdf", filename: "deck.pdf", sizeLabel: "57.7 KB", updatedAt: "2026-09-27T08:00:00" })} now={NOW} />));
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<ArtifactRow subject={testSubject({ kind: "image", filename: "contact-sheet.png", thumbnailUrl: "/sheet.png" })} to="/artifacts/a1" now={NOW} />
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
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(<ArtifactRow subject={testSubject({ kind: "document", filename: "notes.md" })} href="/api/attachments/n/content" external onClick={onClick} now={NOW} />));
|
||||
const link = container.querySelector<HTMLAnchorElement>('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(<ArtifactRow subject={testSubject({ kind: "pdf", filename: "deck.pdf" })} trailing={<a href="/download" aria-label="Download deck.pdf">dl</a>} 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(
|
||||
<ArtifactRowGroup>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<ArtifactRow key={i} subject={testSubject({ kind: "pdf", filename: `deck-${i}.pdf` })} style={inlineRowStyle} now={NOW} />
|
||||
))}
|
||||
</ArtifactRowGroup>,
|
||||
),
|
||||
);
|
||||
const rows = container.querySelectorAll<HTMLElement>('[data-testid="artifact-v1-row"]');
|
||||
expect(rows).toHaveLength(3);
|
||||
for (const row of rows) expect(row.style.flex).toBe("1 0 280px");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<span aria-hidden className="absolute flex flex-col gap-(--sz-3px)" style={{ left: ROW_ICON.glyph.left, top: ROW_ICON.glyph.top, width: ROW_ICON.glyph.size, height: ROW_ICON.glyph.size }}>
|
||||
{ROW_ICON.bars.widths.map((width, i) => (
|
||||
<span key={i} className="block h-(--sz-2px) rounded-full" style={{ width, background: "var(--artifact-row-ink)" }} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function IconGlyph({ subject }: { subject: ArtifactSubject }) {
|
||||
const { left, top, size } = ROW_ICON.glyph;
|
||||
if (isAudio(subject)) return <img src={panelGlyphAudio} alt="" width={size} height={size} className="absolute" style={{ left, top }} />;
|
||||
if (subject.kind === "slides") return <img src={panelGlyphSlides} alt="" width={size} height={12.667} className="absolute" style={{ left, top: top + 0.67 }} />;
|
||||
const glyph = glyphOf(subject);
|
||||
if (glyph === "bars" || subject.kind === "document") return <Bars />;
|
||||
const Icon = glyph;
|
||||
return <Icon aria-hidden className="absolute" style={{ left, top, width: size, height: size, color: "var(--artifact-row-ink)" }} strokeWidth={2} />;
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<span className="relative block shrink-0" style={{ width: ROW_ICON.size, height: ROW_ICON.size }}>
|
||||
<img src={panelDoc} alt="" width={ROW_ICON.size} height={ROW_ICON.size} className="absolute left-0 top-0 block max-w-none" />
|
||||
<img src={panelDocCorner} alt="" width={ROW_ICON.corner.size} height={ROW_ICON.corner.size} className="absolute block max-w-none" style={{ left: ROW_ICON.corner.left, top: ROW_ICON.corner.top }} />
|
||||
<IconGlyph subject={subject} />
|
||||
<span className="absolute whitespace-nowrap font-bold" style={ICON_LABEL}>
|
||||
{iconLabel(subject)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Thumbnail({ subject }: { subject: ArtifactSubject }) {
|
||||
return (
|
||||
<span className="relative block size-10 shrink-0 overflow-hidden rounded-(--sz-4px)">
|
||||
{subject.kind === "video" && !subject.thumbnailUrl ? (
|
||||
<video src={subject.videoUrl ?? undefined} className="absolute inset-0 h-full w-full object-cover" muted playsInline preload="metadata" />
|
||||
) : (
|
||||
<img src={subject.thumbnailUrl ?? undefined} alt="" className="absolute inset-0 h-full w-full object-cover" />
|
||||
)}
|
||||
{subject.kind === "video" ? (
|
||||
<span aria-hidden className="absolute flex items-center justify-center rounded-full" style={{ left: ROW_PLAY.inset, top: ROW_PLAY.inset, width: ROW_PLAY.size, height: ROW_PLAY.size, background: "var(--artifact-play-scrim)" }}>
|
||||
<img src={panelPlay} alt="" width={ROW_PLAY.icon} height={ROW_PLAY.icon} className="block" style={{ marginLeft: 1 }} />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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" ? <Thumbnail subject={subject} /> : <ArtifactRowIcon subject={subject} />}
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span className="flex h-5 w-full text-sm font-medium leading-5 text-foreground" title={name}>
|
||||
<span className="min-w-0 truncate">{stem}</span>
|
||||
<span className="shrink-0">{ext}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs leading-4 text-muted-foreground">
|
||||
{facts.map((fact, i) => (
|
||||
<span key={fact} className="whitespace-nowrap">
|
||||
{i > 0 ? <span className="mr-1">·</span> : null}
|
||||
{fact}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const label = `Open ${name}`;
|
||||
const control = to ? (
|
||||
<Link to={to} aria-label={label} data-testid={`${testId}-open`} className={CONTROL_CLASS}>
|
||||
{content}
|
||||
</Link>
|
||||
) : href ? (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noreferrer" : undefined}
|
||||
aria-label={label}
|
||||
data-testid={`${testId}-open`}
|
||||
className={CONTROL_CLASS}
|
||||
onClick={
|
||||
onClick
|
||||
? (event) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<button type="button" aria-label={label} aria-expanded={expanded} onClick={onClick} data-testid={`${testId}-open`} className={CONTROL_CLASS}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div data-testid={testId} data-artifact-row className={cn(ARTIFACT_ROW_CLASS, className)} style={style}>
|
||||
{control}
|
||||
{trailing ? <span className="flex shrink-0 items-center gap-0.5">{trailing}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div data-testid={testId} className="flex w-full flex-wrap gap-2">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const inlineRowStyle: CSSProperties = { flex: `1 0 ${ARTIFACT_ROW_MIN_WIDTH}px`, minWidth: 1 };
|
||||
@@ -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 (
|
||||
<div aria-hidden className="absolute" style={{ left: PREVIEW_BARS.left, top: PREVIEW_BARS.top, width: PREVIEW_BARS.width, height: PREVIEW_BARS.height }}>
|
||||
{PREVIEW_BAR_ROWS.map((row, r) =>
|
||||
row.map(([x, width], i) => (
|
||||
<span
|
||||
key={`${r}-${i}`}
|
||||
className="absolute rounded-full"
|
||||
style={{ left: x, top: r * PREVIEW_BARS.pitch, width, height: PREVIEW_BARS.barHeight, background: "var(--artifact-ink-faint)" }}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Paper() {
|
||||
return <img src={sheet} alt="" width={SHEET.width} height={SHEET.height} draggable={false} className="absolute inset-0 block" />;
|
||||
}
|
||||
|
||||
export function PreviewSheet({ subject, title = subject.title }: { subject: ArtifactSubject; title?: string }) {
|
||||
return (
|
||||
<div className="artifact-sheet relative shrink-0 overflow-clip" data-artifact-sheet="preview">
|
||||
<Paper />
|
||||
<p className="absolute line-clamp-3 break-words font-semibold" style={TITLE} title={title}>
|
||||
{title}
|
||||
</p>
|
||||
<TextBars />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GLYPH_BOX: CSSProperties = { left: ICON_GLYPH.left, top: ICON_GLYPH.top, width: ICON_GLYPH.size, height: ICON_GLYPH.size };
|
||||
|
||||
function BarsGlyph() {
|
||||
return (
|
||||
<div aria-hidden className="absolute" style={GLYPH_BOX}>
|
||||
{ICON_BARS.widths.map((width, i) => (
|
||||
<span key={i} className="absolute left-0 rounded-full" style={{ top: i * ICON_BARS.pitch, width, height: ICON_BARS.height, background: "var(--artifact-ink-faint)" }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Glyph({ subject }: { subject: ArtifactSubject }) {
|
||||
const glyph = glyphOf(subject);
|
||||
if (glyph === "bars") return <BarsGlyph />;
|
||||
const Icon = glyph;
|
||||
return <Icon aria-hidden className="absolute" style={{ ...GLYPH_BOX, color: "var(--artifact-ink-faint)" }} strokeWidth={2.5} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="artifact-sheet relative shrink-0 overflow-clip" data-artifact-sheet="icon">
|
||||
<Paper />
|
||||
<Glyph subject={subject} />
|
||||
<span className="absolute whitespace-nowrap font-bold" style={LABEL}>
|
||||
{labelOf(subject)}
|
||||
</span>
|
||||
{play ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute flex items-center justify-center rounded-full"
|
||||
style={{ right: ICON_PLAY.inset, top: ICON_PLAY.inset, width: ICON_PLAY.size, height: ICON_PLAY.size, background: "var(--artifact-ink)", color: "var(--artifact-paper)" }}
|
||||
>
|
||||
<Play className="ml-0.5" style={{ width: ICON_PLAY.icon, height: ICON_PLAY.icon }} fill="currentColor" strokeWidth={0} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<img
|
||||
data-artifact-rotate
|
||||
data-artifact-spread={spread}
|
||||
src={FILES[file]}
|
||||
alt=""
|
||||
width={width}
|
||||
height={height}
|
||||
draggable={false}
|
||||
className="artifact-backing pointer-events-none absolute max-w-none"
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div data-artifact-stack="media" className="relative shrink-0" style={{ width: Math.max(MEDIA_COMPOSITION.width, left + size.width), height: MEDIA_COMPOSITION.height }}>
|
||||
{MEDIA_BACKING.map((sheet) => (
|
||||
<Backing key={sheet.file} {...sheet} />
|
||||
))}
|
||||
<ArtifactFront subject={front} print={print ?? undefined} onMediaLoad={onMediaLoad} style={{ position: "absolute", left, top }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div data-artifact-stack="sheet" className="relative shrink-0" style={{ width: SHEET_COMPOSITION.width, height: SHEET_COMPOSITION.height }}>
|
||||
{SHEET_BACKING.map((sheet) => (
|
||||
<Backing key={sheet.file} {...sheet} />
|
||||
))}
|
||||
<div
|
||||
data-artifact-rotate
|
||||
data-artifact-spread={LANDSCAPE_SHEET.spread}
|
||||
aria-hidden
|
||||
className="absolute"
|
||||
style={{
|
||||
left: LANDSCAPE_SHEET.centre.x - LANDSCAPE_SHEET.width / 2,
|
||||
top: LANDSCAPE_SHEET.centre.y - LANDSCAPE_SHEET.height / 2,
|
||||
width: LANDSCAPE_SHEET.width,
|
||||
height: LANDSCAPE_SHEET.height,
|
||||
borderRadius: "var(--artifact-sheet-radius)",
|
||||
background: "var(--artifact-paper)",
|
||||
boxShadow: "var(--artifact-sheet-inset-stroke), var(--artifact-shadow-print)",
|
||||
"--artifact-base": `${LANDSCAPE_SHEET.rotate}deg`,
|
||||
} as CSSProperties}
|
||||
/>
|
||||
<ArtifactFront subject={front} style={{ position: "absolute", left: SHEET_FRONT.left, top: SHEET_FRONT.top }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
<MemoryRouter>
|
||||
<ArtifactTileGrid>
|
||||
<ArtifactStackTile identifier="ACM-3" title="Create visual directions" count={21} updatedAt="2026-09-28T11:07:00" front={testSubject({ kind: "pdf", filename: "deck.pdf" })} to="/artifacts?issue=i3" now={NOW} />
|
||||
</ArtifactTileGrid>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<ArtifactTileGrid>
|
||||
<ArtifactStackTile identifier="ACM-5" title="Shoot cap product photos" count={6} updatedAt="2026-09-27T15:12:00" front={testSubject({ kind: "image", thumbnailUrl: "/cap.png", aspect: 3 / 2 })} now={NOW} />
|
||||
</ArtifactTileGrid>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
expect(container.querySelector('[data-artifact-stack="media"]')).not.toBeNull();
|
||||
const print = container.querySelector<HTMLElement>("[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(
|
||||
<MemoryRouter>
|
||||
<ArtifactTileGrid>
|
||||
<ArtifactSingleTile subject={testSubject({ kind: "video", filename: "reel.webm", videoUrl: "/api/files/reel.webm" })} now={NOW} />
|
||||
</ArtifactTileGrid>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<ArtifactTileGrid>
|
||||
<ArtifactSingleTile subject={testSubject({ kind: "document", title: "Brand strategy", filename: "brand-strategy-brief.md", hasText: true, taskIdentifier: "ACM-2" })} now={NOW} />
|
||||
</ArtifactTileGrid>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="flex w-full flex-col items-center gap-3">
|
||||
<p className="line-clamp-2 w-full break-words text-center text-base font-medium leading-5 text-foreground" title={title}>
|
||||
{title}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{identifier ? (
|
||||
<span data-artifact-pill className="artifact-pill rounded-full px-3 py-1 text-xs font-normal leading-4 transition-colors">
|
||||
{identifier}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="flex items-center gap-1 text-xs leading-4 text-muted-foreground">
|
||||
{facts.map((fact, i) => (
|
||||
<span key={fact} className="whitespace-nowrap">
|
||||
{i > 0 ? <span className="mr-1">·</span> : null}
|
||||
{fact}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<>
|
||||
<div className="flex h-(--artifact-frame-h) w-full shrink-0 items-end justify-center">{children}</div>
|
||||
{caption}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li className="min-w-0">
|
||||
{to ? (
|
||||
// design-allow(card-pattern): navigation <Link> cell, as ArtifactGroupCard; Card renders a div and would break anchor semantics.
|
||||
<Link to={to} aria-label={label} data-testid={testId} className={TILE}>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" aria-label={label} onClick={onClick} data-testid={testId} className={TILE}>
|
||||
{body}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<ArtifactTile label={`Open ${identifier} ${title}, ${countLabel(count)}`} to={to} onClick={onClick} testId="artifact-v1-stack-tile" caption={<ArtifactCaption title={title} identifier={identifier} facts={[countLabel(count), `Updated ${updatedLabel(updatedAt, now)}`]} />}>
|
||||
<ArtifactStack front={front} />
|
||||
</ArtifactTile>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<ArtifactTile label={`Open ${name}`} to={to} onClick={onClick} testId="artifact-v1-single-tile" caption={<ArtifactCaption title={name} identifier={subject.taskIdentifier} facts={subject.updatedAt ? [`Updated ${updatedLabel(subject.updatedAt, now)}`] : []} />}>
|
||||
<ArtifactFront subject={subject} />
|
||||
</ArtifactTile>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<ul data-testid={testId} className="grid gap-7 grid-cols-(--artifact-grid)">
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -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<ArtifactKindFilter, never>;
|
||||
|
||||
export const ARTIFACTS_V1_VIEWS: TabBarItem<ArtifactsV1View>[] = [
|
||||
{ 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 <TabFilters items={ARTIFACTS_V1_VIEWS} value={view} onValueChange={onViewChange} size="standard" label="Show artifacts by task or by type" />;
|
||||
}
|
||||
@@ -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<IssueWorkProduct, "type" | "status" | "reviewState" | "healthStatus">,
|
||||
label?: string | null,
|
||||
): ReactNode {
|
||||
if (workProduct.healthStatus === "unhealthy") return <Badge variant="destructive">Unhealthy</Badge>;
|
||||
const text = label ?? stateChipFor(workProduct.type, workProduct.status, workProduct.reviewState)?.label;
|
||||
return text ? <span className="text-xs text-muted-foreground">{text}</span> : undefined;
|
||||
}
|
||||
@@ -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<string, unknown> | null, key: string): string | null => {
|
||||
const value = metadata?.[key];
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
};
|
||||
const num = (metadata: Record<string, unknown> | 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"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="240.409" height="247.814" viewBox="0 0 240.409 247.814" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Group 2243798">
|
||||
<path id="Vector" d="M30.0069 38.5876C30.0089 31.9621 35.3805 26.598 42.006 26.5978C81.8284 26.5966 131.346 26.5902 168.937 26.5985C180.026 26.6009 184.454 26.5994 198.402 26.5988C205.029 26.5985 210.409 31.9395 210.409 38.5669C210.409 81.9948 210.409 159.012 210.409 202.44C210.409 209.067 205.039 214.408 198.411 214.408C147.61 214.408 92.8077 214.408 42.0063 214.408C35.3789 214.408 30.0087 209.041 30.0084 202.413C30.0062 150.991 29.9916 90.0098 30.0069 38.5876Z" fill="#FAFAFA"/>
|
||||
</g>
|
||||
<clipPath id="inner-stroke-0"><path d="M30.0069 38.5876C30.0089 31.9621 35.3805 26.598 42.006 26.5978C81.8284 26.5966 131.346 26.5902 168.937 26.5985C180.026 26.6009 184.454 26.5994 198.402 26.5988C205.029 26.5985 210.409 31.9395 210.409 38.5669C210.409 81.9948 210.409 159.012 210.409 202.44C210.409 209.067 205.039 214.408 198.411 214.408C147.61 214.408 92.8077 214.408 42.0063 214.408C35.3789 214.408 30.0087 209.041 30.0084 202.413C30.0062 150.991 29.9916 90.0098 30.0069 38.5876Z"/></clipPath><path d="M30.0069 38.5876C30.0089 31.9621 35.3805 26.598 42.006 26.5978C81.8284 26.5966 131.346 26.5902 168.937 26.5985C180.026 26.6009 184.454 26.5994 198.402 26.5988C205.029 26.5985 210.409 31.9395 210.409 38.5669C210.409 81.9948 210.409 159.012 210.409 202.44C210.409 209.067 205.039 214.408 198.411 214.408C147.61 214.408 92.8077 214.408 42.0063 214.408C35.3789 214.408 30.0087 209.041 30.0084 202.413C30.0062 150.991 29.9916 90.0098 30.0069 38.5876Z" fill="none" stroke="#1A1A1A" stroke-opacity="0.1" stroke-width="2" clip-path="url(#inner-stroke-0)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="240.409" height="276.151" viewBox="0 0 240.409 276.151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Group 2243799">
|
||||
<path id="Vector" d="M30.0074 38.5903C30.0092 31.9645 35.3809 26.5986 42.0067 26.5984C81.8289 26.5969 131.346 26.5896 168.937 26.5991C180.026 26.602 184.454 26.6002 198.402 26.5994C205.029 26.5991 210.409 31.9343 210.409 38.5617C210.409 88.7834 210.409 180.561 210.409 230.782C210.409 237.41 205.039 242.745 198.411 242.745C147.61 242.745 92.8077 242.745 42.0063 242.745C35.3789 242.745 30.0087 237.376 30.0085 230.749C30.0065 170.592 29.9911 98.747 30.0074 38.5903Z" fill="#FAFAFA"/>
|
||||
</g>
|
||||
<clipPath id="inner-stroke-1"><path d="M30.0074 38.5903C30.0092 31.9645 35.3809 26.5986 42.0067 26.5984C81.8289 26.5969 131.346 26.5896 168.937 26.5991C180.026 26.602 184.454 26.6002 198.402 26.5994C205.029 26.5991 210.409 31.9343 210.409 38.5617C210.409 88.7834 210.409 180.561 210.409 230.782C210.409 237.41 205.039 242.745 198.411 242.745C147.61 242.745 92.8077 242.745 42.0063 242.745C35.3789 242.745 30.0087 237.376 30.0085 230.749C30.0065 170.592 29.9911 98.747 30.0074 38.5903Z"/></clipPath><path d="M30.0074 38.5903C30.0092 31.9645 35.3809 26.5986 42.0067 26.5984C81.8289 26.5969 131.346 26.5896 168.937 26.5991C180.026 26.602 184.454 26.6002 198.402 26.5994C205.029 26.5991 210.409 31.9343 210.409 38.5617C210.409 88.7834 210.409 180.561 210.409 230.782C210.409 237.41 205.039 242.745 198.411 242.745C147.61 242.745 92.8077 242.745 42.0063 242.745C35.3789 242.745 30.0087 237.376 30.0085 230.749C30.0065 170.592 29.9911 98.747 30.0074 38.5903Z" fill="none" stroke="#1A1A1A" stroke-opacity="0.1" stroke-width="2" clip-path="url(#inner-stroke-1)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="240.409" height="276.151" viewBox="0 0 240.409 276.151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Group 2243798">
|
||||
<path id="Vector" d="M30.0074 38.5903C30.0092 31.9645 35.3809 26.5986 42.0067 26.5984C81.8289 26.5969 131.346 26.5896 168.937 26.5991C180.026 26.602 184.454 26.6002 198.402 26.5994C205.029 26.5991 210.409 31.9343 210.409 38.5617C210.409 88.7834 210.409 180.561 210.409 230.782C210.409 237.41 205.039 242.745 198.411 242.745C147.61 242.745 92.8077 242.745 42.0063 242.745C35.3789 242.745 30.0087 237.376 30.0085 230.749C30.0065 170.592 29.9911 98.747 30.0074 38.5903Z" fill="#FAFAFA"/>
|
||||
</g>
|
||||
<clipPath id="inner-stroke-2"><path d="M30.0074 38.5903C30.0092 31.9645 35.3809 26.5986 42.0067 26.5984C81.8289 26.5969 131.346 26.5896 168.937 26.5991C180.026 26.602 184.454 26.6002 198.402 26.5994C205.029 26.5991 210.409 31.9343 210.409 38.5617C210.409 88.7834 210.409 180.561 210.409 230.782C210.409 237.41 205.039 242.745 198.411 242.745C147.61 242.745 92.8077 242.745 42.0063 242.745C35.3789 242.745 30.0087 237.376 30.0085 230.749C30.0065 170.592 29.9911 98.747 30.0074 38.5903Z"/></clipPath><path d="M30.0074 38.5903C30.0092 31.9645 35.3809 26.5986 42.0067 26.5984C81.8289 26.5969 131.346 26.5896 168.937 26.5991C180.026 26.602 184.454 26.6002 198.402 26.5994C205.029 26.5991 210.409 31.9343 210.409 38.5617C210.409 88.7834 210.409 180.561 210.409 230.782C210.409 237.41 205.039 242.745 198.411 242.745C147.61 242.745 92.8077 242.745 42.0063 242.745C35.3789 242.745 30.0087 237.376 30.0085 230.749C30.0065 170.592 29.9911 98.747 30.0074 38.5903Z" fill="none" stroke="#1A1A1A" stroke-opacity="0.1" stroke-width="2" clip-path="url(#inner-stroke-2)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="9.19537" height="9.19537" viewBox="0 0 9.19537 9.19537" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M0 0.744648C0 -0.330955 0.627435 -0.241321 1.88231 1.01355L8.18182 7.31306C9.43669 8.56793 9.52632 9.19537 8.45072 9.19537H4.02565C1.34188 9.19537 0 7.85348 0 5.16972V0.744648Z" fill="#8B8B8B"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 409 B |
@@ -0,0 +1,3 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="40.0023" height="40" viewBox="0 0 40.0023 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M0.00111796 3.99981C0.00165523 1.79123 1.79061 0.000952545 3.99919 0.000844874C12.054 0.000452191 21.6536 -0.000776594 29.3471 0.000730904C30.3223 0.000921978 31.2557 0.387764 31.9433 1.07919C33.9591 3.1061 36.2345 5.41844 38.9255 8.11782C39.6143 8.80868 40.0023 9.74587 40.0023 10.7214C40.0023 19.7077 40.0022 27.2581 40.0022 35.9998C40.0022 38.2089 38.212 40 36.0028 40C25.5668 40 14.4377 40 4.00169 40C1.79255 40 0.00233959 38.2089 0.00220767 35.9997C0.00152626 24.5885 -0.00165806 15.4112 0.00111796 3.99981Z" fill="#666666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 735 B |
@@ -0,0 +1,7 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="13.9977" height="13.9977" viewBox="0 0 13.9977 13.9977" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame">
|
||||
<path id="Vector" d="M5.24913 10.4983V2.91618L12.248 1.74971V9.33179" stroke-width="1.16647" stroke-linecap="round" stroke-linejoin="round" stroke="#FAFAFA"/>
|
||||
<path id="Vector_2" d="M5.24913 10.4983C5.24913 11.4646 4.46576 12.248 3.49942 12.248C2.53308 12.248 1.74971 11.4646 1.74971 10.4983C1.74971 9.53192 2.53308 8.74855 3.49942 8.74855C4.46576 8.74855 5.24913 9.53192 5.24913 10.4983Z" stroke-width="1.16647" stroke-linecap="round" stroke-linejoin="round" stroke="#FAFAFA"/>
|
||||
<path id="Vector_3" d="M12.248 9.33175C12.248 10.2981 11.4646 11.0815 10.4983 11.0815C9.53192 11.0815 8.74855 10.2981 8.74855 9.33175C8.74855 8.36541 9.53192 7.58204 10.4983 7.58204C11.4646 7.58204 12.248 8.36541 12.248 9.33175Z" stroke-width="1.16647" stroke-linecap="round" stroke-linejoin="round" stroke="#FAFAFA"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1011 B |
@@ -0,0 +1,7 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="12.6674" viewBox="0 0 14 12.6674" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Group">
|
||||
<path id="Vector" d="M12.2667 0H1.73333C0.77604 0 0 0.77604 0 1.73333V7.26667C0 8.22396 0.77604 9 1.73333 9H12.2667C13.224 9 14 8.22396 14 7.26667V1.73333C14 0.77604 13.224 0 12.2667 0Z" fill="white"/>
|
||||
<path id="Vector_2" d="M8.06802 8.66687H5.93469V11.3335H8.06802V8.66687Z" fill="white"/>
|
||||
<path id="Vector_3" d="M9.73375 10.8008H4.26708C3.75162 10.8008 3.33375 11.2186 3.33375 11.7341C3.33375 12.2496 3.75162 12.6674 4.26708 12.6674H9.73375C10.2492 12.6674 10.6671 12.2496 10.6671 11.7341C10.6671 11.2186 10.2492 10.8008 9.73375 10.8008Z" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 759 B |
@@ -0,0 +1,5 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon">
|
||||
<path id="Vector" d="M4.66622 3.06682L12.9329 8.00016L4.66622 12.9335V3.06682Z" fill="white" stroke-width="2.26667" stroke-linejoin="round" stroke="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 349 B |
@@ -0,0 +1,3 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="180" height="216" viewBox="0 0 180 216" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="sheet" d="M0.00738082 11.996C0.00916912 5.37025 5.35752 0.0042415 11.9833 0.00404381C53.4932 0.00280528 126.516 -0.00342139 168.027 0.00254716C174.653 0.0034999 180 5.37557 180 12.0018C180 76.0012 180 140.001 180 204C180 210.627 174.63 216 168.002 216C117.333 216 62.6757 216 12.0062 216C5.37882 216 0.00866235 210.631 0.0084422 204.004C0.00644548 143.893 -0.00884296 72.1063 0.00738082 11.996Z" fill="#FAFAFA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 600 B |
@@ -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<ReadonlyArray<readonly [number, number]>> = [
|
||||
[[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;
|
||||
@@ -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";
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<ArtifactSubject, "ext" | "filename">): 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<ArtifactKind, string> = {
|
||||
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<ArtifactKind, Glyph> = {
|
||||
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<string, ArtifactKind> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ArtifactSubject } from "./kinds";
|
||||
|
||||
export function testSubject(overrides: Partial<ArtifactSubject> & Pick<ArtifactSubject, "kind">): ArtifactSubject {
|
||||
return { title: "An artifact", updatedAt: "2026-09-28T11:07:00", taskIdentifier: "ACM-3", ...overrides };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 (
|
||||
<ArtifactRow
|
||||
subject={subject}
|
||||
onClick={onOpen}
|
||||
expanded={expanded}
|
||||
trailing={
|
||||
statusBadge || actions ? (
|
||||
<>
|
||||
{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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div ref={headerRef}>
|
||||
<DocumentCard
|
||||
title={workProduct.title}
|
||||
summary={workProduct.summary ?? ""}
|
||||
author={author}
|
||||
updatedAt={formatDateTime(workProduct.updatedAt)}
|
||||
filename={metadata.originalFilename ?? workProduct.title}
|
||||
revision={reviewDoc?.latestRevisionNumber}
|
||||
body={reviewDoc?.body ?? ""}
|
||||
onOpen={handleToggle}
|
||||
expanded={expanded}
|
||||
statusBadge={
|
||||
badge ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{badge.label}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
const statusBadge = badge ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{badge.label}
|
||||
</span>
|
||||
) : undefined;
|
||||
const actions = (
|
||||
<>
|
||||
{reviewDoc ? (
|
||||
<DocumentAnnotationsCountChip
|
||||
@@ -243,7 +261,7 @@ function MarkdownWorkProductRow({
|
||||
onClick={() => 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"}
|
||||
>
|
||||
<FileText className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -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"}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
@@ -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"}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div ref={headerRef}>
|
||||
{face ? (
|
||||
face({ onOpen: handleToggle, expanded, actions, statusBadge })
|
||||
) : (
|
||||
<DocumentCard
|
||||
title={workProduct.title}
|
||||
summary={workProduct.summary ?? ""}
|
||||
author={author}
|
||||
updatedAt={formatDateTime(workProduct.updatedAt)}
|
||||
filename={metadata.originalFilename ?? workProduct.title}
|
||||
revision={reviewDoc?.latestRevisionNumber}
|
||||
body={reviewDoc?.body ?? ""}
|
||||
onOpen={handleToggle}
|
||||
expanded={expanded}
|
||||
statusBadge={statusBadge}
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="border-t border-border px-2.5 py-2">{expandedBody}</div>
|
||||
@@ -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 ? (
|
||||
<DocumentAnnotationsCountChip
|
||||
issueId={issueId}
|
||||
docKey={doc.key}
|
||||
panelOpen={annotationPanelOpen}
|
||||
onToggle={() => {
|
||||
setExpanded(true);
|
||||
setAnnotationPanelOpen((open) => !open);
|
||||
}}
|
||||
/>
|
||||
) : undefined;
|
||||
return (
|
||||
<div ref={headerRef}>
|
||||
{face ? (
|
||||
face({ onOpen: open, expanded: onOpen ? undefined : expanded, actions })
|
||||
) : (
|
||||
<DocumentCard
|
||||
title={documentDisplayTitle(doc)}
|
||||
summary=""
|
||||
@@ -312,22 +366,11 @@ function DocumentRow({
|
||||
filename={`${doc.key}.md`}
|
||||
revision={doc.latestRevisionNumber}
|
||||
body={doc.body}
|
||||
onOpen={onOpen ?? (() => setExpanded((open) => !open))}
|
||||
onOpen={open}
|
||||
expanded={onOpen ? undefined : expanded}
|
||||
actions={
|
||||
!onOpen ? (
|
||||
<DocumentAnnotationsCountChip
|
||||
issueId={issueId}
|
||||
docKey={doc.key}
|
||||
panelOpen={annotationPanelOpen}
|
||||
onToggle={() => {
|
||||
setExpanded(true);
|
||||
setAnnotationPanelOpen((open) => !open);
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
{expanded ? (
|
||||
<div className="border-t border-border px-2.5 py-2">
|
||||
{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(
|
||||
<div className="@container flex flex-col gap-3 py-2">
|
||||
{feedback}
|
||||
<ul data-testid="artifacts-v1-list" className="flex flex-col gap-(--artifact-row-gap)">
|
||||
{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 (
|
||||
<li key={row.id} className="min-w-0">
|
||||
<MarkdownWorkProductRow
|
||||
issueId={issue.id}
|
||||
workProduct={wp}
|
||||
author={agent?.name ?? ""}
|
||||
metadata={markdownMetadata}
|
||||
reviewDoc={reviewDocsByKey.get(reviewKey)}
|
||||
openRequestId={documentDeepLink?.documentKey === reviewKey ? documentDeepLink.requestId : undefined}
|
||||
face={v1Face(subject)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
// "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 (
|
||||
<li key={row.id} className="min-w-0">
|
||||
<ArtifactFileRow subject={subject} source={file} statusBadge={statusBadge} updatedAtLabel={formatDateTime(wp.updatedAt)} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
// 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 (
|
||||
<li key={row.id} className="min-w-0">
|
||||
<ArtifactRow subject={subject} href={href || undefined} external trailing={statusBadge} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
if (row.kind === "document") {
|
||||
const doc = row.value;
|
||||
return (
|
||||
<li key={row.id} className="min-w-0">
|
||||
<DocumentRow
|
||||
issueId={issue.id}
|
||||
doc={doc}
|
||||
author={agentsById.get(doc.updatedByAgentId ?? doc.createdByAgentId ?? "")?.name ?? ""}
|
||||
onOpen={onOpenDocument ? () => onOpenDocument(doc) : undefined}
|
||||
openRequestId={documentDeepLink?.documentKey === doc.key ? documentDeepLink.requestId : undefined}
|
||||
face={v1Face(subjectFromIssueDocument(doc))}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const attachment = row.value;
|
||||
return (
|
||||
<li key={row.id} className="min-w-0">
|
||||
<ArtifactFileRow subject={subjectFromAttachment(attachment)} source={fileRowSourceFromAttachment(attachment)} updatedAtLabel={formatDateTime(attachment.updatedAt)} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
return withUploads(
|
||||
<div className="@container flex flex-col gap-3 py-2">
|
||||
{feedback}
|
||||
|
||||
@@ -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<unknown[]> => []),
|
||||
listWorkProducts: vi.fn(async (_issueId: string, _options?: { refreshPullRequests?: boolean }): Promise<unknown[]> => []),
|
||||
ensureWorkProductReviewDocument: vi.fn(async (): Promise<unknown> => ({})),
|
||||
}));
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi }));
|
||||
vi.mock("@/api/activity", () => ({ activityApi: { runsForIssue: vi.fn(async (): Promise<unknown[]> => []) } }));
|
||||
vi.mock("@/api/agents", () => ({ agentsApi: { list: vi.fn(async (): Promise<unknown[]> => []) } }));
|
||||
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 }) => <a href={to} {...props}>{children}</a>,
|
||||
useLocation: () => ({ hash: "" }),
|
||||
}));
|
||||
vi.mock("@/components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: string }) => <div>{children}</div> }));
|
||||
vi.mock("@/components/IssueDocumentAnnotations", () => ({
|
||||
DocumentAnnotationsCountChip: ({ docKey }: { docKey: string }) => <span data-testid={`annotation-count-${docKey}`} />,
|
||||
IssueDocumentAnnotations: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssuePropertiesArtifactsTab issue={issue} {...props} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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<HTMLAnchorElement>('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<HTMLButtonElement>('[aria-label="Open plan.md"]')!.click());
|
||||
expect(onOpenDocument).toHaveBeenCalledWith(planDocument);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>): 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(
|
||||
<ThemeProvider>
|
||||
<IssueGalleryContext.Provider value={openGallery}>
|
||||
<TaskChatBubble
|
||||
item={{ id: "m1", kind: "message", author: "agent", text: "Done.\n\n\n\n[report.pdf](/api/attachments/r1/content)", timestamp: "11:08 AM" }}
|
||||
attachments={[image, pdf]}
|
||||
/>
|
||||
</IssueGalleryContext.Provider>
|
||||
</ThemeProvider>,
|
||||
),
|
||||
);
|
||||
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<HTMLButtonElement>('[data-testid="artifact-v1-row-open"]')!.click());
|
||||
expect(openGallery).toHaveBeenCalledWith("/api/attachments/img/content");
|
||||
const fileLink = rows[1].querySelector<HTMLAnchorElement>('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(
|
||||
<ThemeProvider>
|
||||
<TextAttachmentContext.Provider value={openText}>
|
||||
<TaskChatBubble item={{ id: "m3", kind: "message", author: "agent", text: "[notes.md](/api/attachments/n1/content)", timestamp: "11:10 AM" }} attachments={[notes]} />
|
||||
</TextAttachmentContext.Provider>
|
||||
</ThemeProvider>,
|
||||
),
|
||||
);
|
||||
const link = container.querySelector<HTMLAnchorElement>('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(
|
||||
<ThemeProvider>
|
||||
<TaskChatBubble item={{ id: "m2", kind: "message", author: "agent", text: "Just words.", timestamp: "11:09 AM" }} />
|
||||
</ThemeProvider>,
|
||||
),
|
||||
);
|
||||
expect(container.querySelector('[data-testid="task-chat-bubble-artifacts-v1"]')).toBeNull();
|
||||
expect(container.textContent).toContain("Just words.");
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
{imageRefs.length > 0 ? (
|
||||
{!artifactsV1 && imageRefs.length > 0 ? (
|
||||
<div
|
||||
className="flex max-w-(--pct-85) flex-col gap-2"
|
||||
data-testid="task-chat-bubble-media"
|
||||
@@ -314,7 +317,7 @@ function TaskChatBubbleContent({
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{attachmentRefs.length > 0 ? (
|
||||
{!artifactsV1 && attachmentRefs.length > 0 ? (
|
||||
<div className="flex max-w-(--pct-85) flex-col gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Files · {attachmentRefs.length}
|
||||
@@ -430,6 +433,24 @@ function TaskChatBubbleContent({
|
||||
{item.timestamp}
|
||||
</span>
|
||||
) : 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.
|
||||
<div className="w-full pt-2" data-testid="task-chat-bubble-artifacts-v1">
|
||||
<ArtifactRowGroup>
|
||||
{[...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 <ArtifactRow key={ref.url} subject={subject} onClick={() => 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 <ArtifactRow key={ref.url} subject={subject} href={ref.openPath ?? ref.url} external onClick={() => openText?.(ref.id!, ref.name)} style={inlineRowStyle} />;
|
||||
return <ArtifactRow key={ref.url} subject={subject} href={ref.openPath ?? ref.url} external style={inlineRowStyle} />;
|
||||
})}
|
||||
</ArtifactRowGroup>
|
||||
</div>
|
||||
) : null}
|
||||
{lightboxSrc !== null && lightboxIndex >= 0 ? (
|
||||
<ImageGalleryModal
|
||||
items={galleryItems}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
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 { IssueWorkProduct } from "@paperclipai/shared";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { MemoryRouter } from "@/lib/router";
|
||||
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
|
||||
import type { TaskChatProtocolItem } from "./task-chat-model";
|
||||
|
||||
vi.mock("@/hooks/useArtifactsV1Enabled", () => ({ 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(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<MemoryRouter>
|
||||
<ThemeProvider>
|
||||
<TaskChatProtocolCard item={item} />
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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<HTMLAnchorElement>('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<HTMLAnchorElement>('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<HTMLAnchorElement>('a[data-testid="task-chat-resource-document-open"]');
|
||||
expect(link?.getAttribute("href")).toBe("#document-plan");
|
||||
expect(link?.getAttribute("target")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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<TaskChatProtocolItem, { surface: "resource" }>;
|
||||
}) {
|
||||
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 <ArtifactFileRow subject={subjectFromWorkProduct(workProduct)} source={file} statusBadge={statusBadge} updatedAtLabel={item.timestamp ?? ""} testId={testId} />;
|
||||
}
|
||||
return <ArtifactRow subject={subjectFromWorkProduct(workProduct)} href={href} external trailing={statusBadge} testId={testId} />;
|
||||
}
|
||||
if (item.resourceKind === "attachment" && item.attachment) {
|
||||
return <ArtifactFileRow subject={subjectFromAttachment(item.attachment)} source={fileRowSourceFromAttachment(item.attachment)} updatedAtLabel={item.timestamp ?? ""} testId={testId} />;
|
||||
}
|
||||
const subject = item.document
|
||||
? subjectFromDocumentSummary(item.document)
|
||||
: { kind: item.resourceKind === "document" ? ("document" as const) : ("file" as const), title: item.title };
|
||||
return <ArtifactRow subject={subject} href={href} external={item.resourceKind === "attachment"} testId={testId} />;
|
||||
}
|
||||
if (item.resourceKind === "deliverable" && item.workProduct) {
|
||||
return <RichWorkProductCard workProduct={item.workProduct} href={item.href} />;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<Pick<InstanceExperimentalSettings, "enableArtifactsV1">> | 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,
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<HTMLButtonElement>('[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<HTMLAnchorElement>('[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<HTMLAnchorElement>('[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<HTMLButtonElement>('[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());
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="w-full max-w-6xl space-y-5">
|
||||
<ArtifactsToolbar query={draftQuery} onQueryChange={setDraftQuery} groupBy={groupBy} onGroupByChange={selectGroupBy} kind={kind} onKindChange={selectKind} />
|
||||
<ArtifactsToolbar
|
||||
query={draftQuery}
|
||||
onQueryChange={setDraftQuery}
|
||||
groupBy={groupBy}
|
||||
onGroupByChange={selectGroupBy}
|
||||
kind={kind}
|
||||
onKindChange={selectKind}
|
||||
filters={v1 ? <ArtifactsV1Filters view={artifactsV1View(groupBy, kind)} onViewChange={selectV1View} /> : undefined}
|
||||
/>
|
||||
|
||||
{viewingSelectedStack ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
@@ -247,6 +281,37 @@ export function Artifacts() {
|
||||
<PageSkeleton variant="list" />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState icon={showGroupCards ? Layers : Package} message={emptyMessage} />
|
||||
) : 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. */}
|
||||
<ArtifactTileGrid>
|
||||
{showGroupCards
|
||||
? groups.map((group) => {
|
||||
const preview = group.previewArtifacts[0];
|
||||
if (group.count > 1 || !preview) {
|
||||
return (
|
||||
<ArtifactStackTile
|
||||
key={group.id}
|
||||
identifier={group.issue.identifier}
|
||||
title={group.title}
|
||||
count={group.count}
|
||||
updatedAt={group.updatedAt}
|
||||
front={preview ? subjectFromCompanyArtifact(preview) : { kind: "file", title: group.title, updatedAt: group.updatedAt, taskIdentifier: group.issue.identifier }}
|
||||
to={stackTo(group.issue.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ArtifactSingleTile key={group.id} subject={subjectFromCompanyArtifact(preview)} to={preview.href} />;
|
||||
})
|
||||
: artifacts.map((artifact) => (
|
||||
<ArtifactSingleTile key={`${artifact.source}:${artifact.id}`} subject={subjectFromCompanyArtifact(artifact)} to={artifact.href} />
|
||||
))}
|
||||
</ArtifactTileGrid>
|
||||
<div ref={loadMoreRef} className="flex min-h-10 items-center justify-center pb-2 text-xs text-muted-foreground">
|
||||
{isFetchingNextPage ? "Loading more artifacts..." : hasNextPage ? null : isFetching ? "Updating artifacts..." : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
|
||||
@@ -96,6 +96,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
||||
enableCases: false,
|
||||
enableAgentChat: false,
|
||||
enableCombinedInboxTasks: false,
|
||||
enableArtifactsV1: false,
|
||||
enableConferenceRoomChat: false,
|
||||
enableClassicTaskInterface: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
|
||||
@@ -306,6 +306,18 @@ export function InstanceExperimentalSettings() {
|
||||
ariaLabel="Toggle agent chat experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Artifacts V1"
|
||||
description="Show artifacts the way the artifact directions lab settled: paper stacks and tiles on the Artifacts page, compact rows in the panel's Artifacts tab, and grouped rows in the task thread."
|
||||
footnote="Presentation only. Turning this off restores the current cards; no data changes."
|
||||
checked={experimentalQuery.data?.enableArtifactsV1 ?? false}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableArtifactsV1: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableArtifactsV1"
|
||||
managed={managedKeys.enableArtifactsV1}
|
||||
ariaLabel="Toggle artifacts V1 experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Assistant connections (MCP)"
|
||||
description="Connect Codex, Claude, and other assistants to your Paperclip organization. People sign in, select an organization once, and approve access to review work, delegate tasks, and add feedback."
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ReactNode } from "react";
|
||||
import capBlack from "@/artifact-directions/assets/cap-shot-black.svg";
|
||||
import capForest from "@/artifact-directions/assets/cap-shot-forest.svg";
|
||||
import contactSheet from "@/artifact-directions/assets/contact-sheet-interface-craft.svg";
|
||||
import reelPoster from "@/artifact-directions/assets/cap-reel-poster.svg";
|
||||
import { ArtifactRow, ArtifactRowGroup, ArtifactSingleTile, ArtifactStackTile, ArtifactTileGrid, inlineRowStyle, type ArtifactSubject } from "@/components/artifacts/v1";
|
||||
|
||||
/**
|
||||
* The Artifacts V1 primitives (Settings > 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<ArtifactSubject> & Pick<ArtifactSubject, "kind" | "title">): 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 (
|
||||
<div className="bg-background p-6 text-foreground">
|
||||
<div style={{ width }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<typeof meta>;
|
||||
|
||||
export const Stacks: Story = {
|
||||
name: "Stack tiles",
|
||||
render: () => (
|
||||
<Stage width={1152}>
|
||||
<ArtifactTileGrid>
|
||||
<ArtifactStackTile identifier="ACM-3" title="Create visual directions and first acmehats brand deck" count={21} updatedAt="2026-09-28T11:07:00" front={critique} now={NOW} />
|
||||
<ArtifactStackTile identifier="ACM-5" title="Shoot cap product photos for the storefront" count={6} updatedAt="2026-09-27T15:12:00" front={forest} now={NOW} />
|
||||
<ArtifactStackTile identifier="ACM-5" title="Shoot cap product photos for the storefront" count={6} updatedAt="2026-09-27T14:58:00" front={black} now={NOW} />
|
||||
<ArtifactStackTile identifier="ACM-6" title="Build the wholesale pricing model" count={3} updatedAt="2026-09-24T15:36:00" front={subject({ kind: "sheet", title: "Wholesale pricing model", filename: "wholesale-pricing-model.xlsx", taskIdentifier: "ACM-6" })} now={NOW} />
|
||||
<ArtifactStackTile identifier="ACM-5" title="Shoot cap product photos for the storefront" count={6} updatedAt="2026-09-27T14:44:00" front={reel} now={NOW} />
|
||||
<ArtifactStackTile identifier="ACM-5" title="Shoot cap product photos for the storefront" count={6} updatedAt="2026-09-27T14:40:00" front={sting} now={NOW} />
|
||||
</ArtifactTileGrid>
|
||||
</Stage>
|
||||
),
|
||||
};
|
||||
|
||||
export const Singles: Story = {
|
||||
name: "Single tiles",
|
||||
render: () => (
|
||||
<Stage width={1152}>
|
||||
<ArtifactTileGrid>
|
||||
<ArtifactSingleTile subject={critique} now={NOW} />
|
||||
<ArtifactSingleTile subject={deckPdf} now={NOW} />
|
||||
<ArtifactSingleTile subject={sheetPng} now={NOW} />
|
||||
<ArtifactSingleTile subject={black} now={NOW} />
|
||||
<ArtifactSingleTile subject={reel} now={NOW} />
|
||||
<ArtifactSingleTile subject={sting} now={NOW} />
|
||||
<ArtifactSingleTile subject={zip} now={NOW} />
|
||||
<ArtifactSingleTile subject={storefront} now={NOW} />
|
||||
</ArtifactTileGrid>
|
||||
</Stage>
|
||||
),
|
||||
};
|
||||
|
||||
export const Rows: Story = {
|
||||
name: "Rows (panel)",
|
||||
render: () => (
|
||||
<Stage width={402}>
|
||||
<ul className="flex flex-col gap-(--artifact-row-gap)">
|
||||
{[zip, deckPptx, guideMd, critique, sheetPng, deckPdf, reel, sting, storefront].map((item) => (
|
||||
<li key={item.title}>
|
||||
<ArtifactRow subject={item} now={NOW} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Stage>
|
||||
),
|
||||
};
|
||||
|
||||
export const GroupedRows: Story = {
|
||||
name: "Rows grouped inline (thread)",
|
||||
render: () => (
|
||||
<Stage width={960}>
|
||||
<ArtifactRowGroup>
|
||||
{[sheetPng, deckPptx, deckPdf, guideMd, zip].map((item) => (
|
||||
<ArtifactRow key={item.title} subject={item} style={inlineRowStyle} now={NOW} />
|
||||
))}
|
||||
</ArtifactRowGroup>
|
||||
</Stage>
|
||||
),
|
||||
};
|
||||