From 3136ef54830a8bf9e22e1b87848857885c77a770 Mon Sep 17 00:00:00 2001 From: Hemanth Dhanasekaran <63975676+hemanth1999k@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:01:55 -0500 Subject: [PATCH] feat(meet): pre-fill owner display name from Persona settings (refs #2945) (#3034) Co-authored-by: Steven Enamakel --- app/src/components/skills/MeetingBotsCard.tsx | 19 +++- .../skills/__tests__/MeetingBotsCard.test.tsx | 91 ++++++++++++++----- 2 files changed, 85 insertions(+), 25 deletions(-) diff --git a/app/src/components/skills/MeetingBotsCard.tsx b/app/src/components/skills/MeetingBotsCard.tsx index 6b94488b2..94f379128 100644 --- a/app/src/components/skills/MeetingBotsCard.tsx +++ b/app/src/components/skills/MeetingBotsCard.tsx @@ -16,6 +16,8 @@ import { type MascotMeetPlatform, type MeetCallRecord, } from '../../services/meetCallService'; +import { useAppSelector } from '../../store/hooks'; +import { selectPersonaDisplayName } from '../../store/personaSlice'; type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: string }; @@ -131,7 +133,17 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) { // remote participant from issuing tool calls in the owner's // name. Empty fails closed; the submit handler will surface an // explicit error before opening the CEF window. - const [ownerDisplayName, setOwnerDisplayName] = useState(''); + // + // Effective value = Persona display name (Settings → Persona) until + // the user types into the field — the "name prompt" UX complaint in + // #2945, so repeat callers don't retype the same value every meeting. + // Once the user edits the field, the dirty flag latches and the + // input becomes fully controlled — Persona changes no longer + // overwrite their input, and clearing the field stays empty. + const personaDisplayName = useAppSelector(selectPersonaDisplayName); + const [ownerDisplayNameDraft, setOwnerDisplayNameDraft] = useState(''); + const [isOwnerNameEdited, setIsOwnerNameEdited] = useState(false); + const ownerDisplayName = isOwnerNameEdited ? ownerDisplayNameDraft : personaDisplayName; const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); // Recent-calls history loaded from core when the modal opens. @@ -305,7 +317,10 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) { setOwnerDisplayName(e.target.value)} + onChange={e => { + setOwnerDisplayNameDraft(e.target.value); + setIsOwnerNameEdited(true); + }} maxLength={64} placeholder="As shown in Google Meet (e.g. Nikhil Bajaj)" disabled={isComingSoon || submitting} diff --git a/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx b/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx index 6ca1ae47d..31290597d 100644 --- a/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx +++ b/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx @@ -1,7 +1,8 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +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 { renderWithProviders } from '../../../test/test-utils'; import MeetingBotsCard, { MeetingBotsModal } from '../MeetingBotsCard'; const joinMock = vi.fn(); @@ -32,26 +33,26 @@ describe('MeetingBotsCard', () => { afterEach(() => cleanup()); it('renders the banner and hides the modal by default', () => { - render(); + renderWithProviders(); expect(screen.getByTestId('meeting-bots-banner')).toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('opens the modal when the banner is clicked', () => { - render(); + renderWithProviders(); fireEvent.click(screen.getByTestId('meeting-bots-banner')); expect(screen.getByRole('dialog')).toBeInTheDocument(); }); it('closes the modal on Cancel', () => { - render(); + renderWithProviders(); fireEvent.click(screen.getByTestId('meeting-bots-banner')); fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('closes the modal on Escape', () => { - render(); + renderWithProviders(); fireEvent.click(screen.getByTestId('meeting-bots-banner')); fireEvent.keyDown(window, { key: 'Escape' }); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); @@ -60,7 +61,7 @@ describe('MeetingBotsCard', () => { it('submits to joinMeetCall and fires a success toast', async () => { joinMock.mockResolvedValueOnce({ requestId: 'req-1' }); const onToast = vi.fn(); - render(); + renderWithProviders(); fireEvent.click(screen.getByTestId('meeting-bots-banner')); fireEvent.change(screen.getByLabelText(/meeting link/i), { @@ -105,7 +106,7 @@ describe('MeetingBotsCard', () => { it('surfaces a join error inline + as an error toast', async () => { joinMock.mockRejectedValueOnce(new Error('Bad URL')); const onToast = vi.fn(); - render(); + renderWithProviders(); fireEvent.click(screen.getByTestId('meeting-bots-banner')); fireEvent.change(screen.getByLabelText(/meeting link/i), { @@ -125,13 +126,57 @@ describe('MeetingBotsCard', () => { }); it('disables the submit when the active platform is coming-soon', () => { - render(); + renderWithProviders(); fireEvent.click(screen.getByTestId('meeting-bots-banner')); // Pick Zoom (coming soon) fireEvent.click(screen.getByRole('button', { name: /Zoom/ })); const submit = screen.getByRole('button', { name: /coming soon/i }); expect(submit).toBeDisabled(); }); + + // Issue #2945: users were being asked to retype "your name in the call" + // every meeting. When the Persona display name (Settings → Persona) is + // set, the owner-name field pre-fills from it so the user can submit + // without retyping. + it('pre-fills the owner display name from the Persona slice', () => { + const { store } = renderWithProviders(, { + preloadedState: { persona: { displayName: 'Hemanth', description: '' } }, + }); + fireEvent.click(screen.getByTestId('meeting-bots-banner')); + const ownerInput = screen.getByLabelText(/your name in the call/i) as HTMLInputElement; + expect(ownerInput.value).toBe('Hemanth'); + // Sanity: the slice is wired so the assertion above isn't a no-op. + expect(store.getState().persona.displayName).toBe('Hemanth'); + }); + + it('leaves the owner display name empty when no Persona name is set', () => { + // Default preloadedState — persona slice initial state is + // { displayName: '', description: '' } (see personaSlice.ts). + renderWithProviders(); + fireEvent.click(screen.getByTestId('meeting-bots-banner')); + const ownerInput = screen.getByLabelText(/your name in the call/i) as HTMLInputElement; + expect(ownerInput.value).toBe(''); + }); + + // Dirty-flag contract: once the user has typed into the field, a + // subsequent Persona update from the slice must NOT overwrite their + // input. The pre-edit case is covered by the "pre-fills" test above. + it('does not overwrite user-typed owner name when Persona slice updates later', () => { + const { store } = renderWithProviders(, { + preloadedState: { persona: { displayName: 'Hemanth', description: '' } }, + }); + fireEvent.click(screen.getByTestId('meeting-bots-banner')); + const ownerInput = screen.getByLabelText(/your name in the call/i) as HTMLInputElement; + // Sanity: pre-fill landed. + expect(ownerInput.value).toBe('Hemanth'); + // User types over it. + fireEvent.change(ownerInput, { target: { value: 'Alice' } }); + expect(ownerInput.value).toBe('Alice'); + // Persona name changes underneath (e.g. user edits Settings in another window). + store.dispatch({ type: 'persona/setPersonaDisplayName', payload: 'Nova' }); + // User's typed value wins — does NOT flip to 'Nova'. + expect(ownerInput.value).toBe('Alice'); + }); }); // ── RecentCallsSection / RecentCallRow tests ────────────────────────────────── @@ -160,7 +205,7 @@ describe('MeetingBotsModal — recent calls section', () => { // Never resolves during this test — simulates a slow fetch. listMock.mockReturnValue(new Promise(() => {})); - render( {}} />); + renderWithProviders( {}} />); expect(screen.getByText(/loading…/i)).toBeInTheDocument(); }); @@ -168,7 +213,7 @@ describe('MeetingBotsModal — recent calls section', () => { it('shows an empty-state message when listMeetCalls returns an empty array', async () => { listMock.mockResolvedValueOnce([]); - render( {}} />); + renderWithProviders( {}} />); await waitFor(() => { expect(screen.getByText(/no previous calls yet/i)).toBeInTheDocument(); @@ -182,7 +227,7 @@ describe('MeetingBotsModal — recent calls section', () => { ]; listMock.mockResolvedValueOnce(records); - render( {}} />); + renderWithProviders( {}} />); await waitFor(() => { expect(screen.getByText('aaa-bbbb-ccc')).toBeInTheDocument(); @@ -196,7 +241,7 @@ describe('MeetingBotsModal — recent calls section', () => { it('shows the count badge when there is at least one record', async () => { listMock.mockResolvedValueOnce([makeCallRecord()]); - render( {}} />); + renderWithProviders( {}} />); await waitFor(() => { // The "(1)" count badge next to the "Recent calls" heading. @@ -207,7 +252,7 @@ describe('MeetingBotsModal — recent calls section', () => { it('shows an error hint and an empty list when listMeetCalls rejects', async () => { listMock.mockRejectedValueOnce(new Error('Network timeout')); - render( {}} />); + renderWithProviders( {}} />); await waitFor(() => { expect(screen.getByText(/network timeout/i)).toBeInTheDocument(); @@ -221,7 +266,7 @@ describe('MeetingBotsModal — recent calls section', () => { makeCallRecord({ meet_url: 'https://meet.google.com/xyz-1234-abc' }), ]); - render( {}} />); + renderWithProviders( {}} />); await waitFor(() => { expect(screen.getByText('xyz-1234-abc')).toBeInTheDocument(); @@ -235,7 +280,7 @@ describe('MeetingBotsModal — recent calls section', () => { makeCallRecord({ spoken_seconds: 40, listened_seconds: 20 }), ]); - render( {}} />); + renderWithProviders( {}} />); await waitFor(() => { expect(screen.getByText(/60s on call/i)).toBeInTheDocument(); @@ -248,7 +293,7 @@ describe('MeetingBotsModal — recent calls section', () => { makeCallRecord({ started_at_ms: Date.now() - 5 * 60 * 1000 }), ]); - render( {}} />); + renderWithProviders( {}} />); await waitFor(() => { expect(screen.getByText(/\dm ago/)).toBeInTheDocument(); @@ -258,7 +303,7 @@ describe('MeetingBotsModal — recent calls section', () => { it('shows "—" for a zero started_at_ms timestamp', async () => { listMock.mockResolvedValueOnce([makeCallRecord({ started_at_ms: 0 })]); - render( {}} />); + renderWithProviders( {}} />); await waitFor(() => { expect(screen.getByText('—')).toBeInTheDocument(); @@ -270,7 +315,7 @@ describe('MeetingBotsModal — recent calls section', () => { it('shows singular "turn" (not "turns") when turn_count is 1', async () => { listMock.mockResolvedValueOnce([makeCallRecord({ turn_count: 1 })]); - render( {}} />); + renderWithProviders( {}} />); await waitFor(() => { expect(screen.getByText(/1 turn$/)).toBeInTheDocument(); @@ -281,7 +326,7 @@ describe('MeetingBotsModal — recent calls section', () => { it('falls back to the raw URL when it cannot be parsed', async () => { listMock.mockResolvedValueOnce([makeCallRecord({ meet_url: 'not-a-valid-url' })]); - render( {}} />); + renderWithProviders( {}} />); await waitFor(() => { expect(screen.getByText('not-a-valid-url')).toBeInTheDocument(); @@ -293,7 +338,7 @@ describe('MeetingBotsModal — recent calls section', () => { makeCallRecord({ started_at_ms: Date.now() - 3 * 60 * 60 * 1000 }), ]); - render( {}} />); + renderWithProviders( {}} />); await waitFor(() => { expect(screen.getByText(/3h ago/)).toBeInTheDocument(); @@ -305,7 +350,7 @@ describe('MeetingBotsModal — recent calls section', () => { makeCallRecord({ started_at_ms: Date.now() - 25 * 60 * 60 * 1000 }), ]); - render( {}} />); + renderWithProviders( {}} />); await waitFor(() => { expect(screen.getByText('yesterday')).toBeInTheDocument(); @@ -317,7 +362,7 @@ describe('MeetingBotsModal — recent calls section', () => { makeCallRecord({ started_at_ms: Date.now() - 3 * 24 * 60 * 60 * 1000 }), ]); - render( {}} />); + renderWithProviders( {}} />); await waitFor(() => { expect(screen.getByText(/3d ago/)).toBeInTheDocument(); @@ -329,7 +374,7 @@ describe('MeetingBotsModal — recent calls section', () => { makeCallRecord({ started_at_ms: Date.now() - 10 * 24 * 60 * 60 * 1000 }), ]); - render( {}} />); + renderWithProviders( {}} />); await waitFor(() => { // toLocaleDateString returns "Month Day" — just check it's not a relative label.