Files
open-lovable/app/page.tsx
T
Developers Digest b96d048dbd Save current v2 sandbox implementation before styling refactor
- Modified sandbox API routes for v2 implementation
- Updated sandbox providers (E2B and Vercel)
- Added styling-reference directory with Firecrawl AI-ready website
- Preparing for styling system port from Firecrawl design

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 10:21:28 -04:00

3445 lines
149 KiB
TypeScript

'use client';
import { useState, useEffect, useRef } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { appConfig } from '@/config/app.config';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
// Import icons from centralized module to avoid Turbopack chunk issues
import {
FiFile,
FiChevronRight,
FiChevronDown,
FiGithub,
BsFolderFill,
BsFolder2Open,
SiJavascript,
SiReact,
SiCss3,
SiJson
} from '@/lib/icons';
import { motion, AnimatePresence } from 'framer-motion';
import CodeApplicationProgress, { type CodeApplicationState } from '@/components/CodeApplicationProgress';
interface SandboxData {
sandboxId: string;
url: string;
[key: string]: any;
}
interface ChatMessage {
content: string;
type: 'user' | 'ai' | 'system' | 'file-update' | 'command' | 'error';
timestamp: Date;
metadata?: {
scrapedUrl?: string;
scrapedContent?: any;
generatedCode?: string;
appliedFiles?: string[];
commandType?: 'input' | 'output' | 'error' | 'success';
};
}
export default function AISandboxPage() {
const [sandboxData, setSandboxData] = useState<SandboxData | null>(null);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState({ text: 'Not connected', active: false });
const [responseArea, setResponseArea] = useState<string[]>([]);
const [structureContent, setStructureContent] = useState('No sandbox created yet');
const [promptInput, setPromptInput] = useState('');
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([
{
content: 'Welcome! I can help you generate code with full context of your sandbox files and structure. Just start chatting - I\'ll automatically create a sandbox for you if needed!\n\nTip: If you see package errors like "react-router-dom not found", just type "npm install" or "check packages" to automatically install missing packages.',
type: 'system',
timestamp: new Date()
}
]);
const [aiChatInput, setAiChatInput] = useState('');
const [aiEnabled] = useState(true);
const searchParams = useSearchParams();
const router = useRouter();
const [aiModel, setAiModel] = useState(() => {
const modelParam = searchParams.get('model');
return appConfig.ai.availableModels.includes(modelParam || '') ? modelParam! : appConfig.ai.defaultModel;
});
const [urlOverlayVisible, setUrlOverlayVisible] = useState(false);
const [urlInput, setUrlInput] = useState('');
const [urlStatus, setUrlStatus] = useState<string[]>([]);
const [showHomeScreen, setShowHomeScreen] = useState(true);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(['app', 'src', 'src/components']));
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [homeScreenFading, setHomeScreenFading] = useState(false);
const [homeUrlInput, setHomeUrlInput] = useState('');
const [homeContextInput, setHomeContextInput] = useState('');
const [activeTab, setActiveTab] = useState<'generation' | 'preview'>('preview');
const [showStyleSelector, setShowStyleSelector] = useState(false);
const [selectedStyle, setSelectedStyle] = useState<string | null>(null);
const [showLoadingBackground, setShowLoadingBackground] = useState(false);
const [urlScreenshot, setUrlScreenshot] = useState<string | null>(null);
const [isCapturingScreenshot, setIsCapturingScreenshot] = useState(false);
const [screenshotError, setScreenshotError] = useState<string | null>(null);
const [isPreparingDesign, setIsPreparingDesign] = useState(false);
const [targetUrl, setTargetUrl] = useState<string>('');
const [loadingStage, setLoadingStage] = useState<'gathering' | 'planning' | 'generating' | null>(null);
const [sandboxFiles, setSandboxFiles] = useState<Record<string, string>>({});
const [fileStructure, setFileStructure] = useState<string>('');
const [conversationContext, setConversationContext] = useState<{
scrapedWebsites: Array<{ url: string; content: any; timestamp: Date }>;
generatedComponents: Array<{ name: string; path: string; content: string }>;
appliedCode: Array<{ files: string[]; timestamp: Date }>;
currentProject: string;
lastGeneratedCode?: string;
}>({
scrapedWebsites: [],
generatedComponents: [],
appliedCode: [],
currentProject: '',
lastGeneratedCode: undefined
});
const iframeRef = useRef<HTMLIFrameElement>(null);
const chatMessagesRef = useRef<HTMLDivElement>(null);
const codeDisplayRef = useRef<HTMLDivElement>(null);
const [codeApplicationState, setCodeApplicationState] = useState<CodeApplicationState>({
stage: null
});
const [generationProgress, setGenerationProgress] = useState<{
isGenerating: boolean;
status: string;
components: Array<{ name: string; path: string; completed: boolean }>;
currentComponent: number;
streamedCode: string;
isStreaming: boolean;
isThinking: boolean;
thinkingText?: string;
thinkingDuration?: number;
currentFile?: { path: string; content: string; type: string };
files: Array<{ path: string; content: string; type: string; completed: boolean }>;
lastProcessedPosition: number;
isEdit?: boolean;
}>({
isGenerating: false,
status: '',
components: [],
currentComponent: 0,
streamedCode: '',
isStreaming: false,
isThinking: false,
files: [],
lastProcessedPosition: 0
});
// Clear old conversation data on component mount and create/restore sandbox
useEffect(() => {
let isMounted = true;
let sandboxCreated = false; // Track if sandbox was created in this effect
const initializePage = async () => {
// Prevent double execution in React StrictMode
if (sandboxCreated) return;
// Clear old conversation
try {
await fetch('/api/conversation-state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'clear-old' })
});
console.log('[home] Cleared old conversation data on mount');
} catch (error) {
console.error('[ai-sandbox] Failed to clear old conversation:', error);
if (isMounted) {
addChatMessage('Failed to clear old conversation data.', 'error');
}
}
if (!isMounted) return;
// Check if sandbox ID is in URL
const sandboxIdParam = searchParams.get('sandbox');
setLoading(true);
try {
if (sandboxIdParam) {
console.log('[home] Attempting to restore sandbox:', sandboxIdParam);
// For now, just create a new sandbox - you could enhance this to actually restore
// the specific sandbox if your backend supports it
sandboxCreated = true;
await createSandbox(true);
} else {
console.log('[home] No sandbox in URL, creating new sandbox automatically...');
sandboxCreated = true;
await createSandbox(true);
}
} catch (error) {
console.error('[ai-sandbox] Failed to create or restore sandbox:', error);
if (isMounted) {
addChatMessage('Failed to create or restore sandbox.', 'error');
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
initializePage();
return () => {
isMounted = false;
};
}, []); // Run only on mount
useEffect(() => {
// Handle Escape key for home screen
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && showHomeScreen) {
setHomeScreenFading(true);
setTimeout(() => {
setShowHomeScreen(false);
setHomeScreenFading(false);
}, 500);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [showHomeScreen]);
// Start capturing screenshot if URL is provided on mount (from home screen)
useEffect(() => {
if (!showHomeScreen && homeUrlInput && !urlScreenshot && !isCapturingScreenshot) {
let screenshotUrl = homeUrlInput.trim();
if (!screenshotUrl.match(/^https?:\/\//i)) {
screenshotUrl = 'https://' + screenshotUrl;
}
captureUrlScreenshot(screenshotUrl);
}
}, [showHomeScreen, homeUrlInput]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
// Only check sandbox status on mount and when user navigates to the page
checkSandboxStatus();
// Optional: Check status when window regains focus
const handleFocus = () => {
checkSandboxStatus();
};
window.addEventListener('focus', handleFocus);
return () => window.removeEventListener('focus', handleFocus);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (chatMessagesRef.current) {
chatMessagesRef.current.scrollTop = chatMessagesRef.current.scrollHeight;
}
}, [chatMessages]);
const updateStatus = (text: string, active: boolean) => {
setStatus({ text, active });
};
const log = (message: string, type: 'info' | 'error' | 'command' = 'info') => {
setResponseArea(prev => [...prev, `[${type}] ${message}`]);
};
const addChatMessage = (content: string, type: ChatMessage['type'], metadata?: ChatMessage['metadata']) => {
setChatMessages(prev => {
// Skip duplicate consecutive system messages
if (type === 'system' && prev.length > 0) {
const lastMessage = prev[prev.length - 1];
if (lastMessage.type === 'system' && lastMessage.content === content) {
return prev; // Skip duplicate
}
}
return [...prev, { content, type, timestamp: new Date(), metadata }];
});
};
const checkAndInstallPackages = async () => {
if (!sandboxData) {
addChatMessage('No active sandbox. Create a sandbox first!', 'system');
return;
}
// Vite error checking removed - handled by template setup
addChatMessage('Sandbox is ready. Vite configuration is handled by the template.', 'system');
};
const handleSurfaceError = (errors: any[]) => {
// Function kept for compatibility but Vite errors are now handled by template
// Focus the input
const textarea = document.querySelector('textarea') as HTMLTextAreaElement;
if (textarea) {
textarea.focus();
}
};
const installPackages = async (packages: string[]) => {
if (!sandboxData) {
addChatMessage('No active sandbox. Create a sandbox first!', 'system');
return;
}
try {
const response = await fetch('/api/install-packages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ packages })
});
if (!response.ok) {
throw new Error(`Failed to install packages: ${response.statusText}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (reader) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
switch (data.type) {
case 'command':
// Don't show npm install commands - they're handled by info messages
if (!data.command.includes('npm install')) {
addChatMessage(data.command, 'command', { commandType: 'input' });
}
break;
case 'output':
addChatMessage(data.message, 'command', { commandType: 'output' });
break;
case 'error':
if (data.message && data.message !== 'undefined') {
addChatMessage(data.message, 'command', { commandType: 'error' });
}
break;
case 'warning':
addChatMessage(data.message, 'command', { commandType: 'output' });
break;
case 'success':
addChatMessage(`${data.message}`, 'system');
break;
case 'status':
addChatMessage(data.message, 'system');
break;
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
}
}
}
} catch (error: any) {
addChatMessage(`Failed to install packages: ${error.message}`, 'system');
}
};
const checkSandboxStatus = async () => {
try {
const response = await fetch('/api/sandbox-status');
const data = await response.json();
if (data.active && data.healthy && data.sandboxData) {
setSandboxData(data.sandboxData);
updateStatus('Sandbox active', true);
} else if (data.active && !data.healthy) {
// Sandbox exists but not responding
updateStatus('Sandbox not responding', false);
// Optionally try to create a new one
} else {
setSandboxData(null);
updateStatus('No sandbox', false);
}
} catch (error) {
console.error('Failed to check sandbox status:', error);
setSandboxData(null);
updateStatus('Error', false);
}
};
const sandboxCreationRef = useRef<boolean>(false);
const createSandbox = async (fromHomeScreen = false) => {
// Prevent duplicate sandbox creation
if (sandboxCreationRef.current) {
console.log('[createSandbox] Sandbox creation already in progress, skipping...');
return;
}
sandboxCreationRef.current = true;
console.log('[createSandbox] Starting sandbox creation...');
setLoading(true);
setShowLoadingBackground(true);
updateStatus('Creating sandbox...', false);
setResponseArea([]);
setScreenshotError(null);
try {
const response = await fetch('/api/create-ai-sandbox-v2', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const data = await response.json();
console.log('[createSandbox] Response data:', data);
if (data.success) {
sandboxCreationRef.current = false; // Reset the ref on success
setSandboxData(data);
updateStatus('Sandbox active', true);
log('Sandbox created successfully!');
log(`Sandbox ID: ${data.sandboxId}`);
log(`URL: ${data.url}`);
// Update URL with sandbox ID
const newParams = new URLSearchParams(searchParams.toString());
newParams.set('sandbox', data.sandboxId);
newParams.set('model', aiModel);
router.push(`/?${newParams.toString()}`, { scroll: false });
// Fade out loading background after sandbox loads
setTimeout(() => {
setShowLoadingBackground(false);
}, 3000);
if (data.structure) {
displayStructure(data.structure);
}
// Fetch sandbox files after creation
setTimeout(fetchSandboxFiles, 1000);
// Restart Vite server to ensure it's running
setTimeout(async () => {
try {
console.log('[createSandbox] Ensuring Vite server is running...');
const restartResponse = await fetch('/api/restart-vite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (restartResponse.ok) {
const restartData = await restartResponse.json();
if (restartData.success) {
console.log('[createSandbox] Vite server started successfully');
}
}
} catch (error) {
console.error('[createSandbox] Error starting Vite server:', error);
}
}, 2000);
// Only add welcome message if not coming from home screen
if (!fromHomeScreen) {
addChatMessage(`Sandbox created! ID: ${data.sandboxId}. I now have context of your sandbox and can help you build your app. Just ask me to create components and I'll automatically apply them!
Tip: I automatically detect and install npm packages from your code imports (like react-router-dom, axios, etc.)`, 'system');
}
setTimeout(() => {
if (iframeRef.current) {
iframeRef.current.src = data.url;
}
}, 100);
} else {
throw new Error(data.error || 'Unknown error');
}
} catch (error: any) {
console.error('[createSandbox] Error:', error);
updateStatus('Error', false);
log(`Failed to create sandbox: ${error.message}`, 'error');
addChatMessage(`Failed to create sandbox: ${error.message}`, 'system');
} finally {
setLoading(false);
sandboxCreationRef.current = false; // Reset the ref
}
};
const displayStructure = (structure: any) => {
if (typeof structure === 'object') {
setStructureContent(JSON.stringify(structure, null, 2));
} else {
setStructureContent(structure || 'No structure available');
}
};
const applyGeneratedCode = async (code: string, isEdit: boolean = false) => {
setLoading(true);
log('Applying AI-generated code...');
try {
// Show progress component instead of individual messages
setCodeApplicationState({ stage: 'analyzing' });
// Get pending packages from tool calls
const pendingPackages = ((window as any).pendingPackages || []).filter((pkg: any) => pkg && typeof pkg === 'string');
if (pendingPackages.length > 0) {
console.log('[applyGeneratedCode] Sending packages from tool calls:', pendingPackages);
// Clear pending packages after use
(window as any).pendingPackages = [];
}
// Use streaming endpoint for real-time feedback
const response = await fetch('/api/apply-ai-code-stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
response: code,
isEdit: isEdit,
packages: pendingPackages,
sandboxId: sandboxData?.sandboxId // Pass the sandbox ID to ensure proper connection
})
});
if (!response.ok) {
throw new Error(`Failed to apply code: ${response.statusText}`);
}
// Handle streaming response
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let finalData: any = null;
while (reader) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
switch (data.type) {
case 'start':
// Don't add as chat message, just update state
setCodeApplicationState({ stage: 'analyzing' });
break;
case 'step':
// Update progress state based on step
if (data.message.includes('Installing') && data.packages) {
setCodeApplicationState({
stage: 'installing',
packages: data.packages
});
} else if (data.message.includes('Creating files') || data.message.includes('Applying')) {
setCodeApplicationState({
stage: 'applying',
filesGenerated: results.filesCreated
});
}
break;
case 'package-progress':
// Handle package installation progress
if (data.installedPackages) {
setCodeApplicationState(prev => ({
...prev,
installedPackages: data.installedPackages
}));
}
break;
case 'command':
// Don't show npm install commands - they're handled by info messages
if (data.command && !data.command.includes('npm install')) {
addChatMessage(data.command, 'command', { commandType: 'input' });
}
break;
case 'success':
if (data.installedPackages) {
setCodeApplicationState(prev => ({
...prev,
installedPackages: data.installedPackages
}));
}
break;
case 'file-progress':
// Skip file progress messages, they're noisy
break;
case 'file-complete':
// Could add individual file completion messages if desired
break;
case 'command-progress':
addChatMessage(`${data.action} command: ${data.command}`, 'command', { commandType: 'input' });
break;
case 'command-output':
addChatMessage(data.output, 'command', {
commandType: data.stream === 'stderr' ? 'error' : 'output'
});
break;
case 'command-complete':
if (data.success) {
addChatMessage(`Command completed successfully`, 'system');
} else {
addChatMessage(`Command failed with exit code ${data.exitCode}`, 'system');
}
break;
case 'complete':
finalData = data;
setCodeApplicationState({ stage: 'complete' });
// Clear the state after a delay
setTimeout(() => {
setCodeApplicationState({ stage: null });
}, 3000);
break;
case 'error':
addChatMessage(`Error: ${data.message || data.error || 'Unknown error'}`, 'system');
break;
case 'warning':
addChatMessage(`${data.message}`, 'system');
break;
case 'info':
// Show info messages, especially for package installation
if (data.message) {
addChatMessage(data.message, 'system');
}
break;
}
} catch (e) {
// Ignore parse errors
}
}
}
}
// Process final data
if (finalData && finalData.type === 'complete') {
const data = {
success: true,
results: finalData.results,
explanation: finalData.explanation,
structure: finalData.structure,
message: finalData.message
};
if (data.success) {
const { results } = data;
// Log package installation results without duplicate messages
if (results.packagesInstalled?.length > 0) {
log(`Packages installed: ${results.packagesInstalled.join(', ')}`);
}
if (results.filesCreated?.length > 0) {
log('Files created:');
results.filesCreated.forEach((file: string) => {
log(` ${file}`, 'command');
});
// Verify files were actually created by refreshing the sandbox if needed
if (sandboxData?.sandboxId && results.filesCreated.length > 0) {
// Small delay to ensure files are written
setTimeout(() => {
// Force refresh the iframe to show new files
if (iframeRef.current) {
iframeRef.current.src = iframeRef.current.src;
}
}, 1000);
}
}
if (results.filesUpdated?.length > 0) {
log('Files updated:');
results.filesUpdated.forEach((file: string) => {
log(` ${file}`, 'command');
});
}
// Update conversation context with applied code
setConversationContext(prev => ({
...prev,
appliedCode: [...prev.appliedCode, {
files: [...(results.filesCreated || []), ...(results.filesUpdated || [])],
timestamp: new Date()
}]
}));
if (results.commandsExecuted?.length > 0) {
log('Commands executed:');
results.commandsExecuted.forEach((cmd: string) => {
log(` $ ${cmd}`, 'command');
});
}
if (results.errors?.length > 0) {
results.errors.forEach((err: string) => {
log(err, 'error');
});
}
if (data.structure) {
displayStructure(data.structure);
}
if (data.explanation) {
log(data.explanation);
}
if (data.autoCompleted) {
log('Auto-generating missing components...', 'command');
if (data.autoCompletedComponents) {
setTimeout(() => {
log('Auto-generated missing components:', 'info');
data.autoCompletedComponents.forEach((comp: string) => {
log(` ${comp}`, 'command');
});
}, 1000);
}
} else if (data.warning) {
log(data.warning, 'error');
if (data.missingImports && data.missingImports.length > 0) {
const missingList = data.missingImports.join(', ');
addChatMessage(
`Ask me to "create the missing components: ${missingList}" to fix these import errors.`,
'system'
);
}
}
log('Code applied successfully!');
console.log('[applyGeneratedCode] Response data:', data);
console.log('[applyGeneratedCode] Debug info:', data.debug);
console.log('[applyGeneratedCode] Current sandboxData:', sandboxData);
console.log('[applyGeneratedCode] Current iframe element:', iframeRef.current);
console.log('[applyGeneratedCode] Current iframe src:', iframeRef.current?.src);
if (results.filesCreated?.length > 0) {
setConversationContext(prev => ({
...prev,
appliedCode: [...prev.appliedCode, {
files: results.filesCreated,
timestamp: new Date()
}]
}));
// Update the chat message to show success
// Only show file list if not in edit mode
if (isEdit) {
addChatMessage(`Edit applied successfully!`, 'system');
} else {
// Check if this is part of a generation flow (has recent AI recreation message)
const recentMessages = chatMessages.slice(-5);
const isPartOfGeneration = recentMessages.some(m =>
m.content.includes('AI recreation generated') ||
m.content.includes('Code generated')
);
// Don't show files if part of generation flow to avoid duplication
if (isPartOfGeneration) {
addChatMessage(`Applied ${results.filesCreated.length} files successfully!`, 'system');
} else {
addChatMessage(`Applied ${results.filesCreated.length} files successfully!`, 'system', {
appliedFiles: results.filesCreated
});
}
}
// If there are failed packages, add a message about checking for errors
if (results.packagesFailed?.length > 0) {
addChatMessage(`⚠️ Some packages failed to install. Check the error banner above for details.`, 'system');
}
// Fetch updated file structure
await fetchSandboxFiles();
// Automatically check and install any missing packages
await checkAndInstallPackages();
// Test build to ensure everything compiles correctly
// Skip build test for now - it's causing errors with undefined activeSandbox
// The build test was trying to access global.activeSandbox from the frontend,
// but that's only available in the backend API routes
console.log('[build-test] Skipping build test - would need API endpoint');
// Force iframe refresh after applying code
const refreshDelay = appConfig.codeApplication.defaultRefreshDelay; // Allow Vite to process changes
setTimeout(() => {
if (iframeRef.current && sandboxData?.url) {
console.log('[home] Refreshing iframe after code application...');
// Method 1: Change src with timestamp
const urlWithTimestamp = `${sandboxData.url}?t=${Date.now()}&applied=true`;
iframeRef.current.src = urlWithTimestamp;
// Method 2: Force reload after a short delay
setTimeout(() => {
try {
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.location.reload();
console.log('[home] Force reloaded iframe content');
}
} catch (e) {
console.log('[home] Could not reload iframe (cross-origin):', e);
}
}, 1000);
}
}, refreshDelay);
// Vite error checking removed - handled by template setup
}
// Give Vite HMR a moment to detect changes, then ensure refresh
if (iframeRef.current && sandboxData?.url) {
// Wait for Vite to process the file changes
// If packages were installed, wait longer for Vite to restart
const packagesInstalled = results?.packagesInstalled?.length > 0 || data.results?.packagesInstalled?.length > 0;
const refreshDelay = packagesInstalled ? appConfig.codeApplication.packageInstallRefreshDelay : appConfig.codeApplication.defaultRefreshDelay;
console.log(`[applyGeneratedCode] Packages installed: ${packagesInstalled}, refresh delay: ${refreshDelay}ms`);
setTimeout(async () => {
if (iframeRef.current && sandboxData?.url) {
console.log('[applyGeneratedCode] Starting iframe refresh sequence...');
console.log('[applyGeneratedCode] Current iframe src:', iframeRef.current.src);
console.log('[applyGeneratedCode] Sandbox URL:', sandboxData.url);
// Method 1: Try direct navigation first
try {
const urlWithTimestamp = `${sandboxData.url}?t=${Date.now()}&force=true`;
console.log('[applyGeneratedCode] Attempting direct navigation to:', urlWithTimestamp);
// Remove any existing onload handler
iframeRef.current.onload = null;
// Navigate directly
iframeRef.current.src = urlWithTimestamp;
// Wait a bit and check if it loaded
await new Promise(resolve => setTimeout(resolve, 2000));
// Try to access the iframe content to verify it loaded
try {
const iframeDoc = iframeRef.current.contentDocument || iframeRef.current.contentWindow?.document;
if (iframeDoc && iframeDoc.readyState === 'complete') {
console.log('[applyGeneratedCode] Iframe loaded successfully');
return;
}
} catch (e) {
console.log('[applyGeneratedCode] Cannot access iframe content (CORS), assuming loaded');
return;
}
} catch (e) {
console.error('[applyGeneratedCode] Direct navigation failed:', e);
}
// Method 2: Force complete iframe recreation if direct navigation failed
console.log('[applyGeneratedCode] Falling back to iframe recreation...');
const parent = iframeRef.current.parentElement;
const newIframe = document.createElement('iframe');
// Copy attributes
newIframe.className = iframeRef.current.className;
newIframe.title = iframeRef.current.title;
newIframe.allow = iframeRef.current.allow;
// Copy sandbox attributes
const sandboxValue = iframeRef.current.getAttribute('sandbox');
if (sandboxValue) {
newIframe.setAttribute('sandbox', sandboxValue);
}
// Remove old iframe
iframeRef.current.remove();
// Add new iframe
newIframe.src = `${sandboxData.url}?t=${Date.now()}&recreated=true`;
parent?.appendChild(newIframe);
// Update ref
(iframeRef as any).current = newIframe;
console.log('[applyGeneratedCode] Iframe recreated with new content');
} else {
console.error('[applyGeneratedCode] No iframe or sandbox URL available for refresh');
}
}, refreshDelay); // Dynamic delay based on whether packages were installed
}
} else {
throw new Error(finalData?.error || 'Failed to apply code');
}
} else {
// If no final data was received, still close loading
addChatMessage('Code application may have partially succeeded. Check the preview.', 'system');
}
} catch (error: any) {
log(`Failed to apply code: ${error.message}`, 'error');
} finally {
setLoading(false);
// Clear isEdit flag after applying code
setGenerationProgress(prev => ({
...prev,
isEdit: false
}));
}
};
const fetchSandboxFiles = async () => {
if (!sandboxData) return;
try {
const response = await fetch('/api/get-sandbox-files', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
});
if (response.ok) {
const data = await response.json();
if (data.success) {
setSandboxFiles(data.files || {});
setFileStructure(data.structure || '');
console.log('[fetchSandboxFiles] Updated file list:', Object.keys(data.files || {}).length, 'files');
}
}
} catch (error) {
console.error('[fetchSandboxFiles] Error fetching files:', error);
}
};
const restartViteServer = async () => {
try {
addChatMessage('Restarting Vite dev server...', 'system');
const response = await fetch('/api/restart-vite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (response.ok) {
const data = await response.json();
if (data.success) {
addChatMessage('✓ Vite dev server restarted successfully!', 'system');
// Refresh the iframe after a short delay
setTimeout(() => {
if (iframeRef.current && sandboxData?.url) {
iframeRef.current.src = `${sandboxData.url}?t=${Date.now()}`;
}
}, 2000);
} else {
addChatMessage(`Failed to restart Vite: ${data.error}`, 'error');
}
} else {
addChatMessage('Failed to restart Vite server', 'error');
}
} catch (error) {
console.error('[restartViteServer] Error:', error);
addChatMessage(`Error restarting Vite: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error');
}
};
const applyCode = async () => {
const code = promptInput.trim();
if (!code) {
log('Please enter some code first', 'error');
addChatMessage('No code to apply. Please generate code first.', 'system');
return;
}
// Prevent double clicks
if (loading) {
console.log('[applyCode] Already loading, skipping...');
return;
}
// Determine if this is an edit based on whether we have applied code before
const isEdit = conversationContext.appliedCode.length > 0;
await applyGeneratedCode(code, isEdit);
};
const renderMainContent = () => {
if (activeTab === 'generation' && (generationProgress.isGenerating || generationProgress.files.length > 0)) {
return (
/* Generation Tab Content */
<div className="absolute inset-0 flex overflow-hidden">
{/* File Explorer - Hide during edits */}
{!generationProgress.isEdit && (
<div className="w-[250px] border-r border-gray-200 bg-white flex flex-col flex-shrink-0">
<div className="p-3 bg-gray-100 text-gray-900 flex items-center justify-between">
<div className="flex items-center gap-2">
<BsFolderFill className="w-4 h-4" />
<span className="text-sm font-medium">Explorer</span>
</div>
</div>
{/* File Tree */}
<div className="flex-1 overflow-y-auto p-2 scrollbar-hide">
<div className="text-sm">
{/* Root app folder */}
<div
className="flex items-center gap-1 py-1 px-2 hover:bg-gray-100 rounded cursor-pointer text-gray-700"
onClick={() => toggleFolder('app')}
>
{expandedFolders.has('app') ? (
<FiChevronDown className="w-4 h-4 text-gray-600" />
) : (
<FiChevronRight className="w-4 h-4 text-gray-600" />
)}
{expandedFolders.has('app') ? (
<BsFolder2Open className="w-4 h-4 text-blue-500" />
) : (
<BsFolderFill className="w-4 h-4 text-blue-500" />
)}
<span className="font-medium text-gray-800">app</span>
</div>
{expandedFolders.has('app') && (
<div className="ml-4">
{/* Group files by directory */}
{(() => {
const fileTree: { [key: string]: Array<{ name: string; edited?: boolean }> } = {};
// Create a map of edited files
const editedFiles = new Set(
generationProgress.files
.filter(f => f.edited)
.map(f => f.path)
);
// Process all files from generation progress
generationProgress.files.forEach(file => {
const parts = file.path.split('/');
const dir = parts.length > 1 ? parts.slice(0, -1).join('/') : '';
const fileName = parts[parts.length - 1];
if (!fileTree[dir]) fileTree[dir] = [];
fileTree[dir].push({
name: fileName,
edited: file.edited || false
});
});
return Object.entries(fileTree).map(([dir, files]) => (
<div key={dir} className="mb-1">
{dir && (
<div
className="flex items-center gap-1 py-1 px-2 hover:bg-gray-100 rounded cursor-pointer text-gray-700"
onClick={() => toggleFolder(dir)}
>
{expandedFolders.has(dir) ? (
<FiChevronDown className="w-4 h-4 text-gray-600" />
) : (
<FiChevronRight className="w-4 h-4 text-gray-600" />
)}
{expandedFolders.has(dir) ? (
<BsFolder2Open className="w-4 h-4 text-yellow-600" />
) : (
<BsFolderFill className="w-4 h-4 text-yellow-600" />
)}
<span className="text-gray-700">{dir.split('/').pop()}</span>
</div>
)}
{(!dir || expandedFolders.has(dir)) && (
<div className={dir ? 'ml-6' : ''}>
{files.sort((a, b) => a.name.localeCompare(b.name)).map(fileInfo => {
const fullPath = dir ? `${dir}/${fileInfo.name}` : fileInfo.name;
const isSelected = selectedFile === fullPath;
return (
<div
key={fullPath}
className={`flex items-center gap-2 py-1 px-2 rounded cursor-pointer transition-all ${
isSelected
? 'bg-blue-500 text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
onClick={() => handleFileClick(fullPath)}
>
{getFileIcon(fileInfo.name)}
<span className={`text-xs flex items-center gap-1 ${isSelected ? 'font-medium' : ''}`}>
{fileInfo.name}
{fileInfo.edited && (
<span className={`text-[10px] px-1 rounded ${
isSelected ? 'bg-blue-400' : 'bg-orange-500 text-white'
}`}></span>
)}
</span>
</div>
);
})}
</div>
)}
</div>
));
})()}
</div>
)}
</div>
</div>
</div>
)}
{/* Code Content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Thinking Mode Display - Only show during active generation */}
{generationProgress.isGenerating && (generationProgress.isThinking || generationProgress.thinkingText) && (
<div className="px-6 pb-6">
<div className="flex items-center gap-2 mb-2">
<div className="text-purple-600 font-medium flex items-center gap-2">
{generationProgress.isThinking ? (
<>
<div className="w-2 h-2 bg-purple-600 rounded-full animate-pulse" />
AI is thinking...
</>
) : (
<>
<span className="text-purple-600"></span>
Thought for {generationProgress.thinkingDuration || 0} seconds
</>
)}
</div>
</div>
{generationProgress.thinkingText && (
<div className="bg-purple-950 border border-purple-700 rounded-lg p-4 max-h-48 overflow-y-auto scrollbar-hide">
<pre className="text-xs font-mono text-purple-300 whitespace-pre-wrap">
{generationProgress.thinkingText}
</pre>
</div>
)}
</div>
)}
{/* Live Code Display */}
<div className="flex-1 rounded-lg p-6 flex flex-col min-h-0 overflow-hidden">
<div className="flex-1 overflow-y-auto min-h-0 scrollbar-hide" ref={codeDisplayRef}>
{/* Show selected file if one is selected */}
{selectedFile ? (
<div className="animate-in fade-in slide-in-from-top-2 duration-300">
<div className="bg-black border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<div className="px-4 py-2 bg-[#36322F] text-white flex items-center justify-between">
<div className="flex items-center gap-2">
{getFileIcon(selectedFile)}
<span className="font-mono text-sm">{selectedFile}</span>
</div>
<button
onClick={() => setSelectedFile(null)}
className="hover:bg-black/20 p-1 rounded transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="bg-gray-900 border border-gray-700 rounded">
<SyntaxHighlighter
language={(() => {
const ext = selectedFile.split('.').pop()?.toLowerCase();
if (ext === 'css') return 'css';
if (ext === 'json') return 'json';
if (ext === 'html') return 'html';
return 'jsx';
})()}
style={vscDarkPlus}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.875rem',
background: 'transparent',
}}
showLineNumbers={true}
>
{(() => {
// Find the file content from generated files
const file = generationProgress.files.find(f => f.path === selectedFile);
return file?.content || '// File content will appear here';
})()}
</SyntaxHighlighter>
</div>
</div>
</div>
) : /* If no files parsed yet, show loading or raw stream */
generationProgress.files.length === 0 && !generationProgress.currentFile ? (
generationProgress.isThinking ? (
// Beautiful loading state while thinking
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="mb-8 relative">
<div className="w-24 h-24 mx-auto">
<div className="absolute inset-0 border-4 border-gray-800 rounded-full"></div>
<div className="absolute inset-0 border-4 border-green-500 rounded-full animate-spin border-t-transparent"></div>
</div>
</div>
<h3 className="text-xl font-medium text-white mb-2">AI is analyzing your request</h3>
<p className="text-gray-400 text-sm">{generationProgress.status || 'Preparing to generate code...'}</p>
</div>
</div>
) : (
<div className="bg-black border border-gray-200 rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-gray-100 text-gray-900 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
<span className="font-mono text-sm">Streaming code...</span>
</div>
</div>
<div className="p-4 bg-gray-900 rounded">
<SyntaxHighlighter
language="jsx"
style={vscDarkPlus}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.875rem',
background: 'transparent',
}}
showLineNumbers={true}
>
{generationProgress.streamedCode || 'Starting code generation...'}
</SyntaxHighlighter>
<span className="inline-block w-2 h-4 bg-orange-400 ml-1 animate-pulse" />
</div>
</div>
)
) : (
<div className="space-y-4">
{/* Show current file being generated */}
{generationProgress.currentFile && (
<div className="bg-black border-2 border-gray-400 rounded-lg overflow-hidden shadow-sm">
<div className="px-4 py-2 bg-[#36322F] text-white flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" />
<span className="font-mono text-sm">{generationProgress.currentFile.path}</span>
<span className={`px-2 py-0.5 text-xs rounded ${
generationProgress.currentFile.type === 'css' ? 'bg-blue-600 text-white' :
generationProgress.currentFile.type === 'javascript' ? 'bg-yellow-600 text-white' :
generationProgress.currentFile.type === 'json' ? 'bg-green-600 text-white' :
'bg-gray-200 text-gray-700'
}`}>
{generationProgress.currentFile.type === 'javascript' ? 'JSX' : generationProgress.currentFile.type.toUpperCase()}
</span>
</div>
</div>
<div className="bg-gray-900 border border-gray-700 rounded">
<SyntaxHighlighter
language={
generationProgress.currentFile.type === 'css' ? 'css' :
generationProgress.currentFile.type === 'json' ? 'json' :
generationProgress.currentFile.type === 'html' ? 'html' :
'jsx'
}
style={vscDarkPlus}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.75rem',
background: 'transparent',
}}
showLineNumbers={true}
>
{generationProgress.currentFile.content}
</SyntaxHighlighter>
<span className="inline-block w-2 h-3 bg-orange-400 ml-4 mb-4 animate-pulse" />
</div>
</div>
)}
{/* Show completed files */}
{generationProgress.files.map((file, idx) => (
<div key={idx} className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-[#36322F] text-white flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span className="font-mono text-sm">{file.path}</span>
</div>
<span className={`px-2 py-0.5 text-xs rounded ${
file.type === 'css' ? 'bg-blue-600 text-white' :
file.type === 'javascript' ? 'bg-yellow-600 text-white' :
file.type === 'json' ? 'bg-green-600 text-white' :
'bg-gray-200 text-gray-700'
}`}>
{file.type === 'javascript' ? 'JSX' : file.type.toUpperCase()}
</span>
</div>
<div className="bg-gray-900 border border-gray-700 max-h-48 overflow-y-auto scrollbar-hide">
<SyntaxHighlighter
language={
file.type === 'css' ? 'css' :
file.type === 'json' ? 'json' :
file.type === 'html' ? 'html' :
'jsx'
}
style={vscDarkPlus}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.75rem',
background: 'transparent',
}}
showLineNumbers={true}
wrapLongLines={true}
>
{file.content}
</SyntaxHighlighter>
</div>
</div>
))}
{/* Show remaining raw stream if there's content after the last file */}
{!generationProgress.currentFile && generationProgress.streamedCode.length > 0 && (
<div className="bg-black border border-gray-200 rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-[#36322F] text-white flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
<span className="font-mono text-sm">Processing...</span>
</div>
</div>
<div className="bg-gray-900 border border-gray-700 rounded">
<SyntaxHighlighter
language="jsx"
style={vscDarkPlus}
customStyle={{
margin: 0,
padding: '1rem',
fontSize: '0.75rem',
background: 'transparent',
}}
showLineNumbers={false}
>
{(() => {
// Show only the tail of the stream after the last file
const lastFileEnd = generationProgress.files.length > 0
? generationProgress.streamedCode.lastIndexOf('</file>') + 7
: 0;
let remainingContent = generationProgress.streamedCode.slice(lastFileEnd).trim();
// Remove explanation tags and content
remainingContent = remainingContent.replace(/<explanation>[\s\S]*?<\/explanation>/g, '').trim();
// If only whitespace or nothing left, show waiting message
return remainingContent || 'Waiting for next file...';
})()}
</SyntaxHighlighter>
</div>
</div>
)}
</div>
)}
</div>
</div>
{/* Progress indicator */}
{generationProgress.components.length > 0 && (
<div className="mx-6 mb-6">
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-orange-500 to-orange-400 transition-all duration-300"
style={{
width: `${(generationProgress.currentComponent / Math.max(generationProgress.components.length, 1)) * 100}%`
}}
/>
</div>
</div>
)}
</div>
</div>
);
} else if (activeTab === 'preview') {
// Show screenshot when we have one and (loading OR generating OR no sandbox yet)
if (urlScreenshot && (loading || generationProgress.isGenerating || !sandboxData?.url || isPreparingDesign)) {
return (
<div className="relative w-full h-full bg-gray-100">
<img
src={urlScreenshot}
alt="Website preview"
className="w-full h-full object-contain"
/>
{(generationProgress.isGenerating || isPreparingDesign) && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<div className="text-center bg-black/70 rounded-lg p-6 backdrop-blur-sm">
<div className="w-12 h-12 border-3 border-gray-300 border-t-white rounded-full animate-spin mx-auto mb-3" />
<p className="text-white text-sm font-medium">
{generationProgress.isGenerating ? 'Generating code...' : `Preparing your design for ${targetUrl}...`}
</p>
</div>
</div>
)}
</div>
);
}
// Check loading stage FIRST to prevent showing old sandbox
// Don't show loading overlay for edits
if (loadingStage || (generationProgress.isGenerating && !generationProgress.isEdit)) {
return (
<div className="relative w-full h-full bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="mb-8">
<div className="w-16 h-16 border-4 border-orange-200 border-t-orange-500 rounded-full animate-spin mx-auto"></div>
</div>
<h3 className="text-xl font-semibold text-gray-800 mb-2">
{loadingStage === 'gathering' && 'Gathering website information...'}
{loadingStage === 'planning' && 'Planning your design...'}
{(loadingStage === 'generating' || generationProgress.isGenerating) && 'Generating your application...'}
</h3>
<p className="text-gray-600 text-sm">
{loadingStage === 'gathering' && 'Analyzing the website structure and content'}
{loadingStage === 'planning' && 'Creating the optimal React component architecture'}
{(loadingStage === 'generating' || generationProgress.isGenerating) && 'Writing clean, modern code for your app'}
</p>
</div>
</div>
);
}
// Show sandbox iframe only when not in any loading state
if (sandboxData?.url && !loading) {
return (
<div className="relative w-full h-full">
<iframe
ref={iframeRef}
src={sandboxData.url}
className="w-full h-full border-none"
title="Open Lovable Sandbox"
allow="clipboard-write"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
/>
{/* Refresh button */}
<button
onClick={() => {
if (iframeRef.current && sandboxData?.url) {
console.log('[Manual Refresh] Forcing iframe reload...');
const newSrc = `${sandboxData.url}?t=${Date.now()}&manual=true`;
iframeRef.current.src = newSrc;
}
}}
className="absolute bottom-4 right-4 bg-white/90 hover:bg-white text-gray-700 p-2 rounded-lg shadow-lg transition-all duration-200 hover:scale-105"
title="Refresh sandbox"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
);
}
// Show loading animation when capturing screenshot
if (isCapturingScreenshot) {
return (
<div className="flex items-center justify-center h-full bg-gray-900">
<div className="text-center">
<div className="w-12 h-12 border-3 border-gray-600 border-t-white rounded-full animate-spin mx-auto mb-4" />
<h3 className="text-lg font-medium text-white">Gathering website information</h3>
</div>
</div>
);
}
// Default state when no sandbox and no screenshot
return (
<div className="flex items-center justify-center h-full bg-gray-50 text-gray-600 text-lg">
{screenshotError ? (
<div className="text-center">
<p className="mb-2">Failed to capture screenshot</p>
<p className="text-sm text-gray-500">{screenshotError}</p>
</div>
) : sandboxData ? (
<div className="text-gray-500">
<div className="w-8 h-8 border-2 border-gray-300 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
<p className="text-sm">Loading preview...</p>
</div>
) : (
<div className="text-gray-500 text-center">
<p className="text-sm">Start chatting to create your first app</p>
</div>
)}
</div>
);
}
return null;
};
const sendChatMessage = async () => {
const message = aiChatInput.trim();
if (!message) return;
if (!aiEnabled) {
addChatMessage('AI is disabled. Please enable it first.', 'system');
return;
}
addChatMessage(message, 'user');
setAiChatInput('');
// Check for special commands
const lowerMessage = message.toLowerCase().trim();
if (lowerMessage === 'check packages' || lowerMessage === 'install packages' || lowerMessage === 'npm install') {
if (!sandboxData) {
addChatMessage('No active sandbox. Create a sandbox first!', 'system');
return;
}
await checkAndInstallPackages();
return;
}
// Start sandbox creation in parallel if needed
let sandboxPromise: Promise<void> | null = null;
let sandboxCreating = false;
if (!sandboxData) {
sandboxCreating = true;
addChatMessage('Creating sandbox while I plan your app...', 'system');
sandboxPromise = createSandbox(true).catch((error: any) => {
addChatMessage(`Failed to create sandbox: ${error.message}`, 'system');
throw error;
});
}
// Determine if this is an edit
const isEdit = conversationContext.appliedCode.length > 0;
try {
// Generation tab is already active from scraping phase
setGenerationProgress(prev => ({
...prev, // Preserve all existing state
isGenerating: true,
status: 'Starting AI generation...',
components: [],
currentComponent: 0,
streamedCode: '',
isStreaming: false,
isThinking: true,
thinkingText: 'Analyzing your request...',
thinkingDuration: undefined,
currentFile: undefined,
lastProcessedPosition: 0,
// Add isEdit flag to generation progress
isEdit: isEdit,
// Keep existing files for edits - we'll mark edited ones differently
files: prev.files
}));
// Backend now manages file state - no need to fetch from frontend
console.log('[chat] Using backend file cache for context');
const fullContext = {
sandboxId: sandboxData?.sandboxId || (sandboxCreating ? 'pending' : null),
structure: structureContent,
recentMessages: chatMessages.slice(-20),
conversationContext: conversationContext,
currentCode: promptInput,
sandboxUrl: sandboxData?.url,
sandboxCreating: sandboxCreating
};
// Debug what we're sending
console.log('[chat] Sending context to AI:');
console.log('[chat] - sandboxId:', fullContext.sandboxId);
console.log('[chat] - isEdit:', conversationContext.appliedCode.length > 0);
const response = await fetch('/api/generate-ai-code-stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: message,
model: aiModel,
context: fullContext,
isEdit: conversationContext.appliedCode.length > 0
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let generatedCode = '';
let explanation = '';
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'status') {
setGenerationProgress(prev => ({ ...prev, status: data.message }));
} else if (data.type === 'thinking') {
setGenerationProgress(prev => ({
...prev,
isThinking: true,
thinkingText: (prev.thinkingText || '') + data.text
}));
} else if (data.type === 'thinking_complete') {
setGenerationProgress(prev => ({
...prev,
isThinking: false,
thinkingDuration: data.duration
}));
} else if (data.type === 'conversation') {
// Add conversational text to chat only if it's not code
let text = data.text || '';
// Remove package tags from the text
text = text.replace(/<package>[^<]*<\/package>/g, '');
text = text.replace(/<packages>[^<]*<\/packages>/g, '');
// Filter out any XML tags and file content that slipped through
if (!text.includes('<file') && !text.includes('import React') &&
!text.includes('export default') && !text.includes('className=') &&
text.trim().length > 0) {
addChatMessage(text.trim(), 'ai');
}
} else if (data.type === 'stream' && data.raw) {
setGenerationProgress(prev => {
const newStreamedCode = prev.streamedCode + data.text;
// Tab is already switched after scraping
const updatedState = {
...prev,
streamedCode: newStreamedCode,
isStreaming: true,
isThinking: false,
status: 'Generating code...'
};
// Process complete files from the accumulated stream
const fileRegex = /<file path="([^"]+)">([^]*?)<\/file>/g;
let match;
const processedFiles = new Set(prev.files.map(f => f.path));
while ((match = fileRegex.exec(newStreamedCode)) !== null) {
const filePath = match[1];
const fileContent = match[2];
// Only add if we haven't processed this file yet
if (!processedFiles.has(filePath)) {
const fileExt = filePath.split('.').pop() || '';
const fileType = fileExt === 'jsx' || fileExt === 'js' ? 'javascript' :
fileExt === 'css' ? 'css' :
fileExt === 'json' ? 'json' :
fileExt === 'html' ? 'html' : 'text';
// Check if file already exists
const existingFileIndex = updatedState.files.findIndex(f => f.path === filePath);
if (existingFileIndex >= 0) {
// Update existing file and mark as edited
updatedState.files = [
...updatedState.files.slice(0, existingFileIndex),
{
...updatedState.files[existingFileIndex],
content: fileContent.trim(),
type: fileType,
completed: true,
edited: true
},
...updatedState.files.slice(existingFileIndex + 1)
];
} else {
// Add new file
updatedState.files = [...updatedState.files, {
path: filePath,
content: fileContent.trim(),
type: fileType,
completed: true,
edited: false
}];
}
// Only show file status if not in edit mode
if (!prev.isEdit) {
updatedState.status = `Completed ${filePath}`;
}
processedFiles.add(filePath);
}
}
// Check for current file being generated (incomplete file at the end)
const lastFileMatch = newStreamedCode.match(/<file path="([^"]+)">([^]*?)$/);
if (lastFileMatch && !lastFileMatch[0].includes('</file>')) {
const filePath = lastFileMatch[1];
const partialContent = lastFileMatch[2];
if (!processedFiles.has(filePath)) {
const fileExt = filePath.split('.').pop() || '';
const fileType = fileExt === 'jsx' || fileExt === 'js' ? 'javascript' :
fileExt === 'css' ? 'css' :
fileExt === 'json' ? 'json' :
fileExt === 'html' ? 'html' : 'text';
updatedState.currentFile = {
path: filePath,
content: partialContent,
type: fileType
};
// Only show file status if not in edit mode
if (!prev.isEdit) {
updatedState.status = `Generating ${filePath}`;
}
}
} else {
updatedState.currentFile = undefined;
}
return updatedState;
});
} else if (data.type === 'app') {
setGenerationProgress(prev => ({
...prev,
status: 'Generated App.jsx structure'
}));
} else if (data.type === 'component') {
setGenerationProgress(prev => ({
...prev,
status: `Generated ${data.name}`,
components: [...prev.components, {
name: data.name,
path: data.path,
completed: true
}],
currentComponent: data.index
}));
} else if (data.type === 'package') {
// Handle package installation from tool calls
setGenerationProgress(prev => ({
...prev,
status: data.message || `Installing ${data.name}`
}));
} else if (data.type === 'complete') {
generatedCode = data.generatedCode;
explanation = data.explanation;
// Save the last generated code
setConversationContext(prev => ({
...prev,
lastGeneratedCode: generatedCode
}));
// Clear thinking state when generation completes
setGenerationProgress(prev => ({
...prev,
isThinking: false,
thinkingText: undefined,
thinkingDuration: undefined
}));
// Store packages to install from tool calls
if (data.packagesToInstall && data.packagesToInstall.length > 0) {
console.log('[generate-code] Packages to install from tools:', data.packagesToInstall);
// Store packages globally for later installation
(window as any).pendingPackages = data.packagesToInstall;
}
// Parse all files from the completed code if not already done
const fileRegex = /<file path="([^"]+)">([^]*?)<\/file>/g;
const parsedFiles: Array<{path: string; content: string; type: string; completed: boolean}> = [];
let fileMatch;
while ((fileMatch = fileRegex.exec(data.generatedCode)) !== null) {
const filePath = fileMatch[1];
const fileContent = fileMatch[2];
const fileExt = filePath.split('.').pop() || '';
const fileType = fileExt === 'jsx' || fileExt === 'js' ? 'javascript' :
fileExt === 'css' ? 'css' :
fileExt === 'json' ? 'json' :
fileExt === 'html' ? 'html' : 'text';
parsedFiles.push({
path: filePath,
content: fileContent.trim(),
type: fileType,
completed: true
});
}
setGenerationProgress(prev => ({
...prev,
status: `Generated ${parsedFiles.length > 0 ? parsedFiles.length : prev.files.length} file${(parsedFiles.length > 0 ? parsedFiles.length : prev.files.length) !== 1 ? 's' : ''}!`,
isGenerating: false,
isStreaming: false,
isEdit: prev.isEdit,
// Keep the files that were already parsed during streaming
files: prev.files.length > 0 ? prev.files : parsedFiles
}));
} else if (data.type === 'error') {
throw new Error(data.error);
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
}
}
}
}
if (generatedCode) {
// Parse files from generated code for metadata
const fileRegex = /<file path="([^"]+)">([^]*?)<\/file>/g;
const generatedFiles = [];
let match;
while ((match = fileRegex.exec(generatedCode)) !== null) {
generatedFiles.push(match[1]);
}
// Show appropriate message based on edit mode
if (isEdit && generatedFiles.length > 0) {
// For edits, show which file(s) were edited
const editedFileNames = generatedFiles.map(f => f.split('/').pop()).join(', ');
addChatMessage(
explanation || `Updated ${editedFileNames}`,
'ai',
{
appliedFiles: [generatedFiles[0]] // Only show the first edited file
}
);
} else {
// For new generation, show all files
addChatMessage(explanation || 'Code generated!', 'ai', {
appliedFiles: generatedFiles
});
}
setPromptInput(generatedCode);
// Don't show the Generated Code panel by default
// setLeftPanelVisible(true);
// Wait for sandbox creation if it's still in progress
if (sandboxPromise) {
addChatMessage('Waiting for sandbox to be ready...', 'system');
try {
await sandboxPromise;
// Remove the waiting message
setChatMessages(prev => prev.filter(msg => msg.content !== 'Waiting for sandbox to be ready...'));
} catch {
addChatMessage('Sandbox creation failed. Cannot apply code.', 'system');
return;
}
}
if (sandboxData && generatedCode) {
// Use isEdit flag that was determined at the start
await applyGeneratedCode(generatedCode, isEdit);
}
}
// Show completion status briefly then switch to preview
setGenerationProgress(prev => ({
...prev,
isGenerating: false,
isStreaming: false,
status: 'Generation complete!',
isEdit: prev.isEdit,
// Clear thinking state on completion
isThinking: false,
thinkingText: undefined,
thinkingDuration: undefined
}));
setTimeout(() => {
// Switch to preview but keep files for display
setActiveTab('preview');
}, 1000); // Reduced from 3000ms to 1000ms
} catch (error: any) {
setChatMessages(prev => prev.filter(msg => msg.content !== 'Thinking...'));
addChatMessage(`Error: ${error.message}`, 'system');
// Reset generation progress and switch back to preview on error
setGenerationProgress({
isGenerating: false,
status: '',
components: [],
currentComponent: 0,
streamedCode: '',
isStreaming: false,
isThinking: false,
thinkingText: undefined,
thinkingDuration: undefined,
files: [],
currentFile: undefined,
lastProcessedPosition: 0
});
setActiveTab('preview');
}
};
const downloadZip = async () => {
if (!sandboxData) {
addChatMessage('No active sandbox to download. Create a sandbox first!', 'system');
return;
}
setLoading(true);
log('Creating zip file...');
addChatMessage('Creating ZIP file of your Vite app...', 'system');
try {
const response = await fetch('/api/create-zip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.success) {
log('Zip file created!');
addChatMessage('ZIP file created! Download starting...', 'system');
const link = document.createElement('a');
link.href = data.dataUrl;
link.download = data.fileName || 'e2b-project.zip';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
addChatMessage(
'Your Vite app has been downloaded! To run it locally:\n' +
'1. Unzip the file\n' +
'2. Run: npm install\n' +
'3. Run: npm run dev\n' +
'4. Open http://localhost:5173',
'system'
);
} else {
throw new Error(data.error);
}
} catch (error: any) {
log(`Failed to create zip: ${error.message}`, 'error');
addChatMessage(`Failed to create ZIP: ${error.message}`, 'system');
} finally {
setLoading(false);
}
};
const reapplyLastGeneration = async () => {
if (!conversationContext.lastGeneratedCode) {
addChatMessage('No previous generation to re-apply', 'system');
return;
}
if (!sandboxData) {
addChatMessage('Please create a sandbox first', 'system');
return;
}
addChatMessage('Re-applying last generation...', 'system');
const isEdit = conversationContext.appliedCode.length > 0;
await applyGeneratedCode(conversationContext.lastGeneratedCode, isEdit);
};
// Auto-scroll code display to bottom when streaming
useEffect(() => {
if (codeDisplayRef.current && generationProgress.isStreaming) {
codeDisplayRef.current.scrollTop = codeDisplayRef.current.scrollHeight;
}
}, [generationProgress.streamedCode, generationProgress.isStreaming]);
const toggleFolder = (folderPath: string) => {
const newExpanded = new Set(expandedFolders);
if (newExpanded.has(folderPath)) {
newExpanded.delete(folderPath);
} else {
newExpanded.add(folderPath);
}
setExpandedFolders(newExpanded);
};
const handleFileClick = async (filePath: string) => {
setSelectedFile(filePath);
// TODO: Add file content fetching logic here
};
const getFileIcon = (fileName: string) => {
const ext = fileName.split('.').pop()?.toLowerCase();
if (ext === 'jsx' || ext === 'js') {
return <SiJavascript className="w-4 h-4 text-yellow-500" />;
} else if (ext === 'tsx' || ext === 'ts') {
return <SiReact className="w-4 h-4 text-blue-500" />;
} else if (ext === 'css') {
return <SiCss3 className="w-4 h-4 text-blue-500" />;
} else if (ext === 'json') {
return <SiJson className="w-4 h-4 text-gray-600" />;
} else {
return <FiFile className="w-4 h-4 text-gray-600" />;
}
};
const clearChatHistory = () => {
setChatMessages([{
content: 'Chat history cleared. How can I help you?',
type: 'system',
timestamp: new Date()
}]);
};
const cloneWebsite = async () => {
let url = urlInput.trim();
if (!url) {
setUrlStatus(prev => [...prev, 'Please enter a URL']);
return;
}
if (!url.match(/^https?:\/\//i)) {
url = 'https://' + url;
}
setUrlStatus([`Using: ${url}`, 'Starting to scrape...']);
setUrlOverlayVisible(false);
// Remove protocol for cleaner display
const cleanUrl = url.replace(/^https?:\/\//i, '');
addChatMessage(`Starting to clone ${cleanUrl}...`, 'system');
// Capture screenshot immediately and switch to preview tab
captureUrlScreenshot(url);
try {
addChatMessage('Scraping website content...', 'system');
const scrapeResponse = await fetch('/api/scrape-url-enhanced', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
if (!scrapeResponse.ok) {
throw new Error(`Scraping failed: ${scrapeResponse.status}`);
}
const scrapeData = await scrapeResponse.json();
if (!scrapeData.success) {
throw new Error(scrapeData.error || 'Failed to scrape website');
}
addChatMessage(`Scraped ${scrapeData.content.length} characters from ${url}`, 'system');
// Clear preparing design state and switch to generation tab
setIsPreparingDesign(false);
setActiveTab('generation');
setConversationContext(prev => ({
...prev,
scrapedWebsites: [...prev.scrapedWebsites, {
url,
content: scrapeData,
timestamp: new Date()
}],
currentProject: `Clone of ${url}`
}));
// Start sandbox creation in parallel with code generation
let sandboxPromise: Promise<void> | null = null;
if (!sandboxData) {
addChatMessage('Creating sandbox while generating your React app...', 'system');
sandboxPromise = createSandbox(true);
}
addChatMessage('Analyzing and generating React recreation...', 'system');
const recreatePrompt = `I scraped this website and want you to recreate it as a modern React application.
URL: ${url}
SCRAPED CONTENT:
${scrapeData.content}
${homeContextInput ? `ADDITIONAL CONTEXT/REQUIREMENTS FROM USER:
${homeContextInput}
Please incorporate these requirements into the design and implementation.` : ''}
REQUIREMENTS:
1. Create a COMPLETE React application with App.jsx as the main component
2. App.jsx MUST import and render all other components
3. Recreate the main sections and layout from the scraped content
4. ${homeContextInput ? `Apply the user's context/theme: "${homeContextInput}"` : `Use a modern dark theme with excellent contrast:
- Background: #0a0a0a
- Text: #ffffff
- Links: #60a5fa
- Accent: #3b82f6`}
5. Make it fully responsive
6. Include hover effects and smooth transitions
7. Create separate components for major sections (Header, Hero, Features, etc.)
8. Use semantic HTML5 elements
IMPORTANT CONSTRAINTS:
- DO NOT use React Router or any routing libraries
- Use regular <a> tags with href="#section" for navigation, NOT Link or NavLink components
- This is a single-page application, no routing needed
- ALWAYS create src/App.jsx that imports ALL components
- Each component should be in src/components/
- Use Tailwind CSS for ALL styling (no custom CSS files)
- Make sure the app actually renders visible content
- Create ALL components that you reference in imports
IMAGE HANDLING RULES:
- When the scraped content includes images, USE THE ORIGINAL IMAGE URLS whenever appropriate
- Keep existing images from the scraped site (logos, product images, hero images, icons, etc.)
- Use the actual image URLs provided in the scraped content, not placeholders
- Only use placeholder images or generic services when no real images are available
- For company logos and brand images, ALWAYS use the original URLs to maintain brand identity
- If scraped data contains image URLs, include them in your img tags
- Example: If you see "https://example.com/logo.png" in the scraped content, use that exact URL
Focus on the key sections and content, making it clean and modern while preserving visual assets.`;
setGenerationProgress(prev => ({
isGenerating: true,
status: 'Initializing AI...',
components: [],
currentComponent: 0,
streamedCode: '',
isStreaming: true,
isThinking: false,
thinkingText: undefined,
thinkingDuration: undefined,
// Keep previous files until new ones are generated
files: prev.files || [],
currentFile: undefined,
lastProcessedPosition: 0
}));
// Switch to generation tab when starting
setActiveTab('generation');
const aiResponse = await fetch('/api/generate-ai-code-stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: recreatePrompt,
model: aiModel,
context: {
sandboxId: sandboxData?.id,
structure: structureContent,
conversationContext: conversationContext
}
})
});
if (!aiResponse.ok) {
throw new Error(`AI generation failed: ${aiResponse.status}`);
}
const reader = aiResponse.body?.getReader();
const decoder = new TextDecoder();
let generatedCode = '';
let explanation = '';
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'status') {
setGenerationProgress(prev => ({ ...prev, status: data.message }));
} else if (data.type === 'thinking') {
setGenerationProgress(prev => ({
...prev,
isThinking: true,
thinkingText: (prev.thinkingText || '') + data.text
}));
} else if (data.type === 'thinking_complete') {
setGenerationProgress(prev => ({
...prev,
isThinking: false,
thinkingDuration: data.duration
}));
} else if (data.type === 'conversation') {
// Add conversational text to chat only if it's not code
let text = data.text || '';
// Remove package tags from the text
text = text.replace(/<package>[^<]*<\/package>/g, '');
text = text.replace(/<packages>[^<]*<\/packages>/g, '');
// Filter out any XML tags and file content that slipped through
if (!text.includes('<file') && !text.includes('import React') &&
!text.includes('export default') && !text.includes('className=') &&
text.trim().length > 0) {
addChatMessage(text.trim(), 'ai');
}
} else if (data.type === 'stream' && data.raw) {
setGenerationProgress(prev => ({
...prev,
streamedCode: prev.streamedCode + data.text,
lastProcessedPosition: prev.lastProcessedPosition || 0
}));
} else if (data.type === 'component') {
setGenerationProgress(prev => ({
...prev,
status: `Generated ${data.name}`,
components: [...prev.components, {
name: data.name,
path: data.path,
completed: true
}],
currentComponent: prev.currentComponent + 1
}));
} else if (data.type === 'complete') {
generatedCode = data.generatedCode;
explanation = data.explanation;
// Save the last generated code
setConversationContext(prev => ({
...prev,
lastGeneratedCode: generatedCode
}));
}
} catch (e) {
console.error('Error parsing streaming data:', e);
}
}
}
}
}
setGenerationProgress(prev => ({
...prev,
isGenerating: false,
isStreaming: false,
status: 'Generation complete!',
isEdit: prev.isEdit
}));
if (generatedCode) {
addChatMessage('AI recreation generated!', 'system');
// Add the explanation to chat if available
if (explanation && explanation.trim()) {
addChatMessage(explanation, 'ai');
}
setPromptInput(generatedCode);
// Don't show the Generated Code panel by default
// setLeftPanelVisible(true);
// Wait for sandbox creation if it's still in progress
if (sandboxPromise) {
addChatMessage('Waiting for sandbox to be ready...', 'system');
try {
await sandboxPromise;
// Remove the waiting message
setChatMessages(prev => prev.filter(msg => msg.content !== 'Waiting for sandbox to be ready...'));
} catch (error: any) {
addChatMessage('Sandbox creation failed. Cannot apply code.', 'system');
throw error;
}
}
// First application for cloned site should not be in edit mode
await applyGeneratedCode(generatedCode, false);
addChatMessage(
`Successfully recreated ${url} as a modern React app${homeContextInput ? ` with your requested context: "${homeContextInput}"` : ''}! The scraped content is now in my context, so you can ask me to modify specific sections or add features based on the original site.`,
'ai',
{
scrapedUrl: url,
scrapedContent: scrapeData,
generatedCode: generatedCode
}
);
setUrlInput('');
setUrlStatus([]);
setHomeContextInput('');
// Clear generation progress and all screenshot/design states
setGenerationProgress(prev => ({
...prev,
isGenerating: false,
isStreaming: false,
status: 'Generation complete!'
}));
// Clear screenshot and preparing design states to prevent them from showing on next run
setUrlScreenshot(null);
setIsPreparingDesign(false);
setTargetUrl('');
setScreenshotError(null);
setLoadingStage(null); // Clear loading stage
setTimeout(() => {
// Switch back to preview tab but keep files
setActiveTab('preview');
}, 1000); // Show completion briefly then switch
} else {
throw new Error('Failed to generate recreation');
}
} catch (error: any) {
addChatMessage(`Failed to clone website: ${error.message}`, 'system');
setUrlStatus([]);
setIsPreparingDesign(false);
// Clear all states on error
setUrlScreenshot(null);
setTargetUrl('');
setScreenshotError(null);
setLoadingStage(null);
setGenerationProgress(prev => ({
...prev,
isGenerating: false,
isStreaming: false,
status: '',
// Keep files to display in sidebar
files: prev.files
}));
setActiveTab('preview');
}
};
const captureUrlScreenshot = async (url: string) => {
setIsCapturingScreenshot(true);
setScreenshotError(null);
try {
const response = await fetch('/api/scrape-screenshot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const data = await response.json();
if (data.success && data.screenshot) {
setUrlScreenshot(data.screenshot);
// Set preparing design state
setIsPreparingDesign(true);
// Store the clean URL for display
const cleanUrl = url.replace(/^https?:\/\//i, '');
setTargetUrl(cleanUrl);
// Switch to preview tab to show the screenshot
if (activeTab !== 'preview') {
setActiveTab('preview');
}
} else {
setScreenshotError(data.error || 'Failed to capture screenshot');
}
} catch (error) {
console.error('Failed to capture screenshot:', error);
setScreenshotError('Network error while capturing screenshot');
} finally {
setIsCapturingScreenshot(false);
}
};
const handleHomeScreenSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!homeUrlInput.trim()) return;
setHomeScreenFading(true);
// Clear messages and immediately show the cloning message
setChatMessages([]);
let displayUrl = homeUrlInput.trim();
if (!displayUrl.match(/^https?:\/\//i)) {
displayUrl = 'https://' + displayUrl;
}
// Remove protocol for cleaner display
const cleanUrl = displayUrl.replace(/^https?:\/\//i, '');
addChatMessage(`Starting to clone ${cleanUrl}...`, 'system');
// Start creating sandbox and capturing screenshot immediately in parallel
const sandboxPromise = !sandboxData ? createSandbox(true) : Promise.resolve();
// Only capture screenshot if we don't already have a sandbox (first generation)
// After sandbox is set up, skip the screenshot phase for faster generation
if (!sandboxData) {
captureUrlScreenshot(displayUrl);
}
// Set loading stage immediately before hiding home screen
setLoadingStage('gathering');
// Also ensure we're on preview tab to show the loading overlay
setActiveTab('preview');
setTimeout(async () => {
setShowHomeScreen(false);
setHomeScreenFading(false);
// Wait for sandbox to be ready (if it's still creating)
await sandboxPromise;
// Now start the clone process which will stream the generation
setUrlInput(homeUrlInput);
setUrlOverlayVisible(false); // Make sure overlay is closed
setUrlStatus(['Scraping website content...']);
try {
// Scrape the website
let url = homeUrlInput.trim();
if (!url.match(/^https?:\/\//i)) {
url = 'https://' + url;
}
// Screenshot is already being captured in parallel above
const scrapeResponse = await fetch('/api/scrape-url-enhanced', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
if (!scrapeResponse.ok) {
throw new Error('Failed to scrape website');
}
const scrapeData = await scrapeResponse.json();
if (!scrapeData.success) {
throw new Error(scrapeData.error || 'Failed to scrape website');
}
setUrlStatus(['Website scraped successfully!', 'Generating React app...']);
// Clear preparing design state and switch to generation tab
setIsPreparingDesign(false);
setUrlScreenshot(null); // Clear screenshot when starting generation
setTargetUrl(''); // Clear target URL
// Update loading stage to planning
setLoadingStage('planning');
// Brief pause before switching to generation tab
setTimeout(() => {
setLoadingStage('generating');
setActiveTab('generation');
}, 1500);
// Store scraped data in conversation context
setConversationContext(prev => ({
...prev,
scrapedWebsites: [...prev.scrapedWebsites, {
url: url,
content: scrapeData,
timestamp: new Date()
}],
currentProject: `${url} Clone`
}));
const prompt = `I want to recreate the ${url} website as a complete React application based on the scraped content below.
${JSON.stringify(scrapeData, null, 2)}
${homeContextInput ? `ADDITIONAL CONTEXT/REQUIREMENTS FROM USER:
${homeContextInput}
Please incorporate these requirements into the design and implementation.` : ''}
IMPORTANT INSTRUCTIONS:
- Create a COMPLETE, working React application
- Implement ALL sections and features from the original site
- Use Tailwind CSS for all styling (no custom CSS files)
- Make it responsive and modern
- Ensure all text content matches the original
- Create proper component structure
- Make sure the app actually renders visible content
- Create ALL components that you reference in imports
${homeContextInput ? '- Apply the user\'s context/theme requirements throughout the application' : ''}
Focus on the key sections and content, making it clean and modern.`;
setGenerationProgress(prev => ({
isGenerating: true,
status: 'Initializing AI...',
components: [],
currentComponent: 0,
streamedCode: '',
isStreaming: true,
isThinking: false,
thinkingText: undefined,
thinkingDuration: undefined,
// Keep previous files until new ones are generated
files: prev.files || [],
currentFile: undefined,
lastProcessedPosition: 0
}));
const aiResponse = await fetch('/api/generate-ai-code-stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
model: aiModel,
context: {
sandboxId: sandboxData?.sandboxId,
structure: structureContent,
conversationContext: conversationContext
}
})
});
if (!aiResponse.ok || !aiResponse.body) {
throw new Error('Failed to generate code');
}
const reader = aiResponse.body.getReader();
const decoder = new TextDecoder();
let generatedCode = '';
let explanation = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'status') {
setGenerationProgress(prev => ({ ...prev, status: data.message }));
} else if (data.type === 'thinking') {
setGenerationProgress(prev => ({
...prev,
isThinking: true,
thinkingText: (prev.thinkingText || '') + data.text
}));
} else if (data.type === 'thinking_complete') {
setGenerationProgress(prev => ({
...prev,
isThinking: false,
thinkingDuration: data.duration
}));
} else if (data.type === 'conversation') {
// Add conversational text to chat only if it's not code
let text = data.text || '';
// Remove package tags from the text
text = text.replace(/<package>[^<]*<\/package>/g, '');
text = text.replace(/<packages>[^<]*<\/packages>/g, '');
// Filter out any XML tags and file content that slipped through
if (!text.includes('<file') && !text.includes('import React') &&
!text.includes('export default') && !text.includes('className=') &&
text.trim().length > 0) {
addChatMessage(text.trim(), 'ai');
}
} else if (data.type === 'stream' && data.raw) {
setGenerationProgress(prev => {
const newStreamedCode = prev.streamedCode + data.text;
// Tab is already switched after scraping
const updatedState = {
...prev,
streamedCode: newStreamedCode,
isStreaming: true,
isThinking: false,
status: 'Generating code...'
};
// Process complete files from the accumulated stream
const fileRegex = /<file path="([^"]+)">([^]*?)<\/file>/g;
let match;
const processedFiles = new Set(prev.files.map(f => f.path));
while ((match = fileRegex.exec(newStreamedCode)) !== null) {
const filePath = match[1];
const fileContent = match[2];
// Only add if we haven't processed this file yet
if (!processedFiles.has(filePath)) {
const fileExt = filePath.split('.').pop() || '';
const fileType = fileExt === 'jsx' || fileExt === 'js' ? 'javascript' :
fileExt === 'css' ? 'css' :
fileExt === 'json' ? 'json' :
fileExt === 'html' ? 'html' : 'text';
// Check if file already exists
const existingFileIndex = updatedState.files.findIndex(f => f.path === filePath);
if (existingFileIndex >= 0) {
// Update existing file and mark as edited
updatedState.files = [
...updatedState.files.slice(0, existingFileIndex),
{
...updatedState.files[existingFileIndex],
content: fileContent.trim(),
type: fileType,
completed: true,
edited: true
},
...updatedState.files.slice(existingFileIndex + 1)
];
} else {
// Add new file
updatedState.files = [...updatedState.files, {
path: filePath,
content: fileContent.trim(),
type: fileType,
completed: true,
edited: false
}];
}
// Only show file status if not in edit mode
if (!prev.isEdit) {
updatedState.status = `Completed ${filePath}`;
}
processedFiles.add(filePath);
}
}
// Check for current file being generated (incomplete file at the end)
const lastFileMatch = newStreamedCode.match(/<file path="([^"]+)">([^]*?)$/);
if (lastFileMatch && !lastFileMatch[0].includes('</file>')) {
const filePath = lastFileMatch[1];
const partialContent = lastFileMatch[2];
if (!processedFiles.has(filePath)) {
const fileExt = filePath.split('.').pop() || '';
const fileType = fileExt === 'jsx' || fileExt === 'js' ? 'javascript' :
fileExt === 'css' ? 'css' :
fileExt === 'json' ? 'json' :
fileExt === 'html' ? 'html' : 'text';
updatedState.currentFile = {
path: filePath,
content: partialContent,
type: fileType
};
// Only show file status if not in edit mode
if (!prev.isEdit) {
updatedState.status = `Generating ${filePath}`;
}
}
} else {
updatedState.currentFile = undefined;
}
return updatedState;
});
} else if (data.type === 'complete') {
generatedCode = data.generatedCode;
explanation = data.explanation;
// Save the last generated code
setConversationContext(prev => ({
...prev,
lastGeneratedCode: generatedCode
}));
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
}
}
}
setGenerationProgress(prev => ({
...prev,
isGenerating: false,
isStreaming: false,
status: 'Generation complete!'
}));
if (generatedCode) {
addChatMessage('AI recreation generated!', 'system');
// Add the explanation to chat if available
if (explanation && explanation.trim()) {
addChatMessage(explanation, 'ai');
}
setPromptInput(generatedCode);
// First application for cloned site should not be in edit mode
await applyGeneratedCode(generatedCode, false);
addChatMessage(
`Successfully recreated ${url} as a modern React app${homeContextInput ? ` with your requested context: "${homeContextInput}"` : ''}! The scraped content is now in my context, so you can ask me to modify specific sections or add features based on the original site.`,
'ai',
{
scrapedUrl: url,
scrapedContent: scrapeData,
generatedCode: generatedCode
}
);
setConversationContext(prev => ({
...prev,
generatedComponents: [],
appliedCode: [...prev.appliedCode, {
files: [],
timestamp: new Date()
}]
}));
} else {
throw new Error('Failed to generate recreation');
}
setUrlInput('');
setUrlStatus([]);
setHomeContextInput('');
// Clear generation progress and all screenshot/design states
setGenerationProgress(prev => ({
...prev,
isGenerating: false,
isStreaming: false,
status: 'Generation complete!'
}));
// Clear screenshot and preparing design states to prevent them from showing on next run
setUrlScreenshot(null);
setIsPreparingDesign(false);
setTargetUrl('');
setScreenshotError(null);
setLoadingStage(null); // Clear loading stage
setTimeout(() => {
// Switch back to preview tab but keep files
setActiveTab('preview');
}, 1000); // Show completion briefly then switch
} catch (error: any) {
addChatMessage(`Failed to clone website: ${error.message}`, 'system');
setUrlStatus([]);
setIsPreparingDesign(false);
// Also clear generation progress on error
setGenerationProgress(prev => ({
...prev,
isGenerating: false,
isStreaming: false,
status: '',
// Keep files to display in sidebar
files: prev.files
}));
}
}, 500);
};
return (
<div className="font-sans bg-background text-foreground h-screen flex flex-col">
{/* Home Screen Overlay */}
{showHomeScreen && (
<div className={`fixed inset-0 z-50 transition-opacity duration-500 ${homeScreenFading ? 'opacity-0' : 'opacity-100'}`}>
{/* Simple Sun Gradient Background */}
<div className="absolute inset-0 bg-white overflow-hidden">
{/* Main Sun - Pulsing */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-gradient-radial from-orange-400/50 via-orange-300/30 to-transparent rounded-full blur-[80px] animate-[sunPulse_4s_ease-in-out_infinite]" />
{/* Inner Sun Core - Brighter */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] bg-gradient-radial from-yellow-300/40 via-orange-400/30 to-transparent rounded-full blur-[40px] animate-[sunPulse_4s_ease-in-out_infinite_0.5s]" />
{/* Outer Glow - Subtle */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[1200px] h-[1200px] bg-gradient-radial from-orange-200/20 to-transparent rounded-full blur-[120px]" />
{/* Giant Glowing Orb - Center Bottom */}
<div className="absolute bottom-0 left-1/2 w-[800px] h-[800px] animate-[orbShrink_3s_ease-out_forwards]" style={{ transform: 'translateX(-50%) translateY(45%)' }}>
<div className="relative w-full h-full">
<div className="absolute inset-0 bg-orange-600 rounded-full blur-[100px] opacity-30 animate-pulse"></div>
<div className="absolute inset-16 bg-orange-500 rounded-full blur-[80px] opacity-40 animate-pulse" style={{ animationDelay: '0.3s' }}></div>
<div className="absolute inset-32 bg-orange-400 rounded-full blur-[60px] opacity-50 animate-pulse" style={{ animationDelay: '0.6s' }}></div>
<div className="absolute inset-48 bg-yellow-300 rounded-full blur-[40px] opacity-60"></div>
</div>
</div>
</div>
{/* Close button on hover */}
<button
onClick={() => {
setHomeScreenFading(true);
setTimeout(() => {
setShowHomeScreen(false);
setHomeScreenFading(false);
}, 500);
}}
className="absolute top-8 right-8 text-gray-500 hover:text-gray-700 transition-all duration-300 opacity-0 hover:opacity-100 bg-white/80 backdrop-blur-sm p-2 rounded-lg shadow-sm"
style={{ opacity: 0 }}
onMouseEnter={(e) => e.currentTarget.style.opacity = '0.8'}
onMouseLeave={(e) => e.currentTarget.style.opacity = '0'}
>
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Header */}
<div className="absolute top-0 left-0 right-0 z-20 px-6 py-4 flex items-center justify-between animate-[fadeIn_0.8s_ease-out]">
<img
src="/firecrawl-logo-with-fire.webp"
alt="Firecrawl"
className="h-8 w-auto"
/>
<a
href="https://github.com/mendableai/open-lovable"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 bg-[#36322F] text-white px-3 py-2 rounded-[10px] text-sm font-medium [box-shadow:inset_0px_-2px_0px_0px_#171310,_0px_1px_6px_0px_rgba(58,_33,_8,_58%)] hover:translate-y-[1px] hover:scale-[0.98] hover:[box-shadow:inset_0px_-1px_0px_0px_#171310,_0px_1px_3px_0px_rgba(58,_33,_8,_40%)] active:translate-y-[2px] active:scale-[0.97] active:[box-shadow:inset_0px_1px_1px_0px_#171310,_0px_1px_2px_0px_rgba(58,_33,_8,_30%)] transition-all duration-200"
>
<FiGithub className="w-4 h-4" />
<span>Use this template</span>
</a>
</div>
{/* Main content */}
<div className="relative z-10 h-full flex items-center justify-center px-4">
<div className="text-center max-w-4xl min-w-[600px] mx-auto">
{/* Firecrawl-style Header */}
<div className="text-center">
<h1 className="text-[2.5rem] lg:text-[3.8rem] text-center text-[#36322F] font-semibold tracking-tight leading-[0.9] animate-[fadeIn_0.8s_ease-out]">
<span className="hidden md:inline">Open Lovable</span>
<span className="md:hidden">Open Lovable</span>
</h1>
<motion.p
className="text-base lg:text-lg max-w-lg mx-auto mt-2.5 text-zinc-500 text-center text-balance"
animate={{
opacity: showStyleSelector ? 0.7 : 1
}}
transition={{ duration: 0.3, ease: "easeOut" }}
>
Re-imagine any website, in seconds.
</motion.p>
</div>
<form onSubmit={handleHomeScreenSubmit} className="mt-5 max-w-3xl mx-auto">
<div className="w-full relative group">
<input
type="text"
value={homeUrlInput}
onChange={(e) => {
const value = e.target.value;
setHomeUrlInput(value);
// Check if it's a valid domain
const domainRegex = /^(https?:\/\/)?(([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})(\/?.*)?$/;
if (domainRegex.test(value) && value.length > 5) {
// Small delay to make the animation feel smoother
setTimeout(() => setShowStyleSelector(true), 100);
} else {
setShowStyleSelector(false);
setSelectedStyle(null);
}
}}
placeholder=" "
aria-placeholder="https://firecrawl.dev"
className="h-[3.25rem] w-full resize-none focus-visible:outline-none focus-visible:ring-orange-500 focus-visible:ring-2 rounded-[18px] text-sm text-[#36322F] px-4 pr-12 border-[.75px] border-border bg-white"
style={{
boxShadow: '0 0 0 1px #e3e1de66, 0 1px 2px #5f4a2e14, 0 4px 6px #5f4a2e0a, 0 40px 40px -24px #684b2514',
filter: 'drop-shadow(rgba(249, 224, 184, 0.3) -0.731317px -0.731317px 35.6517px)'
}}
autoFocus
/>
<div
aria-hidden="true"
className={`absolute top-1/2 -translate-y-1/2 left-4 pointer-events-none text-sm text-opacity-50 text-start transition-opacity ${
homeUrlInput ? 'opacity-0' : 'opacity-100'
}`}
>
<span className="text-[#605A57]/50" style={{ fontFamily: 'monospace' }}>
https://firecrawl.dev
</span>
</div>
<button
type="submit"
disabled={!homeUrlInput.trim()}
className="absolute top-1/2 transform -translate-y-1/2 right-2 flex h-10 items-center justify-center rounded-md px-3 text-sm font-medium text-zinc-500 hover:text-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-950 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title={selectedStyle ? `Clone with ${selectedStyle} Style` : 'Clone Website'}
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
<polyline points="9 10 4 15 9 20"></polyline>
<path d="M20 4v7a4 4 0 0 1-4 4H4"></path>
</svg>
</button>
</div>
{/* Style Selector - Slides out when valid domain is entered */}
{showStyleSelector && (
<div className="overflow-hidden mt-4">
<div className={`transition-all duration-500 ease-out transform ${
showStyleSelector ? 'translate-y-0 opacity-100' : '-translate-y-4 opacity-0'
}`}>
<div className="bg-white/80 backdrop-blur-sm border border-gray-200 rounded-xl p-4 shadow-sm">
<p className="text-sm text-gray-600 mb-3 font-medium">How do you want your site to look?</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{[
{ name: 'Neobrutalist', description: 'Bold colors, thick borders' },
{ name: 'Glassmorphism', description: 'Frosted glass effects' },
{ name: 'Minimalist', description: 'Clean and simple' },
{ name: 'Dark Mode', description: 'Dark theme' },
{ name: 'Gradient', description: 'Colorful gradients' },
{ name: 'Retro', description: '80s/90s aesthetic' },
{ name: 'Modern', description: 'Contemporary design' },
{ name: 'Monochrome', description: 'Black and white' }
].map((style) => (
<button
key={style.name}
type="button"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
// Submit the form
const form = e.currentTarget.closest('form');
if (form) {
form.requestSubmit();
}
}
}}
onClick={() => {
if (selectedStyle === style.name) {
// Deselect if clicking the same style
setSelectedStyle(null);
// Keep only additional context, remove the style theme part
const currentAdditional = homeContextInput.replace(/^[^,]+theme\s*,?\s*/, '').trim();
setHomeContextInput(currentAdditional);
} else {
// Select new style
setSelectedStyle(style.name);
// Extract any additional context (everything after the style theme)
const currentAdditional = homeContextInput.replace(/^[^,]+theme\s*,?\s*/, '').trim();
setHomeContextInput(style.name.toLowerCase() + ' theme' + (currentAdditional ? ', ' + currentAdditional : ''));
}
}}
className={`p-3 rounded-lg border transition-all ${
selectedStyle === style.name
? 'border-orange-400 bg-orange-50 text-gray-900 shadow-sm'
: 'border-gray-200 bg-white hover:border-orange-200 hover:bg-orange-50/50 text-gray-700'
}`}
>
<div className="text-sm font-medium">{style.name}</div>
<div className="text-xs text-gray-500 mt-1">{style.description}</div>
</button>
))}
</div>
{/* Additional context input - part of the style selector */}
<div className="mt-4 mb-2">
<input
type="text"
value={(() => {
if (!selectedStyle) return homeContextInput;
// Extract additional context by removing the style theme part
const additional = homeContextInput.replace(new RegExp('^' + selectedStyle.toLowerCase() + ' theme\\s*,?\\s*', 'i'), '');
return additional;
})()}
onChange={(e) => {
const additionalContext = e.target.value;
if (selectedStyle) {
setHomeContextInput(selectedStyle.toLowerCase() + ' theme' + (additionalContext.trim() ? ', ' + additionalContext : ''));
} else {
setHomeContextInput(additionalContext);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const form = e.currentTarget.closest('form');
if (form) {
form.requestSubmit();
}
}
}}
placeholder="Add more details: specific features, color preferences..."
className="w-full px-4 py-2 text-sm bg-white border border-gray-200 rounded-lg text-gray-900 placeholder-gray-500 focus:outline-none focus:border-orange-300 focus:ring-2 focus:ring-orange-100 transition-all duration-200"
/>
</div>
</div>
</div>
</div>
)}
</form>
{/* Model Selector */}
<div className="mt-6 flex items-center justify-center animate-[fadeIn_1s_ease-out]">
<select
value={aiModel}
onChange={(e) => {
const newModel = e.target.value;
setAiModel(newModel);
const params = new URLSearchParams(searchParams);
params.set('model', newModel);
if (sandboxData?.sandboxId) {
params.set('sandbox', sandboxData.sandboxId);
}
router.push(`/?${params.toString()}`);
}}
className="px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-[10px] focus:outline-none focus:ring-2 focus:ring-[#36322F] focus:border-transparent"
style={{
boxShadow: '0 0 0 1px #e3e1de66, 0 1px 2px #5f4a2e14'
}}
>
{appConfig.ai.availableModels.map(model => (
<option key={model} value={model}>
{appConfig.ai.modelDisplayNames[model] || model}
</option>
))}
</select>
</div>
</div>
</div>
</div>
)}
<div className="bg-card px-4 py-4 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-4">
<img
src="/firecrawl-logo-with-fire.webp"
alt="Firecrawl"
className="h-8 w-auto"
/>
</div>
<div className="flex items-center gap-2">
{/* Model Selector - Left side */}
<select
value={aiModel}
onChange={(e) => {
const newModel = e.target.value;
setAiModel(newModel);
const params = new URLSearchParams(searchParams);
params.set('model', newModel);
if (sandboxData?.sandboxId) {
params.set('sandbox', sandboxData.sandboxId);
}
router.push(`/?${params.toString()}`);
}}
className="px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-[10px] focus:outline-none focus:ring-2 focus:ring-[#36322F] focus:border-transparent"
>
{appConfig.ai.availableModels.map(model => (
<option key={model} value={model}>
{appConfig.ai.modelDisplayNames[model] || model}
</option>
))}
</select>
<Button
variant="code"
onClick={() => createSandbox()}
size="sm"
title="Create new sandbox"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</Button>
<Button
variant="code"
onClick={reapplyLastGeneration}
size="sm"
title="Re-apply last generation"
disabled={!conversationContext.lastGeneratedCode || !sandboxData}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</Button>
<Button
variant="code"
onClick={downloadZip}
disabled={!sandboxData}
size="sm"
title="Download your Vite app as ZIP"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
</Button>
<div className="inline-flex items-center gap-2 bg-[#36322F] text-white px-3 py-1.5 rounded-[10px] text-sm font-medium [box-shadow:inset_0px_-2px_0px_0px_#171310,_0px_1px_6px_0px_rgba(58,_33,_8,_58%)]">
<span id="status-text">{status.text}</span>
<div className={`w-2 h-2 rounded-full ${status.active ? 'bg-green-500' : 'bg-gray-500'}`} />
</div>
</div>
</div>
<div className="flex-1 flex overflow-hidden">
{/* Center Panel - AI Chat (1/3 of remaining width) */}
<div className="flex-1 max-w-[400px] flex flex-col border-r border-border bg-background">
{conversationContext.scrapedWebsites.length > 0 && (
<div className="p-4 bg-card">
<div className="flex flex-col gap-2">
{conversationContext.scrapedWebsites.map((site, idx) => {
// Extract favicon and site info from the scraped data
const metadata = site.content?.metadata || {};
const sourceURL = metadata.sourceURL || site.url;
const favicon = metadata.favicon || `https://www.google.com/s2/favicons?domain=${new URL(sourceURL).hostname}&sz=32`;
const siteName = metadata.ogSiteName || metadata.title || new URL(sourceURL).hostname;
return (
<div key={idx} className="flex items-center gap-2 text-sm">
<img
src={favicon}
alt={siteName}
className="w-4 h-4 rounded"
onError={(e) => {
e.currentTarget.src = `https://www.google.com/s2/favicons?domain=${new URL(sourceURL).hostname}&sz=32`;
}}
/>
<a
href={sourceURL}
target="_blank"
rel="noopener noreferrer"
className="text-black hover:text-gray-700 truncate max-w-[250px]"
title={sourceURL}
>
{siteName}
</a>
</div>
);
})}
</div>
</div>
)}
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-1 scrollbar-hide" ref={chatMessagesRef}>
{chatMessages.map((msg, idx) => {
// Check if this message is from a successful generation
const isGenerationComplete = msg.content.includes('Successfully recreated') ||
msg.content.includes('AI recreation generated!') ||
msg.content.includes('Code generated!');
// Get the files from metadata if this is a completion message
const completedFiles = msg.metadata?.appliedFiles || [];
return (
<div key={idx} className="block">
<div className={`flex ${msg.type === 'user' ? 'justify-end' : 'justify-start'} mb-1`}>
<div className="block">
<div className={`block rounded-[10px] px-4 py-2 ${
msg.type === 'user' ? 'bg-[#36322F] text-white ml-auto max-w-[80%]' :
msg.type === 'ai' ? 'bg-gray-100 text-gray-900 mr-auto max-w-[80%]' :
msg.type === 'system' ? 'bg-[#36322F] text-white text-sm' :
msg.type === 'command' ? 'bg-[#36322F] text-white font-mono text-sm' :
msg.type === 'error' ? 'bg-red-900 text-red-100 text-sm border border-red-700' :
'bg-[#36322F] text-white text-sm'
}`}>
{msg.type === 'command' ? (
<div className="flex items-start gap-2">
<span className={`text-xs ${
msg.metadata?.commandType === 'input' ? 'text-blue-400' :
msg.metadata?.commandType === 'error' ? 'text-red-400' :
msg.metadata?.commandType === 'success' ? 'text-green-400' :
'text-gray-400'
}`}>
{msg.metadata?.commandType === 'input' ? '$' : '>'}
</span>
<span className="flex-1 whitespace-pre-wrap text-white">{msg.content}</span>
</div>
) : msg.type === 'error' ? (
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-red-800 rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-red-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
</div>
<div className="flex-1">
<div className="font-semibold mb-1">Build Errors Detected</div>
<div className="whitespace-pre-wrap text-sm">{msg.content}</div>
<div className="mt-2 text-xs opacity-70">Press 'F' or click the Fix button above to resolve</div>
</div>
</div>
) : (
msg.content
)}
</div>
{/* Show applied files if this is an apply success message */}
{msg.metadata?.appliedFiles && msg.metadata.appliedFiles.length > 0 && (
<div className="mt-2 inline-block bg-gray-100 rounded-[10px] p-3">
<div className="text-xs font-medium mb-1 text-gray-700">
{msg.content.includes('Applied') ? 'Files Updated:' : 'Generated Files:'}
</div>
<div className="flex flex-wrap items-start gap-1">
{msg.metadata.appliedFiles.map((filePath, fileIdx) => {
const fileName = filePath.split('/').pop() || filePath;
const fileExt = fileName.split('.').pop() || '';
const fileType = fileExt === 'jsx' || fileExt === 'js' ? 'javascript' :
fileExt === 'css' ? 'css' :
fileExt === 'json' ? 'json' : 'text';
return (
<div
key={`applied-${fileIdx}`}
className="inline-flex items-center gap-1 px-2 py-1 bg-[#36322F] text-white rounded-[10px] text-xs animate-fade-in-up"
style={{ animationDelay: `${fileIdx * 30}ms` }}
>
<span className={`inline-block w-1.5 h-1.5 rounded-full ${
fileType === 'css' ? 'bg-blue-400' :
fileType === 'javascript' ? 'bg-yellow-400' :
fileType === 'json' ? 'bg-green-400' :
'bg-gray-400'
}`} />
{fileName}
</div>
);
})}
</div>
</div>
)}
{/* Show generated files for completion messages - but only if no appliedFiles already shown */}
{isGenerationComplete && generationProgress.files.length > 0 && idx === chatMessages.length - 1 && !msg.metadata?.appliedFiles && !chatMessages.some(m => m.metadata?.appliedFiles) && (
<div className="mt-2 inline-block bg-gray-100 rounded-[10px] p-3">
<div className="text-xs font-medium mb-1 text-gray-700">Generated Files:</div>
<div className="flex flex-wrap items-start gap-1">
{generationProgress.files.map((file, fileIdx) => (
<div
key={`complete-${fileIdx}`}
className="inline-flex items-center gap-1 px-2 py-1 bg-[#36322F] text-white rounded-[10px] text-xs animate-fade-in-up"
style={{ animationDelay: `${fileIdx * 30}ms` }}
>
<span className={`inline-block w-1.5 h-1.5 rounded-full ${
file.type === 'css' ? 'bg-blue-400' :
file.type === 'javascript' ? 'bg-yellow-400' :
file.type === 'json' ? 'bg-green-400' :
'bg-gray-400'
}`} />
{file.path.split('/').pop()}
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
})}
{/* Code application progress */}
{codeApplicationState.stage && (
<CodeApplicationProgress state={codeApplicationState} />
)}
{/* File generation progress - inline display (during generation) */}
{generationProgress.isGenerating && (
<div className="inline-block bg-gray-100 rounded-lg p-3">
<div className="text-sm font-medium mb-2 text-gray-700">
{generationProgress.status}
</div>
<div className="flex flex-wrap items-start gap-1">
{/* Show completed files */}
{generationProgress.files.map((file, idx) => (
<div
key={`file-${idx}`}
className="inline-flex items-center gap-1 px-2 py-1 bg-[#36322F] text-white rounded-[10px] text-xs animate-fade-in-up"
style={{ animationDelay: `${idx * 30}ms` }}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
{file.path.split('/').pop()}
</div>
))}
{/* Show current file being generated */}
{generationProgress.currentFile && (
<div className="flex items-center gap-1 px-2 py-1 bg-[#36322F]/70 text-white rounded-[10px] text-xs animate-pulse"
style={{ animationDelay: `${generationProgress.files.length * 30}ms` }}>
<div className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" />
{generationProgress.currentFile.path.split('/').pop()}
</div>
)}
</div>
{/* Live streaming response display */}
{generationProgress.streamedCode && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="mt-3 border-t border-gray-300 pt-3"
>
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span className="text-xs font-medium text-gray-600">AI Response Stream</span>
</div>
<div className="flex-1 h-px bg-gradient-to-r from-gray-300 to-transparent" />
</div>
<div className="bg-gray-900 border border-gray-700 rounded max-h-32 overflow-y-auto scrollbar-hide">
<SyntaxHighlighter
language="jsx"
style={vscDarkPlus}
customStyle={{
margin: 0,
padding: '0.75rem',
fontSize: '11px',
lineHeight: '1.5',
background: 'transparent',
maxHeight: '8rem',
overflow: 'hidden'
}}
>
{(() => {
const lastContent = generationProgress.streamedCode.slice(-1000);
// Show the last part of the stream, starting from a complete tag if possible
const startIndex = lastContent.indexOf('<');
return startIndex !== -1 ? lastContent.slice(startIndex) : lastContent;
})()}
</SyntaxHighlighter>
<span className="inline-block w-2 h-3 bg-orange-400 ml-3 mb-3 animate-pulse" />
</div>
</motion.div>
)}
</div>
)}
</div>
<div className="p-4 border-t border-border bg-card">
<div className="relative">
<Textarea
className="min-h-[60px] pr-12 resize-y border-2 border-black focus:outline-none"
placeholder=""
value={aiChatInput}
onChange={(e) => setAiChatInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendChatMessage();
}
}}
rows={3}
/>
<button
onClick={sendChatMessage}
className="absolute right-2 bottom-2 p-2 bg-[#36322F] text-white rounded-[10px] hover:bg-[#4a4542] [box-shadow:inset_0px_-2px_0px_0px_#171310,_0px_1px_6px_0px_rgba(58,_33,_8,_58%)] hover:translate-y-[1px] hover:scale-[0.98] hover:[box-shadow:inset_0px_-1px_0px_0px_#171310,_0px_1px_3px_0px_rgba(58,_33,_8,_40%)] active:translate-y-[2px] active:scale-[0.97] active:[box-shadow:inset_0px_1px_1px_0px_#171310,_0px_1px_2px_0px_rgba(58,_33,_8,_30%)] transition-all duration-200"
title="Send message (Enter)"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</button>
</div>
</div>
</div>
{/* Right Panel - Preview or Generation (2/3 of remaining width) */}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="px-4 py-2 bg-card border-b border-border flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="flex bg-[#36322F] rounded-lg p-1">
<button
onClick={() => setActiveTab('generation')}
className={`p-2 rounded-md transition-all ${
activeTab === 'generation'
? 'bg-black text-white'
: 'text-gray-300 hover:text-white hover:bg-gray-700'
}`}
title="Code"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</button>
<button
onClick={() => setActiveTab('preview')}
className={`p-2 rounded-md transition-all ${
activeTab === 'preview'
? 'bg-black text-white'
: 'text-gray-300 hover:text-white hover:bg-gray-700'
}`}
title="Preview"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
</div>
</div>
<div className="flex gap-2 items-center">
{/* Live Code Generation Status - Moved to far right */}
{activeTab === 'generation' && (generationProgress.isGenerating || generationProgress.files.length > 0) && (
<div className="flex items-center gap-3">
{!generationProgress.isEdit && (
<div className="text-gray-600 text-sm">
{generationProgress.files.length} files generated
</div>
)}
<div className={`inline-flex items-center justify-center whitespace-nowrap rounded-[10px] font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-[#36322F] text-white hover:bg-[#36322F] [box-shadow:inset_0px_-2px_0px_0px_#171310,_0px_1px_6px_0px_rgba(58,_33,_8,_58%)] hover:translate-y-[1px] hover:scale-[0.98] hover:[box-shadow:inset_0px_-1px_0px_0px_#171310,_0px_1px_3px_0px_rgba(58,_33,_8,_40%)] active:translate-y-[2px] active:scale-[0.97] active:[box-shadow:inset_0px_1px_1px_0px_#171310,_0px_1px_2px_0px_rgba(58,_33,_8,_30%)] disabled:shadow-none disabled:hover:translate-y-0 disabled:hover:scale-100 h-8 px-3 py-1 text-sm gap-2`}>
{generationProgress.isGenerating ? (
<>
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse shadow-[0_0_10px_rgba(34,197,94,0.5)]" />
{generationProgress.isEdit ? 'Editing code' : 'Live code generation'}
</>
) : (
<>
<div className="w-2 h-2 bg-gray-500 rounded-full" />
COMPLETE
</>
)}
</div>
</div>
)}
{sandboxData && !generationProgress.isGenerating && (
<>
<Button
variant="code"
size="sm"
asChild
>
<a
href={sandboxData.url}
target="_blank"
rel="noopener noreferrer"
title="Open in new tab"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</Button>
</>
)}
</div>
</div>
<div className="flex-1 relative overflow-hidden">
{renderMainContent()}
</div>
</div>
</div>
</div>
);
}