fix(ui): keep hub entries off the Apps route in the sidebar

The Build hub still owned /app/addons through its legacy Add-ons tab,
so the sidebar lit both Apps and Build on the Apps page. A tab whose
gate is closed now owns no route when the sidebar asks, so only the
Apps entry is active there. The hubs keep their own routes, and the
legacy navigation still gives /app/addons to the Build hub.

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 14:04:55 +00:00
1 parent 45fb4e57e7
commit 5d05157bc9
3 files changed
+40 -3

No files matched your search

@@ -253,7 +253,7 @@ export default function Sidebar({ isOpen, onClose }) {
item={item}
onClose={onClose}
collapsed={collapsed}
active={item.hub ? hubOwnsPath(item.hub, location.pathname) : undefined}
active={item.hub ? hubOwnsPath(item.hub, location.pathname, auth) : undefined}
badge={item.hub?.id === 'operate' && activeOps > 0 ? (
<span className={`nav-badge${failedOps > 0 ? ' nav-badge--error' : ''}`}>
{failedOps > 0 ? failedOps : activeOps}
+5 -2
View File
@@ -262,8 +262,11 @@ export function resolveTabs(hub, auth, pathname) {
return { tabs, current }
}
export function hubOwnsPath(hub, pathname) {
return hub.tabs.some(tab => tabOwnsPath(tab, pathname))
// With `auth`, a tab whose gate is closed owns nothing: the Add-ons tab of the
// Build hub only exists in the legacy navigation, so in the four-item sidebar
// /app/addons belongs to the Apps entry alone and must not light a hub entry.
export function hubOwnsPath(hub, pathname, auth) {
return hub.tabs.some(tab => (!auth || tabGateOpen(tab, auth)) && tabOwnsPath(tab, pathname))
}
// The page the hub's single sidebar entry links to: its overview, when the
@@ -151,3 +151,37 @@ test('the first-run card shows only to an installer when nothing is installed',
assert.equal(showFirstRun({ apps: [], isAdmin: false }), false)
assert.equal(showFirstRun({ apps: [], isAdmin: true, canList: false }), false)
})
test('sidebar entries own one route each: Apps on /app/addons, never a hub', async () => {
const { hubs, hubOwnsPath } = await import('../../src/components/hub/hubConfig.js')
const [build, operate] = hubs
// /api/features has answered and the legacy switch is off: four-item sidebar.
const auth = { isAdmin: true, authEnabled: true, hasFeature: () => true, features: { addon_navigation: true } }
for (const p of ['/app/addons', '/app/addons/voice/voice', '/app/addons/notes/home']) {
assert.equal(hubOwnsPath(build, p, auth), false, `Build owns ${p}`)
assert.equal(hubOwnsPath(operate, p, auth), false, `Operate owns ${p}`)
}
// Before /api/features answers the Add-ons tab is also closed.
assert.equal(hubOwnsPath(build, '/app/addons', { ...auth, features: {} }), false)
})
test('sidebar hubs keep their own routes, and Apps does not claim them', async () => {
const { hubs, hubOwnsPath } = await import('../../src/components/hub/hubConfig.js')
const [build, operate] = hubs
const auth = { isAdmin: true, authEnabled: true, hasFeature: () => true, features: { addon_navigation: true } }
for (const p of ['/app/operate', '/app/settings', '/app/backends', '/app/traces', '/app/nodes']) {
assert.equal(hubOwnsPath(operate, p, auth), true, `Operate misses ${p}`)
assert.equal(hubOwnsPath(build, p, auth), false, `Build owns ${p}`)
}
assert.equal(hubOwnsPath(build, '/app/build', auth), true)
assert.equal(hubOwnsPath(build, '/app/agents', auth), true)
assert.equal(hubOwnsPath(operate, '/app/build', auth), false)
// The primary Apps entry matches /app/addons and its subpaths only.
const apps = '/app/addons'
const appsActive = p => p === apps || p.startsWith(apps + '/')
assert.equal(appsActive('/app/addons/voice/voice'), true)
assert.equal(appsActive('/app/operate'), false)
// Legacy navigation: the Build hub keeps the Add-ons tab.
const legacy = { ...auth, features: { addon_navigation: false } }
assert.equal(hubOwnsPath(build, '/app/addons', legacy), true)
})