"use client"; import { ChevronLeft, ChevronRight, Code2, Download, Eye, Maximize2, Minimize2, X, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react"; import { type ReactNode, type Ref, useCallback, useEffect, useId, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import type { AgentCodeLanguage } from "@/components/agents/agent-code"; import { CodeBlock } from "@/components/agents/code-block"; import { CopyButton } from "@/components/motion/copy-button"; import { Skeleton, SkeletonText } from "@/components/motion/skeleton"; import { Tabs, TabsList, TabsTrigger } from "@/components/motion/tabs"; import { TextShimmer } from "@/components/motion/text-shimmer"; import { EASE_DRAWER, EASE_OUT, SPRING_LAYOUT, SPRING_PANEL, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface ArtifactVersion { id: string; /** Short label shown under the title ("Added dark mode"). */ label?: string; code: string; language?: AgentCodeLanguage; /** Rendered output for the Preview tab. Without it the panel shows code only. */ preview?: ReactNode; } export type ArtifactTab = "code" | "preview"; export interface ArtifactPanelProps { title: ReactNode; versions: ArtifactVersion[]; /** Used by the code header and the default download. */ filename?: string; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** Selected version id; defaults to the latest. */ version?: string; defaultVersion?: string; onVersionChange?: (id: string) => void; tab?: ArtifactTab; defaultTab?: ArtifactTab; onTabChange?: (tab: ArtifactTab) => void; expanded?: boolean; defaultExpanded?: boolean; onExpandedChange?: (expanded: boolean) => void; /** A new version is being written: shows a skeleton in place of the content. */ generating?: boolean; /** Replace the default download (a Blob of the code saved as `filename`). */ onDownload?: (version: ArtifactVersion) => void; /** Panel width in px when docked beside the chat. Default 400. */ width?: number; /** The chat the panel slides in next to. Omit to render the panel alone. */ children?: ReactNode; className?: string; panelClassName?: string; } function useControllable( value: T | undefined, defaultValue: T, onChange: ((next: T) => void) | undefined, ) { const [internal, setInternal] = useState(defaultValue); const controlled = value !== undefined; const current = controlled ? value : internal; const set = useCallback( (next: T) => { if (!controlled) setInternal(next); onChange?.(next); }, [controlled, onChange], ); return [current, set] as const; } function downloadText(text: string, filename: string) { const url = URL.createObjectURL(new Blob([text], { type: "text/plain" })); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } export function ArtifactPanel({ title, versions, filename = "artifact.tsx", open: openProp, defaultOpen = true, onOpenChange, version: versionProp, defaultVersion, onVersionChange, tab: tabProp, defaultTab = "preview", onTabChange, expanded: expandedProp, defaultExpanded = false, onExpandedChange, generating = false, onDownload, width = 400, children, className, panelClassName, }: ArtifactPanelProps) { const uid = useId(); const reduce = useReducedMotion() ?? false; const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange); const [expanded, setExpanded] = useControllable(expandedProp, defaultExpanded, onExpandedChange); const [tab, setTab] = useControllable(tabProp, defaultTab, onTabChange); const latest = versions[versions.length - 1]?.id ?? ""; const [versionId, setVersionId] = useControllable( versionProp, defaultVersion ?? "", onVersionChange, ); // Uncontrolled with no default follows the newest version as they arrive. const index = Math.max( 0, versions.findIndex((v) => v.id === (versionId || latest)), ); const current = versions[index]; const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); // Escape leaves fullscreen. useEffect(() => { if (!expanded) return; const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") { focusToggle.current = true; setExpanded(false); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [expanded, setExpanded]); // Docked and fullscreen surfaces mount fresh; after a user toggle the new // one takes focus on its expand/minimize button. const focusToggle = useRef(false); const toggleExpanded = (next: boolean) => { focusToggle.current = true; setExpanded(next); }; const effectiveTab: ArtifactTab = current?.preview == null ? "code" : tab; const body = current ? ( { const next = versions[i]; if (next) setVersionId(next.id); }} generating={generating} expanded={expanded} onExpand={toggleExpanded} focusRef={focusToggle} onClose={() => { setExpanded(false); setOpen(false); }} onDownload={() => onDownload ? onDownload(current) : downloadText(current.code, filename) } reduce={reduce} /> ) : null; const surface = cn( "flex h-full flex-col overflow-hidden rounded-2xl border border-border bg-card text-card-foreground shadow-sm", panelClassName, ); return (
{children != null &&
{children}
} {open && ( // Width is animated on purpose: the docked slot opening is what // pushes the chat aside, so the chat reflows smoothly instead of // jumping. The panel inside keeps a fixed width and never reflows.
{expanded ? (
Open in fullscreen
) : ( {body} )}
)}
{mounted && createPortal( {open && expanded && ( toggleExpanded(false)} /> {/* Shares a layoutId with the docked panel, so the same surface grows out of the chat instead of a new one appearing. */} {body} )} , document.body, )}
); } const LABEL_ROLL: Variants = { enter: (d: number) => ({ opacity: 0, y: d * 10 }), center: { opacity: 1, y: 0 }, exit: (d: number) => ({ opacity: 0, y: d * -10 }), }; const LABEL_FADE: Variants = { enter: { opacity: 0 }, center: { opacity: 1 }, exit: { opacity: 0 } }; interface PanelBodyProps { uid: string; title: ReactNode; filename: string; versions: ArtifactVersion[]; index: number; current: ArtifactVersion; tab: ArtifactTab; onTab: (tab: ArtifactTab) => void; onVersion: (index: number) => void; generating: boolean; expanded: boolean; onExpand: (expanded: boolean) => void; onClose: () => void; onDownload: () => void; focusRef: { current: boolean }; reduce: boolean; } function PanelBody({ uid, title, filename, versions, index, current, tab, onTab, onVersion, generating, expanded, onExpand, onClose, onDownload, focusRef, reduce, }: PanelBodyProps) { const expandRef = useRef(null); const prevIndex = useRef(index); const direction = index >= prevIndex.current ? 1 : -1; useEffect(() => { prevIndex.current = index; }, [index]); useEffect(() => { if (!focusRef.current) return; focusRef.current = false; expandRef.current?.focus(); }, [focusRef]); const hasPreview = current.preview != null; const contentKey = generating ? "generating" : `${current.id}:${tab}`; return ( <>

{title}

{generating ? ( Generating new version… ) : ( (current.label ?? `Version ${index + 1}`) )}
{versions.length > 1 && (
onVersion(index - 1)} > v{index + 1} /{versions.length} onVersion(index + 1)} >
)}
{hasPreview ? ( onTab(v as ArtifactTab)} variant="segment" > ) : ( {filename} )}
onExpand(!expanded)} > {expanded ? : }
{generating ? (
Generating a new version {tab === "preview" && hasPreview ? ( <> ) : ( )}
) : tab === "preview" && hasPreview ? (
{current.preview}
) : ( )}
); } const IconButton = ({ label, disabled, onClick, children, ref, }: { label: string; disabled?: boolean; onClick: () => void; children: ReactNode; ref?: Ref; }) => ( {children} );