From 2262aa0aaef0d43d7ed822f2c0e701c647fc0945 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 21 Oct 2026 10:20:20 +0000 Subject: [PATCH] feat(ui): replace the Pinned sidebar section with an Apps entry The sidebar no longer lists pinned add-ons. It shows one Apps entry that opens the add-ons drawer, with a Show all link below it that opens the same drawer. Pins are used from the drawer, where a pinned tile keeps its pin and move buttons. The server pins API is unchanged. Remove the PinnedNav component and its locale title, rename the items.addons label to Apps, and update the e2e specs and the add-ons documentation to match. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto --- .../react-ui/e2e/addon-navigation.spec.js | 52 +++++++++------ core/http/react-ui/e2e/navigation.spec.js | 6 +- core/http/react-ui/e2e/not-found.spec.js | 2 +- core/http/react-ui/public/locales/en/nav.json | 6 +- core/http/react-ui/src/App.css | 3 + .../react-ui/src/components/PinnedNav.jsx | 65 ------------------- core/http/react-ui/src/components/Sidebar.jsx | 20 ++++-- .../src/context/NavigationContext.jsx | 3 +- docs/content/features/addons.md | 12 ++-- 9 files changed, 63 insertions(+), 106 deletions(-) delete mode 100644 core/http/react-ui/src/components/PinnedNav.jsx 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`.