| import { useCallback, useState } from 'react';
|
| import {
|
| Alert,
|
| Box,
|
| IconButton,
|
| Typography,
|
| CircularProgress,
|
| Divider,
|
| } from '@mui/material';
|
| import AddIcon from '@mui/icons-material/Add';
|
| import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
| import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline';
|
| import { useSessionStore } from '@/store/sessionStore';
|
| import { useAgentStore } from '@/store/agentStore';
|
| import { apiFetch } from '@/utils/api';
|
|
|
| interface SessionSidebarProps {
|
| onClose?: () => void;
|
| }
|
|
|
|
|
| const StatusDot = ({ connected }: { connected: boolean }) => (
|
| <Box
|
| sx={{
|
| width: 6,
|
| height: 6,
|
| borderRadius: '50%',
|
| bgcolor: connected ? 'var(--accent-green)' : 'var(--accent-red)',
|
| boxShadow: connected ? '0 0 4px rgba(76,175,80,0.4)' : 'none',
|
| flexShrink: 0,
|
| }}
|
| />
|
| );
|
|
|
| export default function SessionSidebar({ onClose }: SessionSidebarProps) {
|
| const { sessions, activeSessionId, createSession, deleteSession, switchSession } =
|
| useSessionStore();
|
| const { isConnected, setPlan, setPanelContent } =
|
| useAgentStore();
|
| const [isCreatingSession, setIsCreatingSession] = useState(false);
|
| const [capacityError, setCapacityError] = useState<string | null>(null);
|
|
|
|
|
|
|
| const handleNewSession = useCallback(async () => {
|
| if (isCreatingSession) return;
|
| setIsCreatingSession(true);
|
| setCapacityError(null);
|
| try {
|
| const response = await apiFetch('/api/session', { method: 'POST' });
|
| if (response.status === 503) {
|
| const data = await response.json();
|
| setCapacityError(data.detail || 'Server is at capacity.');
|
| return;
|
| }
|
| const data = await response.json();
|
| createSession(data.session_id);
|
| setPlan([]);
|
| setPanelContent(null);
|
| onClose?.();
|
| } catch {
|
| setCapacityError('Failed to create session.');
|
| } finally {
|
| setIsCreatingSession(false);
|
| }
|
| }, [isCreatingSession, createSession, setPlan, setPanelContent, onClose]);
|
|
|
| const handleDelete = useCallback(
|
| async (sessionId: string, e: React.MouseEvent) => {
|
| e.stopPropagation();
|
| try {
|
| await apiFetch(`/api/session/${sessionId}`, { method: 'DELETE' });
|
| deleteSession(sessionId);
|
| } catch {
|
|
|
| deleteSession(sessionId);
|
| }
|
| },
|
| [deleteSession],
|
| );
|
|
|
| const handleSelect = useCallback(
|
| (sessionId: string) => {
|
| switchSession(sessionId);
|
| setPlan([]);
|
| setPanelContent(null);
|
| onClose?.();
|
| },
|
| [switchSession, setPlan, setPanelContent, onClose],
|
| );
|
|
|
| const formatTime = (d: string) =>
|
| new Date(d).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
|
|
|
|
|
| return (
|
| <Box
|
| sx={{
|
| height: '100%',
|
| display: 'flex',
|
| flexDirection: 'column',
|
| bgcolor: 'var(--panel)',
|
| }}
|
| >
|
| {/* ββ Header βββββββββββββββββββββββββββββββββββββββββββββββββββ */}
|
| <Box sx={{ px: 1.75, pt: 2, pb: 0 }}>
|
| <Typography
|
| variant="caption"
|
| sx={{
|
| color: 'var(--muted-text)',
|
| fontSize: '0.65rem',
|
| fontWeight: 600,
|
| textTransform: 'uppercase',
|
| letterSpacing: '0.08em',
|
| }}
|
| >
|
| Recent chats
|
| </Typography>
|
| </Box>
|
|
|
| {/* ββ Capacity error βββββββββββββββββββββββββββββββββββββββββββ */}
|
| {capacityError && (
|
| <Alert
|
| severity="warning"
|
| variant="outlined"
|
| onClose={() => setCapacityError(null)}
|
| sx={{
|
| m: 1,
|
| fontSize: '0.7rem',
|
| py: 0.25,
|
| '& .MuiAlert-message': { py: 0 },
|
| borderColor: '#FF9D00',
|
| color: 'var(--text)',
|
| }}
|
| >
|
| {capacityError}
|
| </Alert>
|
| )}
|
|
|
| {/* ββ Session list βββββββββββββββββββββββββββββββββββββββββββββ */}
|
| <Box
|
| sx={{
|
| flex: 1,
|
| overflow: 'auto',
|
| py: 1,
|
| // Thinner scrollbar
|
| '&::-webkit-scrollbar': { width: 4 },
|
| '&::-webkit-scrollbar-thumb': {
|
| bgcolor: 'var(--scrollbar-thumb)',
|
| borderRadius: 2,
|
| },
|
| }}
|
| >
|
| {sessions.length === 0 ? (
|
| <Box
|
| sx={{
|
| display: 'flex',
|
| flexDirection: 'column',
|
| alignItems: 'center',
|
| justifyContent: 'center',
|
| py: 8,
|
| px: 3,
|
| gap: 1.5,
|
| }}
|
| >
|
| <ChatBubbleOutlineIcon
|
| sx={{ fontSize: 28, color: 'var(--muted-text)', opacity: 0.25 }}
|
| />
|
| <Typography
|
| variant="caption"
|
| sx={{
|
| color: 'var(--muted-text)',
|
| opacity: 0.5,
|
| textAlign: 'center',
|
| lineHeight: 1.5,
|
| fontSize: '0.72rem',
|
| }}
|
| >
|
| No sessions yet
|
| </Typography>
|
| </Box>
|
| ) : (
|
| [...sessions].reverse().map((session, index) => {
|
| const num = sessions.length - index;
|
| const isSelected = session.id === activeSessionId;
|
|
|
| return (
|
| <Box
|
| key={session.id}
|
| onClick={() => handleSelect(session.id)}
|
| sx={{
|
| display: 'flex',
|
| alignItems: 'center',
|
| gap: 1,
|
| px: 1.5,
|
| py: 0.875,
|
| mx: 0.75,
|
| borderRadius: '10px',
|
| cursor: 'pointer',
|
| transition: 'background-color 0.12s ease',
|
| bgcolor: isSelected
|
| ? 'var(--hover-bg)'
|
| : 'transparent',
|
| '&:hover': {
|
| bgcolor: 'var(--hover-bg)',
|
| },
|
| '& .delete-btn': {
|
| opacity: 0,
|
| transition: 'opacity 0.12s',
|
| },
|
| '&:hover .delete-btn': {
|
| opacity: 1,
|
| },
|
| }}
|
| >
|
| <ChatBubbleOutlineIcon
|
| sx={{
|
| fontSize: 15,
|
| color: isSelected ? 'var(--text)' : 'var(--muted-text)',
|
| opacity: isSelected ? 0.8 : 0.4,
|
| flexShrink: 0,
|
| }}
|
| />
|
|
|
| <Box sx={{ flex: 1, minWidth: 0 }}>
|
| <Typography
|
| variant="body2"
|
| sx={{
|
| fontWeight: isSelected ? 600 : 400,
|
| color: 'var(--text)',
|
| fontSize: '0.84rem',
|
| lineHeight: 1.4,
|
| whiteSpace: 'nowrap',
|
| overflow: 'hidden',
|
| textOverflow: 'ellipsis',
|
| }}
|
| >
|
| {session.title.startsWith('Chat ') ? `Session ${String(num).padStart(2, '0')}` : session.title}
|
| </Typography>
|
| <Typography
|
| variant="caption"
|
| sx={{
|
| color: 'var(--muted-text)',
|
| fontSize: '0.65rem',
|
| lineHeight: 1.2,
|
| }}
|
| >
|
| {formatTime(session.createdAt)}
|
| </Typography>
|
| </Box>
|
|
|
| <IconButton
|
| className="delete-btn"
|
| size="small"
|
| onClick={(e) => handleDelete(session.id, e)}
|
| sx={{
|
| color: 'var(--muted-text)',
|
| width: 26,
|
| height: 26,
|
| flexShrink: 0,
|
| '&:hover': { color: 'var(--accent-red)', bgcolor: 'rgba(244,67,54,0.08)' },
|
| }}
|
| >
|
| <DeleteOutlineIcon sx={{ fontSize: 15 }} />
|
| </IconButton>
|
| </Box>
|
| );
|
| })
|
| )}
|
| </Box>
|
|
|
| {/* ββ Footer: New Session + status ββββββββββββββββββββββββββββ */}
|
| <Divider sx={{ opacity: 0.5 }} />
|
| <Box
|
| sx={{
|
| px: 1.5,
|
| py: 1.5,
|
| display: 'flex',
|
| flexDirection: 'column',
|
| gap: 1,
|
| flexShrink: 0,
|
| }}
|
| >
|
| <Box
|
| component="button"
|
| onClick={handleNewSession}
|
| disabled={isCreatingSession}
|
| sx={{
|
| display: 'inline-flex',
|
| alignItems: 'center',
|
| justifyContent: 'center',
|
| gap: 0.75,
|
| width: '100%',
|
| px: 1.5,
|
| py: 1.25,
|
| border: 'none',
|
| borderRadius: '10px',
|
| bgcolor: '#FF9D00',
|
| color: '#000',
|
| fontSize: '0.85rem',
|
| fontWeight: 700,
|
| cursor: 'pointer',
|
| transition: 'all 0.12s ease',
|
| '&:hover': {
|
| bgcolor: '#FFB340',
|
| },
|
| '&:disabled': {
|
| opacity: 0.5,
|
| cursor: 'not-allowed',
|
| },
|
| }}
|
| >
|
| {isCreatingSession ? (
|
| <>
|
| <CircularProgress size={12} sx={{ color: '#000' }} />
|
| Creating...
|
| </>
|
| ) : (
|
| <>
|
| <AddIcon sx={{ fontSize: 16 }} />
|
| New Session
|
| </>
|
| )}
|
| </Box>
|
|
|
| <Box
|
| sx={{
|
| display: 'flex',
|
| alignItems: 'center',
|
| justifyContent: 'center',
|
| gap: 0.5,
|
| }}
|
| >
|
| <StatusDot connected={isConnected} />
|
| <Typography
|
| variant="caption"
|
| sx={{ color: 'var(--muted-text)', fontSize: '0.62rem', letterSpacing: '0.02em' }}
|
| >
|
| {sessions.length} session{sessions.length !== 1 ? 's' : ''} · Backend {isConnected ? 'online' : 'offline'}
|
| </Typography>
|
| </Box>
|
| </Box>
|
| </Box>
|
| );
|
| }
|
|
|