AI

Artifact Panel

A side panel for the file the assistant keeps rewriting — a revision rail with the prompt behind each one, preview / source / changes, per-revision scroll memory, and copy plus download that always take the revision you are looking at.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  Check,
  Copy,
  CornerUpLeft,
  Download,
  FileCode2,
  FileImage,
  FileText,
  Loader2,
  Sparkles,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/artifact-panel.json

Prompt

Build a React + TypeScript + Tailwind "ArtifactPanel" component (lucide-react
icons, zod for the contract, and the library's StreamingText for the Markdown
preview).

Contract
- zod, one source of truth:
  ArtifactVersion { id: string; revision: number (1-based); content: string
    (the WHOLE text of this revision, never a patch); createdAt: ISO 8601;
    state?: "complete" | "streaming"; prompt?: string; messageId?: string }
  Artifact { id; title; kind: "code" | "markdown" | "svg"; language?: string;
    filename?: string; versions: ArtifactVersion[] (oldest to newest, min 1) }
  ArtifactPanelData { status: "loading" | "empty" | "error" | "ready";
    artifact: Artifact | null }
- Props: status; artifact; selectedVersionId? / defaultSelectedVersionId? /
  onSelectedVersionIdChange? (controlled + uncontrolled selection);
  renderPreview?(version, artifact); renderLine?(line, lineNumber);
  onCopy?(version); onDownload?(version, filename);
  onJumpToMessage?(messageId, version); onClose?; onRetry?; errorMessage?;
  emptyState?; contentMaxHeight = 420; virtualizeThreshold = 400;
  diffMaxRows = 600; resizable = false; defaultWidth = 520; minWidth = 320;
  maxWidth = 960; onWidthChange?; locale = "en-US"; timeZone = "UTC";
  formatTimestamp?(iso). forwardRef to the root div, rest spread onto it.
- Export `artifactFileMeta(artifact, version) -> { name, extension, mimeType }`
  and `diffArtifactVersions(oldText, newText, maxRows)` so the host app can
  reuse the same naming and diffing outside the panel.
- `prompt` and `messageId` are what separate this from a file viewer: every
  revision can answer "which of my messages produced this?".

Behavior
- Copy and download ALWAYS act on the SELECTED revision, never on the newest
  one. Read them from one derived `selected` object so the two handlers cannot
  drift apart. This is the defect users notice last and hate most: reading v1
  and walking away with v3.
- Download builds a Blob from `selected.content` with a MIME type derived from
  the kind/language, names it `<slug>-v<revision>.<ext>`, clicks a temporary
  anchor with a download attribute, and revokes the object URL on a timer that
  is cleared on unmount. The `-v<n>` suffix stops v1 from overwriting the v3
  already sitting in ~/Downloads.
- A revision with state "streaming" is a distinct state, not "ready with a
  spinner": the download button is aria-disabled (never the `disabled`
  attribute — that drops it out of the tab order and takes its explanation with
  it) and the handler returns early; the view switcher forces Source, because a
  preview of half a document reflows on every token and half an SVG is not an
  image; the rail chip carries a spinner and the header says "Generating v3".
  Everything unlocks by itself when `state` flips to "complete" — including the
  view the reader had picked before.
- Scroll memory is keyed by (revision id, view). Save on scroll, restore in a
  layout effect before paint, so v3 to v2 and back lands on the line you left.
  While a revision is streaming the source auto-follows the tail, unless the
  reader scrolls up — then it stays put until they return to the bottom.
- A newly appended revision only steals the selection if the reader was already
  parked on the newest one; someone reading v1 is left alone. A selected id that
  matches no revision (a stale controlled `selectedVersionId`, a pruned
  revision) resolves to the NEWEST one — never to v1, which would quietly hand
  copy and download the wrong text.
- Source view: one row per line, fixed height, horizontal scroll for long lines
  (never wrap — wrapping is what makes windowing impossible), sticky line-number
  gutter. Past `virtualizeThreshold` lines only the visible window plus an
  overscan is mounted, but the spacer is always `lines * lineHeight`, so the
  scrollbar measures the whole file and the last line is reachable. Nothing is
  ever silently cut.
- Changes view: line diff of the previous revision against the selected one,
  Myers' greedy shortest-edit-script capped at an edit distance of 1000. Do NOT
  use a quadratic LCS table capped by area: the everyday edit (rename a line
  near the top, append two at the bottom) defeats common prefix/suffix peeling
  and the area cap then reports "everything changed". Past the cap, show it as
  one wholesale replace and SAY so; cap the rendered rows too and point at
  Source for the rest. Fold runs of unchanged lines into "N unchanged lines".
- Four states are first-class branches: loading (skeleton in the real layout),
  empty (nothing generated yet), error (message + optional retry), ready.

Rendering & styling
- Semantic tokens only: bg-card, bg-muted, border, text-foreground,
  text-muted-foreground, text-primary / bg-primary/10 for additions and the
  selected chip, text-destructive / bg-destructive/10 for deletions and errors,
  ring-ring for focus. No hex, no oklch, no chart tokens (they are fills, not
  text colours). cn() merges the consumer's className.
