diff --git a/app/src/components/meetings/HistorySection.tsx b/app/src/components/meetings/HistorySection.tsx index 385ad1533..16d7897d2 100644 --- a/app/src/components/meetings/HistorySection.tsx +++ b/app/src/components/meetings/HistorySection.tsx @@ -8,10 +8,12 @@ * asynchronous writes from the core (same pattern as old MeetingsPage). */ import debug from 'debug'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; import { listMeetCalls, type MeetCallRecord } from '../../services/meetCallService'; +import { selectBackendMeetStatus } from '../../store/backendMeetSlice'; +import { useAppSelector } from '../../store/hooks'; import HistoryDetail from './HistoryDetail'; import HistoryRail, { type CallGroup } from './HistoryRail'; import { inferPlatformFromUrl } from './meetingUtils'; @@ -70,6 +72,22 @@ export function HistorySection() { const [searchQuery, setSearchQuery] = useState(''); const [platformFilter, setPlatformFilter] = useState(''); + // Live mirror of `records` so the meeting-ended effect can snapshot the + // currently-known calls without taking `records` as a dependency (which + // would re-run the effect on every fetch). + const recordsRef = useRef(records); + useEffect(() => { + recordsRef.current = records; + }, [records]); + + // When a meeting ends we want to auto-select the just-finished call as soon + // as it lands. `pendingSelectLatestRef` arms that intent; `knownIdsAtEndRef` + // holds the call IDs that already existed when the meeting ended, so we can + // tell which row is the genuinely-new one (the retries may fire before the + // core has written it). + const pendingSelectLatestRef = useRef(false); + const knownIdsAtEndRef = useRef>(new Set()); + const fetchCalls = useCallback(async () => { log('[history] fetching calls'); try { @@ -79,6 +97,18 @@ export function HistorySection() { // doesn't flicker between error and loading on retry. setError(null); setRecords(rows); + // If a meeting just ended, select the newly-finished call once it shows + // up at the top (rows are newest-first). Guard on the pre-end snapshot so + // an early retry that hasn't picked up the new record yet doesn't steal + // selection, and so we don't clobber a later manual pick once it's done. + if (pendingSelectLatestRef.current && rows.length > 0) { + const newest = rows[0]; + if (!knownIdsAtEndRef.current.has(newest.request_id)) { + log('[history] auto-selecting newly-finished call %s', newest.request_id); + setSelectedCallId(newest.request_id); + pendingSelectLatestRef.current = false; + } + } } catch (err) { const message = err instanceof Error ? err.message : 'Failed to load calls.'; log('[history] fetch error', err); @@ -90,10 +120,13 @@ export function HistorySection() { } }, []); - useEffect(() => { - // Wrap the initial call in setTimeout so the rule's transitive analysis - // does not flag setState calls (which are all async-after-await in fetchCalls) - // as synchronous within the effect body. + // Fetch immediately, then re-fetch after 1.2s and 3s. The core writes the + // call record a few ms after the transcript arrives, so the delayed retries + // catch the just-written row. Returns a cleanup that cancels pending timers. + // Wrapping the initial call in setTimeout also keeps the rule's transitive + // analysis from flagging fetchCalls' (async-after-await) setState calls as + // synchronous within an effect body. + const fetchCallsWithRetries = useCallback(() => { const id = setTimeout(() => void fetchCalls(), 0); const retries = [1200, 3000].map(delay => setTimeout(() => void fetchCalls(), delay)); return () => { @@ -102,6 +135,33 @@ export function HistorySection() { }; }, [fetchCalls]); + useEffect(() => fetchCallsWithRetries(), [fetchCallsWithRetries]); + + // Auto-refresh when a meeting ends. The history list is rendered alongside + // the live meeting, so when the backend-meet status transitions to 'ended' + // (bot left / meeting finished) we re-run the delayed-retry fetch to surface + // the just-finished call without needing a tab switch or app reload (#4341). + const meetStatus = useAppSelector(selectBackendMeetStatus); + const prevMeetStatusRef = useRef(meetStatus); + useEffect(() => { + const prev = prevMeetStatusRef.current; + prevMeetStatusRef.current = meetStatus; + if (meetStatus === 'ended' && prev !== 'ended') { + log('[history] meeting ended → refreshing recent calls'); + // Only arm auto-select when history is already loaded. With a known + // baseline we can snapshot the existing call IDs and tell which row is + // the just-finished one. If history hasn't loaded yet (recordsRef null), + // there is no explicit user selection to preserve, so the default + // auto-snap to the newest row already lands on the new call — arming from + // an empty snapshot would instead misclassify an old row as new (#4341). + if (recordsRef.current !== null) { + knownIdsAtEndRef.current = new Set(recordsRef.current.map(r => r.request_id)); + pendingSelectLatestRef.current = true; + } + return fetchCallsWithRetries(); + } + }, [meetStatus, fetchCallsWithRetries]); + // Apply search + platform filter const filteredRecords = useMemo(() => { if (!records) return []; @@ -162,6 +222,9 @@ export function HistorySection() { function handleSelect(id: string) { log('[history] selected call', id); + // An explicit pick wins over a pending end-of-meeting auto-select so a + // delayed retry doesn't yank the user off the call they just opened. + pendingSelectLatestRef.current = false; setSelectedCallId(id); } diff --git a/app/src/components/meetings/__tests__/HistorySection.test.tsx b/app/src/components/meetings/__tests__/HistorySection.test.tsx index f0d46a692..93ed3eb69 100644 --- a/app/src/components/meetings/__tests__/HistorySection.test.tsx +++ b/app/src/components/meetings/__tests__/HistorySection.test.tsx @@ -1,7 +1,8 @@ -import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { MeetCallDetail, MeetCallRecord } from '../../../services/meetCallService'; +import { setBackendMeetJoined, setBackendMeetLeft } from '../../../store/backendMeetSlice'; import { renderWithProviders } from '../../../test/test-utils'; import HistorySection from '../HistorySection'; @@ -173,4 +174,101 @@ describe('HistorySection', () => { ).toBeInTheDocument(); }); }); + + // ── Auto-refresh when a meeting ends (#4341) ────────────────────────────── + + // These two use fake timers so the mount fetch's 1.2s/3s retry batch is fully + // drained before the mock is changed — otherwise a leftover mount retry could + // satisfy the assertion (or load the new call early) without the status + // transition driving it. + it('re-fetches recent calls when the meeting status transitions to ended', async () => { + vi.useFakeTimers(); + try { + listMeetCallsMock.mockResolvedValue([todayCall]); + const { store } = renderWithProviders(); + + // Drain the mount fetch + its delayed retries so none linger. + await act(async () => { + await vi.runAllTimersAsync(); + }); + expect(screen.getAllByText('abc-def-ghi').length).toBeGreaterThan(0); + + listMeetCallsMock.mockClear(); + + // Going active must NOT trigger a refetch — only the end does. + act(() => { + store.dispatch(setBackendMeetJoined({ meetUrl: 'https://meet.google.com/abc-def-ghi' })); + }); + expect(listMeetCallsMock).not.toHaveBeenCalled(); + + act(() => { + store.dispatch(setBackendMeetLeft({ reason: 'left' })); + }); + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(listMeetCallsMock).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('auto-selects the just-finished call after a meeting ends', async () => { + vi.useFakeTimers(); + try { + const newCall: MeetCallRecord = { + request_id: 'req-new', + meet_url: 'https://meet.google.com/new-call-xyz', + bot_display_name: 'OpenHuman', + owner_display_name: 'Alice', + started_at_ms: NOW - 1000, + ended_at_ms: NOW, + listened_seconds: 5, + spoken_seconds: 0, + turn_count: 2, + participants: ['Alice'], + }; + + // Initially two calls; the user manually selects the older (yesterday) one. + listMeetCallsMock.mockResolvedValue([todayCall, yesterdayCall]); + const { store } = renderWithProviders(); + await act(async () => { + await vi.runAllTimersAsync(); + }); + + fireEvent.click(screen.getByText('j/999888')); + await act(async () => { + await vi.runAllTimersAsync(); + }); + expect(getMeetCallDetailMock).toHaveBeenCalledWith('req-yesterday'); + + getMeetCallDetailMock.mockClear(); + // After the meeting ends the list gains a brand-new call at the top. + listMeetCallsMock.mockResolvedValue([newCall, todayCall, yesterdayCall]); + + act(() => { + store.dispatch(setBackendMeetJoined({ meetUrl: newCall.meet_url })); + }); + act(() => { + store.dispatch(setBackendMeetLeft({ reason: 'left' })); + }); + // First drain runs the end-of-meeting refetch, which moves the selection + // onto the new call. The second drain fires HistoryDetail's selection + // effect (a setTimeout(0) scheduled at the act boundary) that loads the + // newly-selected call's detail. + await act(async () => { + await vi.runAllTimersAsync(); + }); + await act(async () => { + await vi.runAllTimersAsync(); + }); + + // Selection should jump from the manually-picked older call to the + // newly-finished one, so its detail is fetched. + expect(getMeetCallDetailMock).toHaveBeenCalledWith('req-new'); + } finally { + vi.useRealTimers(); + } + }); });