DataVision CI/CD Bot
release: clean production build for HuggingFace Space
09801ca
Raw
History Blame Contribute Delete
61.5 kB
import React, { useState, useEffect, useRef, Component, ErrorInfo, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
class WebIDEErrorBoundary extends Component<{children: ReactNode}, {hasError: boolean, error: Error | null}> {
constructor(props: {children: ReactNode}) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("WebIDE Error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="fixed inset-0 z-[9999] bg-black text-red-500 p-10 flex flex-col font-mono">
<h1 className="text-2xl font-bold mb-4">WebIDE Crashed</h1>
<pre className="whitespace-pre-wrap">{this.state.error?.toString()}</pre>
<pre className="whitespace-pre-wrap mt-4">{this.state.error?.stack}</pre>
<button onClick={() => window.location.reload()} className="mt-8 px-4 py-2 bg-red-600 text-white rounded w-fit">Reload Page</button>
</div>
);
}
return this.props.children;
}
}
import Editor from '@monaco-editor/react';
import {
Play, Square, MessageSquare, Terminal as TerminalIcon, Loader2, Send,
FileCode2, Folder, FolderOpen, ExternalLink, Files, Search, GitBranch, PlaySquare,
Blocks, Settings, UserCircle, ChevronDown, ChevronRight, Plus, Trash2, X, MoreHorizontal, Check, Download, Edit3, FilePlus, ImagePlus, Mic
} from 'lucide-react';
import { api } from '../services/api';
import { getUserIdSync } from '../utils/userId';
// Memoize editor options to prevent re-renders
const EDITOR_OPTIONS = {
minimap: { enabled: true, scale: 2 },
fontSize: 14,
fontFamily: "'Consolas', 'Courier New', monospace",
scrollBeyondLastLine: false,
padding: { top: 16 },
wordWrap: 'on' as const,
bracketPairColorization: { enabled: true }
};
interface WebIDEProps {
initialFiles: Record<string, string>;
onClose?: () => void;
}
const VSCodeIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" className="w-5 h-5">
<path fill="#0065A9" d="M188.8 11.5l61.5 43.5c2.3 1.6 3.7 4.3 3.7 7.1v131.8c0 2.8-1.4 5.5-3.7 7.1l-61.5 43.5c-4.4 3.1-10.4 2.5-14.1-1.3l-67.9-67.9-49.9 41.9c-2.4 2-5.9 2-8.3 0l-45.7-38C.9 177.3 0 174.6 0 171.7V84.3c0-2.9.9-5.6 2.9-7.5l45.7-38c2.4-2 5.9-2 8.3 0l49.9 41.9 67.9-67.9c4.4-3.1 10.4-2.5 14.1 1.3z"/>
<path fill="#007ACC" d="M189 11.7c-4.4-3.1-10.4-2.5-14.1 1.3l-67.9 67.9-49.9-41.9c-2.4-2-5.9-2-8.3 0l-45.7 38C.9 78.9 0 81.6 0 84.5v87c0 2.9.9 5.6 2.9 7.5l45.7 38c2.4 2 5.9 2 8.3 0l49.9-41.9 67.9 67.9c3.7 3.8 9.7 4.4 14.1 1.3l61.5-43.5c2.3-1.6 3.7-4.3 3.7-7.1V62.1c0-2.8-1.4-5.5-3.7-7.1L189 11.7z"/>
<path fill="#1F9CF0" d="M174.8 244.5L107 176.7l43.2-36.2 39.5 39.4c1.2 1.2 3.1 1.2 4.3 0l49.4-49.3c.7-.7.7-1.7 0-2.4l-49.4-49.3c-1.2-1.2-3.1-1.2-4.3 0l-39.5 39.4-43.2-36.2 67.9-67.9c3.7-3.8 9.7-4.4 14.1-1.3l61.5 43.5c2.3 1.6 3.7 4.3 3.7 7.1v131.8c0 2.8-1.4 5.5-3.7 7.1l-61.5 43.5c-3.6 3.8-9.6 4.4-14.1 1.3z"/>
</svg>
);
const CursorIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" className="w-5 h-5">
<polygon points="50,10 90,30 90,70 50,90 10,70 10,30" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeWidth="4" />
<polygon points="50,10 90,30 50,55 10,30" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="4" />
<polygon points="10,30 50,55 50,90 10,70" fill="currentColor" fillOpacity="0.3" stroke="currentColor" strokeWidth="4" />
<polygon points="90,30 50,55 50,90 90,70" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeWidth="4" />
</svg>
);
const AntigravityIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" className="w-6 h-6">
<defs>
<linearGradient id="agGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#4ADE80" />
<stop offset="50%" stopColor="#3B82F6" />
<stop offset="100%" stopColor="#EC4899" />
</linearGradient>
</defs>
<path fill="url(#agGrad)" d="M50 160 Q80 130 100 60 Q120 130 150 160 Q130 140 100 140 Q70 140 50 160 Z" />
</svg>
);
export const WebIDE: React.FC<WebIDEProps> = ({ initialFiles, onClose }) => {
const [files, setFiles] = useState<Record<string, string>>(initialFiles);
const [activeFile, setActiveFile] = useState<string>(Object.keys(initialFiles)[0] || '');
const [pid, setPid] = useState<string | null>(null);
const [workspacePath, setWorkspacePath] = useState<string>('');
const [logs, setLogs] = useState<string>('');
const [isRunning, setIsRunning] = useState(false);
const [runningUrl, setRunningUrl] = useState<string | null>(null);
const hasOpenedUrlRef = useRef(false);
// Chat state
const [chatMessages, setChatMessages] = useState<{role: 'user'|'ai', content: string, is_plan?: boolean, isStreaming?: boolean, thoughts?: string[], status?: string}[]>([
{ role: 'ai', content: 'Hello! I am your Silicon Valley AI Architect. I have access to your full codebase. How would you like to upgrade your app today?' }
]);
const [chatHistory, setChatHistory] = useState<{id: string, title: string, messages: {role: 'user'|'ai', content: string, is_plan?: boolean, isStreaming?: boolean, thoughts?: string[], status?: string}[]}[]>([
{ id: '1', title: 'Current Session', messages: [
{ role: 'ai', content: 'Hello! I am your Silicon Valley AI Architect. I have access to your full codebase. How would you like to upgrade your app today?' }
]}
]);
const [activeChatId, setActiveChatId] = useState<string>('1');
const [chatSearchQuery, setChatSearchQuery] = useState('');
const [showChatHistoryPanel, setShowChatHistoryPanel] = useState(false);
const [chatInput, setChatInput] = useState('');
const [lastPrompt, setLastPrompt] = useState('');
const [isPlanningMode, setIsPlanningMode] = useState(true);
const [isChatting, setIsChatting] = useState(false);
const [llm, setLlm] = useState<string>('llama'); // LLM Selection state
const [modifiedFiles, setModifiedFiles] = useState<Set<string>>(new Set());
const [newFileInput, setNewFileInput] = useState<string | null>(null);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
// Multimodal state
const [chatImages, setChatImages] = useState<string[]>([]);
const [isRecording, setIsRecording] = useState(false);
// UI state
const [activeSidebar, setActiveSidebar] = useState<'explorer' | 'search' | 'git' | 'run' | 'extensions' | null>('explorer');
const [rightPanelOpen, setRightPanelOpen] = useState(true);
const [terminalOpen, setTerminalOpen] = useState(true);
const [activeTerminalTab, setActiveTerminalTab] = useState<'problems' | 'output' | 'debug' | 'terminal' | 'ports'>('terminal');
const [activeTerminalType, setActiveTerminalType] = useState<'powershell' | 'cmd' | 'python' | null>('powershell');
const [terminals, setTerminals] = useState<Array<{id: string, type: 'powershell' | 'cmd', logsLen: number}>>([]);
const [activeTerminalId, setActiveTerminalId] = useState<string | null>(null);
const [terminalHeight, setTerminalHeight] = useState(300);
const [isDraggingTerminal, setIsDraggingTerminal] = useState(false);
const [showTerminalDropdown, setShowTerminalDropdown] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(true);
const [showCloseModal, setShowCloseModal] = useState(false);
const logsEndRef = useRef<HTMLDivElement>(null);
const chatEndRef = useRef<HTMLDivElement>(null);
const terminalContainerRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
// Refs for terminal state closure
const activeTerminalIdRef = useRef(activeTerminalId);
const activeTerminalTypeRef = useRef(activeTerminalType);
const pidRef = useRef(pid);
const inputBufferRef = useRef('');
useEffect(() => { activeTerminalIdRef.current = activeTerminalId; }, [activeTerminalId]);
useEffect(() => { activeTerminalTypeRef.current = activeTerminalType; }, [activeTerminalType]);
useEffect(() => { pidRef.current = pid; }, [pid]);
// Initialize xterm
useEffect(() => {
if (!terminalContainerRef.current || !terminalOpen || activeTerminalTab !== 'terminal') return;
if (!xtermRef.current) {
const term = new Terminal({
theme: isDarkMode ? { background: '#1e1e1e', foreground: '#cccccc' } : { background: '#ffffff', foreground: '#333333' },
fontFamily: "'Consolas', 'Courier New', monospace",
fontSize: 13,
cursorBlink: true,
disableStdin: false
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(terminalContainerRef.current);
fitAddon.fit();
xtermRef.current = term;
fitAddonRef.current = fitAddon;
// Handle user input in terminal (Line buffered for Windows Pipe compatibility)
term.onData(async (data) => {
const targetPid = (activeTerminalTypeRef.current === 'python') ? pidRef.current : activeTerminalIdRef.current;
if (!targetPid) return;
if (data === '\r') {
// Enter key pressed: send the buffered line
try {
term.write('\r\n');
const lineToSend = inputBufferRef.current + '\n';
inputBufferRef.current = '';
await api.post(`/api/v1/ide/terminal/input/${targetPid}`, { input: lineToSend });
} catch (e) {
console.error("Failed to send terminal input", e);
}
} else if (data === '\x7f' || data === '\b') {
// Backspace pressed
if (inputBufferRef.current.length > 0) {
inputBufferRef.current = inputBufferRef.current.slice(0, -1);
term.write('\b \b'); // Erase character visually
}
} else if (data === '\u0003') {
// Ctrl+C pressed
inputBufferRef.current = '';
term.write('^C\r\n');
try {
await api.post(`/api/v1/ide/terminal/input/${targetPid}`, { input: '\x03' });
} catch (e) {
console.error("Failed to send Ctrl+C", e);
}
} else {
// Normal character typed
inputBufferRef.current += data;
term.write(data);
}
});
// Initial write
if (logs) {
term.write(logs.replace(/\n/g, '\r\n'));
} else {
term.write('Welcome to DataVision Terminal.\r\nType "python api_server.py" to start manually.\r\n\r\n> ');
}
}
const handleResize = () => {
if (fitAddonRef.current) fitAddonRef.current.fit();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
};
}, [terminalOpen, activeTerminalTab, isDarkMode]);
// Terminal Dragging
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDraggingTerminal) return;
// Calculate new height (window height - mouse Y - status bar approx height)
const newHeight = window.innerHeight - e.clientY - 24;
setTerminalHeight(Math.max(100, Math.min(newHeight, window.innerHeight - 200)));
// Trigger xterm fit on drag
if (fitAddonRef.current) fitAddonRef.current.fit();
};
const handleMouseUp = () => setIsDraggingTerminal(false);
if (isDraggingTerminal) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
// Disable text selection while dragging
document.body.style.userSelect = 'none';
} else {
document.body.style.userSelect = '';
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDraggingTerminal]);
// Theme observer
useEffect(() => {
const checkDarkMode = () => setIsDarkMode(document.documentElement.classList.contains('dark'));
checkDarkMode();
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
useEffect(() => {
if (logsEndRef.current) logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}, [logs, activeTerminalTab]);
useEffect(() => {
if (chatEndRef.current) chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
}, [chatMessages]);
useEffect(() => {
const fetchPath = async () => {
try {
const res = await api.get(`/api/v1/ide/workspace-path/${getUserIdSync()}`);
if (res.data.success) {
setWorkspacePath(res.data.path);
}
} catch (e) {}
};
fetchPath();
}, []);
// Load persisted files and check for running process on mount
useEffect(() => {
const userId = getUserIdSync();
// 1. Load persisted files from localStorage
const stored = localStorage.getItem(`ide_files_${userId}`);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (Object.keys(parsed).length > 0) {
setFiles(prev => ({ ...prev, ...parsed }));
}
} catch (e) {}
}
// Removed checkStatus to prevent reconnecting to old powershell terminals
}, []);
// Persist files to localStorage when they change
useEffect(() => {
if (Object.keys(files).length > 0) {
localStorage.setItem(`ide_files_${getUserIdSync()}`, JSON.stringify(files));
}
}, [files]);
const spawnTerminal = async (type: 'powershell' | 'cmd') => {
try {
const res = await api.post('/api/v1/ide/terminal/spawn', { user_id: getUserIdSync(), shell: type });
if (res.data.success) {
const newTerminal = { id: res.data.pid, type, logsLen: 0 };
setTerminals(prev => [...prev, newTerminal]);
setActiveTerminalId(newTerminal.id);
setActiveTerminalType(type);
if (xtermRef.current) xtermRef.current.reset();
}
} catch (e) {
console.error("Failed to spawn terminal", e);
}
};
const deleteTerminal = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
try {
await api.post(`/api/v1/ide/stop/${id}`);
setTerminals(prev => {
const next = prev.filter(t => t.id !== id);
if (activeTerminalId === id) {
if (next.length > 0) {
setActiveTerminalId(next[next.length - 1].id);
setActiveTerminalType(next[next.length - 1].type);
} else {
setActiveTerminalId(null);
// fallback to python if running, else cmd
setActiveTerminalType(isRunning ? 'python' : null);
}
}
return next;
});
} catch (err) {
console.error("Failed to delete terminal", err);
}
};
// Auto-spawn raw terminal when tab opens and no terminals exist
useEffect(() => {
if (terminalOpen && activeTerminalTab === 'terminal' && terminals.length === 0 && (activeTerminalType === 'powershell' || activeTerminalType === 'cmd' || activeTerminalType === null)) {
spawnTerminal('powershell');
}
}, [terminalOpen, activeTerminalTab, terminals.length, activeTerminalType]);
// Handle switching between existing terminals
useEffect(() => {
if (activeTerminalTab === 'terminal') {
if (xtermRef.current) xtermRef.current.reset();
if (activeTerminalType === 'python' && pid) {
// we will fetch logs in the python polling effect
} else if (activeTerminalId) {
// Reset length so it fetches full history of switched terminal
setTerminals(prev => prev.map(t => t.id === activeTerminalId ? { ...t, logsLen: 0 } : t));
}
}
}, [activeTerminalId, activeTerminalType, activeTerminalTab]);
useEffect(() => {
let interval: NodeJS.Timeout;
if (pid && isRunning) {
interval = setInterval(async () => {
try {
const res = await api.get(`/api/v1/ide/logs/${pid}`);
if (res.data.success) {
if (xtermRef.current && res.data.logs && activeTerminalType === 'python') {
const newLogs = res.data.logs.slice(logs.length);
if (newLogs) xtermRef.current.write(newLogs.replace(/\n/g, '\r\n'));
}
setLogs(res.data.logs);
if (res.data.url) {
setRunningUrl(res.data.url);
if (!hasOpenedUrlRef.current) {
hasOpenedUrlRef.current = true;
window.open(res.data.url, '_blank');
}
}
if (res.data.status !== 'running') {
setIsRunning(false);
}
}
} catch (err) {}
}, 1000);
}
return () => clearInterval(interval);
}, [pid, isRunning, logs, activeTerminalType]);
// Polling for Standalone Terminal
useEffect(() => {
let interval: NodeJS.Timeout;
if (activeTerminalId && (activeTerminalType === 'powershell' || activeTerminalType === 'cmd')) {
interval = setInterval(async () => {
try {
const res = await api.get(`/api/v1/ide/logs/${activeTerminalId}`);
if (res.data.success && res.data.logs) {
const allLogs = res.data.logs;
setTerminals(prev => {
const activeTerm = prev.find(t => t.id === activeTerminalId);
if (activeTerm && allLogs.length > activeTerm.logsLen) {
const newChunk = allLogs.slice(activeTerm.logsLen);
if (xtermRef.current) xtermRef.current.write(newChunk.replace(/\n/g, '\r\n'));
return prev.map(t => t.id === activeTerminalId ? { ...t, logsLen: allLogs.length } : t);
}
return prev;
});
}
} catch (err) {}
}, 200); // Poll faster for responsiveness
}
return () => clearInterval(interval);
}, [activeTerminalId, activeTerminalType]);
const handleRun = async () => {
try {
setIsRunning(true);
setRunningUrl(null);
hasOpenedUrlRef.current = false;
setLogs('[System] Instructing backend to auto-detect and run project...\\n');
const res = await api.post(`/api/v1/ide/run`, {
user_id: getUserIdSync(),
command: "python api_server.py", // Or allow auto-detect by passing empty/null if backend supports it
files: files
});
if (res.data.success) {
setPid(res.data.pid);
}
} catch (err: any) {
setLogs(prev => prev + `
[System Error]: ${err.message}`);
setIsRunning(false);
}
};
const handleStop = async () => {
if (!pid) {
setLogs('');
setTerminalOpen(false);
return;
}
try {
await api.post(`/api/v1/ide/stop/${pid}`);
setLogs(prev => prev + '\n[Process stopped by user.]\n');
} catch (err) {
console.error("Failed to stop cleanly", err);
} finally {
setIsRunning(false);
setPid(null);
}
};
const handleDownload = async () => {
try {
const res = await api.post('/api/v1/ide/download', { user_id: getUserIdSync(), files }, { responseType: 'blob' });
const url = window.URL.createObjectURL(new Blob([res.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'workspace.zip');
document.body.appendChild(link);
link.click();
link.remove();
} catch (err) {
console.error("Failed to download workspace", err);
}
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
setChatImages(prev => [...prev, base64String]);
};
reader.readAsDataURL(file);
}
};
const handleVoiceRecord = () => {
// Use browser SpeechRecognition API
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
alert("Voice recording is not supported in your browser.");
return;
}
if (isRecording) return; // Prevent multiple instances
const recognition = new SpeechRecognition();
recognition.continuous = false;
recognition.interimResults = false;
recognition.onstart = () => {
setIsRecording(true);
};
recognition.onresult = (event: any) => {
const transcript = event.results[0][0].transcript;
setChatInput(prev => prev ? prev + ' ' + transcript : transcript);
setIsRecording(false);
};
recognition.onerror = (event: any) => {
console.error("Speech recognition error", event.error);
setIsRecording(false);
};
recognition.onend = () => {
setIsRecording(false);
};
recognition.start();
};
const handleChat = async (overrideMsg?: string, forceMode?: 'plan' | 'execute') => {
const userMsg = overrideMsg || chatInput;
if (!userMsg.trim() || isChatting) return;
if (!overrideMsg) {
setChatInput('');
setLastPrompt(userMsg);
setChatMessages(prev => [...prev, { role: 'user', content: userMsg }]);
}
setIsChatting(true);
// Add a placeholder AI message that we will stream into
setChatMessages(prev => [...prev, { role: 'ai', content: '', isStreaming: true, thoughts: [] }]);
try {
const currentMode = forceMode || (isPlanningMode ? 'plan' : 'execute');
// Build the body for fetch
const body = {
user_id: getUserIdSync(),
files: files,
prompt: userMsg,
model: llm,
chat_history: chatMessages.map(msg => ({ role: msg.role, content: msg.content })),
images: chatImages.length > 0 ? chatImages : undefined,
mode: currentMode
};
// Clear attached images after sending
setChatImages([]);
const token = localStorage.getItem('token');
const response = await fetch('/api/v1/ide/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
},
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No reader available");
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.slice(6);
try {
const data = JSON.parse(dataStr);
setChatMessages(prev => {
const newMsgs = [...prev];
if (newMsgs.length === 0) return prev;
const lastIndex = newMsgs.length - 1;
if (newMsgs[lastIndex].role !== 'ai') return prev;
// Must clone the last message object to avoid mutating state
const lastMsg = { ...newMsgs[lastIndex] };
if (data.type === 'status') {
lastMsg.status = data.content;
} else if (data.type === 'thought') {
lastMsg.thoughts = lastMsg.thoughts ? [...lastMsg.thoughts, data.content] : [data.content];
} else if (data.type === 'tool_call') {
lastMsg.content = (lastMsg.content || "") + `\n> 🛠️ Running Tool: ${data.tool}\n\`\`\`json\n${JSON.stringify(data.args, null, 2)}\n\`\`\`\n`;
} else if (data.type === 'tool_result') {
lastMsg.content = (lastMsg.content || "") + `\n*✅ Tool Complete*\n`;
} else if (data.type === 'message') {
lastMsg.content = (lastMsg.content ? lastMsg.content + "\n\n" : "") + data.content;
} else if (data.type === 'done') {
lastMsg.isStreaming = false;
} else if (data.type === 'error') {
lastMsg.content = (lastMsg.content || "") + `\n\n**Error:** ${data.content}`;
lastMsg.isStreaming = false;
}
newMsgs[lastIndex] = lastMsg;
return newMsgs;
});
if (data.type === 'sync_files' && data.files) {
setFiles(prev => ({ ...prev, ...data.files }));
setModifiedFiles(prev => {
const next = new Set(prev);
Object.keys(data.files).forEach(f => next.add(f));
return next;
});
}
} catch (e) {
console.error("Failed to parse SSE JSON", e, dataStr);
}
}
}
}
} catch (error: any) {
setChatMessages(prev => [...prev, { role: 'ai', content: `Error: ${error.message}` }]);
} finally {
setIsChatting(false);
setChatMessages(prev => {
const newMsgs = [...prev];
const lastMsg = newMsgs[newMsgs.length - 1];
if (lastMsg.role === 'ai') lastMsg.isStreaming = false;
return newMsgs;
});
}
};
// Sync chat messages to history whenever they change
useEffect(() => {
setChatHistory(prev => prev.map(chat =>
chat.id === activeChatId ? { ...chat, messages: chatMessages } : chat
));
}, [chatMessages, activeChatId]);
const createNewChat = () => {
const newId = Date.now().toString();
const initialMsg = { role: 'ai' as const, content: 'Hello! I am your Silicon Valley AI Architect. I have access to your full codebase. How would you like to upgrade your app today?' };
setChatHistory(prev => [{ id: newId, title: 'New Session', messages: [initialMsg] }, ...prev]);
setActiveChatId(newId);
setChatMessages([initialMsg]);
setShowChatHistoryPanel(false);
};
const switchChat = (id: string) => {
const chat = chatHistory.find(c => c.id === id);
if (chat) {
setActiveChatId(id);
setChatMessages(chat.messages);
setShowChatHistoryPanel(false);
}
};
const fileList = Object.keys(files).sort((a, b) => {
const aDepth = a.split('/').length;
const bDepth = b.split('/').length;
if (aDepth !== bDepth) return aDepth - bDepth;
return a.localeCompare(b);
});
// Build folder tree structure
const getFolders = (): string[] => {
const folders = new Set<string>();
fileList.forEach(f => {
const parts = f.split('/');
if (parts.length > 1) {
for (let i = 1; i < parts.length; i++) {
folders.add(parts.slice(0, i).join('/'));
}
}
});
return Array.from(folders).sort();
};
const folders = getFolders();
const toggleFolder = (folder: string) => {
setCollapsedFolders(prev => {
const next = new Set(prev);
if (next.has(folder)) next.delete(folder); else next.add(folder);
return next;
});
};
const isFileVisible = (filename: string): boolean => {
const parts = filename.split('/');
if (parts.length <= 1) return true;
for (let i = 1; i < parts.length; i++) {
const parentFolder = parts.slice(0, i).join('/');
if (collapsedFolders.has(parentFolder)) return false;
}
return true;
};
const handleCreateFile = (filename: string) => {
if (!filename.trim()) return;
setFiles(prev => ({ ...prev, [filename.trim()]: '' }));
setActiveFile(filename.trim());
setNewFileInput(null);
};
const handleDeleteFile = (filename: string) => {
setFiles(prev => {
const next = { ...prev };
delete next[filename];
return next;
});
if (activeFile === filename) {
const remaining = Object.keys(files).filter(f => f !== filename);
setActiveFile(remaining[0] || '');
}
};
const getFileIcon = (filename: string) => {
if (filename.endsWith('.py')) return 'text-blue-500 dark:text-[#4fc1ff]';
if (filename.endsWith('.html')) return 'text-orange-500 dark:text-[#e37933]';
if (filename.endsWith('.css')) return 'text-purple-500 dark:text-[#a855f7]';
if (filename.endsWith('.js') || filename.endsWith('.jsx')) return 'text-yellow-500 dark:text-[#cbcb41]';
if (filename.endsWith('.ts') || filename.endsWith('.tsx')) return 'text-blue-600 dark:text-[#3178c6]';
if (filename.endsWith('.json')) return 'text-yellow-600 dark:text-[#cbcb41]';
if (filename.endsWith('.md')) return 'text-blue-400 dark:text-[#519aba]';
if (filename.endsWith('.csv')) return 'text-green-600 dark:text-[#89d185]';
if (filename.endsWith('.txt') || filename.endsWith('.yml') || filename.endsWith('.yaml')) return 'text-slate-500 dark:text-[#cccccc]';
return 'text-slate-600 dark:text-[#cccccc]';
};
const getMonacoLanguage = (filename: string) => {
if (filename.endsWith('.py')) return 'python';
if (filename.endsWith('.js') || filename.endsWith('.jsx')) return 'javascript';
if (filename.endsWith('.ts') || filename.endsWith('.tsx')) return 'typescript';
if (filename.endsWith('.html')) return 'html';
if (filename.endsWith('.css')) return 'css';
if (filename.endsWith('.json')) return 'json';
if (filename.endsWith('.md')) return 'markdown';
if (filename.endsWith('.yml') || filename.endsWith('.yaml')) return 'yaml';
if (filename.endsWith('.sh') || filename.endsWith('.bash')) return 'shell';
if (filename.endsWith('.dockerfile') || filename === 'Dockerfile') return 'dockerfile';
return 'plaintext';
};
// Simple markdown-like renderer for chat messages
const renderChatContent = (content: string) => {
const parts = content.split(/(```[\s\S]*?```|`[^`]+`|\*\*[^*]+\*\*|\n)/g);
return parts.map((part, i) => {
if (part.startsWith('```') && part.endsWith('```')) {
const code = part.slice(3, -3).replace(/^\w+\n/, '');
return <pre key={i} className="bg-slate-900 text-green-400 text-[11px] p-2 rounded my-1 overflow-x-auto font-mono whitespace-pre-wrap">{code}</pre>;
}
if (part.startsWith('`') && part.endsWith('`')) {
return <code key={i} className="bg-slate-200 dark:bg-[#1e1e1e] text-pink-600 dark:text-[#ce9178] px-1 rounded text-[12px] font-mono">{part.slice(1, -1)}</code>;
}
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={i} className="font-semibold">{part.slice(2, -2)}</strong>;
}
if (part === '\n') return <br key={i} />;
return <span key={i}>{part}</span>;
});
};
const modalContent = (
<div className="fixed inset-0 z-[9999] flex flex-col bg-white dark:bg-[#1e1e1e] text-slate-800 dark:text-[#cccccc] font-sans h-screen w-screen overflow-hidden selection:bg-blue-200 dark:selection:bg-[#264f78]">
{/* VSCode Custom Titlebar */}
<div className="h-[30px] flex items-center justify-between bg-slate-100 dark:bg-[#333333] border-b border-slate-300 dark:border-[#2b2b2b] select-none px-3 shrink-0">
<div className="flex items-center gap-4 h-full">
<img src="/favicon.ico" alt="Logo" className="w-4 h-4 opacity-80" onError={(e) => e.currentTarget.style.display = 'none'} />
<div className="flex gap-4 text-[13px] text-slate-600 dark:text-[#cccccc]">
<span className="hover:text-black dark:hover:text-white cursor-pointer">File</span>
<span className="hover:text-black dark:hover:text-white cursor-pointer">Edit</span>
<span className="hover:text-black dark:hover:text-white cursor-pointer">Selection</span>
<span className="hover:text-black dark:hover:text-white cursor-pointer">View</span>
<span className="hover:text-black dark:hover:text-white cursor-pointer">Go</span>
<span className="hover:text-black dark:hover:text-white cursor-pointer" onClick={() => handleRun()}>Run</span>
<span className="hover:text-black dark:hover:text-white cursor-pointer">Help</span>
</div>
</div>
<div className="text-[12px] text-slate-500 dark:text-[#999999] absolute left-1/2 -translate-x-1/2 font-bold">
DataVision IDE Orchestrator
</div>
<div className="flex gap-2">
<button className="w-3.5 h-3.5 rounded-full bg-red-500 hover:bg-red-400" onClick={() => setShowCloseModal(true)} />
<div className="w-3.5 h-3.5 rounded-full bg-yellow-500" />
<div className="w-3.5 h-3.5 rounded-full bg-green-500" />
</div>
</div>
<div className="flex flex-1 min-h-0 relative">
{/* Orchestrator Hub (Center) */}
<div className="flex-1 flex flex-col items-center justify-center bg-[#f9f9f9] dark:bg-[#1e1e1e] p-8 text-slate-800 dark:text-[#cccccc]">
<img src="/favicon.ico" alt="DataVision" className="w-24 h-24 opacity-20 grayscale mb-8" onError={(e) => e.currentTarget.style.display = 'none'} />
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-4">DataVision IDE Orchestrator</h2>
<p className="text-slate-700 dark:text-slate-300 mb-8 text-center max-w-2xl text-base leading-relaxed">
DataVision is now a <strong className="text-blue-600 dark:text-blue-400">Local Project Orchestrator</strong>. <br/><br/>
Here is how it works:<br/>
1. The <strong className="text-slate-900 dark:text-white">DataVision Agent</strong> on the right writes the code and plans your architecture.<br/>
2. You click <strong className="text-slate-900 dark:text-white">"Open in [IDE]"</strong> below to view or edit the code in your preferred local environment.<br/>
3. You click <strong className="text-slate-900 dark:text-white">"RUN PROJECT"</strong> below to automatically detect frameworks, install dependencies, and start the local server.
<br/><br/>
<span className="text-[14px] text-slate-500 dark:text-slate-400 italic bg-slate-100 dark:bg-[#252526] px-4 py-2 rounded-lg border border-slate-200 dark:border-[#333] inline-block mt-2">
Prefer your own terminal? Click <strong>Copy Path</strong> below and run the project manually in your local laptop terminal!
</span>
</p>
<div className="flex flex-wrap justify-center gap-4 mb-8">
<a
href={`vscode://file/${workspacePath}`}
className="flex items-center gap-2 px-6 py-3 bg-[#007acc] hover:bg-[#005a9e] text-white rounded-lg font-semibold shadow-lg transition-colors"
style={{ color: '#ffffff' }}
>
<VSCodeIcon /> Open in VS Code
</a>
<a
href={`cursor://file/${workspacePath}`}
className="flex items-center gap-2 px-6 py-3 bg-slate-800 hover:bg-black text-white rounded-lg font-semibold shadow-lg border border-slate-700 transition-colors"
style={{ color: '#ffffff' }}
>
<CursorIcon /> Open in Cursor
</a>
<a
href={`antigravity://file/${workspacePath}`}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white rounded-lg font-semibold shadow-lg transition-colors"
style={{ color: '#ffffff' }}
>
<AntigravityIcon /> Open in Antigravity
</a>
<button
onClick={() => {
navigator.clipboard.writeText(workspacePath);
alert('Workspace path copied to clipboard!');
}}
className="flex items-center gap-2 px-6 py-3 bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-800 dark:text-white rounded-lg font-semibold shadow-lg transition-colors"
>
<FolderOpen className="w-5 h-5" /> Copy Path
</button>
</div>
{/* Console & Execution Area */}
<div className="w-full max-w-3xl bg-white dark:bg-[#252526] rounded-xl border border-slate-200 dark:border-[#333] overflow-hidden flex flex-col shadow-lg">
<div className="px-4 py-3 border-b border-slate-200 dark:border-[#333] flex justify-between items-center bg-slate-50 dark:bg-[#2d2d2d]">
<div className="flex items-center gap-2 font-semibold text-slate-700 dark:text-[#ccc]">
<PlaySquare className="w-4 h-4 text-emerald-500" /> Local Server Orchestration
</div>
<div className="flex gap-2">
{!isRunning ? (
<button onClick={() => handleRun()} className="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded font-bold flex items-center gap-2 shadow transition-colors text-sm">
<Play className="w-4 h-4 fill-white" /> RUN PROJECT
</button>
) : (
<>
{runningUrl && (
<a href={runningUrl} target="_blank" rel="noopener noreferrer" className="px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded font-bold flex items-center gap-2 shadow transition-colors text-sm">
<ExternalLink className="w-4 h-4" /> OPEN APP
</a>
)}
<button onClick={handleStop} className="px-4 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded font-bold flex items-center gap-2 shadow transition-colors text-sm">
<Square className="w-4 h-4 fill-white" /> STOP
</button>
</>
)}
</div>
</div>
<div className="h-64 p-4 font-mono text-[13px] overflow-y-auto bg-slate-50 dark:bg-[#0d0d0d] text-slate-800 dark:text-emerald-400 whitespace-pre-wrap leading-relaxed shadow-inner border-t border-slate-200 dark:border-transparent">
{logs || "[Standby] Ready to orchestrate local project...\\nClick 'RUN PROJECT' to automatically detect frameworks and start the server."}
<div ref={logsEndRef} />
</div>
</div>
</div>
{/* Right Panel: AI Architect (Unchanged) */}
<div className={`border-l border-slate-300 dark:border-[#333333] bg-[#f3f3f3] dark:bg-[#252526] flex flex-col transition-all duration-300 ease-in-out shrink-0 overflow-hidden ${rightPanelOpen ? 'w-[350px] opacity-100' : 'w-0 opacity-0 border-l-0'}`}>
<div className="px-4 flex justify-between items-center shrink-0 h-[40px] border-b border-slate-300 dark:border-[#333333]">
<div className="flex items-center gap-2">
<span className="text-[11px] font-bold text-slate-600 dark:text-[#cccccc] uppercase tracking-widest flex items-center gap-2">
<MessageSquare className="w-3.5 h-3.5" /> DataVision Agent
</span>
</div>
<div className="flex items-center gap-2">
<select
value={llm}
onChange={(e) => setLlm(e.target.value)}
className="bg-transparent text-[10px] text-slate-500 dark:text-[#858585] outline-none cursor-pointer hover:text-slate-800 dark:hover:text-[#ccc]"
title="Select AI Model"
>
<option value="llama">Llama 3.3 70B</option>
<option value="deepseek">DeepSeek V4</option>
<option value="nemotron">Nemotron Ultra</option>
<option value="glm">GLM 5.1</option>
<option value="kimi">Kimi 2.6</option>
</select>
<button onClick={() => setShowChatHistoryPanel(!showChatHistoryPanel)} className="text-slate-500 hover:text-blue-500 dark:text-[#858585] dark:hover:text-[#007acc] transition-colors" title="Chat History">
<Files className="w-4 h-4" />
</button>
<button onClick={createNewChat} className="text-slate-500 hover:text-emerald-500 dark:text-[#858585] dark:hover:text-emerald-400 transition-colors" title="New Chat">
<Plus className="w-4 h-4" />
</button>
<div className="w-px h-3 bg-slate-300 dark:bg-[#444] mx-1"></div>
<X className="w-4 h-4 text-slate-500 dark:text-[#858585] hover:text-black dark:hover:text-[#cccccc] cursor-pointer transition-colors" onClick={() => setRightPanelOpen(false)}/>
</div>
</div>
{showChatHistoryPanel ? (
<div className="flex-1 flex flex-col bg-white dark:bg-[#1e1e1e] border-b border-slate-200 dark:border-[#333]">
<div className="p-3 border-b border-slate-200 dark:border-[#333]">
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="Search history..."
value={chatSearchQuery}
onChange={e => setChatSearchQuery(e.target.value)}
className="w-full bg-slate-100 dark:bg-[#2d2d2d] text-[12px] text-slate-800 dark:text-[#ccc] pl-8 pr-3 py-1.5 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1 scrollbar-thin scrollbar-thumb-slate-300 dark:scrollbar-thumb-[#424242]">
{chatHistory.filter(c => c.title.toLowerCase().includes(chatSearchQuery.toLowerCase())).map(chat => (
<div
key={chat.id}
onClick={() => switchChat(chat.id)}
className={`p-2.5 rounded cursor-pointer text-[12px] transition-colors ${activeChatId === chat.id ? 'bg-blue-50 dark:bg-[#2d2d30] text-blue-600 dark:text-[#fff]' : 'text-slate-600 dark:text-[#ccc] hover:bg-slate-50 dark:hover:bg-[#2d2d2d]'}`}
>
<div className="font-medium truncate mb-1">{chat.title}</div>
<div className="text-[10px] text-slate-400 dark:text-[#858585] truncate">
{chat.messages.length > 0 ? chat.messages[chat.messages.length - 1].content : 'Empty session'}
</div>
</div>
))}
</div>
</div>
) : (
<div className="flex-1 overflow-y-auto p-4 space-y-5 scrollbar-thin scrollbar-thumb-slate-300 dark:scrollbar-thumb-[#424242]">
{chatMessages.map((msg, i) => (
<div key={i} className={`flex flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}>
<div className={`text-[11px] font-semibold text-slate-500 dark:text-[#858585] mb-1.5 flex items-center gap-1.5`}>
{msg.role === 'user' ? <UserCircle className="w-3.5 h-3.5" /> : <div className="w-4 h-4 rounded bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center"><Loader2 className="w-2.5 h-2.5 text-white" /></div>}
{msg.role === 'user' ? 'You' : 'DataVision Agent'}
{msg.role === 'user' && (
<button onClick={() => setChatInput(msg.content)} className="ml-2 p-0.5 hover:bg-slate-200 dark:hover:bg-[#444] rounded text-blue-500 transition-colors" title="Edit Query">
<Edit3 className="w-3 h-3" />
</button>
)}
</div>
<div className={`text-[13px] leading-relaxed max-w-[95%] p-3 rounded-xl shadow-sm ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-br-none' : 'bg-white dark:bg-[#333333] text-slate-800 dark:text-[#cccccc] rounded-bl-none border border-slate-200 dark:border-transparent'}`}>
{msg.role === 'ai' && msg.thoughts && msg.thoughts.length > 0 && (
<details className="mb-3 text-[11px] bg-slate-50 dark:bg-[#252526] rounded border border-slate-200 dark:border-[#444]">
<summary className="cursor-pointer font-semibold text-slate-500 dark:text-[#888] px-2 py-1.5 hover:bg-slate-100 dark:hover:bg-[#2d2d2d] rounded">
Agent Thoughts ({msg.thoughts.length})
</summary>
<div className="p-2 pt-0 text-slate-600 dark:text-[#aaa] border-t border-slate-200 dark:border-[#444] space-y-2 font-mono whitespace-pre-wrap">
{msg.thoughts.map((t, idx) => <div key={idx}>{t}</div>)}
</div>
</details>
)}
{msg.role === 'ai' && msg.status && (
<div className="text-[11px] font-mono text-blue-500 dark:text-[#4fc1ff] mb-2 animate-pulse flex items-center gap-1.5">
<Loader2 className="w-3 h-3 animate-spin" /> {msg.status}
</div>
)}
{msg.role === 'ai' ? renderChatContent(msg.content) : msg.content}
{msg.role === 'ai' && msg.is_plan && (
<div className="mt-3 pt-3 border-t border-slate-200 dark:border-[#555] flex justify-end">
<button onClick={() => handleChat(lastPrompt, 'execute')} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-[12px] font-semibold rounded shadow-sm flex items-center gap-1.5 transition-colors">
<PlaySquare className="w-3.5 h-3.5" /> Build It
</button>
</div>
)}
</div>
</div>
))}
<div ref={chatEndRef} />
</div>
)}
<div className="p-3 bg-white dark:bg-[#252526] shrink-0 border-t border-slate-200 dark:border-transparent">
<form onSubmit={(e) => { e.preventDefault(); handleChat(); }} className="relative flex flex-col border border-slate-300 dark:border-[#3c3c3c] bg-white dark:bg-[#3c3c3c] rounded focus-within:border-blue-600 dark:focus-within:border-[#007acc] transition-colors shadow-sm dark:shadow-none">
{chatImages.length > 0 && (
<div className="flex gap-2 p-2 border-b border-slate-200 dark:border-[#555] overflow-x-auto">
{chatImages.map((img, i) => (
<div key={i} className="relative w-12 h-12 shrink-0">
<img src={img} alt={`attachment-${i}`} className="w-full h-full object-cover rounded" />
<button
type="button"
onClick={() => setChatImages(prev => prev.filter((_, index) => index !== i))}
className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full p-0.5 hover:bg-red-600"
>
<X className="w-2 h-2" />
</button>
</div>
))}
</div>
)}
<textarea
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
placeholder="Ask AI to modify code..."
disabled={isChatting}
rows={2}
className="w-full bg-transparent text-[13px] text-slate-800 dark:text-[#cccccc] pl-3 pr-24 py-2 focus:outline-none disabled:opacity-50 resize-none"
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleChat(); } }}
/>
<div className="absolute right-2 bottom-2 flex items-center gap-1">
<label className="p-1 hover:bg-slate-100 dark:hover:bg-[#505050] text-slate-600 dark:text-[#cccccc] rounded cursor-pointer transition-colors" title="Attach Image">
<ImagePlus className="w-3.5 h-3.5" />
<input type="file" accept="image/*" onChange={handleImageUpload} className="hidden" />
</label>
<button
type="button"
onClick={handleVoiceRecord}
className={`p-1 hover:bg-slate-100 dark:hover:bg-[#505050] rounded transition-colors ${isRecording ? 'text-red-500 animate-pulse bg-red-50 dark:bg-red-900/30' : 'text-slate-600 dark:text-[#cccccc]'}`}
title="Voice Dictation"
>
<Mic className="w-3.5 h-3.5" />
</button>
<button type="submit" disabled={!chatInput.trim() || isChatting} className="p-1 hover:bg-slate-100 dark:hover:bg-[#505050] text-slate-600 dark:text-[#cccccc] rounded disabled:opacity-50 transition-colors">
<Send className="w-3.5 h-3.5" />
</button>
</div>
</form>
</div>
</div>
</div>
{/* Close Modal */}
{showCloseModal && (
<div className="fixed inset-0 z-[10000] bg-black/50 flex items-center justify-center">
<div className="bg-white dark:bg-[#252526] p-6 rounded-lg shadow-xl max-w-md w-full mx-4 border border-slate-200 dark:border-[#333333]">
<h3 className="text-lg font-semibold mb-4 text-slate-800 dark:text-white">Session Management</h3>
<p className="text-slate-600 dark:text-[#cccccc] mb-6 text-sm leading-relaxed">
How would you like to handle your current workspace?
{isRunning && <><br/><br/><strong className="text-emerald-600 dark:text-emerald-400">Note:</strong> You have a server process currently running.</>}
</p>
<div className="flex flex-col gap-3">
<button
onClick={() => {
setShowCloseModal(false);
if (onClose) onClose();
}}
className="w-full px-4 py-2.5 text-sm font-semibold bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors text-left flex items-center justify-between"
>
<span>Keep Session Running in Background</span>
<span className="text-[10px] opacity-75 font-normal uppercase tracking-wider border border-white/30 px-1.5 rounded">Recommended</span>
</button>
<button
onClick={async () => {
setShowCloseModal(false);
await handleStop();
if (onClose) onClose();
}}
className="w-full px-4 py-2.5 text-sm font-semibold bg-red-600 hover:bg-red-700 text-white rounded transition-colors text-left flex items-center justify-between"
>
<span>End Session & Close IDE</span>
<span className="text-[10px] opacity-75 font-normal uppercase tracking-wider border border-white/30 px-1.5 rounded">Kills Process</span>
</button>
<button
onClick={() => setShowCloseModal(false)}
className="w-full px-4 py-2 text-sm text-slate-600 dark:text-[#cccccc] hover:bg-slate-100 dark:hover:bg-[#333333] rounded transition-colors mt-2"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
if (typeof document !== 'undefined') {
return createPortal(
<WebIDEErrorBoundary>
{modalContent}
</WebIDEErrorBoundary>,
document.body
);
}
return null;
};