{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "artifact-panel",
  "title": "Artifact Panel",
  "description": "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.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.zyeon.ai/r/streaming-text.json"
  ],
  "files": [
    {
      "path": "src/registry/ui/artifact-panel.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  AlertCircle,\n  Check,\n  Copy,\n  CornerUpLeft,\n  Download,\n  FileCode2,\n  FileImage,\n  FileText,\n  Loader2,\n  Sparkles,\n  X,\n} from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { StreamingText } from \"@/components/ui/streaming-text\"\nimport type { Artifact, ArtifactPanelStatus, ArtifactVersion } from \"./artifact-panel.contract\"\n\n/* ------------------------------------------------------------------ */\n/* File naming — extension and MIME both follow the artifact's kind.   */\n/* ------------------------------------------------------------------ */\n\nconst EXTENSION_BY_LANGUAGE: Record<string, string> = {\n  bash: \"sh\",\n  c: \"c\",\n  cpp: \"cpp\",\n  cs: \"cs\",\n  css: \"css\",\n  go: \"go\",\n  html: \"html\",\n  java: \"java\",\n  javascript: \"js\",\n  js: \"js\",\n  json: \"json\",\n  jsx: \"jsx\",\n  kt: \"kt\",\n  markdown: \"md\",\n  md: \"md\",\n  php: \"php\",\n  py: \"py\",\n  python: \"py\",\n  rb: \"rb\",\n  ruby: \"rb\",\n  rs: \"rs\",\n  rust: \"rs\",\n  sh: \"sh\",\n  sql: \"sql\",\n  svg: \"svg\",\n  swift: \"swift\",\n  toml: \"toml\",\n  ts: \"ts\",\n  tsx: \"tsx\",\n  typescript: \"ts\",\n  yaml: \"yaml\",\n  yml: \"yaml\",\n}\n\n/**\n * Only types a browser will actually honour on a download.\n *\n * `.ts` is deliberately `text/plain`: its registered IANA type is `video/mp2t`\n * (MPEG transport stream), which makes the OS hand a TypeScript file to a video\n * player. Everything unmapped falls through to `text/plain` for the same reason —\n * a wrong-but-specific type is worse than a correct generic one.\n */\nconst MIME_BY_EXTENSION: Record<string, string> = {\n  css: \"text/css\",\n  csv: \"text/csv\",\n  html: \"text/html\",\n  js: \"text/javascript\",\n  json: \"application/json\",\n  jsx: \"text/javascript\",\n  md: \"text/markdown\",\n  svg: \"image/svg+xml\",\n  xml: \"application/xml\",\n  yaml: \"application/yaml\",\n  yml: \"application/yaml\",\n}\n\nfunction slugify(value: string): string {\n  const slug = value\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, \"-\")\n    .replace(/^-+|-+$/g, \"\")\n  return slug || \"artifact\"\n}\n\nexport interface ArtifactFileMeta {\n  /** Download filename, always carrying the revision it came from. */\n  name: string\n  extension: string\n  /** Blob type, charset included. */\n  mimeType: string\n}\n\n/**\n * Resolves the download identity of **one revision**.\n *\n * The revision suffix is not decoration: downloading v1 while v3 exists must not\n * silently overwrite the v3 file already sitting in ~/Downloads.\n */\nexport function artifactFileMeta(artifact: Artifact, version: ArtifactVersion): ArtifactFileMeta {\n  const explicit = artifact.filename?.trim() ?? \"\"\n  const dot = explicit.lastIndexOf(\".\")\n  const fromFilename = dot > 0 ? explicit.slice(dot + 1).toLowerCase() : \"\"\n  const base = explicit ? slugify(dot > 0 ? explicit.slice(0, dot) : explicit) : slugify(artifact.title)\n\n  let extension = fromFilename\n  if (!extension) {\n    if (artifact.kind === \"markdown\") extension = \"md\"\n    else if (artifact.kind === \"svg\") extension = \"svg\"\n    else extension = EXTENSION_BY_LANGUAGE[(artifact.language ?? \"\").toLowerCase()] ?? \"txt\"\n  }\n\n  return {\n    extension,\n    mimeType: `${MIME_BY_EXTENSION[extension] ?? \"text/plain\"};charset=utf-8`,\n    name: `${base}-v${version.revision}.${extension}`,\n  }\n}\n\n/* ------------------------------------------------------------------ */\n/* Clipboard                                                           */\n/* ------------------------------------------------------------------ */\n\n/** Last-resort clipboard write for browsers/contexts without the async Clipboard API. */\nfunction legacyCopy(text: string): boolean {\n  if (typeof document === \"undefined\") return false\n  const textarea = document.createElement(\"textarea\")\n  textarea.value = text\n  textarea.style.position = \"fixed\"\n  textarea.style.opacity = \"0\"\n  textarea.style.pointerEvents = \"none\"\n  document.body.appendChild(textarea)\n  textarea.focus()\n  textarea.select()\n  let ok = false\n  try {\n    ok = document.execCommand(\"copy\")\n  } catch {\n    ok = false\n  }\n  document.body.removeChild(textarea)\n  return ok\n}\n\n/* ------------------------------------------------------------------ */\n/* Source view                                                         */\n/* ------------------------------------------------------------------ */\n\n/**\n * Every source row is exactly this tall, in px rather than a Tailwind step, so\n * the windowing maths cannot drift when the host app changes its root font size.\n */\nconst LINE_HEIGHT = 20\n/** Rows kept mounted above and below the viewport so a fast flick never shows a gap. */\nconst OVERSCAN = 12\n\ninterface ArtifactSourceProps {\n  content: string\n  streaming: boolean\n  renderLine?: (line: string, lineNumber: number) => React.ReactNode\n  /** Below this many lines every row is mounted; past it only the visible window is. */\n  virtualize: boolean\n  scrollTop: number\n  viewportHeight: number\n}\n\nfunction ArtifactSource({ content, renderLine, streaming, virtualize, scrollTop, viewportHeight }: ArtifactSourceProps) {\n  const lines = React.useMemo(() => content.split(\"\\n\"), [content])\n  const total = lines.length\n  const digits = String(total).length\n\n  // One code path for both modes: windowed rendering is just a narrower [from, to).\n  // The spacer is always `total * LINE_HEIGHT`, so the scrollbar tells the truth\n  // about the whole file and the last line is always reachable — nothing is\n  // dropped, only deferred.\n  const windowed = virtualize && viewportHeight > 0\n  const from = windowed ? Math.max(0, Math.floor(scrollTop / LINE_HEIGHT) - OVERSCAN) : 0\n  const to = windowed ? Math.min(total, Math.ceil((scrollTop + viewportHeight) / LINE_HEIGHT) + OVERSCAN) : total\n\n  const rows: React.ReactNode[] = []\n  for (let i = from; i < to; i += 1) {\n    rows.push(\n      <div className=\"flex\" key={i} style={{ height: LINE_HEIGHT, lineHeight: `${LINE_HEIGHT}px` }}>\n        {/* Sticky, so the gutter stays put while a 300-column line scrolls\n            sideways underneath it. Opaque bg-card: a translucent gutter would\n            let the code slide through it. */}\n        <span\n          aria-hidden=\"true\"\n          className=\"sticky left-0 z-10 shrink-0 select-none bg-card pr-3 pl-3 text-right text-muted-foreground/70 tabular-nums\"\n          style={{ width: `calc(${digits}ch + 1.5rem)` }}\n        >\n          {i + 1}\n        </span>\n        <span className=\"pr-4 whitespace-pre\">\n          {renderLine ? renderLine(lines[i], i + 1) : lines[i]}\n          {streaming && i === total - 1 && (\n            <span\n              aria-hidden=\"true\"\n              className=\"ml-0.5 inline-block h-3.5 w-1.5 translate-y-0.5 animate-pulse bg-primary align-baseline motion-reduce:animate-none\"\n            />\n          )}\n        </span>\n      </div>,\n    )\n  }\n\n  return (\n    <div className=\"relative w-max min-w-full font-mono text-[13px]\" style={{ height: total * LINE_HEIGHT }}>\n      <div className=\"absolute left-0 w-max min-w-full\" style={{ top: from * LINE_HEIGHT }}>\n        {rows}\n      </div>\n    </div>\n  )\n}\n\n/* ------------------------------------------------------------------ */\n/* Minimal line diff — \"what changed between these two revisions\"      */\n/*                                                                     */\n/* Inlined on purpose. The library's full `diff-viewer` (word-level     */\n/* highlights, split view, expandable folds) is a Pro component, and a  */\n/* free component may not depend on one: `shadcn add artifact-panel`    */\n/* would fetch a file the buyer isn't entitled to and the import would  */\n/* not resolve. This is the line-level subset the panel actually needs. */\n/* ------------------------------------------------------------------ */\n\ntype DiffKind = \"equal\" | \"add\" | \"remove\"\n\ninterface DiffRow {\n  kind: DiffKind\n  text: string\n  oldNumber: number | null\n  newNumber: number | null\n}\n\ntype DiffBlock = { kind: \"row\"; row: DiffRow } | { kind: \"gap\"; hidden: number }\n\nexport interface ArtifactDiff {\n  blocks: DiffBlock[]\n  added: number\n  removed: number\n  /** The changed middle was too large to match line by line; it is shown as one wholesale replace. */\n  degraded: boolean\n  /** Rows dropped by `maxRows`; 0 when the whole diff is on screen. */\n  truncated: number\n}\n\n/**\n * Myers' algorithm costs O(D·(N+M)) time and ~D² ints of trace, so the cap is on\n * the *edit distance*, not on the file size: 1000 changed lines is answered\n * exactly, a wholesale rewrite bails after ~4 MB of scratch and says so out loud.\n */\nconst MAX_EDIT_DISTANCE = 1000\n/** Unchanged lines kept either side of a change. */\nconst DIFF_CONTEXT = 3\n\nfunction splitLines(text: string): string[] {\n  if (text === \"\") return []\n  const lines = text.split(\"\\n\")\n  // A text ending in \"\\n\" splits into a phantom trailing \"\".\n  if (lines.length > 1 && lines[lines.length - 1] === \"\") lines.pop()\n  return lines\n}\n\nconst diffRow = (kind: DiffKind, text: string, oldNumber: number | null, newNumber: number | null): DiffRow => ({\n  kind,\n  newNumber,\n  oldNumber,\n  text,\n})\n\n/** One step of the shortest edit script: which side the line came from. */\ntype EditOp = { kind: DiffKind; a: number; b: number }\n\n/**\n * Myers' greedy shortest-edit-script, stopped at `MAX_EDIT_DISTANCE`.\n *\n * A quadratic LCS table is the obvious alternative and it is a trap here: it has\n * to be capped by *area*, and the everyday artifact edit — rename a line near the\n * top, append two at the bottom — leaves a 4800×4800 middle after the common\n * prefix/suffix are peeled. Measured on the 5000-line fixture, the area cap gave\n * up and reported +4803/-4801 (\"everything changed\") for a three-line edit. Myers\n * answers the same input at D = 4.\n *\n * Returns `null` when the revisions are further apart than the cap; the caller\n * then shows the change as one wholesale replace and says so.\n */\nfunction myersOps(a: string[], b: string[]): EditOp[] | null {\n  const n = a.length\n  const m = b.length\n  const limit = Math.min(MAX_EDIT_DISTANCE, n + m)\n  const offset = limit + 1\n  const v = new Int32Array(2 * limit + 3)\n  /** V at the start of each round, windowed to the diagonals that round can touch. */\n  const trace: Int32Array[] = []\n  let found = -1\n\n  for (let d = 0; d <= limit && found < 0; d += 1) {\n    trace.push(v.slice(offset - d - 1, offset + d + 2))\n    for (let k = -d; k <= d; k += 2) {\n      let x = k === -d || (k !== d && v[k - 1 + offset] < v[k + 1 + offset]) ? v[k + 1 + offset] : v[k - 1 + offset] + 1\n      let y = x - k\n      while (x < n && y < m && a[x] === b[y]) {\n        x += 1\n        y += 1\n      }\n      v[k + offset] = x\n      if (x >= n && y >= m) {\n        found = d\n        break\n      }\n    }\n  }\n  if (found < 0) return null\n\n  const reversed: EditOp[] = []\n  let x = n\n  let y = m\n  for (let d = found; d > 0; d -= 1) {\n    const round = trace[d]\n    const at = (diagonal: number) => round[diagonal + d + 1]\n    const k = x - y\n    const previousK = k === -d || (k !== d && at(k - 1) < at(k + 1)) ? k + 1 : k - 1\n    const previousX = at(previousK)\n    const previousY = previousX - previousK\n    while (x > previousX && y > previousY) {\n      x -= 1\n      y -= 1\n      reversed.push({ a: x, b: y, kind: \"equal\" })\n    }\n    // One non-diagonal move remains: vertical (b gained a line) or horizontal (a lost one).\n    if (x === previousX) reversed.push({ a: -1, b: previousY, kind: \"add\" })\n    else reversed.push({ a: previousX, b: -1, kind: \"remove\" })\n    x = previousX\n    y = previousY\n  }\n  while (x > 0) {\n    x -= 1\n    y -= 1\n    reversed.push({ a: x, b: y, kind: \"equal\" })\n  }\n  return reversed.reverse()\n}\n\nfunction matchMiddle(a: string[], b: string[], offset: number): { rows: DiffRow[]; degraded: boolean } {\n  const ops = myersOps(a, b)\n  if (!ops) {\n    return {\n      degraded: true,\n      rows: [\n        ...a.map((text, i) => diffRow(\"remove\", text, offset + i + 1, null)),\n        ...b.map((text, i) => diffRow(\"add\", text, null, offset + i + 1)),\n      ],\n    }\n  }\n  return {\n    degraded: false,\n    rows: ops.map(op =>\n      op.kind === \"add\"\n        ? diffRow(\"add\", b[op.b], null, offset + op.b + 1)\n        : op.kind === \"remove\"\n          ? diffRow(\"remove\", a[op.a], offset + op.a + 1, null)\n          : diffRow(\"equal\", a[op.a], offset + op.a + 1, offset + op.b + 1),\n    ),\n  }\n}\n\n/**\n * Line-level diff of two revisions. Common prefix and suffix are peeled off\n * first — that is what keeps a two-line edit inside a 5000-line file off the\n * quadratic path entirely.\n */\nexport function diffArtifactVersions(oldText: string, newText: string, maxRows = 600): ArtifactDiff {\n  const a = splitLines(oldText)\n  const b = splitLines(newText)\n\n  let start = 0\n  while (start < a.length && start < b.length && a[start] === b[start]) start += 1\n  let endA = a.length\n  let endB = b.length\n  while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {\n    endA -= 1\n    endB -= 1\n  }\n\n  const rows: DiffRow[] = []\n  for (let i = 0; i < start; i += 1) rows.push(diffRow(\"equal\", a[i], i + 1, i + 1))\n  const middle = matchMiddle(a.slice(start, endA), b.slice(start, endB), start)\n  rows.push(...middle.rows)\n  for (let i = 0; endA + i < a.length; i += 1) rows.push(diffRow(\"equal\", a[endA + i], endA + i + 1, endB + i + 1))\n\n  // Counts are taken over every row, before any capping, so the header总数 stays\n  // truthful even when the body is cut.\n  let added = 0\n  let removed = 0\n  for (const row of rows) {\n    if (row.kind === \"add\") added += 1\n    else if (row.kind === \"remove\") removed += 1\n  }\n\n  const blocks: DiffBlock[] = []\n  let index = 0\n  while (index < rows.length) {\n    if (rows[index].kind !== \"equal\") {\n      blocks.push({ kind: \"row\", row: rows[index] })\n      index += 1\n      continue\n    }\n    let end = index\n    while (end < rows.length && rows[end].kind === \"equal\") end += 1\n    const run = rows.slice(index, end)\n    const keepTop = index === 0 ? 0 : DIFF_CONTEXT\n    const keepBottom = end === rows.length ? 0 : DIFF_CONTEXT\n    const hidden = run.length - keepTop - keepBottom\n    if (hidden < 2) {\n      for (const row of run) blocks.push({ kind: \"row\", row })\n    } else {\n      for (const row of run.slice(0, keepTop)) blocks.push({ kind: \"row\", row })\n      blocks.push({ hidden, kind: \"gap\" })\n      for (const row of run.slice(run.length - keepBottom)) blocks.push({ kind: \"row\", row })\n    }\n    index = end\n  }\n\n  const cap = Number.isFinite(maxRows) ? Math.max(1, Math.floor(maxRows)) : blocks.length\n  const truncated = blocks.length > cap ? blocks.length - cap : 0\n  return { added, blocks: truncated > 0 ? blocks.slice(0, cap) : blocks, degraded: middle.degraded, removed, truncated }\n}\n\nconst DIFF_TINT: Record<DiffKind, string> = {\n  add: \"bg-primary/10\",\n  equal: \"\",\n  remove: \"bg-destructive/10\",\n}\n\nconst DIFF_MARKER: Record<DiffKind, string> = { add: \"+\", equal: \" \", remove: \"-\" }\n\nconst DIFF_MARKER_CLASS: Record<DiffKind, string> = {\n  add: \"font-semibold text-primary\",\n  equal: \"text-muted-foreground/70\",\n  remove: \"font-semibold text-destructive\",\n}\n\nconst DIFF_SR_PREFIX: Record<DiffKind, string> = { add: \"Added line\", equal: \"Line\", remove: \"Removed line\" }\n\nfunction ArtifactDiffView({ diff }: { diff: ArtifactDiff }) {\n  const digits = Math.max(\n    2,\n    String(diff.blocks.reduce((max, b) => (b.kind === \"row\" ? Math.max(max, b.row.oldNumber ?? 0, b.row.newNumber ?? 0) : max), 0))\n      .length,\n  )\n  return (\n    <div className=\"w-max min-w-full font-mono text-[13px]\">\n      {diff.blocks.map((block, index) =>\n        block.kind === \"gap\" ? (\n          <p\n            className=\"border-y bg-muted/40 px-3 font-sans text-xs text-muted-foreground\"\n            key={`gap-${index}`}\n            style={{ height: LINE_HEIGHT, lineHeight: `${LINE_HEIGHT}px` }}\n          >\n            {`${block.hidden} unchanged line${block.hidden === 1 ? \"\" : \"s\"}`}\n          </p>\n        ) : (\n          <div className={cn(\"flex\", DIFF_TINT[block.row.kind])} key={index} style={{ height: LINE_HEIGHT, lineHeight: `${LINE_HEIGHT}px` }}>\n            {/* aria-hidden + select-none: the numbers are announced as a sentence\n                by the sr-only prefix instead of as a column of bare digits, and a\n                mouse selection copies code rather than gutters. */}\n            <span\n              aria-hidden=\"true\"\n              className={cn(\"sticky left-0 z-10 shrink-0 select-none bg-card pr-2 pl-3 text-right tabular-nums\", DIFF_TINT[block.row.kind])}\n            >\n              <span className=\"inline-block text-muted-foreground/70\" style={{ width: `${digits}ch` }}>\n                {block.row.oldNumber ?? \"\"}\n              </span>\n              <span className=\"ml-2 inline-block text-muted-foreground/70\" style={{ width: `${digits}ch` }}>\n                {block.row.newNumber ?? \"\"}\n              </span>\n              <span className={cn(\"ml-2 inline-block w-[1ch]\", DIFF_MARKER_CLASS[block.row.kind])}>\n                {DIFF_MARKER[block.row.kind]}\n              </span>\n            </span>\n            <span className=\"pr-4 pl-2 whitespace-pre\">\n              <span className=\"sr-only select-none\">\n                {`${DIFF_SR_PREFIX[block.row.kind]} ${block.row.newNumber ?? block.row.oldNumber ?? \"\"}: `}\n              </span>\n              {block.row.text}\n            </span>\n          </div>\n        ),\n      )}\n    </div>\n  )\n}\n\n/* ------------------------------------------------------------------ */\n/* Small pieces                                                        */\n/* ------------------------------------------------------------------ */\n\nconst KIND_ICON = { code: FileCode2, markdown: FileText, svg: FileImage } as const\n\nfunction ToolbarButton({\n  children,\n  disabledReason,\n  label,\n  onClick,\n}: {\n  children: React.ReactNode\n  /** Non-empty turns the button into an aria-disabled button that explains itself. */\n  disabledReason?: string\n  label: string\n  onClick: () => void\n}) {\n  const blocked = Boolean(disabledReason)\n  return (\n    <button\n      // aria-disabled, not the `disabled` attribute: a disabled button is removed\n      // from the tab order and drops its tooltip, so the reader is told nothing\n      // about *why* it cannot download. The handler does the blocking.\n      aria-disabled={blocked || undefined}\n      aria-label={label}\n      className={cn(\n        \"inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\",\n        blocked && \"cursor-not-allowed opacity-50 hover:bg-transparent hover:text-muted-foreground\",\n      )}\n      onClick={onClick}\n      title={disabledReason ?? label}\n      type=\"button\"\n    >\n      {children}\n    </button>\n  )\n}\n\nfunction SkeletonBar({ className, style }: { className?: string; style?: React.CSSProperties }) {\n  return <div className={cn(\"animate-pulse rounded bg-muted motion-reduce:animate-none\", className)} style={style} />\n}\n\n/* ------------------------------------------------------------------ */\n/* Panel                                                               */\n/* ------------------------------------------------------------------ */\n\nexport type ArtifactView = \"preview\" | \"source\" | \"diff\"\n\nconst VIEW_LABEL: Record<ArtifactView, string> = { diff: \"Changes\", preview: \"Preview\", source: \"Source\" }\n\ninterface ScrollMemory {\n  top: number\n  left: number\n  /** Whether the reader was pinned to the bottom — only consulted for a streaming revision. */\n  follow: boolean\n}\n\n// `onCopy` shadows the DOM clipboard handler on purpose: on this component it\n// means \"the selected revision reached the clipboard\", which is the only copy\n// event a consumer cares about here.\nexport interface ArtifactPanelProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\" | \"onCopy\"> {\n  status: ArtifactPanelStatus\n  /** `null` in every non-ready branch. */\n  artifact: Artifact | null\n  /** Controlled selection. Omit to let the panel own it. An id nothing matches shows the newest revision. */\n  selectedVersionId?: string\n  /** Uncontrolled starting selection; defaults to the newest revision. */\n  defaultSelectedVersionId?: string\n  onSelectedVersionIdChange?: (id: string, version: ArtifactVersion) => void\n  /**\n   * Live preview for artifacts the panel cannot render itself (`kind: \"code\"`\n   * needs a sandboxed iframe on your side). Given, it replaces the built-in\n   * Markdown/SVG preview too.\n   */\n  renderPreview?: (version: ArtifactVersion, artifact: Artifact) => React.ReactNode\n  /** Syntax highlighting hook. Must render a single row — see `virtualizeThreshold`. */\n  renderLine?: (line: string, lineNumber: number) => React.ReactNode\n  /** Fired after the selected revision's text reached the clipboard. */\n  onCopy?: (version: ArtifactVersion) => void\n  /** Fired after the selected revision's Blob was handed to the browser. */\n  onDownload?: (version: ArtifactVersion, filename: string) => void\n  /** Renders \"Go to message\" — only when this *and* `version.messageId` exist. */\n  onJumpToMessage?: (messageId: string, version: ArtifactVersion) => void\n  /** Renders the close button in the header. */\n  onClose?: () => void\n  /** Renders the retry button in the error branch. */\n  onRetry?: () => void\n  errorMessage?: string\n  emptyState?: React.ReactNode\n  /** Cap on the scroll area. Pass `\"none\"` when the panel itself is height-constrained. */\n  contentMaxHeight?: number | string\n  /**\n   * Line count past which the source view mounts only the visible window.\n   * Windowing assumes fixed-height rows: pass `Infinity` if `renderLine` wraps.\n   */\n  virtualizeThreshold?: number\n  /** Rows the Changes view renders before it stops, with a visible notice past the cap. */\n  diffMaxRows?: number\n  /** Adds a draggable + keyboard-operable left edge. */\n  resizable?: boolean\n  defaultWidth?: number\n  minWidth?: number\n  maxWidth?: number\n  onWidthChange?: (width: number) => void\n  /** Explicit BCP 47 tag — `Intl.*(undefined)` desyncs SSR from the visitor's locale. */\n  locale?: string\n  /** IANA zone for the revision timestamp. UTC by default so server and client agree. */\n  timeZone?: string\n  /** Replaces the built-in absolute timestamp (drop a relative formatter in here). */\n  formatTimestamp?: (iso: string) => string\n}\n\nconst useIsomorphicLayoutEffect = typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\nexport const ArtifactPanel = React.forwardRef<HTMLDivElement, ArtifactPanelProps>(function ArtifactPanel(\n  {\n    status,\n    artifact,\n    selectedVersionId,\n    defaultSelectedVersionId,\n    onSelectedVersionIdChange,\n    renderPreview,\n    renderLine,\n    onCopy,\n    onDownload,\n    onJumpToMessage,\n    onClose,\n    onRetry,\n    errorMessage = \"The artifact couldn't be loaded.\",\n    emptyState,\n    contentMaxHeight = 420,\n    virtualizeThreshold = 400,\n    diffMaxRows = 600,\n    resizable = false,\n    defaultWidth = 520,\n    minWidth = 320,\n    maxWidth = 960,\n    onWidthChange,\n    locale = \"en-US\",\n    timeZone = \"UTC\",\n    formatTimestamp,\n    className,\n    style,\n    ...props\n  },\n  forwardedRef,\n) {\n  const uid = React.useId()\n  const panelId = `${uid}-panel`\n  const rootRef = React.useRef<HTMLDivElement | null>(null)\n  const scrollRef = React.useRef<HTMLDivElement | null>(null)\n  const railRef = React.useRef<HTMLDivElement | null>(null)\n  const optionRefs = React.useRef(new Map<string, HTMLButtonElement>())\n\n  const versions = artifact?.versions ?? []\n  const latest = versions.length > 0 ? versions[versions.length - 1] : null\n\n  /* --- selection -------------------------------------------------------- */\n\n  const [selection, setSelection] = React.useState(() => ({\n    artifactId: artifact?.id ?? null,\n    followLatest: defaultSelectedVersionId === undefined || defaultSelectedVersionId === latest?.id,\n    versionId: defaultSelectedVersionId ?? latest?.id ?? null,\n  }))\n\n  // Adjust-state-during-render (no setState-in-effect): a different artifact\n  // resets the selection; a *new* revision only steals it when the reader was\n  // already parked on the newest one — reading v1 must not be yanked away\n  // mid-sentence when the model finishes v4.\n  let selectedId = selection.versionId\n  if (artifact && latest) {\n    if (selection.artifactId !== artifact.id) {\n      const initial = defaultSelectedVersionId ?? latest.id\n      selectedId = versions.some(v => v.id === initial) ? initial : latest.id\n      setSelection({ artifactId: artifact.id, followLatest: selectedId === latest.id, versionId: selectedId })\n    } else if (selection.followLatest && selection.versionId !== latest.id) {\n      selectedId = latest.id\n      setSelection({ artifactId: artifact.id, followLatest: true, versionId: latest.id })\n    } else if (!versions.some(v => v.id === selection.versionId)) {\n      selectedId = latest.id\n      setSelection({ artifactId: artifact.id, followLatest: true, versionId: latest.id })\n    }\n  }\n\n  // An id nothing matches — a stale controlled `selectedVersionId`, or a revision\n  // that has since been pruned — resolves to the newest one, which is where the\n  // uncontrolled path recovers to as well. Clamping the miss to 0 instead would\n  // silently rewind the reader to v1, and copy/download act on the selection.\n  const matched = versions.findIndex(v => v.id === (selectedVersionId ?? selectedId))\n  const selectedIndex = matched === -1 ? versions.length - 1 : matched\n  const selected: ArtifactVersion | null = versions[selectedIndex] ?? latest\n  const previous: ArtifactVersion | null = selectedIndex > 0 ? versions[selectedIndex - 1] : null\n  const isStreaming = selected?.state === \"streaming\"\n\n  const selectVersion = (version: ArtifactVersion) => {\n    if (selectedVersionId === undefined && artifact) {\n      setSelection({ artifactId: artifact.id, followLatest: version.id === latest?.id, versionId: version.id })\n    }\n    onSelectedVersionIdChange?.(version.id, version)\n  }\n\n  /* --- views ------------------------------------------------------------ */\n\n  const hasPreview = Boolean(renderPreview) || artifact?.kind === \"markdown\" || artifact?.kind === \"svg\"\n  const views: ArtifactView[] = [...(hasPreview ? ([\"preview\"] as const) : []), \"source\", ...(previous ? ([\"diff\"] as const) : [])]\n\n  const [requestedView, setRequestedView] = React.useState<ArtifactView>(\"preview\")\n  // Derived, never stored: a view that stopped existing (kind changed, v1 selected)\n  // falls back instead of leaving a tab selected that isn't on screen.\n  const resolvedView: ArtifactView = views.includes(requestedView) ? requestedView : views[0]\n  // While the model is still writing, only the raw source is honest: a preview of\n  // half a document reflows on every token and half an SVG is not an image at all.\n  const view: ArtifactView = isStreaming ? \"source\" : resolvedView\n  const viewIndex = views.indexOf(view)\n\n  const viewBlockedReason = isStreaming ? `Available when ${selected ? `v${selected.revision}` : \"this revision\"} finishes generating` : \"\"\n\n  // Computed only while the Changes view is open: diffing two 5000-line\n  // revisions on every render of a panel nobody opened is pure waste.\n  const diff = React.useMemo(\n    () => (view === \"diff\" && previous && selected ? diffArtifactVersions(previous.content, selected.content, diffMaxRows) : null),\n    [diffMaxRows, previous, selected, view],\n  )\n\n  /* --- scroll position, per (revision, view) ---------------------------- */\n\n  const scrollMemory = React.useRef(new Map<string, ScrollMemory>())\n  const scrollKey = `${selected?.id ?? \"none\"}::${view}`\n  const followRef = React.useRef(true)\n  const [scrollTop, setScrollTop] = React.useState(0)\n  const [viewportHeight, setViewportHeight] = React.useState(0)\n\n  const lineCount = selected ? selected.content.split(\"\\n\").length : 0\n  const virtualize = view === \"source\" && Number.isFinite(virtualizeThreshold) && lineCount > virtualizeThreshold\n\n  const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {\n    const el = event.currentTarget\n    const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24\n    followRef.current = atBottom\n    scrollMemory.current.set(scrollKey, { follow: atBottom, left: el.scrollLeft, top: el.scrollTop })\n    if (virtualize) setScrollTop(el.scrollTop)\n  }\n\n  // Restore before paint, so switching v3 → v2 → v3 never flashes at the top.\n  useIsomorphicLayoutEffect(() => {\n    const el = scrollRef.current\n    if (!el) return\n    const saved = scrollMemory.current.get(scrollKey)\n    el.scrollTop = saved?.top ?? 0\n    el.scrollLeft = saved?.left ?? 0\n    followRef.current = saved?.follow ?? true\n    setScrollTop(el.scrollTop)\n  }, [scrollKey])\n\n  // Follow the tail of a growing revision — unless the reader scrolled up, which\n  // hands control back to them until they return to the bottom.\n  React.useEffect(() => {\n    if (!isStreaming || !followRef.current) return\n    const el = scrollRef.current\n    if (!el) return\n    el.scrollTop = el.scrollHeight\n  }, [isStreaming, selected?.content, view])\n\n  React.useEffect(() => {\n    const el = scrollRef.current\n    if (!el || typeof ResizeObserver === \"undefined\") return\n    const observer = new ResizeObserver(entries => {\n      const box = entries[0]?.contentRect\n      if (box) setViewportHeight(box.height)\n    })\n    observer.observe(el)\n    return () => observer.disconnect()\n  }, [status, artifact?.id])\n\n  /* --- copy ------------------------------------------------------------- */\n\n  const [copyState, setCopyState] = React.useState<\"idle\" | \"copied\" | \"error\">(\"idle\")\n  const copyTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n  const objectUrls = React.useRef(new Map<string, ReturnType<typeof setTimeout>>())\n\n  React.useEffect(() => {\n    // The Map identity never changes, so capturing it here is the same object the\n    // handlers mutate — it just keeps the exhaustive-deps ref rule quiet.\n    const urls = objectUrls.current\n    return () => {\n      if (copyTimer.current) clearTimeout(copyTimer.current)\n      for (const [url, timer] of urls) {\n        clearTimeout(timer)\n        URL.revokeObjectURL(url)\n      }\n      urls.clear()\n    }\n  }, [])\n\n  const handleCopy = async () => {\n    // Read the *selected* revision, never `versions.at(-1)`: the reader is looking\n    // at v1, so v1 is what has to land on the clipboard.\n    if (!selected) return\n    const text = selected.content\n    if (copyTimer.current) clearTimeout(copyTimer.current)\n    let ok = false\n    try {\n      if (!navigator.clipboard?.writeText) throw new Error(\"clipboard api unavailable\")\n      await navigator.clipboard.writeText(text)\n      ok = true\n    } catch {\n      ok = legacyCopy(text)\n    }\n    setCopyState(ok ? \"copied\" : \"error\")\n    copyTimer.current = setTimeout(() => setCopyState(\"idle\"), 2000)\n    if (ok) onCopy?.(selected)\n  }\n\n  const handleDownload = () => {\n    // Same rule as copy, plus: a revision that is still being written has no\n    // finished bytes to save, so the button refuses instead of saving a stump.\n    if (!artifact || !selected || selected.state === \"streaming\") return\n    const meta = artifactFileMeta(artifact, selected)\n    const url = URL.createObjectURL(new Blob([selected.content], { type: meta.mimeType }))\n    const anchor = document.createElement(\"a\")\n    anchor.href = url\n    anchor.download = meta.name\n    anchor.rel = \"noopener\"\n    document.body.appendChild(anchor)\n    anchor.click()\n    anchor.remove()\n    // Revoking synchronously can cancel the transfer in some browsers; the timer\n    // is tracked so unmounting mid-download still frees the URL.\n    const timer = setTimeout(() => {\n      URL.revokeObjectURL(url)\n      objectUrls.current.delete(url)\n    }, 1000)\n    objectUrls.current.set(url, timer)\n    onDownload?.(selected, meta.name)\n  }\n\n  /* --- resizing --------------------------------------------------------- */\n\n  const clampWidth = React.useCallback(\n    (value: number) => {\n      const min = Number.isFinite(minWidth) ? Math.max(160, Math.floor(minWidth)) : 320\n      const max = Number.isFinite(maxWidth) ? Math.max(min, Math.floor(maxWidth)) : Math.max(min, 960)\n      if (!Number.isFinite(value)) return min\n      return Math.min(max, Math.max(min, Math.round(value)))\n    },\n    [maxWidth, minWidth],\n  )\n\n  const [width, setWidth] = React.useState(() => clampWidth(defaultWidth))\n  const requestedWidth = clampWidth(width)\n  const [availableWidth, setAvailableWidth] = React.useState(0)\n  const dragRef = React.useRef<{ pointerId: number; startX: number; startWidth: number } | null>(null)\n  const [dragging, setDragging] = React.useState(false)\n\n  // Mirrors the committed width so a burst of key events that React batches into\n  // one commit still steps once per key instead of collapsing into a single step\n  // (measured: 60 synchronous presses moved the edge 16px, not 960px).\n  const widthRef = React.useRef(requestedWidth)\n  React.useEffect(() => {\n    widthRef.current = requestedWidth\n  }, [requestedWidth])\n\n  const applyWidth = (next: number) => {\n    const clamped = clampWidth(next)\n    widthRef.current = clamped\n    setWidth(clamped)\n    onWidthChange?.(clamped)\n  }\n\n  // Measure the *container*, not ourselves. Measuring our own box makes the\n  // verdict depend on the width we just asked for, and that feedback loop is\n  // vicious: the observer is one frame behind, so mid-drag the panel looks\n  // \"wider than it fits\", the handle unmounts, the pointer capture dies with it\n  // and the drag stops dead after the first move (measured: a 120px drag moved\n  // the edge 20px). The container's width doesn't move while you drag.\n  React.useEffect(() => {\n    if (!resizable) return\n    const node = rootRef.current?.parentElement\n    if (!node || typeof ResizeObserver === \"undefined\") return\n    const observer = new ResizeObserver(() => setAvailableWidth(node.clientWidth))\n    observer.observe(node)\n    return () => observer.disconnect()\n  }, [resizable])\n\n  // Too little room to adjust anything: `max-w-full` already has the panel at\n  // 100% of a phone-width container, and a handle whose whole range is 20px is\n  // worse than no handle. Never yanked away mid-drag.\n  const constrained = availableWidth > 0 && availableWidth < clampWidth(0) + 64\n  const showHandle = resizable && (!constrained || dragging)\n\n  const endDrag = () => {\n    dragRef.current = null\n    setDragging(false)\n  }\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (event.button !== 0) return\n    dragRef.current = { pointerId: event.pointerId, startWidth: requestedWidth, startX: event.clientX }\n    setDragging(true)\n    event.currentTarget.setPointerCapture(event.pointerId)\n  }\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const drag = dragRef.current\n    if (!drag || drag.pointerId !== event.pointerId) return\n    // The pointer was released outside the capture (or over another window), so\n    // the pointerup never came: without this the panel keeps resizing on a plain\n    // hover, because browsers reuse pointer ids.\n    if (event.buttons === 0) {\n      endDrag()\n      return\n    }\n    // Delta from the grab point, not the pointer's absolute x — grabbing a handle\n    // 4px off-centre must not teleport the edge by 4px on the first move.\n    applyWidth(drag.startWidth + (drag.startX - event.clientX))\n  }\n\n  const handleHandleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const step = event.shiftKey ? 64 : 16\n    if (event.key === \"ArrowLeft\") applyWidth(widthRef.current + step)\n    else if (event.key === \"ArrowRight\") applyWidth(widthRef.current - step)\n    else if (event.key === \"Home\") applyWidth(0)\n    else if (event.key === \"End\") applyWidth(Number.MAX_SAFE_INTEGER)\n    else return\n    event.preventDefault()\n  }\n\n  /* --- rail keyboard ---------------------------------------------------- */\n\n  const focusVersion = (index: number) => {\n    const target = versions[Math.max(0, Math.min(versions.length - 1, index))]\n    if (!target) return\n    selectVersion(target)\n    const node = optionRefs.current.get(target.id)\n    const rail = railRef.current\n    // preventScroll + manual maths: letting the browser scroll the option into\n    // view also scrolls every ancestor, which yanks the whole page.\n    node?.focus({ preventScroll: true })\n    if (node && rail) {\n      const left = node.offsetLeft\n      const right = left + node.offsetWidth\n      if (left < rail.scrollLeft) rail.scrollLeft = left - 8\n      else if (right > rail.scrollLeft + rail.clientWidth) rail.scrollLeft = right - rail.clientWidth + 8\n    }\n  }\n\n  const handleRailKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (event.altKey || event.ctrlKey || event.metaKey) return\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") focusVersion(selectedIndex + 1)\n    else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") focusVersion(selectedIndex - 1)\n    else if (event.key === \"Home\") focusVersion(0)\n    else if (event.key === \"End\") focusVersion(versions.length - 1)\n    else return\n    event.preventDefault()\n  }\n\n  /* --- view tabs keyboard ----------------------------------------------- */\n\n  // The tabs carry a roving tabindex, so arrow keys are not decoration: without\n  // them Tab reaches only the selected tab and the others are unreachable by\n  // keyboard — the Changes view would be mouse-only.\n  const handleTabsKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (event.altKey || event.ctrlKey || event.metaKey) return\n    const total = views.length\n    const next =\n      event.key === \"ArrowRight\" || event.key === \"ArrowDown\"\n        ? (viewIndex + 1) % total\n        : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\"\n          ? (viewIndex - 1 + total) % total\n          : event.key === \"Home\"\n            ? 0\n            : event.key === \"End\"\n              ? total - 1\n              : -1\n    if (next < 0) return\n    event.preventDefault()\n    const target = views[next]\n    if (!target) return\n    // The tab buttons are this element's own children, in `views` order — no ref\n    // map to keep in sync.\n    event.currentTarget.querySelectorAll<HTMLElement>('[role=\"tab\"]')[next]?.focus()\n    // A tab blocked mid-stream still takes focus and still explains itself; only\n    // the activation is refused, exactly like its click handler.\n    if (!(isStreaming && target !== \"source\")) setRequestedView(target)\n  }\n\n  /* --- formatting ------------------------------------------------------- */\n\n  const timeFormatter = React.useMemo(() => {\n    try {\n      return new Intl.DateTimeFormat(locale, { dateStyle: \"medium\", timeStyle: \"short\", timeZone })\n    } catch {\n      return null\n    }\n  }, [locale, timeZone])\n\n  const stamp = (iso: string) => {\n    if (formatTimestamp) return formatTimestamp(iso)\n    const epoch = Date.parse(iso)\n    if (!Number.isFinite(epoch) || !timeFormatter) return iso\n    return timeFormatter.format(epoch)\n  }\n\n  const setRootRef = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      rootRef.current = node\n      if (typeof forwardedRef === \"function\") forwardedRef(node)\n      else if (forwardedRef) forwardedRef.current = node\n    },\n    [forwardedRef],\n  )\n\n  const KindIcon = artifact ? KIND_ICON[artifact.kind] : FileCode2\n  const fileMeta = artifact && selected ? artifactFileMeta(artifact, selected) : null\n  const versionLabel = selected ? `v${selected.revision}` : \"\"\n\n  const rootStyle: React.CSSProperties = {\n    ...(resizable ? { maxWidth: \"100%\", width: requestedWidth } : null),\n    ...style,\n  }\n\n  return (\n    <div\n      className={cn(\n        \"relative flex w-full min-w-0 flex-col overflow-hidden rounded-lg border bg-card text-sm\",\n        resizable && \"shrink-0\",\n        className,\n      )}\n      data-status={status}\n      ref={setRootRef}\n      role=\"group\"\n      style={rootStyle}\n      {...props}\n    >\n      {showHandle && (\n        <div\n          aria-label=\"Resize artifact panel\"\n          aria-orientation=\"vertical\"\n          aria-valuemax={clampWidth(Number.MAX_SAFE_INTEGER)}\n          aria-valuemin={clampWidth(0)}\n          aria-valuenow={requestedWidth}\n          className={cn(\n            \"absolute inset-y-0 left-0 z-20 w-1.5 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-primary/40 focus-visible:bg-primary/60 focus-visible:outline-none motion-reduce:transition-none\",\n            dragging && \"bg-primary/60\",\n          )}\n          onKeyDown={handleHandleKeyDown}\n          onLostPointerCapture={endDrag}\n          onPointerCancel={endDrag}\n          onPointerDown={handlePointerDown}\n          onPointerMove={handlePointerMove}\n          onPointerUp={endDrag}\n          role=\"separator\"\n          tabIndex={0}\n        />\n      )}\n\n      {status === \"loading\" && (\n        <>\n          <span className=\"sr-only\" role=\"status\">\n            Loading artifact\n          </span>\n          <div aria-hidden=\"true\" className=\"flex flex-col\">\n            <div className=\"flex items-center gap-3 border-b px-3 py-2.5\">\n              <SkeletonBar className=\"size-5 shrink-0 rounded-md\" />\n              <SkeletonBar className=\"h-3 w-40\" />\n              <SkeletonBar className=\"ml-auto h-3 w-16\" />\n            </div>\n            <div className=\"flex gap-1.5 border-b px-3 py-2\">\n              {[0, 1, 2].map(i => (\n                <SkeletonBar className=\"h-6 w-12\" key={i} />\n              ))}\n            </div>\n            <div className=\"flex flex-col gap-2 p-3\">\n              {Array.from({ length: 8 }, (_, i) => (\n                <SkeletonBar className=\"h-3\" key={i} style={{ width: `${42 + ((i * 17) % 48)}%` }} />\n              ))}\n            </div>\n          </div>\n        </>\n      )}\n\n      {status === \"empty\" &&\n        (emptyState ?? (\n          <div className=\"flex flex-col items-center gap-2 px-6 py-14 text-center\">\n            <Sparkles aria-hidden=\"true\" className=\"size-8 text-muted-foreground/60\" />\n            <p className=\"font-medium\">Nothing generated yet</p>\n            <p className=\"text-muted-foreground\">\n              Ask for a component, a document or a diagram — it opens here and every revision stays reachable.\n            </p>\n          </div>\n        ))}\n\n      {status === \"error\" && (\n        <div className=\"flex flex-col items-center gap-3 px-6 py-14 text-center\">\n          <AlertCircle aria-hidden=\"true\" className=\"size-8 text-destructive\" />\n          <div className=\"flex flex-col gap-1\">\n            <p className=\"font-medium\">Couldn&apos;t open the artifact</p>\n            <p className=\"text-muted-foreground\">{errorMessage}</p>\n          </div>\n          {onRetry && (\n            <button\n              className=\"cursor-pointer rounded-md border px-3 py-1.5 transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\"\n              onClick={onRetry}\n              type=\"button\"\n            >\n              Try again\n            </button>\n          )}\n        </div>\n      )}\n\n      {status === \"ready\" && artifact && selected && (\n        <>\n          {/* Header: identity on the left, actions on the right. */}\n          <div className=\"flex items-center gap-2 border-b px-3 py-2\">\n            <KindIcon aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n            {/* Both lines truncate with an ellipsis and carry the full string in\n                `title`, so a narrow panel shortens them visibly instead of\n                hiding the tail. */}\n            <div className=\"flex min-w-0 flex-1 flex-col\">\n              <p className=\"truncate font-medium\" title={artifact.title}>\n                {artifact.title}\n              </p>\n              <p\n                className=\"truncate text-xs text-muted-foreground\"\n                title={`${fileMeta?.name}${versions.length > 1 ? ` · ${versions.length} revisions` : \"\"}`}\n              >\n                {fileMeta?.name}\n                {versions.length > 1 && ` · ${versions.length} revisions`}\n              </p>\n            </div>\n            <span aria-live=\"polite\" className=\"sr-only\">\n              {copyState === \"copied\" ? `Copied ${versionLabel}` : copyState === \"error\" ? \"Copy failed\" : \"\"}\n            </span>\n            <ToolbarButton\n              label={isStreaming ? `Copy ${versionLabel} source so far` : `Copy ${versionLabel} source`}\n              onClick={handleCopy}\n            >\n              {copyState === \"copied\" ? (\n                <Check aria-hidden=\"true\" className=\"size-4 text-primary\" />\n              ) : (\n                <Copy aria-hidden=\"true\" className=\"size-4\" />\n              )}\n            </ToolbarButton>\n            <ToolbarButton\n              disabledReason={isStreaming ? `${versionLabel} is still generating — download unlocks when it finishes` : undefined}\n              label={`Download ${versionLabel} as ${fileMeta?.name}`}\n              onClick={handleDownload}\n            >\n              <Download aria-hidden=\"true\" className=\"size-4\" />\n            </ToolbarButton>\n            {onClose && (\n              <ToolbarButton label=\"Close artifact panel\" onClick={onClose}>\n                <X aria-hidden=\"true\" className=\"size-4\" />\n              </ToolbarButton>\n            )}\n          </div>\n\n          {/* Version rail. A listbox, not a tablist: these are N interchangeable\n              rows of one dataset (scrollable, growing while you read, each with\n              its own state), and the panel already owns a tablist for the views —\n              two tablists pointing at one panel would make the relationship\n              unreadable. Roving tabindex, selection follows focus. */}\n          {copyState === \"error\" && (\n            <p className=\"border-b bg-destructive/10 px-3 py-1.5 text-xs text-destructive\" role=\"status\">\n              Copy failed — select the source manually.\n            </p>\n          )}\n\n          <div\n            aria-controls={panelId}\n            aria-label={`${artifact.title} revisions`}\n            aria-orientation=\"horizontal\"\n            className=\"flex gap-1 overflow-x-auto border-b px-3 py-2\"\n            onKeyDown={handleRailKeyDown}\n            ref={railRef}\n            role=\"listbox\"\n          >\n            {versions.map(version => {\n              const active = version.id === selected.id\n              const streamingRow = version.state === \"streaming\"\n              return (\n                <button\n                  aria-label={`Version ${version.revision}${streamingRow ? \", generating\" : \"\"}`}\n                  aria-selected={active}\n                  className={cn(\n                    \"inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium tabular-nums transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\",\n                    active\n                      ? \"border-primary bg-primary/10 text-foreground\"\n                      : \"border-transparent text-muted-foreground hover:bg-muted hover:text-foreground\",\n                  )}\n                  data-version-id={version.id}\n                  key={version.id}\n                  onClick={() => selectVersion(version)}\n                  ref={node => {\n                    if (node) optionRefs.current.set(version.id, node)\n                    else optionRefs.current.delete(version.id)\n                  }}\n                  role=\"option\"\n                  tabIndex={active ? 0 : -1}\n                  type=\"button\"\n                >\n                  v{version.revision}\n                  {streamingRow && (\n                    <Loader2 aria-hidden=\"true\" className=\"size-3 animate-spin motion-reduce:animate-none\" />\n                  )}\n                </button>\n              )\n            })}\n          </div>\n\n          {/* View switcher + the selected revision's provenance. */}\n          <div className=\"flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 border-b px-3 py-1.5\">\n            <div className=\"flex shrink-0 gap-0.5\" onKeyDown={handleTabsKeyDown} role=\"tablist\">\n              {views.map(item => {\n                const blocked = isStreaming && item !== \"source\"\n                const active = item === view\n                return (\n                  <button\n                    aria-controls={panelId}\n                    aria-disabled={blocked || undefined}\n                    aria-selected={active}\n                    className={cn(\n                      \"cursor-pointer rounded-md px-2 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\",\n                      active ? \"bg-muted text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n                      blocked && \"cursor-not-allowed opacity-50 hover:text-muted-foreground\",\n                    )}\n                    id={`${uid}-tab-${item}`}\n                    key={item}\n                    onClick={() => {\n                      if (!blocked) setRequestedView(item)\n                    }}\n                    role=\"tab\"\n                    tabIndex={active ? 0 : -1}\n                    title={blocked ? viewBlockedReason : undefined}\n                    type=\"button\"\n                  >\n                    {VIEW_LABEL[item]}\n                  </button>\n                )\n              })}\n            </div>\n\n            <div className=\"flex min-w-0 items-center gap-2 text-xs text-muted-foreground\">\n              {isStreaming ? (\n                <span className=\"inline-flex shrink-0 items-center gap-1.5 font-medium text-foreground\" role=\"status\">\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"size-1.5 animate-pulse rounded-full bg-primary motion-reduce:animate-none\"\n                  />\n                  Generating {versionLabel}…\n                </span>\n              ) : (\n                <time className=\"shrink-0 tabular-nums\" dateTime={selected.createdAt}>\n                  {stamp(selected.createdAt)}\n                </time>\n              )}\n              {selected.messageId && onJumpToMessage && (\n                <button\n                  className=\"inline-flex shrink-0 cursor-pointer items-center gap-1 rounded-md px-1.5 py-0.5 transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\"\n                  onClick={() => {\n                    if (selected.messageId) onJumpToMessage(selected.messageId, selected)\n                  }}\n                  type=\"button\"\n                >\n                  <CornerUpLeft aria-hidden=\"true\" className=\"size-3\" />\n                  Go to message\n                </button>\n              )}\n            </div>\n          </div>\n\n          {selected.prompt && (\n            <p className=\"min-w-0 border-b px-3 py-1.5 text-xs text-muted-foreground\">\n              <span className=\"text-foreground/70\">From your message:</span>{\" \"}\n              <span className=\"[overflow-wrap:anywhere] italic\">“{selected.prompt}”</span>\n            </p>\n          )}\n\n          {/* The change summary sits *outside* the scroll box: the totals are the\n              one thing that must stay readable while you scroll through 600 rows. */}\n          {view === \"diff\" && diff && previous && (\n            <p className=\"flex flex-wrap items-center gap-x-2 gap-y-0.5 border-b px-3 py-1 text-xs\">\n              <span className=\"text-muted-foreground\">\n                v{previous.revision} → v{selected.revision}\n              </span>\n              <span className=\"font-medium text-primary tabular-nums\">+{diff.added}</span>\n              <span className=\"font-medium text-destructive tabular-nums\">-{diff.removed}</span>\n              {diff.added + diff.removed === 0 && <span className=\"text-muted-foreground\">identical</span>}\n              {diff.degraded && (\n                <span className=\"text-muted-foreground\">\n                  too different to match line by line — shown as one wholesale replace\n                </span>\n              )}\n              {diff.truncated > 0 && (\n                <span className=\"text-muted-foreground\">\n                  {diff.truncated} more rows not shown — open Source for the whole revision\n                </span>\n              )}\n            </p>\n          )}\n\n          <div\n            aria-labelledby={`${uid}-tab-${view}`}\n            className=\"min-h-0 flex-1 overflow-auto focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:outline-none\"\n            data-view={view}\n            id={panelId}\n            onScroll={handleScroll}\n            ref={scrollRef}\n            role=\"tabpanel\"\n            style={{ maxHeight: contentMaxHeight }}\n            tabIndex={0}\n          >\n            {view === \"source\" && (\n              <div className=\"py-2\">\n                <ArtifactSource\n                  content={selected.content}\n                  renderLine={renderLine}\n                  scrollTop={scrollTop}\n                  streaming={Boolean(isStreaming)}\n                  viewportHeight={viewportHeight}\n                  virtualize={virtualize}\n                />\n              </div>\n            )}\n\n            {view === \"preview\" && (\n              <ArtifactPreview\n                artifact={artifact}\n                boxHeight={typeof contentMaxHeight === \"number\" ? contentMaxHeight : undefined}\n                renderPreview={renderPreview}\n                version={selected}\n              />\n            )}\n\n            {view === \"diff\" && diff && <ArtifactDiffView diff={diff} />}\n          </div>\n        </>\n      )}\n    </div>\n  )\n})\n\n/* ------------------------------------------------------------------ */\n/* Preview                                                             */\n/* ------------------------------------------------------------------ */\n\nfunction ArtifactPreview({\n  artifact,\n  boxHeight,\n  renderPreview,\n  version,\n}: {\n  artifact: Artifact\n  /** Definite height of the scroll box, when there is one — an SVG needs it to scale down into. */\n  boxHeight?: number\n  renderPreview?: (version: ArtifactVersion, artifact: Artifact) => React.ReactNode\n  version: ArtifactVersion\n}) {\n  const [broken, setBroken] = React.useState<string | null>(null)\n\n  if (renderPreview) return <>{renderPreview(version, artifact)}</>\n\n  if (artifact.kind === \"markdown\") {\n    return (\n      <StreamingText\n        announce=\"off\"\n        className=\"px-4 py-3\"\n        cursor={false}\n        markdown\n        status=\"complete\"\n        text={version.content}\n      />\n    )\n  }\n\n  // A data: URL in an <img> renders the vector but cannot run its scripts —\n  // model output goes on screen without becoming an execution surface.\n  const src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(version.content)}`\n  if (broken === version.content) {\n    return (\n      <div className=\"flex flex-col items-center gap-2 px-6 py-10 text-center text-xs text-muted-foreground\">\n        <AlertCircle aria-hidden=\"true\" className=\"size-6 text-destructive\" />\n        <p>This revision isn&apos;t valid SVG yet — open Source to see what the model wrote.</p>\n      </div>\n    )\n  }\n  return (\n    // A viewBox-only SVG has no intrinsic size, so `width: auto` resolves to the\n    // container's width and a 240×132 chart is blown up to the panel's width —\n    // measured 640px tall inside a 300px box, i.e. cut in half. Giving the\n    // wrapper a definite height is what makes `max-h-full` resolvable, so the\n    // vector scales down to fit instead of overflowing.\n    <div className=\"flex min-h-40 items-center justify-center p-4\" style={{ height: boxHeight }}>\n      {/* eslint-disable-next-line @next/next/no-img-element -- model-generated markup, framework-agnostic (no next/image) */}\n      <img\n        alt={`${artifact.title}, revision ${version.revision}`}\n        className=\"max-h-full max-w-full object-contain\"\n        onError={() => setBroken(version.content)}\n        src={src}\n      />\n    </div>\n  )\n}\n\nexport default ArtifactPanel\n",
      "type": "registry:ui"
    },
    {
      "path": "src/registry/ui/artifact-panel.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * What the model produced. One field drives three decisions: which preview\n * renderer runs, which file extension the download gets, and which MIME type\n * the Blob is built with — so a chart never lands on disk as `chart.txt`.\n */\nexport const artifactKindSchema = z.enum([\"code\", \"markdown\", \"svg\"])\n\n/**\n * A revision is either finished or still being written by the model.\n *\n * `streaming` is a first-class state, not \"ready with a spinner\": its text is\n * incomplete by definition, so the panel refuses to download it, keeps it out\n * of the diff, and shows the raw source instead of a half-rendered preview.\n * Only the newest revision can ever be `streaming`.\n */\nexport const artifactVersionStateSchema = z.enum([\"complete\", \"streaming\"])\n\nexport const artifactVersionSchema = z.object({\n  id: z.string().min(1),\n  /** 1-based. What the rail prints (\"v3\") and what the download filename carries. */\n  revision: z.number().int().positive(),\n  /**\n   * The **whole** text of this revision, not a patch. An artifact is rewritten\n   * end to end on every turn, so each version stands on its own — that is what\n   * lets the panel copy, download or diff any revision without replaying history.\n   */\n  content: z.string(),\n  /** ISO 8601 with a timezone designator, e.g. \"2026-05-12T14:20:00.000Z\". */\n  createdAt: z.iso.datetime({ offset: true }),\n  /** Omitted means `\"complete\"`. */\n  state: artifactVersionStateSchema.optional(),\n  /**\n   * The instruction that produced this revision. This is the conversation link:\n   * an artifact panel without it is just a file viewer — with it, every version\n   * answers \"which of my messages made this change?\".\n   */\n  prompt: z.string().optional(),\n  /** Id of the chat message this revision came out of; enables \"jump to message\". */\n  messageId: z.string().optional(),\n})\n\nexport const artifactSchema = z.object({\n  id: z.string().min(1),\n  title: z.string().min(1),\n  kind: artifactKindSchema,\n  /** Extension hint for `kind: \"code\"` — \"tsx\", \"py\", \"sql\"… Ignored otherwise. */\n  language: z.string().optional(),\n  /** Overrides the derived download name; the `-v<n>` suffix is still appended. */\n  filename: z.string().optional(),\n  /** Oldest → newest. The last entry is the latest revision. At least one. */\n  versions: z.array(artifactVersionSchema).min(1),\n})\n\n/**\n * The panel's own render state — \"is there an artifact to show at all\",\n * independent of any single revision's `state`.\n */\nexport const artifactPanelStatusSchema = z.enum([\"loading\", \"empty\", \"error\", \"ready\"])\n\nexport const artifactPanelSchema = z.object({\n  status: artifactPanelStatusSchema,\n  /** `null` in every non-ready branch. */\n  artifact: artifactSchema.nullable(),\n})\n\nexport type ArtifactKind = z.infer<typeof artifactKindSchema>\nexport type ArtifactVersionState = z.infer<typeof artifactVersionStateSchema>\nexport type ArtifactVersion = z.infer<typeof artifactVersionSchema>\nexport type Artifact = z.infer<typeof artifactSchema>\nexport type ArtifactPanelStatus = z.infer<typeof artifactPanelStatusSchema>\nexport type ArtifactPanelData = z.infer<typeof artifactPanelSchema>\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}
