mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(nav): remove agent profile avatar from bottom tab bar (#3533)
This commit is contained in:
@@ -1,20 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../providers/CoreStateProvider';
|
||||
import { trackEvent } from '../services/analytics';
|
||||
import {
|
||||
loadAgentProfiles,
|
||||
selectActiveAgentProfile,
|
||||
selectActiveAgentProfileId,
|
||||
selectAgentProfile,
|
||||
selectAgentProfiles,
|
||||
} from '../store/agentProfileSlice';
|
||||
import { selectCompanionSessionActive } from '../store/companionSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import { selectUnreadCount } from '../store/notificationSlice';
|
||||
import type { AgentProfile } from '../types/agentProfile';
|
||||
import { isAccountsFullscreen } from '../utils/accountsFullscreen';
|
||||
|
||||
const makeTabs = (t: (key: string) => string) => [
|
||||
@@ -134,91 +126,23 @@ const makeTabs = (t: (key: string) => string) => [
|
||||
},
|
||||
];
|
||||
|
||||
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('');
|
||||
};
|
||||
|
||||
function AgentProfileAvatar({
|
||||
profile,
|
||||
fallbackName,
|
||||
}: {
|
||||
profile: AgentProfile | null;
|
||||
fallbackName: string;
|
||||
}) {
|
||||
const name = profile?.name?.trim() || fallbackName;
|
||||
if (profile?.avatarUrl) {
|
||||
return (
|
||||
<img
|
||||
src={profile.avatarUrl}
|
||||
alt=""
|
||||
className="h-6 w-6 rounded-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary-500 text-[10px] font-semibold leading-none text-white">
|
||||
{getInitials(name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const BottomTabBar = () => {
|
||||
const { t } = useT();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { snapshot } = useCoreState();
|
||||
const token = snapshot.sessionToken;
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||
const profileMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const activeAccountId = useAppSelector(state => state.accounts.activeAccountId);
|
||||
const unreadCount = useAppSelector(state => selectUnreadCount(state.notifications.items));
|
||||
const companionActive = useAppSelector(selectCompanionSessionActive);
|
||||
const agentProfiles = useAppSelector(selectAgentProfiles);
|
||||
const activeAgentProfileId = useAppSelector(selectActiveAgentProfileId);
|
||||
const activeAgentProfile = useAppSelector(selectActiveAgentProfile);
|
||||
const agentProfileStatus = useAppSelector(state => state.agentProfiles.status);
|
||||
const agentProfileError = useAppSelector(state => state.agentProfiles.error);
|
||||
// `state.theme` is undefined in some test fixtures that build a minimal
|
||||
// store without the theme slice; default to the historical 'hover' behavior
|
||||
// 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 fallbackProfileName = t('nav.defaultAgentProfile');
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || agentProfiles.length > 0 || agentProfileStatus !== 'idle') return;
|
||||
void dispatch(loadAgentProfiles());
|
||||
}, [agentProfileStatus, agentProfiles.length, dispatch, token]);
|
||||
|
||||
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 (
|
||||
@@ -265,30 +189,6 @@ const BottomTabBar = () => {
|
||||
navigate(tab.path);
|
||||
};
|
||||
|
||||
const handleProfileSelect = async (profile: AgentProfile) => {
|
||||
if (profile.id === activeAgentProfileId) {
|
||||
setProfileMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent('agent_profile_switch', {
|
||||
from_profile_id: activeAgentProfileId,
|
||||
to_profile_id: profile.id,
|
||||
from_path: location.pathname,
|
||||
});
|
||||
|
||||
try {
|
||||
await dispatch(selectAgentProfile(profile.id)).unwrap();
|
||||
setProfileMenuOpen(false);
|
||||
} catch (error) {
|
||||
console.warn('[BottomTabBar] agent profile select failed', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetryProfiles = () => {
|
||||
void dispatch(loadAgentProfiles());
|
||||
};
|
||||
|
||||
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
|
||||
@@ -362,105 +262,6 @@ const BottomTabBar = () => {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className="relative ml-1 border-l border-stone-300 pl-1 dark:border-neutral-700"
|
||||
ref={profileMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProfileMenuOpen(open => !open)}
|
||||
className={`relative flex h-9 w-9 items-center justify-center rounded-sm transition-colors duration-300 cursor-pointer ${
|
||||
profileMenuOpen
|
||||
? 'bg-white text-stone-900 shadow-sm dark:bg-neutral-800 dark:text-neutral-100'
|
||||
: 'bg-transparent text-stone-500 hover:bg-stone-300/50 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200'
|
||||
}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={profileMenuOpen}
|
||||
aria-label={`${t('nav.switchAgentProfile')}: ${
|
||||
activeAgentProfile?.name ?? fallbackProfileName
|
||||
}`}
|
||||
title={activeAgentProfile?.name ?? fallbackProfileName}>
|
||||
<AgentProfileAvatar profile={activeAgentProfile} fallbackName={fallbackProfileName} />
|
||||
{agentProfileStatus === 'saving' && (
|
||||
<span className="absolute inset-1 rounded-sm border border-primary-400/80 animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{profileMenuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
aria-label={t('nav.agentProfiles')}
|
||||
className="absolute bottom-full right-0 mb-2 w-64 overflow-hidden rounded-sm border border-stone-300 bg-white shadow-soft dark:border-neutral-700 dark:bg-neutral-900">
|
||||
<div className="border-b border-stone-200 px-3 py-2 text-xs font-semibold text-stone-500 dark:border-neutral-800 dark:text-neutral-400">
|
||||
{t('nav.agentProfiles')}
|
||||
</div>
|
||||
|
||||
{agentProfileError && (
|
||||
<div className="space-y-2 px-3 py-3 text-sm text-coral-700 dark:text-coral-300">
|
||||
<div>{agentProfileError}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetryProfiles}
|
||||
className="rounded-sm border border-coral-300 px-2 py-1 text-xs font-semibold text-coral-700 hover:bg-coral-50 dark:border-coral-700 dark:text-coral-200 dark:hover:bg-coral-950/40">
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!agentProfileError && agentProfileStatus === 'loading' && (
|
||||
<div className="px-3 py-3 text-sm text-stone-500 dark:text-neutral-400">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!agentProfileError &&
|
||||
agentProfileStatus !== 'loading' &&
|
||||
agentProfiles.length === 0 && (
|
||||
<div className="px-3 py-3 text-sm text-stone-500 dark:text-neutral-400">
|
||||
{t('nav.noAgentProfiles')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!agentProfileError && agentProfiles.length > 0 && (
|
||||
<div className="max-h-72 overflow-y-auto p-1">
|
||||
{agentProfiles.map(profile => {
|
||||
const active = profile.id === activeAgentProfileId;
|
||||
return (
|
||||
<button
|
||||
key={profile.id}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={active}
|
||||
onClick={() => void handleProfileSelect(profile)}
|
||||
className={`flex w-full items-center gap-3 rounded-sm px-2 py-2 text-left transition-colors ${
|
||||
active
|
||||
? 'bg-primary-50 text-primary-900 dark:bg-primary-950/40 dark:text-primary-100'
|
||||
: 'text-stone-700 hover:bg-stone-100 dark:text-neutral-200 dark:hover:bg-neutral-800'
|
||||
}`}>
|
||||
<AgentProfileAvatar
|
||||
profile={profile}
|
||||
fallbackName={fallbackProfileName}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{profile.name}
|
||||
</span>
|
||||
{profile.description && (
|
||||
<span className="block truncate text-xs text-stone-500 dark:text-neutral-400">
|
||||
{profile.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{active && (
|
||||
<span className="h-2 w-2 rounded-full bg-primary-500" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,13 +7,12 @@
|
||||
* [#1123] Covers the walkthroughAttr object added for the Joyride walkthrough.
|
||||
*/
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
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';
|
||||
@@ -22,15 +21,6 @@ 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<typeof import('../../utils/config')>();
|
||||
return { ...actual, APP_ENVIRONMENT: 'development' };
|
||||
@@ -45,44 +35,14 @@ 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',
|
||||
@@ -146,7 +106,6 @@ 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()
|
||||
@@ -223,40 +182,4 @@ describe('BottomTabBar', () => {
|
||||
|
||||
expect(trackEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders an avatar-only agent profile switcher with the active profile', async () => {
|
||||
await renderBottomTabBar('/home');
|
||||
|
||||
const switcher = screen.getByRole('button', { name: 'Switch agent profile: Planner' });
|
||||
expect(switcher).toHaveAttribute('title', 'Planner');
|
||||
expect(switcher.querySelector('img')).toHaveAttribute('src', 'https://example.com/planner.png');
|
||||
|
||||
fireEvent.click(switcher);
|
||||
|
||||
expect(screen.getByRole('menu', { name: 'Agent profiles' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitemradio', { name: /Planner/ })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true'
|
||||
);
|
||||
expect(screen.getByRole('menuitemradio', { name: /Research/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches the active agent profile from the taskbar menu', async () => {
|
||||
const { trackEvent } = await import('../../services/analytics');
|
||||
agentProfilesApiMock.select.mockResolvedValueOnce({
|
||||
...testProfiles,
|
||||
activeProfileId: 'research',
|
||||
});
|
||||
await renderBottomTabBar('/home');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch agent profile: Planner' }));
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /Research/ }));
|
||||
|
||||
await waitFor(() => expect(agentProfilesApiMock.select).toHaveBeenCalledWith('research'));
|
||||
expect(trackEvent).toHaveBeenCalledWith('agent_profile_switch', {
|
||||
from_profile_id: 'planner',
|
||||
to_profile_id: 'research',
|
||||
from_path: '/home',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
bootAuthenticatedPage,
|
||||
dismissWalkthroughIfPresent,
|
||||
waitForAppReady,
|
||||
} from '../helpers/core-rpc';
|
||||
|
||||
async function activeAgentProfileId(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => {
|
||||
const win = window as unknown as {
|
||||
__OPENHUMAN_STORE__?: {
|
||||
getState?: () => { agentProfiles?: { activeProfileId?: string | null } };
|
||||
};
|
||||
};
|
||||
return win.__OPENHUMAN_STORE__?.getState?.().agentProfiles?.activeProfileId ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Agent profile taskbar switcher', () => {
|
||||
test('switches the active agent profile from the bottom taskbar avatar menu', async ({
|
||||
page,
|
||||
}) => {
|
||||
await bootAuthenticatedPage(page, 'pw-agent-profile-taskbar', '/home');
|
||||
await waitForAppReady(page);
|
||||
await dismissWalkthroughIfPresent(page);
|
||||
|
||||
const switcher = page.getByRole('button', { name: /Switch agent profile:/ });
|
||||
await expect(switcher).toBeVisible();
|
||||
await expect(switcher).toHaveAttribute('aria-label', 'Switch agent profile: Default');
|
||||
|
||||
await switcher.click();
|
||||
await expect(page.getByRole('menu', { name: 'Agent profiles' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitemradio', { name: /Default/ })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true'
|
||||
);
|
||||
|
||||
await page.getByRole('menuitemradio', { name: /Research/ }).click();
|
||||
|
||||
await expect.poll(() => activeAgentProfileId(page), { timeout: 10_000 }).toBe('research');
|
||||
await expect(switcher).toHaveAttribute('aria-label', 'Switch agent profile: Research');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user