feat(ui): list pinned apps under Apps in the sidebar

A pinned app now shows as an indented child of the Apps entry, with its icon and title, in pin order. Show all stays the last child. The pins come from GET /api/addons/pins through the navigation context, and the apps 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, nothing extra shows when no app is pinned, and the children are hidden when the sidebar is collapsed to the rail.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-10-21 10:42:56 +00:00
1 parent aa4a0b14c9
commit 4f355b7792
5 files changed
+126 -14

No files matched your search

+28 -10
View File
@@ -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 }) => {
@@ -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 (
<NavLink
key={link.id}
to={link.to}
onClick={onClose}
className={({ isActive }) => `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
? <Icon name={link.iconName || 'puzzle'} className="nav-icon" aria-hidden="true" />
: <img className="nav-icon" src={apiUrl(link.icon)} alt="" width="18" height="18" />}
<span className="nav-label">{title}</span>
</NavLink>
)
})
}
@@ -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 => (
<Fragment key={item.path}>
<NavItem item={item} onClose={onClose} collapsed={collapsed} />
{/* 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 && <AppsPinnedLinks onClose={onClose} />}
{!legacy && item.addonsEntry && !collapsed && (
<NavLink to={item.path} onClick={onClose} className="nav-item nav-item--sub" data-testid="apps-show-all">
<span className="nav-label">{t('items.showAll')}</span>
+16
View File
@@ -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',
}))
}
@@ -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)
})