import { selectedAppIdAtom, appUrlAtom, appOutputAtom, previewErrorMessageAtom, } from "@/atoms/appAtoms"; import { useAtomValue, useSetAtom, useAtom } from "jotai"; import { useEffect, useRef, useState } from "react"; import { ArrowLeft, ArrowRight, RefreshCw, ExternalLink, Loader2, X, Sparkles, ChevronDown, Lightbulb, ChevronRight, MousePointerClick, Power, MonitorSmartphone, Monitor, Tablet, Smartphone, Pen, } from "lucide-react"; import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { CopyErrorMessage } from "@/components/CopyErrorMessage"; import { IpcClient } from "@/ipc/ipc_client"; import { useParseRouter } from "@/hooks/useParseRouter"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useStreamChat } from "@/hooks/useStreamChat"; import { selectedComponentsPreviewAtom, visualEditingSelectedComponentAtom, currentComponentCoordinatesAtom, previewIframeRefAtom, annotatorModeAtom, screenshotDataUrlAtom, pendingVisualChangesAtom, } from "@/atoms/previewAtoms"; import { ComponentSelection } from "@/ipc/ipc_types"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useRunApp } from "@/hooks/useRunApp"; import { useShortcut } from "@/hooks/useShortcut"; import { cn } from "@/lib/utils"; import { normalizePath } from "../../../shared/normalizePath"; import { showError } from "@/lib/toast"; import { AnnotatorOnlyForPro } from "./AnnotatorOnlyForPro"; import { useAttachments } from "@/hooks/useAttachments"; import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo"; import { Annotator } from "@/pro/ui/components/Annotator/Annotator"; import { VisualEditingToolbar } from "./VisualEditingToolbar"; interface ErrorBannerProps { error: { message: string; source: "preview-app" | "dyad-app" } | undefined; onDismiss: () => void; onAIFix: () => void; } const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => { const [isCollapsed, setIsCollapsed] = useState(true); const { isStreaming } = useStreamChat(); if (!error) return null; const isDockerError = error.message.includes("Cannot connect to the Docker"); const getTruncatedError = () => { const firstLine = error.message.split("\n")[0]; const snippetLength = 250; const snippet = error.message.substring(0, snippetLength); return firstLine.length < snippet.length ? firstLine : snippet + (snippet.length === snippetLength ? "..." : ""); }; return (
{/* Close button in top left */} {/* Add a little chip that says "Internal error" if source is "dyad-app" */} {error.source === "dyad-app" && (
Internal Dyad error
)} {/* Error message in the middle */}
setIsCollapsed(!isCollapsed)} > {isCollapsed ? getTruncatedError() : error.message}
{/* Tip message */}
Tip: {isDockerError ? "Make sure Docker Desktop is running and try restarting the app." : error.source === "dyad-app" ? "Try restarting the Dyad app or restarting your computer to see if that fixes the error." : "Check if restarting the app fixes the error."}
{/* Action buttons at the bottom */} {!isDockerError && error.source === "preview-app" && (
)}
); }; // Preview iframe component export const PreviewIframe = ({ loading }: { loading: boolean }) => { const selectedAppId = useAtomValue(selectedAppIdAtom); const { appUrl, originalUrl } = useAtomValue(appUrlAtom); const setAppOutput = useSetAtom(appOutputAtom); // State to trigger iframe reload const [reloadKey, setReloadKey] = useState(0); const [errorMessage, setErrorMessage] = useAtom(previewErrorMessageAtom); const selectedChatId = useAtomValue(selectedChatIdAtom); const { streamMessage } = useStreamChat(); const { routes: availableRoutes } = useParseRouter(selectedAppId); const { restartApp } = useRunApp(); const { userBudget } = useUserBudgetInfo(); const isProMode = !!userBudget; // Navigation state const [isComponentSelectorInitialized, setIsComponentSelectorInitialized] = useState(false); const [canGoBack, setCanGoBack] = useState(false); const [canGoForward, setCanGoForward] = useState(false); const [navigationHistory, setNavigationHistory] = useState([]); const [currentHistoryPosition, setCurrentHistoryPosition] = useState(0); const setSelectedComponentsPreview = useSetAtom( selectedComponentsPreviewAtom, ); const [visualEditingSelectedComponent, setVisualEditingSelectedComponent] = useAtom(visualEditingSelectedComponentAtom); const setCurrentComponentCoordinates = useSetAtom( currentComponentCoordinatesAtom, ); const setPreviewIframeRef = useSetAtom(previewIframeRefAtom); const iframeRef = useRef(null); const [isPicking, setIsPicking] = useState(false); const [annotatorMode, setAnnotatorMode] = useAtom(annotatorModeAtom); const [screenshotDataUrl, setScreenshotDataUrl] = useAtom( screenshotDataUrlAtom, ); const { addAttachments } = useAttachments(); const setPendingChanges = useSetAtom(pendingVisualChangesAtom); // AST Analysis State const [isDynamicComponent, setIsDynamicComponent] = useState(false); const [hasStaticText, setHasStaticText] = useState(false); // Device mode state type DeviceMode = "desktop" | "tablet" | "mobile"; const [deviceMode, setDeviceMode] = useState("desktop"); const [isDevicePopoverOpen, setIsDevicePopoverOpen] = useState(false); // Device configurations const deviceWidthConfig = { tablet: 768, mobile: 375, }; //detect if the user is using Mac const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; const analyzeComponent = async (componentId: string) => { if (!componentId || !selectedAppId) return; try { const result = await IpcClient.getInstance().analyzeComponent({ appId: selectedAppId, componentId, }); setIsDynamicComponent(result.isDynamic); setHasStaticText(result.hasStaticText); // Automatically enable text editing if component has static text if (result.hasStaticText && iframeRef.current?.contentWindow) { iframeRef.current.contentWindow.postMessage( { type: "enable-dyad-text-editing", data: { componentId: componentId, runtimeId: visualEditingSelectedComponent?.runtimeId, }, }, "*", ); } } catch (err) { console.error("Failed to analyze component", err); setIsDynamicComponent(false); setHasStaticText(false); } }; const handleTextUpdated = async (data: any) => { const { componentId, text } = data; if (!componentId || !selectedAppId) return; // Parse componentId to extract file path and line number const [filePath, lineStr] = componentId.split(":"); const lineNumber = parseInt(lineStr, 10); if (!filePath || isNaN(lineNumber)) { console.error("Invalid componentId format:", componentId); return; } // Store text change in pending changes setPendingChanges((prev) => { const updated = new Map(prev); const existing = updated.get(componentId); updated.set(componentId, { componentId: componentId, componentName: existing?.componentName || visualEditingSelectedComponent?.name || "", relativePath: filePath, lineNumber: lineNumber, styles: existing?.styles || {}, textContent: text, }); return updated; }); }; // Function to get current styles from selected element const getCurrentElementStyles = () => { if (!iframeRef.current?.contentWindow || !visualEditingSelectedComponent) return; try { // Send message to iframe to get current styles iframeRef.current.contentWindow.postMessage( { type: "get-dyad-component-styles", data: { elementId: visualEditingSelectedComponent.id, runtimeId: visualEditingSelectedComponent.runtimeId, }, }, "*", ); } catch (error) { console.error("Failed to get element styles:", error); } }; useEffect(() => { setAnnotatorMode(false); }, []); // Reset visual editing state when app changes or component unmounts useEffect(() => { return () => { // Cleanup on unmount or when app changes setVisualEditingSelectedComponent(null); setPendingChanges(new Map()); setCurrentComponentCoordinates(null); }; }, [selectedAppId]); // Update iframe ref atom useEffect(() => { setPreviewIframeRef(iframeRef.current); }, [iframeRef.current, setPreviewIframeRef]); // Send pro mode status to iframe useEffect(() => { if (iframeRef.current?.contentWindow && isComponentSelectorInitialized) { iframeRef.current.contentWindow.postMessage( { type: "dyad-pro-mode", enabled: isProMode }, "*", ); } }, [isProMode, isComponentSelectorInitialized]); // Add message listener for iframe errors and navigation events useEffect(() => { const handleMessage = (event: MessageEvent) => { // Only handle messages from our iframe if (event.source !== iframeRef.current?.contentWindow) { return; } if (event.data?.type === "dyad-component-selector-initialized") { setIsComponentSelectorInitialized(true); iframeRef.current?.contentWindow?.postMessage( { type: "dyad-pro-mode", enabled: isProMode }, "*", ); return; } if (event.data?.type === "dyad-text-updated") { handleTextUpdated(event.data); return; } if (event.data?.type === "dyad-text-finalized") { handleTextUpdated(event.data); return; } if (event.data?.type === "dyad-component-selected") { console.log("Component picked:", event.data); const component = parseComponentSelection(event.data); if (!component) return; // Store the coordinates if (event.data.coordinates && isProMode) { setCurrentComponentCoordinates(event.data.coordinates); } // Add to selected components if not already there setSelectedComponentsPreview((prev) => { const exists = prev.some((c) => { // Check by runtimeId if available otherwise by id // Stored components may have lost their runtimeId after re-renders or reloading the page if (component.runtimeId && c.runtimeId) { return c.runtimeId === component.runtimeId; } return c.id === component.id; }); if (exists) { return prev; } return [...prev, component]; }); if (isProMode) { // Set as the highlighted component for visual editing setVisualEditingSelectedComponent(component); // Trigger AST analysis analyzeComponent(component.id); } return; } if (event.data?.type === "dyad-component-deselected") { const componentId = event.data.componentId; if (componentId) { // Disable text editing for the deselected component if (iframeRef.current?.contentWindow) { iframeRef.current.contentWindow.postMessage( { type: "disable-dyad-text-editing", data: { componentId }, }, "*", ); } setSelectedComponentsPreview((prev) => prev.filter((c) => c.id !== componentId), ); setVisualEditingSelectedComponent((prev) => { const shouldClear = prev?.id === componentId; if (shouldClear) { setCurrentComponentCoordinates(null); } return shouldClear ? null : prev; }); } return; } if (event.data?.type === "dyad-component-coordinates-updated") { if (event.data.coordinates) { setCurrentComponentCoordinates(event.data.coordinates); } return; } if (event.data?.type === "dyad-screenshot-response") { if (event.data.success && event.data.dataUrl) { setScreenshotDataUrl(event.data.dataUrl); setAnnotatorMode(true); } else { showError(event.data.error); } return; } const { type, payload } = event.data as { type: | "window-error" | "unhandled-rejection" | "iframe-sourcemapped-error" | "build-error-report" | "pushState" | "replaceState"; payload?: { message?: string; stack?: string; reason?: string; newUrl?: string; file?: string; frame?: string; }; }; if ( type === "window-error" || type === "unhandled-rejection" || type === "iframe-sourcemapped-error" ) { const stack = type === "iframe-sourcemapped-error" ? payload?.stack?.split("\n").slice(0, 1).join("\n") : payload?.stack; const errorMessage = `Error ${ payload?.message || payload?.reason }\nStack trace: ${stack}`; console.error("Iframe error:", errorMessage); setErrorMessage({ message: errorMessage, source: "preview-app" }); setAppOutput((prev) => [ ...prev, { message: `Iframe error: ${errorMessage}`, type: "client-error", appId: selectedAppId!, timestamp: Date.now(), }, ]); } else if (type === "build-error-report") { console.debug(`Build error report: ${payload}`); const errorMessage = `${payload?.message} from file ${payload?.file}.\n\nSource code:\n${payload?.frame}`; setErrorMessage({ message: errorMessage, source: "preview-app" }); setAppOutput((prev) => [ ...prev, { message: `Build error report: ${JSON.stringify(payload)}`, type: "client-error", appId: selectedAppId!, timestamp: Date.now(), }, ]); } else if (type === "pushState" || type === "replaceState") { console.debug(`Navigation event: ${type}`, payload); // Update navigation history based on the type of state change if (type === "pushState" && payload?.newUrl) { // For pushState, we trim any forward history and add the new URL const newHistory = [ ...navigationHistory.slice(0, currentHistoryPosition + 1), payload.newUrl, ]; setNavigationHistory(newHistory); setCurrentHistoryPosition(newHistory.length - 1); } else if (type === "replaceState" && payload?.newUrl) { // For replaceState, we replace the current URL const newHistory = [...navigationHistory]; newHistory[currentHistoryPosition] = payload.newUrl; setNavigationHistory(newHistory); } } }; window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); }, [ navigationHistory, currentHistoryPosition, selectedAppId, errorMessage, setErrorMessage, setIsComponentSelectorInitialized, setSelectedComponentsPreview, setVisualEditingSelectedComponent, ]); useEffect(() => { // Update navigation buttons state setCanGoBack(currentHistoryPosition > 0); setCanGoForward(currentHistoryPosition < navigationHistory.length - 1); }, [navigationHistory, currentHistoryPosition]); // Initialize navigation history when iframe loads useEffect(() => { if (appUrl) { setNavigationHistory([appUrl]); setCurrentHistoryPosition(0); setCanGoBack(false); setCanGoForward(false); } }, [appUrl]); // Get current styles when component is selected for visual editing useEffect(() => { if (visualEditingSelectedComponent) { getCurrentElementStyles(); } }, [visualEditingSelectedComponent]); // Function to activate component selector in the iframe const handleActivateComponentSelector = () => { if (iframeRef.current?.contentWindow) { const newIsPicking = !isPicking; if (!newIsPicking) { // Clean up any text editing states when deactivating iframeRef.current.contentWindow.postMessage( { type: "cleanup-all-text-editing" }, "*", ); } setIsPicking(newIsPicking); setVisualEditingSelectedComponent(null); iframeRef.current.contentWindow.postMessage( { type: newIsPicking ? "activate-dyad-component-selector" : "deactivate-dyad-component-selector", }, "*", ); } }; // Function to handle annotator button click const handleAnnotatorClick = () => { if (annotatorMode) { setAnnotatorMode(false); return; } if (iframeRef.current?.contentWindow) { iframeRef.current.contentWindow.postMessage( { type: "dyad-take-screenshot", }, "*", ); } }; // Activate component selector using a shortcut useShortcut( "c", { shift: true, ctrl: !isMac, meta: isMac }, handleActivateComponentSelector, isComponentSelectorInitialized, iframeRef, ); // Function to navigate back const handleNavigateBack = () => { if (canGoBack && iframeRef.current?.contentWindow) { iframeRef.current.contentWindow.postMessage( { type: "navigate", payload: { direction: "backward" }, }, "*", ); // Update our local state setCurrentHistoryPosition((prev) => prev - 1); setCanGoBack(currentHistoryPosition - 1 > 0); setCanGoForward(true); } }; // Function to navigate forward const handleNavigateForward = () => { if (canGoForward && iframeRef.current?.contentWindow) { iframeRef.current.contentWindow.postMessage( { type: "navigate", payload: { direction: "forward" }, }, "*", ); // Update our local state setCurrentHistoryPosition((prev) => prev + 1); setCanGoBack(true); setCanGoForward( currentHistoryPosition + 1 < navigationHistory.length - 1, ); } }; // Function to handle reload const handleReload = () => { setReloadKey((prevKey) => prevKey + 1); setErrorMessage(undefined); // Reset visual editing state setVisualEditingSelectedComponent(null); setPendingChanges(new Map()); setCurrentComponentCoordinates(null); // Optionally, add logic here if you need to explicitly stop/start the app again // For now, just changing the key should remount the iframe console.debug("Reloading iframe preview for app", selectedAppId); }; // Function to navigate to a specific route const navigateToRoute = (path: string) => { if (iframeRef.current?.contentWindow && appUrl) { // Create the full URL by combining the base URL with the path const baseUrl = new URL(appUrl).origin; const newUrl = `${baseUrl}${path}`; // Navigate to the URL iframeRef.current.contentWindow.location.href = newUrl; // iframeRef.current.src = newUrl; // Update navigation history const newHistory = [ ...navigationHistory.slice(0, currentHistoryPosition + 1), newUrl, ]; setNavigationHistory(newHistory); setCurrentHistoryPosition(newHistory.length - 1); setCanGoBack(true); setCanGoForward(false); } }; // Display loading state if (loading) { return (

