/** * Sidebar Component * Navigation sidebar with menu items. * No longer fixed - sits inside the flex layout below the title bar. */ import { useEffect, useState } from 'react'; import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { Network, Bot, Puzzle, Clock, Settings as SettingsIcon, PanelLeftClose, PanelLeft, Plus, Terminal, ExternalLink, Trash2, Cpu, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useSettingsStore } from '@/stores/settings'; import { useChatStore } from '@/stores/chat'; import { useGatewayStore } from '@/stores/gateway'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { hostApiFetch } from '@/lib/host-api'; import { useTranslation } from 'react-i18next'; import logoSvg from '@/assets/logo.svg'; type SessionBucketKey = | 'today' | 'yesterday' | 'withinWeek' | 'withinTwoWeeks' | 'withinMonth' | 'older'; interface NavItemProps { to: string; icon: React.ReactNode; label: string; badge?: string; collapsed?: boolean; onClick?: () => void; } function NavItem({ to, icon, label, badge, collapsed, onClick }: NavItemProps) { return ( cn( 'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-[14px] font-medium transition-colors', 'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80', isActive ? 'bg-black/5 dark:bg-white/10 text-foreground' : '', collapsed && 'justify-center px-0' ) } > {({ isActive }) => ( <> {icon} {!collapsed && ( <> {label} {badge && ( {badge} )} > )} > )} ); } function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey { if (!activityMs || activityMs <= 0) return 'older'; const now = new Date(nowMs); const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000; if (activityMs >= startOfToday) return 'today'; if (activityMs >= startOfYesterday) return 'yesterday'; const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000); if (daysAgo <= 7) return 'withinWeek'; if (daysAgo <= 14) return 'withinTwoWeeks'; if (daysAgo <= 30) return 'withinMonth'; return 'older'; } const INITIAL_NOW_MS = Date.now(); export function Sidebar() { const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed); const sessions = useChatStore((s) => s.sessions); const currentSessionKey = useChatStore((s) => s.currentSessionKey); const sessionLabels = useChatStore((s) => s.sessionLabels); const sessionLastActivity = useChatStore((s) => s.sessionLastActivity); const switchSession = useChatStore((s) => s.switchSession); const newSession = useChatStore((s) => s.newSession); const deleteSession = useChatStore((s) => s.deleteSession); const loadSessions = useChatStore((s) => s.loadSessions); const loadHistory = useChatStore((s) => s.loadHistory); const gatewayStatus = useGatewayStore((s) => s.status); const isGatewayRunning = gatewayStatus.state === 'running'; useEffect(() => { if (!isGatewayRunning) return; let cancelled = false; const hasExistingMessages = useChatStore.getState().messages.length > 0; (async () => { await loadSessions(); if (cancelled) return; await loadHistory(hasExistingMessages); })(); return () => { cancelled = true; }; }, [isGatewayRunning, loadHistory, loadSessions]); const navigate = useNavigate(); const isOnChat = useLocation().pathname === '/'; const getSessionLabel = (key: string, displayName?: string, label?: string) => sessionLabels[key] ?? label ?? displayName ?? key; const openDevConsole = async () => { try { const result = await hostApiFetch<{ success: boolean; url?: string; error?: string; }>('/api/gateway/control-ui'); if (result.success && result.url) { window.electron.openExternal(result.url); } else { console.error('Failed to get Dev Console URL:', result.error); } } catch (err) { console.error('Error opening Dev Console:', err); } }; const { t } = useTranslation(['common', 'chat']); const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null); const [nowMs, setNowMs] = useState(INITIAL_NOW_MS); useEffect(() => { const timer = window.setInterval(() => { setNowMs(Date.now()); }, 60 * 1000); return () => window.clearInterval(timer); }, []); const sessionBuckets: Array<{ key: SessionBucketKey; label: string; sessions: typeof sessions }> = [ { key: 'today', label: t('chat:historyBuckets.today'), sessions: [] }, { key: 'yesterday', label: t('chat:historyBuckets.yesterday'), sessions: [] }, { key: 'withinWeek', label: t('chat:historyBuckets.withinWeek'), sessions: [] }, { key: 'withinTwoWeeks', label: t('chat:historyBuckets.withinTwoWeeks'), sessions: [] }, { key: 'withinMonth', label: t('chat:historyBuckets.withinMonth'), sessions: [] }, { key: 'older', label: t('chat:historyBuckets.older'), sessions: [] }, ]; const sessionBucketMap = Object.fromEntries(sessionBuckets.map((bucket) => [bucket.key, bucket])) as Record< SessionBucketKey, (typeof sessionBuckets)[number] >; for (const session of [...sessions].sort((a, b) => (sessionLastActivity[b.key] ?? 0) - (sessionLastActivity[a.key] ?? 0) )) { const bucketKey = getSessionBucket(sessionLastActivity[session.key] ?? 0, nowMs); sessionBucketMap[bucketKey].sessions.push(session); } const navItems = [ { to: '/models', icon: , label: t('sidebar.models') }, { to: '/agents', icon: , label: t('sidebar.agents') }, { to: '/channels', icon: , label: t('sidebar.channels') }, { to: '/skills', icon: , label: t('sidebar.skills') }, { to: '/cron', icon: , label: t('sidebar.cronTasks') }, ]; return ( ); }