diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 1eebfc69d..c409ef819 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -1,3 +1,5 @@ pnpm-lock.yaml .omc/ src/content/**/*.mdx +playwright-report/ +test-results/ diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 93130c44f..46ae18441 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -8,7 +8,7 @@ import { SquareArrowOutUpRightIcon, XIcon, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Streamdown } from "streamdown"; @@ -30,8 +30,16 @@ import { 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 { 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"; @@ -44,6 +52,8 @@ import { Tooltip } from "../tooltip"; import { useArtifacts } from "./context"; +const WRITE_FILE_PREVIEW_REFRESH_INTERVAL_MS = 3000; + export function ArtifactFileDetail({ className, filepath: filepathFromProps, @@ -55,6 +65,7 @@ export function ArtifactFileDetail({ }) { const { t } = useI18n(); const { artifacts, setOpen, select } = useArtifacts(); + const { thread, isMock } = useThread(); const isWriteFile = useMemo(() => { return filepathFromProps.startsWith("write-file:"); }, [filepathFromProps]); @@ -83,6 +94,22 @@ export function ArtifactFileDetail({ 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, @@ -90,17 +117,20 @@ export function ArtifactFileDetail({ }); 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">("code"); + const [viewMode, setViewMode] = useState<"code" | "preview">( + artifactViewState.initialViewMode, + ); const [isInstalling, setIsInstalling] = useState(false); - const { isMock } = useThread(); useEffect(() => { - if (isSupportPreview) { - setViewMode("preview"); - } else { - setViewMode("code"); - } - }, [isSupportPreview]); + setViewMode(artifactViewState.initialViewMode); + }, [artifactViewState.initialViewMode]); const handleInstallSkill = useCallback(async () => { if (isInstalling) return; @@ -149,7 +179,7 @@ export function ArtifactFileDetail({
- {isSupportPreview && ( + {artifactViewState.canPreview && ( { try { - await navigator.clipboard.writeText(displayContent ?? ""); + await navigator.clipboard.writeText(visibleContent ?? ""); toast.success(t.clipboard.copiedToClipboard); } catch (error) { toast.error("Failed to copy to clipboard"); @@ -249,20 +279,20 @@ export function ArtifactFileDetail({
- {isSupportPreview && + {artifactViewState.canPreview && viewMode === "preview" && (language === "markdown" || language === "html") && ( )} {isCodeFile && viewMode === "code" && ( )} @@ -279,25 +309,78 @@ export function ArtifactFileDetail({ export function ArtifactFilePreview({ content, - isWriteFile, language, + scrollKey, url, }: { content: string; - isWriteFile: boolean; language: string; + scrollKey: string; url?: string; }) { + const iframeRef = useRef(null); + const scrollPositionRef = useRef({ x: 0, y: 0 }); + const scrollMessageKey = useMemo( + () => createHtmlPreviewScrollKey(scrollKey), + [scrollKey], + ); const [htmlPreviewUrl, setHtmlPreviewUrl] = useState(); useEffect(() => { - if (language !== "html" || isWriteFile) { + scrollPositionRef.current = { x: 0, y: 0 }; + }, [scrollMessageKey]); + + useEffect(() => { + if (language !== "html") { + return; + } + + const handleMessage = (event: MessageEvent) => { + if (event.source !== iframeRef.current?.contentWindow) { + return; + } + if (!isArtifactScrollMessage(event.data, scrollMessageKey)) { + return; + } + + if (event.data.type === "save") { + const x = scrollCoordinate(event.data.x); + const y = scrollCoordinate(event.data.y); + if (x !== undefined && y !== undefined) { + scrollPositionRef.current = { x, y }; + } + return; + } + + iframeRef.current?.contentWindow?.postMessage( + { + source: HTML_PREVIEW_SCROLL_MESSAGE_SOURCE, + key: scrollMessageKey, + type: "restore", + ...scrollPositionRef.current, + }, + "*", + ); + }; + + window.addEventListener("message", handleMessage); + return () => { + window.removeEventListener("message", handleMessage); + }; + }, [language, scrollMessageKey]); + + useEffect(() => { + if (language !== "html") { setHtmlPreviewUrl(undefined); return; } - const blob = new Blob([htmlWithBaseHref(content ?? "", url)], { - type: "text/html", + const previewContent = appendHtmlPreviewScrollRestoration( + appendHtmlPreviewBaseHref(content ?? "", url), + scrollKey, + ); + const blob = new Blob([previewContent], { + type: "text/html;charset=utf-8", }); const objectUrl = URL.createObjectURL(blob); setHtmlPreviewUrl(objectUrl); @@ -305,7 +388,7 @@ export function ArtifactFilePreview({ return () => { URL.revokeObjectURL(objectUrl); }; - }, [content, isWriteFile, language, url]); + }, [content, language, scrollKey, url]); if (language === "markdown") { return ( @@ -323,38 +406,110 @@ export function ArtifactFilePreview({ if (language === "html") { return (