diff --git a/client/src/components/Layout/navItems.hook.test.tsx b/client/src/components/Layout/navItems.hook.test.tsx
index 82aea2a4c..900ef8173 100644
--- a/client/src/components/Layout/navItems.hook.test.tsx
+++ b/client/src/components/Layout/navItems.hook.test.tsx
@@ -1,11 +1,13 @@
-// FE-W4NAVH-001 to FE-W4NAVH-005
+// FE-W4NAVH-001 to FE-W4NAVH-008
import { describe, it, expect, beforeEach } from 'vitest'
-import { CalendarDays, Globe, LayoutGrid } from 'lucide-react'
+import { CalendarDays, Globe, LayoutGrid, Sparkles } from 'lucide-react'
import { renderHook } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext'
import { useAddonStore } from '../../store/addonStore'
import { usePluginStore } from '../../store/pluginStore'
-import { useNavItems } from './navItems'
+import { useAssistantStore } from '../../store/assistantStore'
+import type { AiStatusResponse } from '@trek/shared'
+import { useDockNavItems, useNavItems } from './navItems'
function wrapper({ children }: { children: React.ReactNode }) {
return {children}
@@ -16,6 +18,7 @@ const render = () => renderHook(() => useNavItems(), { wrapper })
beforeEach(() => {
useAddonStore.setState({ addons: [] })
usePluginStore.setState({ plugins: [] })
+ useAssistantStore.setState({ status: null })
})
describe('useNavItems', () => {
@@ -77,3 +80,33 @@ describe('useNavItems', () => {
expect(result.current).toBe(first)
})
})
+
+describe('useDockNavItems', () => {
+ const renderDock = () => renderHook(() => useDockNavItems(), { wrapper })
+ const assistantAddon = (enabled: boolean) =>
+ useAddonStore.setState({ addons: [{ id: 'ai_assistant', name: 'AI assistant', icon: 'Sparkles', type: 'integration', enabled }] as never })
+
+ it('FE-W4NAVH-006: adds the assistant after the pages while its addon is on and it can run', () => {
+ assistantAddon(true)
+ useAssistantStore.setState({ status: { available: true } as AiStatusResponse })
+
+ const { result } = renderDock()
+
+ expect(result.current.map(i => i.id)).toEqual(['dashboard', 'assistant'])
+ expect(result.current[1]).toMatchObject({ to: '', label: 'Assistant', icon: Sparkles })
+ })
+
+ it('FE-W4NAVH-007: leaves it out while the addon is off', () => {
+ assistantAddon(false)
+ useAssistantStore.setState({ status: { available: true } as AiStatusResponse })
+
+ expect(renderDock().result.current.map(i => i.id)).toEqual(['dashboard'])
+ })
+
+ it('FE-W4NAVH-008: leaves it out while it cannot run for this user', () => {
+ assistantAddon(true)
+ useAssistantStore.setState({ status: { available: false } as AiStatusResponse })
+
+ expect(renderDock().result.current.map(i => i.id)).toEqual(['dashboard'])
+ })
+})
diff --git a/client/src/components/Layout/navItems.ts b/client/src/components/Layout/navItems.ts
index 9cc34db93..7c3393f2d 100644
--- a/client/src/components/Layout/navItems.ts
+++ b/client/src/components/Layout/navItems.ts
@@ -1,7 +1,8 @@
import { useMemo } from 'react'
-import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, type LucideIcon } from 'lucide-react'
+import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Sparkles, type LucideIcon } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useAddonStore } from '../../store/addonStore'
+import { useAssistantStore } from '../../store/assistantStore'
import { usePluginStore } from '../../store/pluginStore'
import { resolvePluginIcon } from '../shared/PluginIcon'
@@ -12,16 +13,21 @@ import { resolvePluginIcon } from '../shared/PluginIcon'
*
* Stable ids used for persistence: `'dashboard'` (pinned), an enabled global
* addon's id (vacay|atlas|journey|collections|future), or `'plugin:'` for a
- * page plugin. Dashboard is always first and immovable.
+ * page plugin. Dashboard is always first and immovable. The mobile dock adds
+ * `'assistant'` (see useDockNavItems).
*/
export interface NavItemDef {
id: string
+ /** The page it opens; empty for the assistant, which opens the home chat instead. */
to: string
label: string
icon: LucideIcon
pinned?: boolean
}
+/** The home assistant's entry in the mobile dock: a chat it opens, not a page. */
+export const ASSISTANT_NAV_ID = 'assistant'
+
// Signature glyphs for the four catalog global addons, keyed by id so the known
// nav items keep their icon regardless of the (string) icon the addon row ships;
// any future/unknown global addon resolves through its manifest icon name.
@@ -33,7 +39,7 @@ const ADDON_ICONS: Record = {
}
/** The bar holds Dashboard + at most this many custom items; the rest go to More.
- * Two keeps the dock at 3 destinations (Dashboard + 2) so the More slot still fits. */
+ * Two keeps the dock at 3 entries (Dashboard + 2) so the More slot still fits. */
export const MOBILE_NAV_MAX_BAR = 2
/**
* The built-in dock next to Dashboard for an un-customised account, in priority
@@ -44,9 +50,11 @@ export const MOBILE_NAV_MAX_BAR = 2
* to every day, not the map of countries it already visited. Journey ships
* disabled though, so an instance that never turned it on keeps the Vacay/Atlas
* dock it has today. Listing all four known global addons rather than just the
- * top two also keeps the dock filled when one of them is switched off.
+ * top two also keeps the dock filled when one of them is switched off. The
+ * assistant comes last: it takes a slot only one of the addons left free, rather
+ * than sitting alone behind the More button.
*/
-export const DEFAULT_DOCK_IDS = ['vacay', 'journey', 'atlas', 'collections']
+export const DEFAULT_DOCK_IDS = ['vacay', 'journey', 'atlas', 'collections', ASSISTANT_NAV_ID]
export function buildNavItems(
globalAddons: { id: string; name: string; icon: string }[],
@@ -90,6 +98,26 @@ export function useNavItems(): NavItemDef[] {
)
}
+/**
+ * The entries the mobile dock arranges: the nav items, and the home assistant
+ * while its addon is on and it can run for this user. The settings customizer
+ * sorts the same list, so the assistant can go into the bar or under More.
+ */
+export function useDockNavItems(): NavItemDef[] {
+ const { t } = useTranslation()
+ const items = useNavItems()
+ const addonOn = useAddonStore((s) => s.isEnabled('ai_assistant'))
+ const available = useAssistantStore((s) => s.status?.available === true)
+ const withAssistant = addonOn && available
+ return useMemo(
+ () =>
+ withAssistant
+ ? [...items, { id: ASSISTANT_NAV_ID, to: '', label: t('assistant.launcher.tooltip'), icon: Sparkles }]
+ : items,
+ [items, withAssistant, t],
+ )
+}
+
export interface MobileNavSplit {
/** Items shown directly in the dock, Dashboard first. */
bar: NavItemDef[]
diff --git a/client/src/components/Settings/useMobileNavEditor.ts b/client/src/components/Settings/useMobileNavEditor.ts
index 7b4b6184a..a96877306 100644
--- a/client/src/components/Settings/useMobileNavEditor.ts
+++ b/client/src/components/Settings/useMobileNavEditor.ts
@@ -1,4 +1,4 @@
-import { useNavItems, splitMobileNav, MOBILE_NAV_MAX_BAR, type NavItemDef } from '../Layout/navItems'
+import { useDockNavItems, splitMobileNav, MOBILE_NAV_MAX_BAR, type NavItemDef } from '../Layout/navItems'
export interface MobileNavValue {
bar: string[]
@@ -36,7 +36,7 @@ export function useMobileNavEditor(
value: MobileNavValue,
onChange: (next: MobileNavValue) => void,
): MobileNavEditor {
- const items = useNavItems()
+ const items = useDockNavItems()
const split = splitMobileNav(items, value)
const dashboard = split.bar.find((i) => i.id === 'dashboard')
const barItems = split.bar.filter((i) => i.id !== 'dashboard')
diff --git a/client/src/mobile/components/MBottomNav.test.tsx b/client/src/mobile/components/MBottomNav.test.tsx
index 82a9a8346..ff88f484c 100644
--- a/client/src/mobile/components/MBottomNav.test.tsx
+++ b/client/src/mobile/components/MBottomNav.test.tsx
@@ -1,4 +1,4 @@
-// FE-COMP-MBOTTOMNAV-001 to FE-COMP-MBOTTOMNAV-008
+// FE-COMP-MBOTTOMNAV-001 to FE-COMP-MBOTTOMNAV-011
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
@@ -23,11 +23,19 @@ import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import { useAssistantStore } from '../../store/assistantStore';
+import { useSettingsStore } from '../../store/settingsStore';
import type { AiStatusResponse } from '@trek/shared';
import MBottomNav from './MBottomNav';
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
+const vacay = { id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true };
+const atlas = { id: 'atlas', name: 'Atlas', type: 'global', icon: 'globe', enabled: true };
+const assistantAddon = { id: 'ai_assistant', name: 'AI assistant', type: 'integration', icon: 'sparkles', enabled: true };
+
+const withAssistant = () =>
+ useAssistantStore.setState({ status: { available: true } as AiStatusResponse, window: { open: false, minimized: false } });
+
const withCollections = () =>
seedStore(useAddonStore, {
addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: 'bookmark', enabled: true }],
@@ -40,6 +48,10 @@ beforeEach(() => {
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
});
+afterEach(() => {
+ useAssistantStore.setState({ status: null, window: { open: false, minimized: false } });
+});
+
describe('MBottomNav', () => {
it('FE-COMP-MBOTTOMNAV-001: the dock "+" creates a trip on an unclaimed route', async () => {
const user = userEvent.setup();
@@ -100,14 +112,46 @@ describe('MBottomNav', () => {
expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument();
});
- it('FE-COMP-MBOTTOMNAV-008: the More list leads with the assistant while it can run, and opens the home chat', async () => {
+ it('FE-COMP-MBOTTOMNAV-008: the assistant takes a free dock slot, so More does not hold it alone', async () => {
const user = userEvent.setup();
- useAssistantStore.setState({ status: { available: true } as AiStatusResponse, window: { open: false, minimized: false } });
+ withAssistant();
+ seedStore(useAddonStore, { addons: [atlas, assistantAddon] });
+ render(, { initialEntries: ['/atlas'] });
+ expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: 'Assistant' }));
+ expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('FE-COMP-MBOTTOMNAV-009: with the dock full the assistant waits under More, and opens the home chat from there', async () => {
+ const user = userEvent.setup();
+ withAssistant();
+ seedStore(useAddonStore, { addons: [vacay, atlas, assistantAddon] });
render(, { initialEntries: ['/atlas'] });
await user.click(screen.getByRole('button', { name: 'More' }));
await user.click(screen.getByRole('button', { name: 'Assistant' }));
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
expect(mockNavigate).not.toHaveBeenCalled();
- useAssistantStore.setState({ status: null, window: { open: false, minimized: false } });
+ });
+
+ it('FE-COMP-MBOTTOMNAV-010: the assistant goes where the settings put it', () => {
+ withAssistant();
+ seedStore(useAddonStore, { addons: [vacay, atlas, assistantAddon] });
+ seedStore(useSettingsStore, {
+ settings: { ...useSettingsStore.getState().settings, appearance: { mobileNav: { bar: ['assistant', 'atlas'], more: ['vacay'] } } },
+ });
+ render(, { initialEntries: ['/atlas'] });
+ expect(screen.getByRole('button', { name: 'Assistant' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Atlas' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Vacay' })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument();
+ });
+
+ it('FE-COMP-MBOTTOMNAV-011: no assistant entry while its addon is off', () => {
+ withAssistant();
+ seedStore(useAddonStore, { addons: [atlas, { ...assistantAddon, enabled: false }] });
+ render(, { initialEntries: ['/atlas'] });
+ expect(screen.queryByRole('button', { name: 'Assistant' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument();
});
});
diff --git a/client/src/mobile/components/MBottomNav.tsx b/client/src/mobile/components/MBottomNav.tsx
index 3471cad34..bb70b1a79 100644
--- a/client/src/mobile/components/MBottomNav.tsx
+++ b/client/src/mobile/components/MBottomNav.tsx
@@ -2,17 +2,14 @@ import { useEffect, useState } from 'react'
import { useNavigate, useLocation, matchPath } from 'react-router'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
-import { ChevronRight, MoreHorizontal, Plus, Search, Sparkles, Upload } from 'lucide-react'
-import type { LucideIcon } from 'lucide-react'
+import { ChevronRight, MoreHorizontal, Plus, Search, Upload } from 'lucide-react'
import { normalizeAppearance } from '@trek/shared'
-import { useNavItems, splitMobileNav } from '../../components/Layout/navItems'
+import { useDockNavItems, splitMobileNav, ASSISTANT_NAV_ID, type NavItemDef } from '../../components/Layout/navItems'
import { isNavItemActive, useCreateAction } from '../../components/Layout/useCreateAction'
import MFab from './MFab'
import { useAssistantHomeLauncher } from '../../components/Assistant/useAssistantHome'
import MDockScrim from './MDockScrim'
-interface NavItem { to: string; label: string; icon: LucideIcon }
-
/**
* Floating glass dock of the mobile shell. Same tab/gating/"+" logic as the
* legacy BottomNav (addons, ?create= contract), redesigned as the demo's icon
@@ -28,7 +25,8 @@ export default function MBottomNav() {
// the dock yields the slot instead (see screenFabSlot below, #1811).
const create = useCreateAction(true)
const [moreOpen, setMoreOpen] = useState(false)
- // The home chat leads the More list on every page outside a trip, as the assistant tile leads a trip's.
+ // The home chat, an entry of the dock like the pages (useDockNavItems): sorted
+ // into the bar or under More in the settings, there only while it can run.
const assistant = useAssistantHomeLauncher()
// Close the popover when the route changes underneath it (browser back etc.).
@@ -37,20 +35,21 @@ export default function MBottomNav() {
// The dock split + order is user-configurable (Settings → Appearance → Mobile);
// an un-customised account falls back to the built-in Dashboard + vacay/atlas
// dock with everything else under "More".
- const navItems = useNavItems()
+ const navItems = useDockNavItems()
const appearance = useSettingsStore(s => s.settings.appearance)
const split = splitMobileNav(navItems, normalizeAppearance(appearance).mobileNav)
- const dockItems: NavItem[] = split.bar
- const moreItems: NavItem[] = split.more
+ const dockItems = split.bar
+ const moreItems = split.more
- const isActive = (to: string) => isNavItemActive(location.pathname, to)
- const moreActive = moreItems.some(item => isActive(item.to))
- const moreRows = [
- ...(assistant.available ? [{ key: 'assistant', label: t('assistant.launcher.tooltip'), icon: Sparkles, go: assistant.open }] : []),
- ...moreItems.map(({ to, label, icon }) => ({ key: to, label, icon, go: () => navigate(to) })),
- ]
- const hasMore = moreRows.length > 0
+ const isAssistant = (item: NavItemDef) => item.id === ASSISTANT_NAV_ID
+ const isActive = (item: NavItemDef) => !isAssistant(item) && isNavItemActive(location.pathname, item.to)
+ const open = (item: NavItemDef): void => {
+ if (isAssistant(item)) assistant.open()
+ else void navigate(item.to)
+ }
+ const moreActive = moreItems.some(isActive)
+ const hasMore = moreItems.length > 0
// The FAB gives way to a decorative logo slot on screens without an add
// action (settings/admin, demo Z. 1372/1429).
@@ -82,16 +81,17 @@ export default function MBottomNav() {
// A map runs to the screen's edge on purpose; everything else fades out under the dock.
const overMap = location.pathname.startsWith('/atlas') || matchPath('/journey/:id', location.pathname) !== null
- const renderItem = ({ to, label, icon: Icon }: NavItem) => {
- const active = isActive(to)
+ const renderItem = (item: NavItemDef) => {
+ const { id, label, icon: Icon } = item
+ const active = isActive(item)
// Fixed sizes per slot (demo): the dashboard grid is 18/2.1, every other
// slot 21/1.9 — independent of the active state.
- const dash = to === '/dashboard'
+ const dash = id === 'dashboard'
return (