diff --git a/core/http/react-ui/e2e/addon-navigation.spec.js b/core/http/react-ui/e2e/addon-navigation.spec.js index a0034a3c3..86435df7b 100644 --- a/core/http/react-ui/e2e/addon-navigation.spec.js +++ b/core/http/react-ui/e2e/addon-navigation.spec.js @@ -26,22 +26,40 @@ async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true } test.describe('Add-on navigation', () => { - test('the sidebar shows Chat and one Apps entry, with no Pinned section', async ({ page }) => { + test('the sidebar lists the pinned apps under Apps, in pin order, with Show all last', async ({ page }) => { await setup(page) await page.goto('/app') const nav = page.getByRole('navigation') await expect(nav.getByRole('link', { name: 'Chat' })).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) + // Apps, then the pinned apps in pin order, then Show all as the last line. + await expect(nav.getByRole('link', { name: 'Apps', exact: true })).toHaveCount(1) + const pinned = nav.getByTestId('apps-pinned-link') + await expect(pinned).toHaveCount(2) + await expect(pinned.nth(0)).toHaveAttribute('data-app', 'studio') + await expect(pinned.nth(0)).toHaveText('Studio') + await expect(pinned.nth(1)).toHaveAttribute('data-app', 'talk') + await expect(pinned.nth(1)).toHaveText('Talk') + await expect(nav.getByTestId('apps-show-all')).toHaveText('Show all') + const group = await nav.locator('a.nav-item').evaluateAll(els => els.map(e => e.textContent.trim())) + const at = group.indexOf('Apps') + expect(group.slice(at, at + 4)).toEqual(['Apps', 'Studio', 'Talk', 'Show all']) + await expect(pinned.nth(0)).toHaveAttribute('href', /\/app\/addons\/studio\//) + // There is no Pinned section. 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('the sidebar follows the pin order and shows no pinned app when none is pinned', async ({ page }) => { + await setup(page, { pins: ['talk', 'studio', 'missing'] }) + await page.goto('/app') + const nav = page.getByRole('navigation') + await expect(nav.getByTestId('apps-pinned-link')).toHaveText(['Talk', 'Studio']) + await expect(nav.getByTestId('apps-show-all')).toBeVisible() + await page.unroute('**/api/addons/pins') + await page.route('**/api/addons/pins', route => route.fulfill({ json: [] })) + await page.goto('/app') + await expect(nav.getByTestId('apps-show-all')).toBeVisible() + await expect(nav.getByTestId('apps-pinned-link')).toHaveCount(0) }) test('the Apps entry opens the drawer, where pinned add-ons are shown and their order can be changed', async ({ page }) => { diff --git a/core/http/react-ui/src/components/AppsPinnedLinks.jsx b/core/http/react-ui/src/components/AppsPinnedLinks.jsx new file mode 100644 index 000000000..2c56add3a --- /dev/null +++ b/core/http/react-ui/src/components/AppsPinnedLinks.jsx @@ -0,0 +1,48 @@ +/* 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 { buildTiles, sidebarPinLinks } from '../utils/addonTiles' +import { apiUrl } from '../utils/basePath' + +// The caller's pinned apps, drawn as children of the Apps entry in pin order. +// The apps come from the add-on registry, which the drawer feeds on every list +// read, so the links follow the drawer. A pin without an installed app is not +// drawn, and nothing is drawn when nothing is pinned. +export default function AppsPinnedLinks({ onClose }) { + const { t } = useTranslation('nav') + const { 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 app pin needs the apps list once; the registry keeps it current. + useEffect(() => (needsApps ? acquire() : undefined), [needsApps, acquire]) + + const links = useMemo( + () => sidebarPinLinks(pinIds, buildTiles(registry.apps, builtin, { language: i18n.language })), + [pinIds, registry.apps, builtin, i18n.language], + ) + + return links.map(link => { + const title = link.titleKey ? t(link.titleKey) : link.title + return ( + `nav-item nav-item--sub${isActive ? ' active' : ''}${link.stopped ? ' nav-item--muted' : ''}`} + data-testid="apps-pinned-link" + data-app={link.id} + > + {link.source === 'builtin' || !link.icon + ? + ) + }) +} diff --git a/core/http/react-ui/src/components/Sidebar.jsx b/core/http/react-ui/src/components/Sidebar.jsx index 10acd72b6..7ae71e40e 100644 --- a/core/http/react-ui/src/components/Sidebar.jsx +++ b/core/http/react-ui/src/components/Sidebar.jsx @@ -13,13 +13,14 @@ import { primaryDestinations, visiblePrimary } from '../utils/navigationModel' import { useNavigation } from '../context/NavigationContext' import { useOperations } from '../hooks/useOperations' import Icon from './Icon' +import AppsPinnedLinks from './AppsPinnedLinks' 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 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) +// Workspace. Pinned apps are listed under Apps. 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 @@ -219,8 +220,10 @@ export default function Sidebar({ isOpen, onClose }) { {(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. */} + {/* The Apps group: the pinned apps in pin order, then the full list + of apps as the last line. Show all has no active style of its + own; the Apps entry above carries it. */} + {!legacy && item.addonsEntry && !collapsed && } {!legacy && item.addonsEntry && !collapsed && ( {t('items.showAll')} diff --git a/core/http/react-ui/src/utils/addonTiles.js b/core/http/react-ui/src/utils/addonTiles.js index 36075928e..afdd7a129 100644 --- a/core/http/react-ui/src/utils/addonTiles.js +++ b/core/http/react-ui/src/utils/addonTiles.js @@ -91,3 +91,19 @@ export function movePin(ids, id, delta) { ;[next[i], next[j]] = [next[j], next[i]] return next } + +// The links shown under the Apps entry of the sidebar: the pinned tiles in pin +// order, each with the page it opens. A pin without a tile (not installed, hidden) +// is left out. A stopped add-on has no live main page, so it opens its detail view. +export function sidebarPinLinks(ids, tiles) { + return resolvePins(ids, tiles).map(tile => ({ + id: tile.id, + to: tile.target || tile.detailPath, + title: tile.title, + titleKey: tile.titleKey, + icon: tile.icon, + iconName: tile.iconName, + source: tile.source, + stopped: tile.state === 'stopped', + })) +} diff --git a/core/http/react-ui/tests/unit/navigation.test.mjs b/core/http/react-ui/tests/unit/navigation.test.mjs index b1cc9efce..bcc5d15b3 100644 --- a/core/http/react-ui/tests/unit/navigation.test.mjs +++ b/core/http/react-ui/tests/unit/navigation.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { - PIN_CAP, tileFromApp, tileFromBuiltin, buildTiles, filterTiles, resolvePins, togglePin, movePin, + PIN_CAP, sidebarPinLinks, tileFromApp, tileFromBuiltin, buildTiles, filterTiles, resolvePins, togglePin, movePin, } from '../../src/utils/addonTiles.js' import { primaryDestinations, visiblePrimary } from '../../src/utils/navigationModel.js' import { createPinsApi } from '../../src/utils/pinsApi.js' @@ -116,3 +116,30 @@ test('pins api: a failed answer is an error with the status', async () => { const api = createPinsApi({ base: (p) => p, fetchImpl: async () => ({ ok: false, status: 503, json: async () => ({}) }) }) await assert.rejects(() => api.listPins(), (e) => e.status === 503) }) + +test('sidebarPinLinks: pinned apps are listed in pin order with their main page', () => { + const tiles = buildTiles([app({ id: 'notes', title: 'Notes' }), app({ id: 'studio', title: 'Studio', mainPage: '/app/addons/studio/home' })], []) + const links = sidebarPinLinks(['studio', 'notes'], tiles) + assert.deepEqual(links.map(l => l.id), ['studio', 'notes']) + assert.deepEqual(links.map(l => l.title), ['Studio', 'Notes']) + assert.deepEqual(links.map(l => l.to), ['/app/addons/studio/home', '/app/addons/notes/home']) + assert.ok(links.every(l => l.stopped === false)) +}) + +test('sidebarPinLinks: nothing is listed when no app is pinned', () => { + const tiles = buildTiles([app()], []) + assert.deepEqual(sidebarPinLinks([], tiles), []) + assert.deepEqual(sidebarPinLinks(undefined, tiles), []) +}) + +test('sidebarPinLinks: a pin that is not installed is not listed', () => { + const tiles = buildTiles([app({ id: 'notes' })], []) + assert.deepEqual(sidebarPinLinks(['gone', 'notes', 'also-gone'], tiles).map(l => l.id), ['notes']) +}) + +test('sidebarPinLinks: a stopped app opens its detail view and is marked stopped', () => { + const tiles = buildTiles([app({ enabled: false, state: 'stopped' })], []) + const [link] = sidebarPinLinks(['notes'], tiles) + assert.equal(link.to, '/app/addons?detail=notes') + assert.equal(link.stopped, true) +})