mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(ui): add taskbar agent profile switcher (#3456)
This commit is contained in:
@@ -1,12 +1,20 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, 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 { useAppSelector } from '../store/hooks';
|
||||
import { useAppDispatch, 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) => [
|
||||
@@ -126,23 +134,91 @@ 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 (
|
||||
@@ -189,6 +265,30 @@ 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
|
||||
@@ -262,6 +362,105 @@ 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,12 +7,13 @@
|
||||
* [#1123] Covers the walkthroughAttr object added for the Joyride walkthrough.
|
||||
*/
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } 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';
|
||||
@@ -21,6 +22,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<typeof import('../../utils/config')>();
|
||||
return { ...actual, APP_ENVIRONMENT: 'development' };
|
||||
@@ -35,14 +45,44 @@ 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',
|
||||
@@ -106,6 +146,7 @@ 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()
|
||||
@@ -182,4 +223,40 @@ 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,10 @@ const messages: TranslationMap = {
|
||||
'nav.alerts': 'التنبيهات',
|
||||
'nav.rewards': 'المكافآت',
|
||||
'nav.settings': 'الإعدادات',
|
||||
'nav.agentProfiles': 'ملفات الوكلاء',
|
||||
'nav.switchAgentProfile': 'تبديل ملف الوكيل',
|
||||
'nav.defaultAgentProfile': 'الوكيل الافتراضي',
|
||||
'nav.noAgentProfiles': 'لم يتم العثور على ملفات وكلاء',
|
||||
'common.cancel': 'إلغاء',
|
||||
'common.save': 'حفظ',
|
||||
'common.confirm': 'تأكيد',
|
||||
|
||||
@@ -11,6 +11,10 @@ const messages: TranslationMap = {
|
||||
'nav.alerts': 'সতর্কতা',
|
||||
'nav.rewards': 'পুরস্কার',
|
||||
'nav.settings': 'সেটিংস',
|
||||
'nav.agentProfiles': 'এজেন্ট প্রোফাইল',
|
||||
'nav.switchAgentProfile': 'এজেন্ট প্রোফাইল বদলান',
|
||||
'nav.defaultAgentProfile': 'ডিফল্ট এজেন্ট',
|
||||
'nav.noAgentProfiles': 'কোনো এজেন্ট প্রোফাইল পাওয়া যায়নি',
|
||||
'common.cancel': 'বাতিল',
|
||||
'common.save': 'সংরক্ষণ',
|
||||
'common.confirm': 'নিশ্চিত করুন',
|
||||
|
||||
@@ -11,6 +11,10 @@ const messages: TranslationMap = {
|
||||
'nav.alerts': 'Benachrichtigungen',
|
||||
'nav.rewards': 'Belohnungen',
|
||||
'nav.settings': 'Einstellungen',
|
||||
'nav.agentProfiles': 'Agentenprofile',
|
||||
'nav.switchAgentProfile': 'Agentenprofil wechseln',
|
||||
'nav.defaultAgentProfile': 'Standardagent',
|
||||
'nav.noAgentProfiles': 'Keine Agentenprofile gefunden',
|
||||
'common.cancel': 'Abbrechen',
|
||||
'common.save': 'Speichern',
|
||||
'common.confirm': 'Bestätigen',
|
||||
|
||||
@@ -10,6 +10,10 @@ const en: TranslationMap = {
|
||||
'nav.alerts': 'Alerts',
|
||||
'nav.rewards': 'Rewards',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.agentProfiles': 'Agent profiles',
|
||||
'nav.switchAgentProfile': 'Switch agent profile',
|
||||
'nav.defaultAgentProfile': 'Default agent',
|
||||
'nav.noAgentProfiles': 'No agent profiles found',
|
||||
|
||||
// Common
|
||||
'common.cancel': 'Cancel',
|
||||
|
||||
@@ -11,6 +11,10 @@ const messages: TranslationMap = {
|
||||
'nav.alerts': 'Alertas',
|
||||
'nav.rewards': 'Recompensas',
|
||||
'nav.settings': 'Configuración',
|
||||
'nav.agentProfiles': 'Perfiles de agente',
|
||||
'nav.switchAgentProfile': 'Cambiar perfil de agente',
|
||||
'nav.defaultAgentProfile': 'Agente predeterminado',
|
||||
'nav.noAgentProfiles': 'No se encontraron perfiles de agente',
|
||||
'common.cancel': 'Cancelar',
|
||||
'common.save': 'Guardar',
|
||||
'common.confirm': 'Confirmar',
|
||||
|
||||
@@ -11,6 +11,10 @@ const messages: TranslationMap = {
|
||||
'nav.alerts': 'Alertes',
|
||||
'nav.rewards': 'Récompenses',
|
||||
'nav.settings': 'Paramètres',
|
||||
'nav.agentProfiles': "Profils d'agent",
|
||||
'nav.switchAgentProfile': "Changer de profil d'agent",
|
||||
'nav.defaultAgentProfile': 'Agent par défaut',
|
||||
'nav.noAgentProfiles': "Aucun profil d'agent trouvé",
|
||||
'common.cancel': 'Annuler',
|
||||
'common.save': 'Enregistrer',
|
||||
'common.confirm': 'Confirmer',
|
||||
|
||||
@@ -11,6 +11,10 @@ const messages: TranslationMap = {
|
||||
'nav.alerts': 'अलर्ट',
|
||||
'nav.rewards': 'रिवॉर्ड',
|
||||
'nav.settings': 'सेटिंग्स',
|
||||
'nav.agentProfiles': 'एजेंट प्रोफाइल',
|
||||
'nav.switchAgentProfile': 'एजेंट प्रोफाइल बदलें',
|
||||
'nav.defaultAgentProfile': 'डिफॉल्ट एजेंट',
|
||||
'nav.noAgentProfiles': 'कोई एजेंट प्रोफाइल नहीं मिला',
|
||||
'common.cancel': 'रद्द करें',
|
||||
'common.save': 'सेव करें',
|
||||
'common.confirm': 'कन्फर्म करें',
|
||||
|
||||
@@ -11,6 +11,10 @@ const messages: TranslationMap = {
|
||||
'nav.alerts': 'Peringatan',
|
||||
'nav.rewards': 'Hadiah',
|
||||
'nav.settings': 'Pengaturan',
|
||||
'nav.agentProfiles': 'Profil agen',
|
||||
'nav.switchAgentProfile': 'Ganti profil agen',
|
||||
'nav.defaultAgentProfile': 'Agen default',
|
||||
'nav.noAgentProfiles': 'Profil agen tidak ditemukan',
|
||||
'common.cancel': 'Batal',
|
||||
'common.save': 'Simpan',
|
||||
'common.confirm': 'Konfirmasi',
|
||||
|
||||
@@ -11,6 +11,10 @@ const messages: TranslationMap = {
|
||||
'nav.alerts': 'Avvisi',
|
||||
'nav.rewards': 'Premi',
|
||||
'nav.settings': 'Impostazioni',
|
||||
'nav.agentProfiles': 'Profili agente',
|
||||
'nav.switchAgentProfile': 'Cambia profilo agente',
|
||||
'nav.defaultAgentProfile': 'Agente predefinito',
|
||||
'nav.noAgentProfiles': 'Nessun profilo agente trovato',
|
||||
'common.cancel': 'Annulla',
|
||||
'common.save': 'Salva',
|
||||
'common.confirm': 'Conferma',
|
||||
|
||||
@@ -11,6 +11,10 @@ const messages: TranslationMap = {
|
||||
'nav.alerts': '알림',
|
||||
'nav.rewards': '보상',
|
||||
'nav.settings': '설정',
|
||||
'nav.agentProfiles': '에이전트 프로필',
|
||||
'nav.switchAgentProfile': '에이전트 프로필 전환',
|
||||
'nav.defaultAgentProfile': '기본 에이전트',
|
||||
'nav.noAgentProfiles': '에이전트 프로필을 찾을 수 없습니다',
|
||||
'common.cancel': '취소',
|
||||
'common.save': '저장',
|
||||
'common.confirm': '확인',
|
||||
|
||||
@@ -11,6 +11,10 @@ const messages: TranslationMap = {
|
||||
'nav.alerts': 'Alerty',
|
||||
'nav.rewards': 'Nagrody',
|
||||
'nav.settings': 'Ustawienia',
|
||||
'nav.agentProfiles': 'Profile agentów',
|
||||
'nav.switchAgentProfile': 'Przełącz profil agenta',
|
||||
'nav.defaultAgentProfile': 'Domyślny agent',
|
||||
'nav.noAgentProfiles': 'Nie znaleziono profili agentów',
|
||||
'common.cancel': 'Anuluj',
|
||||
'common.save': 'Zapisz',
|
||||
'common.confirm': 'Potwierdź',
|
||||
|
||||
@@ -11,6 +11,10 @@ const messages: TranslationMap = {
|
||||
'nav.alerts': 'Alertas',
|
||||
'nav.rewards': 'Recompensas',
|
||||
'nav.settings': 'Configurações',
|
||||
'nav.agentProfiles': 'Perfis de agente',
|
||||
'nav.switchAgentProfile': 'Trocar perfil de agente',
|
||||
'nav.defaultAgentProfile': 'Agente padrão',
|
||||
'nav.noAgentProfiles': 'Nenhum perfil de agente encontrado',
|
||||
'common.cancel': 'Cancelar',
|
||||
'common.save': 'Salvar',
|
||||
'common.confirm': 'Confirmar',
|
||||
|
||||
@@ -11,6 +11,10 @@ const messages: TranslationMap = {
|
||||
'nav.alerts': 'Оповещения',
|
||||
'nav.rewards': 'Награды',
|
||||
'nav.settings': 'Настройки',
|
||||
'nav.agentProfiles': 'Профили агентов',
|
||||
'nav.switchAgentProfile': 'Сменить профиль агента',
|
||||
'nav.defaultAgentProfile': 'Агент по умолчанию',
|
||||
'nav.noAgentProfiles': 'Профили агентов не найдены',
|
||||
'common.cancel': 'Отмена',
|
||||
'common.save': 'Сохранить',
|
||||
'common.confirm': 'Подтвердить',
|
||||
|
||||
@@ -11,6 +11,10 @@ const messages: TranslationMap = {
|
||||
'nav.alerts': '通知',
|
||||
'nav.rewards': '奖励',
|
||||
'nav.settings': '设置',
|
||||
'nav.agentProfiles': '代理档案',
|
||||
'nav.switchAgentProfile': '切换代理档案',
|
||||
'nav.defaultAgentProfile': '默认代理',
|
||||
'nav.noAgentProfiles': '未找到代理档案',
|
||||
'common.cancel': '取消',
|
||||
'common.save': '保存',
|
||||
'common.confirm': '确认',
|
||||
|
||||
@@ -6,6 +6,7 @@ import reducer, {
|
||||
type AgentProfileState,
|
||||
deleteAgentProfile,
|
||||
loadAgentProfiles,
|
||||
selectActiveAgentProfile,
|
||||
selectActiveAgentProfileId,
|
||||
selectAgentProfile,
|
||||
selectAgentProfiles,
|
||||
@@ -237,4 +238,16 @@ describe('agentProfileSlice — selectors', () => {
|
||||
};
|
||||
expect(selectActiveAgentProfileId(storeState)).toBe('research');
|
||||
});
|
||||
|
||||
it('selectActiveAgentProfile returns the active profile object', () => {
|
||||
const storeState = {
|
||||
agentProfiles: {
|
||||
profiles: twoProfiles.profiles,
|
||||
activeProfileId: 'planner',
|
||||
status: 'idle',
|
||||
error: null,
|
||||
} as AgentProfileState,
|
||||
};
|
||||
expect(selectActiveAgentProfile(storeState)?.name).toBe('Planner');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,4 +125,9 @@ export const selectAgentProfiles = (state: { agentProfiles: AgentProfileState })
|
||||
export const selectActiveAgentProfileId = (state: { agentProfiles: AgentProfileState }) =>
|
||||
state.agentProfiles.activeProfileId;
|
||||
|
||||
export const selectActiveAgentProfile = (state: { agentProfiles: AgentProfileState }) =>
|
||||
state.agentProfiles.profiles.find(
|
||||
profile => profile.id === state.agentProfiles.activeProfileId
|
||||
) ?? null;
|
||||
|
||||
export default agentProfileSlice.reducer;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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