mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
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:
co-authored by
M3gA-Mind
M3gA-Mind
parent
5319479182
commit
5dfdd7ee2e
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user