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,
} from "lucide-react";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { IpcClient } from "@/ipc/ipc_client";
import { useLoadAppFile } from "@/hooks/useLoadAppFile";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useStreamChat } from "@/hooks/useStreamChat";
import { selectedComponentPreviewAtom } from "@/atoms/previewAtoms";
import { ComponentSelection } from "@/ipc/ipc_types";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface ErrorBannerProps {
error: string | undefined;
onDismiss: () => void;
onAIFix: () => void;
}
const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
const [isCollapsed, setIsCollapsed] = useState(true);
const { isStreaming } = useStreamChat();
if (!error) return null;
const getTruncatedError = () => {
const firstLine = error.split("\n")[0];
const snippetLength = 200;
const snippet = error.substring(0, snippetLength);
return firstLine.length < snippet.length
? firstLine
: snippet + (snippet.length === snippetLength ? "..." : "");
};
return (
{/* Close button in top left */}
{/* Error message in the middle */}
setIsCollapsed(!isCollapsed)}
>
{isCollapsed ? getTruncatedError() : error}
{/* Tip message */}
Tip: Check if restarting the
app fixes the error.
{/* AI Fix button at the bottom */}
);
};
// 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 [availableRoutes, setAvailableRoutes] = useState<
Array<{ path: string; label: string }>
>([]);
// Load router related files to extract routes
const { content: routerContent } = useLoadAppFile(
selectedAppId,
"src/App.tsx",
);
// Effect to parse routes from the router file
useEffect(() => {
if (routerContent) {
try {
const routes: Array<{ path: string; label: string }> = [];
// Extract route imports and paths using regex for React Router syntax
// Match
const routePathsRegex = /]*\s+)?path=["']([^"']+)["']/g;
let match;
// Find all route paths in the router content
while ((match = routePathsRegex.exec(routerContent)) !== null) {
const path = match[1];
// Create a readable label from the path
const label =
path === "/"
? "Home"
: path
.split("/")
.filter((segment) => segment && !segment.startsWith(":"))
.pop()
?.replace(/[-_]/g, " ")
.replace(/^\w/, (c) => c.toUpperCase()) || path;
if (!routes.some((r) => r.path === path)) {
routes.push({ path, label });
}
}
setAvailableRoutes(routes);
} catch (e) {
console.error("Error parsing router file:", e);
}
}
}, [routerContent]);
// 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 [selectedComponentPreview, setSelectedComponentPreview] = useAtom(
selectedComponentPreviewAtom,
);
const iframeRef = useRef(null);
const [isPicking, setIsPicking] = useState(false);
// Deactivate component selector when selection is cleared
useEffect(() => {
if (!selectedComponentPreview) {
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(
{ type: "deactivate-dyad-component-selector" },
"*",
);
}
setIsPicking(false);
}
}, [selectedComponentPreview]);
// 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);
return;
}
if (event.data?.type === "dyad-component-selected") {
console.log("Component picked:", event.data);
setSelectedComponentPreview(parseComponentSelection(event.data));
setIsPicking(false);
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(errorMessage);
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(errorMessage);
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,
setSelectedComponentPreview,
]);
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]);
// Function to activate component selector in the iframe
const handleActivateComponentSelector = () => {
if (iframeRef.current?.contentWindow) {
const newIsPicking = !isPicking;
setIsPicking(newIsPicking);
iframeRef.current.contentWindow.postMessage(
{
type: newIsPicking
? "activate-dyad-component-selector"
: "deactivate-dyad-component-selector",
},
"*",
);
}
};
// 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);
// 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 Loading app preview...
;
}
// Display message if no app is selected
if (selectedAppId === null) {
return (
Select an app to see the preview.
);
}
return (
{/* Browser-style header */}
{/* Navigation Buttons */}
{isPicking
? "Deactivate component selector"
: "Select component"}
{/* 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 */}
setErrorMessage(undefined)}
onAIFix={() => {
if (selectedChatId) {
streamMessage({
prompt: `Fix error: ${errorMessage}`,
chatId: selectedChatId,
});
}
}}
/>
{!appUrl ? (
) : (
);
};
function parseComponentSelection(data: any): ComponentSelection | null {
if (
!data ||
data.type !== "dyad-component-selected" ||
typeof data.id !== "string" ||
typeof data.name !== "string"
) {
return null;
}
const { id, name } = data;
// The id is expected to be in the format "filepath:line:column"
const parts = id.split(":");
if (parts.length < 3) {
console.error(`Invalid component selection id format: "${id}"`);
return null;
}
const columnStr = parts.pop();
const lineStr = parts.pop();
const relativePath = parts.join(":");
if (!columnStr || !lineStr || !relativePath) {
console.error(`Could not parse component selection from id: "${id}"`);
return null;
}
const lineNumber = parseInt(lineStr, 10);
const columnNumber = parseInt(columnStr, 10);
if (isNaN(lineNumber) || isNaN(columnNumber)) {
console.error(`Could not parse line/column from id: "${id}"`);
return null;
}
return {
id,
name,
relativePath,
lineNumber,
columnNumber,
};
}