mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Simplify Google Meet connection CTA (#3453)
This commit is contained in:
@@ -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) {
|
||||
<div className="relative overflow-hidden rounded-2xl border border-primary-200/60 dark:border-primary-500/30 bg-gradient-to-br from-primary-50 via-white to-amber-50 dark:from-primary-500/15 dark:via-neutral-900 dark:to-amber-500/10 p-4 shadow-soft animate-fade-up">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="flex items-center gap-1.5 rounded-full bg-coral-500/10 dark:bg-coral-400/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-coral-600 dark:text-coral-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-coral-500 animate-pulse" aria-hidden="true" />
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full bg-coral-500 animate-pulse"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('skills.meetingBots.liveBadge')}
|
||||
</span>
|
||||
{canLeave && (
|
||||
<button type="button" onClick={handleLeave} disabled={leaving}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLeave}
|
||||
disabled={leaving}
|
||||
className="rounded-xl px-3 py-1.5 text-xs font-medium bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-300 hover:bg-stone-200 dark:hover:bg-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{t('skills.meetingBots.leaveButton')}
|
||||
</button>
|
||||
)}
|
||||
{isDone && (
|
||||
<button type="button" onClick={() => dispatch(resetBackendMeet())}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(resetBackendMeet())}
|
||||
className="rounded-xl px-3 py-1.5 text-xs font-medium bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-300 hover:bg-stone-200 dark:hover:bg-neutral-700">
|
||||
{t('common.close')}
|
||||
</button>
|
||||
@@ -168,7 +164,9 @@ function ActiveMeetingView({ onToast }: Props) {
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">{statusText}</div>
|
||||
{meetingCode && (
|
||||
<div className="mt-1 truncate font-mono text-[11px] text-stone-600 dark:text-neutral-400">{meetingCode}</div>
|
||||
<div className="mt-1 truncate font-mono text-[11px] text-stone-600 dark:text-neutral-400">
|
||||
{meetingCode}
|
||||
</div>
|
||||
)}
|
||||
{lastReply?.reply && (
|
||||
<div className="mt-1.5 text-xs text-stone-600 dark:text-neutral-300 line-clamp-2 italic">
|
||||
@@ -243,11 +241,13 @@ interface ModalProps {
|
||||
export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
const [platform, setPlatform] = useState<MascotMeetPlatform>('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<string | null>(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<HTMLFormElement>) => {
|
||||
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">
|
||||
✕
|
||||
</button>
|
||||
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">{t('skills.meetingBots.modalTitle')}</h2>
|
||||
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
|
||||
{t('skills.meetingBots.modalTitle')}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs leading-relaxed text-stone-600 dark:text-neutral-300">
|
||||
{t('skills.meetingBots.modalDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PLATFORMS.map(p => {
|
||||
const active = p.platform === platform;
|
||||
return (
|
||||
<button
|
||||
key={p.platform}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPlatform(p.platform);
|
||||
setError(null);
|
||||
}}
|
||||
className={`rounded-full px-3 py-1 text-[11px] font-medium transition ${
|
||||
active
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 hover:bg-stone-200 dark:hover:bg-neutral-700'
|
||||
}`}>
|
||||
{t(p.labelKey)}
|
||||
{p.comingSoon && (
|
||||
<span className="ml-1 opacity-70">
|
||||
· {t('skills.meetingBots.soonSuffix')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
@@ -395,64 +372,14 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
|
||||
spellCheck={false}
|
||||
value={meetUrl}
|
||||
onChange={e => setMeetUrl(e.target.value)}
|
||||
placeholder={t(selected.domainHintKey)}
|
||||
disabled={isComingSoon || submitting}
|
||||
placeholder={t('skills.meetingBots.platformHints.gmeet')}
|
||||
disabled={submitting}
|
||||
autoFocus
|
||||
className="mt-1 w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed disabled:bg-stone-50 dark:disabled:bg-neutral-800/60"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
{t('skills.meetingBots.displayName')}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
maxLength={64}
|
||||
disabled={isComingSoon || submitting}
|
||||
className="mt-1 w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed disabled:bg-stone-50 dark:disabled:bg-neutral-800/60"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
{t('skills.meetingBots.respondToParticipant')}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={respondToParticipant}
|
||||
onChange={e => setRespondToParticipant(e.target.value)}
|
||||
placeholder={t('skills.meetingBots.respondToParticipantHint')}
|
||||
maxLength={128}
|
||||
disabled={isComingSoon || submitting}
|
||||
className="mt-1 w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed disabled:bg-stone-50 dark:disabled:bg-neutral-800/60"
|
||||
/>
|
||||
<span className="mt-1 block text-[10px] text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.meetingBots.respondToParticipantDesc')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
{t('skills.meetingBots.wakePhrase')}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={wakePhrase}
|
||||
onChange={e => setWakePhrase(e.target.value)}
|
||||
placeholder={t('skills.meetingBots.wakePhraseHint')}
|
||||
maxLength={128}
|
||||
disabled={isComingSoon || submitting}
|
||||
className="mt-1 w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed disabled:bg-stone-50 dark:disabled:bg-neutral-800/60"
|
||||
/>
|
||||
<span className="mt-1 block text-[10px] text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.meetingBots.wakePhraseDesc')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -470,15 +397,11 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
submitting || isComingSoon || !meetUrl.trim()
|
||||
}
|
||||
disabled={submitting || !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
|
||||
? t('skills.meetingBots.comingSoon').replace('{label}', selectedLabel)
|
||||
: submitting
|
||||
? t('skills.meetingBots.starting')
|
||||
: t('skills.meetingBots.sendTo').replace('{label}', selectedLabel)}
|
||||
{submitting
|
||||
? t('skills.meetingBots.starting')
|
||||
: t('skills.meetingBots.sendTo').replace('{label}', selectedLabel)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -534,7 +457,9 @@ function RecentCallsSection({
|
||||
)}
|
||||
|
||||
{rows === null ? (
|
||||
<p className="mt-2 text-[11px] text-stone-400 dark:text-neutral-500">{t('skills.meetingBots.recentCallsLoading')}</p>
|
||||
<p className="mt-2 text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.meetingBots.recentCallsLoading')}
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="mt-2 text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.meetingBots.recentCallsEmpty')}
|
||||
@@ -567,13 +492,17 @@ function RecentCallRow({ call }: { call: MeetCallRecord }) {
|
||||
return (
|
||||
<li className="rounded-lg px-2 py-1.5 text-[11px] text-stone-700 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800/40">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-mono text-stone-800 dark:text-neutral-200">{meetingCode}</span>
|
||||
<span className="truncate font-mono text-stone-800 dark:text-neutral-200">
|
||||
{meetingCode}
|
||||
</span>
|
||||
<span className="shrink-0 text-stone-400 dark:text-neutral-500">
|
||||
{formatRelativeTime(call.started_at_ms)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-stone-500 dark:text-neutral-400">
|
||||
<span>{call.turn_count} turn{call.turn_count === 1 ? '' : 's'}</span>
|
||||
<span>
|
||||
{call.turn_count} turn{call.turn_count === 1 ? '' : 's'}
|
||||
</span>
|
||||
<span>{duration}s on call</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -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(<MeetingBotsCard />, {
|
||||
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(<MeetingBotsCard />);
|
||||
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(<MeetingBotsCard />);
|
||||
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(<MeetingBotsCard />, {
|
||||
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(<MeetingBotsCard onToast={onToast} />, {
|
||||
preloadedState: activeMeetState,
|
||||
});
|
||||
renderWithProviders(<MeetingBotsCard onToast={onToast} />, { 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(<MeetingBotsModal onClose={() => {}} />);
|
||||
|
||||
@@ -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(<MeetingBotsModal onClose={() => {}} />);
|
||||
|
||||
|
||||
@@ -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': 'البحث في الذكريات...',
|
||||
|
||||
@@ -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': 'মেমোরি খুঁজুন...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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': 'मेमोरी सर्च करें...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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': '메모리 검색...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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': 'Поиск воспоминаний...',
|
||||
|
||||
@@ -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': '搜索记忆...',
|
||||
|
||||
@@ -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=<composio|channels|mcp>` so deep links land on the right
|
||||
// Honour `?tab=<composio|channels|mcp|meetings>` 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<ConnectionsTab>(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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MeetingBotsCard onToast={addToast} />
|
||||
{activeTab === 'meetings' && <MeetingBotsCard onToast={addToast} />}
|
||||
|
||||
{activeTab === 'composio' && (
|
||||
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 shadow-soft animate-fade-up">
|
||||
|
||||
@@ -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: () => <div data-testid="meeting-bots-card">Meeting bot CTA</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useChannelDefinitions', () => ({
|
||||
useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/api/skillsApi', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../services/api/skillsApi')>(
|
||||
'../../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<string>(),
|
||||
loading: true,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Skills page — Meetings tab', () => {
|
||||
it('keeps the meeting bot CTA in its own Connections tab', () => {
|
||||
renderWithProviders(<Skills />, { 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(<Skills />, { initialEntries: ['/skills?tab=meetings'] });
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'Google Meet' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
);
|
||||
expect(screen.getByTestId('meeting-bots-card')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user