From 4f355b77921a044396998a98b739742bb3dd0885 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 21 Oct 2026 10:42:56 +0000 Subject: [PATCH] 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 --- .../react-ui/e2e/addon-navigation.spec.js | 38 +++++++++++---- .../src/components/AppsPinnedLinks.jsx | 48 +++++++++++++++++++ core/http/react-ui/src/components/Sidebar.jsx | 9 ++-- core/http/react-ui/src/utils/addonTiles.js | 16 +++++++ .../react-ui/tests/unit/navigation.test.mjs | 29 ++++++++++- 5 files changed, 126 insertions(+), 14 deletions(-) create mode 100644 core/http/react-ui/src/components/AppsPinnedLinks.jsx 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) +})