From 88ccc04b34489bd7f693898667d16f0150ae860d Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 21 Oct 2026 11:07:33 +0000 Subject: [PATCH] feat(ui): show a first-run card on Apps when nothing is installed An administrator who opens Apps with no add-on installed now sees a short card with three steps: choose a library, browse and install, start the add-on. Its button opens the Browse tab. The card is hidden once an add-on is installed and for anyone who cannot install. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto --- .../react-ui/e2e/addon-navigation.spec.js | 52 ++++++++++++++++++- .../react-ui/public/locales/en/addons.json | 10 ++++ .../src/components/addons/AppsView.jsx | 16 +++++- .../src/components/addons/addonState.js | 6 +++ .../react-ui/src/components/addons/addons.css | 2 + .../react-ui/tests/unit/navigation.test.mjs | 8 +++ 6 files changed, 90 insertions(+), 4 deletions(-) diff --git a/core/http/react-ui/e2e/addon-navigation.spec.js b/core/http/react-ui/e2e/addon-navigation.spec.js index 6454c6135..122c9405c 100644 --- a/core/http/react-ui/e2e/addon-navigation.spec.js +++ b/core/http/react-ui/e2e/addon-navigation.spec.js @@ -9,9 +9,9 @@ import { FACE_MODEL, clipFile, faces, match, mockIdentity, people, photoFile } f // Studio and Talk are packaged add-ons, not built-in tiles: the specs install // the real packages. No page is built in today, so `builtin` is empty unless a // spec makes one up to check the built-in rules. -async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true, builtin = [], studio = true } = {}) { +async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true, builtin = [], studio = true, user = MEMBER } = {}) { let saved = pins - await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons }) })) + await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(user, { addons }) })) await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: true } })) await page.route('**/api/addons/builtin', route => route.fulfill({ json: { builtin } })) // mockAddons installs the Apps routes with the real response shape ({ apps }). @@ -64,6 +64,27 @@ test.describe('Add-on navigation', () => { await expect(nav.getByTestId('apps-pinned-link')).toHaveCount(0) }) + test('the icon rail shows Apps only, centred, with no pinned app', async ({ page }) => { + await setup(page) + for (const size of [{ width: 1280, height: 800, collapsed: true }, { width: 1000, height: 800, collapsed: false }]) { + await page.setViewportSize({ width: size.width, height: size.height }) + await page.addInitScript(collapsed => { + try { localStorage.setItem('localai_sidebar_collapsed', String(collapsed)) } catch (_) { /* ignore */ } + }, size.collapsed) + await page.goto('/app') + const nav = page.getByRole('navigation') + await expect(nav.locator('a.nav-item[href="/app/addons"]')).toBeVisible() + await expect(nav.getByTestId('apps-pinned-link').filter({ visible: true })).toHaveCount(0) + const rail = await page.locator('.sidebar').boundingBox() + const icons = await nav.locator('a.nav-item:visible .nav-icon').evaluateAll(els => els.map(e => { + const r = e.getBoundingClientRect() + return (r.left + r.right) / 2 + })) + expect(icons.length).toBeGreaterThan(0) + for (const x of icons) expect(Math.abs(x - (rail.x + rail.width / 2))).toBeLessThan(2) + } + }) + 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') @@ -91,6 +112,33 @@ test.describe('Add-on navigation', () => { expect(server.saved()).toEqual(['notes']) }) + test('the first-run card shows to an administrator with nothing installed and leads to Browse', async ({ page }) => { + await setup(page, { user: ADMIN, studio: false, pins: [] }) + await page.goto('/app/addons') + const card = page.getByTestId('addons-first-run') + await expect(card).toBeVisible() + await expect(card.getByRole('listitem')).toHaveCount(3) + await expect(card).toContainText('official library is registered by default') + await expect(page.getByTestId('addon-tile')).toHaveCount(0) + await card.getByTestId('addons-first-run-browse').click() + await expect(page).toHaveURL(/tab=browse/) + await expect(page.getByTestId('addons-first-run')).toHaveCount(0) + }) + + test('the first-run card is hidden once an add-on is installed', async ({ page }) => { + await setup(page, { user: ADMIN, pins: [] }) + await page.goto('/app/addons') + await expect(page.getByTestId('addon-tile').first()).toBeVisible() + await expect(page.getByTestId('addons-first-run')).toHaveCount(0) + }) + + test('the first-run card is hidden from a caller who cannot install add-ons', async ({ page }) => { + await setup(page, { studio: false, pins: [] }) + await page.goto('/app/addons') + await expect(page.getByRole('heading', { name: 'No apps yet' })).toBeVisible() + await expect(page.getByTestId('addons-first-run')).toHaveCount(0) + }) + test('a stopped tile opens the detail view and never the main page', async ({ page }) => { await setup(page, { apps: [app({ id: 'notes', title: 'Notes', enabled: false, state: 'stopped', mainPage: undefined })] }) await page.goto('/app/addons') diff --git a/core/http/react-ui/public/locales/en/addons.json b/core/http/react-ui/public/locales/en/addons.json index 3201f05ec..85ee3fab9 100644 --- a/core/http/react-ui/public/locales/en/addons.json +++ b/core/http/react-ui/public/locales/en/addons.json @@ -35,6 +35,16 @@ "apps": { "emptyBody": "When an administrator installs and enables an add-on, it appears here.", "emptyTitle": "No apps yet", + "firstRun": { + "browse": "Browse add-ons", + "browseBody": "Pick an add-on from a library and install it.", + "browseTitle": "Browse and install.", + "libraryBody": "The official library is registered by default. You can add more under Libraries.", + "libraryTitle": "Choose a library.", + "startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.", + "startTitle": "Start it.", + "title": "Welcome to Apps" + }, "headless": "Runs in the background and has no page of its own.", "listLabel": "Apps", "loading": "Loading apps", diff --git a/core/http/react-ui/src/components/addons/AppsView.jsx b/core/http/react-ui/src/components/addons/AppsView.jsx index f8bd5c793..391fa3e9f 100644 --- a/core/http/react-ui/src/components/addons/AppsView.jsx +++ b/core/http/react-ui/src/components/addons/AppsView.jsx @@ -18,7 +18,7 @@ import { useNavigation } from '../../context/NavigationContext' import { buildTiles, filterTiles, resolvePins } from '../../utils/addonTiles' import { apiUrl } from '../../utils/basePath' import { StateChip, AppIcon, UnknownNotice, UpdateChip } from './AddonBits' -import { appActions, localizedText, toolSummary } from './addonState' +import { appActions, localizedText, showFirstRun, toolSummary } from './addonState' import { describeError, describeOutcome } from './errors' import { useAddonActions } from './useAddonActions' @@ -159,7 +159,19 @@ export default function AppsView({ api }) { )} - {shown.length === 0 ? ( + {showFirstRun({ apps: state.apps, isAdmin, canList }) ? ( +
+

