import { useEffect, useRef, useState } from 'react'; import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild'; import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList'; import langbotIcon from '@/app/assets/langbot-logo.webp'; import { systemInfo, httpClient } from '@/app/infra/http/HttpClient'; import { clearUserInfo, getCloudServiceClientSync, useCurrentWorkspace, useWorkspaceBootstrap, } from '@/app/infra/http'; import { useTranslation } from 'react-i18next'; import { Moon, Sun, Monitor, ChevronsUpDown, CircleHelp, Lightbulb, LogOut, KeyRound, Settings, Star, Ellipsis, ArrowUp, ExternalLink, Trash, Bug, Upload, Store, Github, Zap, FilePlus2, Sparkles, Server, Puzzle, RefreshCcw, UsersRound, } from 'lucide-react'; import { useTheme } from '@/components/providers/theme-provider'; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Checkbox } from '@/components/ui/checkbox'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { LanguageSelector } from '@/components/ui/language-selector'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import WorkspaceSwitcher, { OPEN_WORKSPACE_SETTINGS_EVENT, } from '@/app/home/components/workspace-settings/WorkspaceSwitcher'; import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog'; import SettingsDialog, { SettingsSection, SETTINGS_ACTION_BY_SECTION, SETTINGS_SECTION_BY_ACTION, } from '@/app/home/components/settings-dialog/SettingsDialog'; import { GitHubRelease } from '@/app/infra/http/CloudServiceClient'; import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask'; import { toast } from 'sonner'; import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, useSidebar, } from '@/components/ui/sidebar'; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible'; import { ChevronDown, ChevronRight, Plus } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; import { useSidebarData, SidebarEntityItem } from './SidebarDataContext'; import { FeedbackPopoverContent } from './FeedbackPopover'; import { type WorkspaceQuotaItem, useWorkspaceQuotaStatus, } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus'; import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip'; // Compare two version strings, returns true if v1 > v2 function compareVersions(v1: string, v2: string): boolean { const clean1 = v1.replace(/^v/, ''); const clean2 = v2.replace(/^v/, ''); const parts1 = clean1.split('.').map((p) => parseInt(p, 10) || 0); const parts2 = clean2.split('.').map((p) => parseInt(p, 10) || 0); const maxLen = Math.max(parts1.length, parts2.length); for (let i = 0; i < maxLen; i++) { const p1 = parts1[i] || 0; const p2 = parts2[i] || 0; if (p1 > p2) return true; if (p1 < p2) return false; } return false; } // Discord brand glyph (lucide-react has no Discord icon). function DiscordIcon({ className }: { className?: string }) { return ( ); } // IDs of sidebar entries that have collapsible entity sub-items const ENTITY_CATEGORY_IDS = [ 'bots', 'pipelines', 'knowledge', 'plugins', 'mcp', 'skills', ] as const; type EntityCategoryId = (typeof ENTITY_CATEGORY_IDS)[number]; // Categories that support detail pages via ?id= query param const DETAIL_PAGE_CATEGORIES: EntityCategoryId[] = [ 'bots', 'pipelines', 'knowledge', 'plugins', 'mcp', 'skills', ]; // Categories that support creating new entities from the sidebar const CREATABLE_CATEGORIES: EntityCategoryId[] = [ 'bots', 'pipelines', 'knowledge', 'mcp', 'skills', ]; // Categories where clicking the parent only toggles collapse (no list page) const COLLAPSIBLE_ONLY_CATEGORIES: EntityCategoryId[] = [ 'bots', 'pipelines', 'knowledge', 'mcp', 'skills', ]; function isEntityCategory(id: string): id is EntityCategoryId { return (ENTITY_CATEGORY_IDS as readonly string[]).includes(id); } // Map sidebar config IDs to SidebarDataContext keys const ENTITY_KEY_MAP: Record< EntityCategoryId, 'bots' | 'pipelines' | 'knowledgeBases' | 'plugins' | 'mcpServers' | 'skills' > = { bots: 'bots', pipelines: 'pipelines', knowledge: 'knowledgeBases', plugins: 'plugins', mcp: 'mcpServers', skills: 'skills', }; // Route prefix map for entity detail pages const ENTITY_ROUTE_MAP: Record = { bots: '/home/bots', pipelines: '/home/pipelines', knowledge: '/home/knowledge', plugins: '/home/extensions', mcp: '/home/mcp', skills: '/home/skills', }; // localStorage key for collapsible section open/closed state const SIDEBAR_SECTIONS_KEY = 'sidebar_sections'; const SIDEBAR_LIST_EXPANSION_KEY = 'sidebar_entity_list_expansion'; const SCROLL_HINT_BOTTOM_THRESHOLD = 40; type SidebarNavSection = 'home' | 'extensions'; type SidebarListExpansionState = Record< SidebarNavSection, Partial> >; function createEmptyListExpansionState(): SidebarListExpansionState { return { home: {}, extensions: {}, }; } function loadSectionState(): Record { if (typeof window === 'undefined') return {}; try { const stored = localStorage.getItem(SIDEBAR_SECTIONS_KEY); return stored ? JSON.parse(stored) : {}; } catch { return {}; } } function saveSectionState(state: Record) { try { localStorage.setItem(SIDEBAR_SECTIONS_KEY, JSON.stringify(state)); } catch { // Ignore storage errors } } function loadListExpansionState(): SidebarListExpansionState { if (typeof window === 'undefined') return createEmptyListExpansionState(); try { const stored = localStorage.getItem(SIDEBAR_LIST_EXPANSION_KEY); if (!stored) return createEmptyListExpansionState(); const parsed = JSON.parse(stored) as Partial; return { home: parsed.home ?? {}, extensions: parsed.extensions ?? {}, }; } catch { return createEmptyListExpansionState(); } } function saveListExpansionState(state: SidebarListExpansionState) { try { localStorage.setItem(SIDEBAR_LIST_EXPANSION_KEY, JSON.stringify(state)); } catch { // Ignore storage errors } } // Maximum number of entity sub-items visible before "More" toggle const MAX_VISIBLE_ITEMS = 5; const MCP_REFRESH_POLL_INTERVAL_MS = 1000; const MCP_REFRESH_TIMEOUT_MS = 60000; function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } const UNLIMITED_QUOTA: WorkspaceQuotaItem = { count: 0, max: -1, reached: false, loading: false, disabled: false, }; async function waitForMCPRefreshTask(taskId: number) { const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS; while (Date.now() < deadline) { const task = await httpClient.getAsyncTask(taskId); if (task.runtime.done) return task; await sleep(MCP_REFRESH_POLL_INTERVAL_MS); } throw new Error(`Timed out waiting for MCP refresh task ${taskId}`); } async function refreshEnabledMCPConnections() { const resp = await httpClient.getMCPServers(); const enabledServers = resp.servers.filter((server) => server.enable); if (enabledServers.length === 0) return; const taskResults = await Promise.allSettled( enabledServers.map((server) => httpClient.testMCPServer(server.name, {})), ); const taskIds: number[] = []; for (const result of taskResults) { if ( result.status === 'fulfilled' && typeof result.value.task_id === 'number' ) { taskIds.push(result.value.task_id); } else if (result.status === 'rejected') { console.error('Failed to start MCP refresh task:', result.reason); } } await Promise.allSettled(taskIds.map(waitForMCPRefreshTask)); } // Sort entity items by updatedAt descending (most recent first), items without updatedAt go last function sortByRecent(items: SidebarEntityItem[]): SidebarEntityItem[] { return [...items].sort((a, b) => { if (!a.updatedAt && !b.updatedAt) return 0; if (!a.updatedAt) return 1; if (!b.updatedAt) return -1; return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); }); } // MCP status dot color: disabled → gray, error → red, connecting → yellow, connected → green function mcpStatusColor(item: SidebarEntityItem): string { if (item.enabled === false) return 'bg-muted-foreground/40'; switch (item.runtimeStatus) { case 'connected': return 'bg-green-500'; case 'connecting': return 'bg-yellow-500'; case 'error': return 'bg-red-500'; default: return 'bg-muted-foreground/40'; } } function MCPStatusIcon({ item, borderClass, }: { item: SidebarEntityItem; borderClass: string; }) { return ( ); } // Plugin operation type enum enum PluginOperationType { DELETE = 'DELETE', UPDATE = 'UPDATE', } // Renders sidebar navigation items with collapsible sub-items for entity categories function NavItems({ selectedChild, onChildClick, section, sectionOpenState, onSectionToggle, }: { selectedChild: SidebarChildVO | undefined; onChildClick: (child: SidebarChildVO) => void; section: SidebarNavSection; sectionOpenState: Record; onSectionToggle: (id: string, open: boolean) => void; }) { const navigate = useNavigate(); const location = useLocation(); const pathname = location.pathname; const [searchParams] = useSearchParams(); const sidebarData = useSidebarData(); const quotaStatus = useWorkspaceQuotaStatus(); const { state: sidebarState, isMobile } = useSidebar(); const { t } = useTranslation(); const currentWorkspace = useCurrentWorkspace(); const canManageResources = currentWorkspace?.permissions.includes('resource.manage') ?? false; const canOperateRuntime = currentWorkspace?.permissions.includes('runtime.operate') ?? false; // Track which entity categories have their full list expanded const [expandedLists, setExpandedLists] = useState( loadListExpansionState, ); // Track popover open state for collapsed sidebar entity categories const [popoverOpen, setPopoverOpen] = useState>({}); // Spin state for the installed-extensions refresh button const [extRefreshing, setExtRefreshing] = useState(false); const handleRefreshExtensions = async (e: React.MouseEvent) => { e.stopPropagation(); if (extRefreshing) return; setExtRefreshing(true); try { const results = await Promise.allSettled([ sidebarData.refreshPlugins(), sidebarData.refreshSkills(), refreshEnabledMCPConnections(), ]); const mcpRefreshResult = results[2]; if (mcpRefreshResult.status === 'rejected') { console.error( 'Failed to refresh MCP connections:', mcpRefreshResult.reason, ); } await sidebarData.refreshMCPServers(); } finally { setExtRefreshing(false); } }; // Plugin operation state const [showPluginOpModal, setShowPluginOpModal] = useState(false); const [pluginOpType, setPluginOpType] = useState( PluginOperationType.DELETE, ); const [targetPluginItem, setTargetPluginItem] = useState(null); const [deleteData, setDeleteData] = useState(false); const asyncTask = useAsyncTask({ onSuccess: () => { const msg = pluginOpType === PluginOperationType.DELETE ? t('plugins.deleteSuccess') : t('plugins.updateSuccess'); toast.success(msg); setShowPluginOpModal(false); sidebarData.refreshPlugins(); }, }); function handlePluginDelete(item: SidebarEntityItem) { setTargetPluginItem(item); setPluginOpType(PluginOperationType.DELETE); setDeleteData(false); asyncTask.reset(); setShowPluginOpModal(true); } function handlePluginUpdate(item: SidebarEntityItem) { setTargetPluginItem(item); setPluginOpType(PluginOperationType.UPDATE); asyncTask.reset(); setShowPluginOpModal(true); } function executePluginOperation() { if (!targetPluginItem) return; const slashIdx = targetPluginItem.id.indexOf('/'); const author = slashIdx >= 0 ? targetPluginItem.id.substring(0, slashIdx) : ''; const name = slashIdx >= 0 ? targetPluginItem.id.substring(slashIdx + 1) : targetPluginItem.id; const apiCall = pluginOpType === PluginOperationType.DELETE ? httpClient.removePlugin(author, name, deleteData) : httpClient.upgradePlugin(author, name); apiCall .then((res) => { asyncTask.startTask(res.task_id); }) .catch((error) => { const errorMessage = pluginOpType === PluginOperationType.DELETE ? t('plugins.deleteError') + error.message : t('plugins.updateError') + error.message; toast.error(errorMessage); }); } const sectionItems = sidebarConfigList.filter((c) => c.section === section); function handleListExpansionToggle(id: EntityCategoryId, expanded: boolean) { setExpandedLists(() => { const latest = loadListExpansionState(); const next = { ...latest, [section]: { ...latest[section], [id]: expanded, }, }; saveListExpansionState(next); return next; }); } // Persist open state for sections that become active through navigation, // so they remain expanded when the user switches to a different section. const sectionOpenRef = useRef(sectionOpenState); sectionOpenRef.current = sectionOpenState; useEffect(() => { sectionItems.forEach((config) => { if (!isEntityCategory(config.id)) return; const routePrefix = ENTITY_ROUTE_MAP[config.id]; const active = pathname === routePrefix || pathname.startsWith(routePrefix + '/'); if (active && sectionOpenRef.current[config.id] === undefined) { onSectionToggle(config.id, true); } }); }, [pathname, sectionItems, onSectionToggle]); return ( <> {sectionItems.map((config) => { if (!isEntityCategory(config.id)) { if (config.id === 'add-extension' && !canManageResources) { return null; } // Non-entity entries (e.g. monitoring and the extension market) render as plain links. return ( onChildClick(config)} tooltip={config.name} > {config.icon} {config.name} ); } // Entity categories: collapsible with sub-items const categoryId = config.id; const entityKey = ENTITY_KEY_MAP[categoryId]; const isExtensionsCategory = categoryId === 'plugins'; const items: SidebarEntityItem[] = isExtensionsCategory ? [ ...sidebarData.plugins.map((p) => ({ ...p, extensionType: 'plugin' as const, })), ...sidebarData.mcpServers.map((m) => ({ ...m, extensionType: 'mcp' as const, })), ...sidebarData.skills.map((s) => ({ ...s, extensionType: 'skill' as const, })), ] : sidebarData[entityKey]; const routePrefix = ENTITY_ROUTE_MAP[categoryId]; const hasDetailPages = DETAIL_PAGE_CATEGORIES.includes(categoryId); const canCreate = canManageResources && CREATABLE_CATEGORIES.includes(categoryId); const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(categoryId); const isPlugin = categoryId === 'plugins'; const isSkill = categoryId === 'skills'; const isBot = categoryId === 'bots'; const isMCP = categoryId === 'mcp'; const quota = categoryId === 'bots' ? quotaStatus.bots : categoryId === 'pipelines' ? quotaStatus.pipelines : categoryId === 'knowledge' ? quotaStatus.knowledgeBases : categoryId === 'plugins' || categoryId === 'mcp' || categoryId === 'skills' ? quotaStatus.extensions : UNLIMITED_QUOTA; const resolveItemRoute = (item: SidebarEntityItem): string => { if (item.extensionType === 'mcp') { return `/home/mcp?id=${encodeURIComponent(item.id)}`; } if (item.extensionType === 'skill') { return `/home/skills?id=${encodeURIComponent(item.id)}`; } return hasDetailPages ? `${routePrefix}?id=${encodeURIComponent(item.id)}` : routePrefix; }; const isActive = selectedChild?.id === categoryId || pathname === routePrefix || pathname.startsWith(routePrefix + '/'); // Use stored open state if available, otherwise default to active state const isOpen = sectionOpenState[categoryId] ?? isActive; // When sidebar is collapsed on desktop and category is collapse-only, // show a popover flyout instead of the hidden collapsible sub-items const isCollapsed = sidebarState === 'collapsed' && !isMobile; const showPopover = isCollapsed && isCollapseOnly; // Shared entity list renderer used by both popover and collapsible const renderEntityList = (inPopover: boolean) => { const sortedItems = isExtensionsCategory ? [...items].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base', }), ) : sortByRecent(items); const isExpanded = expandedLists[section]?.[categoryId] ?? false; const maxItems = inPopover ? 10 : MAX_VISIBLE_ITEMS; const visibleItems = sortedItems.length > maxItems && !isExpanded ? sortedItems.slice(0, maxItems) : sortedItems; const hiddenCount = sortedItems.length - maxItems; if (sortedItems.length === 0) { return (
{t('common.noItems')}
); } const itemActiveCheck = (item: SidebarEntityItem): boolean => { if (item.extensionType === 'mcp') { return ( pathname === '/home/mcp' && searchParams.get('id') === item.id ); } if (item.extensionType === 'skill') { return ( pathname === '/home/skills' && searchParams.get('id') === item.id ); } return ( hasDetailPages && pathname === routePrefix && searchParams.get('id') === item.id ); }; const itemIsPlugin = (item: SidebarEntityItem): boolean => isExtensionsCategory ? item.extensionType === 'plugin' : isPlugin; const showGroupHeaders = isExtensionsCategory && !inPopover && sidebarData.extensionsGroupByType; const groupOrder: Array<'plugin' | 'mcp' | 'skill'> = [ 'plugin', 'mcp', 'skill', ]; const groupLabelKey: Record<'plugin' | 'mcp' | 'skill', string> = { plugin: 'market.typePlugin', mcp: 'market.typeMCP', skill: 'market.typeSkill', }; const renderItem = (item: SidebarEntityItem) => { const itemRoute = resolveItemRoute(item); const isItemActive = itemActiveCheck(item); const itemIsPluginType = itemIsPlugin(item); if (inPopover) { return ( ); } // Normal sidebar sub-item rendering return ( { e.preventDefault(); navigate(itemRoute); }} > {item.extensionType === 'mcp' ? ( ) : item.extensionType === 'skill' ? ( ) : item.emoji ? ( {item.emoji} ) : item.iconURL ? ( {(isBot || isMCP) && ( )} ) : item.extensionType === 'plugin' ? ( ) : isMCP ? ( ) : null} {item.name} {item.debug && ( )} {/* Full name — so truncated sidebar items are readable on hover */}
{item.name}
{item.description && (
{item.description.length > 80 ? item.description.slice(0, 80) + '…' : item.description}
)}
{/* Plugin context menu - shown on hover (not for debug plugins) */} {itemIsPluginType && !item.debug && ( handlePluginUpdate(item)} onDelete={() => handlePluginDelete(item)} /> )}
); }; return ( <> {showGroupHeaders ? groupOrder.map((type) => { const groupItems = visibleItems.filter( (it) => it.extensionType === type, ); if (groupItems.length === 0) return null; return (
{t(groupLabelKey[type])}
{groupItems.map((item) => renderItem(item))}
); }) : visibleItems.map((item) => renderItem(item))} {/* Show more / less toggle when items exceed limit */} {sortedItems.length > maxItems && !inPopover && ( )} {hiddenCount > 0 && inPopover && !isExpanded && ( )} ); }; // Popover flyout for collapsed sidebar if (showPopover) { return ( setPopoverOpen((prev) => ({ ...prev, [config.id]: open })) } > {config.icon} {config.name}
{config.name} {canCreate && ( {isPlugin ? ( {systemInfo.enable_marketplace && ( { e.stopPropagation(); navigate('/home/add-extension'); setPopoverOpen((prev) => ({ ...prev, [config.id]: false, })); }} > {t('plugins.goToMarketplace')} )} { e.stopPropagation(); navigate('/home/add-extension?manual=1'); setPopoverOpen((prev) => ({ ...prev, [config.id]: false, })); }} > {t('plugins.uploadLocal')} { e.stopPropagation(); navigate('/home/add-extension?manual=1'); setPopoverOpen((prev) => ({ ...prev, [config.id]: false, })); }} > {t('plugins.installFromGithub')} ) : isSkill ? ( { e.stopPropagation(); navigate('/home/skills?action=create'); setPopoverOpen((prev) => ({ ...prev, [config.id]: false, })); }} > {t('skills.createManually')} { e.stopPropagation(); navigate('/home/add-extension?manual=1'); setPopoverOpen((prev) => ({ ...prev, [config.id]: false, })); }} > {t('skills.uploadZip')} { e.stopPropagation(); navigate('/home/add-extension?manual=1'); setPopoverOpen((prev) => ({ ...prev, [config.id]: false, })); }} > {t('skills.importFromGithub')} ) : ( )} )}
{renderEntityList(true)}
); } // Normal expanded sidebar with collapsible sub-items return ( onSectionToggle(config.id, open)} className="group/collapsible" >
{ if (isCollapseOnly) { onSectionToggle(config.id, !isOpen); } else { onChildClick(config); } }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (isCollapseOnly) { onSectionToggle(config.id, !isOpen); } else { onChildClick(config); } } }} > {config.icon} {config.name}
{isExtensionsCategory && canOperateRuntime && ( )} {canCreate && ( {isPlugin ? ( {systemInfo.enable_marketplace && ( { e.stopPropagation(); navigate('/home/add-extension'); }} > {t('plugins.goToMarketplace')} )} { e.stopPropagation(); navigate('/home/add-extension?manual=1'); }} > {t('plugins.uploadLocal')} { e.stopPropagation(); navigate('/home/add-extension?manual=1'); }} > {t('plugins.installFromGithub')} ) : isSkill ? ( { e.stopPropagation(); navigate('/home/skills?action=create'); }} > {t('skills.createManually')} { e.stopPropagation(); navigate('/home/add-extension?manual=1'); }} > {t('skills.uploadZip')} { e.stopPropagation(); navigate('/home/add-extension?manual=1'); }} > {t('skills.importFromGithub')} ) : ( )} )}
{renderEntityList(false)}
); })} {/* Plugin operation confirmation dialog */} { if (!open) { setShowPluginOpModal(false); setTargetPluginItem(null); asyncTask.reset(); } }} > {pluginOpType === PluginOperationType.DELETE ? t('plugins.deleteConfirm') : t('plugins.updateConfirm')} {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
{(() => { const slashIdx = targetPluginItem?.id.indexOf('/') ?? -1; const author = slashIdx >= 0 ? targetPluginItem!.id.substring(0, slashIdx) : ''; const name = slashIdx >= 0 ? targetPluginItem!.id.substring(slashIdx + 1) : (targetPluginItem?.id ?? ''); return pluginOpType === PluginOperationType.DELETE ? t('plugins.confirmDeletePlugin', { author, name }) : t('plugins.confirmUpdatePlugin', { author, name }); })()}
{pluginOpType === PluginOperationType.DELETE && (
setDeleteData(checked === true) } />
)}
)} {asyncTask.status === AsyncTaskStatus.RUNNING && (
{pluginOpType === PluginOperationType.DELETE ? t('plugins.deleting') : t('plugins.updating')}
)} {asyncTask.status === AsyncTaskStatus.ERROR && (
{pluginOpType === PluginOperationType.DELETE ? t('plugins.deleteError') : t('plugins.updateError')}
{asyncTask.error}
)}
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && ( )} {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && ( )} {asyncTask.status === AsyncTaskStatus.RUNNING && ( )} {asyncTask.status === AsyncTaskStatus.ERROR && ( )}
); } // Dropdown menu for plugin sidebar sub-items (shown on hover) function PluginItemMenu({ item, canManage, onUpdate, onDelete, }: { item: SidebarEntityItem; canManage: boolean; onUpdate: () => void; onDelete: () => void; }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const isMarketplace = item.installSource === 'marketplace'; const isGithub = item.installSource === 'github'; const hasSourceLink = isMarketplace || isGithub; if (!canManage && !hasSourceLink) return null; function handleViewSource() { const slashIdx = item.id.indexOf('/'); const author = slashIdx >= 0 ? item.id.substring(0, slashIdx) : ''; const name = slashIdx >= 0 ? item.id.substring(slashIdx + 1) : item.id; if (isGithub && item.installInfo?.github_url) { window.open(item.installInfo.github_url as string, '_blank'); } else if (isMarketplace) { window.open( getCloudServiceClientSync().getPluginMarketplaceURL( systemInfo.cloud_service_url, author, name, ), '_blank', ); } } return ( {canManage && isMarketplace && ( { onUpdate(); setOpen(false); }} > {t('plugins.update')} {item.hasUpdate && ( {t('plugins.new')} )} )} {hasSourceLink && ( { handleViewSource(); setOpen(false); }} > {t('plugins.viewSource')} )} {canManage && ( { onDelete(); setOpen(false); }} > {t('plugins.delete')} )} ); } // Plugin pages navigation section — grouped by plugin function PluginPagesNav() { const { pluginPages } = useSidebarData(); const navigate = useNavigate(); const location = useLocation(); const [searchParams] = useSearchParams(); const { t } = useTranslation(); if (pluginPages.length === 0) return null; const pathname = location.pathname; const currentId = pathname === '/home/plugin-pages' ? searchParams.get('id') : null; // Group pages by plugin (author/name) const grouped = new Map< string, { label: string; iconURL: string; pages: typeof pluginPages } >(); for (const page of pluginPages) { const key = `${page.pluginAuthor}/${page.pluginName}`; if (!grouped.has(key)) { grouped.set(key, { label: page.pluginLabel, iconURL: page.pluginIconURL, pages: [], }); } grouped.get(key)!.pages.push(page); } return ( {t('sidebar.pluginPages')} {Array.from(grouped.entries()).map( ([pluginKey, { label, iconURL, pages }]) => { const hasActivePage = pages.some((p) => p.id === currentId); const pluginIcon = ( { (e.target as HTMLImageElement).style.display = 'none'; }} /> ); // Single page — render directly without nesting if (pages.length === 1) { const page = pages[0]; const isActive = currentId === page.id; const route = `/home/plugin-pages?id=${encodeURIComponent(page.id)}`; return ( navigate(route)} className="select-none" > {pluginIcon} {page.name} ); } // Multiple pages — collapsible group return ( {pluginIcon} {label} {pages.map((page) => { const isActive = currentId === page.id; const route = `/home/plugin-pages?id=${encodeURIComponent(page.id)}`; return ( navigate(route)} className="select-none" > {page.name} ); })} ); }, )} ); } function findSidebarChildForPath(pathname: string): SidebarChildVO | undefined { const matchedChild = sidebarConfigList.find((childConfig) => childConfig.route === pathname) || sidebarConfigList.find((childConfig) => pathname.startsWith(childConfig.route + '/'), ); if (matchedChild) return matchedChild; if ( pathname === '/home/mcp' || pathname === '/home/skills' || pathname === '/home/plugin-pages' || pathname.startsWith('/home/mcp/') || pathname.startsWith('/home/skills/') || pathname.startsWith('/home/plugin-pages/') ) { return sidebarConfigList.find( (childConfig) => childConfig.id === 'plugins', ); } if ( pathname === '/home/add-extension' || pathname.startsWith('/home/add-extension/') ) { return sidebarConfigList.find( (childConfig) => childConfig.id === 'add-extension', ); } return undefined; } export default function HomeSidebar({ onSelectedChangeAction, }: { onSelectedChangeAction: (sidebarChild: SidebarChildVO) => void; }) { const navigate = useNavigate(); const location = useLocation(); const pathname = location.pathname; const [searchParams] = useSearchParams(); const { isMobile } = useSidebar(); useEffect(() => { handleRouteChange(pathname); }, [pathname]); useEffect(() => { const action = searchParams.get('action'); if (action && SETTINGS_SECTION_BY_ACTION[action]) { setSettingsSection(SETTINGS_SECTION_BY_ACTION[action]); setSettingsOpen(true); } }, [searchParams]); const [selectedChild, setSelectedChild] = useState(); const [sectionOpenState, setSectionOpenState] = useState>(loadSectionState); const { theme, setTheme } = useTheme(); const { t } = useTranslation(); const currentWorkspace = useCurrentWorkspace(); const workspaces = useWorkspaceBootstrap(); const showWorkspaceSwitcher = workspaces.length > 1 || currentWorkspace?.workspace.source === 'cloud_projection'; const canViewStorageAnalysis = currentWorkspace?.workspace.source !== 'cloud_projection' && currentWorkspace?.permissions.includes('audit.view'); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsSection, setSettingsSection] = useState('models'); const [latestRelease, setLatestRelease] = useState( null, ); const [hasNewVersion, setHasNewVersion] = useState(false); const [versionDialogOpen, setVersionDialogOpen] = useState(false); const [feedbackOpen, setFeedbackOpen] = useState(false); const [userEmail, setUserEmail] = useState(''); const [starCount, setStarCount] = useState(null); const [userMenuOpen, setUserMenuOpen] = useState(false); const navigationContentRef = useRef(null); const [showScrollHint, setShowScrollHint] = useState(false); function scrollNavigationToBottom() { const contentEl = navigationContentRef.current; if (!contentEl) return; const maxScrollTop = contentEl.scrollHeight - contentEl.clientHeight; contentEl.scrollTo({ top: maxScrollTop, behavior: 'smooth', }); setShowScrollHint(false); window.setTimeout(() => { if (contentEl.scrollTop < maxScrollTop - 2) { contentEl.scrollTop = maxScrollTop; } setShowScrollHint(false); }, 250); } function openSettings(section: SettingsSection) { setSettingsSection(section); setSettingsOpen(true); const params = new URLSearchParams(searchParams.toString()); params.set('action', SETTINGS_ACTION_BY_SECTION[section]); navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true, }); } useEffect(() => { const openWorkspaceSettings = () => openSettings('workspace'); window.addEventListener( OPEN_WORKSPACE_SETTINGS_EVENT, openWorkspaceSettings, ); return () => window.removeEventListener( OPEN_WORKSPACE_SETTINGS_EVENT, openWorkspaceSettings, ); }); function handleSettingsSectionChange(section: SettingsSection) { setSettingsSection(section); const params = new URLSearchParams(searchParams.toString()); params.set('action', SETTINGS_ACTION_BY_SECTION[section]); navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true, }); } function handleSettingsOpenChange(open: boolean) { setSettingsOpen(open); if (!open) { const params = new URLSearchParams(searchParams.toString()); params.delete('action'); const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname; navigate(newUrl, { preventScrollReset: true }); } } useEffect(() => { initSelect(); const storedEmail = localStorage.getItem('userEmail'); if (storedEmail) { setUserEmail(storedEmail); } else { httpClient .getUserInfo() .then((info) => { setUserEmail(info.user); localStorage.setItem('userEmail', info.user); }) .catch(() => {}); } // Cloud edition is updated centrally by the operator, so end users should // not see a "new version available" prompt in the sidebar. Skip the GitHub // release check entirely for edition=cloud. if (systemInfo?.edition !== 'cloud') { getCloudServiceClientSync() .getLangBotReleases() .then((releases) => { if (releases && releases.length > 0) { const latestStable = releases.find( (r) => !r.prerelease && !r.draft, ); const latest = latestStable || releases[0]; setLatestRelease(latest); const currentVersion = systemInfo?.version; if (currentVersion && latest.tag_name) { const isNewer = compareVersions(latest.tag_name, currentVersion); setHasNewVersion(isNewer); } } }) .catch((error) => { console.error('Failed to fetch releases:', error); }); } getCloudServiceClientSync() .getGitHubRepoInfo() .then((info) => { if (info?.repo?.stargazers_count != null) { setStarCount(info.repo.stargazers_count); } }) .catch(() => {}); }, []); useEffect(() => { const contentEl = navigationContentRef.current; if (!contentEl) return; let animationFrame = 0; const updateScrollHint = () => { cancelAnimationFrame(animationFrame); animationFrame = requestAnimationFrame(() => { const hasHiddenContent = contentEl.scrollTop + contentEl.clientHeight < contentEl.scrollHeight - SCROLL_HINT_BOTTOM_THRESHOLD; setShowScrollHint(hasHiddenContent); }); }; updateScrollHint(); contentEl.addEventListener('scroll', updateScrollHint, { passive: true }); const resizeObserver = new ResizeObserver(updateScrollHint); resizeObserver.observe(contentEl); if (contentEl.firstElementChild) { resizeObserver.observe(contentEl.firstElementChild); } const mutationObserver = new MutationObserver(updateScrollHint); mutationObserver.observe(contentEl, { childList: true, subtree: true, attributes: true, }); window.addEventListener('resize', updateScrollHint); return () => { cancelAnimationFrame(animationFrame); contentEl.removeEventListener('scroll', updateScrollHint); resizeObserver.disconnect(); mutationObserver.disconnect(); window.removeEventListener('resize', updateScrollHint); }; }, []); // Update selected state + notify parent without navigating function selectChild(child: SidebarChildVO) { setSelectedChild(child); onSelectedChangeAction(child); } // Toggle collapsible section open/closed with localStorage persistence function handleSectionToggle(id: string, open: boolean) { setSectionOpenState((prev) => { const next = { ...prev, [id]: open }; saveSectionState(next); return next; }); } // User click: update state AND navigate function handleChildClick(child: SidebarChildVO) { selectChild(child); navigate(child.route); } function initSelect() { const currentPath = pathname; const matchedChild = findSidebarChildForPath(currentPath); if (matchedChild) { // Route already matches — just select without navigating (preserves ?id= query params) selectChild(matchedChild); } else { // No match — redirect to the first route under /home const defaultChild = sidebarConfigList.find((c) => c.route.startsWith('/home')) ?? sidebarConfigList[0]; handleChildClick(defaultChild); } } function handleRouteChange(pathname: string) { if (!pathname.startsWith('/home')) return; const routeSelectChild = findSidebarChildForPath(pathname); if (routeSelectChild) { setSelectedChild(routeSelectChild); onSelectedChangeAction(routeSelectChild); } } function handleLogout() { clearUserInfo(); localStorage.removeItem('token'); localStorage.removeItem('userEmail'); window.location.href = '/login'; } // Get the initial letter for user avatar const userInitial = userEmail ? userEmail.charAt(0).toUpperCase() : 'U'; return ( <> {/* Header: Logo using sidebar-07 team-switcher pattern */} LangBot
LangBot {systemInfo?.edition === 'cloud' ? t('sidebar.editionCloud') : t('sidebar.editionCommunity')}
{systemInfo?.version} {hasNewVersion && ( setVersionDialogOpen(true)} className="bg-red-500 hover:bg-red-600 text-white text-[0.55rem] px-1 py-0 h-3.5 cursor-pointer" > {t('plugins.new')} )}
{showWorkspaceSwitcher && (
)} {/* Navigation items grouped by section */}
{t('sidebar.home')} {t('sidebar.extensions')}
{/* Footer */} {/* Models entry */} openSettings('models')} tooltip={t('models.title')} > {t('models.title')} {/* API-key management is available only to authorized Workspace roles. */} {currentWorkspace?.permissions.includes('api_key.manage') && ( openSettings('apiIntegration')} tooltip={t('common.apiIntegration')} > {t('common.apiIntegration')} )} {/* User menu using sidebar-07 nav-user DropdownMenu pattern */} {userInitial}
{userEmail || t('common.accountOptions')}
{/* User info header */}
{userInitial}
{userEmail || t('common.accountOptions')}
{/* Language & Theme row */}
{/* Account actions */} { setUserMenuOpen(false); openSettings('account'); }} > {t('account.settings')} { setUserMenuOpen(false); openSettings('workspace'); }} > {t('workspace.settings')} {canViewStorageAnalysis && ( { setUserMenuOpen(false); openSettings('storageAnalysis'); }} > {t('storageAnalysis.title')} )} { setUserMenuOpen(false); navigate('/wizard'); }} > {t('sidebar.quickStart')} {/* External links */} { const language = localStorage.getItem('langbot_language'); if (language === 'zh-Hans' || language === 'zh-Hant') { window.open( 'https://link.langbot.app/zh/docs/guide', '_blank', ); } else { window.open( 'https://link.langbot.app/en/docs/guide', '_blank', ); } }} > {t('common.helpDocs')} { setUserMenuOpen(false); setFeedbackOpen(true); }} > {t('common.featureRequest')} { window.open( 'https://github.com/langbot-app/LangBot', '_blank', ); }} > {t('common.starOnGitHub')} {starCount != null && ( {starCount >= 1000 ? `${(starCount / 1000).toFixed(1)}k` : starCount} )} { window.open('https://discord.gg/wdNEHETs87', '_blank'); }} > {t('common.joinDiscord')} {/* Logout */} handleLogout()}> {t('common.logout')}
{t('monitoring.feedback.title')} {t('monitoring.feedback.description')} setFeedbackOpen(false)} /> ); }