From f6917f2ecebfe848284bcf8b74b79db4376f5823 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sat, 6 Jun 2026 19:53:14 -0400 Subject: [PATCH] feat(ui): add taskbar agent profile switcher (#3456) --- app/src/components/BottomTabBar.tsx | 203 +++++++++++++++++- .../__tests__/BottomTabBar.test.tsx | 79 ++++++- app/src/lib/i18n/ar.ts | 4 + app/src/lib/i18n/bn.ts | 4 + app/src/lib/i18n/de.ts | 4 + app/src/lib/i18n/en.ts | 4 + app/src/lib/i18n/es.ts | 4 + app/src/lib/i18n/fr.ts | 4 + app/src/lib/i18n/hi.ts | 4 + app/src/lib/i18n/id.ts | 4 + app/src/lib/i18n/it.ts | 4 + app/src/lib/i18n/ko.ts | 4 + app/src/lib/i18n/pl.ts | 4 + app/src/lib/i18n/pt.ts | 4 + app/src/lib/i18n/ru.ts | 4 + app/src/lib/i18n/zh-CN.ts | 4 + .../store/__tests__/agentProfileSlice.test.ts | 13 ++ app/src/store/agentProfileSlice.ts | 5 + .../specs/agent-profile-taskbar.spec.ts | 44 ++++ 19 files changed, 397 insertions(+), 3 deletions(-) create mode 100644 app/test/playwright/specs/agent-profile-taskbar.spec.ts diff --git a/app/src/components/BottomTabBar.tsx b/app/src/components/BottomTabBar.tsx index 85226c0af..98f918785 100644 --- a/app/src/components/BottomTabBar.tsx +++ b/app/src/components/BottomTabBar.tsx @@ -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 ( + + ); + } + + return ( + + {getInitials(name)} + + ); +} + 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(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 = () => { ); })} +
+ + + {profileMenuOpen && ( +
+
+ {t('nav.agentProfiles')} +
+ + {agentProfileError && ( +
+
{agentProfileError}
+ +
+ )} + + {!agentProfileError && agentProfileStatus === 'loading' && ( +
+ {t('common.loading')} +
+ )} + + {!agentProfileError && + agentProfileStatus !== 'loading' && + agentProfiles.length === 0 && ( +
+ {t('nav.noAgentProfiles')} +
+ )} + + {!agentProfileError && agentProfiles.length > 0 && ( +
+ {agentProfiles.map(profile => { + const active = profile.id === activeAgentProfileId; + return ( + + ); + })} +
+ )} +
+ )} +
diff --git a/app/src/components/__tests__/BottomTabBar.test.tsx b/app/src/components/__tests__/BottomTabBar.test.tsx index 67276f00e..0938c10c4 100644 --- a/app/src/components/__tests__/BottomTabBar.test.tsx +++ b/app/src/components/__tests__/BottomTabBar.test.tsx @@ -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(); 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', + }); + }); }); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index bca6356ef..6ab5997bc 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': 'تأكيد', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index d2cf135e5..110d0902f 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': 'নিশ্চিত করুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index c9e1e89e4..543745597 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 04dec1a56..d80242337 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 5dbd6392c..ab2a8336d 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 927aa1760..b20b85e83 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 13cca76a8..939d3beb7 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': 'कन्फर्म करें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 5b8edee13..8461877e1 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index c0a1d0c5d..a38efe589 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index dbb1acac3..8ffd08321 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': '확인', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 120d118d1..7f3a28093 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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ź', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 7fca8773b..3dd4e5363 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index cf7b3eef3..faa756d5f 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': 'Подтвердить', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 4ef89c683..0f49616ee 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': '确认', diff --git a/app/src/store/__tests__/agentProfileSlice.test.ts b/app/src/store/__tests__/agentProfileSlice.test.ts index cfa1acc6e..69725bc4a 100644 --- a/app/src/store/__tests__/agentProfileSlice.test.ts +++ b/app/src/store/__tests__/agentProfileSlice.test.ts @@ -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'); + }); }); diff --git a/app/src/store/agentProfileSlice.ts b/app/src/store/agentProfileSlice.ts index 7441defe5..b5ecf9113 100644 --- a/app/src/store/agentProfileSlice.ts +++ b/app/src/store/agentProfileSlice.ts @@ -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; diff --git a/app/test/playwright/specs/agent-profile-taskbar.spec.ts b/app/test/playwright/specs/agent-profile-taskbar.spec.ts new file mode 100644 index 000000000..83dac56d9 --- /dev/null +++ b/app/test/playwright/specs/agent-profile-taskbar.spec.ts @@ -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 { + 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'); + }); +});