Artifact Preview
SaveA file an agent produced: name, type, size, status, an inline preview, and where to open it.
- Category
- Agent
- Version
- 0.2.0
- Frameworks
- Next.js, Vite + React
- Styling
- Tailwind CSS v4
Preview
Artifact Preview preview
rate-limits.csv
text/csv · 58 B
plan,requests_per_minute free,60 pro,600
Installation
npx @seamlessui/cli add artifact-previewPre-release@seamlessui/cli is not on npm yet. Install from a local build.
Review the plan first. A dry run lists every file and package without changing anything:
npx @seamlessui/cli add artifact-preview --dry-runUsage
artifact-preview-example.tsx
import { ArtifactPreview } from "@/components/seamless/agent/artifact-preview";
export default function ArtifactPreviewExample() {
return (
<ArtifactPreview
className="w-full max-w-md"
name="rate-limits.csv"
type="text/csv"
status="ready"
path="/artifacts/rate-limits.csv"
sizeBytes={58}
content={"plan,requests_per_minute\nfree,60\npro,600"}
/>
);
}States
Every state, rendered from local fixture data.
Artifact Preview states
report.md
text/markdown
Writing the file
report.md
text/markdown · 30 B
# Report All checks passed.
report.md
text/markdown
The source page timed out.
API
| Prop | Type | Default | Description |
|---|---|---|---|
| name / type | string | — | File name and MIME type. |
| status | "generating" | "ready" | "failed" | — | Generating shows a static placeholder and a spinner; failed shows the error. |
| path | string | — | Where to open or download the file. Images preview from this path. |
| content | string | — | Text to preview for text and JSON types, rendered as text. |
| sizeBytes / error | number / string | — | Optional size and failure message. |
Accessibility
- Status is a text badge in a live region.
- The download link names the file for screen readers.
- The generating placeholder is decorative and static; only its small spinner turns, and it holds still with reduced motion.
Files
Installed into src/components/seamless, or components/seamless when the project has no src directory.
src/components/seamless/agent/artifact-preview.tsxartifact-preview- Also installs badge@0.2.0registry dependency
- Also installs agent-run@0.1.1registry dependency
- npm packages: noneReact and Tailwind CSS v4 required
Source
The exact source the CLI installs, from /r/items/react/artifact-preview/0.2.0.json.
agent/artifact-preview.tsxShowHide
agent/artifact-preview.tsx
import type { ComponentProps } from "react";
import { Badge, type BadgeTone } from "../ui/badge";
import type { ArtifactStatus } from "./agent-run";
const statuses: Record<ArtifactStatus, { label: string; tone: BadgeTone }> = {
generating: { label: "Generating", tone: "info" },
ready: { label: "Ready", tone: "success" },
failed: { label: "Failed", tone: "danger" },
};
const enter =
"transition-[opacity,translate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] starting:translate-y-1 starting:opacity-0 motion-reduce:transition-none";
export interface ArtifactPreviewProps extends Omit<ComponentProps<"article">, "children"> {
name: string;
/** MIME type, e.g. text/markdown, text/csv, application/json, image/png. */
type: string;
status: ArtifactStatus;
/** Where to open or download the artifact once it is ready. */
path?: string;
/** Text content to preview for text-like types. Rendered as text, never HTML. */
content?: string;
sizeBytes?: number;
error?: string;
}
/** A file an agent produced: name, type, size, status, a preview, and where to open it. */
export function ArtifactPreview({ name, type, status, path, content, sizeBytes, error, className, ...props }: ArtifactPreviewProps) {
const meta = statuses[status];
const isImage = type.startsWith("image/");
const isText = type.startsWith("text/") || type === "application/json";
return (
<article
data-status={status}
aria-busy={status === "generating" || undefined}
className={[
"overflow-hidden rounded-xl border bg-white text-zinc-950 shadow-[0_1px_2px_rgb(0_0_0/0.04)] transition-[border-color] duration-200 motion-reduce:transition-none",
"dark:bg-black dark:text-white dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06)]",
"border-zinc-950/10 dark:border-white/10",
className,
]
.filter(Boolean)
.join(" ")}
{...props}
>
<header className="flex items-center gap-3 px-4 py-3">
<FileIcon extension={extensionOf(name)} />
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-medium">{name}</h3>
<p className="truncate font-mono text-xs text-zinc-500">
{type}
{sizeBytes != null ? ` · ${formatSize(sizeBytes)}` : ""}
</p>
</div>
<span role="status" className="shrink-0">
<Badge tone={meta.tone}>{meta.label}</Badge>
</span>
</header>
<div key={status} className={`border-t border-zinc-950/[0.07] p-4 dark:border-white/[0.08] ${enter}`}>
{status === "generating" ? (
<div className="grid gap-3">
<div aria-hidden="true" className="grid gap-2">
{["w-11/12", "w-4/5", "w-3/5"].map((width) => (
<div key={width} className={`h-2 rounded-full bg-zinc-950/[0.06] dark:bg-white/[0.08] ${width}`} />
))}
</div>
<p className="flex items-center gap-2 text-xs text-zinc-500">
<Spinner />
Writing the file
</p>
</div>
) : status === "failed" ? (
<p className="flex items-start gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="mt-0.5 size-4 shrink-0 text-red-600 dark:text-red-400">
<circle cx="8" cy="8" r="6.25" />
<path d="m6.1 6.1 3.8 3.8m0-3.8-3.8 3.8" />
</svg>
<span>{error ?? "The artifact could not be generated."}</span>
</p>
) : isImage && path ? (
<img
src={path}
alt={`Preview of ${name}`}
className="max-h-64 rounded-lg border border-zinc-950/[0.07] object-contain dark:border-white/[0.08]"
/>
) : isText && content != null ? (
<pre
className={`max-h-64 overflow-auto rounded-lg ${type === "text/markdown" || type === "text/plain" ? "break-words whitespace-pre-wrap" : ""} border border-zinc-950/[0.07] bg-zinc-950/[0.02] px-3 py-2.5 font-mono text-xs leading-relaxed text-zinc-800 dark:border-white/[0.08] dark:bg-transparent dark:text-zinc-300`}
>
{content}
</pre>
) : (
<p className="text-sm text-zinc-500">No preview for this file type.</p>
)}
</div>
{status === "ready" && path ? (
<footer className="flex items-center justify-between gap-3 border-t border-zinc-950/[0.07] px-4 py-2.5 dark:border-white/[0.08]">
<code className="min-w-0 truncate font-mono text-xs text-zinc-500">{path}</code>
<a
href={path}
download={name}
className={[
"inline-flex h-7 shrink-0 items-center gap-1.5 rounded-lg border border-zinc-950/12 px-2.5 text-xs font-medium text-zinc-800",
"transition-colors duration-150 hover:border-zinc-950/20 hover:bg-zinc-950/[0.03] motion-reduce:transition-none",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500",
"dark:border-white/15 dark:text-zinc-200 dark:hover:border-white/25 dark:hover:bg-white/[0.06]",
].join(" ")}
>
<svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="size-3.5">
<path d="M8 2.75v7.5M4.75 7 8 10.25 11.25 7M3 13.25h10" />
</svg>
Download<span className="sr-only"> {name}</span>
</a>
</footer>
) : null}
</article>
);
}
/** A file tile: the extension in mono when there is one, otherwise a page outline. */
function FileIcon({ extension }: { extension: string }) {
return (
<span
aria-hidden="true"
className="grid size-9 shrink-0 place-items-center rounded-lg border border-zinc-950/10 font-mono text-[11px] font-medium text-zinc-600 dark:border-white/10 dark:text-zinc-400"
>
{extension || (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinejoin="round" className="size-5">
<path d="M5.5 2.75h6l3 3v11.5h-9z" />
<path d="M11.5 2.75v3h3" />
</svg>
)}
</span>
);
}
function Spinner() {
return (
<svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="size-3.5 shrink-0 text-sky-600 dark:text-sky-400">
<circle cx="8" cy="8" r="6.25" strokeOpacity="0.2" />
<path d="M8 1.75A6.25 6.25 0 1 1 1.75 8" className="origin-center motion-safe:animate-spin" />
</svg>
);
}
function extensionOf(name: string): string {
const match = name.match(/\.([a-z0-9]{1,4})$/i);
return match ? match[1]!.toLowerCase() : "";
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}