fix(meetings): populate Recent calls for backend meet flow (owner, duration, participants) (#3710)

This commit is contained in:
YellowSnnowmann
2026-06-17 13:31:30 +05:30
committed by GitHub
parent a1b7c29338
commit eaad68ecaa
24 changed files with 591 additions and 96 deletions
+10 -96
View File
@@ -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 (
<section
aria-label={t('skills.meetingBots.recentCallsAriaLabel')}
className="mt-4 border-t border-stone-200 dark:border-neutral-800 pt-4">
<div className="flex items-baseline justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('skills.meetingBots.recentCallsHeading')}
{rows && rows.length > 0 && (
<span className="ml-1 text-stone-400 dark:text-neutral-500 normal-case font-normal">
({rows.length})
</span>
)}
</h3>
</div>
{error && (
<p className="mt-2 text-[11px] text-coral-600 dark:text-coral-400">{error}</p>
)}
{rows === null ? (
<p className="mt-2 text-[11px] text-stone-400 dark:text-neutral-500">
{t('skills.meetingBots.recentCallsLoading')}
</p>
) : rows.length === 0 ? (
<p className="mt-2 text-[11px] text-stone-400 dark:text-neutral-500">
{t('skills.meetingBots.recentCallsEmpty')}
</p>
) : (
<ul className="mt-2 max-h-48 space-y-1 overflow-y-auto pr-1">
{rows.map(call => (
<RecentCallRow key={call.request_id} call={call} />
))}
</ul>
)}
</section>
);
}
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 (
<li className="rounded-lg px-2 py-1.5 text-[11px] text-stone-700 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800/40">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-stone-800 dark:text-neutral-200">
{meetingCode}
</span>
<span className="shrink-0 text-stone-400 dark:text-neutral-500">
{formatRelativeTime(call.started_at_ms)}
</span>
</div>
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-stone-500 dark:text-neutral-400">
<span>
{call.turn_count} turn{call.turn_count === 1 ? '' : 's'}
</span>
<span>{duration}s on call</span>
</div>
</li>
);
}
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 '—';
}
}
@@ -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 (
<section
aria-label={t('skills.meetingBots.recentCallsAriaLabel')}
className="mt-4 border-t border-stone-200 dark:border-neutral-800 pt-4">
<div className="flex items-baseline justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('skills.meetingBots.recentCallsHeading')}
{rows && rows.length > 0 && (
<span className="ml-1 text-stone-400 dark:text-neutral-500 normal-case font-normal">
({rows.length})
</span>
)}
</h3>
</div>
{error && <p className="mt-2 text-[11px] text-coral-600 dark:text-coral-400">{error}</p>}
{rows === null ? (
<p className="mt-2 text-[11px] text-stone-400 dark:text-neutral-500">
{t('skills.meetingBots.recentCallsLoading')}
</p>
) : rows.length === 0 ? (
<p className="mt-2 text-[11px] text-stone-400 dark:text-neutral-500">
{t('skills.meetingBots.recentCallsEmpty')}
</p>
) : (
<ul className="mt-2 max-h-48 space-y-1 overflow-y-auto pr-1">
{rows.map(call => (
<RecentCallRow key={call.request_id} call={call} />
))}
</ul>
)}
</section>
);
}
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 (
<li className="rounded-lg px-2 py-1.5 text-[11px] text-stone-700 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800/40">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-stone-800 dark:text-neutral-200">
{meetingCode}
</span>
<span className="shrink-0 text-stone-400 dark:text-neutral-500">
{formatRelativeTime(call.started_at_ms)}
</span>
</div>
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-stone-500 dark:text-neutral-400">
<span>
{call.turn_count} turn{call.turn_count === 1 ? '' : 's'}
</span>
<span>{duration}s on call</span>
{owner && (
<span className="truncate">
{t('skills.meetingBots.recentCallAddedBy').replace('{name}', owner)}
</span>
)}
</div>
{participants.length > 0 && (
<div className="mt-0.5 truncate text-[10px] text-stone-500 dark:text-neutral-400">
{t('skills.meetingBots.recentCallParticipants').replace('{names}', participants.join(', '))}
</div>
)}
</li>
);
}
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 '—';
}
}
@@ -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(<MeetingBotsCard />);
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(<MeetingBotsCard />);
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(<MeetingBotsCard />);
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+6
View File
@@ -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 {