feat(mobile): the assistant is an entry of the dock, sorted like the pages

The home assistant sat behind the More button on every page outside a
trip, so a dock with free slots showed "..." for a single entry. It is now
an entry of the dock list while its addon is on and it can run for the
user: an account that never sorted its dock gets it in a slot the addons
leave free, and Settings > Appearance > Mobile can move it between the bar
and More like any page.
This commit is contained in:
Maurice
2026-11-13 08:27:31 +01:00
parent fdb2095ced
commit ccfa92f650
5 changed files with 145 additions and 40 deletions
@@ -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 <TranslationProvider>{children}</TranslationProvider>
@@ -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'])
})
})
+33 -5
View File
@@ -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:<id>'` 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<string, LucideIcon> = {
}
/** 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[]
@@ -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')
@@ -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(<MBottomNav />, { 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(<MBottomNav />, { 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(<MBottomNav />, { 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(<MBottomNav />, { initialEntries: ['/atlas'] });
expect(screen.queryByRole('button', { name: 'Assistant' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument();
});
});
+26 -26
View File
@@ -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 (
<button
key={to}
key={id}
type="button"
onClick={() => navigate(to)}
onClick={() => open(item)}
aria-label={label}
aria-current={active ? 'page' : undefined}
className={circleCls(active)}
@@ -111,17 +111,17 @@ export default function MBottomNav() {
role="presentation"
onClick={e => e.stopPropagation()}
>
{moreRows.map(({ key, label, icon: Icon, go }) => (
{moreItems.map((item) => (
<button
key={key}
key={item.id}
type="button"
onClick={() => { setMoreOpen(false); go() }}
onClick={() => { setMoreOpen(false); open(item) }}
className="flex items-center gap-[13px] rounded-[18px] bg-[color:var(--m-ic)] px-4 py-[14px] text-start"
>
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-[color:var(--m-ic)] text-m-ink">
<Icon size={20} strokeWidth={2} />
<item.icon size={20} strokeWidth={2} />
</span>
<span className="block min-w-0 flex-1 truncate text-[0.9375rem] font-extrabold text-m-ink">{label}</span>
<span className="block min-w-0 flex-1 truncate text-[0.9375rem] font-extrabold text-m-ink">{item.label}</span>
<ChevronRight size={17} strokeWidth={2} className="flex-none text-m-faint" />
</button>
))}