From eaad68ecaa2ea5e8cbae8567a9fcf0c6d7dd2e27 Mon Sep 17 00:00:00 2001
From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com>
Date: Wed, 17 Jun 2026 13:31:30 +0530
Subject: [PATCH] fix(meetings): populate Recent calls for backend meet flow
(owner, duration, participants) (#3710)
---
app/src/components/skills/MeetingBotsCard.tsx | 106 +-----
.../components/skills/RecentCallsSection.tsx | 118 ++++++
.../skills/__tests__/MeetingBotsCard.test.tsx | 26 ++
app/src/lib/i18n/ar.ts | 2 +
app/src/lib/i18n/bn.ts | 2 +
app/src/lib/i18n/de.ts | 2 +
app/src/lib/i18n/en.ts | 2 +
app/src/lib/i18n/es.ts | 2 +
app/src/lib/i18n/fr.ts | 2 +
app/src/lib/i18n/hi.ts | 2 +
app/src/lib/i18n/id.ts | 2 +
app/src/lib/i18n/it.ts | 2 +
app/src/lib/i18n/ko.ts | 2 +
app/src/lib/i18n/pl.ts | 2 +
app/src/lib/i18n/pt.ts | 2 +
app/src/lib/i18n/ru.ts | 2 +
app/src/lib/i18n/zh-CN.ts | 2 +
app/src/services/meetCallService.ts | 6 +
src/openhuman/agent_meetings/bus.rs | 12 +
src/openhuman/agent_meetings/mod.rs | 1 +
src/openhuman/agent_meetings/ops.rs | 20 +
src/openhuman/agent_meetings/recent_calls.rs | 358 ++++++++++++++++++
src/openhuman/meet_agent/rpc.rs | 4 +
src/openhuman/meet_agent/store.rs | 8 +
24 files changed, 591 insertions(+), 96 deletions(-)
create mode 100644 app/src/components/skills/RecentCallsSection.tsx
create mode 100644 src/openhuman/agent_meetings/recent_calls.rs
diff --git a/app/src/components/skills/MeetingBotsCard.tsx b/app/src/components/skills/MeetingBotsCard.tsx
index 6d0d3fa47..ca579baaf 100644
--- a/app/src/components/skills/MeetingBotsCard.tsx
+++ b/app/src/components/skills/MeetingBotsCard.tsx
@@ -29,6 +29,7 @@ import {
selectSelectedMascotId,
} from '../../store/mascotSlice';
import { selectPersonaDescription, selectPersonaDisplayName } from '../../store/personaSlice';
+import { RecentCallsSection } from './RecentCallsSection';
type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: string };
@@ -239,6 +240,15 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps)
useEffect(() => {
void refreshRecentCalls();
+ // This inline form remounts the instant a call ends, but the core writes
+ // the call record asynchronously a few ms after the transcript arrives —
+ // so the mount-time fetch can race ahead of that write and miss the just-
+ // ended call. A couple of short delayed re-fetches reliably reflect it
+ // without the user having to reopen the tab. Cheap (a ~2ms RPC each).
+ const retries = [1200, 3000].map(delay =>
+ setTimeout(() => void refreshRecentCalls(), delay)
+ );
+ return () => retries.forEach(clearTimeout);
}, [refreshRecentCalls]);
const selectedLabel = t('skills.meetingBots.platforms.gmeet');
@@ -387,99 +397,3 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps)
);
}
-function RecentCallsSection({
- rows,
- error,
-}: {
- rows: MeetCallRecord[] | null;
- error: string | null;
-}) {
- const { t } = useT();
- return (
-
-
-
- {t('skills.meetingBots.recentCallsHeading')}
- {rows && rows.length > 0 && (
-
- ({rows.length})
-
- )}
-
-
-
- {error && (
- {error}
- )}
-
- {rows === null ? (
-
- {t('skills.meetingBots.recentCallsLoading')}
-
- ) : rows.length === 0 ? (
-
- {t('skills.meetingBots.recentCallsEmpty')}
-
- ) : (
-
- {rows.map(call => (
-
- ))}
-
- )}
-
- );
-}
-
-function RecentCallRow({ call }: { call: MeetCallRecord }) {
- const meetingCode = (() => {
- try {
- const parsed = new URL(call.meet_url);
- const tail = parsed.pathname.replace(/^\/+/, '');
- return tail || call.meet_url;
- } catch {
- return call.meet_url || '(unknown URL)';
- }
- })();
- const duration = Math.max(0, Math.round(call.spoken_seconds + call.listened_seconds));
- return (
-
-
-
- {meetingCode}
-
-
- {formatRelativeTime(call.started_at_ms)}
-
-
-
-
- {call.turn_count} turn{call.turn_count === 1 ? '' : 's'}
-
- {duration}s on call
-
-
- );
-}
-
-function formatRelativeTime(ms: number): string {
- if (!ms) return '—';
- const diff = Date.now() - ms;
- if (diff < 0) return 'just now';
- const seconds = Math.floor(diff / 1000);
- if (seconds < 60) return 'just now';
- const minutes = Math.floor(seconds / 60);
- if (minutes < 60) return `${minutes}m ago`;
- const hours = Math.floor(minutes / 60);
- if (hours < 24) return `${hours}h ago`;
- const days = Math.floor(hours / 24);
- if (days === 1) return 'yesterday';
- if (days < 7) return `${days}d ago`;
- try {
- return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
- } catch {
- return '—';
- }
-}
diff --git a/app/src/components/skills/RecentCallsSection.tsx b/app/src/components/skills/RecentCallsSection.tsx
new file mode 100644
index 000000000..19a3915c0
--- /dev/null
+++ b/app/src/components/skills/RecentCallsSection.tsx
@@ -0,0 +1,118 @@
+import { useT } from '../../lib/i18n/I18nContext';
+import { type MeetCallRecord } 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).
+ *
+ * Extracted from `MeetingBotsCard` to keep that component within the repo's
+ * ~500-line file-size guideline.
+ */
+export function RecentCallsSection({
+ rows,
+ error,
+}: {
+ rows: MeetCallRecord[] | null;
+ error: string | null;
+}) {
+ const { t } = useT();
+ return (
+
+
+
+ {t('skills.meetingBots.recentCallsHeading')}
+ {rows && rows.length > 0 && (
+
+ ({rows.length})
+
+ )}
+
+
+
+ {error && {error}
}
+
+ {rows === null ? (
+
+ {t('skills.meetingBots.recentCallsLoading')}
+
+ ) : rows.length === 0 ? (
+
+ {t('skills.meetingBots.recentCallsEmpty')}
+
+ ) : (
+
+ {rows.map(call => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function RecentCallRow({ call }: { call: MeetCallRecord }) {
+ const { t } = useT();
+ const meetingCode = (() => {
+ try {
+ const parsed = new URL(call.meet_url);
+ const tail = parsed.pathname.replace(/^\/+/, '');
+ return tail || call.meet_url;
+ } catch {
+ return call.meet_url || '(unknown URL)';
+ }
+ })();
+ const duration = Math.max(0, Math.round(call.spoken_seconds + call.listened_seconds));
+ const owner = call.owner_display_name?.trim();
+ const participants = (call.participants ?? []).map(p => p.trim()).filter(Boolean);
+ return (
+
+
+
+ {meetingCode}
+
+
+ {formatRelativeTime(call.started_at_ms)}
+
+
+
+
+ {call.turn_count} turn{call.turn_count === 1 ? '' : 's'}
+
+ {duration}s on call
+ {owner && (
+
+ {t('skills.meetingBots.recentCallAddedBy').replace('{name}', owner)}
+
+ )}
+
+ {participants.length > 0 && (
+
+ {t('skills.meetingBots.recentCallParticipants').replace('{names}', participants.join(', '))}
+
+ )}
+
+ );
+}
+
+function formatRelativeTime(ms: number): string {
+ if (!ms) return '—';
+ const diff = Date.now() - ms;
+ if (diff < 0) return 'just now';
+ const seconds = Math.floor(diff / 1000);
+ if (seconds < 60) return 'just now';
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ if (days === 1) return 'yesterday';
+ if (days < 7) return `${days}d ago`;
+ try {
+ return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+ } catch {
+ return '—';
+ }
+}
diff --git a/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx b/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx
index 9bc663e0a..cf9a1dde8 100644
--- a/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx
+++ b/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx
@@ -348,6 +348,32 @@ describe('MeetingBotsCard — recent calls section', () => {
expect(screen.getByText(/5 turns/i)).toBeInTheDocument();
});
+ it('renders the owner and participant names on a call row', async () => {
+ listMock.mockResolvedValueOnce([
+ makeCallRecord({
+ owner_display_name: 'Shanu Goyanka',
+ participants: ['Shanu Goyanka', 'Alex Rivera'],
+ }),
+ ]);
+ renderWithProviders();
+ await waitFor(() => {
+ expect(screen.getByText(/added by shanu goyanka/i)).toBeInTheDocument();
+ });
+ expect(screen.getByText(/with shanu goyanka, alex rivera/i)).toBeInTheDocument();
+ });
+
+ it('omits the participants line when the record has none', async () => {
+ listMock.mockResolvedValueOnce([
+ makeCallRecord({ owner_display_name: '', participants: [] }),
+ ]);
+ renderWithProviders();
+ await waitFor(() => {
+ expect(screen.getByText(/3 turns/i)).toBeInTheDocument();
+ });
+ expect(screen.queryByText(/^with /i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/added by/i)).not.toBeInTheDocument();
+ });
+
it('shows the count badge when there is at least one record', async () => {
listMock.mockResolvedValueOnce([makeCallRecord()]);
renderWithProviders();
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts
index c7a14b316..2c14a4632 100644
--- a/app/src/lib/i18n/ar.ts
+++ b/app/src/lib/i18n/ar.ts
@@ -4542,6 +4542,8 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsHeading': 'المكالمات الأخيرة',
'skills.meetingBots.recentCallsEmpty': 'لا توجد مكالمات سابقة بعد — سيظهر سجل اجتماعاتك هنا.',
'skills.meetingBots.recentCallsLoading': 'جارٍ التحميل\u2026',
+ 'skills.meetingBots.recentCallAddedBy': 'أضافه {name}',
+ 'skills.meetingBots.recentCallParticipants': 'مع {names}',
'skills.meetingBots.liveBadge': 'مباشر',
'skills.meetingBots.liveTitle': 'في اجتماع',
'skills.meetingBots.liveStatusJoining': 'جارٍ الانضمام\u2026',
diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts
index 6c4b02b75..f8746fd2e 100644
--- a/app/src/lib/i18n/bn.ts
+++ b/app/src/lib/i18n/bn.ts
@@ -4634,6 +4634,8 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsHeading': 'সাম্প্রতিক কল',
'skills.meetingBots.recentCallsEmpty': 'এখনও কোনো আগের কল নেই — আপনার মিটিং ইতিহাস এখানে দেখাবে।',
'skills.meetingBots.recentCallsLoading': 'লোড হচ্ছে\u2026',
+ 'skills.meetingBots.recentCallAddedBy': '{name} যোগ করেছেন',
+ 'skills.meetingBots.recentCallParticipants': '{names} এর সাথে',
'skills.meetingBots.liveBadge': 'লাইভ',
'skills.meetingBots.liveTitle': 'মিটিংয়ে',
'skills.meetingBots.liveStatusJoining': 'যোগ দিচ্ছে\u2026',
diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts
index 9b2adc08d..4064ec303 100644
--- a/app/src/lib/i18n/de.ts
+++ b/app/src/lib/i18n/de.ts
@@ -4752,6 +4752,8 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsEmpty':
'Noch keine früheren Anrufe — Ihr Meeting-Verlauf erscheint hier.',
'skills.meetingBots.recentCallsLoading': 'Laden\u2026',
+ 'skills.meetingBots.recentCallAddedBy': 'Hinzugefügt von {name}',
+ 'skills.meetingBots.recentCallParticipants': 'Mit {names}',
'skills.meetingBots.liveBadge': 'Live',
'skills.meetingBots.liveTitle': 'Im Meeting',
'skills.meetingBots.liveStatusJoining': 'Beitritt\u2026',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index cb382ecad..783e335c8 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -5239,6 +5239,8 @@ const en: TranslationMap = {
'skills.meetingBots.recentCallsEmpty':
'No previous calls yet — your meeting history will appear here.',
'skills.meetingBots.recentCallsLoading': 'Loading\u2026',
+ 'skills.meetingBots.recentCallAddedBy': 'Added by {name}',
+ 'skills.meetingBots.recentCallParticipants': 'With {names}',
'skills.meetingBots.liveBadge': 'Live',
'skills.meetingBots.liveTitle': 'In Meeting',
'skills.meetingBots.liveStatusJoining': 'Joining\u2026',
diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts
index 9672c06ea..f9d94e3c3 100644
--- a/app/src/lib/i18n/es.ts
+++ b/app/src/lib/i18n/es.ts
@@ -4723,6 +4723,8 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsEmpty':
'Aún no hay llamadas anteriores — tu historial de reuniones aparecerá aquí.',
'skills.meetingBots.recentCallsLoading': 'Cargando\u2026',
+ 'skills.meetingBots.recentCallAddedBy': 'Añadido por {name}',
+ 'skills.meetingBots.recentCallParticipants': 'Con {names}',
'skills.meetingBots.liveBadge': 'En vivo',
'skills.meetingBots.liveTitle': 'En reunión',
'skills.meetingBots.liveStatusJoining': 'Uniéndose\u2026',
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index 2a5694bd2..8af1313f9 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -4743,6 +4743,8 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsEmpty':
'Pas encore d\u2019appels précédents — votre historique de réunions apparaîtra ici.',
'skills.meetingBots.recentCallsLoading': 'Chargement\u2026',
+ 'skills.meetingBots.recentCallAddedBy': 'Ajouté par {name}',
+ 'skills.meetingBots.recentCallParticipants': 'Avec {names}',
'skills.meetingBots.liveBadge': 'En direct',
'skills.meetingBots.liveTitle': 'En réunion',
'skills.meetingBots.liveStatusJoining': 'Connexion\u2026',
diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts
index dc84edb96..000382bdf 100644
--- a/app/src/lib/i18n/hi.ts
+++ b/app/src/lib/i18n/hi.ts
@@ -4638,6 +4638,8 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsEmpty':
'अभी तक कोई पिछली कॉल नहीं — आपका मीटिंग इतिहास यहाँ दिखेगा।',
'skills.meetingBots.recentCallsLoading': 'लोड हो रहा है\u2026',
+ 'skills.meetingBots.recentCallAddedBy': '{name} द्वारा जोड़ा गया',
+ 'skills.meetingBots.recentCallParticipants': '{names} के साथ',
'skills.meetingBots.liveBadge': 'लाइव',
'skills.meetingBots.liveTitle': 'मीटिंग में',
'skills.meetingBots.liveStatusJoining': 'शामिल हो रहा है\u2026',
diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts
index 13dbfc5d2..890b96aa7 100644
--- a/app/src/lib/i18n/id.ts
+++ b/app/src/lib/i18n/id.ts
@@ -4651,6 +4651,8 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsEmpty':
'Belum ada panggilan sebelumnya — riwayat rapat Anda akan muncul di sini.',
'skills.meetingBots.recentCallsLoading': 'Memuat\u2026',
+ 'skills.meetingBots.recentCallAddedBy': 'Ditambahkan oleh {name}',
+ 'skills.meetingBots.recentCallParticipants': 'Dengan {names}',
'skills.meetingBots.liveBadge': 'Langsung',
'skills.meetingBots.liveTitle': 'Dalam Rapat',
'skills.meetingBots.liveStatusJoining': 'Bergabung\u2026',
diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts
index 54f02b4e6..3f3396fa0 100644
--- a/app/src/lib/i18n/it.ts
+++ b/app/src/lib/i18n/it.ts
@@ -4712,6 +4712,8 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsEmpty':
'Nessuna chiamata precedente — la cronologia delle riunioni apparirà qui.',
'skills.meetingBots.recentCallsLoading': 'Caricamento\u2026',
+ 'skills.meetingBots.recentCallAddedBy': 'Aggiunto da {name}',
+ 'skills.meetingBots.recentCallParticipants': 'Con {names}',
'skills.meetingBots.liveBadge': 'In diretta',
'skills.meetingBots.liveTitle': 'In Riunione',
'skills.meetingBots.liveStatusJoining': 'Partecipando\u2026',
diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts
index c796c4fad..6ab975d28 100644
--- a/app/src/lib/i18n/ko.ts
+++ b/app/src/lib/i18n/ko.ts
@@ -4591,6 +4591,8 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsHeading': '최근 통화',
'skills.meetingBots.recentCallsEmpty': '이전 통화가 없습니다 — 회의 기록이 여기에 표시됩니다.',
'skills.meetingBots.recentCallsLoading': '로딩 중\u2026',
+ 'skills.meetingBots.recentCallAddedBy': '{name}님이 추가함',
+ 'skills.meetingBots.recentCallParticipants': '{names}님과 함께',
'skills.meetingBots.liveBadge': '라이브',
'skills.meetingBots.liveTitle': '회의 중',
'skills.meetingBots.liveStatusJoining': '참가 중\u2026',
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts
index a80d0dedd..fdd2110bd 100644
--- a/app/src/lib/i18n/pl.ts
+++ b/app/src/lib/i18n/pl.ts
@@ -4706,6 +4706,8 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsEmpty':
'Brak poprzednich rozmów — historia spotkań pojawi się tutaj.',
'skills.meetingBots.recentCallsLoading': 'Ładowanie\u2026',
+ 'skills.meetingBots.recentCallAddedBy': 'Dodane przez {name}',
+ 'skills.meetingBots.recentCallParticipants': 'Uczestnicy: {names}',
'skills.meetingBots.liveBadge': 'Na żywo',
'skills.meetingBots.liveTitle': 'Na spotkaniu',
'skills.meetingBots.liveStatusJoining': 'Dołączanie\u2026',
diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts
index 25f220691..b424a61bd 100644
--- a/app/src/lib/i18n/pt.ts
+++ b/app/src/lib/i18n/pt.ts
@@ -4715,6 +4715,8 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsEmpty':
'Nenhuma chamada anterior ainda — seu histórico de reuniões aparecerá aqui.',
'skills.meetingBots.recentCallsLoading': 'Carregando\u2026',
+ 'skills.meetingBots.recentCallAddedBy': 'Adicionado por {name}',
+ 'skills.meetingBots.recentCallParticipants': 'Com {names}',
'skills.meetingBots.liveBadge': 'Ao vivo',
'skills.meetingBots.liveTitle': 'Em reunião',
'skills.meetingBots.liveStatusJoining': 'Entrando\u2026',
diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts
index a186fdc1c..906e5ee67 100644
--- a/app/src/lib/i18n/ru.ts
+++ b/app/src/lib/i18n/ru.ts
@@ -4679,6 +4679,8 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsHeading': 'Недавние звонки',
'skills.meetingBots.recentCallsEmpty': 'Предыдущих звонков нет — история встреч появится здесь.',
'skills.meetingBots.recentCallsLoading': 'Загрузка\u2026',
+ 'skills.meetingBots.recentCallAddedBy': 'Добавил {name}',
+ 'skills.meetingBots.recentCallParticipants': 'С {names}',
'skills.meetingBots.liveBadge': 'Эфир',
'skills.meetingBots.liveTitle': 'На встрече',
'skills.meetingBots.liveStatusJoining': 'Подключение\u2026',
diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts
index f5138ce35..9a73e7483 100644
--- a/app/src/lib/i18n/zh-CN.ts
+++ b/app/src/lib/i18n/zh-CN.ts
@@ -4403,6 +4403,8 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsHeading': '最近通话',
'skills.meetingBots.recentCallsEmpty': '暂无历史通话记录 — 您的会议记录将显示在此处。',
'skills.meetingBots.recentCallsLoading': '加载中\u2026',
+ 'skills.meetingBots.recentCallAddedBy': '由 {name} 添加',
+ 'skills.meetingBots.recentCallParticipants': '与 {names}',
'skills.meetingBots.liveBadge': '直播',
'skills.meetingBots.liveTitle': '会议中',
'skills.meetingBots.liveStatusJoining': '正在加入\u2026',
diff --git a/app/src/services/meetCallService.ts b/app/src/services/meetCallService.ts
index 8a2262c36..37ea4ed90 100644
--- a/app/src/services/meetCallService.ts
+++ b/app/src/services/meetCallService.ts
@@ -128,6 +128,12 @@ export interface MeetCallRecord {
listened_seconds: number;
spoken_seconds: number;
turn_count: number;
+ /**
+ * Distinct human participant display names mined from the transcript
+ * (backend-meet flow). Older records and local meet-agent calls omit this,
+ * so it is optional and defaults to an empty list at the UI.
+ */
+ participants?: string[];
}
interface CoreListCallsResponse {
diff --git a/src/openhuman/agent_meetings/bus.rs b/src/openhuman/agent_meetings/bus.rs
index 8c799333d..f93ad8108 100644
--- a/src/openhuman/agent_meetings/bus.rs
+++ b/src/openhuman/agent_meetings/bus.rs
@@ -61,6 +61,18 @@ impl EventHandler for MeetingEventSubscriber {
"{LOG_PREFIX} transcript received — creating meeting thread"
);
+ // Record a recent-calls entry (meet id, duration, owner,
+ // participants) so the meeting-bots panel shows call history.
+ // Done first (before the heavier thread-creation path) so the
+ // record is on disk by the time the panel refetches at call-end.
+ // Best-effort: never blocks; logs on failure internally.
+ super::recent_calls::record_backend_call(
+ turns,
+ *duration_ms,
+ correlation_id.as_deref(),
+ )
+ .await;
+
// Create the meeting thread with transcript.
if let Err(e) = create_meeting_thread_with_transcript(
turns,
diff --git a/src/openhuman/agent_meetings/mod.rs b/src/openhuman/agent_meetings/mod.rs
index 99e2f7c6c..4c332d865 100644
--- a/src/openhuman/agent_meetings/mod.rs
+++ b/src/openhuman/agent_meetings/mod.rs
@@ -18,6 +18,7 @@ pub mod bus;
pub mod calendar;
pub mod in_call;
pub mod ops;
+pub mod recent_calls;
pub mod schemas;
pub mod store;
pub mod types;
diff --git a/src/openhuman/agent_meetings/ops.rs b/src/openhuman/agent_meetings/ops.rs
index 8b08c03d8..97c458dda 100644
--- a/src/openhuman/agent_meetings/ops.rs
+++ b/src/openhuman/agent_meetings/ops.rs
@@ -327,6 +327,26 @@ pub async fn handle_join(params: Map) -> Result {
.await
.map_err(|e| format!("[agent_meetings] emit failed: {e}"))?;
+ // Snapshot join context so the post-call recent-calls record can show who
+ // launched the bot, into which meeting. Keyed by correlation_id; consumed
+ // when the `BackendMeetTranscript` event arrives at call-end. No-op when
+ // the caller didn't supply a correlation_id.
+ super::recent_calls::remember_join(
+ req.correlation_id.as_deref(),
+ super::recent_calls::JoinMeta {
+ meet_url: normalized_url.to_string(),
+ // "Your Name in This Meeting" — the human who launched the bot and
+ // whom it answers to. This is the owner shown in the recent-calls list.
+ owner_display_name: req.respond_to_participant.clone().unwrap_or_default(),
+ // The bot's tile name in the meeting (persona display name).
+ bot_display_name: display_name.clone(),
+ started_at_ms: std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_millis() as u64)
+ .unwrap_or(0),
+ },
+ );
+
// Active mode (listen_only = false, the modal's "respond when addressed"
// toggle) enables in-call agency for just this meeting, so the toggle
// "just works" without flipping the global config. Passive joins leave
diff --git a/src/openhuman/agent_meetings/recent_calls.rs b/src/openhuman/agent_meetings/recent_calls.rs
new file mode 100644
index 000000000..902ba1506
--- /dev/null
+++ b/src/openhuman/agent_meetings/recent_calls.rs
@@ -0,0 +1,358 @@
+//! Recent-calls recording for the **backend meet** flow.
+//!
+//! The "Send OpenHuman to a meeting" card drives the backend bot via
+//! `agent_meetings_join` → `bot:join`, and the call ends with a
+//! `BackendMeetTranscript` event carrying only `turns` / `duration_ms` /
+//! `correlation_id`. None of the join context (who launched the bot, which
+//! URL, the bot's display name) survives to call-end on its own.
+//!
+//! To give the Recent-calls panel real detail we:
+//! 1. snapshot the join inputs in an in-memory registry keyed by
+//! `correlation_id` at [`remember_join`] time, and
+//! 2. at transcript time [`record_backend_call`] looks that snapshot back
+//! up, mines the participant names out of the transcript, and appends a
+//! [`MeetCallRecord`] to the shared recent-calls store the UI reads.
+//!
+//! The registry is intentionally in-memory: a call lives for minutes and the
+//! snapshot is only needed for that window. If the app restarts mid-call we
+//! simply fall back to a leaner record (duration + participants, no owner/URL)
+//! rather than failing — recording is best-effort and never blocks the call.
+
+use std::collections::HashMap;
+use std::sync::{Mutex, OnceLock};
+
+use crate::core::event_bus::BackendMeetTurn;
+use crate::openhuman::meet_agent::store::{self, MeetCallRecord};
+
+const LOG_PREFIX: &str = "[agent_meetings::recent_calls]";
+
+/// Join-time context snapshotted so the call record can be enriched at
+/// transcript time. Keyed by `correlation_id` in the pending registry.
+#[derive(Debug, Clone)]
+pub struct JoinMeta {
+ pub meet_url: String,
+ /// Display name the user gave themselves in the call ("who added the bot").
+ pub owner_display_name: String,
+ /// The bot's tile name in the meeting.
+ pub bot_display_name: String,
+ pub started_at_ms: u64,
+}
+
+/// Evict a pending join this long after its `started_at_ms` if no transcript
+/// ever claimed it (failed join, crash, bot never admitted, …). Generous —
+/// a real call's transcript lands within minutes — but bounded so abandoned
+/// joins can't accumulate in a long-lived process.
+const PENDING_JOIN_TTL_MS: u64 = 6 * 60 * 60 * 1000; // 6h
+
+/// Backstop hard cap on retained pending joins, independent of the TTL, so
+/// pathological churn within the window still can't grow without bound.
+/// Oldest entries are evicted first.
+const MAX_PENDING_JOINS: usize = 256;
+
+fn registry() -> &'static Mutex> {
+ static REG: OnceLock>> = OnceLock::new();
+ REG.get_or_init(|| Mutex::new(HashMap::new()))
+}
+
+/// Snapshot join context for `correlation_id`. No-op when the id is absent
+/// (we can't key the later lookup without it). Opportunistically prunes stale
+/// entries on each insert so a transcript that never arrives can't leak.
+pub fn remember_join(correlation_id: Option<&str>, meta: JoinMeta) {
+ let Some(cid) = correlation_id.map(str::trim).filter(|c| !c.is_empty()) else {
+ return;
+ };
+ let mut map = registry().lock().unwrap();
+ map.insert(cid.to_string(), meta);
+ prune_stale(&mut map, now_ms());
+ log::debug!(
+ "{LOG_PREFIX} remembered join correlation_id={cid} pending={}",
+ map.len()
+ );
+}
+
+/// Drop entries past their TTL, then enforce `MAX_PENDING_JOINS` by evicting
+/// the oldest. Pure over its inputs (takes `now_ms`) so it is unit-testable.
+fn prune_stale(map: &mut HashMap, now_ms: u64) {
+ map.retain(|_, m| now_ms.saturating_sub(m.started_at_ms) <= PENDING_JOIN_TTL_MS);
+ if map.len() > MAX_PENDING_JOINS {
+ let mut by_age: Vec<(String, u64)> = map
+ .iter()
+ .map(|(k, m)| (k.clone(), m.started_at_ms))
+ .collect();
+ by_age.sort_by_key(|(_, ts)| *ts);
+ for (k, _) in by_age.into_iter().take(map.len() - MAX_PENDING_JOINS) {
+ map.remove(&k);
+ }
+ }
+}
+
+/// Take (and remove) the snapshot for `correlation_id`, if any.
+fn take_join(correlation_id: Option<&str>) -> Option {
+ let cid = correlation_id.map(str::trim).filter(|c| !c.is_empty())?;
+ registry().lock().unwrap().remove(cid)
+}
+
+/// Build and persist a [`MeetCallRecord`] for a finished backend-meet call.
+///
+/// Best-effort: any failure is logged and swallowed — the call is already
+/// over and the UI degrades to "no record" rather than erroring.
+pub async fn record_backend_call(
+ turns: &[BackendMeetTurn],
+ duration_ms: u64,
+ correlation_id: Option<&str>,
+) {
+ let meta = take_join(correlation_id);
+ let record = build_record(meta.as_ref(), turns, duration_ms, correlation_id, now_ms());
+ match store::append_record(&record).await {
+ Ok(()) => log::info!(
+ "{LOG_PREFIX} recorded call request_id={} participants={} duration_s={:.0}",
+ record.request_id,
+ record.participants.len(),
+ record.listened_seconds
+ ),
+ Err(e) => log::warn!("{LOG_PREFIX} append_record failed: {e}"),
+ }
+}
+
+/// Map a finished call's inputs to a [`MeetCallRecord`]. Pure (takes `now_ms`,
+/// no I/O) so the field-mapping and fallback logic is unit-testable without a
+/// store or a workspace.
+fn build_record(
+ meta: Option<&JoinMeta>,
+ turns: &[BackendMeetTurn],
+ duration_ms: u64,
+ correlation_id: Option<&str>,
+ now_ms: u64,
+) -> MeetCallRecord {
+ // Prefer the snapshotted start; otherwise derive it from the duration so
+ // the row still sorts roughly correctly in the newest-first list.
+ let started_at_ms = meta
+ .map(|m| m.started_at_ms)
+ .unwrap_or_else(|| now_ms.saturating_sub(duration_ms));
+ MeetCallRecord {
+ request_id: correlation_id
+ .map(str::trim)
+ .filter(|c| !c.is_empty())
+ .map(str::to_string)
+ .unwrap_or_else(|| format!("backend-{started_at_ms}")),
+ meet_url: meta.map(|m| m.meet_url.clone()).unwrap_or_default(),
+ bot_display_name: meta.map(|m| m.bot_display_name.clone()).unwrap_or_default(),
+ owner_display_name: meta
+ .map(|m| m.owner_display_name.clone())
+ .unwrap_or_default(),
+ started_at_ms,
+ ended_at_ms: now_ms,
+ // The backend flow reports a single wall-clock duration rather than
+ // split listen/speak meters; surface it as "listened" so the existing
+ // UI ("Ns on call" = listened + spoken) shows the real call length.
+ listened_seconds: (duration_ms as f32) / 1000.0,
+ spoken_seconds: 0.0,
+ turn_count: turns.len() as u32,
+ participants: extract_participants(turns),
+ }
+}
+
+/// Extract distinct human participant names from a transcript.
+///
+/// Backend transcript lines look like `[00:51] [Shanu Goyanka] your time` or
+/// `[00:00] [System] Tiny joined the meeting`. The speaker is the first
+/// bracketed token that is **not** a `MM:SS` timestamp. We skip:
+/// - assistant turns (that's the bot, not a participant),
+/// - the synthetic `System` speaker (join/leave/presence noise),
+/// - blank/duplicate names (first-seen order preserved).
+fn extract_participants(turns: &[BackendMeetTurn]) -> Vec {
+ let mut seen = Vec::new();
+ for turn in turns {
+ if turn.role.eq_ignore_ascii_case("assistant") {
+ continue;
+ }
+ let Some(name) = speaker_name(&turn.content) else {
+ continue;
+ };
+ if name.eq_ignore_ascii_case("system") {
+ continue;
+ }
+ if !seen.iter().any(|n: &String| n == &name) {
+ seen.push(name);
+ }
+ }
+ seen
+}
+
+/// Pull the speaker name from a single transcript line: the first
+/// `[...]` group whose contents are not a clock timestamp.
+fn speaker_name(content: &str) -> Option {
+ let mut rest = content.trim_start();
+ while let Some(stripped) = rest.strip_prefix('[') {
+ let close = stripped.find(']')?;
+ let inner = stripped[..close].trim();
+ rest = stripped[close + 1..].trim_start();
+ if inner.is_empty() || is_timestamp(inner) {
+ continue;
+ }
+ return Some(inner.to_string());
+ }
+ None
+}
+
+/// True for `M:SS` / `MM:SS` / `H:MM:SS` clock stamps.
+fn is_timestamp(s: &str) -> bool {
+ let parts: Vec<&str> = s.split(':').collect();
+ if parts.len() < 2 || parts.len() > 3 {
+ return false;
+ }
+ parts
+ .iter()
+ .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
+}
+
+fn now_ms() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_millis() as u64)
+ .unwrap_or(0)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn turn(role: &str, content: &str) -> BackendMeetTurn {
+ BackendMeetTurn {
+ role: role.to_string(),
+ content: content.to_string(),
+ }
+ }
+
+ #[test]
+ fn speaker_name_skips_timestamp_and_takes_name() {
+ assert_eq!(
+ speaker_name("[00:51] [Shanu Goyanka] your time").as_deref(),
+ Some("Shanu Goyanka")
+ );
+ assert_eq!(
+ speaker_name("[System] Tiny joined").as_deref(),
+ Some("System")
+ );
+ // No bracketed speaker → none.
+ assert_eq!(speaker_name("just text"), None);
+ assert_eq!(speaker_name("[12:00]"), None);
+ }
+
+ #[test]
+ fn is_timestamp_matches_clock_only() {
+ assert!(is_timestamp("0:00"));
+ assert!(is_timestamp("00:51"));
+ assert!(is_timestamp("1:02:03"));
+ assert!(!is_timestamp("Shanu"));
+ assert!(!is_timestamp("12"));
+ assert!(!is_timestamp("a:bb"));
+ }
+
+ #[test]
+ fn extract_participants_dedups_and_excludes_system_and_bot() {
+ let turns = vec![
+ turn("user", "[00:00] [System] Tiny joined the meeting"),
+ turn("user", "[00:51] [Shanu Goyanka] your time"),
+ turn("assistant", "[00:55] [Tiny] On it."),
+ turn("user", "[02:09] [Shanu Goyanka] hey hello"),
+ turn("user", "[02:20] [Alex Rivera] sounds good"),
+ ];
+ let names = extract_participants(&turns);
+ assert_eq!(names, vec!["Shanu Goyanka", "Alex Rivera"]);
+ }
+
+ #[test]
+ fn extract_participants_empty_when_no_speakers() {
+ let turns = vec![turn("user", "no brackets here"), turn("assistant", "hi")];
+ assert!(extract_participants(&turns).is_empty());
+ }
+
+ fn meta(started_at_ms: u64) -> JoinMeta {
+ JoinMeta {
+ meet_url: "https://meet.google.com/abc-defg-hij".into(),
+ owner_display_name: "Shanu".into(),
+ bot_display_name: "Tiny".into(),
+ started_at_ms,
+ }
+ }
+
+ #[test]
+ fn remember_then_take_round_trips_and_consumes() {
+ let cid = "corr-rc-test-1";
+ // Fresh timestamp so the opportunistic prune in remember_join keeps it.
+ remember_join(Some(cid), meta(now_ms()));
+ let got = take_join(Some(cid)).expect("present");
+ assert_eq!(got.owner_display_name, "Shanu");
+ // Consumed — a second take is empty.
+ assert!(take_join(Some(cid)).is_none());
+ }
+
+ #[test]
+ fn prune_stale_evicts_expired_keeps_fresh() {
+ let now = 10 * PENDING_JOIN_TTL_MS;
+ let mut map = HashMap::new();
+ map.insert("fresh".to_string(), meta(now)); // age 0
+ map.insert("old".to_string(), meta(now - PENDING_JOIN_TTL_MS - 1)); // just past TTL
+ prune_stale(&mut map, now);
+ assert!(map.contains_key("fresh"));
+ assert!(!map.contains_key("old"));
+ }
+
+ #[test]
+ fn prune_stale_enforces_size_cap_evicting_oldest() {
+ let now = 10 * PENDING_JOIN_TTL_MS;
+ let mut map = HashMap::new();
+ // All within TTL, but more than the cap — oldest must be evicted.
+ for i in 0..(MAX_PENDING_JOINS + 5) as u64 {
+ map.insert(format!("c{i}"), meta(now - i)); // c0 newest … higher i older
+ }
+ prune_stale(&mut map, now);
+ assert_eq!(map.len(), MAX_PENDING_JOINS);
+ assert!(map.contains_key("c0")); // newest kept
+ assert!(!map.contains_key(&format!("c{}", MAX_PENDING_JOINS + 4))); // oldest evicted
+ }
+
+ #[test]
+ fn build_record_uses_join_meta_when_present() {
+ let turns = vec![turn("user", "[00:10] [Shanu Goyanka] hi")];
+ let rec = build_record(Some(&meta(1000)), &turns, 30_000, Some("corr-42"), 50_000);
+ assert_eq!(rec.request_id, "corr-42");
+ assert_eq!(rec.meet_url, "https://meet.google.com/abc-defg-hij");
+ assert_eq!(rec.owner_display_name, "Shanu");
+ assert_eq!(rec.bot_display_name, "Tiny");
+ assert_eq!(rec.started_at_ms, 1000); // from meta, not derived
+ assert_eq!(rec.ended_at_ms, 50_000);
+ assert_eq!(rec.listened_seconds, 30.0);
+ assert_eq!(rec.turn_count, 1);
+ assert_eq!(rec.participants, vec!["Shanu Goyanka"]);
+ }
+
+ #[test]
+ fn build_record_falls_back_without_meta() {
+ let turns = vec![turn("user", "[00:00] [System] joined")];
+ let rec = build_record(None, &turns, 8_000, None, 100_000);
+ // No correlation id → synthesised request_id from the derived start.
+ assert_eq!(rec.started_at_ms, 92_000); // now - duration
+ assert_eq!(rec.request_id, "backend-92000");
+ assert!(rec.meet_url.is_empty());
+ assert!(rec.owner_display_name.is_empty());
+ // Only a System turn → no human participants.
+ assert!(rec.participants.is_empty());
+ }
+
+ #[test]
+ fn remember_join_noop_without_correlation_id() {
+ remember_join(
+ None,
+ JoinMeta {
+ meet_url: "x".into(),
+ owner_display_name: "y".into(),
+ bot_display_name: "z".into(),
+ started_at_ms: 0,
+ },
+ );
+ assert!(take_join(None).is_none());
+ assert!(take_join(Some(" ")).is_none());
+ }
+}
diff --git a/src/openhuman/meet_agent/rpc.rs b/src/openhuman/meet_agent/rpc.rs
index 25442f652..efdb6b88d 100644
--- a/src/openhuman/meet_agent/rpc.rs
+++ b/src/openhuman/meet_agent/rpc.rs
@@ -232,6 +232,10 @@ pub async fn handle_stop_session(params: Map) -> Result,
}
/// Hard cap on the rows returned from `read_recent`. The UI shows ~20
@@ -177,6 +184,7 @@ mod tests {
listened_seconds: 12.5,
spoken_seconds: 4.2,
turn_count: 3,
+ participants: vec!["Alice".into(), "Bob".into()],
}
}