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)',
},
}}
>
)}
);
}