- The revision rail is a `listbox` with roving tabindex and horizontal arrow
  keys, `aria-controls` pointing at the content panel; the view switcher is a
  `tablist` whose tabs also point `aria-controls` at that same panel, which is
  `role="tabpanel"` + `aria-labelledby` the active tab. Both roving groups need
  their own arrow/Home/End handler — a roving tabindex without one leaves every
  unselected tab out of the tab order AND unreachable by keyboard, i.e. the
  Changes view becomes mouse-only. Versions are a listbox
  rather than a second tablist on purpose: they are N interchangeable rows of
  one growing dataset, and two tablists aimed at one panel make the
  relationship unreadable.
- SVG previews go through a `data:image/svg+xml` URL in an img element: the
  vector paints, its scripts cannot run. The price of that isolation is that an
  image document does not inherit the page's `currentColor` — model output
  written with `fill="currentColor"` paints black and disappears on a dark
  surface, so treat a vector artifact as carrying its own palette (or sanitise
  and inline it, and accept the wider attack surface). Markdown previews go
  through StreamingText. `kind: "code"` has no built-in preview — do not render
  a Preview tab that leads nowhere; the host passes `renderPreview` (a sandboxed
  iframe) to get one.
- Optional resize handle on the left edge: `role="separator"` with
  aria-valuenow/min/max, pointer drag by delta from the grab point (never by
  absolute x), an `e.buttons === 0` bail-out so a pointerup lost outside the
  window cannot leave it dragging, and arrow/Home/End on the keyboard. Decide
  whether to show it from the CONTAINER's width, not the panel's own —
  measuring yourself makes the verdict lag one frame behind the width you just
  asked for, the handle unmounts mid-drag and takes the pointer capture with it.
- Animations are decoration only: motion-reduce:animate-none everywhere, and
  the "Generating" state stays readable with every animation off.

Customization levers
- Kinds: add "html" / "json" to the enum with its extension + MIME, or drop svg
  if you never produce vectors. `renderPreview` is the escape hatch for
  anything the panel cannot render itself (live React preview, Mermaid, a
  spreadsheet).
- Syntax highlighting: pass `renderLine` (Shiki, your own tokenizer, the
  library's CodeBlock line renderer). Keep every row the same fixed height, or
  set `virtualizeThreshold={Infinity}` — a wrapping renderer and row windowing
  are mutually exclusive.
- Density: `contentMaxHeight` (or "none" plus a height on the root for a
  full-height dock), `diffMaxRows`, `virtualizeThreshold`, the diff context
  constant (3), the rail chip labels.
- Chrome: drop `onClose` for a permanently docked panel; drop `resizable` for a
  fixed rail; drop the provenance line if your product has no chat to jump to;
  swap the absolute timestamp for a relative one with `formatTimestamp`.
- Version state: add a "failed" member to the state enum for interrupted
  generations — it renders like "streaming" but with the destructive tokens and
  a retry affordance.
- Full word-level, side-by-side comparison is deliberately NOT built in: it is
  its own component (`display/diff-viewer`), and a free component may not depend
  on a Pro one. Swap the built-in diff view for it if you own both.

Concepts

  • selected-not-latest — The only revision eligible to be copied / downloaded / diffed is the selected one. Both handlers read one derived object, so "take the newest" is not even expressible; a control build that read the newest copied 1478 characters of v3 while v1 (549) was on screen.
  • streaming-revision-is-not-downloadable — "Generating" is not "ready with a spinner": the bytes are incomplete by definition, so download is held by aria-disabled with the reason attached (not the native disabled, which costs the button its focusability and its tooltip), and preview / changes give way to source. The moment state flips to complete, the view the reader had picked comes back on its own.
  • per-revision-scroll-memory — Scroll position is booked per (revision, view): saved on the scroll event as you leave, restored in a layout effect before paint as you come back. Switch away and return and you land on the line you left, not at the top.
  • revision-rail-as-listbox — The rail is a listbox, not a second tablist: revisions are N interchangeable rows of one growing dataset, each with its own state, while Preview / Source / Changes is the tablist. Two tablists aimed at one panel leave "which controls what" unsayable.
  • prompt-to-revision-provenance — Every revision carries the sentence that produced it plus the message id, so the panel can answer "which of my messages caused this change?" and hand the jump back to the host. Without it, it degrades into a file viewer.
  • windowed-source-rows — Past the threshold only the visible window plus 12 rows either side is mounted, but the spacer is always sized for the whole file: measured on 5000 lines, 15003 DOM nodes down to 84 (178×), scrollbar height unchanged (100016px), last line still reachable — rendering deferred, nothing truncated.
  • myers-line-diff — Line comparison runs Myers' shortest edit script, capped on edit distance. Capping a quadratic LCS table by area is a trap: "rename one line, append two at the end" defeats the common prefix and the common suffix at once — measured on a 5000-line file it reported +4803/−4801 ("everything changed"), while Myers answers the same input at D=4 and renders 13 rows.

On This Page