import { useCallback, useRef, useEffect } from 'react'; import { Avatar, Box, Drawer, Typography, IconButton, Alert, AlertTitle, useMediaQuery, useTheme, } from '@mui/material'; import MenuIcon from '@mui/icons-material/Menu'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined'; import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined'; import { logger } from '@/utils/logger'; import { useSessionStore } from '@/store/sessionStore'; import { useAgentStore } from '@/store/agentStore'; import { useLayoutStore } from '@/store/layoutStore'; import { useAgentWebSocket } from '@/hooks/useAgentWebSocket'; import SessionSidebar from '@/components/SessionSidebar/SessionSidebar'; import CodePanel from '@/components/CodePanel/CodePanel'; import ChatInput from '@/components/Chat/ChatInput'; import MessageList from '@/components/Chat/MessageList'; import WelcomeScreen from '@/components/WelcomeScreen/WelcomeScreen'; import { apiFetch } from '@/utils/api'; import type { Message } from '@/types/agent'; const DRAWER_WIDTH = 260; export default function AppLayout() { const { sessions, activeSessionId, deleteSession, updateSessionTitle } = useSessionStore(); const { isConnected, isProcessing, getMessages, addMessage, setProcessing, llmHealthError, setLlmHealthError, user } = useAgentStore(); const { isLeftSidebarOpen, isRightPanelOpen, rightPanelWidth, themeMode, setRightPanelWidth, setLeftSidebarOpen, toggleLeftSidebar, toggleTheme, } = useLayoutStore(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isResizing = useRef(false); const handleMouseMove = useCallback((e: MouseEvent) => { if (!isResizing.current) return; const newWidth = window.innerWidth - e.clientX; const maxWidth = window.innerWidth * 0.6; const minWidth = 300; if (newWidth > minWidth && newWidth < maxWidth) { setRightPanelWidth(newWidth); } }, [setRightPanelWidth]); const stopResizing = useCallback(() => { isResizing.current = false; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', stopResizing); document.body.style.cursor = 'default'; }, [handleMouseMove]); const startResizing = useCallback((e: React.MouseEvent) => { e.preventDefault(); isResizing.current = true; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', stopResizing); document.body.style.cursor = 'col-resize'; }, [handleMouseMove, stopResizing]); useEffect(() => { return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', stopResizing); }; }, [handleMouseMove, stopResizing]); // ── LLM health check on mount ─────────────────────────────────── useEffect(() => { let cancelled = false; (async () => { try { const res = await apiFetch('/api/health/llm'); const data = await res.json(); if (!cancelled && data.status === 'error') { setLlmHealthError({ error: data.error || 'Unknown LLM error', errorType: data.error_type || 'unknown', model: data.model, }); } else if (!cancelled) { setLlmHealthError(null); } } catch { // Backend unreachable — not an LLM issue, ignore } })(); return () => { cancelled = true; }; }, []); // eslint-disable-line react-hooks/exhaustive-deps const messages = activeSessionId ? getMessages(activeSessionId) : []; const hasAnySessions = sessions.length > 0; useAgentWebSocket({ sessionId: activeSessionId, onReady: () => logger.log('Agent ready'), onError: (error) => logger.error('Agent error:', error), onSessionDead: (deadSessionId) => { logger.log('Removing dead session:', deadSessionId); deleteSession(deadSessionId); }, }); const handleSendMessage = useCallback( async (text: string) => { if (!activeSessionId || !text.trim() || isProcessing) return; // Lock input immediately to prevent double-sends setProcessing(true); const userMsg: Message = { id: `user_${Date.now()}`, role: 'user', content: text.trim(), timestamp: new Date().toISOString(), }; addMessage(activeSessionId, userMsg); // Auto-title the session from the first user message (async, non-blocking) const currentMessages = getMessages(activeSessionId); const isFirstMessage = currentMessages.filter((m) => m.role === 'user').length <= 1; if (isFirstMessage) { const sessionId = activeSessionId; apiFetch('/api/title', { method: 'POST', body: JSON.stringify({ session_id: sessionId, text: text.trim() }), }) .then((res) => res.json()) .then((data) => { if (data.title) updateSessionTitle(sessionId, data.title); }) .catch(() => { const raw = text.trim(); updateSessionTitle(sessionId, raw.length > 40 ? raw.slice(0, 40) + '…' : raw); }); } try { await apiFetch('/api/submit', { method: 'POST', body: JSON.stringify({ session_id: activeSessionId, text: text.trim(), }), }); } catch (e) { logger.error('Send failed:', e); } }, [activeSessionId, addMessage, getMessages, updateSessionTitle, isProcessing, setProcessing] ); // Close sidebar on mobile after selecting a session const handleSidebarClose = useCallback(() => { if (isMobile) setLeftSidebarOpen(false); }, [isMobile, setLeftSidebarOpen]); // ── LLM error banner (shared) ───────────────────────────────────── const llmBanner = llmHealthError && ( setLlmHealthError(null)} sx={{ borderRadius: 0, flexShrink: 0, '& .MuiAlert-message': { flex: 1 } }} > {llmHealthError.errorType === 'credits' ? 'API Credits Exhausted' : llmHealthError.errorType === 'auth' ? 'Invalid API Key' : llmHealthError.errorType === 'rate_limit' ? 'Rate Limited' : llmHealthError.errorType === 'network' ? 'LLM Provider Unreachable' : 'LLM Error'} Model: {llmHealthError.model} — {llmHealthError.error.slice(0, 200)} ); // ── Welcome screen: no sessions at all ──────────────────────────── if (!hasAnySessions) { return ( {llmBanner} ); } // ── Sidebar drawer ──────────────────────────────────────────────── const sidebarDrawer = ( setLeftSidebarOpen(false)} ModalProps={{ keepMounted: true }} // Better mobile perf sx={{ '& .MuiDrawer-paper': { boxSizing: 'border-box', width: DRAWER_WIDTH, borderRight: '1px solid', borderColor: 'divider', top: 0, height: '100%', bgcolor: 'var(--panel)', }, }} > ); // ── Main chat interface ─────────────────────────────────────────── return ( {/* ── Left Sidebar ─────────────────────────────────────────── */} {isMobile ? ( // Mobile: temporary overlay drawer (no reserved width) sidebarDrawer ) : ( // Desktop: persistent drawer with reserved width {sidebarDrawer} )} {/* ── Main Content (header + chat + code panel) ────────────── */} {/* ── Top Header Bar ─────────────────────────────────────── */} {isLeftSidebarOpen && !isMobile ? : } ML Agent {themeMode === 'dark' ? : } {user?.picture ? ( ) : user?.username ? ( {user.username[0].toUpperCase()} ) : null} {/* ── LLM Health Error Banner ────────────────────────────── */} {llmBanner} {/* ── Chat + Code Panel ──────────────────────────────────── */} {/* Chat area */} {activeSessionId ? ( <> {!isConnected && messages.length > 0 && ( Session expired — create a new session to continue. )} ) : ( NO SESSION SELECTED Initialize a session via the sidebar )} {/* Code panel — inline on desktop, overlay drawer on mobile */} {isRightPanelOpen && !isMobile && ( <> )} {/* Code panel — drawer overlay on mobile */} {isMobile && ( useLayoutStore.getState().setRightPanelOpen(false)} sx={{ '& .MuiDrawer-paper': { height: '75vh', borderTopLeftRadius: 16, borderTopRightRadius: 16, bgcolor: 'var(--panel)', }, }} > )} ); }