fix(meet): block join modal until backend admits + hide wake-phrase field (#3597)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
YellowSnnowmann
2026-06-11 19:04:29 +05:30
committed by GitHub
co-authored by Claude Opus 4.7
parent 70210a2283
commit 188f0fb7eb
4 changed files with 173 additions and 45 deletions
+79 -22
View File
@@ -4,7 +4,7 @@
// 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { type MascotFace, RiveMascot } from '../../features/human/Mascot';
import { useT } from '../../lib/i18n/I18nContext';
@@ -19,6 +19,7 @@ import {
type BackendMeetReplyEvent,
type BackendMeetStatus,
resetBackendMeet,
selectBackendMeetError,
selectBackendMeetLastHarness,
selectBackendMeetLastReply,
selectBackendMeetListenOnly,
@@ -45,7 +46,11 @@ export default function MeetingBotsCard({ onToast }: Props) {
const [open, setOpen] = useState(false);
const status = useAppSelector(selectBackendMeetStatus);
const showActive = status === 'active' || status === 'joining';
// Only switch to ActiveMeetingView once the backend has actually admitted
// the bot. The 'joining' state still leaves the user on the modal so a
// synchronous backend rejection (e.g. paid-plan gate) surfaces in the
// modal's error alert instead of flashing through ActiveMeetingView.
const showActive = status === 'active';
return (
<>
@@ -248,7 +253,11 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
const { t } = useT();
const dispatch = useAppDispatch();
const [meetUrl, setMeetUrl] = useState('');
const [respondTo, setRespondTo] = useState('');
// PASSIVE MODE: the respondTo state is retained for the
// joinMeetViaBackendBot payload (always passed undefined now since the
// backend ignores it) but the setter is unused while the input field
// is hidden. Restore `[respondTo, setRespondTo]` if the field returns.
const [respondTo] = useState('');
const personaDisplayName = useAppSelector(selectPersonaDisplayName);
const personaDescription = useAppSelector(selectPersonaDescription);
const selectedMascotId = useAppSelector(selectSelectedMascotId);
@@ -257,6 +266,13 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
const customSecondaryColor = useAppSelector(selectCustomSecondaryColor);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const meetStatus = useAppSelector(selectBackendMeetStatus);
const meetError = useAppSelector(selectBackendMeetError);
// True once the user has clicked Join in this modal session — guards the
// status-watching effect against stale redux state from a prior attempt.
// Held as a ref (not state) so toggling it inside the effect doesn't
// trigger a re-render cascade — see react-hooks/set-state-in-effect.
const hasSubmittedRef = useRef(false);
// Recent-calls history loaded from core when the modal opens.
// `null` means "not yet fetched"; `[]` means "fetched, no rows".
// Separating the two lets the UI render a "Loading…" hint on
@@ -292,25 +308,62 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
? { primaryColor: customPrimaryColor, secondaryColor: customSecondaryColor }
: undefined;
// The modal blocks dismissal while a join request is in flight so the
// backend's admit/reject verdict (success toast + close, or inline
// error) isn't skipped by an early Escape / backdrop click / X / Cancel.
const canDismiss = !submitting;
// Esc closes the modal — matches the OpenhumanLinkModal pattern.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Escape' && canDismiss) onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
}, [canDismiss, onClose]);
// After Join is clicked, watch the backend meet status. The RPC returns
// as soon as the join request reaches the core, but the actual admit /
// reject from the bot service arrives asynchronously over the socket.
// - 'active' → bot was admitted; surface success toast and close.
// - 'error' → bot was rejected (paid-plan gate, capacity, etc); leave
// the modal open with the backend's message in the alert
// so the user is blocked from joining and sees why.
useEffect(() => {
if (!hasSubmittedRef.current) return;
if (meetStatus === 'active') {
hasSubmittedRef.current = false;
onToast?.({
type: 'success',
title: t('skills.meetingBots.joiningTitle'),
message: t('skills.meetingBots.joiningMessage'),
});
setMeetUrl('');
onClose();
return;
}
if (meetStatus === 'error') {
hasSubmittedRef.current = false;
const message = meetError?.trim() || t('skills.meetingBots.failedToStart');
setError(message);
setSubmitting(false);
onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message });
}
}, [meetStatus, meetError, onClose, onToast, t]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setSubmitting(true);
hasSubmittedRef.current = true;
try {
// Generate a correlation ID so every backend event for this session
// can be tied back to this meeting.
const meetingId = crypto.randomUUID();
// Optimistically update Redux state so the banner transitions to
// the ActiveMeetingView immediately, before the backend responds.
// Mark the meet slice as "joining" so the rest of the app reflects
// the in-flight state. The modal stays open until the backend either
// admits the bot (status → 'active', useEffect closes the modal) or
// rejects it (status → 'error', useEffect surfaces the message).
dispatch(setBackendMeetJoining({ meetUrl: meetUrl.trim(), meetingId }));
// Backend Recall.ai bot: sends the mascot into the meeting via
// the backend's Recall.ai integration. The backend joins as a
@@ -327,19 +380,14 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
correlationId: meetingId,
respondToParticipant: respondTo.trim() || undefined,
});
onToast?.({
type: 'success',
title: t('skills.meetingBots.joiningTitle'),
message: t('skills.meetingBots.joiningMessage'),
});
setMeetUrl('');
onClose();
// Don't close the modal here — wait for status === 'active' or 'error'
// via the watcher useEffect above.
} catch (err) {
const message = err instanceof Error ? err.message : t('skills.meetingBots.failedToStart');
setError(message);
onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message });
} finally {
setSubmitting(false);
hasSubmittedRef.current = false;
onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message });
}
};
@@ -349,7 +397,9 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
aria-modal="true"
aria-label={t('skills.meetingBots.modalAriaLabel')}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onClose}>
onClick={() => {
if (canDismiss) onClose();
}}>
<div
className="w-full max-w-md overflow-hidden rounded-2xl bg-white dark:bg-neutral-900 shadow-xl"
onClick={e => e.stopPropagation()}>
@@ -360,7 +410,8 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
type="button"
onClick={onClose}
aria-label={t('common.close')}
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">
disabled={!canDismiss}
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 disabled:cursor-not-allowed disabled:opacity-40">
</button>
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
@@ -392,7 +443,12 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
/>
</label>
<label className="block">
{/* PASSIVE MODE: the bot doesn't listen for a wake phrase or
respond to a single participant — it just transcribes. The
"Your Name in This Meeting" field is hidden so users aren't
prompted for input that no longer affects behavior. Restore
this block if the responsive bot is ever re-enabled. */}
{/* <label className="block">
<span className="text-[10px] font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('skills.meetingBots.respondToParticipant')}
</span>
@@ -410,7 +466,7 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
<p className="mt-1 text-[10px] text-stone-400 dark:text-neutral-500">
{t('skills.meetingBots.respondToParticipantDesc')}
</p>
</label>
</label> */}
{error && (
<div
@@ -424,12 +480,13 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
<button
type="button"
onClick={onClose}
className="rounded-xl px-3 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800">
disabled={!canDismiss}
className="rounded-xl px-3 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 disabled:cursor-not-allowed disabled:opacity-40">
{t('common.cancel')}
</button>
<button
type="submit"
disabled={submitting || !meetUrl.trim() || !respondTo.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">
{submitting
? t('skills.meetingBots.starting')
@@ -2,6 +2,10 @@ import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MeetCallRecord } from '../../../services/meetCallService';
import {
setBackendMeetError,
setBackendMeetJoined,
} from '../../../store/backendMeetSlice';
import { renderWithProviders } from '../../../test/test-utils';
import MeetingBotsCard, { MeetingBotsModal } from '../MeetingBotsCard';
@@ -62,15 +66,12 @@ describe('MeetingBotsCard', () => {
platform: 'gmeet',
});
const onToast = vi.fn();
renderWithProviders(<MeetingBotsCard onToast={onToast} />);
const { store } = renderWithProviders(<MeetingBotsCard onToast={onToast} />);
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
fireEvent.change(screen.getByLabelText(/meeting link/i), {
target: { value: 'https://meet.google.com/abc-defg-hij' },
});
fireEvent.change(screen.getByLabelText(/your name in this meeting/i), {
target: { value: 'Alice' },
});
const form = screen.getByRole('dialog').querySelector('form')!;
fireEvent.submit(form);
@@ -81,16 +82,21 @@ describe('MeetingBotsCard', () => {
displayName: 'OpenHuman',
platform: 'gmeet',
agentName: 'OpenHuman',
respondToParticipant: 'Alice',
})
);
});
// The modal now waits for the backend's admit signal — simulate it by
// dispatching the same slice action the socket layer fires on
// bot:joined / agent_meetings:joined.
store.dispatch(
setBackendMeetJoined({ meetUrl: 'https://meet.google.com/abc-defg-hij' })
);
await vi.waitFor(() => {
expect(onToast).toHaveBeenCalledWith(
expect.objectContaining({ type: 'success', title: expect.stringMatching(/joining/i) })
);
});
// Modal closes on success
// Modal closes after the backend admits the bot.
await vi.waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
@@ -122,9 +128,6 @@ describe('MeetingBotsCard', () => {
fireEvent.change(screen.getByLabelText(/meeting link/i), {
target: { value: 'https://meet.google.com/abc-defg-hij' },
});
fireEvent.change(screen.getByLabelText(/your name in this meeting/i), {
target: { value: 'Alice' },
});
fireEvent.submit(screen.getByRole('dialog').querySelector('form')!);
await vi.waitFor(() => {
@@ -150,9 +153,6 @@ describe('MeetingBotsCard', () => {
fireEvent.change(screen.getByLabelText(/meeting link/i), {
target: { value: 'https://meet.google.com/x' },
});
fireEvent.change(screen.getByLabelText(/your name in this meeting/i), {
target: { value: 'Alice' },
});
fireEvent.submit(screen.getByRole('dialog').querySelector('form')!);
await vi.waitFor(() => {
@@ -163,6 +163,42 @@ describe('MeetingBotsCard', () => {
expect(screen.getByRole('alert')).toHaveTextContent('Bad URL');
});
it('keeps the modal open with the backend message when the bot is rejected', async () => {
joinMock.mockResolvedValueOnce({
meetUrl: 'https://meet.google.com/abc-defg-hij',
platform: 'gmeet',
});
const onToast = vi.fn();
const { store } = renderWithProviders(<MeetingBotsCard onToast={onToast} />);
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).toHaveBeenCalled());
// Simulate the backend rejecting the bot (paid-plan gate, capacity, etc).
store.dispatch(
setBackendMeetError({ error: 'Meeting bot is a paid-plan feature.' })
);
await vi.waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(
'Meeting bot is a paid-plan feature.'
);
});
// Modal stays open so the user is blocked rather than being dropped into
// an ActiveMeetingView that immediately collapses.
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(onToast).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
title: expect.stringMatching(/not start/i),
})
);
});
it('does not show meeting platform choices in the Google Meet CTA', () => {
renderWithProviders(<MeetingBotsCard />);
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
@@ -171,15 +207,42 @@ describe('MeetingBotsCard', () => {
expect(screen.getByRole('button', { name: /send to google meet/i })).toBeInTheDocument();
});
it('only asks for the meeting link, not old bot tuning fields', () => {
it('blocks Escape / Cancel / X / backdrop dismissals while a join is in flight', async () => {
// Make the join RPC hang so we stay in the in-flight state.
let resolveJoin: ((v: unknown) => void) | undefined;
joinMock.mockImplementationOnce(() => new Promise(r => (resolveJoin = r)));
renderWithProviders(<MeetingBotsCard />);
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 waitFor(() => expect(joinMock).toHaveBeenCalled());
// Cancel + X are visually disabled while in flight.
expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /close/i })).toBeDisabled();
// Escape, backdrop click, Cancel click — modal stays open.
fireEvent.keyDown(window, { key: 'Escape' });
fireEvent.click(screen.getByRole('dialog'));
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Release the RPC so other tests' state doesn't leak.
resolveJoin?.({ meetUrl: 'https://meet.google.com/abc-defg-hij', platform: 'gmeet' });
});
it('only asks for the meeting link in passive mode', () => {
renderWithProviders(<MeetingBotsCard />);
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
expect(screen.getByLabelText(/meeting link/i)).toBeInTheDocument();
// respondTo field is present (participant name the bot should respond to)
expect(screen.getByLabelText(/your name in this meeting/i)).toBeInTheDocument();
// Old bot-tuning fields should be absent
// PASSIVE MODE: the "Your Name in This Meeting" (respondTo) field is
// hidden because the bot no longer listens for a wake phrase or
// targets a specific speaker — it just transcribes.
expect(screen.queryByLabelText(/your name in this meeting/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/^display name$/i)).not.toBeInTheDocument();
// No standalone "Wake Phrase" label — the phrase appears only in the respondTo hint text
expect(screen.queryByText(/^wake phrase$/i)).not.toBeInTheDocument();
});
});
@@ -245,13 +308,18 @@ describe('MeetingBotsCard — ActiveMeetingView', () => {
expect(screen.getByText(/hi there/i)).toBeInTheDocument();
});
it('shows joining status text when status is joining', () => {
it('keeps the banner (not ActiveMeetingView) while status is joining', () => {
// The 'joining' status no longer flips to ActiveMeetingView — the
// modal stays open over the banner until the backend either admits
// (status → 'active') or rejects (status → 'error'). When the modal
// is closed and status is 'joining', the banner remains.
renderWithProviders(<MeetingBotsCard />, {
preloadedState: {
backendMeet: { ...activeMeetState.backendMeet, status: 'joining' as const },
},
});
expect(screen.getByText(/joining/i)).toBeInTheDocument();
expect(screen.getByTestId('meeting-bots-banner')).toBeInTheDocument();
expect(screen.queryByText(/live in meeting/i)).not.toBeInTheDocument();
});
it('shows banner (not ActiveMeetingView) when status is ended', () => {
+2
View File
@@ -127,5 +127,7 @@ export const selectBackendMeetMeetingId = (state: {
}): string | null => state.backendMeet.meetingId;
export const selectBackendMeetListenOnly = (state: { backendMeet: BackendMeetState }): boolean =>
state.backendMeet.listenOnly;
export const selectBackendMeetError = (state: { backendMeet: BackendMeetState }): string | null =>
state.backendMeet.error;
export default backendMeetSlice.reducer;
@@ -31,14 +31,15 @@ test.describe('Google Meet Connections tab', () => {
await page.getByTestId('meeting-bots-banner').click();
// The modal has a Meeting link (url) field plus the "Your Name in This
// Meeting" (respondTo) text field added in #3555. It stays Google-Meet only
// — no other platforms.
// PASSIVE MODE: the modal asks for the Meeting link only. The
// "Your Name in This Meeting" (respondTo) text field added in #3555
// is hidden because the backend bot no longer listens for a wake
// phrase. It stays Google-Meet only — no other platforms.
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(1);
await expect(dialog.locator('input[type="text"]')).toHaveCount(0);
await expect(dialog.getByText('Zoom')).toHaveCount(0);
await expect(dialog.getByText('Microsoft Teams')).toHaveCount(0);
});