import { Code2Icon, CopyIcon, DownloadIcon, EyeIcon, LoaderIcon, PackageIcon, SquareArrowOutUpRightIcon, XIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Artifact, ArtifactAction, ArtifactActions, ArtifactContent, ArtifactHeader, ArtifactTitle, } from "@/components/ai-elements/artifact"; import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown"; import { Select, SelectItem } from "@/components/ui/select"; import { SelectContent, SelectGroup, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { CodeEditor } from "@/components/workspace/code-editor"; import { useArtifactContent } from "@/core/artifacts/hooks"; import { appendHtmlPreviewBaseHref, appendHtmlPreviewScrollRestoration, createHtmlPreviewScrollKey, getArtifactViewState, HTML_PREVIEW_SCROLL_MESSAGE_SOURCE, } from "@/core/artifacts/preview"; import { urlOfArtifact } from "@/core/artifacts/utils"; import { writeTextToClipboard } from "@/core/clipboard"; import { useI18n } from "@/core/i18n/hooks"; import { findToolCallResult } from "@/core/messages/utils"; import { installSkill } from "@/core/skills/api"; import { streamdownPlugins } from "@/core/streamdown"; import { checkCodeFile, getFileName } from "@/core/utils/files"; import { env } from "@/env"; import { cn } from "@/lib/utils"; import { ArtifactLink } from "../citations/artifact-link"; import { useThread } from "../messages/context"; import { Tooltip } from "../tooltip"; import { useArtifacts } from "./context"; const WRITE_FILE_PREVIEW_REFRESH_INTERVAL_MS = 3000; export function ArtifactFileDetail({ className, filepath: filepathFromProps, threadId, }: { className?: string; filepath: string; threadId: string; }) { const { t } = useI18n(); const { artifacts, setOpen, select } = useArtifacts(); const { thread, isMock } = useThread(); const isWriteFile = useMemo(() => { return filepathFromProps.startsWith("write-file:"); }, [filepathFromProps]); const filepath = useMemo(() => { if (isWriteFile) { const url = new URL(filepathFromProps); return decodeURIComponent(url.pathname); } return filepathFromProps; }, [filepathFromProps, isWriteFile]); const isSkillFile = useMemo(() => { return filepath.endsWith(".skill"); }, [filepath]); const { isCodeFile, language } = useMemo(() => { if (isWriteFile) { let language = checkCodeFile(filepath).language; language ??= "text"; return { isCodeFile: true, language }; } // Treat .skill files as markdown (they contain SKILL.md) if (isSkillFile) { return { isCodeFile: true, language: "markdown" }; } return checkCodeFile(filepath); }, [filepath, isWriteFile, isSkillFile]); const isSupportPreview = useMemo(() => { return language === "html" || language === "markdown"; }, [language]); const toolResult = (() => { if (!isWriteFile) { return undefined; } const url = new URL(filepathFromProps); const toolCallId = url.searchParams.get("tool_call_id"); if (!toolCallId) { return undefined; } return findToolCallResult(toolCallId, thread.messages); })(); const artifactViewState = getArtifactViewState({ filepath: filepathFromProps, isSupportPreview, toolResult, }); const { content, url } = useArtifactContent({ threadId, filepath: filepathFromProps, enabled: isCodeFile && !isWriteFile, }); const displayContent = content ?? ""; const isWritingFile = isWriteFile && toolResult === undefined; const visibleContent = useThrottledValue( displayContent, isWritingFile ? WRITE_FILE_PREVIEW_REFRESH_INTERVAL_MS : 0, filepathFromProps, ); const [viewMode, setViewMode] = useState<"code" | "preview">( artifactViewState.initialViewMode, ); const [isInstalling, setIsInstalling] = useState(false); useEffect(() => { setViewMode(artifactViewState.initialViewMode); }, [artifactViewState.initialViewMode]); const handleInstallSkill = useCallback(async () => { if (isInstalling) return; setIsInstalling(true); try { const result = await installSkill({ thread_id: threadId, path: filepath, }); if (result.success) { toast.success(result.message); } else { toast.error(result.message ?? "Failed to install skill"); } } catch (error) { console.error("Failed to install skill:", error); toast.error("Failed to install skill"); } finally { setIsInstalling(false); } }, [threadId, filepath, isInstalling]); return (
{isWriteFile ? (
{getFileName(filepath)}
) : ( )}
{artifactViewState.canPreview && ( { if (value) { setViewMode(value as "code" | "preview"); } }} > )}
{!isWriteFile && filepath.endsWith(".skill") && ( )} {!isWriteFile && ( { const w = window.open( urlOfArtifact({ filepath, threadId, isMock }), "_blank", "noopener,noreferrer", ); if (w) w.opener = null; }} /> )} {isCodeFile && ( { void (async () => { const didCopy = await writeTextToClipboard( visibleContent ?? "", ); if (!didCopy) { toast.error(t.clipboard.failedToCopyToClipboard); return; } toast.success(t.clipboard.copiedToClipboard); })().catch(() => { toast.error(t.clipboard.failedToCopyToClipboard); }); }} tooltip={t.clipboard.copyToClipboard} /> )} {!isWriteFile && ( { const w = window.open( urlOfArtifact({ filepath, threadId, download: true, isMock, }), "_blank", "noopener,noreferrer", ); if (w) w.opener = null; }} /> )} setOpen(false)} tooltip={t.common.close} />
{artifactViewState.canPreview && viewMode === "preview" && (language === "markdown" || language === "html") && ( )} {isCodeFile && viewMode === "code" && ( )} {!isCodeFile && (