{t('apps.firstRun.title')}

+
    +
  1. {t('apps.firstRun.libraryTitle')} {t('apps.firstRun.libraryBody')}
  2. +
  3. {t('apps.firstRun.browseTitle')} {t('apps.firstRun.browseBody')}
  4. +
  5. {t('apps.firstRun.startTitle')} {t('apps.firstRun.startBody')}
  6. +
+
+ {t('apps.firstRun.browse')} +
+
+ ) : shown.length === 0 ? ( ) : (
    diff --git a/core/http/react-ui/src/components/addons/addonState.js b/core/http/react-ui/src/components/addons/addonState.js index 6419e5175..453402d0e 100644 --- a/core/http/react-ui/src/components/addons/addonState.js +++ b/core/http/react-ui/src/components/addons/addonState.js @@ -15,6 +15,12 @@ export function visibleTabs(isAdmin) { return TABS.filter(tab => isAdmin || !ADMIN_TABS.has(tab)) } +// The first-run card: shown to someone who can install add-ons, once the list +// has loaded and nothing is installed. It goes away with the first install. +export function showFirstRun({ apps, isAdmin, canList = true }) { + return Boolean(isAdmin) && Boolean(canList) && Array.isArray(apps) && apps.length === 0 +} + // The management link of an app opens the Installed tab on that add-on. export function resolveManage(params, isAdmin) { if (!isAdmin) return '' diff --git a/core/http/react-ui/src/components/addons/addons.css b/core/http/react-ui/src/components/addons/addons.css index 396b8fc43..a3030c11a 100644 --- a/core/http/react-ui/src/components/addons/addons.css +++ b/core/http/react-ui/src/components/addons/addons.css @@ -61,6 +61,8 @@ border-radius: var(--dk-radius-3); background: var(--dk-card); } +.ad-first__steps { margin: 0; padding-left: 22px; display: flex; flex-direction: column; gap: var(--dk-space-2); font-size: var(--dk-text-13); line-height: 1.5; } +.ad-first__steps li::marker { color: var(--dk-muted); font-family: var(--font-mono); } .ad-card__head { display: flex; align-items: flex-start; gap: var(--dk-space-3); min-width: 0; } .ad-card__chips { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--dk-space-1); } .ad-reasons { margin: var(--dk-space-1) 0 0; padding: 0; list-style: none; } diff --git a/core/http/react-ui/tests/unit/navigation.test.mjs b/core/http/react-ui/tests/unit/navigation.test.mjs index bcc5d15b3..f59ca9747 100644 --- a/core/http/react-ui/tests/unit/navigation.test.mjs +++ b/core/http/react-ui/tests/unit/navigation.test.mjs @@ -4,6 +4,7 @@ import { PIN_CAP, sidebarPinLinks, tileFromApp, tileFromBuiltin, buildTiles, filterTiles, resolvePins, togglePin, movePin, } from '../../src/utils/addonTiles.js' import { primaryDestinations, visiblePrimary } from '../../src/utils/navigationModel.js' +import { showFirstRun } from '../../src/components/addons/addonState.js' import { createPinsApi } from '../../src/utils/pinsApi.js' const app = (over = {}) => ({ @@ -143,3 +144,10 @@ test('sidebarPinLinks: a stopped app opens its detail view and is marked stopped assert.equal(link.to, '/app/addons?detail=notes') assert.equal(link.stopped, true) }) + +test('the first-run card shows only to an installer when nothing is installed', () => { + assert.equal(showFirstRun({ apps: [], isAdmin: true }), true) + assert.equal(showFirstRun({ apps: [app()], isAdmin: true }), false) + assert.equal(showFirstRun({ apps: [], isAdmin: false }), false) + assert.equal(showFirstRun({ apps: [], isAdmin: true, canList: false }), false) +})