import { useState, useEffect, useRef, Suspense } from 'react' import { Outlet, useLocation, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import Sidebar from './components/Sidebar' import OperationsBar from './components/OperationsBar' import { ToastContainer, useToast } from './components/Toast' import { systemApi } from './utils/api' import { useTheme } from './contexts/ThemeContext' import { useBranding } from './contexts/BrandingContext' import { useAuth } from './context/AuthContext' import RouteFallback from './components/RouteFallback' import { hubs, hubOwnsPath } from './components/hub/hubConfig' import Icon from './components/Icon' const COLLAPSED_KEY = 'localai_sidebar_collapsed' // The page wrapper is keyed so its transition replays on navigation. Within a // hub, collapse the key to the hub id so the layout (and its tab bar) // persists across tab-to-tab nav instead of remounting and flashing — only // the inner page swaps. Normal routes keep their per-path key. // // A model's own page (/app/models/) is a child of the Models list, which // stays mounted behind it so Back finds the list as it was. It shares the // list's key for the same reason. function pageTransitionKey(pathname) { for (const h of hubs) { if (hubOwnsPath(h, pathname)) return `hub:${h.id}` } if (/^\/app\/models(\/|$)/.test(pathname)) return '/app/models' return pathname } export default function App() { const [sidebarOpen, setSidebarOpen] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { try { return localStorage.getItem(COLLAPSED_KEY) === 'true' } catch (_) { return false } }) const { toasts, addToast, removeToast } = useToast() const [version, setVersion] = useState('') const location = useLocation() const navigate = useNavigate() const { theme, toggleTheme } = useTheme() const { authEnabled, user } = useAuth() const branding = useBranding() const { t } = useTranslation('nav') const hamburgerRef = useRef(null) const isChatRoute = location.pathname.match(/\/chat(\/|$)/) || location.pathname.match(/\/agents\/[^/]+\/(chat|runs\/[^/]+)\/?$/) useEffect(() => { systemApi.version() .then(data => setVersion(typeof data === 'string' ? data : (data?.version || ''))) .catch(() => {}) }, []) useEffect(() => { const handler = (e) => setSidebarCollapsed(e.detail.collapsed) window.addEventListener('sidebar-collapse', handler) return () => window.removeEventListener('sidebar-collapse', handler) }, []) // Scroll to top on route change useEffect(() => { window.scrollTo(0, 0) }, [location.pathname]) // Drawer polish: lock body scroll, close on Escape, return focus to the // hamburger when the drawer closes. Only engages when the drawer is open; // desktop and tablet rail mode are unaffected. useEffect(() => { if (!sidebarOpen) return const prevOverflow = document.body.style.overflow document.body.style.overflow = 'hidden' const onKey = (e) => { if (e.key === 'Escape') setSidebarOpen(false) } window.addEventListener('keydown', onKey) return () => { document.body.style.overflow = prevOverflow window.removeEventListener('keydown', onKey) // Restore focus to the trigger so keyboard users land back where // they invoked the drawer from. hamburgerRef.current?.focus() } }, [sidebarOpen]) // Reset scroll to the top on every route change. The default (non-chat) // layout uses the document as its scroll container, so without this a new // page opens at the previous page's scroll position - navigating a hub // rail from a scrolled page would land mid-view instead of at the top. useEffect(() => { window.scrollTo(0, 0) }, [location.pathname]) const layoutClasses = [ 'app-layout', isChatRoute ? 'app-layout-chat' : '', sidebarCollapsed ? 'sidebar-is-collapsed' : '', ].filter(Boolean).join(' ') const showAvatar = authEnabled && user const accountLabel = user?.name || user?.email || t('account') const themeToggleLabel = theme === 'dark' ? t('switchToLightMode') : t('switchToDarkMode') return (
setSidebarOpen(false)} />
{/* Mobile header — primary actions reachable without opening the drawer. Hamburger is the only way to expand the nav on phones; theme toggle and account avatar are mirrored from the sidebar footer so they remain one tap away. */}
{branding.instanceName}
{showAvatar && ( )}
{/* Per-route Suspense catches React.lazy chunk loads (router.jsx) here, inside the App layout. Without it, suspension would bubble up to main.jsx's outer boundary and unmount the sidebar/header on every navigation. RouteFallback shows a delayed loader so the content area isn't blank while a chunk arrives (hub pages get their own boundary in HubLayout so the tab bar stays put). */} }>
{!isChatRoute && ( )}
) }