diff --git a/core/http/react-ui/e2e/role-mode-adaptive.spec.js b/core/http/react-ui/e2e/role-mode-adaptive.spec.js deleted file mode 100644 index 0e2e1b37b..000000000 --- a/core/http/react-ui/e2e/role-mode-adaptive.spec.js +++ /dev/null @@ -1,100 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' - -// These specs stub /api/features and /api/auth/status per cell. The test server -// disables auth (isAdmin=true) and reports its own features, so we intercept -// before navigation to simulate each role x mode cell. - -function stubFeatures(page, features) { - return page.route('**/api/features', route => - route.fulfill({ contentType: 'application/json', body: JSON.stringify(features) })) -} - -function stubNoP2P(page) { - // P2P token endpoint returns empty -> p2pEnabled=false. - return page.route('**/api/p2p/token', route => - route.fulfill({ contentType: 'text/plain', body: '' })) -} - -test.describe('Adaptive landing (HomeRoute)', () => { - test('admin + distributed redirects /app to Nodes', async ({ page }) => { - await stubFeatures(page, { distributed: true }) - await stubNoP2P(page) - await page.goto('/app') - await expect(page).toHaveURL(/\/app\/nodes$/) - await expect(page.locator('.page-title').first()).toBeVisible({ timeout: 15_000 }) - }) - - test('admin + single-node stays on Home', async ({ page }) => { - await stubFeatures(page, { distributed: false }) - await stubNoP2P(page) - await page.goto('/app') - await expect(page).toHaveURL(/\/app$/) - await expect(page.locator('.home-greeting')).toBeVisible({ timeout: 15_000 }) - }) -}) - -test.describe('Adaptive sidebar', () => { - test('distributed pins the Cluster group with Nodes at the top', async ({ page }) => { - await stubFeatures(page, { distributed: true }) - await stubNoP2P(page) - await page.goto('/app/chat') // any in-app page so the sidebar is mounted - const pinned = page.locator('.sidebar-nav .sidebar-section-items').first() - await expect(pinned.getByText('Nodes', { exact: false })).toBeVisible({ timeout: 15_000 }) - }) - - test('single-node does not pin a Cluster group', async ({ page }) => { - await stubFeatures(page, { distributed: false }) - await stubNoP2P(page) - await page.goto('/app/chat') - // Nodes is reachable only via the Operate rail, not pinned at the top. - await expect(page.locator('.sidebar-nav')).toBeVisible({ timeout: 15_000 }) - await expect(page.locator('.sidebar-nav .sidebar-section-items').first() - .getByText('Nodes', { exact: false })).toHaveCount(0) - }) -}) - -test.describe('Top navbar', () => { - test('admin sees the mode pill and settings cog', async ({ page }) => { - await stubFeatures(page, { distributed: true }) - await stubNoP2P(page) - await page.goto('/app/chat') - await expect(page.locator('.top-navbar__mode')).toBeVisible({ timeout: 15_000 }) - await expect(page.locator('.top-navbar__icon[aria-label]')).not.toHaveCount(0) - }) - - test('admin-via-chat jump shows when localai_assistant is enabled', async ({ page }) => { - await stubFeatures(page, { distributed: false, localai_assistant: true }) - await stubNoP2P(page) - await page.goto('/app/chat') - await expect(page.locator('.top-navbar__assistant')).toBeVisible({ timeout: 15_000 }) - }) - - test('admin-via-chat jump hidden when localai_assistant is off', async ({ page }) => { - await stubFeatures(page, { distributed: false, localai_assistant: false }) - await stubNoP2P(page) - await page.goto('/app/chat') - await expect(page.locator('.top-navbar__assistant')).toHaveCount(0) - }) -}) - -test.describe('Token usage meter', () => { - test('renders when admin usage has data', async ({ page }) => { - await stubFeatures(page, { distributed: false }) - await stubNoP2P(page) - await page.route('**/api/auth/admin/usage**', route => - route.fulfill({ contentType: 'application/json', - body: JSON.stringify({ buckets: [{ total_tokens: 1234 }] }) })) - await page.goto('/app/chat') - await expect(page.locator('.top-navbar__meter')).toBeVisible({ timeout: 15_000 }) - }) - - test('hidden when admin usage is empty (graceful degrade)', async ({ page }) => { - await stubFeatures(page, { distributed: false }) - await stubNoP2P(page) - await page.route('**/api/auth/admin/usage**', route => - route.fulfill({ contentType: 'application/json', body: JSON.stringify({ buckets: [] }) })) - await page.goto('/app/chat') - await expect(page.locator('.top-navbar')).toBeVisible({ timeout: 15_000 }) - await expect(page.locator('.top-navbar__meter')).toHaveCount(0) - }) -}) diff --git a/core/http/react-ui/public/locales/en/nav.json b/core/http/react-ui/public/locales/en/nav.json index 7317c74cd..5423438f9 100644 --- a/core/http/react-ui/public/locales/en/nav.json +++ b/core/http/react-ui/public/locales/en/nav.json @@ -12,16 +12,6 @@ "accountSettings": "Account settings", "account": "Account", "accountFor": "Account: {{name}}", - "topbar": { - "label": "Top bar", - "modeDistributed": "Distributed", - "modeSwarm": "Swarm", - "modeSingle": "Single-node", - "pickModel": "Models", - "adminViaChat": "Admin via chat", - "tokensToday": "Tokens today", - "usageDetail": "View usage detail" - }, "sections": { "create": "Create", "recognition": "Recognition", diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 0238f2fb1..cf1a46bd3 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -184,50 +184,6 @@ font-size: 1.5rem; } -/* Desktop top bar: deployment + admin affordances on wide screens. Hidden on - mobile, where .mobile-header carries the equivalent actions. */ -.top-navbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-md); - padding: var(--spacing-sm) var(--spacing-lg); - border-bottom: 1px solid var(--color-border-default); - background: var(--color-bg-secondary); -} -.top-navbar__right { display: flex; align-items: center; gap: var(--spacing-sm); } -.top-navbar__mode { - font-size: 0.75rem; - padding: 2px 10px; - border-radius: 999px; - border: 1px solid var(--color-border-default); - color: var(--color-text-secondary); -} -.top-navbar__mode.is-active { color: var(--color-success); border-color: var(--color-success); } -.top-navbar__btn { - display: inline-flex; align-items: center; gap: 6px; - font-size: 0.8125rem; padding: 5px 10px; border-radius: 8px; - border: 1px solid var(--color-border-default); background: var(--color-bg-tertiary); - color: var(--color-text-primary); cursor: pointer; -} -.top-navbar__icon { - width: 32px; height: 32px; display: inline-flex; align-items: center; - justify-content: center; border-radius: 8px; border: 1px solid var(--color-border-default); - background: var(--color-bg-tertiary); color: var(--color-text-secondary); cursor: pointer; -} -.top-navbar__avatar img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; } -.top-navbar__meter { - display: inline-flex; flex-direction: column; gap: 3px; align-items: flex-start; - padding: 4px 10px; border-radius: 8px; border: 1px solid var(--color-border-default); - background: var(--color-bg-tertiary); cursor: pointer; min-width: 150px; -} -.top-navbar__meter-label { font-size: 0.6875rem; color: var(--color-text-secondary); } -.top-navbar__meter-bar { width: 100%; height: 5px; border-radius: 3px; background: var(--color-bg-secondary); overflow: hidden; } -.top-navbar__meter-bar i { display: block; height: 100%; background: var(--color-primary); } -@media (max-width: 639px) { - .top-navbar { display: none; } -} - /* Sidebar */ .sidebar { position: fixed; diff --git a/core/http/react-ui/src/App.jsx b/core/http/react-ui/src/App.jsx index 37ebf384f..b922499b5 100644 --- a/core/http/react-ui/src/App.jsx +++ b/core/http/react-ui/src/App.jsx @@ -3,7 +3,6 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import Sidebar from './components/Sidebar' import OperationsBar from './components/OperationsBar' -import TopNavbar from './components/TopNavbar' import { ToastContainer, useToast } from './components/Toast' import { systemApi } from './utils/api' import { useTheme } from './contexts/ThemeContext' @@ -99,7 +98,6 @@ export default function App() { 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 diff --git a/core/http/react-ui/src/components/HomeRoute.jsx b/core/http/react-ui/src/components/HomeRoute.jsx deleted file mode 100644 index 6e0008d8f..000000000 --- a/core/http/react-ui/src/components/HomeRoute.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import { lazy, Suspense } from 'react' -import { Navigate } from 'react-router-dom' -import { useAuth } from '../context/AuthContext' -import { useDeployment } from '../contexts/DeploymentContext' -import { resolveHome } from '../utils/resolveHome' -import RouteFallback from './RouteFallback' - -const Home = lazy(() => import('../pages/Home')) - -// Index-route element. Waits for auth + deployment signals to load (so we never -// flash the wrong landing), then either renders Home or redirects to the cell's -// landing page. Redirecting (rather than rendering Nodes/Chat inline at /app) -// keeps each target's own route guard, active-nav state, and deep-linkability. -export default function HomeRoute() { - const { isAdmin, loading: authLoading } = useAuth() - const { distributed, p2pEnabled, loading: deployLoading } = useDeployment() - - if (authLoading || deployLoading) return - - const target = resolveHome({ isAdmin, distributed, p2pEnabled }) - if (target) return - - return ( - }> - - - ) -} diff --git a/core/http/react-ui/src/components/Sidebar.jsx b/core/http/react-ui/src/components/Sidebar.jsx index 679897e33..58438fd51 100644 --- a/core/http/react-ui/src/components/Sidebar.jsx +++ b/core/http/react-ui/src/components/Sidebar.jsx @@ -5,11 +5,9 @@ import ThemeToggle from './ThemeToggle' import LanguageSwitcher from './LanguageSwitcher' import { useAuth } from '../context/AuthContext' import { useBranding } from '../contexts/BrandingContext' -import { useDeployment } from '../contexts/DeploymentContext' import { apiUrl } from '../utils/basePath' import { preloadRoute } from '../router' import { consoles, firstVisiblePath, consolePaths } from './console/consoleConfig' -import { clusterPinItems, shouldCollapseCreate } from '../utils/sidebarPolicy' const COLLAPSED_KEY = 'localai_sidebar_collapsed' const SECTIONS_KEY = 'localai_sidebar_sections' @@ -60,13 +58,11 @@ function NavItem({ item, onClose, collapsed }) { ) } -function loadSectionState(collapseCreate = false) { - // Tiers render expanded by default; users can collapse any tier and the - // choice persists (stored values override defaults). In cluster cells we - // start Create collapsed so the pinned cluster group leads - but only when - // the user has not already expressed a preference. +function loadSectionState() { + // Tiers render expanded by default (the redesign favours showing the few + // intent groups up front); users can still collapse any tier and the choice + // is persisted. Stored values override the defaults so a saved collapse wins. const defaults = Object.fromEntries(sections.map(s => [s.id, true])) - if (collapseCreate) defaults.create = false try { const stored = localStorage.getItem(SECTIONS_KEY) return stored ? { ...defaults, ...JSON.parse(stored) } : defaults @@ -81,34 +77,20 @@ function saveSectionState(state) { export default function Sidebar({ isOpen, onClose }) { const { t } = useTranslation('nav') - const { isAdmin, authEnabled, user, logout, hasFeature } = useAuth() - // Deployment shape (server features + p2p) drives the adaptive sidebar; the - // shared context replaces the sidebar's own /api/features fetch so the - // landing resolver, navbar, and this policy agree on one snapshot. - const deployment = useDeployment() - const features = deployment.features - // Shared shape for the console gating helpers (consoleConfig.js); in scope for - // both the pinned cluster group and the console-tier rendering below. - const auth = { isAdmin, authEnabled, hasFeature, features } - const collapseCreate = shouldCollapseCreate(auth, deployment) + const [features, setFeatures] = useState({}) const [collapsed, setCollapsed] = useState(() => { try { return localStorage.getItem(COLLAPSED_KEY) === 'true' } catch (_) { return false } }) const [openSections, setOpenSections] = useState(loadSectionState) + const { isAdmin, authEnabled, user, logout, hasFeature } = useAuth() const branding = useBranding() const navigate = useNavigate() const location = useLocation() const closeBtnRef = useRef(null) - // Apply the cluster-cell Create-collapse default once, only when the user has - // no stored section preference (so we never override an explicit choice). useEffect(() => { - if (deployment.loading) return - let hasStored = false - try { hasStored = !!localStorage.getItem(SECTIONS_KEY) } catch { hasStored = false } - if (hasStored || !collapseCreate) return - setOpenSections(prev => (prev.create === false ? prev : { ...prev, create: false })) - }, [deployment.loading, collapseCreate]) + fetch(apiUrl('/api/features')).then(r => r.json()).then(setFeatures).catch(() => {}) + }, []) // Stay in sync with external collapse dispatches (e.g. the chat // page's focus mode). The collapse-toggle button still owns the @@ -175,6 +157,8 @@ export default function Sidebar({ isOpen, onClose }) { } const visibleTopItems = topItems.filter(filterItem) + // Shared shape for the console gating helpers (consoleConfig.js). + const auth = { isAdmin, authEnabled, hasFeature, features } // Inline sections (Create) carry no gating; a plain filterItem pass suffices. const getVisibleSectionItems = (section) => section.items.filter(filterItem) @@ -215,28 +199,6 @@ export default function Sidebar({ isOpen, onClose }) { ))} - {/* Pinned Cluster quick-access (admin + distributed/p2p). Same gate - as the Operate rail; surfaced at the top for cluster operators. */} - {(() => { - const pinned = clusterPinItems(auth, deployment) - if (pinned.length === 0) return null - return ( -
-
{t('operate.cluster')}
-
- {pinned.map(item => ( - - ))} -
-
- ) - })()} - {/* Collapsible sections */} {sections.map(section => { const visibleItems = getVisibleSectionItems(section) diff --git a/core/http/react-ui/src/components/TopNavbar.jsx b/core/http/react-ui/src/components/TopNavbar.jsx deleted file mode 100644 index a1227b0a9..000000000 --- a/core/http/react-ui/src/components/TopNavbar.jsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useNavigate } from 'react-router-dom' -import { useTranslation } from 'react-i18next' -import { useAuth } from '../context/AuthContext' -import { useDeployment } from '../contexts/DeploymentContext' -import { useTheme } from '../contexts/ThemeContext' -import { launchAssistantChat } from '../utils/launchAssistantChat' -import TokenUsageMeter from './navbar/TokenUsageMeter' - -// Desktop top bar. Complementary to the mobile-only header in App.jsx: this is -// hidden on small screens (see .top-navbar CSS) and shows deployment/admin -// affordances on wide screens where the sidebar footer is far from the content. -export default function TopNavbar() { - const { t } = useTranslation('nav') - const navigate = useNavigate() - const { isAdmin, authEnabled, user } = useAuth() - const { features, distributed, p2pEnabled } = useDeployment() - const { theme, toggleTheme } = useTheme() - - const modeLabel = distributed - ? t('topbar.modeDistributed') - : p2pEnabled - ? t('topbar.modeSwarm') - : t('topbar.modeSingle') - - const showAssistantJump = isAdmin && !!features.localai_assistant - const showAvatar = authEnabled && user - const themeLabel = theme === 'dark' ? t('switchToLightMode') : t('switchToDarkMode') - - return ( -
-
- {isAdmin && ( - -