Simplify Google Meet connection CTA (#3453)

This commit is contained in:
Steven Enamakel
2026-06-06 19:16:53 -04:00
committed by GitHub
parent e356c5cd5a
commit bb67282232
19 changed files with 263 additions and 178 deletions
+80 -151
View File
@@ -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={() => {}} />);
+1
View File
@@ -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': 'البحث في الذكريات...',
+1
View File
@@ -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': 'মেমোরি খুঁজুন...',
+1
View File
@@ -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...',
+1
View File
@@ -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',
+1
View File
@@ -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...',
+1
View File
@@ -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…',
+1
View File
@@ -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': 'मेमोरी सर्च करें...',
+1
View File
@@ -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...',
+1
View File
@@ -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...',
+1
View File
@@ -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': '메모리 검색...',
+1
View File
@@ -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...',
+1
View File
@@ -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...',
+1
View File
@@ -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': 'Поиск воспоминаний...',
+1
View File
@@ -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': '搜索记忆...',
+5 -4
View File
@@ -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);
});
});