import { useCallback, useRef, useEffect, useState } from 'react'; import { Avatar, Box, Drawer, Typography, IconButton, Alert, AlertTitle, Snackbar, 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 { useSessionStore } from '@/store/sessionStore'; import { useAgentStore } from '@/store/agentStore'; import { useLayoutStore } from '@/store/layoutStore'; import SessionSidebar from '@/components/SessionSidebar/SessionSidebar'; import SessionChat from '@/components/SessionChat'; import CodePanel from '@/components/CodePanel/CodePanel'; import WelcomeScreen from '@/components/WelcomeScreen/WelcomeScreen'; import { apiFetch } from '@/utils/api'; const DRAWER_WIDTH = 260; export default function AppLayout() { const { sessions, activeSessionId, deleteSession } = useSessionStore(); const { isConnected, 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 [showExpiredToast, setShowExpiredToast] = useState(false); const disconnectTimer = useRef | null>(null); 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 hasAnySessions = sessions.length > 0; // Debounced "session expired" toast useEffect(() => { if (!isConnected && activeSessionId) { disconnectTimer.current = setTimeout(() => setShowExpiredToast(true), 2000); } else { if (disconnectTimer.current) clearTimeout(disconnectTimer.current); disconnectTimer.current = null; setShowExpiredToast(false); } return () => { if (disconnectTimer.current) clearTimeout(disconnectTimer.current); }; }, [isConnected, activeSessionId]); const handleSessionDead = useCallback( (deadSessionId: string) => { useAgentStore.getState().clearSessionState(deadSessionId); deleteSession(deadSessionId); }, [deleteSession], ); // Close sidebar on mobile after selecting a session const handleSidebarClose = useCallback(() => { if (isMobile) setLeftSidebarOpen(false); }, [isMobile, setLeftSidebarOpen]); // -- LLM error toast helper -------------------------------------------- const llmErrorTitle = llmHealthError ? 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' : ''; // -- Welcome screen: no sessions at all --------------------------------- if (!hasAnySessions) { return ( ); } // -- Sidebar drawer ----------------------------------------------------- const sidebarDrawer = ( setLeftSidebarOpen(false)} ModalProps={{ keepMounted: true }} 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 ? ( sidebarDrawer ) : ( {sidebarDrawer} )} {/* -- Main Content (header + chat + code panel) -------------------- */} {/* -- Top Header Bar --------------------------------------------- */} {isLeftSidebarOpen && !isMobile ? : } HF Agent {themeMode === 'dark' ? : } {user?.picture ? ( ) : user?.username ? ( {user.username[0].toUpperCase()} ) : null} {/* -- Chat + Code Panel ------------------------------------------ */} {/* Chat area */} {activeSessionId ? ( // Render ALL sessions — each owns its own useAgentChat. // Only the active one renders visible UI (others return null). sessions.map((s) => ( )) ) : ( 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)', }, }} > )} setShowExpiredToast(false)} > setShowExpiredToast(false)} sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }} > Task expired — create a new task to continue. setLlmHealthError(null)} > setLlmHealthError(null)} sx={{ fontSize: '0.8rem', maxWidth: 480 }} > {llmErrorTitle} {llmHealthError && ( {llmHealthError.model} — {llmHealthError.error.slice(0, 150)} )} ); }