fix(meetings): auto-refresh Recent Calls when a meeting ends (#4347)

Co-authored-by: M3gA-Mind <megamind@mahadao.com>
Co-authored-by: M3gA-Mind <elvin@mahadao.com>
This commit is contained in:
Cyrus Gray
2026-06-30 22:09:41 +05:30
committed by GitHub
co-authored by M3gA-Mind M3gA-Mind
parent 5319479182
commit 5dfdd7ee2e
2 changed files with 167 additions and 6 deletions
+68 -5
View File
@@ -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<MeetCallRecord[] | null>(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<Set<string>>(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);
}
@@ -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(<HistorySection />);
// 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(<HistorySection />);
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();
}
});
});