feat(nav): remove agent profile avatar from bottom tab bar (#3533)

This commit is contained in:
Cyrus Gray
2026-06-09 03:48:03 -07:00
committed by GitHub
parent c147a80357
commit da5092d469
3 changed files with 3 additions and 323 deletions
+2 -201
View File
@@ -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');
});
});