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 — 面板里唯一有资格被复制 / 下载 / 对比的,是当前所选的那一版。两个 handler 都从同一个派生对象取内容,不给「拿最新版」留下写法上的可能;实测对照版(改成取最新版)在选中 v1 时复制走的是 1478 字符的 v3 而不是 549 字符的 v1。
  • streaming-revision-is-not-downloadable — 「正在生成」不是「就绪 + 转圈」:它的字节按定义还不完整,所以下载被 aria-disabled 拦住并给出原因(不用原生 disabled,那会让按钮失去焦点与提示),预览与对比也让位给源码;state 一翻成 complete,读者之前选的视图自己回来。
  • per-revision-scroll-memory — 滚动位置按 (版本, 视图) 记账,离开时随滚动事件存,回来时在 layout effect 里于绘制前还原。切到别的版本再切回来,回到你离开的那一行,而不是回到顶部。
  • revision-rail-as-listbox — 版本条是 listbox 而不是第二个 tablist:它们是同一份数据集里 N 个可互换、还在增长、各自带状态的行;而「预览 / 源码 / 变更」才是 tablist。两个 tablist 指向同一个面板会让「谁控制谁」无法表达。
  • prompt-to-revision-provenance — 每一版都带着产出它的那句话和消息 id,面板因此能回答「这次改动是我哪条消息造成的」,并把跳回对话的动作交给宿主。少了这条,它就退化成一个文件查看器。
  • windowed-source-rows — 超过阈值只挂载可视窗口 + 上下各 12 行,但占位高度永远按整份文件算:实测 5000 行从 15003 个 DOM 节点降到 84 个(178 倍),滚动条高度一模一样(100016px),最后一行照样滚得到 —— 延后渲染,不是悄悄截断。
  • myers-line-diff — 逐行对比走 Myers 最短编辑脚本并对编辑距离封顶。二次 LCS 表按面积封顶是个陷阱:「改一行 + 末尾追加两行」会同时废掉公共前缀和公共后缀,实测在 5000 行文件上报出 +4803/−4801(「全都变了」),而 Myers 在 D=4 就答完,同一份输入只渲染 13 行。

On This Page