diff --git a/app/src/components/skills/MeetingBotsCard.tsx b/app/src/components/skills/MeetingBotsCard.tsx index c93dd1f26a..b549eab9db 100644 --- a/app/src/components/skills/MeetingBotsCard.tsx +++ b/app/src/components/skills/MeetingBotsCard.tsx @@ -115,7 +115,7 @@ interface ModalProps { onToast?: (toast: Toast) => void; } -function MeetingBotsModal({ onClose, onToast }: ModalProps) { +export function MeetingBotsModal({ onClose, onToast }: ModalProps) { const { t } = useT(); const [platform, setPlatform] = useState('gmeet'); const [meetUrl, setMeetUrl] = useState(''); @@ -282,10 +282,10 @@ function MeetingBotsModal({ onClose, onToast }: ModalProps) { disabled={submitting || isComingSoon || !meetUrl.trim()} className="rounded-xl bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 disabled:cursor-not-allowed disabled:bg-stone-200 dark:disabled:bg-neutral-700 disabled:text-stone-400 dark:disabled:text-neutral-500"> {isComingSoon - ? `${selected.label} ${t('skills.meetingBots.comingSoon')}` + ? t('skills.meetingBots.comingSoon').replace('{label}', selected.label) : submitting ? t('skills.meetingBots.starting') - : `${t('skills.meetingBots.sendTo')} ${selected.label}`} + : t('skills.meetingBots.sendTo').replace('{label}', selected.label)} diff --git a/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx b/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx index 1d99f8d739..fb5fd4c412 100644 --- a/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx +++ b/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx @@ -1,7 +1,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import MeetingBotsCard from '../MeetingBotsCard'; +import MeetingBotsCard, { MeetingBotsModal } from '../MeetingBotsCard'; const joinMock = vi.fn(); @@ -126,4 +126,14 @@ describe('MeetingBotsCard', () => { const submit = screen.getByRole('button', { name: /coming soon/i }); expect(submit).toBeDisabled(); }); + + // MeetingBotsModal is exported standalone so HumanPage (and any future + // surface) can open the same join flow without the banner chrome. + it('exposes MeetingBotsModal as a standalone export usable without the banner', () => { + const onClose = vi.fn(); + render(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/src/features/human/HumanPage.test.tsx b/app/src/features/human/HumanPage.test.tsx index 50ecd2014a..33b88e2878 100644 --- a/app/src/features/human/HumanPage.test.tsx +++ b/app/src/features/human/HumanPage.test.tsx @@ -32,6 +32,21 @@ vi.mock('./Mascot', () => ({ vi.mock('./useHumanMascot', () => ({ useHumanMascot: () => ({ face: 'idle', visemes: [] }) })); +// Stub the meeting-bots modal — the real component imports apiClient and a +// network-aware service singleton. The HumanPage tests only care that the pill +// toggles the modal open, not the modal's submit machinery (that lives in +// MeetingBotsCard.test.tsx). +vi.mock('../../components/skills/MeetingBotsCard', () => ({ + default: () => null, + MeetingBotsModal: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), +})); + const SPEAK_REPLIES_KEY = 'human.speakReplies'; function buildMinimalStore() { @@ -102,6 +117,15 @@ describe('HumanPage — speak-replies localStorage persistence', () => { expect(checkbox).toBeChecked(); }); + it('opens the meeting-bots modal when the join-meeting pill is clicked', async () => { + renderHumanPage(); + expect(screen.queryByTestId('meeting-bots-modal-stub')).not.toBeInTheDocument(); + await act(async () => { + fireEvent.click(screen.getByTestId('human-join-meeting-pill')); + }); + expect(screen.getByTestId('meeting-bots-modal-stub')).toBeInTheDocument(); + }); + it('renders sub-mascots for the selected thread subagent timeline', () => { const store = buildMinimalStore(); store.dispatch(setSelectedThread('thread-subagents')); diff --git a/app/src/features/human/HumanPage.tsx b/app/src/features/human/HumanPage.tsx index 8def765428..37b4eac88d 100644 --- a/app/src/features/human/HumanPage.tsx +++ b/app/src/features/human/HumanPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; +import { MeetingBotsModal } from '../../components/skills/MeetingBotsCard'; import { useT } from '../../lib/i18n/I18nContext'; import Conversations from '../../pages/Conversations'; import type { ToolTimelineEntry } from '../../store/chatRuntimeSlice'; @@ -21,6 +22,7 @@ const HumanPage = () => { const raw = window.localStorage.getItem(SPEAK_REPLIES_KEY); return raw === null ? true : raw === '1'; }); + const [joinMeetingOpen, setJoinMeetingOpen] = useState(false); useEffect(() => { window.localStorage.setItem(SPEAK_REPLIES_KEY, speakReplies ? '1' : '0'); @@ -65,6 +67,20 @@ const HumanPage = () => { {t('voice.pushToTalk')} + {/* "Send OpenHuman to a meeting" — opens the same backend-bot modal as + the Skills tab so the mascot joins as a separate participant (its + own audio identity, no echo against the user's mic). */} + + + {joinMeetingOpen && setJoinMeetingOpen(false)} />} + {/* Chat sidebar — vertically centered above the BottomTabBar (~80px). */}
)} - {/* */} +
diff --git a/app/src/services/meetCallService.ts b/app/src/services/meetCallService.ts index 6ca0248c25..3e97afe3c3 100644 --- a/app/src/services/meetCallService.ts +++ b/app/src/services/meetCallService.ts @@ -114,7 +114,7 @@ export interface MascotJoinMeetingResult { * without leaking the underlying paid-plan rule. */ export const SERVER_OVERLOADED_MESSAGE = - 'OpenHuman is under heavy load right now. Please try again in a few minutes.'; + 'Mascot streaming capacity is exhausted. Please try again later.'; export interface MascotJoinMeetingError { /** User-safe error text. Falls back to a generic message. */