mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 21:05:21 +02:00
feat(ui): add task artifact media gallery and full-row links (#13825)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Tasks collect the files and work products that agents create. > - The Artifacts tab shows these outputs in rows, which makes videos hard to compare. > - Small text links also make artifact rows harder to open. > - This pull request adds image and video tiles with previews and makes the full artifact row clickable. > - Users can compare outputs and open the existing media viewer with one click. ## Linked Issues or Issue Description **What existing behavior does this improve?** The task Artifacts tab and media previews in task chat. **Current behavior** Video outputs appear as file rows or icons. Users must click a small link to open a work product. A task with eight video outputs gives little visual context. **Proposed behavior** Show images and videos in a responsive gallery. Show a paused video frame as the thumbnail. Keep documents, links, and other files in rows whose entire area opens the item. **Reason and benefit** Users can compare generated media without opening each item. Larger click targets also make the sidebar easier to use. **Breaking changes** None. This uses the existing artifact URLs, media viewer, run grouping, and attachment filters. No API or database changes. Related work: #11226 added the task sidebar output surface, and #7361 added rich attachment previews. #3524 concerns a separate reviewed-assets panel. This PR improves the existing task artifact components. The duplicate search found no active PR for this change. This is polish for the shipped Artifacts & Work Products roadmap item. ## What Changed - Add a shared media tile for work products and agent attachments. - Reuse video and image previews in task artifacts and chat. Seek up to one second into videos and reset preview state when the source changes. - Use the existing task gallery for playback and downloads. Preserve grouping and attachment deduplication. - Extend native links and buttons across work-product rows, including keyboard focus indicators. - Add eight offline Storybook examples for video outputs, mixed media, clickable rows, narrow and wide panels, missing previews, empty state, and light mode. - Register the component in the design guide and document its use. ## Verification - 108 focused component tests pass, including thumbnail seeking, source changes, gallery activation, and attachment deduplication. - `pnpm build`, `pnpm -r typecheck`, Storybook build, token gates, and `git diff --check` pass locally. - Reviewed the production components in the embedded browser. Checked all eight video thumbnails, mixed media, narrow layout, light mode, blank-area row clicks, keyboard gallery activation for generic-MIME images, and playback from chat video thumbnails. - Storybook: open **Tasks / Artifact Gallery** and select **Eight Video Outputs**, **Mixed Media And Files**, or **Whole Row Clickable**. The small local clips are synthetic fixtures. - All build, typecheck, unit, runner, and end-to-end CI jobs pass for `b8ace289b5e07df5b9f2c319b159f3923ce427b4`. Greptile gives 5/5 with both review findings resolved. All 54 PR checks pass, including the external security scan. The full test suite passed in CI. The duplicate serial local test run was stopped after CI finished; the 108 focused tests, full build, and recursive typecheck passed locally. ## Risks - Video thumbnails require the browser to load metadata and a frame. A slow server or unsupported codec can leave the fallback visible; opening and downloading still use the existing viewer. - Full-row click targets change pointer interaction with work-product cards. Native link and button semantics remain in place. ## Model Used OpenAI GPT-6 in Codex, with code execution and browser tools. The exact deployment ID and context window are not exposed in this 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 - [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 - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
92d4868e79
commit
1ccae464c5
27 files changed
+351
-45
No files matched your search
@@ -72,3 +72,9 @@ This system was built to be steered by instruction. "Make all running indicators
|
||||
- **ESLint ratchet** — will eventually enforce the token rules at lint time; until then `check:token-gates` is the gate.
|
||||
|
||||
See `DECISION-SHEET.md` for the full ledger.
|
||||
|
||||
### Task artifact galleries
|
||||
|
||||
The task Artifacts tab renders images and videos with `MediaArtifactCard`, including agent attachments that have not been promoted to work products. Its container-responsive grid preserves run grouping and chronological order; documents and other outputs span the full width. `RichWorkProductCard` supports `gallery` for media and keeps `card`/`compact` rows for other outputs. Rows use a stretched native link or button so titles, thumbnails, metadata, and padding activate the same action, with keyboard focus and normal link modifiers preserved.
|
||||
|
||||
`ArtifactPreview` is shared with the company gallery. Video previews are muted, never autoplay, and seek up to one second into the clip after metadata loads. Changing the source resets preview state. The existing task gallery handles playback and download. Storybook **Tasks → Artifact Gallery** covers eight generated video outputs, mixed images/files, full-row activation, narrow/expanded panels, empty state, unavailable previews, and light mode using small offline fixtures.
|
||||
@@ -132,6 +132,34 @@ describe("ArtifactCard", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("seeks into clips and resets thumbnail state when the source changes", () => {
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
const render = (contentPath: string) => flushSync(() => root.render(
|
||||
<ArtifactCard artifact={makeArtifact({ mediaKind: "video", contentPath })} />,
|
||||
));
|
||||
try {
|
||||
render("/first.mp4");
|
||||
const first = container.querySelector("video")!;
|
||||
Object.defineProperty(first, "duration", { value: 8 });
|
||||
flushSync(() => first.dispatchEvent(new Event("loadedmetadata")));
|
||||
expect(first.currentTime).toBe(1);
|
||||
flushSync(() => first.dispatchEvent(new Event("seeked")));
|
||||
expect(first.dataset.frameReady).toBe("true");
|
||||
render("/short.mp4");
|
||||
const next = container.querySelector("video")!;
|
||||
expect(next).not.toBe(first);
|
||||
expect(next.dataset.frameReady).toBe("false");
|
||||
Object.defineProperty(next, "duration", { value: 0.2 });
|
||||
flushSync(() => next.dispatchEvent(new Event("loadedmetadata")));
|
||||
expect(next.currentTime).toBe(0.05);
|
||||
expect(next.autoplay).toBe(false);
|
||||
expect(next.muted).toBe(true);
|
||||
} finally {
|
||||
flushSync(() => root.unmount());
|
||||
}
|
||||
});
|
||||
|
||||
it("reveals video previews if the browser does not report seek completion", () => {
|
||||
vi.useFakeTimers();
|
||||
const container = document.createElement("div");
|
||||
|
||||
@@ -32,7 +32,9 @@ function PlaceholderPreview({ label }: { label?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ImagePreview({ artifact }: { artifact: CompanyArtifact }) {
|
||||
type PreviewArtifact = Pick<CompanyArtifact, "mediaKind" | "contentPath" | "title"> & Partial<Pick<CompanyArtifact, "source" | "previewText">>;
|
||||
|
||||
function ImagePreview({ artifact }: { artifact: PreviewArtifact }) {
|
||||
const [errored, setErrored] = useState(false);
|
||||
if (errored || !artifact.contentPath) {
|
||||
return <PlaceholderPreview label="Image" />;
|
||||
@@ -50,7 +52,7 @@ function ImagePreview({ artifact }: { artifact: CompanyArtifact }) {
|
||||
);
|
||||
}
|
||||
|
||||
function VideoPreview({ artifact }: { artifact: CompanyArtifact }) {
|
||||
function VideoPreview({ artifact }: { artifact: PreviewArtifact }) {
|
||||
const [errored, setErrored] = useState(false);
|
||||
const [frameReady, setFrameReady] = useState(false);
|
||||
const thumbnailSeekRequested = useRef(false);
|
||||
@@ -90,7 +92,7 @@ function VideoPreview({ artifact }: { artifact: CompanyArtifact }) {
|
||||
thumbnailSeekRequested.current = true;
|
||||
const video = event.currentTarget;
|
||||
const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0;
|
||||
const seekTarget = duration > 0 ? Math.min(0.12, duration / 2) : 0.05;
|
||||
const seekTarget = duration > 0 ? Math.min(1, duration / 4) : 0.05;
|
||||
try {
|
||||
if (Math.abs(video.currentTime - seekTarget) > 0.001) {
|
||||
video.currentTime = seekTarget;
|
||||
@@ -131,7 +133,7 @@ function VideoPreview({ artifact }: { artifact: CompanyArtifact }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TextPreview({ artifact }: { artifact: CompanyArtifact }) {
|
||||
function TextPreview({ artifact }: { artifact: PreviewArtifact }) {
|
||||
const preview = artifact.previewText?.trim();
|
||||
if (!preview) {
|
||||
return <PlaceholderPreview label={artifact.source === "document" ? "Document" : "Text"} />;
|
||||
@@ -148,12 +150,12 @@ function TextPreview({ artifact }: { artifact: CompanyArtifact }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactPreview({ artifact }: { artifact: CompanyArtifact }) {
|
||||
export function ArtifactPreview({ artifact }: { artifact: PreviewArtifact }) {
|
||||
switch (artifact.mediaKind) {
|
||||
case "image":
|
||||
return <ImagePreview artifact={artifact} />;
|
||||
return <ImagePreview key={artifact.contentPath} artifact={artifact} />;
|
||||
case "video":
|
||||
return <VideoPreview artifact={artifact} />;
|
||||
return <VideoPreview key={artifact.contentPath} artifact={artifact} />;
|
||||
case "text":
|
||||
case "document":
|
||||
return <TextPreview artifact={artifact} />;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useContext, useState, type ReactNode } from "react";
|
||||
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
|
||||
import { ImageGalleryModal } from "@/components/ImageGalleryModal";
|
||||
import { isVideoLikeOutput } from "@/lib/issue-output";
|
||||
import { ArtifactPreview } from "./ArtifactCard";
|
||||
|
||||
/** A media tile shared by uploaded files and attachment-backed work products. */
|
||||
export function MediaArtifactCard({ id, title, contentPath, contentType, originalFilename, downloadPath, detail, badge }: {
|
||||
id: string;
|
||||
title: string;
|
||||
contentPath: string;
|
||||
contentType: string;
|
||||
originalFilename: string;
|
||||
downloadPath?: string;
|
||||
detail?: string;
|
||||
badge?: ReactNode;
|
||||
}) {
|
||||
const openIssueGallery = useContext(IssueGalleryContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
const mediaKind = isVideoLikeOutput(contentType, originalFilename) ? "video" : "image";
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Open gallery: ${title}`}
|
||||
onClick={() => { if (!openIssueGallery?.(contentPath)) setOpen(true); }}
|
||||
className="group flex h-full w-full min-w-0 flex-col overflow-hidden rounded-md border border-border bg-card text-left hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ArtifactPreview artifact={{ title, contentPath, mediaKind }} />
|
||||
<span className="flex w-full flex-1 flex-col gap-1 p-2.5">
|
||||
<span className="line-clamp-2 break-words text-sm font-medium" title={title}>{title}</span>
|
||||
<span className="flex flex-wrap items-center justify-between gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{detail ?? (mediaKind === "video" ? "Video" : "Image")}</span>
|
||||
{badge}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{open ? <ImageGalleryModal items={[{ id, contentPath, contentType, originalFilename, downloadPath }]} initialIndex={0} open onOpenChange={setOpen} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,9 @@ import {
|
||||
selectAgentArtifactAttachments,
|
||||
workProductHref,
|
||||
} from "@/lib/issue-artifacts";
|
||||
import { attachmentOpenPath } from "@/lib/issue-attachments";
|
||||
import { MediaArtifactCard } from "@/components/artifacts/MediaArtifactCard";
|
||||
import { isImageLikeOutput, isVideoLikeOutput } from "@/lib/issue-output";
|
||||
import { attachmentDownloadPath, attachmentOpenPath } from "@/lib/issue-attachments";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { RichWorkProductCard } from "@/components/task-chat/RichWorkProductCard";
|
||||
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "@/components/IssueDocumentAnnotations";
|
||||
@@ -75,7 +77,7 @@ function workProductStatusBadge(status: string): { label: string; cssVar: string
|
||||
}
|
||||
|
||||
const ROW_CLASS =
|
||||
"flex items-center gap-2 rounded-md border border-border bg-card/50 px-2.5 py-1.5 text-sm";
|
||||
"flex items-center gap-2 rounded-md border border-border bg-card/50 px-2.5 py-1.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
||||
|
||||
/**
|
||||
* Work-product row for an eligible Markdown artifact (LOOA-1533 gap): expands
|
||||
@@ -438,7 +440,7 @@ export function IssuePropertiesArtifactsTab({ issue, documentDeepLink, onOpenDoc
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<div className="@container flex flex-col gap-3 py-2">
|
||||
{groupedRows.map((group) => {
|
||||
const run = group.runId === "other" ? null : runsById.get(group.runId);
|
||||
const agent = run ? agentsById.get(run.agentId) : null;
|
||||
@@ -454,7 +456,7 @@ export function IssuePropertiesArtifactsTab({ issue, documentDeepLink, onOpenDoc
|
||||
</time>
|
||||
</header>
|
||||
) : null}
|
||||
<ul className="flex flex-col gap-1">
|
||||
<ul className="grid grid-cols-1 gap-2 @xs:grid-cols-2 @2xl:grid-cols-3">
|
||||
{group.rows.map((row) => {
|
||||
if (row.kind === "work_product") {
|
||||
const wp = row.value;
|
||||
@@ -462,7 +464,7 @@ export function IssuePropertiesArtifactsTab({ issue, documentDeepLink, onOpenDoc
|
||||
if (markdownMetadata) {
|
||||
const reviewKey = artifactReviewDocumentKey(wp.id);
|
||||
return (
|
||||
<li key={row.id}>
|
||||
<li key={row.id} className="col-span-full min-w-0">
|
||||
<MarkdownWorkProductRow
|
||||
issueId={issue.id}
|
||||
workProduct={wp}
|
||||
@@ -473,16 +475,20 @@ export function IssuePropertiesArtifactsTab({ issue, documentDeepLink, onOpenDoc
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const contentType = typeof wp.metadata?.contentType === "string" ? wp.metadata.contentType : "";
|
||||
const filename = typeof wp.metadata?.originalFilename === "string" ? wp.metadata.originalFilename : wp.title;
|
||||
const hasMediaPath = Boolean(wp.metadata?.contentPath || wp.metadata?.openPath || workProductHref(wp));
|
||||
const media = hasMediaPath && wp.type === "artifact" && (isImageLikeOutput(contentType, filename) || isVideoLikeOutput(contentType, filename));
|
||||
return (
|
||||
<li key={row.id}>
|
||||
<RichWorkProductCard workProduct={wp} href={workProductHref(wp)} variant="compact" />
|
||||
<li key={row.id} className={cn("min-w-0", !media && "col-span-full")}>
|
||||
<RichWorkProductCard workProduct={wp} href={workProductHref(wp)} variant={media ? "gallery" : "compact"} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
if (row.kind === "document") {
|
||||
const doc = row.value;
|
||||
return (
|
||||
<li key={row.id}>
|
||||
<li key={row.id} className="col-span-full min-w-0">
|
||||
<DocumentRow
|
||||
issueId={issue.id}
|
||||
doc={doc}
|
||||
@@ -493,8 +499,24 @@ export function IssuePropertiesArtifactsTab({ issue, documentDeepLink, onOpenDoc
|
||||
);
|
||||
}
|
||||
const attachment = row.value;
|
||||
const filename = attachment.originalFilename ?? attachment.objectKey;
|
||||
if (isImageLikeOutput(attachment.contentType, filename) || isVideoLikeOutput(attachment.contentType, filename)) {
|
||||
return (
|
||||
<li key={row.id} className="min-w-0">
|
||||
<MediaArtifactCard
|
||||
id={attachment.id}
|
||||
title={filename}
|
||||
contentPath={attachment.contentPath}
|
||||
contentType={attachment.contentType}
|
||||
originalFilename={filename}
|
||||
downloadPath={attachmentDownloadPath(attachment)}
|
||||
detail={formatBytes(attachment.byteSize)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={row.id}>
|
||||
<li key={row.id} className="col-span-full min-w-0">
|
||||
<a href={attachmentOpenPath(attachment)} target="_blank" rel="noreferrer" className={cn(ROW_CLASS, "hover:bg-accent/50")}>
|
||||
<Paperclip className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{attachment.originalFilename ?? attachment.objectKey}</span>
|
||||
|
||||
@@ -354,6 +354,26 @@ describe("markdown work product review row", () => {
|
||||
expect(container.querySelector("button[aria-expanded]")).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["image/png", "application/octet-stream"])("renders %s media tiles without duplicates or user uploads", async (contentType) => {
|
||||
const image = { ...makeMarkdownAttachment(), contentType, originalFilename: "cover.png" };
|
||||
const looseImage = { ...image, id: "loose-image", contentPath: "/api/attachments/loose-image/content" };
|
||||
const video = { ...makeMarkdownAttachment(), id: "loose-video", contentType: "application/octet-stream", originalFilename: "clip.mp4", contentPath: "/api/attachments/loose-video/content" };
|
||||
mockIssuesApi.listAttachments.mockResolvedValue([image, video, looseImage, { ...video, id: "user-video", createdByAgentId: null, createdByUserId: "user-1" }]);
|
||||
mockIssuesApi.listWorkProducts.mockResolvedValue([makeMarkdownWorkProduct({
|
||||
title: "Cover artwork",
|
||||
metadata: { attachmentId: ATTACHMENT_ID, contentType, originalFilename: "cover.png", contentPath: image.contentPath, openPath: image.contentPath, downloadPath: `${image.contentPath}?download=1`, byteSize: 64 },
|
||||
})]);
|
||||
await renderTab({}, "Cover artwork");
|
||||
await waitForAssertion(() => {
|
||||
const buttons = container.querySelectorAll('button[aria-label^="Open gallery:"]');
|
||||
expect(buttons).toHaveLength(3);
|
||||
expect(container.querySelectorAll("img")).toHaveLength(2);
|
||||
expect(buttons[0].querySelector("img")).not.toBeNull();
|
||||
expect(buttons[1].querySelector("video")?.getAttribute("src")).toBe(video.contentPath);
|
||||
expect(container.querySelectorAll("video")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("groups compact rows by producing run", async () => {
|
||||
const runOne = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const runTwo = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useContext, useState, type CSSProperties } from "react";
|
||||
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
|
||||
import { ArtifactPreview } from "@/components/artifacts/ArtifactCard";
|
||||
import { MediaArtifactCard } from "@/components/artifacts/MediaArtifactCard";
|
||||
import { ImageGalleryModal } from "@/components/ImageGalleryModal";
|
||||
import { isImageContentType, isVideoLikeOutput } from "@/lib/issue-output";
|
||||
import { isImageLikeOutput, isVideoLikeOutput } from "@/lib/issue-output";
|
||||
import { attachmentDownloadPath } from "@/lib/issue-attachments";
|
||||
import type { IssueWorkProduct } from "@paperclipai/shared";
|
||||
import {
|
||||
@@ -124,7 +126,7 @@ function Chip({ chip }: { chip: StateChip }) {
|
||||
export interface RichWorkProductCardProps {
|
||||
workProduct: IssueWorkProduct;
|
||||
href: string | null;
|
||||
variant?: "card" | "compact";
|
||||
variant?: "card" | "compact" | "gallery";
|
||||
}
|
||||
|
||||
export function RichWorkProductCard({ workProduct, href, variant = "card" }: RichWorkProductCardProps) {
|
||||
@@ -132,8 +134,8 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric
|
||||
const [galleryOpen, setGalleryOpen] = useState(false);
|
||||
const metadata = workProduct.metadata;
|
||||
const contentType = stringMeta(metadata, "contentType") ?? "";
|
||||
const isImage = isImageContentType(contentType);
|
||||
const isVideo = isVideoLikeOutput(contentType, stringMeta(metadata, "originalFilename"));
|
||||
const isImage = isImageLikeOutput(contentType, stringMeta(metadata, "originalFilename") ?? workProduct.title);
|
||||
const isVideo = isVideoLikeOutput(contentType, stringMeta(metadata, "originalFilename") ?? workProduct.title);
|
||||
let Icon: LucideIcon = File;
|
||||
let meta: Array<string | null> = [];
|
||||
let action = "Open preview";
|
||||
@@ -214,10 +216,6 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric
|
||||
const fileCount = files === null ? null : `${files} ${files === 1 ? "file" : "files"}`;
|
||||
const statsLabel = [changeCounts || null, fileCount].filter(Boolean).join(" · ");
|
||||
const compact = variant === "compact";
|
||||
const imagePath = isImage
|
||||
? stringMeta(metadata, "openPath", "contentPath") ?? href
|
||||
: null;
|
||||
|
||||
const mediaPath = workProduct.type === "artifact" && (isImage || isVideo)
|
||||
? stringMeta(metadata, "contentPath", "openPath") ?? href
|
||||
: null;
|
||||
@@ -232,10 +230,26 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric
|
||||
if (mediaPath && !openIssueGallery?.(mediaPath)) setGalleryOpen(true);
|
||||
};
|
||||
|
||||
if (variant === "gallery" && mediaPath) {
|
||||
return (
|
||||
<MediaArtifactCard
|
||||
id={workProduct.id}
|
||||
title={workProduct.title}
|
||||
contentPath={mediaPath}
|
||||
contentType={contentType}
|
||||
originalFilename={stringMeta(metadata, "originalFilename") ?? workProduct.title}
|
||||
downloadPath={stringMeta(metadata, "downloadPath") ?? undefined}
|
||||
detail={visibleMeta.join(" · ")}
|
||||
badge={chip ? <Chip chip={chip} /> : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"@container flex min-w-0 rounded-md border border-border bg-card/60",
|
||||
"@container relative flex min-w-0 rounded-md border border-border bg-card/60",
|
||||
(mediaPath || actionHref) && "hover:bg-accent/50",
|
||||
compact ? "items-center gap-2 px-2.5 py-1.5" : "items-start gap-3 px-3 py-2.5",
|
||||
)}
|
||||
data-testid={`task-chat-rich-work-product-${workProduct.type}`}
|
||||
@@ -243,10 +257,10 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric
|
||||
>
|
||||
<div className={cn(
|
||||
"flex shrink-0 items-center justify-center overflow-hidden rounded-sm bg-muted/60 text-muted-foreground",
|
||||
compact ? "h-8 w-8" : "h-10 w-10",
|
||||
mediaPath ? "w-20" : compact ? "h-8 w-8" : "h-10 w-10",
|
||||
)}>
|
||||
{imagePath ? (
|
||||
<img src={imagePath} alt="" className="h-full w-full object-cover" />
|
||||
{mediaPath ? (
|
||||
<ArtifactPreview artifact={{ contentPath: mediaPath, title: workProduct.title, mediaKind: isVideo ? "video" : "image" }} />
|
||||
) : (
|
||||
<Icon aria-hidden className={compact ? "h-4 w-4" : "h-5 w-5"} />
|
||||
)}
|
||||
@@ -259,11 +273,11 @@ export function RichWorkProductCard({ workProduct, href, variant = "card" }: Ric
|
||||
<div className={cn("flex shrink-0 items-center", compact ? "gap-1.5" : "gap-2")}>
|
||||
{chip ? <Chip chip={chip} /> : null}
|
||||
{mediaPath ? (
|
||||
<button type="button" onClick={openGallery} aria-label={`${action}: ${workProduct.title}`} className="inline-flex items-center gap-1 text-xs font-medium text-foreground hover:underline">
|
||||
<button type="button" onClick={openGallery} aria-label={`${action}: ${workProduct.title}`} className="inline-flex items-center gap-1 text-xs font-medium text-foreground after:absolute after:inset-0 after:rounded-md focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-ring">
|
||||
{compact ? null : <span className="hidden @sm:inline">{action}</span>}<Maximize2 aria-hidden className="h-3 w-3" />
|
||||
</button>
|
||||
) : actionHref ? (
|
||||
<a href={actionHref} aria-label={`${action}: ${workProduct.title}`} className="inline-flex items-center gap-1 text-xs font-medium text-foreground hover:underline" target={actionHref.startsWith("http") ? "_blank" : undefined} rel={actionHref.startsWith("http") ? "noreferrer" : undefined}>
|
||||
<a href={actionHref} aria-label={`${action}: ${workProduct.title}`} className="inline-flex items-center gap-1 text-xs font-medium text-foreground after:absolute after:inset-0 after:rounded-md focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-ring" target={actionHref.startsWith("http") ? "_blank" : undefined} rel={actionHref.startsWith("http") ? "noreferrer" : undefined}>
|
||||
{compact ? null : <span className="hidden @sm:inline">{action}</span>}<ExternalLink aria-hidden className="h-3 w-3" />
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
@@ -94,6 +94,33 @@ describe("TaskChatBubble attachment chips", () => {
|
||||
expect(container.textContent).toContain("PDF");
|
||||
});
|
||||
|
||||
it("opens video thumbnails in a local gallery when no task gallery is available", () => {
|
||||
renderMessage("[clip.mp4](/api/attachments/clip/content)", "agent", [
|
||||
attachment({ id: "clip", originalFilename: "clip.mp4", contentType: "video/mp4" }),
|
||||
]);
|
||||
const group = container.querySelector('[data-testid="task-chat-bubble-attachments"]');
|
||||
expect(group?.querySelector("video")?.getAttribute("src")).toBe("/api/attachments/clip/content");
|
||||
expect(group?.querySelector("video")?.autoplay).toBe(false);
|
||||
flushSync(() => group!.querySelector<HTMLButtonElement>('button[aria-label="Open clip.mp4"]')!.click());
|
||||
expect(document.querySelector('[role="dialog"] video')?.getAttribute("src")).toBe("/api/attachments/clip/content");
|
||||
expect(document.querySelector('[role="dialog"] a[download]')?.getAttribute("href")).toBe("/api/attachments/clip/content?download=1");
|
||||
});
|
||||
|
||||
it.each(["video/mp4", "application/octet-stream"])("opens %s video attachments in the shared task gallery", (contentType) => {
|
||||
const openGallery = vi.fn(() => true);
|
||||
const clip = attachment({ id: "clip", originalFilename: "clip.mp4", contentType });
|
||||
flushSync(() => root!.render(
|
||||
<ThemeProvider>
|
||||
<IssueGalleryContext.Provider value={openGallery}>
|
||||
<TaskChatBubble item={{ id: "m1", kind: "message", author: "agent", text: `[clip.mp4](${clip.contentPath})` }} attachments={[clip]} />
|
||||
</IssueGalleryContext.Provider>
|
||||
</ThemeProvider>,
|
||||
));
|
||||
flushSync(() => container.querySelector<HTMLButtonElement>('button[aria-label="Open clip.mp4"]')!.click());
|
||||
expect(openGallery).toHaveBeenCalledWith(clip.contentPath);
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves messages without file references untouched", () => {
|
||||
renderMessage("Just words and a [normal link](https://example.com).");
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ArtifactPreview } from "@/components/artifacts/ArtifactCard";
|
||||
import { isVideoLikeOutput } from "@/lib/issue-output";
|
||||
import { AgentAvatar, type AvatarAgent } from "../AgentAvatar";
|
||||
import { useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { useEmailComment } from "@/components/EmailMessageCard";
|
||||
@@ -96,7 +98,7 @@ export function TaskChatAgentIdentity({
|
||||
* surface with an avatar author header (the agent's assigned icon + name);
|
||||
* system notices are centered and recede.
|
||||
*/
|
||||
function galleryItemForImage(
|
||||
function galleryItemForMedia(
|
||||
src: string,
|
||||
name?: string,
|
||||
attachment?: ReturnType<typeof hydrateAttachmentRefs>[number],
|
||||
@@ -198,16 +200,17 @@ function TaskChatBubbleContent({
|
||||
...hydratedLinkedRefs.filter((ref) => !isImageAttachment(ref)),
|
||||
...boundAttachmentRefs.filter((ref) => !isImageAttachment(ref)),
|
||||
]);
|
||||
const mediaRefs = [...imageRefs, ...attachmentRefs.filter((ref) => isVideoLikeOutput(ref.contentType, ref.name))];
|
||||
const galleryItems: GalleryMediaItem[] =
|
||||
lightboxSrc !== null && !imageRefs.some((ref) => ref.url === lightboxSrc)
|
||||
lightboxSrc !== null && !mediaRefs.some((ref) => ref.url === lightboxSrc)
|
||||
? // A clicked image the extractor missed (e.g. inline HTML) still gets a
|
||||
// single-item lightbox rather than nothing.
|
||||
[galleryItemForImage(lightboxSrc)]
|
||||
: imageRefs.map((ref) => galleryItemForImage(ref.url, ref.name, ref));
|
||||
[galleryItemForMedia(lightboxSrc)]
|
||||
: mediaRefs.map((ref) => galleryItemForMedia(ref.url, ref.name, ref));
|
||||
const lightboxIndex =
|
||||
lightboxSrc === null
|
||||
? -1
|
||||
: Math.max(0, imageRefs.findIndex((ref) => ref.url === lightboxSrc));
|
||||
: Math.max(0, mediaRefs.findIndex((ref) => ref.url === lightboxSrc));
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -308,10 +311,11 @@ function TaskChatBubbleContent({
|
||||
const kind = fileKindForAttachment(ref);
|
||||
const KindIcon = kind.icon;
|
||||
const size = formatFileSize(ref.byteSize);
|
||||
const video = isVideoLikeOutput(ref.contentType, ref.name);
|
||||
return (
|
||||
<Attachment key={ref.url} size="sm">
|
||||
<AttachmentMedia>
|
||||
<KindIcon aria-hidden />
|
||||
<AttachmentMedia className={video ? "aspect-video group-data-[size=sm]/attachment:w-20" : undefined}>
|
||||
{video ? <ArtifactPreview artifact={{ title: ref.name, contentPath: ref.openPath ?? ref.url, mediaKind: "video" }} /> : <KindIcon aria-hidden />}
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle className="max-w-48">
|
||||
@@ -323,7 +327,8 @@ function TaskChatBubbleContent({
|
||||
</AttachmentContent>
|
||||
<AttachmentTrigger
|
||||
aria-label={`Open ${ref.name}`}
|
||||
render={
|
||||
onClick={video ? () => openImage(ref.url) : undefined}
|
||||
render={video ? <button type="button" /> :
|
||||
<a
|
||||
href={ref.openPath ?? ref.url}
|
||||
target="_blank"
|
||||
|
||||
@@ -181,7 +181,10 @@ describe("TaskChatProtocolCard", () => {
|
||||
expect(container.textContent).toContain("Open gallery");
|
||||
});
|
||||
|
||||
it.each(["image/png", "video/webm"])("opens %s artifacts in the task gallery", (contentType) => {
|
||||
it.each([
|
||||
["image/png", "compact"], ["video/webm", "compact"],
|
||||
["image/png", "gallery"], ["video/webm", "gallery"],
|
||||
] as const)("opens %s artifacts from the %s presentation in the task gallery", (contentType, variant) => {
|
||||
const openGallery = vi.fn(() => true);
|
||||
const contentPath = "/api/attachments/media/content";
|
||||
flushSync(() => root.render(
|
||||
@@ -189,7 +192,7 @@ describe("TaskChatProtocolCard", () => {
|
||||
<RichWorkProductCard
|
||||
workProduct={workProduct({ type: "artifact", metadata: { contentType, contentPath } })}
|
||||
href={contentPath}
|
||||
variant="compact"
|
||||
variant={variant}
|
||||
/>
|
||||
</IssueGalleryContext.Provider>,
|
||||
));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IssueAttachment } from "@paperclipai/shared";
|
||||
import { isMarkdownAttachmentContent } from "@paperclipai/shared";
|
||||
import { isVideoLikeOutput } from "./issue-output";
|
||||
import { isImageLikeOutput, isVideoLikeOutput } from "./issue-output";
|
||||
|
||||
type AttachmentPathLike = {
|
||||
contentPath: string;
|
||||
@@ -24,9 +24,9 @@ export function attachmentDownloadPath(attachment: AttachmentPathLike) {
|
||||
return attachment.downloadPath ?? `${attachment.contentPath}?download=1`;
|
||||
}
|
||||
|
||||
export function isImageAttachment(attachment: Pick<IssueAttachment, "contentType">) {
|
||||
export function isImageAttachment(attachment: Pick<IssueAttachment, "contentType"> & Partial<Pick<IssueAttachment, "originalFilename">>) {
|
||||
const type = normalizedContentType(attachment);
|
||||
return type.startsWith("image/") && !/^image\/hei[cf](?:-sequence)?$/.test(type);
|
||||
return isImageLikeOutput(type, attachment.originalFilename) && !/^image\/hei[cf](?:-sequence)?$/.test(type);
|
||||
}
|
||||
|
||||
export function isVideoAttachment(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getOutputFileGlyph,
|
||||
getPromotedOutputAttachmentIds,
|
||||
isOutputEligibleContentType,
|
||||
isImageLikeOutput,
|
||||
} from "./issue-output";
|
||||
|
||||
function makeWorkProduct(overrides: Partial<IssueWorkProduct> & { id: string }): IssueWorkProduct {
|
||||
@@ -235,3 +236,14 @@ describe("getPromotedOutputAttachmentIds", () => {
|
||||
expect(Array.from(ids)).toEqual([videoAttachmentId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isImageLikeOutput", () => {
|
||||
it.each(["image/png", "application/octet-stream", "binary/octet-stream", "application/x-binary", ""])("recognizes images with %s MIME type", (contentType) => {
|
||||
expect(isImageLikeOutput(contentType, "Cover.PNG")).toBe(true);
|
||||
});
|
||||
it("respects specific non-image MIME types and unsupported filenames", () => {
|
||||
expect(isImageLikeOutput("text/plain", "cover.png")).toBe(false);
|
||||
expect(isImageLikeOutput("application/octet-stream", "archive.zip")).toBe(false);
|
||||
expect(isImageLikeOutput("application/octet-stream", "image.heic")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,8 @@ const VIDEO_FILENAME_EXTENSIONS = [
|
||||
".quicktime",
|
||||
];
|
||||
|
||||
const IMAGE_FILENAME_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".bmp", ".svg", ".ico"];
|
||||
|
||||
const BINARY_OUTPUT_APPLICATION_TYPES = new Set([
|
||||
"application/wasm",
|
||||
]);
|
||||
@@ -226,6 +228,18 @@ export function isImageContentType(contentType: string | null | undefined): bool
|
||||
return normalizeOutputContentType(contentType).startsWith("image/");
|
||||
}
|
||||
|
||||
/** Recognize image uploads with missing or generic MIME types by filename. */
|
||||
export function isImageLikeOutput(
|
||||
contentType: string | null | undefined,
|
||||
originalFilename?: string | null,
|
||||
): boolean {
|
||||
const type = normalizeOutputContentType(contentType);
|
||||
if (isImageContentType(type)) return true;
|
||||
if (type && !GENERIC_BINARY_CONTENT_TYPES.has(type)) return false;
|
||||
const filename = (originalFilename ?? "").trim().toLowerCase();
|
||||
return IMAGE_FILENAME_EXTENSIONS.some((extension) => filename.endsWith(extension));
|
||||
}
|
||||
|
||||
/**
|
||||
* A single rendered output. `metadata` is null when the work product's stored
|
||||
* metadata fails validation — the row is still surfaced (degraded) so we never
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MediaArtifactCard } from "@/components/artifacts/MediaArtifactCard";
|
||||
import { WebhookUrlWarning } from "@/components/routine-triggers/WebhookUrlWarning";
|
||||
import { SetupWizardNavigation, SetupWizardFooter } from "../components/SetupWizard";
|
||||
import { RemoteMcpDesignExample } from "@/features/connections/remote-mcp/RemoteMcpDesignExample";
|
||||
@@ -2308,6 +2309,14 @@ export function DesignGuide() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Media artifacts">
|
||||
<p className="text-sm text-muted-foreground">Images and videos use gallery tiles. The whole tile opens the task gallery; files and links keep compact, fully clickable rows. Task/Artifact Gallery in Storybook covers playable videos, mixed files, narrow panels, and unavailable previews.</p>
|
||||
<div className="grid max-w-2xl grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<MediaArtifactCard id="design-image" title="Launch artwork" contentPath="/announcement-preview.svg" contentType="image/svg+xml" originalFilename="launch.svg" detail="Image" />
|
||||
<MediaArtifactCard id="design-video" title="Video preview unavailable" contentPath="" contentType="video/mp4" originalFilename="preview.mp4" detail="Video" />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="AI Connections">
|
||||
<AiConnectionDesignExamples />
|
||||
</Section>
|
||||
|
||||
@@ -192,7 +192,7 @@ import { isImageAttachment, isVideoAttachment } from "../lib/issue-attachments";
|
||||
import {
|
||||
getIssueOutputs,
|
||||
getPromotedOutputAttachmentIds,
|
||||
isImageContentType,
|
||||
isImageLikeOutput,
|
||||
isVideoLikeOutput,
|
||||
} from "../lib/issue-output";
|
||||
import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation";
|
||||
@@ -5532,7 +5532,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
const meta = item.metadata;
|
||||
if (!meta) continue;
|
||||
const isMedia =
|
||||
isImageContentType(meta.contentType) ||
|
||||
isImageLikeOutput(meta.contentType, meta.originalFilename ?? item.title) ||
|
||||
isVideoLikeOutput(meta.contentType, meta.originalFilename);
|
||||
if (!isMedia || hasSeen(meta.attachmentId, meta.contentPath)) continue;
|
||||
items.push({
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Synthetic offline media fixtures for the artifact gallery stories. These illustrative style cards are not copies of production videos. Three-second H.264 clips fade in from black so real thumbnail seeking and playback can be checked. PNGs are the matching artwork. No production data or remote requests.
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,102 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { IssueAttachment, IssueWorkProduct } from "@paperclipai/shared";
|
||||
import { IssuePropertiesArtifactsTab } from "@/components/issue-properties/IssuePropertiesArtifactsTab";
|
||||
import { RichWorkProductCard } from "@/components/task-chat/RichWorkProductCard";
|
||||
import { ImageGalleryModal, type GalleryMediaItem } from "@/components/ImageGalleryModal";
|
||||
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { isImageLikeOutput, isVideoLikeOutput } from "@/lib/issue-output";
|
||||
import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
|
||||
import { workProductHref } from "@/lib/issue-artifacts";
|
||||
import { createIssue, storybookAgents } from "../fixtures/paperclipData";
|
||||
import manila from "../fixtures/artifact-media/manila-ledger.mp4?url";
|
||||
import night from "../fixtures/artifact-media/night-pills.mp4?url";
|
||||
import tickets from "../fixtures/artifact-media/ticket-board.mp4?url";
|
||||
import type from "../fixtures/artifact-media/big-type.mp4?url";
|
||||
import trail from "../fixtures/artifact-media/paper-trail.mp4?url";
|
||||
import manilaImage from "../fixtures/artifact-media/manila-ledger.png?url";
|
||||
import nightImage from "../fixtures/artifact-media/night-pills.png?url";
|
||||
|
||||
const issue = createIssue({ id: "artifact-gallery-story", identifier: "DEMO-101", title: "Explore Paperclip Ships styles", status: "done" });
|
||||
const date = new Date("2026-09-22T18:03:00Z");
|
||||
const media = [["Paper Trail", trail], ["Big Type", type], ["Ticket Board", tickets], ["Night Pills", night], ["Manila Ledger", manila]];
|
||||
function product(title: string, index: number, overrides: Partial<IssueWorkProduct> = {}): IssueWorkProduct {
|
||||
return {
|
||||
id: `gallery-product-${index}`, companyId: issue.companyId, projectId: null, issueId: issue.id,
|
||||
executionWorkspaceId: null, runtimeServiceId: null, type: "artifact", provider: "paperclip",
|
||||
externalId: null, title, url: null, status: "approved", reviewState: "none", isPrimary: false,
|
||||
healthStatus: "unknown", summary: null, metadata: null, createdByRunId: "style-exploration",
|
||||
createdAt: new Date(date.getTime() - index * 1000), updatedAt: date, ...overrides,
|
||||
} as IssueWorkProduct;
|
||||
}
|
||||
const videos = [...media, ["Motion concept — Sep 21", manila], ["Motion concept — Sep 19", night], ["Motion concept — Sep 18", tickets]].map(([name, src], index) => product(`Paperclip Ships — ${name}`, index, {
|
||||
createdByRunId: index < 5 ? "style-exploration" : "first-concepts",
|
||||
metadata: { contentType: "video/mp4", contentPath: src, openPath: src, originalFilename: `${name.toLowerCase().replaceAll(" ", "-")}.mp4`, byteSize: 128_000 },
|
||||
}));
|
||||
const files = [
|
||||
product("Style exploration notes.txt", 20, { metadata: { contentType: "text/plain", byteSize: 2400, downloadPath: "data:text/plain,Five%20Paperclip%20Ships%20style%20directions", contentPath: "data:text/plain,Five%20Paperclip%20Ships%20style%20directions" } }),
|
||||
product("Preview the daily ships page", 21, { type: "preview_url", url: "https://paperclip.ing", metadata: null }),
|
||||
product("Source package is still being prepared", 22, { status: "pending" }),
|
||||
];
|
||||
const images = [manilaImage, nightImage].map((src, index) => product(index ? "Night Pills — cover image" : "Manila Ledger — cover image", index + 10, {
|
||||
metadata: { contentType: index ? "application/octet-stream" : "image/png", contentPath: src, originalFilename: `cover-${index}.png`, byteSize: 42000 },
|
||||
}));
|
||||
|
||||
type Scenario = "videos" | "mixed" | "rows" | "fallback" | "empty";
|
||||
function GalleryStory({ scenario = "videos", width = 480 }: { scenario?: Scenario; width?: number }) {
|
||||
const workProducts = scenario === "empty" ? [] : scenario === "rows" ? [...videos.slice(0, 1), ...images.slice(0, 1), ...files] : scenario === "mixed" ? [...videos.slice(0, 2), ...images, ...files] : scenario === "fallback" ? [product("Video preview unavailable", 30, { metadata: { contentType: "video/mp4", contentPath: "data:video/mp4;base64,AA==", originalFilename: "unavailable.mp4" } }), product("Image preview unavailable", 31, { metadata: { contentType: "image/png", contentPath: "data:image/png;base64,AA==" } })] : videos;
|
||||
const attachments = scenario === "mixed" ? [{ id: "loose-image", companyId: issue.companyId, issueId: issue.id, createdByAgentId: storybookAgents[0].id, contentPath: nightImage, contentType: "image/png", originalFilename: "Unpromoted agent attachment.png", objectKey: "cover.png", byteSize: 42000, createdAt: date } as IssueAttachment] : [];
|
||||
const [client] = useState(() => {
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
|
||||
cache.setQueryData(queryKeys.issues.workProducts(issue.id), workProducts);
|
||||
cache.setQueryData(queryKeys.issues.attachments(issue.id), attachments);
|
||||
cache.setQueryData([...queryKeys.issues.documents(issue.id), "list"], []);
|
||||
cache.setQueryData(queryKeys.issues.runs(issue.id), [
|
||||
{ runId: "style-exploration", agentId: storybookAgents[0].id, startedAt: date },
|
||||
{ runId: "first-concepts", agentId: storybookAgents[0].id, startedAt: new Date("2026-09-22T17:43:00Z") },
|
||||
]);
|
||||
cache.setQueryData(queryKeys.agents.list(issue.companyId), [{ ...storybookAgents[0], name: "CodexRunner" }]);
|
||||
return cache;
|
||||
});
|
||||
const [galleryIndex, setGalleryIndex] = useState<number | null>(null);
|
||||
const items: GalleryMediaItem[] = [...workProducts.flatMap((wp) => {
|
||||
const metadata = wp.metadata;
|
||||
if (typeof metadata?.contentPath !== "string" || typeof metadata?.contentType !== "string" || !(isImageLikeOutput(metadata.contentType, String(metadata.originalFilename ?? wp.title)) || isVideoLikeOutput(metadata.contentType, String(metadata.originalFilename ?? wp.title)))) return [];
|
||||
return [{ id: wp.id, contentPath: metadata.contentPath, contentType: metadata.contentType, originalFilename: typeof metadata.originalFilename === "string" ? metadata.originalFilename : wp.title }];
|
||||
}), ...attachments.map((attachment) => ({ id: attachment.id, contentPath: attachment.contentPath, contentType: attachment.contentType, originalFilename: attachment.originalFilename }))];
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<IssueGalleryContext.Provider value={(src) => { const index = items.findIndex((item) => item.contentPath === src); if (index < 0) return false; setGalleryIndex(index); return true; }}>
|
||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-start gap-8">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3" style={{ minWidth: 240 }}>
|
||||
<p className="font-mono text-xs text-muted-foreground">Media artifacts · Design review</p>
|
||||
<h1 className="text-xl font-semibold">Paperclip Ships artifacts</h1>
|
||||
<p className="max-w-md text-sm text-muted-foreground">Eight video outputs across two runs. Compare previews at a glance, then click anywhere on a tile to watch it.</p>
|
||||
<p className="max-w-md text-xs text-muted-foreground">Illustrative offline clips inspired by the task’s five style directions. These stories use the production artifact components.</p>
|
||||
{scenario === "rows" ? <div className="flex flex-col gap-2"><TaskChatBubble item={{ id: "clip-comment", kind: "message", author: "agent", text: "Video ready to review." }} attachments={[{ id: "chat-clip", issueCommentId: "clip-comment", contentPath: trail, contentType: "video/mp4", originalFilename: "paper-trail.mp4", byteSize: 128000 } as IssueAttachment]} />{workProducts.map((wp) => <RichWorkProductCard key={wp.id} workProduct={wp} href={workProductHref(wp)} variant="compact" />)}</div> : null}
|
||||
</div>
|
||||
<section className="max-w-full shrink-0 rounded-lg border border-border bg-background" style={{ width }} aria-label="Task artifacts panel">
|
||||
<header className="flex items-center gap-4 border-b border-border px-4 py-3 text-sm"><span className="text-muted-foreground">Properties</span><strong className="font-medium">Artifacts</strong></header>
|
||||
<div className="p-3"><IssuePropertiesArtifactsTab issue={issue} /></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
{galleryIndex !== null ? <ImageGalleryModal items={items} initialIndex={galleryIndex} open onOpenChange={(open) => { if (!open) setGalleryIndex(null); }} /> : null}
|
||||
</IssueGalleryContext.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
const meta = { title: "Tasks/Artifact Gallery", component: GalleryStory, parameters: { layout: "fullscreen" }, args: { scenario: "videos", width: 480 }, render: (args, context) => <GalleryStory key={`${context.id}-${args.scenario}`} {...args} /> } satisfies Meta<typeof GalleryStory>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const EightVideoOutputs: Story = {};
|
||||
export const MixedMediaAndFiles: Story = { args: { scenario: "mixed" } };
|
||||
export const WholeRowClickable: Story = { args: { scenario: "rows" } };
|
||||
export const NarrowPanel: Story = { args: { width: 320 } };
|
||||
export const ExpandedPanel: Story = { args: { width: 800 } };
|
||||
export const UnavailablePreviews: Story = { args: { scenario: "fallback" } };
|
||||
export const Empty: Story = { args: { scenario: "empty" } };
|
||||
export const Light: Story = { globals: { theme: "light" } };
|
||||
Reference in new issue
Block a user