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 ( ))}