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