diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index 7bfa4ec09..fd8c33444 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -4,18 +4,15 @@ import AppRoutesIOS from './AppRoutesIOS'; import DefaultRedirect from './components/DefaultRedirect'; import ProtectedRoute from './components/ProtectedRoute'; import PublicRoute from './components/PublicRoute'; -import HumanPage from './features/human/HumanPage'; import { getIsMobile } from './lib/platform'; import Accounts from './pages/Accounts'; -import Channels from './pages/Channels'; +import Activity from './pages/Activity'; import Home from './pages/Home'; -import Intelligence from './pages/Intelligence'; import Invites from './pages/Invites'; import Notifications from './pages/Notifications'; import Onboarding from './pages/onboarding/Onboarding'; import { PttOverlayPage } from './pages/PttOverlayPage'; import Rewards from './pages/Rewards'; -import Routines from './pages/Routines'; import Settings from './pages/Settings'; import Skills from './pages/Skills'; import WebCallbackPage from './pages/WebCallbackPage'; @@ -65,33 +62,34 @@ const AppRoutes = () => { } /> + {/* Phase 6 — /human merged into /chat (Assistant surface). + Preserve the route for back-compat (deep links, iOS share sheets, etc.). + iOS AppRoutesIOS still serves /human natively — only desktop redirects. */} + } /> + + {/* Primary Activity surface — replaces /intelligence (Phase 3). */} - + } /> - - - - } - /> + {/* Back-compat: /intelligence → /activity (preserves ?tab= deep links). + Deep links such as ?tab=memory or ?tab=agents still resolve but fall + back to the tasks tab in prod (dev-only tabs are gated inside Activity). */} + } /> - {/* Skills lives at /skills with its 4 sub-tabs (Composio / Channels / - MCP Servers / Runners). The scheduled-skills dashboard concept - composes INSIDE the Runners sub-tab, not as a separate top-level - page — the bottom-bar "Connections" entry has always pointed at - /skills to surface Composio integrations + MCP, and that muscle - memory is restored here. + {/* Connections page lives at /connections (Phase 2 rename from /skills). + The old /skills path is kept as a back-compat redirect so bookmarks + and deep links continue to work. `?tab=` query params are preserved + by Navigate (replace) so existing deep links still land on the right + sub-tab. `/workflows/new` is the create-a-skill authoring page. - Order matters: keep `/workflows/new` before `/skills` so it wins the - prefix match. */} + Order matters: keep `/workflows/new` before `/connections` so it wins + the prefix match. */} { /> @@ -119,6 +117,9 @@ const AppRoutes = () => { } /> + {/* Back-compat: /skills → /connections (preserves ?tab= deep links). */} + } /> + {/* Unified chat = agent + connected web apps. Replaces the old /conversations and /accounts routes. */} { } /> - - - - } - /> + {/* Back-compat: /channels was an orphaned standalone page; it now + redirects to the unified Connections page on the Messaging tab. */} + } /> { } /> - - - - } - /> + {/* Back-compat: /routines was an orphaned dead page (superseded by the + Cron Jobs settings panel). Redirect to Activity → Automations so + any surviving deep links land somewhere sensible. */} + } /> { } /> - {/* Workflows moved onto the Intelligence page (its own tab). Keep the + {/* Workflows moved onto the Activity page (Automations tab). Keep the old /workflows path working as a deep link into that tab. */} - } /> + } /> } /> diff --git a/app/src/components/BottomTabBar.tsx b/app/src/components/BottomTabBar.tsx index 85226c0af..fe9e0a456 100644 --- a/app/src/components/BottomTabBar.tsx +++ b/app/src/components/BottomTabBar.tsx @@ -1,6 +1,7 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import { AVATAR_MENU_ITEMS, NAV_TABS } from '../config/navConfig'; import { useT } from '../lib/i18n/I18nContext'; import { useCoreState } from '../providers/CoreStateProvider'; import { trackEvent } from '../services/analytics'; @@ -8,123 +9,105 @@ import { selectCompanionSessionActive } from '../store/companionSlice'; import { useAppSelector } from '../store/hooks'; import { selectUnreadCount } from '../store/notificationSlice'; import { isAccountsFullscreen } from '../utils/accountsFullscreen'; +import { BILLING_DASHBOARD_URL } from '../utils/links'; +import { isLocalSessionToken } from '../utils/localSession'; +import { openUrl } from '../utils/openUrl'; +import { resolveUserName } from '../utils/userName'; -const makeTabs = (t: (key: string) => string) => [ - { - id: 'home', - label: t('nav.home'), - path: '/home', - icon: ( - - - - ), - }, - { - id: 'human', - label: t('nav.human'), - path: '/human', - icon: ( - - - - ), - }, - { - id: 'chat', - label: t('nav.chat'), - path: '/chat', - icon: ( - - - - ), - }, - { - id: 'skills', - label: t('nav.connections'), - path: '/skills', - icon: ( - - - - ), - }, - { - id: 'intelligence', - label: t('nav.memory'), - path: '/intelligence', - icon: ( - - - - ), - }, - // Alerts/Notifications used to be its own bottom tab; moved into - // Settings › Notifications since it's a low-traffic destination. - // The /notifications route still exists for deep links. - { - id: 'rewards', - label: t('nav.rewards'), - path: '/rewards', - icon: ( - - - - ), - }, - { - id: 'settings', - label: t('nav.settings'), - path: '/settings', - icon: ( - - - - - ), - }, -]; +// ── SVG icons, keyed by tab id ──────────────────────────────────────────────── + +function TabIcon({ id }: { id: string }) { + switch (id) { + case 'home': + return ( + + + + ); + case 'human': + return ( + + + + ); + case 'chat': + return ( + + + + ); + case 'connections': + return ( + + + + ); + case 'activity': + // Reuse the Intelligence/memory lightbulb icon for the Activity tab + return ( + + + + ); + case 'settings': + return ( + + + + + ); + default: + return null; + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const getInitials = (name: string): string => { + const words = name.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return 'OH'; + return words + .slice(0, 2) + .map(word => word[0]?.toUpperCase() ?? '') + .join(''); +}; + +// ── Component ───────────────────────────────────────────────────────────────── const BottomTabBar = () => { const { t } = useT(); @@ -133,6 +116,8 @@ const BottomTabBar = () => { const { snapshot } = useCoreState(); const token = snapshot.sessionToken; const [revealed, setRevealed] = useState(false); + const [profileMenuOpen, setProfileMenuOpen] = useState(false); + const profileMenuRef = useRef(null); const activeAccountId = useAppSelector(state => state.accounts.activeAccountId); const unreadCount = useAppSelector(state => selectUnreadCount(state.notifications.items)); @@ -142,7 +127,32 @@ const BottomTabBar = () => { // so an absent theme branch can't crash the bar. const tabBarLabels = useAppSelector(state => state.theme?.tabBarLabels ?? 'hover'); const labelsAlwaysVisible = tabBarLabels === 'always'; - const tabs = useMemo(() => makeTabs(t), [t]); + + const isLocalSession = isLocalSessionToken(token); + // The avatar button shows the signed-in user's initials. + const userInitials = getInitials(resolveUserName(snapshot.currentUser)); + + // Resolve translated labels for NAV_TABS once per render cycle. + const tabs = useMemo(() => NAV_TABS.map(tab => ({ ...tab, label: t(tab.labelKey) })), [t]); + + useEffect(() => { + if (!profileMenuOpen) return; + + const onPointerDown = (event: PointerEvent) => { + if (profileMenuRef.current?.contains(event.target as Node)) return; + setProfileMenuOpen(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setProfileMenuOpen(false); + }; + + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [profileMenuOpen]); const hiddenPaths = ['/', '/login']; if ( @@ -189,6 +199,16 @@ const BottomTabBar = () => { navigate(tab.path); }; + const handleAvatarMenuItemClick = (itemId: string, kind: string, target: string) => { + setProfileMenuOpen(false); + if (kind === 'openUrl') { + openUrl(target).catch(() => {}); + } else { + navigate(target); + } + trackEvent('avatar_menu_item_click', { item_id: itemId }); + }; + return ( // pointer-events-none on the full-width shell so transparent areas (e.g. // beside the centered nav pill) do not steal clicks from sticky footers @@ -217,18 +237,10 @@ const BottomTabBar = () => { const active = isActive(tab.path); const showBadge = tab.id === 'notifications' && unreadCount > 0; const showCompanionDot = tab.id === 'settings' && companionActive; - // data-walkthrough attributes for the Joyride walkthrough steps. - // Maps tab ids to their walkthrough target names. - const walkthroughAttr: Record = { - chat: 'tab-chat', - skills: 'tab-skills', - notifications: 'tab-notifications', - settings: 'tab-settings', - }; return ( ); })} +
+ + + {profileMenuOpen && ( +
+
+ {AVATAR_MENU_ITEMS.filter(item => !item.cloudOnly || !isLocalSession).map( + item => { + // Billing target is resolved from the canonical constant rather than the + // data-file placeholder so it stays in sync with SettingsHome. + const target = item.id === 'billing' ? BILLING_DASHBOARD_URL : item.target; + return ( + + ); + } + )} +
+
+ )} +
diff --git a/app/src/components/__tests__/BottomTabBar.test.tsx b/app/src/components/__tests__/BottomTabBar.test.tsx index 67276f00e..9aef3141b 100644 --- a/app/src/components/__tests__/BottomTabBar.test.tsx +++ b/app/src/components/__tests__/BottomTabBar.test.tsx @@ -1,10 +1,13 @@ /** * Tests for BottomTabBar — verifies that: - * - the tab bar renders when the user has a session token and is on a non-hidden path - * - the walkthroughAttr mapping (line 222) is exercised by rendering the tabs - * - the tab bar is hidden on '/' and '/login' paths + * - 5 tabs are rendered (no Rewards tab, no Human tab), Activity label is present + * - Assistant tab is present (was "Chat", id stays 'chat', label now 'Assistant') + * - Walkthrough attributes reflect the new ids (tab-connections, tab-activity) + * - Avatar menu opens and shows Account / Billing / Rewards / Invites / Wallet + * - Clicking an avatar menu item navigates or opens URL + * - The bar is hidden on '/' and '/login' paths * - * [#1123] Covers the walkthroughAttr object added for the Joyride walkthrough. + * Updated for IA Phase 6: Human tab removed; Chat renamed to Assistant. */ import { configureStore } from '@reduxjs/toolkit'; import { fireEvent, render, screen } from '@testing-library/react'; @@ -13,6 +16,7 @@ import { MemoryRouter } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import accountsReducer from '../../store/accountsSlice'; +import agentProfileReducer, { setAgentProfilesFromResponse } from '../../store/agentProfileSlice'; import companionReducer from '../../store/companionSlice'; import notificationReducer from '../../store/notificationSlice'; import BottomTabBar from '../BottomTabBar'; @@ -21,6 +25,15 @@ import BottomTabBar from '../BottomTabBar'; vi.mock('../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() })); +const agentProfilesApiMock = vi.hoisted(() => ({ + list: vi.fn(), + select: vi.fn(), + upsert: vi.fn(), + delete: vi.fn(), +})); + +vi.mock('../../services/api/agentProfilesApi', () => ({ agentProfilesApi: agentProfilesApiMock })); + vi.mock('../../utils/config', async importOriginal => { const actual = await importOriginal(); return { ...actual, APP_ENVIRONMENT: 'development' }; @@ -29,20 +42,53 @@ vi.mock('../../utils/config', async importOriginal => { vi.mock('../../utils/accountsFullscreen', () => ({ isAccountsFullscreen: vi.fn(() => false) })); vi.mock('../../services/analytics', () => ({ trackEvent: vi.fn() })); +// Mock openUrl so tests don't try to open real URLs +vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn().mockResolvedValue(undefined) })); + // ── Helpers ──────────────────────────────────────────────────────────────── interface BuildStoreOpts { companionSessionActive?: boolean; } +const testProfiles = { + activeProfileId: 'planner', + profiles: [ + { + id: 'default', + name: 'Orchestrator', + description: 'Default agent', + agentId: 'orchestrator', + builtIn: true, + }, + { + id: 'planner', + name: 'Planner', + description: 'Plans multi-step work', + agentId: 'planner', + builtIn: true, + avatarUrl: 'https://example.com/planner.png', + }, + { + id: 'research', + name: 'Research', + description: 'Finds and summarizes sources', + agentId: 'research', + builtIn: true, + }, + ], +}; + function buildStore(opts: BuildStoreOpts = {}) { const store = configureStore({ reducer: { accounts: accountsReducer, notifications: notificationReducer, companion: companionReducer, + agentProfiles: agentProfileReducer, }, }); + store.dispatch(setAgentProfilesFromResponse(testProfiles)); if (opts.companionSessionActive) { store.dispatch({ type: 'companion/setSessionActive', @@ -56,6 +102,7 @@ interface RenderOpts { hasToken?: boolean; companionSessionActive?: boolean; tokenValue?: string; + currentUser?: unknown; } async function renderBottomTabBar(pathname = '/home', opts: RenderOpts | boolean = {}) { @@ -68,7 +115,7 @@ async function renderBottomTabBar(pathname = '/home', opts: RenderOpts | boolean snapshot: { sessionToken: hasToken ? tokenValue : null, auth: { isAuthenticated: true, userId: 'u1', user: null, profileId: null }, - currentUser: null, + currentUser: resolved.currentUser ?? null, onboardingCompleted: true, chatOnboardingCompleted: true, analyticsEnabled: false, @@ -106,19 +153,50 @@ async function renderBottomTabBar(pathname = '/home', opts: RenderOpts | boolean describe('BottomTabBar', () => { beforeEach(() => { vi.clearAllMocks(); + agentProfilesApiMock.select.mockResolvedValue(testProfiles); }); - // [#1123] Covers line 222 — walkthroughAttr object created per-tab inside .map() - it('renders navigation tabs with data-walkthrough attributes when session is active', async () => { + it('renders exactly 5 tab buttons (Phase 6: Human merged into Assistant)', async () => { await renderBottomTabBar('/home'); + // Query only buttons inside