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
+ ?
+ :
}
+ {title}
+
+ )
+ })
+}
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)
+})