Preparing app preview...

); } // Display message if no app is selected if (selectedAppId === null) { return (
Select an app to see the preview.
); } const onRestart = () => { restartApp(); }; return (
{/* Browser-style header - hide when annotator is active */} {!annotatorMode && (
{/* Navigation Buttons */}

{isPicking ? "Deactivate component selector" : "Select component"}

{isMac ? "⌘ + ⇧ + C" : "Ctrl + ⇧ + C"}

{annotatorMode ? "Annotator mode active" : "Activate annotator"}

{/* Address Bar with Routes Dropdown - using shadcn/ui dropdown-menu */}
{navigationHistory[currentHistoryPosition] ? new URL(navigationHistory[currentHistoryPosition]) .pathname : "/"}
{availableRoutes.length > 0 ? ( availableRoutes.map((route) => ( navigateToRoute(route.path)} className="flex justify-between" > {route.label} {route.path} )) ) : ( Loading routes... )}
{/* Action Buttons */}
{/* Device Mode Button */} e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} > { if (value) { setDeviceMode(value as DeviceMode); setIsDevicePopoverOpen(false); } }} variant="outline" > {/* Tooltips placed inside items instead of wrapping to avoid asChild prop merging that breaks highlighting */}

Desktop

Tablet

Mobile

)}
setErrorMessage(undefined)} onAIFix={() => { if (selectedChatId) { streamMessage({ prompt: `Fix error: ${errorMessage?.message}`, chatId: selectedChatId, }); } }} /> {!appUrl ? (

Starting your app server...

) : (
{annotatorMode && screenshotDataUrl ? (
{userBudget ? ( ) : ( setAnnotatorMode(false)} /> )}
) : ( <>