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 <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-10-21 11:07:33 +00:00
1 parent c9eb6dcd7e
commit 88ccc04b34
6 files changed
+90 -4

No files matched your search

@@ -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')
@@ -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",
@@ -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 }) {
</>
)}
</div>
{shown.length === 0 ? (
{showFirstRun({ apps: state.apps, isAdmin, canList }) ? (
<section className="ad-card ad-first" data-testid="addons-first-run" aria-labelledby="ad-first-title">
<h2 className="ad-card__title" id="ad-first-title">{t('apps.firstRun.title')}</h2>
<ol className="ad-first__steps">
<li><strong>{t('apps.firstRun.libraryTitle')}</strong> {t('apps.firstRun.libraryBody')}</li>
<li><strong>{t('apps.firstRun.browseTitle')}</strong> {t('apps.firstRun.browseBody')}</li>
<li><strong>{t('apps.firstRun.startTitle')}</strong> {t('apps.firstRun.startBody')}</li>
</ol>
<div className="ad-section__acts">
<Link className="dk-btn dk-btn--primary" to="/app/addons?tab=browse" data-testid="addons-first-run-browse"><Icon name="store" /> {t('apps.firstRun.browse')}</Link>
</div>
</section>
) : shown.length === 0 ? (
<CatalogEmpty icon="puzzle" title={t('apps.emptyTitle')} text={t('apps.emptyBody')} />
) : (
<ul className="ad-tiles" aria-label={t('apps.listLabel')}>
@@ -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 ''
@@ -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; }
@@ -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)
})