diff --git a/core/http/react-ui/e2e/addon-navigation.spec.js b/core/http/react-ui/e2e/addon-navigation.spec.js index 9eda5b19b..1ccf91734 100644 --- a/core/http/react-ui/e2e/addon-navigation.spec.js +++ b/core/http/react-ui/e2e/addon-navigation.spec.js @@ -26,21 +26,34 @@ async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true } test.describe('Add-on navigation', () => { - test('the sidebar shows Chat, Add-ons and the Pinned section', async ({ page }) => { + test('the sidebar shows Chat and one Apps entry, with no Pinned section', async ({ page }) => { await setup(page) await page.goto('/app') const nav = page.getByRole('navigation') await expect(nav.getByRole('link', { name: 'Chat' })).toBeVisible() - await expect(nav.getByRole('link', { name: 'Add-ons' })).toBeVisible() - const pinned = page.getByTestId('pinned-nav') - await expect(pinned.getByRole('link', { name: 'Studio' })).toBeVisible() - await expect(pinned.getByRole('link', { name: 'Talk' })).toBeVisible() + // Apps, then Show all: both open the drawer, and Show all comes last in the group. + const apps = nav.locator('a.nav-item[href="/app/addons"]') + await expect(apps).toHaveCount(2) + await expect(apps.nth(0)).toHaveText('Apps') + await expect(apps.nth(1)).toHaveText('Show all') + await expect(nav.getByRole('link', { name: 'Apps' })).toHaveCount(1) + await expect(page.getByTestId('pinned-nav')).toHaveCount(0) + await expect(page.locator('.sidebar-section-title', { hasText: 'Pinned' })).toHaveCount(0) + // Pinned add-ons are not links in the sidebar. + await expect(nav.getByRole('link', { name: 'Studio' })).toHaveCount(0) + await expect(nav.getByRole('link', { name: 'Talk' })).toHaveCount(0) }) - test('an empty Pinned section is hidden', async ({ page }) => { - await setup(page, { pins: [] }) + test('the Apps entry opens the drawer, where pinned add-ons are shown and their order can be changed', async ({ page }) => { + const server = await setup(page, { pins: ['studio', 'talk'] }) await page.goto('/app') - await expect(page.getByTestId('pinned-nav')).toHaveCount(0) + await page.getByRole('navigation').getByRole('link', { name: 'Apps' }).click() + await expect(page).toHaveURL(/\/app\/addons$/) + await expect(page.getByTestId('addon-tile')).toHaveCount(2) + await expect(page.getByTestId('pin-toggle')).toHaveCount(2) + for (const toggle of await page.getByTestId('pin-toggle').all()) await expect(toggle).toHaveAttribute('aria-pressed', 'true') + await page.locator('[data-testid="addon-tile"][data-app="studio"]').getByTestId('pin-later').click() + await expect.poll(() => server.saved()).toEqual(['talk', 'studio']) }) test('the drawer lists tiles, searches them and pins from the keyboard', async ({ page }) => { @@ -117,14 +130,14 @@ test.describe('Add-on navigation', () => { await setup(page, { addons: false }) await page.goto('/app/addons') await expect(page).toHaveURL(/\/app$/) - await expect(page.getByRole('navigation').getByRole('link', { name: 'Add-ons' })).toHaveCount(0) + await expect(page.getByRole('navigation').locator('a.nav-item[href="/app/addons"]')).toHaveCount(0) }) test('the legacy flag brings the old sidebar back', async ({ page }) => { await setup(page) await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: false } })) await page.goto('/app') - await expect(page.getByTestId('pinned-nav')).toHaveCount(0) + await expect(page.getByTestId('apps-show-all')).toHaveCount(0) // Home, Models and Chat head the old sidebar; Studio and Talk are add-ons and are not in it. await expect(page.getByRole('navigation').getByRole('link', { name: 'Chat' })).toBeVisible() await expect(page.getByRole('navigation').getByRole('link', { name: 'Studio' })).toHaveCount(0) @@ -158,7 +171,7 @@ test.describe('Add-on navigation', () => { await expect(toggle).toHaveAccessibleName(/(Pin|Unpin) /) }) - test('a pin of an add-on that is no longer listed is not drawn and shows no error', async ({ page }) => { + test('a pin of an add-on that is no longer listed is not drawn in the drawer and shows no error', async ({ page }) => { await setup(page, { pins: ['studio', 'removed-addon'] }) // The Home page raises its own alert when the model list or a staging // operation fails. Both come from the shared test server, so a slow answer @@ -173,22 +186,21 @@ test.describe('Add-on navigation', () => { await route.fulfill({ json: { apps: [packagedApp('studio')] } }) }) const listed = page.waitForResponse(res => /\/api\/addons\/apps$/.test(res.url())) - await page.goto('/app') + await page.goto('/app/addons') await listed - // Two frames: the list reaches React and the sidebar redraws from it. + // Two frames: the list reaches React and the drawer redraws from it. await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) - const pinned = page.getByTestId('pinned-nav') - await expect(pinned.getByRole('link')).toHaveCount(1) + await expect(page.getByTestId('addon-tile')).toHaveCount(1) await expect(page.getByRole('alert')).toHaveCount(0) }) - test('a stopped pinned add-on is muted and opens the detail view', async ({ page }) => { + test('a stopped pinned add-on in the drawer opens the detail view', async ({ page }) => { await setup(page, { pins: ['notes'], apps: [app({ id: 'notes', title: 'Notes', enabled: false, state: 'stopped' })] }) await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor({ ...MEMBER, role: 'admin' }, { addons: true }) })) - await page.goto('/app') - const link = page.getByTestId('pinned-nav').getByRole('link', { name: /Notes/ }) - await expect(link).toHaveClass(/nav-item--muted/) - await link.click() + await page.goto('/app/addons') + const tile = page.locator('[data-testid="addon-tile"][data-app="notes"]') + await expect(tile).toHaveAttribute('data-state', 'stopped') + await tile.locator('.ad-tile__open').click() await expect(page).toHaveURL(/\/app\/addons\?detail=notes$/) await expect(page.getByTestId('addon-detail')).toBeVisible() }) diff --git a/core/http/react-ui/e2e/navigation.spec.js b/core/http/react-ui/e2e/navigation.spec.js index b7a707c8a..68752fc9d 100644 --- a/core/http/react-ui/e2e/navigation.spec.js +++ b/core/http/react-ui/e2e/navigation.spec.js @@ -20,11 +20,11 @@ test.describe('Navigation', () => { await expect(models.locator('.nav-label')).toHaveText('Models') }) - test('the sidebar has Chat, Models and Add-ons, no Settings and no Create tier', async ({ page }) => { + test('the sidebar has Chat, Models and Apps, no Settings and no Create tier', async ({ page }) => { await page.goto('/app') await expect(page.locator('.sidebar-section-title', { hasText: 'Create' })).toHaveCount(0) for (const href of ['/app/chat', '/app/models', '/app/addons']) { - await expect(page.locator(`.sidebar-nav a.nav-item[href="${href}"]`)).toBeVisible() + await expect(page.locator(`.sidebar-nav a.nav-item[href="${href}"]`).first()).toBeVisible() } // Settings lives under the Operate hub, not in the sidebar. await expect(page.locator('.sidebar-nav a.nav-item[href="/app/settings"]')).toHaveCount(0) @@ -35,7 +35,7 @@ test.describe('Navigation', () => { await settingsTab.click() await expect(page).toHaveURL(/\/app\/settings/) await expect(page.locator('.dk-hubtabs [data-hub-tab="settings"]')).toHaveAttribute('aria-current', 'page') - // Studio and Talk are add-ons: the sidebar lists them only as pins, and only while they are installed. + // Studio and Talk are add-ons: the sidebar does not list them; they are in the drawer while installed. for (const href of ['/app/studio', '/app/talk']) { await expect(page.locator(`.sidebar-nav a.nav-item[href="${href}"]`)).toHaveCount(0) } diff --git a/core/http/react-ui/e2e/not-found.spec.js b/core/http/react-ui/e2e/not-found.spec.js index 2ee6f85a6..6c3c7cbb0 100644 --- a/core/http/react-ui/e2e/not-found.spec.js +++ b/core/http/react-ui/e2e/not-found.spec.js @@ -9,7 +9,7 @@ test.describe('404', () => { await expect(nf).toContainText('/app/no-such-page') await expect(nf.getByRole('link', { name: 'Go home' })).toHaveAttribute('href', '/app') const places = nf.getByRole('navigation', { name: 'Where to go' }) - for (const name of ['Home', 'Models', 'Chat', 'Add-ons', 'Build', 'Operate']) { + for (const name of ['Home', 'Models', 'Chat', 'Apps', 'Build', 'Operate']) { await expect(places.getByRole('link', { name })).toBeVisible() } // Settings is a tab of the Operate hub, so the places do not list it. diff --git a/core/http/react-ui/public/locales/en/nav.json b/core/http/react-ui/public/locales/en/nav.json index e57397aa0..c17375216 100644 --- a/core/http/react-ui/public/locales/en/nav.json +++ b/core/http/react-ui/public/locales/en/nav.json @@ -16,8 +16,7 @@ "recognition": "Recognition", "build": "Build", "operate": "Operate", - "workspace": "Workspace", - "pinned": "Pinned" + "workspace": "Workspace" }, "operate": { "inference": "Inference", @@ -67,7 +66,8 @@ "trafficModels": "Models", "gpuHost": "GPU and host", "prometheus": "Prometheus", - "addons": "Add-ons" + "addons": "Apps", + "showAll": "Show all" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 980dd7492..502a504e1 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -9692,3 +9692,6 @@ button.lane:hover { @media (prefers-reduced-motion: reduce) { .lai-icon--beat { animation: none; } } + +/* The "Show all" line under the Apps entry: its label lines up with the labels above it. */ +.sidebar-nav .nav-item.nav-item--sub { padding-inline-start: 36px; } diff --git a/core/http/react-ui/src/components/PinnedNav.jsx b/core/http/react-ui/src/components/PinnedNav.jsx deleted file mode 100644 index 3bd4f80d3..000000000 --- a/core/http/react-ui/src/components/PinnedNav.jsx +++ /dev/null @@ -1,65 +0,0 @@ -/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ -import { useEffect, useMemo } from 'react' -import { NavLink } from 'react-router-dom' -import { useTranslation } from 'react-i18next' -import Icon from './Icon' -import { useNavigation } from '../context/NavigationContext' -import { useAddonRegistry } from '../addons/useAddonRegistry' -import { navigationItems } from '../addons/registryState' -import { buildTiles, resolvePins } from '../utils/addonTiles' -import { apiUrl } from '../utils/basePath' - -// The Pinned section of the sidebar: the caller's pins in the chosen order. -// Each pin is resolved against the built-in catalog and the Apps list at -// render time, so a stopped add-on is muted, and a removed or hidden one is -// not drawn at all. An empty section is not drawn. -export default function PinnedNav({ onClose, collapsed }) { - const { t } = useTranslation('nav') - const { t: ta, i18n } = useTranslation('addons') - const { builtin, pinIds } = useNavigation() - const registry = useAddonRegistry() - const { acquire } = registry - const needsApps = pinIds.some(id => !builtin.some(b => b.id === id)) - // Resolving an add-on pin needs the Apps list once; the registry keeps it - // current while an add-on contributes UI. - useEffect(() => (needsApps ? acquire() : undefined), [needsApps, acquire]) - - const tiles = useMemo(() => buildTiles(registry.apps, builtin, { language: i18n.language }), [registry.apps, builtin, i18n.language]) - const pinned = useMemo(() => resolvePins(pinIds, tiles), [pinIds, tiles]) - const extra = useMemo(() => navigationItems(registry.apps, { language: i18n.language }), [registry.apps, i18n.language]) - if (pinned.length === 0) return null - - return ( -
- {!collapsed &&
{t('sections.pinned')}
} -
- {pinned.map(tile => { - const title = tile.titleKey ? t(tile.titleKey) : tile.title - const to = tile.target || tile.detailPath - const links = extra.filter(x => x.addonId === tile.id && x.path !== tile.target) - return ( -
- `nav-item${isActive ? ' active' : ''}${tile.state === 'stopped' ? ' nav-item--muted' : ''}`} - title={collapsed ? title : undefined} - > - {tile.source === 'builtin' || !tile.icon - ? - {tile.state === 'running' && links.map(link => ( - - {link.label} - - ))} -
- ) - })} -
-
- ) -} diff --git a/core/http/react-ui/src/components/Sidebar.jsx b/core/http/react-ui/src/components/Sidebar.jsx index 6bebba8a5..10acd72b6 100644 --- a/core/http/react-ui/src/components/Sidebar.jsx +++ b/core/http/react-ui/src/components/Sidebar.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { Fragment, useState, useEffect, useRef } from 'react' import { NavLink, useNavigate, useLocation } from 'react-router-dom' import { useTranslation } from 'react-i18next' import ThemeToggle from './ThemeToggle' @@ -11,7 +11,6 @@ import { hubs, hubEntryPath, hubOwnsPath } from './hub/hubConfig' import { topDestinations } from '../utils/destinations' import { primaryDestinations, visiblePrimary } from '../utils/navigationModel' import { useNavigation } from '../context/NavigationContext' -import PinnedNav from './PinnedNav' import { useOperations } from '../hooks/useOperations' import Icon from './Icon' @@ -19,8 +18,8 @@ const COLLAPSED_KEY = 'localai_sidebar_collapsed' const SECTIONS_KEY = 'localai_sidebar_sections' // The sidebar has two layouts. The four-item layout shows Home, Chat, Models -// and Add-ons, the Pinned section, and Build and Operate under -// Workspace. With the legacy switch on (features.addon_navigation === false) +// and Apps (the entry for the add-ons drawer), and Build and Operate under +// Workspace. Pinned add-ons are not in the sidebar; they are used from the drawer. With the legacy switch on (features.addon_navigation === false) // it shows Home, Models and Chat, and Workspace. Build and Operate are single // entries; each opens a hub whose tab bar lives in hub/hubConfig.js (shared // with HubLayout). The legacy entries are shared with the 404 page, which @@ -218,12 +217,19 @@ export default function Sidebar({ isOpen, onClose }) { {/* Top-level items */}
{(legacy ? visibleTopItems : [...visibleTopItems.filter(i => i.path === '/app'), ...primary]).map(item => ( - + + + {/* The last line of the Apps group: the full list of apps. It has + no active style of its own; the Apps entry above carries it. */} + {!legacy && item.addonsEntry && !collapsed && ( + + {t('items.showAll')} + + )} + ))}
- {!legacy && } - {/* Collapsible sections */} {(legacy ? sections : sections.filter(s => s.hubs)).map(section => { const visibleItems = getVisibleSectionItems(section) diff --git a/core/http/react-ui/src/context/NavigationContext.jsx b/core/http/react-ui/src/context/NavigationContext.jsx index f7c481aef..e3e8f1e84 100644 --- a/core/http/react-ui/src/context/NavigationContext.jsx +++ b/core/http/react-ui/src/context/NavigationContext.jsx @@ -3,8 +3,7 @@ import { useAuth } from './AuthContext' import { pinsApi } from '../utils/pinsClient' import { PIN_CAP, togglePin, movePin } from '../utils/addonTiles' -// What the shell needs to draw the Add-ons item, the drawer and the Pinned -// section: the built-in catalog and the caller's pins. Both come from the +// What the shell needs to draw the Apps item and the drawer: the built-in catalog and the caller's pins. Both come from the // server, so a pin follows the user across browsers. const EMPTY = Object.freeze({ status: 'idle', builtin: [], pinIds: [], diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md index 99550bda8..f0eabcfb9 100644 --- a/docs/content/features/addons.md +++ b/docs/content/features/addons.md @@ -1662,6 +1662,8 @@ sent with `Cache-Control: private, no-store`. ### Pins +Pins no longer appear in the sidebar. They are used from the add-ons drawer: a pinned tile there has buttons to move it earlier or later. + `GET /api/addons/pins` returns the caller's pinned add-on IDs as a JSON array, in order. `PUT /api/addons/pins` takes a JSON array of add-on IDs, replaces the caller's pins with it and returns the saved list. Both routes need an authenticated caller. They need no @@ -1709,11 +1711,11 @@ reach **Browse**, **Installed** and **Libraries** from the drawer. The tabs are convenience: the owner checks administration again on every call, so a request that skips the page is denied the same way (403). -The sidebar has four items: **Chat**, **Models**, **Add-ons** and **Settings**. Below them -a **Pinned** section lists the add-ons the user pinned, in the order the user chose. Pins -belong to the user, follow the user across browsers, and are capped at eight in the -sidebar. A pin whose add-on is stopped is muted and opens the detail view; a pin whose -add-on was removed or is hidden is not drawn. An empty section is not drawn. Operators who +The sidebar has the items **Chat**, **Models** and **Apps**, and below Apps a **Show all** +link. Both open the add-ons drawer at `/app/addons`. Pins no longer appear in the sidebar: +the user pins add-ons and changes their order with the buttons on a pinned tile in the drawer. Pins belong to the user, follow the user across browsers, and are +capped at eight. A pin whose add-on is stopped opens the detail view; a pin whose add-on +was removed or is hidden is not drawn. Operators who need the previous sidebar can set `LOCALAI_LEGACY_NAVIGATION=true` (flag `--legacy-navigation`); it also brings back the Add-ons tab of the Build hub, which then opens `/app/addons`. The switch is a rollback aid and is removed one release after the new navigation ships; the UI reads it from the `addon_navigation` key of `/api/features`.