akseljoonas's picture
akseljoonas HF Staff
feat: per-session WebSocket architecture for parallel chat sessions
854c261
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<ReturnType<typeof setTimeout> | 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) => {
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 (
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
<WelcomeScreen />
</Box>
);
}
// -- Sidebar drawer -----------------------------------------------------
const sidebarDrawer = (
<Drawer
variant={isMobile ? 'temporary' : 'persistent'}
anchor="left"
open={isLeftSidebarOpen}
onClose={() => 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)',
},
}}
>
<SessionSidebar onClose={handleSidebarClose} />
</Drawer>
);
// -- Main chat interface ------------------------------------------------
return (
<Box sx={{ display: 'flex', width: '100%', height: '100%' }}>
{/* -- Left Sidebar ------------------------------------------------- */}
{isMobile ? (
sidebarDrawer
) : (
<Box
component="nav"
sx={{
width: isLeftSidebarOpen ? DRAWER_WIDTH : 0,
flexShrink: 0,
transition: isResizing.current ? 'none' : 'width 0.2s',
overflow: 'hidden',
}}
>
{sidebarDrawer}
</Box>
)}
{/* -- Main Content (header + chat + code panel) -------------------- */}
<Box
sx={{
flexGrow: 1,
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: isResizing.current ? 'none' : 'width 0.2s',
overflow: 'hidden',
minWidth: 0,
}}
>
{/* -- Top Header Bar --------------------------------------------- */}
<Box sx={{
height: { xs: 52, md: 60 },
px: { xs: 1, md: 2 },
display: 'flex',
alignItems: 'center',
borderBottom: 1,
borderColor: 'divider',
bgcolor: 'background.default',
zIndex: 1200,
flexShrink: 0,
}}>
<IconButton onClick={toggleLeftSidebar} size="small">
{isLeftSidebarOpen && !isMobile ? <ChevronLeftIcon /> : <MenuIcon />}
</IconButton>
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 0.75 }}>
<Box
component="img"
src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg"
alt="HF"
sx={{ width: { xs: 20, md: 22 }, height: { xs: 20, md: 22 } }}
/>
<Typography
variant="subtitle1"
sx={{
fontWeight: 700,
color: 'var(--text)',
letterSpacing: '-0.01em',
fontSize: { xs: '0.88rem', md: '0.95rem' },
}}
>
HF Agent
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<IconButton
onClick={toggleTheme}
size="small"
sx={{
color: 'text.secondary',
'&:hover': { color: 'primary.main' },
}}
>
{themeMode === 'dark' ? <LightModeOutlinedIcon fontSize="small" /> : <DarkModeOutlinedIcon fontSize="small" />}
</IconButton>
{user?.picture ? (
<Avatar
src={user.picture}
alt={user.username || 'User'}
sx={{ width: 28, height: 28, ml: 0.5 }}
/>
) : user?.username ? (
<Avatar
sx={{
width: 28,
height: 28,
ml: 0.5,
bgcolor: 'primary.main',
fontSize: '0.75rem',
fontWeight: 700,
}}
>
{user.username[0].toUpperCase()}
</Avatar>
) : null}
</Box>
</Box>
{/* -- Chat + Code Panel ------------------------------------------ */}
<Box
sx={{
flexGrow: 1,
display: 'flex',
overflow: 'hidden',
}}
>
{/* Chat area */}
<Box
component="main"
className="chat-pane"
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
background: 'var(--body-gradient)',
p: { xs: 1.5, sm: 2, md: 3 },
minWidth: 0,
}}
>
{activeSessionId ? (
// Render ALL sessions — each owns its own useAgentChat.
// Only the active one renders visible UI (others return null).
sessions.map((s) => (
<SessionChat
key={s.id}
sessionId={s.id}
isActive={s.id === activeSessionId}
onSessionDead={handleSessionDead}
/>
))
) : (
<Box
sx={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 2,
px: 2,
}}
>
<Typography variant="h5" color="text.secondary" sx={{ fontFamily: 'monospace', fontSize: { xs: '1rem', md: '1.5rem' } }}>
NO SESSION SELECTED
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontFamily: 'monospace', fontSize: { xs: '0.75rem', md: '0.875rem' } }}>
Initialize a session via the sidebar
</Typography>
</Box>
)}
</Box>
{/* Code panel -- inline on desktop, overlay drawer on mobile */}
{isRightPanelOpen && !isMobile && (
<>
<Box
onMouseDown={startResizing}
sx={{
width: '4px',
cursor: 'col-resize',
bgcolor: 'divider',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background-color 0.2s',
flexShrink: 0,
'&:hover': { bgcolor: 'primary.main' },
}}
>
<DragIndicatorIcon
sx={{ fontSize: '0.8rem', color: 'text.secondary', pointerEvents: 'none' }}
/>
</Box>
<Box
sx={{
width: rightPanelWidth,
flexShrink: 0,
height: '100%',
overflow: 'hidden',
borderLeft: '1px solid',
borderColor: 'divider',
bgcolor: 'var(--panel)',
}}
>
<CodePanel />
</Box>
</>
)}
</Box>
</Box>
{/* Code panel -- drawer overlay on mobile */}
{isMobile && (
<Drawer
anchor="bottom"
open={isRightPanelOpen}
onClose={() => useLayoutStore.getState().setRightPanelOpen(false)}
sx={{
'& .MuiDrawer-paper': {
height: '75vh',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
bgcolor: 'var(--panel)',
},
}}
>
<CodePanel />
</Drawer>
)}
<Snackbar
open={showExpiredToast}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
onClose={() => setShowExpiredToast(false)}
>
<Alert
severity="warning"
variant="filled"
onClose={() => setShowExpiredToast(false)}
sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}
>
Task expired — create a new task to continue.
</Alert>
</Snackbar>
<Snackbar
open={!!llmHealthError}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
onClose={() => setLlmHealthError(null)}
>
<Alert
severity="error"
variant="filled"
onClose={() => setLlmHealthError(null)}
sx={{ fontSize: '0.8rem', maxWidth: 480 }}
>
<AlertTitle sx={{ fontWeight: 700, fontSize: '0.85rem' }}>
{llmErrorTitle}
</AlertTitle>
{llmHealthError && (
<Typography variant="body2" sx={{ fontSize: '0.78rem', opacity: 0.9 }}>
{llmHealthError.model} — {llmHealthError.error.slice(0, 150)}
</Typography>
)}
</Alert>
</Snackbar>
</Box>
);
}