diff --git a/app/src/components/skills/RecentCallsSection.test.tsx b/app/src/components/skills/RecentCallsSection.test.tsx new file mode 100644 index 000000000..c2b013a20 --- /dev/null +++ b/app/src/components/skills/RecentCallsSection.test.tsx @@ -0,0 +1,169 @@ +/** + * Tests for the recent-calls panel's expandable rows. + * + * Confirms a row lazily fetches its transcript + summary on first expand, + * renders both, and degrades gracefully when the core has no detail or the + * fetch fails (with a working retry). + */ +import { configureStore } from '@reduxjs/toolkit'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Provider } from 'react-redux'; + +import { I18nProvider } from '../../lib/i18n/I18nContext'; +import type { MeetCallDetail, MeetCallRecord } from '../../services/meetCallService'; +import localeReducer from '../../store/localeSlice'; +import { RecentCallsSection } from './RecentCallsSection'; + +const getMeetCallDetail = vi.fn<(requestId: string) => Promise>(); + +vi.mock('../../services/meetCallService', () => ({ + getMeetCallDetail: (requestId: string) => getMeetCallDetail(requestId), +})); + +function call(overrides: Partial = {}): MeetCallRecord { + return { + request_id: 'corr-1', + meet_url: 'https://meet.google.com/yfj-hcek-zyv', + bot_display_name: 'Tiny', + owner_display_name: 'Shanu', + started_at_ms: Date.now() - 60_000, + ended_at_ms: Date.now(), + listened_seconds: 100, + spoken_seconds: 20, + turn_count: 3, + participants: ['Shanu', 'Alan'], + ...overrides, + }; +} + +function renderSection(rows: MeetCallRecord[]) { + const store = configureStore({ + reducer: { locale: localeReducer }, + preloadedState: { locale: { current: 'en' as const } }, + }); + return render( + + + + + + ); +} + +describe('RecentCallsSection', () => { + beforeEach(() => { + getMeetCallDetail.mockReset(); + }); + afterEach(() => { + vi.clearAllMocks(); + }); + + it('lazily loads and renders summary + transcript on expand', async () => { + getMeetCallDetail.mockResolvedValue({ + request_id: 'corr-1', + summary: { + headline: 'Agreed to ship Friday.', + key_points: ['Ship Friday', 'QA owns sign-off'], + action_items: [ + { description: 'Send release notes', kind: 'executable', tool_name: 'gmail', assignee: 'Sam' }, + ], + }, + transcript: [ + { role: 'participant', content: '[00:51] [Shanu] your time' }, + { role: 'assistant', content: '[00:55] [Tiny] On it.' }, + ], + }); + + renderSection([call()]); + // Not fetched until the row is expanded. + expect(getMeetCallDetail).not.toHaveBeenCalled(); + + await userEvent.click(screen.getByRole('button')); + + expect(getMeetCallDetail).toHaveBeenCalledExactlyOnceWith('corr-1'); + await waitFor(() => expect(screen.getByText('Agreed to ship Friday.')).toBeInTheDocument()); + expect(screen.getByText('Summary')).toBeInTheDocument(); + expect(screen.getByText('Ship Friday')).toBeInTheDocument(); + expect(screen.getByText('Transcript')).toBeInTheDocument(); + expect(screen.getByText('[00:55] [Tiny] On it.')).toBeInTheDocument(); + // Action item description + assignee/tool meta. + expect(screen.getByText('Send release notes')).toBeInTheDocument(); + }); + + it('shows an empty state when the call has no recorded detail', async () => { + getMeetCallDetail.mockResolvedValue(null); + renderSection([call()]); + + await userEvent.click(screen.getByRole('button')); + + await waitFor(() => + expect( + screen.getByText('No transcript or summary was captured for this call.') + ).toBeInTheDocument() + ); + }); + + it('surfaces an error with a working retry', async () => { + getMeetCallDetail.mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce({ + request_id: 'corr-1', + summary: null, + transcript: [{ role: 'participant', content: 'recovered line' }], + }); + + renderSection([call()]); + await userEvent.click(screen.getByRole('button')); + + const retry = await screen.findByRole('button', { name: 'Retry' }); + await userEvent.click(retry); + + await waitFor(() => expect(screen.getByText('recovered line')).toBeInTheDocument()); + expect(getMeetCallDetail).toHaveBeenCalledTimes(2); + }); + + it('does not refetch when collapsing and re-expanding a fully-loaded call', async () => { + getMeetCallDetail.mockResolvedValue({ + request_id: 'corr-1', + summary: { headline: 'All set.', key_points: [], action_items: [] }, + transcript: [{ role: 'participant', content: 'cached line' }], + }); + + renderSection([call()]); + const toggle = screen.getByRole('button'); + await userEvent.click(toggle); // expand → fetch + await waitFor(() => expect(screen.getByText('cached line')).toBeInTheDocument()); + await userEvent.click(toggle); // collapse + await userEvent.click(toggle); // re-expand → reuse cache (summary already present) + + expect(getMeetCallDetail).toHaveBeenCalledTimes(1); + }); + + it('refetches on re-expand to pick up a summary generated after call-end', async () => { + // First load: transcript persisted, summary still generating (null). + getMeetCallDetail + .mockResolvedValueOnce({ + request_id: 'corr-1', + summary: null, + transcript: [{ role: 'participant', content: 'early line' }], + }) + // Second load: summary has since landed. + .mockResolvedValueOnce({ + request_id: 'corr-1', + summary: { headline: 'Summary arrived.', key_points: [], action_items: [] }, + transcript: [{ role: 'participant', content: 'early line' }], + }); + + renderSection([call()]); + const toggle = screen.getByRole('button'); + await userEvent.click(toggle); // expand → first fetch (no summary yet) + await waitFor(() => expect(screen.getByText('early line')).toBeInTheDocument()); + expect(screen.queryByText('Summary arrived.')).not.toBeInTheDocument(); + + await userEvent.click(toggle); // collapse + await userEvent.click(toggle); // re-expand → refetch (summary was missing) + + await waitFor(() => expect(screen.getByText('Summary arrived.')).toBeInTheDocument()); + expect(getMeetCallDetail).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/src/components/skills/RecentCallsSection.tsx b/app/src/components/skills/RecentCallsSection.tsx index 22e85bffe..b04e30932 100644 --- a/app/src/components/skills/RecentCallsSection.tsx +++ b/app/src/components/skills/RecentCallsSection.tsx @@ -1,11 +1,24 @@ +import { useCallback, useState } from 'react'; + import { useT } from '../../lib/i18n/I18nContext'; -import { type MeetCallRecord } from '../../services/meetCallService'; +import { + getMeetCallDetail, + type MeetCallDetail, + type MeetCallRecord, + type MeetCallSummary, + type MeetCallTranscriptLine, +} from '../../services/meetCallService'; /** * Recent-calls history shown under the meeting-bot join form. Renders the * loading / empty / populated states and one row per completed call (meeting * code, relative time, turn count, duration, owner, and participants). * + * Each row is expandable: on first expand it lazily fetches the call's + * transcript + summary via `meet_agent_get_call_detail` so the list payload + * stays lean. Older calls recorded before the feature have no detail and show + * a "nothing captured" state. + * * Extracted from `MeetingBotsCard` to keep that component within the repo's * ~500-line file-size guideline. */ @@ -43,7 +56,7 @@ export function RecentCallsSection({ {t('skills.meetingBots.recentCallsEmpty')}

) : ( -