diff --git a/app/src/components/skills/MeetingBotsCard.tsx b/app/src/components/skills/MeetingBotsCard.tsx index f1c735a9c..ed5243c53 100644 --- a/app/src/components/skills/MeetingBotsCard.tsx +++ b/app/src/components/skills/MeetingBotsCard.tsx @@ -4,19 +4,16 @@ // backend to send a Recall.ai-hosted mascot bot into the meeting. The // backend streams replies, harness requests, and the final transcript // back through the core Socket.IO bridge. - import { useCallback, useEffect, useMemo, useState } from 'react'; -import { RiveMascot, type MascotFace } from '../../features/human/Mascot'; +import { type MascotFace, RiveMascot } from '../../features/human/Mascot'; import { useT } from '../../lib/i18n/I18nContext'; import { joinMeetViaBackendBot, leaveBackendMeetBot, listMeetCalls, - type MascotMeetPlatform, type MeetCallRecord, } from '../../services/meetCallService'; -import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { type BackendMeetHarnessEvent, type BackendMeetReplyEvent, @@ -28,6 +25,14 @@ import { selectBackendMeetUrl, setBackendMeetJoining, } from '../../store/backendMeetSlice'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { + selectCustomPrimaryColor, + selectCustomSecondaryColor, + selectMascotColor, + selectSelectedMascotId, +} from '../../store/mascotSlice'; +import { selectPersonaDescription, selectPersonaDisplayName } from '../../store/personaSlice'; type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: string }; @@ -35,31 +40,6 @@ interface Props { onToast?: (toast: Toast) => void; } -interface PlatformDef { - platform: MascotMeetPlatform; - labelKey: string; - domainHintKey: string; - comingSoon?: boolean; -} - -const PLATFORMS: PlatformDef[] = [ - { - platform: 'gmeet', - labelKey: 'skills.meetingBots.platforms.gmeet', - domainHintKey: 'skills.meetingBots.platformHints.gmeet', - }, - { - platform: 'zoom', - labelKey: 'skills.meetingBots.platforms.zoom', - domainHintKey: 'skills.meetingBots.platformHints.zoom', - }, - { - platform: 'teams', - labelKey: 'skills.meetingBots.platforms.teams', - domainHintKey: 'skills.meetingBots.platformHints.teams', - }, -]; - export default function MeetingBotsCard({ onToast }: Props) { const [open, setOpen] = useState(false); const status = useAppSelector(selectBackendMeetStatus); @@ -81,7 +61,7 @@ export default function MeetingBotsCard({ onToast }: Props) { function faceFromMeetState( status: BackendMeetStatus, lastReply: BackendMeetReplyEvent | null, - lastHarness: BackendMeetHarnessEvent | null, + lastHarness: BackendMeetHarnessEvent | null ): MascotFace { if (status === 'joining') return 'thinking'; if (status === 'error') return 'concerned'; @@ -89,7 +69,8 @@ function faceFromMeetState( if (lastHarness) return 'thinking'; if (lastReply) { const e = (lastReply.emotion ?? '').toLowerCase(); - if (e.includes('happy') || e.includes('pleased') || e.includes('joy') || e.includes('excit')) return 'happy'; + if (e.includes('happy') || e.includes('pleased') || e.includes('joy') || e.includes('excit')) + return 'happy'; if (e.includes('celebrat') || e.includes('proud')) return 'celebrating'; if (e.includes('concern') || e.includes('worried') || e.includes('unsure')) return 'concerned'; if (e.includes('curious') || e.includes('interest')) return 'curious'; @@ -110,7 +91,9 @@ function ActiveMeetingView({ onToast }: Props) { try { const tail = new URL(meetUrl).pathname.replace(/^\/+/, ''); return tail || meetUrl; - } catch { return meetUrl; } + } catch { + return meetUrl; + } }, [meetUrl]); const [leaving, setLeaving] = useState(false); @@ -121,19 +104,24 @@ function ActiveMeetingView({ onToast }: Props) { try { await leaveBackendMeetBot('user-requested'); } catch (err) { - onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message: String(err) }); + onToast?.({ + type: 'error', + title: t('skills.meetingBots.couldNotStartTitle'), + message: String(err), + }); } finally { setLeaving(false); } }; - const statusText = { - joining: t('skills.meetingBots.liveStatusJoining'), - active: t('skills.meetingBots.liveStatusActive'), - ended: t('skills.meetingBots.liveStatusEnded'), - error: t('skills.meetingBots.liveStatusError'), - idle: '', - }[status] ?? ''; + const statusText = + { + joining: t('skills.meetingBots.liveStatusJoining'), + active: t('skills.meetingBots.liveStatusActive'), + ended: t('skills.meetingBots.liveStatusEnded'), + error: t('skills.meetingBots.liveStatusError'), + idle: '', + }[status] ?? ''; const canLeave = status === 'active' || status === 'joining'; const isDone = status === 'ended' || status === 'error'; @@ -142,17 +130,25 @@ function ActiveMeetingView({ onToast }: Props) {
-
{statusText}
{meetingCode && ( -
{meetingCode}
+
+ {meetingCode} +
)} {lastReply?.reply && (
@@ -243,11 +241,13 @@ interface ModalProps { export function MeetingBotsModal({ onClose, onToast }: ModalProps) { const { t } = useT(); const dispatch = useAppDispatch(); - const [platform, setPlatform] = useState('gmeet'); const [meetUrl, setMeetUrl] = useState(''); - const [displayName, setDisplayName] = useState('OpenHuman'); - const [respondToParticipant, setRespondToParticipant] = useState(''); - const [wakePhrase, setWakePhrase] = useState('Hey OpenHuman'); + const personaDisplayName = useAppSelector(selectPersonaDisplayName); + const personaDescription = useAppSelector(selectPersonaDescription); + const selectedMascotId = useAppSelector(selectSelectedMascotId); + const mascotColor = useAppSelector(selectMascotColor); + const customPrimaryColor = useAppSelector(selectCustomPrimaryColor); + const customSecondaryColor = useAppSelector(selectCustomSecondaryColor); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); // Recent-calls history loaded from core when the modal opens. @@ -276,9 +276,14 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) { void refreshRecentCalls(); }, [refreshRecentCalls]); - const selected = PLATFORMS.find(p => p.platform === platform) ?? PLATFORMS[0]; - const selectedLabel = t(selected.labelKey); - const isComingSoon = !!selected.comingSoon; + const selectedLabel = t('skills.meetingBots.platforms.gmeet'); + const agentName = personaDisplayName.trim() || 'OpenHuman'; + const systemPrompt = personaDescription.trim() || undefined; + const mascotId = selectedMascotId ?? (mascotColor === 'custom' ? undefined : mascotColor); + const riveColors = + mascotColor === 'custom' + ? { primaryColor: customPrimaryColor, secondaryColor: customSecondaryColor } + : undefined; // Esc closes the modal — matches the OpenhumanLinkModal pattern. useEffect(() => { @@ -292,10 +297,6 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) { const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(null); - if (isComingSoon) { - setError(t('skills.meetingBots.platformComingSoon').replace('{label}', selectedLabel)); - return; - } setSubmitting(true); try { // Optimistically update Redux state so the banner transitions to @@ -307,11 +308,12 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) { // streams transcript events back over Socket.IO. await joinMeetViaBackendBot({ meetUrl, - displayName, - platform, - agentName: displayName, - respondToParticipant: respondToParticipant.trim() || undefined, - wakePhrase: wakePhrase.trim() || undefined, + displayName: agentName, + platform: 'gmeet', + agentName, + systemPrompt, + mascotId, + riveColors, }); onToast?.({ type: 'success', @@ -349,40 +351,15 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) { className="absolute right-3 top-3 rounded-full p-1 text-stone-500 dark:text-neutral-400 hover:bg-white/80 dark:hover:bg-neutral-800/60 hover:text-stone-800 dark:hover:text-neutral-100"> ✕ -

{t('skills.meetingBots.modalTitle')}

+

+ {t('skills.meetingBots.modalTitle')} +

{t('skills.meetingBots.modalDesc')}

-
- {PLATFORMS.map(p => { - const active = p.platform === platform; - return ( - - ); - })} -
-
- - - - - - {error && (
@@ -534,7 +457,9 @@ function RecentCallsSection({ )} {rows === null ? ( -

{t('skills.meetingBots.recentCallsLoading')}

+

+ {t('skills.meetingBots.recentCallsLoading')} +

) : rows.length === 0 ? (

{t('skills.meetingBots.recentCallsEmpty')} @@ -567,13 +492,17 @@ function RecentCallRow({ call }: { call: MeetCallRecord }) { return (

  • - {meetingCode} + + {meetingCode} + {formatRelativeTime(call.started_at_ms)}
    - {call.turn_count} turn{call.turn_count === 1 ? '' : 's'} + + {call.turn_count} turn{call.turn_count === 1 ? '' : 's'} + {duration}s on call
  • diff --git a/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx b/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx index b3a1f09e0..9a34aa27d 100644 --- a/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx +++ b/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx @@ -92,6 +92,48 @@ describe('MeetingBotsCard', () => { }); }); + it('uses the saved persona and mascot profile when joining', async () => { + joinMock.mockResolvedValueOnce({ + meetUrl: 'https://meet.google.com/abc-defg-hij', + platform: 'gmeet', + }); + + renderWithProviders(, { + preloadedState: { + persona: { displayName: 'Nova', description: 'Calm and concise.' }, + mascot: { + color: 'custom', + voiceId: null, + voiceGender: 'male', + voiceUseLocaleDefault: false, + selectedMascotId: 'yellow', + customMascotGifUrl: null, + customPrimaryColor: '#123456', + customSecondaryColor: '#abcdef', + }, + }, + }); + + fireEvent.click(screen.getByTestId('meeting-bots-banner')); + fireEvent.change(screen.getByLabelText(/meeting link/i), { + target: { value: 'https://meet.google.com/abc-defg-hij' }, + }); + fireEvent.submit(screen.getByRole('dialog').querySelector('form')!); + + await vi.waitFor(() => { + expect(joinMock).toHaveBeenCalledWith( + expect.objectContaining({ + meetUrl: 'https://meet.google.com/abc-defg-hij', + displayName: 'Nova', + agentName: 'Nova', + systemPrompt: 'Calm and concise.', + mascotId: 'yellow', + riveColors: { primaryColor: '#123456', secondaryColor: '#abcdef' }, + }) + ); + }); + }); + it('surfaces a join error inline + as an error toast', async () => { joinMock.mockRejectedValueOnce(new Error('Bad URL')); const onToast = vi.fn(); @@ -111,19 +153,21 @@ describe('MeetingBotsCard', () => { expect(screen.getByRole('alert')).toHaveTextContent('Bad URL'); }); - it('Zoom is a live platform — submit is labelled "Send to Zoom", not "coming soon"', () => { + it('does not show meeting platform choices in the Google Meet CTA', () => { renderWithProviders(); fireEvent.click(screen.getByTestId('meeting-bots-banner')); - // Zoom is fully supported via Recall.ai; submit should not say "coming soon". - fireEvent.click(screen.getByRole('button', { name: /Zoom/ })); - expect(screen.queryByRole('button', { name: /coming soon/i })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: /send to zoom/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Zoom/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Microsoft Teams/ })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /send to google meet/i })).toBeInTheDocument(); }); - it('does not require the old owner-name field for backend Recall joins', () => { + it('only asks for the meeting link, not old bot tuning fields', () => { renderWithProviders(); fireEvent.click(screen.getByTestId('meeting-bots-banner')); + expect(screen.getByLabelText(/meeting link/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/^display name$/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/your name in the call/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/wake phrase/i)).not.toBeInTheDocument(); }); }); @@ -201,9 +245,7 @@ describe('MeetingBotsCard — ActiveMeetingView', () => { // MeetingBotsCard only shows ActiveMeetingView for active/joining. // When ended the banner is rendered so the user can start a new call. renderWithProviders(, { - preloadedState: { - backendMeet: { ...activeMeetState.backendMeet, status: 'ended' as const }, - }, + preloadedState: { backendMeet: { ...activeMeetState.backendMeet, status: 'ended' as const } }, }); expect(screen.getByTestId('meeting-bots-banner')).toBeInTheDocument(); expect(screen.queryByText(/live in meeting/i)).not.toBeInTheDocument(); @@ -212,14 +254,10 @@ describe('MeetingBotsCard — ActiveMeetingView', () => { it('shows error toast when leave call fails', async () => { leaveMock.mockRejectedValueOnce(new Error('Network error')); const onToast = vi.fn(); - renderWithProviders(, { - preloadedState: activeMeetState, - }); + renderWithProviders(, { preloadedState: activeMeetState }); fireEvent.click(screen.getByRole('button', { name: /leave/i })); await waitFor(() => - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }) - ) + expect(onToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) ); }); }); @@ -267,8 +305,16 @@ describe('MeetingBotsModal — recent calls section', () => { it('renders a row for each returned call record', async () => { const records = [ - makeCallRecord({ request_id: 'req-1', meet_url: 'https://meet.google.com/aaa-bbbb-ccc', turn_count: 2 }), - makeCallRecord({ request_id: 'req-2', meet_url: 'https://meet.google.com/ddd-eeee-fff', turn_count: 5 }), + makeCallRecord({ + request_id: 'req-1', + meet_url: 'https://meet.google.com/aaa-bbbb-ccc', + turn_count: 2, + }), + makeCallRecord({ + request_id: 'req-2', + meet_url: 'https://meet.google.com/ddd-eeee-fff', + turn_count: 5, + }), ]; listMock.mockResolvedValueOnce(records); @@ -321,9 +367,7 @@ describe('MeetingBotsModal — recent calls section', () => { }); it('shows duration as combined spoken + listened seconds', async () => { - listMock.mockResolvedValueOnce([ - makeCallRecord({ spoken_seconds: 40, listened_seconds: 20 }), - ]); + listMock.mockResolvedValueOnce([makeCallRecord({ spoken_seconds: 40, listened_seconds: 20 })]); renderWithProviders( {}} />); @@ -334,9 +378,7 @@ describe('MeetingBotsModal — recent calls section', () => { it('shows a relative timestamp for recent calls', async () => { // started 5 minutes ago - listMock.mockResolvedValueOnce([ - makeCallRecord({ started_at_ms: Date.now() - 5 * 60 * 1000 }), - ]); + listMock.mockResolvedValueOnce([makeCallRecord({ started_at_ms: Date.now() - 5 * 60 * 1000 })]); renderWithProviders( {}} />); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index c657d3d3c..07a87ad9d 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -273,6 +273,7 @@ const messages: TranslationMap = { 'skills.tabs.composio': 'Composio', 'skills.tabs.channels': 'القنوات', 'skills.tabs.explorer': 'المهارات', + 'skills.tabs.meetings': 'Google Meet', 'skills.tabs.mcp': 'MCP الخوادم', 'memory.title': 'الذاكرة', 'memory.search': 'البحث في الذكريات...', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index e422f5c2c..d80241e1a 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -275,6 +275,7 @@ const messages: TranslationMap = { 'skills.tabs.composio': 'Composio', 'skills.tabs.channels': 'চ্যানেল', 'skills.tabs.explorer': 'স্কিল', + 'skills.tabs.meetings': 'Google Meet', 'skills.tabs.mcp': 'MCP সার্ভার', 'memory.title': 'মেমোরি', 'memory.search': 'মেমোরি খুঁজুন...', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 9e011fb04..e39e62a19 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -284,6 +284,7 @@ const messages: TranslationMap = { 'skills.tabs.composio': 'Composio', 'skills.tabs.channels': 'Kanäle', 'skills.tabs.explorer': 'Skills', + 'skills.tabs.meetings': 'Google Meet', 'skills.tabs.mcp': 'MCP Server', 'memory.title': 'Erinnerung', 'memory.search': 'Erinnerungen suchen...', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index b4172b69f..1b4dd44f9 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -300,6 +300,7 @@ const en: TranslationMap = { 'skills.tabs.composio': 'Composio', 'skills.tabs.channels': 'Channels', 'skills.tabs.explorer': 'Skills', + 'skills.tabs.meetings': 'Google Meet', 'skills.tabs.mcp': 'MCP Servers', // Intelligence / Memory 'memory.title': 'Memory', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 3cc6fe72d..984a2d008 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -285,6 +285,7 @@ const messages: TranslationMap = { 'skills.tabs.composio': 'Composio', 'skills.tabs.channels': 'Canales', 'skills.tabs.explorer': 'Skills', + 'skills.tabs.meetings': 'Google Meet', 'skills.tabs.mcp': 'MCP Servidores', 'memory.title': 'Memoria', 'memory.search': 'Buscar recuerdos...', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 355740053..4539b6c73 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -283,6 +283,7 @@ const messages: TranslationMap = { 'skills.tabs.composio': 'Composio', 'skills.tabs.channels': 'Canaux', 'skills.tabs.explorer': 'Skills', + 'skills.tabs.meetings': 'Google Meet', 'skills.tabs.mcp': 'MCP Serveurs', 'memory.title': 'Mémoire', 'memory.search': 'Rechercher dans la mémoire…', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index f96fcffee..7ad19cdf8 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -275,6 +275,7 @@ const messages: TranslationMap = { 'skills.tabs.composio': 'Composio', 'skills.tabs.channels': 'चैनल', 'skills.tabs.explorer': 'स्किल', + 'skills.tabs.meetings': 'Google Meet', 'skills.tabs.mcp': 'MCP सर्वर', 'memory.title': 'मेमोरी', 'memory.search': 'मेमोरी सर्च करें...', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 81bed168e..745c540bd 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -277,6 +277,7 @@ const messages: TranslationMap = { 'skills.tabs.composio': 'Composio', 'skills.tabs.channels': 'Saluran', 'skills.tabs.explorer': 'Skill', + 'skills.tabs.meetings': 'Google Meet', 'skills.tabs.mcp': 'MCP Server', 'memory.title': 'Memori', 'memory.search': 'Cari memori...', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 5e643e924..7e1326508 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -281,6 +281,7 @@ const messages: TranslationMap = { 'skills.tabs.composio': 'Composio', 'skills.tabs.channels': 'Canali', 'skills.tabs.explorer': 'Skill', + 'skills.tabs.meetings': 'Google Meet', 'skills.tabs.mcp': 'MCP Server', 'memory.title': 'Memoria', 'memory.search': 'Cerca memorie...', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 3f2f70848..7fac6c5de 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -275,6 +275,7 @@ const messages: TranslationMap = { 'skills.tabs.composio': 'Composio', 'skills.tabs.channels': '채널', 'skills.tabs.explorer': '스킬', + 'skills.tabs.meetings': 'Google Meet', 'skills.tabs.mcp': 'MCP 서버', 'memory.title': '메모리', 'memory.search': '메모리 검색...', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 7b9598d39..d16fa3067 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -279,6 +279,7 @@ const messages: TranslationMap = { 'skills.tabs.composio': 'Composio', 'skills.tabs.channels': 'Kanały', 'skills.tabs.explorer': 'Skille', + 'skills.tabs.meetings': 'Google Meet', 'skills.tabs.mcp': 'Serwery MCP', 'memory.title': 'Pamięć', 'memory.search': 'Szukaj w pamięci...', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 75fd73437..9fc408cd8 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -283,6 +283,7 @@ const messages: TranslationMap = { 'skills.tabs.composio': 'Composio', 'skills.tabs.channels': 'Canais', 'skills.tabs.explorer': 'Skills', + 'skills.tabs.meetings': 'Google Meet', 'skills.tabs.mcp': 'MCP Servidores', 'memory.title': 'Memória', 'memory.search': 'Pesquisar memórias...', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 81d958036..89378ec8b 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -277,6 +277,7 @@ const messages: TranslationMap = { 'skills.tabs.composio': 'Composio', 'skills.tabs.channels': 'Каналы', 'skills.tabs.explorer': 'Навыки', + 'skills.tabs.meetings': 'Google Meet', 'skills.tabs.mcp': 'MCP Серверы', 'memory.title': 'Память', 'memory.search': 'Поиск воспоминаний...', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 84f2b6b23..d5866ae2c 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -264,6 +264,7 @@ const messages: TranslationMap = { 'skills.tabs.composio': 'Composio', 'skills.tabs.channels': '渠道', 'skills.tabs.explorer': '技能', + 'skills.tabs.meetings': 'Google Meet', 'skills.tabs.mcp': 'MCP 服务器', 'memory.title': '记忆', 'memory.search': '搜索记忆...', diff --git a/app/src/pages/Skills.tsx b/app/src/pages/Skills.tsx index e8757bd08..cb4066ea2 100644 --- a/app/src/pages/Skills.tsx +++ b/app/src/pages/Skills.tsx @@ -342,7 +342,7 @@ interface SkillItem { // ─── Main Skills Page ────────────────────────────────────────────────────────── -type ConnectionsTab = 'channels' | 'composio' | 'mcp'; +type ConnectionsTab = 'channels' | 'composio' | 'mcp' | 'meetings'; export default function Skills() { const { t } = useT(); @@ -350,13 +350,13 @@ export default function Skills() { const location = useLocation(); const navigate = useNavigate(); const isLocalSession = isLocalSessionToken(getCoreStateSnapshot().snapshot.sessionToken); - // Honour `?tab=` so deep links land on the right + // Honour `?tab=` so deep links land on the right // sub-tab. (The legacy `runners` tab was removed; running a workflow now // lives on its detail drawer → /skills/run.) const initialTab: ConnectionsTab = (() => { const params = new URLSearchParams(location.search); const t = params.get('tab'); - if (t === 'composio' || t === 'channels' || t === 'mcp') return t; + if (t === 'composio' || t === 'channels' || t === 'mcp' || t === 'meetings') return t; return 'composio'; })(); const [activeTab, setActiveTab] = useState(initialTab); @@ -792,6 +792,7 @@ export default function Skills() { items={[ { value: 'composio', label: t('skills.tabs.composio') }, { value: 'channels', label: t('skills.tabs.channels') }, + { value: 'meetings', label: t('skills.tabs.meetings') }, { value: 'mcp', label: t('skills.tabs.mcp') }, ]} /> @@ -859,7 +860,7 @@ export default function Skills() {
    )} - + {activeTab === 'meetings' && } {activeTab === 'composio' && (
    diff --git a/app/src/pages/__tests__/Skills.meetings-tab.test.tsx b/app/src/pages/__tests__/Skills.meetings-tab.test.tsx new file mode 100644 index 000000000..29527373e --- /dev/null +++ b/app/src/pages/__tests__/Skills.meetings-tab.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import '../../test/mockDefaultSkillStatusHooks'; +import { renderWithProviders } from '../../test/test-utils'; +import Skills from '../Skills'; + +vi.mock('../../components/skills/MeetingBotsCard', () => ({ + default: () =>
    Meeting bot CTA
    , +})); + +vi.mock('../../hooks/useChannelDefinitions', () => ({ + useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }), +})); + +vi.mock('../../services/api/skillsApi', async () => { + const actual = await vi.importActual( + '../../services/api/skillsApi' + ); + return { + ...actual, + skillsApi: { ...actual.skillsApi, listSkills: vi.fn().mockResolvedValue([]) }, + }; +}); + +vi.mock('../../lib/composio/hooks', () => ({ + useComposioIntegrations: () => ({ + toolkits: [], + connectionByToolkit: new Map(), + refresh: vi.fn(), + loading: false, + error: null, + }), + useAgentReadyComposioToolkits: () => ({ + agentReady: new Set(), + loading: true, + error: null, + }), +})); + +describe('Skills page — Meetings tab', () => { + it('keeps the meeting bot CTA in its own Connections tab', () => { + renderWithProviders(, { initialEntries: ['/skills'] }); + + expect(screen.queryByTestId('meeting-bots-card')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('tab', { name: 'Google Meet' })); + + expect(screen.getByTestId('meeting-bots-card')).toBeInTheDocument(); + }); + + it('supports direct links to the Meetings tab', () => { + renderWithProviders(, { initialEntries: ['/skills?tab=meetings'] }); + + expect(screen.getByRole('tab', { name: 'Google Meet' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByTestId('meeting-bots-card')).toBeInTheDocument(); + }); +}); diff --git a/app/test/playwright/specs/gmeet-connections-tab.spec.ts b/app/test/playwright/specs/gmeet-connections-tab.spec.ts new file mode 100644 index 000000000..c44c4a154 --- /dev/null +++ b/app/test/playwright/specs/gmeet-connections-tab.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; + +import { + bootAuthenticatedPage, + dismissWalkthroughIfPresent, + waitForAppReady, +} from '../helpers/core-rpc'; + +test.describe('Google Meet Connections tab', () => { + test.beforeEach(async ({ page }) => { + await bootAuthenticatedPage(page, 'pw-gmeet-connections-tab-user', '/skills?tab=meetings'); + await waitForAppReady(page); + await dismissWalkthroughIfPresent(page); + }); + + test('opens the dedicated tab and shows a one-field meeting link modal', async ({ page }) => { + await expect + .poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 }) + .toContain('/skills?tab=meetings'); + + await expect(page.getByRole('tab', { name: 'Google Meet', exact: true })).toHaveAttribute( + 'aria-selected', + 'true' + ); + + await page.getByTestId('meeting-bots-banner').click(); + + const dialog = page.getByRole('dialog', { name: 'Send OpenHuman to a meeting' }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByLabel('Meeting link')).toBeVisible(); + await expect(dialog.locator('input[type="url"]')).toHaveCount(1); + await expect(dialog.locator('input[type="text"]')).toHaveCount(0); + await expect(dialog.getByText('Wake Phrase')).toHaveCount(0); + await expect(dialog.getByText('Display name')).toHaveCount(0); + await expect(dialog.getByText('Zoom')).toHaveCount(0); + await expect(dialog.getByText('Microsoft Teams')).toHaveCount(0); + }); +});