From 96ce16854ccb187d0a4cc6cf99867ff4dc771012 Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Mon, 29 Jun 2026 19:07:57 +0530 Subject: [PATCH 01/20] feat(meet): auto-fill meeting display name from connected account Prefill 'Your name in this meeting' from the Composio account that best matches the meeting platform (own account -> mailbox -> blank). Composio exposes only an account email, so derive a 'First Last' display name from the local part. Only prefills while the field is untouched, so it never clobbers manual input. --- app/src/components/skills/MeetingBotsCard.tsx | 66 ++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/app/src/components/skills/MeetingBotsCard.tsx b/app/src/components/skills/MeetingBotsCard.tsx index 6c059982f6..90dc902494 100644 --- a/app/src/components/skills/MeetingBotsCard.tsx +++ b/app/src/components/skills/MeetingBotsCard.tsx @@ -2,6 +2,8 @@ import debug from 'debug'; import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type MascotFace, RiveMascot } from '../../features/human/Mascot'; +import { useComposioIntegrations } from '../../lib/composio/hooks'; +import type { ComposioConnection } from '../../lib/composio/types'; import { useT } from '../../lib/i18n/I18nContext'; import { isCapacityGateMessage, @@ -9,6 +11,7 @@ import { leaveBackendMeetBot, listMeetCalls, type MeetCallRecord, + type MeetingPlatform, } from '../../services/meetCallService'; import { type BackendMeetHarnessEvent, @@ -38,6 +41,46 @@ type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: stri const log = debug('meeting-bots'); +// Composio only hands back a connected account's email — there is no separate +// display-name field on `ComposioConnection`. A meeting display name is almost +// always "First Last" derived from that account, so we best-effort humanize the +// email's local part (`first.last` → `First Last`). +function deriveDisplayNameFromEmail(email: string | undefined): string { + const localPart = email?.split('@')[0]?.trim(); + if (!localPart) return ''; + return localPart + .split(/[._-]+/) + .filter(Boolean) + .map(token => token.charAt(0).toUpperCase() + token.slice(1)) + .join(' '); +} + +// Per-platform priority of Composio toolkits to source the user's in-call +// display name from: the platform's own connected account first, then the +// mailbox, then blank. Slugs are canonical Composio slugs (see +// `canonicalizeComposioToolkitSlug`). +const NAME_SOURCE_TOOLKITS: Record = { + gmeet: ['googlemeet', 'gmail'], + zoom: ['zoom', 'gmail'], + teams: ['microsoft_teams', 'outlook', 'gmail'], + webex: ['webex', 'gmail'], +}; + +// Resolve a default "Your name in this meeting" for the given platform: walk +// that platform's toolkit priority (own account → mail → blank) and return the +// first connected account whose email yields a usable name; blank if none are +// connected. The single entry point the form calls. +function resolveMeetingDisplayName( + platform: MeetingPlatform, + connectionByToolkit: Map +): string { + for (const slug of NAME_SOURCE_TOOLKITS[platform]) { + const name = deriveDisplayNameFromEmail(connectionByToolkit.get(slug)?.accountEmail); + if (name) return name; + } + return ''; +} + interface Props { onToast?: (toast: Toast) => void; } @@ -205,6 +248,9 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps) // backend join payload as `respondToParticipant` → `respondTo`, which the // meeting stream uses to gate in-call requests to this speaker only. const [respondTo, setRespondTo] = useState(''); + // Once the user types in the name field we stop auto-prefilling it, so a + // late-arriving Composio fetch (it polls) can never clobber manual input. + const respondToTouchedRef = useRef(false); // Active (respond when addressed) vs listen-only (transcribe only). Defaults // to active; the bot still only replies after being addressed by the wake // phrase. Forwarded to the backend as `listenOnly` and mirrored into the @@ -222,6 +268,19 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps) const meetError = useAppSelector(selectBackendMeetError); const [recentCalls, setRecentCalls] = useState(null); const [recentError, setRecentError] = useState(null); + // The meeting platform this form sends to (only Google Meet is wired up for + // now). Drives both the join payload and which connected accounts we source + // the default display name from. + const platform: MeetingPlatform = 'gmeet'; + // Prefill "Your name in this meeting" from the connected account that best + // matches this platform (calendar → mail → platform-native). + const { connectionByToolkit } = useComposioIntegrations(); + const resolvedDisplayName = resolveMeetingDisplayName(platform, connectionByToolkit); + + useEffect(() => { + if (respondToTouchedRef.current || !resolvedDisplayName) return; + setRespondTo(prev => (prev.trim() ? prev : resolvedDisplayName)); + }, [resolvedDisplayName]); const refreshRecentCalls = useCallback(async () => { setRecentError(null); @@ -295,7 +354,7 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps) await joinMeetViaBackendBot({ meetUrl, displayName: agentName, - platform: 'gmeet', + platform, agentName, systemPrompt, mascotId, @@ -356,7 +415,10 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps) autoComplete="off" spellCheck={false} value={respondTo} - onChange={e => setRespondTo(e.target.value)} + onChange={e => { + respondToTouchedRef.current = true; + setRespondTo(e.target.value); + }} placeholder={t('skills.meetingBots.respondToParticipantHint')} disabled={submitting} required From 8c945c5d525dd1bfb8dc09eee894d214dc3c9c1e Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Mon, 29 Jun 2026 22:25:34 +0530 Subject: [PATCH 02/20] feat(meet): redesign meetings composer with platform selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1A of the meetings redesign. Replace the single-platform inline form with a hero composer + Google Meet/Zoom/Teams/Webex platform chips, un-hardcoding the platform so the selection flows through to the join payload, URL placeholder, and display-name auto-fill source. Extract the new components into app/src/components/meetings/ (MeetingsPage, MeetComposer, PlatformChips, ActiveMeetingBanner, meetingUtils); MeetingBotsCard becomes a back-compat re-export shim. The chips are a pure selector — no connection is required to send the bot. --- .../meetings/ActiveMeetingBanner.tsx | 154 ++++++ app/src/components/meetings/MeetComposer.tsx | 287 +++++++++++ app/src/components/meetings/MeetingsPage.tsx | 100 ++++ app/src/components/meetings/PlatformChips.tsx | 108 ++++ .../__tests__/ActiveMeetingBanner.test.tsx | 178 +++++++ .../meetings/__tests__/MeetComposer.test.tsx | 291 +++++++++++ .../meetings/__tests__/PlatformChips.test.tsx | 72 +++ .../meetings/__tests__/meetingUtils.test.ts | 196 +++++++ app/src/components/meetings/meetingUtils.ts | 112 ++++ app/src/components/skills/MeetingBotsCard.tsx | 483 +----------------- .../skills/__tests__/MeetingBotsCard.test.tsx | 9 +- 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/pages/Skills.tsx | 9 +- .../__tests__/Skills.meetings-tab.test.tsx | 2 +- 27 files changed, 1546 insertions(+), 483 deletions(-) create mode 100644 app/src/components/meetings/ActiveMeetingBanner.tsx create mode 100644 app/src/components/meetings/MeetComposer.tsx create mode 100644 app/src/components/meetings/MeetingsPage.tsx create mode 100644 app/src/components/meetings/PlatformChips.tsx create mode 100644 app/src/components/meetings/__tests__/ActiveMeetingBanner.test.tsx create mode 100644 app/src/components/meetings/__tests__/MeetComposer.test.tsx create mode 100644 app/src/components/meetings/__tests__/PlatformChips.test.tsx create mode 100644 app/src/components/meetings/__tests__/meetingUtils.test.ts create mode 100644 app/src/components/meetings/meetingUtils.ts diff --git a/app/src/components/meetings/ActiveMeetingBanner.tsx b/app/src/components/meetings/ActiveMeetingBanner.tsx new file mode 100644 index 0000000000..3376e66dbd --- /dev/null +++ b/app/src/components/meetings/ActiveMeetingBanner.tsx @@ -0,0 +1,154 @@ +/** + * Live/active meeting view — shown when `backendMeet.status` is `'joining'`, + * `'active'`, `'ended'`, or `'error'`. + * + * Extracted from `MeetingBotsCard` (previously `ActiveMeetingView`) to keep + * each component within the repo's ~500-line guideline. Behavior is identical + * to the original; it just lives in its own file now. + */ +import { useMemo, useState } from 'react'; + +import { type MascotFace, RiveMascot } from '../../features/human/Mascot'; +import { useT } from '../../lib/i18n/I18nContext'; +import { leaveBackendMeetBot } from '../../services/meetCallService'; +import { + type BackendMeetHarnessEvent, + type BackendMeetReplyEvent, + type BackendMeetStatus, + resetBackendMeet, + selectBackendMeetLastHarness, + selectBackendMeetLastReply, + selectBackendMeetListenOnly, + selectBackendMeetStatus, + selectBackendMeetUrl, +} from '../../store/backendMeetSlice'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import Button from '../ui/Button'; + +type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: string }; + +export interface ActiveMeetingBannerProps { + onToast?: (toast: Toast) => void; +} + +function faceFromMeetState( + status: BackendMeetStatus, + lastReply: BackendMeetReplyEvent | null, + lastHarness: BackendMeetHarnessEvent | null +): MascotFace { + if (status === 'joining') return 'thinking'; + if (status === 'error') return 'concerned'; + if (status === 'ended') return 'happy'; + if (lastHarness) return 'thinking'; + if (lastReply) { + const e = (lastReply.emotion ?? '').toLowerCase(); + if (e.includes('happy') || e.includes('pleased') || e.includes('joy') || e.includes('excit')) + return 'happy'; + if (e.includes('celebrat') || e.includes('proud')) return 'celebrating'; + if (e.includes('concern') || e.includes('worried') || e.includes('unsure')) return 'concerned'; + if (e.includes('curious') || e.includes('interest')) return 'curious'; + } + return 'idle'; +} + +export function ActiveMeetingBanner({ onToast }: ActiveMeetingBannerProps) { + const { t } = useT(); + const dispatch = useAppDispatch(); + const status = useAppSelector(selectBackendMeetStatus); + const meetUrl = useAppSelector(selectBackendMeetUrl); + const listenOnly = useAppSelector(selectBackendMeetListenOnly); + const lastReply = useAppSelector(selectBackendMeetLastReply); + const lastHarness = useAppSelector(selectBackendMeetLastHarness); + // selectBackendMeetError imported for parity; not used visually here — errors + // surface in the composer's inline alert during the error state. + const face = faceFromMeetState(status, lastReply, lastHarness); + + const meetingCode = useMemo(() => { + if (!meetUrl) return ''; + try { + const tail = new URL(meetUrl).pathname.replace(/^\/+/, ''); + return tail || meetUrl; + } catch { + return meetUrl; + } + }, [meetUrl]); + + const [leaving, setLeaving] = useState(false); + + const handleLeave = async () => { + if (leaving) return; + setLeaving(true); + try { + await leaveBackendMeetBot('user-requested'); + } catch (err) { + onToast?.({ + type: 'error', + title: t('skills.meetingBots.couldNotStartTitle'), + message: String(err), + }); + } finally { + setLeaving(false); + } + }; + + const statusText = (() => { + const base: Record = { + joining: t('skills.meetingBots.liveStatusJoining'), + active: listenOnly + ? t('skills.meetingBots.liveStatusListening') + : t('skills.meetingBots.liveStatusActive'), + ended: t('skills.meetingBots.liveStatusEnded'), + error: t('skills.meetingBots.liveStatusError'), + idle: '', + }; + return base[status] ?? ''; + })(); + + const canLeave = status === 'active' || status === 'joining'; + const isDone = status === 'ended' || status === 'error'; + + return ( +
+
+ + + {canLeave && ( + + )} + {isDone && ( + + )} +
+
+
+ +
+
+
+ {t('skills.meetingBots.liveTitle')} +
+
{statusText}
+ {meetingCode && ( +
+ {meetingCode} +
+ )} + {lastReply?.reply && ( +
+ “{lastReply.reply}” +
+ )} +
+
+
+ ); +} diff --git a/app/src/components/meetings/MeetComposer.tsx b/app/src/components/meetings/MeetComposer.tsx new file mode 100644 index 0000000000..001027bfc3 --- /dev/null +++ b/app/src/components/meetings/MeetComposer.tsx @@ -0,0 +1,287 @@ +/** + * Redesigned meeting composer card. + * + * Replaces the hardcoded-gmeet `MeetingBotsInline` form with a platform + * selector (Google Meet / Zoom / Teams / Webex), a URL input whose placeholder + * adapts to the selected platform, a "Your name" field that auto-prefills from + * the connected Composio account, and a respond-when-addressed toggle. + */ +import debug from 'debug'; +import { type RefObject, useEffect, useRef, useState } from 'react'; + +import { useComposioIntegrations } from '../../lib/composio/hooks'; +import { useT } from '../../lib/i18n/I18nContext'; +import { + isCapacityGateMessage, + joinMeetViaBackendBot, + type MeetingPlatform, +} from '../../services/meetCallService'; +import { + selectBackendMeetError, + selectBackendMeetStatus, + setBackendMeetJoining, +} from '../../store/backendMeetSlice'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { + selectCustomPrimaryColor, + selectCustomSecondaryColor, + selectMascotColor, + selectSelectedMascotId, +} from '../../store/mascotSlice'; +import { selectPersonaDescription, selectPersonaDisplayName } from '../../store/personaSlice'; +import Button from '../ui/Button'; +import { PlatformChips } from './PlatformChips'; +import { + platformLabel, + platformUrlPlaceholder, + resolveMeetingDisplayName, +} from './meetingUtils'; + +const log = debug('meetings:composer'); + +type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: string }; + +export interface MeetComposerProps { + onToast?: (toast: Toast) => void; + /** Ref owned by the parent (MeetingsPage) so the success toast can fire + * after the inline form unmounts on status → 'active'. */ + hasSubmittedRef: RefObject; +} + +export function MeetComposer({ onToast, hasSubmittedRef }: MeetComposerProps) { + const { t } = useT(); + const dispatch = useAppDispatch(); + + // ── Platform selector ──────────────────────────────────────────────────── + const [platform, setPlatform] = useState('gmeet'); + + // ── Form state ─────────────────────────────────────────────────────────── + const [meetUrl, setMeetUrl] = useState(''); + // The participant the bot answers to (authorized speaker). Wired to the + // backend join payload as `respondToParticipant`. + const [respondTo, setRespondTo] = useState(''); + // Once the user types in the name field we stop auto-prefilling it, so a + // late-arriving Composio fetch (it polls) can never clobber manual input. + const respondToTouchedRef = useRef(false); + // Active (respond when addressed) vs listen-only (transcribe only). + const [listenOnly, setListenOnly] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // ── Persona / mascot config ────────────────────────────────────────────── + const personaDisplayName = useAppSelector(selectPersonaDisplayName); + const personaDescription = useAppSelector(selectPersonaDescription); + const selectedMascotId = useAppSelector(selectSelectedMascotId); + const mascotColor = useAppSelector(selectMascotColor); + const customPrimaryColor = useAppSelector(selectCustomPrimaryColor); + const customSecondaryColor = useAppSelector(selectCustomSecondaryColor); + + // ── Meet slice ─────────────────────────────────────────────────────────── + const meetStatus = useAppSelector(selectBackendMeetStatus); + const meetError = useAppSelector(selectBackendMeetError); + + // ── Composio name prefill ──────────────────────────────────────────────── + const { connectionByToolkit } = useComposioIntegrations(); + const resolvedDisplayName = resolveMeetingDisplayName(platform, connectionByToolkit); + + // Prefill / re-prefill when platform changes or Composio resolves, + // but never overwrite a value the user has manually typed. + useEffect(() => { + if (respondToTouchedRef.current || !resolvedDisplayName) return; + setRespondTo(prev => (prev.trim() ? prev : resolvedDisplayName)); + }, [resolvedDisplayName]); + + // When the platform changes and the field is still auto-filled (untouched), + // re-derive from the new platform's connected accounts. + const handlePlatformChange = (next: MeetingPlatform) => { + log('[composer] platform changed from=%s to=%s', platform, next); + setPlatform(next); + if (!respondToTouchedRef.current) { + const name = resolveMeetingDisplayName(next, connectionByToolkit); + if (name) setRespondTo(name); + else setRespondTo(''); + } + }; + + // ── Error path (inline form stays mounted during 'error') ──────────────── + useEffect(() => { + if (!hasSubmittedRef.current) return; + if (meetStatus === 'error') { + hasSubmittedRef.current = false; + const raw = meetError?.trim() || t('skills.meetingBots.failedToStart'); + const message = isCapacityGateMessage(raw) + ? t('skills.meetingBots.serverOverloaded') + : raw; + log('[composer] join error: %s', message); + setError(message); + setSubmitting(false); + onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message }); + } + }, [meetStatus, meetError, onToast, t, hasSubmittedRef]); + + // ── Submit ─────────────────────────────────────────────────────────────── + const agentName = personaDisplayName.trim() || 'Tiny'; + const systemPrompt = personaDescription.trim() || undefined; + const mascotId = selectedMascotId ?? (mascotColor === 'custom' ? undefined : mascotColor); + const riveColors = + mascotColor === 'custom' + ? { primaryColor: customPrimaryColor, secondaryColor: customSecondaryColor } + : undefined; + const wakePhrase = listenOnly ? undefined : `Hey ${agentName}`; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setSubmitting(true); + hasSubmittedRef.current = true; + const meetingId = crypto.randomUUID(); + log('[composer] submit platform=%s active=%s correlationId=%s', platform, !listenOnly, meetingId); + try { + // Await the RPC BEFORE dispatching setBackendMeetJoining so that a + // synchronous rejection (bad URL, auth failure) can be shown inline + // without unmounting this component. setBackendMeetJoining transitions + // status to 'joining' which causes MeetingsPage to swap this composer for + // ActiveMeetingBanner — if we did that before the await, a sync throw + // would land in the catch block of an already-unmounted component and + // the error would never surface. + await joinMeetViaBackendBot({ + meetUrl, + displayName: agentName, + platform, + agentName, + systemPrompt, + mascotId, + riveColors, + correlationId: meetingId, + respondToParticipant: respondTo.trim() || undefined, + wakePhrase, + listenOnly, + }); + // RPC was accepted — transition the UI to the joining / active banner. + dispatch(setBackendMeetJoining({ meetUrl: meetUrl.trim(), meetingId, listenOnly })); + } catch (err) { + const raw = err instanceof Error ? err.message : t('skills.meetingBots.failedToStart'); + const message = isCapacityGateMessage(raw) + ? t('skills.meetingBots.serverOverloaded') + : raw; + log('[composer] join threw: %s', message); + setError(message); + setSubmitting(false); + hasSubmittedRef.current = false; + onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message }); + } + }; + + const selectedLabel = platformLabel(platform, t); + const urlPlaceholder = platformUrlPlaceholder(platform, t); + + return ( +
+ {/* Header */} +
+

+ {t('skills.meetingBots.modalTitle')} +

+

+ {t('skills.meetingBots.modalDesc')} +

+
+ + {/* Platform selector */} +
+ +
+ +
+ {/* Meeting URL */} + + + {/* Your name */} + + + {/* Respond toggle */} + + + {/* Inline error */} + {error && ( +
+ {error} +
+ )} + + {/* Submit */} +
+ +
+
+
+ ); +} diff --git a/app/src/components/meetings/MeetingsPage.tsx b/app/src/components/meetings/MeetingsPage.tsx new file mode 100644 index 0000000000..a95170ce4a --- /dev/null +++ b/app/src/components/meetings/MeetingsPage.tsx @@ -0,0 +1,100 @@ +/** + * Meetings page orchestrator. + * + * Renders the Beta banner, the active-meeting overlay (when a bot is running), + * the meeting composer (when idle), and the recent-calls history below. + * + * Owns the `hasSubmittedRef` success-toast pattern — the ref lives here so the + * toast fires reliably even though the inline composer unmounts when status + * flips to 'active' (same pattern as the original `MeetingBotsCard`). + */ +import debug from 'debug'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { listMeetCalls, type MeetCallRecord } from '../../services/meetCallService'; +import { selectBackendMeetStatus } from '../../store/backendMeetSlice'; +import { useAppSelector } from '../../store/hooks'; +import { useT } from '../../lib/i18n/I18nContext'; +import BetaBanner from '../ui/BetaBanner'; +import { RecentCallsSection } from '../skills/RecentCallsSection'; +import { ActiveMeetingBanner } from './ActiveMeetingBanner'; +import { MeetComposer } from './MeetComposer'; + +const log = debug('meetings:page'); + +type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: string }; + +export interface MeetingsPageProps { + onToast?: (toast: Toast) => void; +} + +export default function MeetingsPage({ onToast }: MeetingsPageProps) { + const { t } = useT(); + const status = useAppSelector(selectBackendMeetStatus); + // Show the live banner while joining or in an active meeting. All other + // states ('idle', 'ended', 'error') render the composer so the user can + // submit a new join or see the inline error from a failed attempt. + const showActive = status === 'joining' || status === 'active'; + + // `hasSubmittedRef` lives in this always-mounted parent so the success toast + // fires reliably. When a join succeeds, `status` flips to 'active' and this + // component swaps `MeetComposer` → `ActiveMeetingBanner`, unmounting the + // composer before any effect inside it could observe 'active'. The composer + // sets this ref on submit; we fire the success toast here. + const hasSubmittedRef = useRef(false); + useEffect(() => { + if (!hasSubmittedRef.current) return; + if (status === 'active') { + hasSubmittedRef.current = false; + log('[page] join succeeded → status=active, firing success toast'); + onToast?.({ + type: 'success', + title: t('skills.meetingBots.joiningTitle'), + message: t('skills.meetingBots.joiningMessage'), + }); + } + }, [status, onToast, t]); + + // ── Recent calls ───────────────────────────────────────────────────────── + const [recentCalls, setRecentCalls] = useState(null); + const [recentError, setRecentError] = useState(null); + + const refreshRecentCalls = useCallback(async () => { + setRecentError(null); + try { + const rows = await listMeetCalls(20); + log('[page] loaded %d recent calls', rows.length); + setRecentCalls(rows); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load recent calls.'; + console.warn('[meetings] listMeetCalls failed:', err); + setRecentError(message); + setRecentCalls([]); + } + }, []); + + useEffect(() => { + void refreshRecentCalls(); + // 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. A couple of short delayed re-fetches reliably reflect it. + const retries = [1200, 3000].map(delay => + setTimeout(() => void refreshRecentCalls(), delay) + ); + return () => retries.forEach(clearTimeout); + }, [refreshRecentCalls]); + + return ( +
+ + + {showActive ? ( + + ) : ( + + )} + + +
+ ); +} diff --git a/app/src/components/meetings/PlatformChips.tsx b/app/src/components/meetings/PlatformChips.tsx new file mode 100644 index 0000000000..be69a68d32 --- /dev/null +++ b/app/src/components/meetings/PlatformChips.tsx @@ -0,0 +1,108 @@ +/** + * Platform selector chip group for the Meetings composer. + * + * Renders Google Meet / Zoom / Teams / Webex as keyboard-navigable radio + * buttons. This is purely a selector: the bot joins via the pasted meeting + * link regardless of any Composio account, so no connection is required or + * implied here. (A connected account only ever helps silently auto-fill the + * display name.) + */ +import debug from 'debug'; +import { useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { MeetingPlatform } from '../../services/meetCallService'; +import { MEETING_PLATFORMS, platformLabel, platformLogoUrl } from './meetingUtils'; + +const log = debug('meetings:platform-chips'); + +export interface PlatformChipsProps { + selected: MeetingPlatform; + onSelect: (platform: MeetingPlatform) => void; + disabled?: boolean; +} + +function PlatformLogo({ + platform, + label, +}: { + platform: MeetingPlatform; + label: string; +}) { + const [failed, setFailed] = useState(false); + + if (failed) { + // Fallback: first letter of the platform label + return ( + + ); + } + + return ( + setFailed(true)} + /> + ); +} + +export function PlatformChips({ selected, onSelect, disabled = false }: PlatformChipsProps) { + const { t } = useT(); + + function handleClick(platform: MeetingPlatform) { + if (disabled) return; + log('[platform-chips] selected platform=%s', platform); + onSelect(platform); + } + + function handleKeyDown(event: React.KeyboardEvent, platform: MeetingPlatform) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleClick(platform); + } + } + + return ( +
+ {MEETING_PLATFORMS.map(platform => { + const isSelected = selected === platform; + const label = platformLabel(platform, t); + + return ( + + ); + })} +
+ ); +} diff --git a/app/src/components/meetings/__tests__/ActiveMeetingBanner.test.tsx b/app/src/components/meetings/__tests__/ActiveMeetingBanner.test.tsx new file mode 100644 index 0000000000..700dfe9af7 --- /dev/null +++ b/app/src/components/meetings/__tests__/ActiveMeetingBanner.test.tsx @@ -0,0 +1,178 @@ +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + setBackendMeetJoined, + setBackendMeetLeft, +} from '../../../store/backendMeetSlice'; +import { renderWithProviders } from '../../../test/test-utils'; +import { ActiveMeetingBanner } from '../ActiveMeetingBanner'; + +const leaveMock = vi.fn(); + +vi.mock('../../../services/meetCallService', async () => { + const actual = await vi.importActual( + '../../../services/meetCallService' + ); + return { + ...actual, + leaveBackendMeetBot: (...args: unknown[]) => leaveMock(...args), + }; +}); + +// RiveMascot is heavy — stub it out +vi.mock('../../../features/human/Mascot', () => ({ + RiveMascot: ({ face }: { face: string }) => ( +
+ ), +})); + +const joiningState = { + backendMeet: { + status: 'joining' as const, + meetUrl: 'https://meet.google.com/abc-defg-hij', + meetingId: 'test-id', + listenOnly: false, + lastReply: null, + lastHarness: null, + transcript: null, + error: null, + }, +}; + +const activeState = { + backendMeet: { + status: 'active' as const, + meetUrl: 'https://meet.google.com/abc-defg-hij', + meetingId: 'test-id', + listenOnly: false, + lastReply: null, + lastHarness: null, + transcript: null, + error: null, + }, +}; + +describe('ActiveMeetingBanner', () => { + beforeEach(() => { + leaveMock.mockReset(); + }); + afterEach(() => cleanup()); + + it('renders joining state with LIVE badge', () => { + renderWithProviders(, { preloadedState: joiningState }); + + expect(screen.getByText(/live/i)).toBeInTheDocument(); + expect(screen.getByText(/joining/i)).toBeInTheDocument(); + }); + + it('renders active state with Leave button', () => { + renderWithProviders(, { preloadedState: activeState }); + + expect(screen.getByRole('button', { name: /leave/i })).toBeInTheDocument(); + }); + + it('shows meeting code in active state', () => { + renderWithProviders(, { preloadedState: activeState }); + + expect(screen.getByText(/abc-defg-hij/i)).toBeInTheDocument(); + }); + + it('calls leaveBackendMeetBot when Leave is clicked', async () => { + leaveMock.mockResolvedValueOnce(undefined); + renderWithProviders(, { preloadedState: activeState }); + + fireEvent.click(screen.getByRole('button', { name: /leave/i })); + + await waitFor(() => { + expect(leaveMock).toHaveBeenCalledWith('user-requested'); + }); + }); + + it('renders ended state with Close button', () => { + const { store } = renderWithProviders( + , + { preloadedState: activeState } + ); + + store.dispatch(setBackendMeetLeft({ reason: 'done' })); + + // After ended, "Leave" disappears and "Close" appears + waitFor(() => { + expect(screen.queryByRole('button', { name: /leave/i })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); + }); + }); + + it('renders error state with Close button', () => { + const errorState = { + backendMeet: { + status: 'error' as const, + meetUrl: 'https://meet.google.com/abc-defg-hij', + meetingId: 'test-id', + listenOnly: false, + lastReply: null, + lastHarness: null, + transcript: null, + error: 'Failed to connect.', + }, + }; + + renderWithProviders(, { preloadedState: errorState }); + + expect(screen.getByText(/failed to join/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); + }); + + it('shows listening status when listenOnly is active', () => { + const listenState = { + backendMeet: { + status: 'active' as const, + meetUrl: 'https://meet.google.com/abc-defg-hij', + meetingId: 'test-id', + listenOnly: true, + lastReply: null, + lastHarness: null, + transcript: null, + error: null, + }, + }; + + renderWithProviders(, { preloadedState: listenState }); + + expect(screen.getByText(/listening/i)).toBeInTheDocument(); + }); + + it('calls onToast with error when leave fails', async () => { + leaveMock.mockRejectedValueOnce(new Error('Network error')); + const onToast = vi.fn(); + + renderWithProviders(, { + preloadedState: activeState, + }); + + fireEvent.click(screen.getByRole('button', { name: /leave/i })); + + await waitFor(() => { + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }) + ); + }); + }); + + it('transitions mascot face based on state', () => { + const { store } = renderWithProviders( + , + { preloadedState: joiningState } + ); + + // Joining → thinking face + expect(screen.getByTestId('rive-mascot')).toHaveAttribute('data-face', 'thinking'); + + // Active → idle (no replies yet) + store.dispatch(setBackendMeetJoined({ meetUrl: 'https://meet.google.com/abc-defg-hij' })); + waitFor(() => { + expect(screen.getByTestId('rive-mascot')).toHaveAttribute('data-face', 'idle'); + }); + }); +}); diff --git a/app/src/components/meetings/__tests__/MeetComposer.test.tsx b/app/src/components/meetings/__tests__/MeetComposer.test.tsx new file mode 100644 index 0000000000..2f7a6ee53e --- /dev/null +++ b/app/src/components/meetings/__tests__/MeetComposer.test.tsx @@ -0,0 +1,291 @@ +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createRef } from 'react'; + +import { setBackendMeetError } from '../../../store/backendMeetSlice'; +import { renderWithProviders } from '../../../test/test-utils'; +import { MeetComposer } from '../MeetComposer'; + +type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: string }; + +const joinMock = vi.fn(); +const listMock = vi.fn(); + +vi.mock('../../../services/meetCallService', async () => { + const actual = await vi.importActual( + '../../../services/meetCallService' + ); + return { + ...actual, + joinMeetViaBackendBot: (...args: unknown[]) => joinMock(...args), + listMeetCalls: (...args: unknown[]) => listMock(...args), + }; +}); + +const mockConnectionByToolkit = vi.fn(() => new Map()); + +vi.mock('../../../lib/composio/hooks', () => ({ + useComposioIntegrations: () => ({ + toolkits: [], + connectionByToolkit: mockConnectionByToolkit(), + connectionsByToolkit: new Map(), + catalogByToolkit: new Map(), + loading: false, + error: null, + refresh: vi.fn(), + }), +})); + +function renderComposer(props: { onToast?: ReturnType } = {}) { + const hasSubmittedRef = createRef() as React.MutableRefObject; + hasSubmittedRef.current = false; + // Cast the vitest mock to the expected callback type — vi.fn() returns a + // broad Mock type that TypeScript doesn't automatically narrow to the + // specific callback signature the component expects. + const onToast = props.onToast as unknown as ((toast: Toast) => void) | undefined; + const result = renderWithProviders( + + ); + return { ...result, hasSubmittedRef }; +} + +describe('MeetComposer', () => { + beforeEach(() => { + joinMock.mockReset(); + listMock.mockReset(); + listMock.mockResolvedValue([]); + mockConnectionByToolkit.mockReturnValue(new Map()); + }); + afterEach(() => cleanup()); + + it('renders the join form with meeting link and name inputs', () => { + renderComposer(); + expect(screen.getByLabelText(/meeting link/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/your name in this meeting/i)).toBeInTheDocument(); + }); + + it('defaults to Google Meet and shows the correct submit label', () => { + renderComposer(); + // Submit button label starts with "Send to Google Meet" + expect(screen.getByRole('button', { name: /send to google meet/i })).toBeInTheDocument(); + }); + + it('updates submit label and URL placeholder when switching platforms', async () => { + renderComposer(); + + // Switch to Zoom + const zoomChip = screen.getByRole('radio', { name: /zoom/i }); + fireEvent.click(zoomChip); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /send to zoom/i })).toBeInTheDocument(); + }); + + const urlInput = screen.getByLabelText(/meeting link/i); + expect(urlInput).toHaveAttribute('placeholder', 'zoom.us/j/...'); + }); + + it('updates submit label and URL placeholder for Webex', async () => { + renderComposer(); + + const webexChip = screen.getByRole('radio', { name: /webex/i }); + fireEvent.click(webexChip); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /send to webex/i })).toBeInTheDocument(); + }); + + const urlInput = screen.getByLabelText(/meeting link/i); + expect(urlInput).toHaveAttribute('placeholder', 'webex.com/meet/...'); + }); + + it('prefills the name field from a connected Composio account', async () => { + mockConnectionByToolkit.mockReturnValue( + new Map([ + ['googlemeet', { id: 'c1', toolkit: 'googlemeet', status: 'ACTIVE', accountEmail: 'alice.smith@gmail.com' }], + ]) + ); + + renderComposer(); + + await waitFor(() => { + expect(screen.getByLabelText(/your name in this meeting/i)).toHaveValue('Alice Smith'); + }); + }); + + it('re-derives name from platform-specific account when platform changes (untouched)', async () => { + mockConnectionByToolkit.mockReturnValue( + new Map([ + ['googlemeet', { id: 'c1', toolkit: 'googlemeet', status: 'ACTIVE', accountEmail: 'alice.gmeet@company.com' }], + ['zoom', { id: 'c2', toolkit: 'zoom', status: 'ACTIVE', accountEmail: 'alice.zoom@company.com' }], + ]) + ); + + renderComposer(); + + // Initially fills from gmeet + await waitFor(() => { + expect(screen.getByLabelText(/your name in this meeting/i)).toHaveValue('Alice Gmeet'); + }); + + // Switch to zoom — should re-derive from zoom account + fireEvent.click(screen.getByRole('radio', { name: /zoom/i })); + + await waitFor(() => { + expect(screen.getByLabelText(/your name in this meeting/i)).toHaveValue('Alice Zoom'); + }); + }); + + it('never overwrites a manually typed name when platform changes', async () => { + renderComposer(); + + const nameInput = screen.getByLabelText(/your name in this meeting/i); + + // User types manually + fireEvent.change(nameInput, { target: { value: 'Custom Name' } }); + + // Switch platform + fireEvent.click(screen.getByRole('radio', { name: /zoom/i })); + + // Manually typed value must not be overwritten + expect(nameInput).toHaveValue('Custom Name'); + }); + + it('submits with the SELECTED platform (not hardcoded gmeet)', async () => { + joinMock.mockResolvedValueOnce({ meetUrl: 'https://zoom.us/j/123', platform: 'zoom' }); + + renderComposer(); + + // Switch to Zoom + fireEvent.click(screen.getByRole('radio', { name: /zoom/i })); + + fireEvent.change(screen.getByLabelText(/meeting link/i), { + target: { value: 'https://zoom.us/j/123' }, + }); + fireEvent.change(screen.getByLabelText(/your name in this meeting/i), { + target: { value: 'Alice' }, + }); + const form = document.querySelector('form')!; + fireEvent.submit(form); + + await waitFor(() => { + expect(joinMock).toHaveBeenCalledWith( + expect.objectContaining({ + meetUrl: 'https://zoom.us/j/123', + platform: 'zoom', + respondToParticipant: 'Alice', + listenOnly: false, + }) + ); + }); + }); + + it('submits with gmeet by default (no platform switch)', async () => { + joinMock.mockResolvedValueOnce({ + meetUrl: 'https://meet.google.com/abc-defg-hij', + platform: 'gmeet', + }); + + renderComposer(); + + fireEvent.change(screen.getByLabelText(/meeting link/i), { + target: { value: 'https://meet.google.com/abc-defg-hij' }, + }); + fireEvent.change(screen.getByLabelText(/your name in this meeting/i), { + target: { value: 'Alice' }, + }); + const form = document.querySelector('form')!; + fireEvent.submit(form); + + await waitFor(() => { + expect(joinMock).toHaveBeenCalledWith( + expect.objectContaining({ + platform: 'gmeet', + }) + ); + }); + }); + + it('shows an inline error when the backend returns an error via Redux', async () => { + const onToast = vi.fn(); + const { hasSubmittedRef, store } = renderComposer({ onToast }); + + // Simulate submission having been started + hasSubmittedRef.current = true; + + // Backend fires an error event + store.dispatch(setBackendMeetError({ error: 'Bot failed to join.' })); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('Bot failed to join.'); + }); + + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }) + ); + }); + + it('shows the capacity-gate message when the server is overloaded', async () => { + const onToast = vi.fn(); + const { hasSubmittedRef, store } = renderComposer({ onToast }); + + hasSubmittedRef.current = true; + + store.dispatch( + setBackendMeetError({ error: 'Mascot streaming capacity is exhausted. Please try again later.' }) + ); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent( + /heavy load/i + ); + }); + }); + + it('shows an inline error when joinMeetViaBackendBot throws synchronously', async () => { + joinMock.mockRejectedValueOnce(new Error('Network error.')); + const onToast = vi.fn(); + const { hasSubmittedRef } = renderComposer({ onToast }); + + fireEvent.change(screen.getByLabelText(/meeting link/i), { + target: { value: 'https://meet.google.com/abc-defg-hij' }, + }); + fireEvent.change(screen.getByLabelText(/your name in this meeting/i), { + target: { value: 'Alice' }, + }); + const form = document.querySelector('form')!; + fireEvent.submit(form); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('Network error.'); + }); + + // hasSubmittedRef should be reset so a second attempt works + expect(hasSubmittedRef.current).toBe(false); + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }) + ); + }); + + it('disables submit when meetUrl or name is empty', () => { + renderComposer(); + + const submitBtn = screen.getByRole('button', { name: /send to google meet/i }); + + // Both empty — disabled + expect(submitBtn).toBeDisabled(); + + // Fill URL only + fireEvent.change(screen.getByLabelText(/meeting link/i), { + target: { value: 'https://meet.google.com/abc' }, + }); + expect(submitBtn).toBeDisabled(); + + // Fill name too — enabled + fireEvent.change(screen.getByLabelText(/your name in this meeting/i), { + target: { value: 'Alice' }, + }); + expect(submitBtn).not.toBeDisabled(); + }); +}); diff --git a/app/src/components/meetings/__tests__/PlatformChips.test.tsx b/app/src/components/meetings/__tests__/PlatformChips.test.tsx new file mode 100644 index 0000000000..b7e107568b --- /dev/null +++ b/app/src/components/meetings/__tests__/PlatformChips.test.tsx @@ -0,0 +1,72 @@ +import { cleanup, fireEvent, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import { PlatformChips } from '../PlatformChips'; + +describe('PlatformChips', () => { + afterEach(() => cleanup()); + + it('renders all four platform chips', () => { + const onSelect = vi.fn(); + renderWithProviders(); + + expect(screen.getByRole('radio', { name: /google meet/i })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /zoom/i })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /microsoft teams/i })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /webex/i })).toBeInTheDocument(); + }); + + it('marks the selected platform as checked', () => { + renderWithProviders(); + + expect(screen.getByRole('radio', { name: /zoom/i })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('radio', { name: /google meet/i })).toHaveAttribute( + 'aria-checked', + 'false' + ); + }); + + it('calls onSelect with the clicked platform', () => { + const onSelect = vi.fn(); + renderWithProviders(); + + fireEvent.click(screen.getByRole('radio', { name: /zoom/i })); + expect(onSelect).toHaveBeenCalledWith('zoom'); + }); + + it('does not call onSelect when disabled', () => { + const onSelect = vi.fn(); + renderWithProviders(); + + const zoomChip = screen.getByRole('radio', { name: /zoom/i }); + expect(zoomChip).toBeDisabled(); + fireEvent.click(zoomChip); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('is a pure selector — never renders a Connect badge', () => { + renderWithProviders(); + + // The chips do not gate on or imply any account connection. + expect(screen.queryByText(/^connect$/i)).not.toBeInTheDocument(); + }); + + it('is keyboard accessible via Enter key', () => { + const onSelect = vi.fn(); + renderWithProviders(); + + const teamsChip = screen.getByRole('radio', { name: /microsoft teams/i }); + fireEvent.keyDown(teamsChip, { key: 'Enter' }); + expect(onSelect).toHaveBeenCalledWith('teams'); + }); + + it('is keyboard accessible via Space key', () => { + const onSelect = vi.fn(); + renderWithProviders(); + + const webexChip = screen.getByRole('radio', { name: /webex/i }); + fireEvent.keyDown(webexChip, { key: ' ' }); + expect(onSelect).toHaveBeenCalledWith('webex'); + }); +}); diff --git a/app/src/components/meetings/__tests__/meetingUtils.test.ts b/app/src/components/meetings/__tests__/meetingUtils.test.ts new file mode 100644 index 0000000000..96af6708e9 --- /dev/null +++ b/app/src/components/meetings/__tests__/meetingUtils.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; + +import type { ComposioConnection } from '../../../lib/composio/types'; +import { + deriveDisplayNameFromEmail, + MEETING_PLATFORMS, + platformLabel, + platformPrimaryToolkit, + platformUrlPlaceholder, + resolveMeetingDisplayName, +} from '../meetingUtils'; + +// --------------------------------------------------------------------------- +// deriveDisplayNameFromEmail +// --------------------------------------------------------------------------- + +describe('deriveDisplayNameFromEmail', () => { + it('converts first.last to First Last', () => { + expect(deriveDisplayNameFromEmail('first.last@example.com')).toBe('First Last'); + }); + + it('handles underscore separator', () => { + expect(deriveDisplayNameFromEmail('alice_smith@example.com')).toBe('Alice Smith'); + }); + + it('handles hyphen separator', () => { + expect(deriveDisplayNameFromEmail('alice-smith@example.com')).toBe('Alice Smith'); + }); + + it('handles single-word local part', () => { + expect(deriveDisplayNameFromEmail('alice@example.com')).toBe('Alice'); + }); + + it('returns empty string for undefined', () => { + expect(deriveDisplayNameFromEmail(undefined)).toBe(''); + }); + + it('returns empty string for empty email', () => { + expect(deriveDisplayNameFromEmail('')).toBe(''); + }); + + it('handles email with no local part', () => { + expect(deriveDisplayNameFromEmail('@example.com')).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// resolveMeetingDisplayName — per-platform priority +// --------------------------------------------------------------------------- + +function makeConn(email: string): ComposioConnection { + return { + id: `conn-${email}`, + toolkit: 'unknown', + status: 'ACTIVE', + accountEmail: email, + createdAt: new Date().toISOString(), + }; +} + +describe('resolveMeetingDisplayName', () => { + it('prefers the platform-native toolkit for gmeet over gmail', () => { + const map = new Map([ + ['googlemeet', makeConn('alice.native@google.com')], + ['gmail', makeConn('alice.mail@gmail.com')], + ]); + expect(resolveMeetingDisplayName('gmeet', map)).toBe('Alice Native'); + }); + + it('falls through to gmail for gmeet when platform toolkit missing', () => { + const map = new Map([['gmail', makeConn('alice.mail@gmail.com')]]); + expect(resolveMeetingDisplayName('gmeet', map)).toBe('Alice Mail'); + }); + + it('prefers zoom over gmail', () => { + const map = new Map([ + ['zoom', makeConn('bob.zoom@company.com')], + ['gmail', makeConn('bob.gmail@gmail.com')], + ]); + expect(resolveMeetingDisplayName('zoom', map)).toBe('Bob Zoom'); + }); + + it('prefers microsoft_teams over outlook and gmail for teams', () => { + const map = new Map([ + ['microsoft_teams', makeConn('carol.teams@company.com')], + ['outlook', makeConn('carol.outlook@company.com')], + ['gmail', makeConn('carol.gmail@gmail.com')], + ]); + expect(resolveMeetingDisplayName('teams', map)).toBe('Carol Teams'); + }); + + it('falls through to outlook for teams when ms_teams missing', () => { + const map = new Map([ + ['outlook', makeConn('carol.outlook@company.com')], + ['gmail', makeConn('carol.gmail@gmail.com')], + ]); + expect(resolveMeetingDisplayName('teams', map)).toBe('Carol Outlook'); + }); + + it('returns blank when no toolkits are connected', () => { + expect(resolveMeetingDisplayName('gmeet', new Map())).toBe(''); + }); + + it('skips entries whose email yields an empty name', () => { + const map = new Map([ + ['googlemeet', makeConn('@no-local-part.com')], + ['gmail', makeConn('alice.gmail@gmail.com')], + ]); + expect(resolveMeetingDisplayName('gmeet', map)).toBe('Alice Gmail'); + }); + + it('prefers webex over gmail for webex platform', () => { + const map = new Map([ + ['webex', makeConn('dave.webex@cisco.com')], + ['gmail', makeConn('dave.gmail@gmail.com')], + ]); + expect(resolveMeetingDisplayName('webex', map)).toBe('Dave Webex'); + }); +}); + +// --------------------------------------------------------------------------- +// MEETING_PLATFORMS +// --------------------------------------------------------------------------- + +describe('MEETING_PLATFORMS', () => { + it('includes all four platforms', () => { + expect(MEETING_PLATFORMS).toEqual(expect.arrayContaining(['gmeet', 'zoom', 'teams', 'webex'])); + expect(MEETING_PLATFORMS).toHaveLength(4); + }); +}); + +// --------------------------------------------------------------------------- +// platformPrimaryToolkit +// --------------------------------------------------------------------------- + +describe('platformPrimaryToolkit', () => { + it('returns googlemeet for gmeet', () => { + expect(platformPrimaryToolkit('gmeet')).toBe('googlemeet'); + }); + + it('returns zoom for zoom', () => { + expect(platformPrimaryToolkit('zoom')).toBe('zoom'); + }); + + it('returns microsoft_teams for teams', () => { + expect(platformPrimaryToolkit('teams')).toBe('microsoft_teams'); + }); + + it('returns webex for webex', () => { + expect(platformPrimaryToolkit('webex')).toBe('webex'); + }); +}); + +// --------------------------------------------------------------------------- +// platformLabel / platformUrlPlaceholder +// --------------------------------------------------------------------------- + +describe('platformLabel', () => { + const t = (key: string) => { + const translations: Record = { + 'skills.meetingBots.platforms.gmeet': 'Google Meet', + 'skills.meetingBots.platforms.zoom': 'Zoom', + 'skills.meetingBots.platforms.teams': 'Microsoft Teams', + 'skills.meetingBots.platforms.webex': 'Webex', + }; + return translations[key] ?? key; + }; + + it('returns Google Meet for gmeet', () => { + expect(platformLabel('gmeet', t)).toBe('Google Meet'); + }); + + it('returns Webex for webex', () => { + expect(platformLabel('webex', t)).toBe('Webex'); + }); +}); + +describe('platformUrlPlaceholder', () => { + const t = (key: string) => { + const translations: Record = { + 'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij', + 'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...', + 'skills.meetingBots.platformHints.teams': 'teams.microsoft.com/...', + 'skills.meetingBots.platformHints.webex': 'webex.com/meet/...', + }; + return translations[key] ?? key; + }; + + it('returns the gmeet URL hint', () => { + expect(platformUrlPlaceholder('gmeet', t)).toBe('meet.google.com/abc-defg-hij'); + }); + + it('returns the webex URL hint', () => { + expect(platformUrlPlaceholder('webex', t)).toBe('webex.com/meet/...'); + }); +}); diff --git a/app/src/components/meetings/meetingUtils.ts b/app/src/components/meetings/meetingUtils.ts new file mode 100644 index 0000000000..953fb18688 --- /dev/null +++ b/app/src/components/meetings/meetingUtils.ts @@ -0,0 +1,112 @@ +/** + * Shared utilities for the Meetings composer. + * + * Centralises the platform metadata, display-name resolution helpers that were + * previously embedded inside MeetingBotsCard so they can be unit-tested in + * isolation and shared across the split composer components. + */ +import type { ComposioConnection } from '../../lib/composio/types'; +import type { MeetingPlatform } from '../../services/meetCallService'; + +// --------------------------------------------------------------------------- +// Platform registry +// --------------------------------------------------------------------------- + +/** Ordered list of all supported meeting platforms. */ +export const MEETING_PLATFORMS: MeetingPlatform[] = ['gmeet', 'zoom', 'teams', 'webex']; + +/** + * Return the Composio toolkit slug whose connection is the primary identifier + * for a given platform — used to decide whether a platform chip shows as + * "connected" or "needs connecting". + */ +export function platformPrimaryToolkit(platform: MeetingPlatform): string { + switch (platform) { + case 'gmeet': + return 'googlemeet'; + case 'zoom': + return 'zoom'; + case 'teams': + return 'microsoft_teams'; + case 'webex': + return 'webex'; + } +} + +/** + * Composio logo CDN URL for a given toolkit slug (same source as + * `composioLogoUrl` in `toolkitMeta.tsx` — kept local to avoid a cross- + * component import). + */ +export function platformLogoUrl(platform: MeetingPlatform): string { + return `https://logos.composio.dev/api/${platformPrimaryToolkit(platform)}`; +} + +/** + * Return the localised display label for a meeting platform. + * Delegates to `skills.meetingBots.platforms.*` i18n keys. + */ +export function platformLabel(platform: MeetingPlatform, t: (key: string) => string): string { + return t(`skills.meetingBots.platforms.${platform}`); +} + +/** + * Return the URL placeholder string for the meeting-link input. + * Delegates to `skills.meetingBots.platformHints.*` i18n keys. + */ +export function platformUrlPlaceholder( + platform: MeetingPlatform, + t: (key: string) => string +): string { + return t(`skills.meetingBots.platformHints.${platform}`); +} + +// --------------------------------------------------------------------------- +// Display-name resolution +// --------------------------------------------------------------------------- + +/** + * Composio only hands back a connected account's email — there is no separate + * display-name field on `ComposioConnection`. A meeting display name is almost + * always "First Last" derived from that account, so we best-effort humanize the + * email's local part (`first.last` → `First Last`). + */ +export function deriveDisplayNameFromEmail(email: string | undefined): string { + const localPart = email?.split('@')[0]?.trim(); + if (!localPart) return ''; + return localPart + .split(/[._-]+/) + .filter(Boolean) + .map(token => token.charAt(0).toUpperCase() + token.slice(1)) + .join(' '); +} + +/** + * Per-platform priority of Composio toolkits to source the user's in-call + * display name from: the platform's own connected account first, then the + * mailbox, then blank. Slugs are canonical Composio slugs (see + * `canonicalizeComposioToolkitSlug`). + */ +export const NAME_SOURCE_TOOLKITS: Record = { + gmeet: ['googlemeet', 'gmail'], + zoom: ['zoom', 'gmail'], + teams: ['microsoft_teams', 'outlook', 'gmail'], + webex: ['webex', 'gmail'], +}; + +/** + * Resolve a default "Your name in this meeting" for the given platform: walk + * that platform's toolkit priority (own account → mail → blank) and return the + * first connected account whose email yields a usable name; blank if none are + * connected. The single entry point the form calls. + */ +export function resolveMeetingDisplayName( + platform: MeetingPlatform, + connectionByToolkit: Map +): string { + for (const slug of NAME_SOURCE_TOOLKITS[platform]) { + const name = deriveDisplayNameFromEmail(connectionByToolkit.get(slug)?.accountEmail); + if (name) return name; + } + return ''; +} diff --git a/app/src/components/skills/MeetingBotsCard.tsx b/app/src/components/skills/MeetingBotsCard.tsx index 90dc902494..c0e48a68f2 100644 --- a/app/src/components/skills/MeetingBotsCard.tsx +++ b/app/src/components/skills/MeetingBotsCard.tsx @@ -1,473 +1,10 @@ -import debug from 'debug'; -import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -import { type MascotFace, RiveMascot } from '../../features/human/Mascot'; -import { useComposioIntegrations } from '../../lib/composio/hooks'; -import type { ComposioConnection } from '../../lib/composio/types'; -import { useT } from '../../lib/i18n/I18nContext'; -import { - isCapacityGateMessage, - joinMeetViaBackendBot, - leaveBackendMeetBot, - listMeetCalls, - type MeetCallRecord, - type MeetingPlatform, -} from '../../services/meetCallService'; -import { - type BackendMeetHarnessEvent, - type BackendMeetReplyEvent, - type BackendMeetStatus, - resetBackendMeet, - selectBackendMeetError, - selectBackendMeetLastHarness, - selectBackendMeetLastReply, - selectBackendMeetListenOnly, - selectBackendMeetStatus, - selectBackendMeetUrl, - setBackendMeetJoining, -} from '../../store/backendMeetSlice'; -import { useAppDispatch, useAppSelector } from '../../store/hooks'; -import { - selectCustomPrimaryColor, - selectCustomSecondaryColor, - selectMascotColor, - selectSelectedMascotId, -} from '../../store/mascotSlice'; -import { selectPersonaDescription, selectPersonaDisplayName } from '../../store/personaSlice'; -import Button from '../ui/Button'; -import { RecentCallsSection } from './RecentCallsSection'; - -type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: string }; - -const log = debug('meeting-bots'); - -// Composio only hands back a connected account's email — there is no separate -// display-name field on `ComposioConnection`. A meeting display name is almost -// always "First Last" derived from that account, so we best-effort humanize the -// email's local part (`first.last` → `First Last`). -function deriveDisplayNameFromEmail(email: string | undefined): string { - const localPart = email?.split('@')[0]?.trim(); - if (!localPart) return ''; - return localPart - .split(/[._-]+/) - .filter(Boolean) - .map(token => token.charAt(0).toUpperCase() + token.slice(1)) - .join(' '); -} - -// Per-platform priority of Composio toolkits to source the user's in-call -// display name from: the platform's own connected account first, then the -// mailbox, then blank. Slugs are canonical Composio slugs (see -// `canonicalizeComposioToolkitSlug`). -const NAME_SOURCE_TOOLKITS: Record = { - gmeet: ['googlemeet', 'gmail'], - zoom: ['zoom', 'gmail'], - teams: ['microsoft_teams', 'outlook', 'gmail'], - webex: ['webex', 'gmail'], -}; - -// Resolve a default "Your name in this meeting" for the given platform: walk -// that platform's toolkit priority (own account → mail → blank) and return the -// first connected account whose email yields a usable name; blank if none are -// connected. The single entry point the form calls. -function resolveMeetingDisplayName( - platform: MeetingPlatform, - connectionByToolkit: Map -): string { - for (const slug of NAME_SOURCE_TOOLKITS[platform]) { - const name = deriveDisplayNameFromEmail(connectionByToolkit.get(slug)?.accountEmail); - if (name) return name; - } - return ''; -} - -interface Props { - onToast?: (toast: Toast) => void; -} - -interface MeetingBotsInlineProps extends Props { - hasSubmittedRef: RefObject; -} - -export default function MeetingBotsCard({ onToast }: Props) { - const { t } = useT(); - const status = useAppSelector(selectBackendMeetStatus); - const showActive = status === 'active'; - - // `hasSubmittedRef` lives in this always-mounted parent so the success toast - // fires reliably. When a join succeeds, `status` flips to 'active' and this - // component swaps `MeetingBotsInline` → `ActiveMeetingView`, unmounting the - // inline form before any effect inside it could observe 'active' (#3611 - // flattened these into a mutually-exclusive ternary). The inline form sets - // this ref on submit; we fire the success toast here. The error path stays in - // the inline form, which remains mounted during the 'error' state. - const hasSubmittedRef = useRef(false); - useEffect(() => { - if (!hasSubmittedRef.current) return; - if (status === 'active') { - hasSubmittedRef.current = false; - onToast?.({ - type: 'success', - title: t('skills.meetingBots.joiningTitle'), - message: t('skills.meetingBots.joiningMessage'), - }); - } - }, [status, onToast, t]); - - return showActive ? ( - - ) : ( - - ); -} - -function faceFromMeetState( - status: BackendMeetStatus, - lastReply: BackendMeetReplyEvent | null, - lastHarness: BackendMeetHarnessEvent | null -): MascotFace { - if (status === 'joining') return 'thinking'; - if (status === 'error') return 'concerned'; - if (status === 'ended') return 'happy'; - if (lastHarness) return 'thinking'; - if (lastReply) { - const e = (lastReply.emotion ?? '').toLowerCase(); - if (e.includes('happy') || e.includes('pleased') || e.includes('joy') || e.includes('excit')) - return 'happy'; - if (e.includes('celebrat') || e.includes('proud')) return 'celebrating'; - if (e.includes('concern') || e.includes('worried') || e.includes('unsure')) return 'concerned'; - if (e.includes('curious') || e.includes('interest')) return 'curious'; - } - return 'idle'; -} - -function ActiveMeetingView({ onToast }: Props) { - const { t } = useT(); - const dispatch = useAppDispatch(); - const status = useAppSelector(selectBackendMeetStatus); - const meetUrl = useAppSelector(selectBackendMeetUrl); - const listenOnly = useAppSelector(selectBackendMeetListenOnly); - const lastReply = useAppSelector(selectBackendMeetLastReply); - const lastHarness = useAppSelector(selectBackendMeetLastHarness); - const face = faceFromMeetState(status, lastReply, lastHarness); - const meetingCode = useMemo(() => { - if (!meetUrl) return ''; - try { - const tail = new URL(meetUrl).pathname.replace(/^\/+/, ''); - return tail || meetUrl; - } catch { - return meetUrl; - } - }, [meetUrl]); - - const [leaving, setLeaving] = useState(false); - - const handleLeave = async () => { - if (leaving) return; - setLeaving(true); - try { - await leaveBackendMeetBot('user-requested'); - } catch (err) { - onToast?.({ - type: 'error', - title: t('skills.meetingBots.couldNotStartTitle'), - message: String(err), - }); - } finally { - setLeaving(false); - } - }; - - const statusText = (() => { - const base: Record = { - joining: t('skills.meetingBots.liveStatusJoining'), - active: listenOnly - ? t('skills.meetingBots.liveStatusListening') - : t('skills.meetingBots.liveStatusActive'), - ended: t('skills.meetingBots.liveStatusEnded'), - error: t('skills.meetingBots.liveStatusError'), - idle: '', - }; - return base[status] ?? ''; - })(); - - const canLeave = status === 'active' || status === 'joining'; - const isDone = status === 'ended' || status === 'error'; - - return ( -
-
- - - {canLeave && ( - - )} - {isDone && ( - - )} -
-
-
- -
-
-
- {t('skills.meetingBots.liveTitle')} -
-
{statusText}
- {meetingCode && ( -
- {meetingCode} -
- )} - {lastReply?.reply && ( -
- “{lastReply.reply}” -
- )} -
-
-
- ); -} - -function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps) { - const { t } = useT(); - const dispatch = useAppDispatch(); - const [meetUrl, setMeetUrl] = useState(''); - // The participant the bot answers to (authorized speaker). Wired to the - // backend join payload as `respondToParticipant` → `respondTo`, which the - // meeting stream uses to gate in-call requests to this speaker only. - const [respondTo, setRespondTo] = useState(''); - // Once the user types in the name field we stop auto-prefilling it, so a - // late-arriving Composio fetch (it polls) can never clobber manual input. - const respondToTouchedRef = useRef(false); - // Active (respond when addressed) vs listen-only (transcribe only). Defaults - // to active; the bot still only replies after being addressed by the wake - // phrase. Forwarded to the backend as `listenOnly` and mirrored into the - // meet slice so the active view shows the right status. - const [listenOnly, setListenOnly] = useState(false); - const personaDisplayName = useAppSelector(selectPersonaDisplayName); - const personaDescription = useAppSelector(selectPersonaDescription); - const selectedMascotId = useAppSelector(selectSelectedMascotId); - const mascotColor = useAppSelector(selectMascotColor); - const customPrimaryColor = useAppSelector(selectCustomPrimaryColor); - const customSecondaryColor = useAppSelector(selectCustomSecondaryColor); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - const meetStatus = useAppSelector(selectBackendMeetStatus); - const meetError = useAppSelector(selectBackendMeetError); - const [recentCalls, setRecentCalls] = useState(null); - const [recentError, setRecentError] = useState(null); - // The meeting platform this form sends to (only Google Meet is wired up for - // now). Drives both the join payload and which connected accounts we source - // the default display name from. - const platform: MeetingPlatform = 'gmeet'; - // Prefill "Your name in this meeting" from the connected account that best - // matches this platform (calendar → mail → platform-native). - const { connectionByToolkit } = useComposioIntegrations(); - const resolvedDisplayName = resolveMeetingDisplayName(platform, connectionByToolkit); - - useEffect(() => { - if (respondToTouchedRef.current || !resolvedDisplayName) return; - setRespondTo(prev => (prev.trim() ? prev : resolvedDisplayName)); - }, [resolvedDisplayName]); - - const refreshRecentCalls = useCallback(async () => { - setRecentError(null); - try { - const rows = await listMeetCalls(20); - setRecentCalls(rows); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to load recent calls.'; - console.warn('[meeting-bots] listMeetCalls failed:', err); - setRecentError(message); - setRecentCalls([]); - } - }, []); - - 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'); - const agentName = personaDisplayName.trim() || 'Tiny'; - const systemPrompt = personaDescription.trim() || undefined; - const mascotId = selectedMascotId ?? (mascotColor === 'custom' ? undefined : mascotColor); - const riveColors = - mascotColor === 'custom' - ? { primaryColor: customPrimaryColor, secondaryColor: customSecondaryColor } - : undefined; - const wakePhrase = listenOnly ? undefined : `Hey ${agentName}`; - - // Success ('active') is handled by the parent MeetingBotsCard, which stays - // mounted across the inline→active view swap. The error path lives here - // because the inline form remains mounted during the 'error' state and needs - // to surface the failure inline (setError/setSubmitting) alongside the toast. - useEffect(() => { - if (!hasSubmittedRef.current) return; - if (meetStatus === 'error') { - hasSubmittedRef.current = false; - const raw = meetError?.trim() || t('skills.meetingBots.failedToStart'); - // A capacity-gate error carries the backend's terse "…try again later." - // wording; show the tailored, actionable (and localized) copy instead (#4151). - const message = isCapacityGateMessage(raw) - ? t('skills.meetingBots.serverOverloaded') - : raw; - setError(message); - setSubmitting(false); - onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message }); - } - }, [meetStatus, meetError, onToast, t, hasSubmittedRef]); - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - setError(null); - setSubmitting(true); - hasSubmittedRef.current = true; - try { - const meetingId = crypto.randomUUID(); - log('join submit %o', { - active: !listenOnly, - agentChars: agentName.length, - ownerChars: respondTo.trim().length, - wakeChars: wakePhrase?.length ?? 0, - correlationId: meetingId, - }); - dispatch(setBackendMeetJoining({ meetUrl: meetUrl.trim(), meetingId, listenOnly })); - await joinMeetViaBackendBot({ - meetUrl, - displayName: agentName, - platform, - agentName, - systemPrompt, - mascotId, - riveColors, - correlationId: meetingId, - respondToParticipant: respondTo.trim() || undefined, - wakePhrase, - listenOnly, - }); - } catch (err) { - const raw = err instanceof Error ? err.message : t('skills.meetingBots.failedToStart'); - const message = isCapacityGateMessage(raw) - ? t('skills.meetingBots.serverOverloaded') - : raw; - setError(message); - setSubmitting(false); - hasSubmittedRef.current = false; - onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message }); - } - }; - - return ( -
-
-

- {t('skills.meetingBots.modalTitle')} -

-

- {t('skills.meetingBots.modalDesc')} -

-
- -
- - - - - - - {error && ( -
- {error} -
- )} - -
- -
-
- - -
- ); -} +/** + * Backward-compatible re-export of the redesigned Meetings page. + * + * Skills.tsx now imports and renders `MeetingsPage` directly; this shim + * keeps existing test mocks and any other importers working without + * requiring a search-and-replace across the codebase. + * + * New code should import from `components/meetings/MeetingsPage` directly. + */ +export { default } from '../meetings/MeetingsPage'; diff --git a/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx b/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx index b63d8232e0..87ce6ff88d 100644 --- a/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx +++ b/app/src/components/skills/__tests__/MeetingBotsCard.test.tsx @@ -259,14 +259,17 @@ describe('MeetingBotsCard — ActiveMeetingView', () => { expect(screen.getByText(/hi there/i)).toBeInTheDocument(); }); - it('shows the inline form (not ActiveMeetingView) while status is joining', () => { + it('shows the active banner (not the inline form) while status is joining', () => { + // The redesigned composer shows the live banner for 'joining' (not the inline + // form). The banner shows the LIVE badge and "Joining…" status text. The + // composer unmounts so there is no meeting-link input while joining. renderWithProviders(, { preloadedState: { backendMeet: { ...activeMeetState.backendMeet, status: 'joining' as const }, }, }); - expect(screen.getByLabelText(/meeting link/i)).toBeInTheDocument(); - expect(screen.queryByText(/live in meeting/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/meeting link/i)).not.toBeInTheDocument(); + expect(screen.getByText(/joining/i)).toBeInTheDocument(); }); it('shows the inline form (not ActiveMeetingView) when status is ended', () => { diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 2640f0a3dd..4909f7d043 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -5042,9 +5042,11 @@ const messages: TranslationMap = { 'skills.meetingBots.platformComingSoon': '{label} الدعم قريبًا.', 'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij', 'skills.meetingBots.platformHints.teams': 'Teams.microsoft.com/...', + 'skills.meetingBots.platformHints.webex': 'webex.com/meet/...', 'skills.meetingBots.platformHints.zoom': 'Zoom.us/j/...', 'skills.meetingBots.platforms.gmeet': 'Google لقاء', 'skills.meetingBots.platforms.teams': 'Microsoft Teams', + 'skills.meetingBots.platforms.webex': 'Webex', 'skills.meetingBots.platforms.zoom': 'تكبير', 'skills.meetingBots.sendTo': 'إرسال إلى', 'skills.meetingBots.serverOverloaded': diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 8308d21fcb..1082d19dde 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -5146,9 +5146,11 @@ const messages: TranslationMap = { 'skills.meetingBots.platformComingSoon': '{label} সমর্থন শীঘ্রই আসছে।', 'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij', 'skills.meetingBots.platformHints.teams': 'teams.microsoft.com/...', + 'skills.meetingBots.platformHints.webex': 'webex.com/meet/...', 'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...', 'skills.meetingBots.platforms.gmeet': 'Google Meet', 'skills.meetingBots.platforms.teams': 'মাইক্রোসফট টিম', + 'skills.meetingBots.platforms.webex': 'Webex', 'skills.meetingBots.platforms.zoom': 'জুম', 'skills.meetingBots.sendTo': 'পাঠান', 'skills.meetingBots.serverOverloaded': diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 32f9944f56..b661316a0a 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -5278,9 +5278,11 @@ const messages: TranslationMap = { 'skills.meetingBots.platformComingSoon': '{label}-Unterstützung ist bald verfügbar.', 'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij', 'skills.meetingBots.platformHints.teams': 'teams.microsoft.com/...', + 'skills.meetingBots.platformHints.webex': 'webex.com/meet/...', 'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...', 'skills.meetingBots.platforms.gmeet': 'Google Treffen Sie', 'skills.meetingBots.platforms.teams': 'Microsoft Teams', + 'skills.meetingBots.platforms.webex': 'Webex', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': 'Senden an', 'skills.meetingBots.serverOverloaded': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a844317b81..745d3bbecd 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -5793,9 +5793,11 @@ const en: TranslationMap = { 'skills.meetingBots.platformComingSoon': '{label} support is coming soon.', 'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij', 'skills.meetingBots.platformHints.teams': 'teams.microsoft.com/...', + 'skills.meetingBots.platformHints.webex': 'webex.com/meet/...', 'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...', 'skills.meetingBots.platforms.gmeet': 'Google Meet', 'skills.meetingBots.platforms.teams': 'Microsoft Teams', + 'skills.meetingBots.platforms.webex': 'Webex', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': 'Send to {label}', 'skills.meetingBots.serverOverloaded': diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 8807dcbc06..57411b5bfe 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -5242,9 +5242,11 @@ const messages: TranslationMap = { 'skills.meetingBots.platformComingSoon': 'El soporte de {label} llegará pronto.', 'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij', 'skills.meetingBots.platformHints.teams': 'equipos.microsoft.com/...', + 'skills.meetingBots.platformHints.webex': 'webex.com/meet/...', 'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...', 'skills.meetingBots.platforms.gmeet': 'Google Conocer', 'skills.meetingBots.platforms.teams': 'Equipos de Microsoft', + 'skills.meetingBots.platforms.webex': 'Webex', 'skills.meetingBots.platforms.zoom': 'Ampliar', 'skills.meetingBots.sendTo': 'Enviar a', 'skills.meetingBots.serverOverloaded': diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index eda813ef3b..6c79a8b22c 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -5263,9 +5263,11 @@ const messages: TranslationMap = { 'skills.meetingBots.platformComingSoon': '{label} sera bientôt disponible.', 'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij', 'skills.meetingBots.platformHints.teams': 'teams.microsoft.com/...', + 'skills.meetingBots.platformHints.webex': 'webex.com/meet/...', 'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...', 'skills.meetingBots.platforms.gmeet': 'Google Meet', 'skills.meetingBots.platforms.teams': 'Microsoft Teams', + 'skills.meetingBots.platforms.webex': 'Webex', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': 'Envoyer à', 'skills.meetingBots.serverOverloaded': diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 44c9822132..d54886ee39 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -5150,9 +5150,11 @@ const messages: TranslationMap = { 'skills.meetingBots.platformComingSoon': '{label} समर्थन जल्द ही आ रहा है।', 'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij', 'skills.meetingBots.platformHints.teams': 'Teams.microsoft.com/...', + 'skills.meetingBots.platformHints.webex': 'webex.com/meet/...', 'skills.meetingBots.platformHints.zoom': 'Zoom.us/j/...', 'skills.meetingBots.platforms.gmeet': 'Google मिलें', 'skills.meetingBots.platforms.teams': 'माइक्रोसॉफ्ट टीमें', + 'skills.meetingBots.platforms.webex': 'Webex', 'skills.meetingBots.platforms.zoom': 'ज़ूम करें', 'skills.meetingBots.sendTo': 'भेजें', 'skills.meetingBots.serverOverloaded': diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index a797f61861..05e6027d3d 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -5164,9 +5164,11 @@ const messages: TranslationMap = { 'skills.meetingBots.platformComingSoon': '{label} dukungan akan segera hadir.', 'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij', 'skills.meetingBots.platformHints.teams': 'team.microsoft.com/...', + 'skills.meetingBots.platformHints.webex': 'webex.com/meet/...', 'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...', 'skills.meetingBots.platforms.gmeet': 'Google Temui', 'skills.meetingBots.platforms.teams': 'Microsoft Teams', + 'skills.meetingBots.platforms.webex': 'Webex', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': 'Kirim ke', 'skills.meetingBots.serverOverloaded': diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 2a08afc327..86dc270b9b 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -5231,9 +5231,11 @@ const messages: TranslationMap = { 'skills.meetingBots.platformComingSoon': 'Il supporto {label} sarà presto disponibile.', 'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij', 'skills.meetingBots.platformHints.teams': 'teams.microsoft.com/...', + 'skills.meetingBots.platformHints.webex': 'webex.com/meet/...', 'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...', 'skills.meetingBots.platforms.gmeet': 'Google Incontra', 'skills.meetingBots.platforms.teams': 'Microsoft Teams', + 'skills.meetingBots.platforms.webex': 'Webex', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': 'Invia a', 'skills.meetingBots.serverOverloaded': diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index efcbfa98e8..52d54fc47e 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -5098,9 +5098,11 @@ const messages: TranslationMap = { 'skills.meetingBots.platformComingSoon': '{label} 지원이 곧 제공될 예정입니다.', 'skills.meetingBots.platformHints.gmeet': 'Meet.google.com/abc-defg-hij', 'skills.meetingBots.platformHints.teams': 'Teams.microsoft.com/...', + 'skills.meetingBots.platformHints.webex': 'webex.com/meet/...', 'skills.meetingBots.platformHints.zoom': 'Zoom.us/j/...', 'skills.meetingBots.platforms.gmeet': 'Google 모임', 'skills.meetingBots.platforms.teams': 'Microsoft Teams', + 'skills.meetingBots.platforms.webex': 'Webex', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': '보내기', 'skills.meetingBots.serverOverloaded': diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 07c2fb6c1a..3d0f050d8f 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -5218,9 +5218,11 @@ const messages: TranslationMap = { 'skills.meetingBots.platformComingSoon': 'Obsługa {label} jest już wkrótce.', 'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij', 'skills.meetingBots.platformHints.teams': 'teams.microsoft.com/...', + 'skills.meetingBots.platformHints.webex': 'webex.com/meet/...', 'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...', 'skills.meetingBots.platforms.gmeet': 'Google Meet', 'skills.meetingBots.platforms.teams': 'Microsoft Teams', + 'skills.meetingBots.platforms.webex': 'Webex', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': 'Wyślij do', 'skills.meetingBots.serverOverloaded': diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 38816d45ba..9a711c64ce 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -5234,9 +5234,11 @@ const messages: TranslationMap = { 'skills.meetingBots.platformComingSoon': 'O suporte {label} estará disponível em breve.', 'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij', 'skills.meetingBots.platformHints.teams': 'times.microsoft.com/...', + 'skills.meetingBots.platformHints.webex': 'webex.com/meet/...', 'skills.meetingBots.platformHints.zoom': 'zoom.us/j/...', 'skills.meetingBots.platforms.gmeet': 'Google Conheça', 'skills.meetingBots.platforms.teams': 'Microsoft Teams', + 'skills.meetingBots.platforms.webex': 'Webex', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': 'Enviar para', 'skills.meetingBots.serverOverloaded': diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 851d13ddfb..8bc9cfbc2d 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -5193,9 +5193,11 @@ const messages: TranslationMap = { 'skills.meetingBots.platformComingSoon': 'Поддержка {label} скоро появится.', 'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij', 'skills.meetingBots.platformHints.teams': 'Teams.microsoft.com/...', + 'skills.meetingBots.platformHints.webex': 'webex.com/meet/...', 'skills.meetingBots.platformHints.zoom': 'Zoom.us/j/...', 'skills.meetingBots.platforms.gmeet': 'Google Встречайте', 'skills.meetingBots.platforms.teams': 'Microsoft Teams', + 'skills.meetingBots.platforms.webex': 'Webex', 'skills.meetingBots.platforms.zoom': 'Zoom', 'skills.meetingBots.sendTo': 'Отправить', 'skills.meetingBots.serverOverloaded': diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 543eb53c58..64527b0e1f 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4892,9 +4892,11 @@ const messages: TranslationMap = { 'skills.meetingBots.platformComingSoon': '{label} 支持即将推出。', 'skills.meetingBots.platformHints.gmeet': 'meet.google.com/abc-defg-hij', 'skills.meetingBots.platformHints.teams': 'team.microsoft.com/...', + 'skills.meetingBots.platformHints.webex': 'webex.com/meet/...', 'skills.meetingBots.platformHints.zoom': 'Zoom.us/j/...', 'skills.meetingBots.platforms.gmeet': 'Google 见面', 'skills.meetingBots.platforms.teams': '微软团队', + 'skills.meetingBots.platforms.webex': 'Webex', 'skills.meetingBots.platforms.zoom': '变焦', 'skills.meetingBots.sendTo': '发送到会议', 'skills.meetingBots.serverOverloaded': 'OpenHuman 当前负载过高,请几分钟后重试。', diff --git a/app/src/pages/Skills.tsx b/app/src/pages/Skills.tsx index f64ad49bfd..63628bb900 100644 --- a/app/src/pages/Skills.tsx +++ b/app/src/pages/Skills.tsx @@ -21,7 +21,7 @@ import EmbeddingsPanel from '../components/settings/panels/EmbeddingsPanel'; import SearchPanel from '../components/settings/panels/SearchPanel'; import VoicePanel from '../components/settings/panels/VoicePanel'; import AutocompleteSetupModal from '../components/skills/AutocompleteSetupModal'; -import MeetingBotsCard from '../components/skills/MeetingBotsCard'; +import MeetingsPage from '../components/meetings/MeetingsPage'; import ScreenIntelligenceSetupModal from '../components/skills/ScreenIntelligenceSetupModal'; import UnifiedSkillCard from '../components/skills/SkillCard'; import { SKILL_CATEGORY_ORDER, type SkillCategory } from '../components/skills/skillCategories'; @@ -1030,7 +1030,7 @@ export default function Skills() {
) : ( -
+
{/*

@@ -1260,10 +1260,7 @@ export default function Skills() { )} {activeTab === 'meetings' && ( -
- - -
+ )} } diff --git a/app/src/pages/__tests__/Skills.meetings-tab.test.tsx b/app/src/pages/__tests__/Skills.meetings-tab.test.tsx index 46c6e0b4be..1b7f5ff319 100644 --- a/app/src/pages/__tests__/Skills.meetings-tab.test.tsx +++ b/app/src/pages/__tests__/Skills.meetings-tab.test.tsx @@ -5,7 +5,7 @@ import '../../test/mockDefaultSkillStatusHooks'; import { renderWithProviders } from '../../test/test-utils'; import Skills from '../Skills'; -vi.mock('../../components/skills/MeetingBotsCard', () => ({ +vi.mock('../../components/meetings/MeetingsPage', () => ({ default: () =>
Meeting bot CTA
, })); From 17a19fd921ae2f9abcd1e879018bd4763c764662 Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Mon, 29 Jun 2026 22:56:08 +0530 Subject: [PATCH 03/20] feat(meet): history master-detail with rich transcript Phase 1B of the meetings redesign. Replace the flat expandable recent- calls list with a master-detail history view: a searchable, platform- filtered, date-grouped call rail beside a detail pane that lazy-loads each call's summary and transcript. Add a rich TranscriptViewer that parses the [MM:SS] [Name] line prefix into timestamped, speaker-labeled, role-colored lines with copy/download, and an ActionItemChecklist that surfaces executable items with a Run-with-OpenHuman action. Infer the platform from the meeting URL for per-row icons. Removes the old RecentCallsSection (now only used here). --- .../meetings/ActionItemChecklist.tsx | 94 +++++ app/src/components/meetings/HistoryDetail.tsx | 224 +++++++++++ app/src/components/meetings/HistoryRail.tsx | 172 +++++++++ .../components/meetings/HistorySection.tsx | 198 ++++++++++ app/src/components/meetings/MeetingsPage.tsx | 36 +- .../components/meetings/TranscriptViewer.tsx | 98 +++++ .../__tests__/ActionItemChecklist.test.tsx | 83 ++++ .../meetings/__tests__/HistoryDetail.test.tsx | 166 ++++++++ .../meetings/__tests__/HistoryRail.test.tsx | 129 +++++++ .../__tests__/HistorySection.test.tsx | 154 ++++++++ .../__tests__/TranscriptViewer.test.tsx | 134 +++++++ app/src/components/meetings/meetingUtils.ts | 17 + .../skills/RecentCallsSection.test.tsx | 169 --------- .../components/skills/RecentCallsSection.tsx | 355 ------------------ app/src/lib/i18n/ar.ts | 11 + app/src/lib/i18n/bn.ts | 11 + app/src/lib/i18n/de.ts | 11 + app/src/lib/i18n/en.ts | 11 + app/src/lib/i18n/es.ts | 11 + app/src/lib/i18n/fr.ts | 11 + app/src/lib/i18n/hi.ts | 11 + app/src/lib/i18n/id.ts | 11 + app/src/lib/i18n/it.ts | 11 + app/src/lib/i18n/ko.ts | 11 + app/src/lib/i18n/pl.ts | 11 + app/src/lib/i18n/pt.ts | 11 + app/src/lib/i18n/ru.ts | 11 + app/src/lib/i18n/zh-CN.ts | 11 + .../__tests__/meetCallService.test.ts | 33 ++ app/src/services/meetCallService.ts | 32 ++ 30 files changed, 1691 insertions(+), 557 deletions(-) create mode 100644 app/src/components/meetings/ActionItemChecklist.tsx create mode 100644 app/src/components/meetings/HistoryDetail.tsx create mode 100644 app/src/components/meetings/HistoryRail.tsx create mode 100644 app/src/components/meetings/HistorySection.tsx create mode 100644 app/src/components/meetings/TranscriptViewer.tsx create mode 100644 app/src/components/meetings/__tests__/ActionItemChecklist.test.tsx create mode 100644 app/src/components/meetings/__tests__/HistoryDetail.test.tsx create mode 100644 app/src/components/meetings/__tests__/HistoryRail.test.tsx create mode 100644 app/src/components/meetings/__tests__/HistorySection.test.tsx create mode 100644 app/src/components/meetings/__tests__/TranscriptViewer.test.tsx delete mode 100644 app/src/components/skills/RecentCallsSection.test.tsx delete mode 100644 app/src/components/skills/RecentCallsSection.tsx diff --git a/app/src/components/meetings/ActionItemChecklist.tsx b/app/src/components/meetings/ActionItemChecklist.tsx new file mode 100644 index 0000000000..976d01b69f --- /dev/null +++ b/app/src/components/meetings/ActionItemChecklist.tsx @@ -0,0 +1,94 @@ +/** + * ActionItemChecklist — renders a list of MeetCallActionItem objects. + * + * Executable items show a "Run with OpenHuman" button that navigates to /chat. + * Advisory items show only the description + metadata. + * Checked state is cosmetic (local only, not persisted). + */ +import debug from 'debug'; +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { MeetCallActionItem } from '../../services/meetCallService'; +import Button from '../ui/Button'; + +const log = debug('meetings:action'); + +interface ActionItemChecklistProps { + items: MeetCallActionItem[]; +} + +export function ActionItemChecklist({ items }: ActionItemChecklistProps) { + const { t } = useT(); + const navigate = useNavigate(); + const [checked, setChecked] = useState>({}); + + if (items.length === 0) return null; + + function handleCheck(index: number) { + setChecked(prev => ({ ...prev, [index]: !prev[index] })); + } + + function handleRun(item: MeetCallActionItem) { + log('[action] run with OpenHuman clicked', { description: item.description, tool: item.tool_name }); + // TODO: prefill chat with action item description — prefill not yet supported + void navigate('/chat'); + } + + return ( +
+

+ {t('skills.meetingBots.callActionItemsHeading')} +

+
    + {items.map((item, i) => { + const isExecutable = item.kind === 'executable'; + const meta = [ + item.assignee?.trim() || undefined, + isExecutable ? item.tool_name?.trim() || undefined : undefined, + ].filter(Boolean); + + return ( +
  • + handleCheck(i)} + aria-label={item.description} + className="mt-0.5 h-3 w-3 shrink-0 cursor-pointer rounded accent-primary-600" + /> +
    + + {item.description} + + {meta.length > 0 && ( + + ({meta.join(' · ')}) + + )} + {isExecutable && ( + + + + )} +
    +
  • + ); + })} +
+
+ ); +} + +export default ActionItemChecklist; diff --git a/app/src/components/meetings/HistoryDetail.tsx b/app/src/components/meetings/HistoryDetail.tsx new file mode 100644 index 0000000000..73b7f91336 --- /dev/null +++ b/app/src/components/meetings/HistoryDetail.tsx @@ -0,0 +1,224 @@ +/** + * HistoryDetail — shows the full detail for a selected call: header metadata, + * summary (action items + key points + headline), and the transcript. + * + * When no record is selected, renders a placeholder prompt. + * Lazy-loads the detail via getMeetCallDetail on each new request_id. + * Re-fetches once after 2 s if the loaded detail has no summary yet + * (the summary is generated asynchronously at call-end). + */ +import debug from 'debug'; +import { useEffect, useRef, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { + getMeetCallDetail, + type MeetCallDetail, + type MeetCallRecord, +} from '../../services/meetCallService'; +import { inferPlatformFromUrl, platformLabel, platformLogoUrl } from './meetingUtils'; +import ActionItemChecklist from './ActionItemChecklist'; +import TranscriptViewer from './TranscriptViewer'; + +const log = debug('meetings:detail'); + +type DetailStatus = 'idle' | 'loading' | 'loaded' | 'error'; + +function hasSummaryDetail(detail: MeetCallDetail | null): boolean { + const summary = detail?.summary; + return ( + !!summary && + (summary.headline.trim().length > 0 || + summary.key_points.length > 0 || + summary.action_items.length > 0) + ); +} + +function extractMeetingCode(url: string): string { + try { + return new URL(url).pathname.replace(/^\/+/, '') || url; + } catch { + return url; + } +} + +interface HistoryDetailProps { + record: MeetCallRecord | null; +} + +export function HistoryDetail({ record }: HistoryDetailProps) { + const { t } = useT(); + const [status, setStatus] = useState('idle'); + const [detail, setDetail] = useState(null); + // Track which request_id we've loaded so we can reset on change + const loadedForRef = useRef(null); + + async function loadDetail(requestId: string) { + log('[detail] loading detail for', requestId); + setStatus('loading'); + try { + const result = await getMeetCallDetail(requestId); + log('[detail] loaded detail for', requestId, 'hasSummary=%s', hasSummaryDetail(result)); + setDetail(result); + setStatus('loaded'); + loadedForRef.current = requestId; + } catch (err) { + log('[detail] error loading detail for', requestId, err); + setStatus('error'); + } + } + + useEffect(() => { + if (!record) { + setStatus('idle'); + setDetail(null); + loadedForRef.current = null; + return; + } + + // Reset and load when the selected call changes + setStatus('idle'); + setDetail(null); + loadedForRef.current = null; + void loadDetail(record.request_id); + }, [record?.request_id]); // eslint-disable-line react-hooks/exhaustive-deps + + // If loaded but no summary yet, retry once after 2 s + useEffect(() => { + if (status !== 'loaded' || !record) return; + if (hasSummaryDetail(detail)) return; + + log('[detail] no summary yet, scheduling retry in 2000ms for', record.request_id); + const timer = setTimeout(() => { + log('[detail] retrying detail load for', record.request_id); + void loadDetail(record.request_id); + }, 2000); + return () => clearTimeout(timer); + }, [status, detail, record?.request_id]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!record) { + return ( +
+

+ {t('skills.meetingBots.history.selectPrompt')} +

+
+ ); + } + + const meetingCode = extractMeetingCode(record.meet_url); + const platform = inferPlatformFromUrl(record.meet_url); + const logoUrl = platform ? platformLogoUrl(platform) : null; + const platformName = platform ? platformLabel(platform, t) : null; + const startTime = new Date(record.started_at_ms).toLocaleString(); + const duration = Math.max(0, Math.round(record.spoken_seconds + record.listened_seconds)); + const participants = (record.participants ?? []).map(p => p.trim()).filter(Boolean); + + return ( +
+ {/* Header */} +
+
+ {logoUrl && ( + {platformName + )} + + {meetingCode} + +
+
+ {startTime} + + {t('skills.meetingBots.recentCallDuration').replace('{seconds}', String(duration))} + + {record.owner_display_name?.trim() && ( + + {t('skills.meetingBots.recentCallAddedBy').replace( + '{name}', + record.owner_display_name.trim() + )} + + )} +
+ {participants.length > 0 && ( +

+ {participants.length === 1 + ? t('skills.meetingBots.history.participantCount').replace( + '{count}', + String(participants.length) + ) + : t('skills.meetingBots.history.participantCountPlural').replace( + '{count}', + String(participants.length) + )} + {': '} + {participants.join(', ')} +

+ )} +
+ + {/* Detail body */} + {(status === 'idle' || status === 'loading') && ( +

+ {t('skills.meetingBots.callDetailLoading')} +

+ )} + + {status === 'error' && ( +

+ {t('skills.meetingBots.callDetailError')}{' '} + +

+ )} + + {status === 'loaded' && !hasSummaryDetail(detail) && (detail?.transcript ?? []).length === 0 && ( +

+ {t('skills.meetingBots.callDetailEmpty')} +

+ )} + + {status === 'loaded' && (hasSummaryDetail(detail) || (detail?.transcript ?? []).length > 0) && ( +
+ {hasSummaryDetail(detail) && detail?.summary && ( +
+ {detail.summary.headline.trim() && ( +

{detail.summary.headline}

+ )} + {detail.summary.key_points.length > 0 && ( +
+

+ {t('skills.meetingBots.callKeyPointsHeading')} +

+
    + {detail.summary.key_points.map((point, i) => ( +
  • {point}
  • + ))} +
+
+ )} + {detail.summary.action_items.length > 0 && ( + + )} +
+ )} + {(detail?.transcript ?? []).length > 0 && ( + + )} +
+ )} +
+ ); +} + +export default HistoryDetail; diff --git a/app/src/components/meetings/HistoryRail.tsx b/app/src/components/meetings/HistoryRail.tsx new file mode 100644 index 0000000000..3d764b4032 --- /dev/null +++ b/app/src/components/meetings/HistoryRail.tsx @@ -0,0 +1,172 @@ +/** + * HistoryRail — the left-hand call list with search + platform filter. + * + * Renders date-grouped rows; each row is a button showing the platform logo, + * meeting code, relative time, and turn count. The selected row is highlighted. + */ +import debug from 'debug'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { MeetCallRecord, MeetingPlatform } from '../../services/meetCallService'; +import { MEETING_PLATFORMS, platformLabel, platformLogoUrl } from './meetingUtils'; + +const log = debug('meetings:rail'); + +export interface CallGroup { + label: string; + calls: MeetCallRecord[]; +} + +interface HistoryRailProps { + groups: CallGroup[]; + selectedId: string | null; + onSelect: (id: string) => void; + searchQuery: string; + onSearchChange: (q: string) => void; + platformFilter: string; + onPlatformChange: (p: string) => void; +} + +function extractMeetingCode(url: string): string { + try { + return new URL(url).pathname.replace(/^\/+/, '') || url; + } catch { + return url; + } +} + +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 '—'; + } +} + +export function HistoryRail({ + groups, + selectedId, + onSelect, + searchQuery, + onSearchChange, + platformFilter, + onPlatformChange, +}: HistoryRailProps) { + const { t } = useT(); + + const totalCalls = groups.reduce((sum, g) => sum + g.calls.length, 0); + + return ( +
+ {/* Search */} + onSearchChange(e.target.value)} + placeholder={t('skills.meetingBots.history.searchPlaceholder')} + className="w-full rounded-lg border border-line bg-surface px-2.5 py-1.5 text-[12px] text-content placeholder:text-content-faint focus:outline-none focus:ring-1 focus:ring-primary-400" + /> + + {/* Platform filter */} + + + {/* Groups */} +
+ {totalCalls === 0 && ( +

+ {t('skills.meetingBots.recentCallsEmpty')} +

+ )} + {groups.map(group => ( +
+

+ {group.label} +

+
    + {group.calls.map(call => { + const isSelected = call.request_id === selectedId; + const code = extractMeetingCode(call.meet_url); + const platform = (() => { + try { + const host = new URL(call.meet_url).hostname.toLowerCase(); + if (host.includes('meet.google.com')) return 'gmeet' as MeetingPlatform; + if (host.includes('zoom.us')) return 'zoom' as MeetingPlatform; + if (host.includes('teams.microsoft.com')) return 'teams' as MeetingPlatform; + if (host.includes('webex.com')) return 'webex' as MeetingPlatform; + return null; + } catch { + return null; + } + })(); + + return ( +
  • + +
  • + ); + })} +
+
+ ))} +
+
+ ); +} + +export default HistoryRail; diff --git a/app/src/components/meetings/HistorySection.tsx b/app/src/components/meetings/HistorySection.tsx new file mode 100644 index 0000000000..673e4617b5 --- /dev/null +++ b/app/src/components/meetings/HistorySection.tsx @@ -0,0 +1,198 @@ +/** + * HistorySection — orchestrates the two-column call-history view. + * + * Left column: HistoryRail (search, filter, date groups). + * Right column: HistoryDetail (detail for the selected call). + * + * Fetches listMeetCalls(50) on mount with two delayed retries to catch + * asynchronous writes from the core (same pattern as old MeetingsPage). + */ +import debug from 'debug'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { + listMeetCalls, + type MeetCallRecord, +} from '../../services/meetCallService'; +import { inferPlatformFromUrl } from './meetingUtils'; +import HistoryRail, { type CallGroup } from './HistoryRail'; +import HistoryDetail from './HistoryDetail'; + +const log = debug('meetings:history'); + +/** UTC day key for grouping: "YYYY-MM-DD". */ +function utcDayKey(ms: number): string { + const d = new Date(ms); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`; +} + +function todayKey(): string { + return utcDayKey(Date.now()); +} + +function yesterdayKey(): string { + return utcDayKey(Date.now() - 86400000); +} + +function groupRecords( + records: MeetCallRecord[], + todayLabel: string, + yesterdayLabel: string, + earlierLabel: string +): CallGroup[] { + const today = todayKey(); + const yesterday = yesterdayKey(); + + const todayCalls: MeetCallRecord[] = []; + const yesterdayCalls: MeetCallRecord[] = []; + const earlierCalls: MeetCallRecord[] = []; + + for (const r of records) { + const key = utcDayKey(r.started_at_ms); + if (key === today) todayCalls.push(r); + else if (key === yesterday) yesterdayCalls.push(r); + else earlierCalls.push(r); + } + + const groups: CallGroup[] = []; + if (todayCalls.length > 0) groups.push({ label: todayLabel, calls: todayCalls }); + if (yesterdayCalls.length > 0) groups.push({ label: yesterdayLabel, calls: yesterdayCalls }); + if (earlierCalls.length > 0) groups.push({ label: earlierLabel, calls: earlierCalls }); + return groups; +} + +export function HistorySection() { + const { t } = useT(); + const [records, setRecords] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedCallId, setSelectedCallId] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [platformFilter, setPlatformFilter] = useState(''); + + const fetchCalls = useCallback(async () => { + log('[history] fetching calls'); + setError(null); + try { + const rows = await listMeetCalls(50); + log('[history] loaded %d calls', rows.length); + setRecords(rows); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load calls.'; + log('[history] fetch error', err); + console.warn('[meetings:history] listMeetCalls failed:', err); + setError(message); + setRecords([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchCalls(); + const retries = [1200, 3000].map(delay => setTimeout(() => void fetchCalls(), delay)); + return () => retries.forEach(clearTimeout); + }, [fetchCalls]); + + // Apply search + platform filter + const filteredRecords = useMemo(() => { + if (!records) return []; + return records.filter(r => { + // Platform filter + if (platformFilter) { + const inferred = inferPlatformFromUrl(r.meet_url); + if (inferred !== platformFilter) return false; + } + // Search query + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + const code = (() => { + try { + return new URL(r.meet_url).pathname.replace(/^\/+/, ''); + } catch { + return r.meet_url; + } + })(); + const participantStr = (r.participants ?? []).join(' ').toLowerCase(); + const owner = (r.owner_display_name ?? '').toLowerCase(); + if ( + !code.toLowerCase().includes(q) && + !participantStr.includes(q) && + !owner.includes(q) + ) { + return false; + } + } + return true; + }); + }, [records, searchQuery, platformFilter]); + + const groups = useMemo( + () => + groupRecords( + filteredRecords, + t('skills.meetingBots.history.today'), + t('skills.meetingBots.history.yesterday'), + t('skills.meetingBots.history.earlier') + ), + [filteredRecords, t] + ); + + const selectedRecord = useMemo( + () => records?.find(r => r.request_id === selectedCallId) ?? null, + [records, selectedCallId] + ); + + function handleSelect(id: string) { + log('[history] selected call', id); + setSelectedCallId(id); + } + + return ( +
+
+

+ {t('skills.meetingBots.recentCallsHeading')} + {records && records.length > 0 && ( + + ({records.length}) + + )} +

+
+ + {error && ( +

{error}

+ )} + + {loading && records === null ? ( +

+ {t('skills.meetingBots.recentCallsLoading')} +

+ ) : ( +
+ {/* Left: Rail — on narrow screens hide when a call is selected */} +
+ +
+ + {/* Right: Detail — on narrow screens show only when something is selected */} +
+ +
+
+ )} +
+ ); +} + +export default HistorySection; diff --git a/app/src/components/meetings/MeetingsPage.tsx b/app/src/components/meetings/MeetingsPage.tsx index a95170ce4a..9f44da6a81 100644 --- a/app/src/components/meetings/MeetingsPage.tsx +++ b/app/src/components/meetings/MeetingsPage.tsx @@ -9,15 +9,14 @@ * flips to 'active' (same pattern as the original `MeetingBotsCard`). */ import debug from 'debug'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; -import { listMeetCalls, type MeetCallRecord } from '../../services/meetCallService'; import { selectBackendMeetStatus } from '../../store/backendMeetSlice'; import { useAppSelector } from '../../store/hooks'; import { useT } from '../../lib/i18n/I18nContext'; import BetaBanner from '../ui/BetaBanner'; -import { RecentCallsSection } from '../skills/RecentCallsSection'; import { ActiveMeetingBanner } from './ActiveMeetingBanner'; +import HistorySection from './HistorySection'; import { MeetComposer } from './MeetComposer'; const log = debug('meetings:page'); @@ -55,35 +54,6 @@ export default function MeetingsPage({ onToast }: MeetingsPageProps) { } }, [status, onToast, t]); - // ── Recent calls ───────────────────────────────────────────────────────── - const [recentCalls, setRecentCalls] = useState(null); - const [recentError, setRecentError] = useState(null); - - const refreshRecentCalls = useCallback(async () => { - setRecentError(null); - try { - const rows = await listMeetCalls(20); - log('[page] loaded %d recent calls', rows.length); - setRecentCalls(rows); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to load recent calls.'; - console.warn('[meetings] listMeetCalls failed:', err); - setRecentError(message); - setRecentCalls([]); - } - }, []); - - useEffect(() => { - void refreshRecentCalls(); - // 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. A couple of short delayed re-fetches reliably reflect it. - const retries = [1200, 3000].map(delay => - setTimeout(() => void refreshRecentCalls(), delay) - ); - return () => retries.forEach(clearTimeout); - }, [refreshRecentCalls]); - return (
@@ -94,7 +64,7 @@ export default function MeetingsPage({ onToast }: MeetingsPageProps) { )} - +
); } diff --git a/app/src/components/meetings/TranscriptViewer.tsx b/app/src/components/meetings/TranscriptViewer.tsx new file mode 100644 index 0000000000..6c45872714 --- /dev/null +++ b/app/src/components/meetings/TranscriptViewer.tsx @@ -0,0 +1,98 @@ +/** + * TranscriptViewer — renders a list of transcript lines with speaker labels + * and timestamps, plus copy-all and download controls. + */ +import debug from 'debug'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { + parseTranscriptLine, + type MeetCallTranscriptLine, +} from '../../services/meetCallService'; +import Button from '../ui/Button'; + +const log = debug('meetings:transcript'); + +interface TranscriptViewerProps { + lines: MeetCallTranscriptLine[]; +} + +function buildPlainText(lines: MeetCallTranscriptLine[]): string { + return lines + .map(line => { + const parsed = parseTranscriptLine(line); + const ts = parsed.timestamp ? `${parsed.timestamp} ` : ''; + const speaker = parsed.speaker ? `${parsed.speaker}: ` : ''; + return `${ts}${speaker}${parsed.text}`; + }) + .join('\n'); +} + +export function TranscriptViewer({ lines }: TranscriptViewerProps) { + const { t } = useT(); + + async function handleCopy() { + log('[transcript] copy all clicked, lines=%d', lines.length); + try { + await navigator.clipboard.writeText(buildPlainText(lines)); + log('[transcript] copy succeeded'); + } catch (e) { + log('[transcript] copy failed', e); + } + } + + function handleDownload() { + log('[transcript] download clicked, lines=%d', lines.length); + const text = buildPlainText(lines); + const blob = new Blob([text], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'transcript.txt'; + a.click(); + URL.revokeObjectURL(url); + } + + return ( +
+
+

+ {t('skills.meetingBots.callTranscriptHeading')} +

+
+ + +
+
+
+ {lines.map((line, i) => { + const parsed = parseTranscriptLine(line); + const isAssistant = parsed.role === 'assistant'; + return ( +

+ {parsed.timestamp && ( + {parsed.timestamp} + )} + {parsed.speaker && ( + {parsed.speaker}: + )} + {parsed.text} +

+ ); + })} +
+
+ ); +} + +export default TranscriptViewer; diff --git a/app/src/components/meetings/__tests__/ActionItemChecklist.test.tsx b/app/src/components/meetings/__tests__/ActionItemChecklist.test.tsx new file mode 100644 index 0000000000..3fba4bbc67 --- /dev/null +++ b/app/src/components/meetings/__tests__/ActionItemChecklist.test.tsx @@ -0,0 +1,83 @@ +import { cleanup, fireEvent, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import type { MeetCallActionItem } from '../../../services/meetCallService'; +import ActionItemChecklist from '../ActionItemChecklist'; + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const executableItem: MeetCallActionItem = { + description: 'Schedule follow-up meeting', + kind: 'executable', + tool_name: 'calendar', + assignee: 'Alice', +}; + +const advisoryItem: MeetCallActionItem = { + description: 'Review the proposal', + kind: 'advisory', + tool_name: null, + assignee: 'Bob', +}; + +describe('ActionItemChecklist', () => { + it('renders nothing when items list is empty', () => { + const { container } = renderWithProviders(); + expect(container.firstChild).toBeNull(); + }); + + it('shows Run with OpenHuman button for executable items', () => { + renderWithProviders(); + expect(screen.getByText('Run with OpenHuman')).toBeInTheDocument(); + }); + + it('does not show Run with OpenHuman button for advisory items', () => { + renderWithProviders(); + expect(screen.queryByText('Run with OpenHuman')).toBeNull(); + }); + + it('navigates to /chat when Run with OpenHuman is clicked', () => { + renderWithProviders(); + fireEvent.click(screen.getByText('Run with OpenHuman')); + expect(mockNavigate).toHaveBeenCalledWith('/chat'); + }); + + it('toggles checkbox on click (cosmetic only)', () => { + renderWithProviders(); + const checkbox = screen.getByRole('checkbox', { name: executableItem.description }); + expect(checkbox).not.toBeChecked(); + fireEvent.click(checkbox); + expect(checkbox).toBeChecked(); + fireEvent.click(checkbox); + expect(checkbox).not.toBeChecked(); + }); + + it('renders assignee and tool_name metadata for executable items', () => { + renderWithProviders(); + expect(screen.getByText(/Alice/)).toBeInTheDocument(); + expect(screen.getByText(/calendar/)).toBeInTheDocument(); + }); + + it('renders assignee metadata for advisory items but no tool_name', () => { + renderWithProviders(); + expect(screen.getByText(/Bob/)).toBeInTheDocument(); + // Advisory items don't show tool_name in metadata even if present + expect(screen.queryByText('Run with OpenHuman')).toBeNull(); + }); + + it('renders description text', () => { + renderWithProviders(); + expect(screen.getByText(executableItem.description)).toBeInTheDocument(); + expect(screen.getByText(advisoryItem.description)).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/meetings/__tests__/HistoryDetail.test.tsx b/app/src/components/meetings/__tests__/HistoryDetail.test.tsx new file mode 100644 index 0000000000..1d2364ab20 --- /dev/null +++ b/app/src/components/meetings/__tests__/HistoryDetail.test.tsx @@ -0,0 +1,166 @@ +import { cleanup, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import type { MeetCallRecord, MeetCallDetail } from '../../../services/meetCallService'; +import HistoryDetail from '../HistoryDetail'; + +const getMeetCallDetailMock = vi.fn(); + +vi.mock('../../../services/meetCallService', async () => { + const actual = await vi.importActual( + '../../../services/meetCallService' + ); + return { + ...actual, + getMeetCallDetail: (...args: unknown[]) => getMeetCallDetailMock(...args), + }; +}); + +// Also mock ActionItemChecklist and TranscriptViewer for isolation +vi.mock('../ActionItemChecklist', () => ({ + default: ({ items }: { items: unknown[] }) => ( +
action-items:{items.length}
+ ), + ActionItemChecklist: ({ items }: { items: unknown[] }) => ( +
action-items:{items.length}
+ ), +})); + +vi.mock('../TranscriptViewer', () => ({ + default: ({ lines }: { lines: unknown[] }) => ( +
transcript:{lines.length}
+ ), + TranscriptViewer: ({ lines }: { lines: unknown[] }) => ( +
transcript:{lines.length}
+ ), +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const record: MeetCallRecord = { + request_id: 'req-1', + meet_url: 'https://meet.google.com/abc-def-ghi', + bot_display_name: 'OpenHuman', + owner_display_name: 'Alice', + started_at_ms: 1700000000000, + ended_at_ms: 1700000600000, + listened_seconds: 300, + spoken_seconds: 300, + turn_count: 5, + participants: ['Alice', 'Bob'], +}; + +const detailWithSummary: MeetCallDetail = { + request_id: 'req-1', + summary: { + headline: 'Great meeting', + key_points: ['Point 1', 'Point 2'], + action_items: [ + { description: 'Do something', kind: 'executable', tool_name: 'calendar', assignee: 'Alice' }, + ], + }, + transcript: [ + { role: 'participant', content: '[0:01] [Alice] Hello' }, + { role: 'assistant', content: 'Hi there' }, + ], +}; + +const detailNoSummary: MeetCallDetail = { + request_id: 'req-1', + summary: null, + transcript: [{ role: 'participant', content: 'Plain transcript line' }], +}; + +describe('HistoryDetail', () => { + it('shows select prompt when record is null', () => { + renderWithProviders(); + expect( + screen.getByText('Select a call to see its summary and transcript.') + ).toBeInTheDocument(); + }); + + it('shows loading state while detail is being fetched', async () => { + // Never resolve + getMeetCallDetailMock.mockReturnValue(new Promise(() => {})); + renderWithProviders(); + expect(await screen.findByText(/loading/i)).toBeInTheDocument(); + }); + + it('renders summary and transcript on success', async () => { + getMeetCallDetailMock.mockResolvedValue(detailWithSummary); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('action-items')).toBeInTheDocument(); + expect(screen.getByTestId('transcript')).toBeInTheDocument(); + }); + }); + + it('renders key points and headline from summary', async () => { + getMeetCallDetailMock.mockResolvedValue(detailWithSummary); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Great meeting')).toBeInTheDocument(); + expect(screen.getByText('Point 1')).toBeInTheDocument(); + }); + }); + + it('shows error state when fetch fails', async () => { + getMeetCallDetailMock.mockRejectedValue(new Error('network error')); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText(/retry/i)).toBeInTheDocument(); + }); + }); + + it('shows empty state when detail has no summary or transcript', async () => { + const emptyDetail: MeetCallDetail = { + request_id: 'req-1', + summary: null, + transcript: [], + }; + getMeetCallDetailMock.mockResolvedValue(emptyDetail); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText(/nothing captured|no transcript|nothing|empty/i)).toBeInTheDocument(); + }); + }); + + it('renders meeting code from URL', async () => { + getMeetCallDetailMock.mockResolvedValue(detailWithSummary); + renderWithProviders(); + // meeting code = pathname stripped of leading slash + expect(screen.getByText('abc-def-ghi')).toBeInTheDocument(); + }); + + it('renders participant count', async () => { + getMeetCallDetailMock.mockResolvedValue(detailWithSummary); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText(/2 participants/)).toBeInTheDocument(); + }); + }); + + it('shows transcript without summary when summary is null', async () => { + getMeetCallDetailMock.mockResolvedValue(detailNoSummary); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('transcript')).toBeInTheDocument(); + expect(screen.queryByTestId('action-items')).toBeNull(); + }); + }); + + it('reloads when record changes', async () => { + getMeetCallDetailMock.mockResolvedValue(detailWithSummary); + const { rerender } = renderWithProviders(); + await waitFor(() => expect(getMeetCallDetailMock).toHaveBeenCalledTimes(1)); + + const record2: MeetCallRecord = { ...record, request_id: 'req-2' }; + getMeetCallDetailMock.mockResolvedValue({ ...detailWithSummary, request_id: 'req-2' }); + rerender(); + await waitFor(() => expect(getMeetCallDetailMock).toHaveBeenCalledWith('req-2')); + }); +}); diff --git a/app/src/components/meetings/__tests__/HistoryRail.test.tsx b/app/src/components/meetings/__tests__/HistoryRail.test.tsx new file mode 100644 index 0000000000..755df2be99 --- /dev/null +++ b/app/src/components/meetings/__tests__/HistoryRail.test.tsx @@ -0,0 +1,129 @@ +import { cleanup, fireEvent, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import type { MeetCallRecord } from '../../../services/meetCallService'; +import HistoryRail, { type CallGroup } from '../HistoryRail'; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const call1: MeetCallRecord = { + request_id: 'req-1', + meet_url: 'https://meet.google.com/abc-def-ghi', + bot_display_name: 'OpenHuman', + owner_display_name: 'Alice', + started_at_ms: Date.now() - 3600000, + ended_at_ms: Date.now() - 3000000, + listened_seconds: 300, + spoken_seconds: 60, + turn_count: 5, + participants: ['Alice', 'Bob'], +}; + +const call2: MeetCallRecord = { + request_id: 'req-2', + meet_url: 'https://zoom.us/j/123456', + bot_display_name: 'OpenHuman', + owner_display_name: 'Carol', + started_at_ms: Date.now() - 86400000 - 3600000, + ended_at_ms: Date.now() - 86400000 - 3000000, + listened_seconds: 120, + spoken_seconds: 30, + turn_count: 2, + participants: ['Carol'], +}; + +const groups: CallGroup[] = [ + { label: 'Today', calls: [call1] }, + { label: 'Yesterday', calls: [call2] }, +]; + +function renderRail(overrides?: Partial[0]>) { + const props = { + groups, + selectedId: null, + onSelect: vi.fn(), + searchQuery: '', + onSearchChange: vi.fn(), + platformFilter: '', + onPlatformChange: vi.fn(), + ...overrides, + }; + return { ...renderWithProviders(), props }; +} + +describe('HistoryRail', () => { + it('renders group labels', () => { + renderRail(); + expect(screen.getByText('Today')).toBeInTheDocument(); + expect(screen.getByText('Yesterday')).toBeInTheDocument(); + }); + + it('renders meeting codes for each call', () => { + renderRail(); + expect(screen.getByText('abc-def-ghi')).toBeInTheDocument(); + expect(screen.getByText('j/123456')).toBeInTheDocument(); + }); + + it('renders platform logo for google meet', () => { + renderRail(); + const imgs = screen.getAllByRole('img'); + const gmeetImg = imgs.find(img => (img as HTMLImageElement).alt === 'Google Meet'); + expect(gmeetImg).toBeDefined(); + }); + + it('renders turn count for each call', () => { + renderRail(); + // turn_count=5 should show plural "5 turns" + expect(screen.getByText(/5 turn/)).toBeInTheDocument(); + }); + + it('fires onSelect when a row is clicked', () => { + const onSelect = vi.fn(); + renderRail({ onSelect }); + const button = screen.getAllByRole('button')[0]; + fireEvent.click(button); + expect(onSelect).toHaveBeenCalledWith('req-1'); + }); + + it('highlights the selected row', () => { + const { container } = renderRail({ selectedId: 'req-1' }); + const selectedBtn = container.querySelector('button.bg-primary-50'); + expect(selectedBtn).not.toBeNull(); + }); + + it('reflects search query in the input', () => { + renderRail({ searchQuery: 'abc' }); + const input = screen.getByRole('searchbox') as HTMLInputElement; + expect(input.value).toBe('abc'); + }); + + it('calls onSearchChange when search input changes', () => { + const onSearchChange = vi.fn(); + renderRail({ onSearchChange }); + const input = screen.getByRole('searchbox'); + fireEvent.change(input, { target: { value: 'zoom' } }); + expect(onSearchChange).toHaveBeenCalledWith('zoom'); + }); + + it('shows All platforms option in the select', () => { + renderRail(); + expect(screen.getByText('All platforms')).toBeInTheDocument(); + }); + + it('calls onPlatformChange when platform filter changes', () => { + const onPlatformChange = vi.fn(); + renderRail({ onPlatformChange }); + const select = screen.getByRole('combobox') as HTMLSelectElement; + fireEvent.change(select, { target: { value: 'zoom' } }); + expect(onPlatformChange).toHaveBeenCalledWith('zoom'); + }); + + it('renders empty state when no groups have calls', () => { + renderRail({ groups: [{ label: 'Today', calls: [] }] }); + expect(screen.getByText(/no.*call/i)).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/meetings/__tests__/HistorySection.test.tsx b/app/src/components/meetings/__tests__/HistorySection.test.tsx new file mode 100644 index 0000000000..161e93a8cb --- /dev/null +++ b/app/src/components/meetings/__tests__/HistorySection.test.tsx @@ -0,0 +1,154 @@ +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import type { MeetCallRecord, MeetCallDetail } from '../../../services/meetCallService'; +import HistorySection from '../HistorySection'; + +const listMeetCallsMock = vi.fn(); +const getMeetCallDetailMock = vi.fn(); + +vi.mock('../../../services/meetCallService', async () => { + const actual = await vi.importActual( + '../../../services/meetCallService' + ); + return { + ...actual, + listMeetCalls: (...args: unknown[]) => listMeetCallsMock(...args), + getMeetCallDetail: (...args: unknown[]) => getMeetCallDetailMock(...args), + }; +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const NOW = Date.now(); + +const todayCall: MeetCallRecord = { + request_id: 'req-today', + meet_url: 'https://meet.google.com/abc-def-ghi', + bot_display_name: 'OpenHuman', + owner_display_name: 'Alice', + started_at_ms: NOW - 3600000, + ended_at_ms: NOW - 3000000, + listened_seconds: 300, + spoken_seconds: 60, + turn_count: 5, + participants: ['Alice'], +}; + +const yesterdayCall: MeetCallRecord = { + request_id: 'req-yesterday', + meet_url: 'https://zoom.us/j/999888', + bot_display_name: 'OpenHuman', + owner_display_name: 'Bob', + started_at_ms: NOW - 86400000 - 3600000, + ended_at_ms: NOW - 86400000 - 3000000, + listened_seconds: 120, + spoken_seconds: 30, + turn_count: 2, + participants: ['Bob'], +}; + +const detail: MeetCallDetail = { + request_id: 'req-today', + summary: { + headline: 'Sync meeting', + key_points: [], + action_items: [], + }, + transcript: [{ role: 'participant', content: 'Hello' }], +}; + +describe('HistorySection', () => { + it('shows loading state while fetching', async () => { + listMeetCallsMock.mockReturnValue(new Promise(() => {})); + renderWithProviders(); + expect(await screen.findByText(/loading/i)).toBeInTheDocument(); + }); + + it('shows empty state when no calls returned', async () => { + listMeetCallsMock.mockResolvedValue([]); + renderWithProviders(); + await waitFor(() => { + // HistoryRail shows the i18n empty text when all groups have no calls + expect(screen.getByText(/no previous calls yet|your meeting history will appear|no.*call/i)).toBeInTheDocument(); + }); + }); + + it('renders grouped calls', async () => { + listMeetCallsMock.mockResolvedValue([todayCall, yesterdayCall]); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Today')).toBeInTheDocument(); + expect(screen.getByText('Yesterday')).toBeInTheDocument(); + expect(screen.getByText('abc-def-ghi')).toBeInTheDocument(); + expect(screen.getByText('j/999888')).toBeInTheDocument(); + }); + }); + + it('shows detail pane when a call is selected', async () => { + listMeetCallsMock.mockResolvedValue([todayCall]); + getMeetCallDetailMock.mockResolvedValue(detail); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('abc-def-ghi')).toBeInTheDocument(); + }); + + // Click the call row button + const buttons = screen.getAllByRole('button'); + const callButton = buttons.find(b => b.textContent?.includes('abc-def-ghi')); + expect(callButton).toBeDefined(); + fireEvent.click(callButton!); + + await waitFor(() => { + expect(getMeetCallDetailMock).toHaveBeenCalledWith('req-today'); + }); + }); + + it('filters calls by search query', async () => { + listMeetCallsMock.mockResolvedValue([todayCall, yesterdayCall]); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('abc-def-ghi')).toBeInTheDocument(); + }); + + // Search for the Zoom meeting's code (part of the URL path) + const searchInput = screen.getByRole('searchbox'); + fireEvent.change(searchInput, { target: { value: '999888' } }); + + await waitFor(() => { + expect(screen.queryByText('abc-def-ghi')).toBeNull(); + expect(screen.getByText('j/999888')).toBeInTheDocument(); + }); + }); + + it('filters calls by platform', async () => { + listMeetCallsMock.mockResolvedValue([todayCall, yesterdayCall]); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('abc-def-ghi')).toBeInTheDocument(); + }); + + const platformSelect = screen.getByRole('combobox') as HTMLSelectElement; + fireEvent.change(platformSelect, { target: { value: 'gmeet' } }); + + await waitFor(() => { + expect(screen.getByText('abc-def-ghi')).toBeInTheDocument(); + expect(screen.queryByText('j/999888')).toBeNull(); + }); + }); + + it('shows error state when listMeetCalls throws', async () => { + listMeetCallsMock.mockRejectedValue(new Error('network error')); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText(/network error/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/components/meetings/__tests__/TranscriptViewer.test.tsx b/app/src/components/meetings/__tests__/TranscriptViewer.test.tsx new file mode 100644 index 0000000000..96bbf8eadc --- /dev/null +++ b/app/src/components/meetings/__tests__/TranscriptViewer.test.tsx @@ -0,0 +1,134 @@ +import { cleanup, fireEvent, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import { + parseTranscriptLine, + type MeetCallTranscriptLine, +} from '../../../services/meetCallService'; +import TranscriptViewer from '../TranscriptViewer'; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const lineWithPrefix: MeetCallTranscriptLine = { + role: 'participant', + content: '[1:23] [Alice] Hello there!', +}; + +const lineWithoutPrefix: MeetCallTranscriptLine = { + role: 'assistant', + content: 'How can I help you?', +}; + +// ── parseTranscriptLine unit tests ────────────────────────────────────────── + +describe('parseTranscriptLine', () => { + it('parses a line with [MM:SS] [Name] prefix', () => { + const result = parseTranscriptLine(lineWithPrefix); + expect(result.timestamp).toBe('1:23'); + expect(result.speaker).toBe('Alice'); + expect(result.text).toBe('Hello there!'); + expect(result.role).toBe('participant'); + }); + + it('returns null timestamp and speaker when prefix is absent', () => { + const result = parseTranscriptLine(lineWithoutPrefix); + expect(result.timestamp).toBeNull(); + expect(result.speaker).toBeNull(); + expect(result.text).toBe('How can I help you?'); + expect(result.role).toBe('assistant'); + }); + + it('handles partial brackets — no match, returns full content', () => { + const line: MeetCallTranscriptLine = { role: 'participant', content: '[1:23] missing second bracket' }; + const result = parseTranscriptLine(line); + expect(result.timestamp).toBeNull(); + expect(result.speaker).toBeNull(); + expect(result.text).toBe('[1:23] missing second bracket'); + }); + + it('handles malformed content gracefully', () => { + const line: MeetCallTranscriptLine = { role: 'participant', content: 'just plain text' }; + const result = parseTranscriptLine(line); + expect(result.timestamp).toBeNull(); + expect(result.speaker).toBeNull(); + expect(result.text).toBe('just plain text'); + }); +}); + +// ── TranscriptViewer component tests ──────────────────────────────────────── + +describe('TranscriptViewer', () => { + it('renders speaker label and timestamp when prefix present', () => { + renderWithProviders(); + expect(screen.getByText('1:23')).toBeInTheDocument(); + expect(screen.getByText('Alice:')).toBeInTheDocument(); + expect(screen.getByText('Hello there!')).toBeInTheDocument(); + }); + + it('renders plain content when no prefix', () => { + renderWithProviders(); + expect(screen.getByText('How can I help you?')).toBeInTheDocument(); + // No timestamp or speaker + expect(screen.queryByText(':')).toBeNull(); + }); + + it('applies primary color class to assistant lines', () => { + const { container } = renderWithProviders(); + const p = container.querySelector('p.text-primary-600'); + expect(p).not.toBeNull(); + }); + + it('applies secondary color class to non-assistant lines', () => { + const { container } = renderWithProviders(); + const p = container.querySelector('p.text-content-secondary'); + expect(p).not.toBeNull(); + }); + + it('copy button calls navigator.clipboard.writeText with plain text', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + + renderWithProviders(); + fireEvent.click(screen.getByText('Copy')); + + // Wait for async clipboard write + await vi.waitFor(() => { + expect(writeText).toHaveBeenCalledWith('1:23 Alice: Hello there!'); + }); + }); + + it('download button creates a blob and triggers download', () => { + const createObjectURL = vi.fn().mockReturnValue('blob:test'); + const revokeObjectURL = vi.fn(); + Object.defineProperty(URL, 'createObjectURL', { value: createObjectURL, writable: true, configurable: true }); + Object.defineProperty(URL, 'revokeObjectURL', { value: revokeObjectURL, writable: true, configurable: true }); + + // Save original before mocking to avoid infinite recursion + const originalCreateElement = document.createElement.bind(document); + const clickSpy = vi.fn(); + const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = originalCreateElement(tag); + if (tag === 'a') { + el.click = clickSpy; + } + return el; + }); + + renderWithProviders(); + fireEvent.click(screen.getByText('Download')); + + expect(createObjectURL).toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:test'); + + createElementSpy.mockRestore(); + }); +}); diff --git a/app/src/components/meetings/meetingUtils.ts b/app/src/components/meetings/meetingUtils.ts index 953fb18688..b30824ac0b 100644 --- a/app/src/components/meetings/meetingUtils.ts +++ b/app/src/components/meetings/meetingUtils.ts @@ -110,3 +110,20 @@ export function resolveMeetingDisplayName( } return ''; } + +/** + * Infer the meeting platform from a URL's hostname. + * Returns null when the host doesn't match any known platform. + */ +export function inferPlatformFromUrl(url: string): MeetingPlatform | null { + try { + const host = new URL(url).hostname.toLowerCase(); + if (host.includes('meet.google.com')) return 'gmeet'; + if (host.includes('zoom.us')) return 'zoom'; + if (host.includes('teams.microsoft.com')) return 'teams'; + if (host.includes('webex.com')) return 'webex'; + return null; + } catch { + return null; + } +} diff --git a/app/src/components/skills/RecentCallsSection.test.tsx b/app/src/components/skills/RecentCallsSection.test.tsx deleted file mode 100644 index c2b013a208..0000000000 --- a/app/src/components/skills/RecentCallsSection.test.tsx +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Tests for the recent-calls panel's expandable rows. - * - * Confirms a row lazily fetches its transcript + summary on first expand, - * renders both, and degrades gracefully when the core has no detail or the - * fetch fails (with a working retry). - */ -import { configureStore } from '@reduxjs/toolkit'; -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Provider } from 'react-redux'; - -import { I18nProvider } from '../../lib/i18n/I18nContext'; -import type { MeetCallDetail, MeetCallRecord } from '../../services/meetCallService'; -import localeReducer from '../../store/localeSlice'; -import { RecentCallsSection } from './RecentCallsSection'; - -const getMeetCallDetail = vi.fn<(requestId: string) => Promise>(); - -vi.mock('../../services/meetCallService', () => ({ - getMeetCallDetail: (requestId: string) => getMeetCallDetail(requestId), -})); - -function call(overrides: Partial = {}): MeetCallRecord { - return { - request_id: 'corr-1', - meet_url: 'https://meet.google.com/yfj-hcek-zyv', - bot_display_name: 'Tiny', - owner_display_name: 'Shanu', - started_at_ms: Date.now() - 60_000, - ended_at_ms: Date.now(), - listened_seconds: 100, - spoken_seconds: 20, - turn_count: 3, - participants: ['Shanu', 'Alan'], - ...overrides, - }; -} - -function renderSection(rows: MeetCallRecord[]) { - const store = configureStore({ - reducer: { locale: localeReducer }, - preloadedState: { locale: { current: 'en' as const } }, - }); - return render( - - - - - - ); -} - -describe('RecentCallsSection', () => { - beforeEach(() => { - getMeetCallDetail.mockReset(); - }); - afterEach(() => { - vi.clearAllMocks(); - }); - - it('lazily loads and renders summary + transcript on expand', async () => { - getMeetCallDetail.mockResolvedValue({ - request_id: 'corr-1', - summary: { - headline: 'Agreed to ship Friday.', - key_points: ['Ship Friday', 'QA owns sign-off'], - action_items: [ - { description: 'Send release notes', kind: 'executable', tool_name: 'gmail', assignee: 'Sam' }, - ], - }, - transcript: [ - { role: 'participant', content: '[00:51] [Shanu] your time' }, - { role: 'assistant', content: '[00:55] [Tiny] On it.' }, - ], - }); - - renderSection([call()]); - // Not fetched until the row is expanded. - expect(getMeetCallDetail).not.toHaveBeenCalled(); - - await userEvent.click(screen.getByRole('button')); - - expect(getMeetCallDetail).toHaveBeenCalledExactlyOnceWith('corr-1'); - await waitFor(() => expect(screen.getByText('Agreed to ship Friday.')).toBeInTheDocument()); - expect(screen.getByText('Summary')).toBeInTheDocument(); - expect(screen.getByText('Ship Friday')).toBeInTheDocument(); - expect(screen.getByText('Transcript')).toBeInTheDocument(); - expect(screen.getByText('[00:55] [Tiny] On it.')).toBeInTheDocument(); - // Action item description + assignee/tool meta. - expect(screen.getByText('Send release notes')).toBeInTheDocument(); - }); - - it('shows an empty state when the call has no recorded detail', async () => { - getMeetCallDetail.mockResolvedValue(null); - renderSection([call()]); - - await userEvent.click(screen.getByRole('button')); - - await waitFor(() => - expect( - screen.getByText('No transcript or summary was captured for this call.') - ).toBeInTheDocument() - ); - }); - - it('surfaces an error with a working retry', async () => { - getMeetCallDetail.mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce({ - request_id: 'corr-1', - summary: null, - transcript: [{ role: 'participant', content: 'recovered line' }], - }); - - renderSection([call()]); - await userEvent.click(screen.getByRole('button')); - - const retry = await screen.findByRole('button', { name: 'Retry' }); - await userEvent.click(retry); - - await waitFor(() => expect(screen.getByText('recovered line')).toBeInTheDocument()); - expect(getMeetCallDetail).toHaveBeenCalledTimes(2); - }); - - it('does not refetch when collapsing and re-expanding a fully-loaded call', async () => { - getMeetCallDetail.mockResolvedValue({ - request_id: 'corr-1', - summary: { headline: 'All set.', key_points: [], action_items: [] }, - transcript: [{ role: 'participant', content: 'cached line' }], - }); - - renderSection([call()]); - const toggle = screen.getByRole('button'); - await userEvent.click(toggle); // expand → fetch - await waitFor(() => expect(screen.getByText('cached line')).toBeInTheDocument()); - await userEvent.click(toggle); // collapse - await userEvent.click(toggle); // re-expand → reuse cache (summary already present) - - expect(getMeetCallDetail).toHaveBeenCalledTimes(1); - }); - - it('refetches on re-expand to pick up a summary generated after call-end', async () => { - // First load: transcript persisted, summary still generating (null). - getMeetCallDetail - .mockResolvedValueOnce({ - request_id: 'corr-1', - summary: null, - transcript: [{ role: 'participant', content: 'early line' }], - }) - // Second load: summary has since landed. - .mockResolvedValueOnce({ - request_id: 'corr-1', - summary: { headline: 'Summary arrived.', key_points: [], action_items: [] }, - transcript: [{ role: 'participant', content: 'early line' }], - }); - - renderSection([call()]); - const toggle = screen.getByRole('button'); - await userEvent.click(toggle); // expand → first fetch (no summary yet) - await waitFor(() => expect(screen.getByText('early line')).toBeInTheDocument()); - expect(screen.queryByText('Summary arrived.')).not.toBeInTheDocument(); - - await userEvent.click(toggle); // collapse - await userEvent.click(toggle); // re-expand → refetch (summary was missing) - - await waitFor(() => expect(screen.getByText('Summary arrived.')).toBeInTheDocument()); - expect(getMeetCallDetail).toHaveBeenCalledTimes(2); - }); -}); diff --git a/app/src/components/skills/RecentCallsSection.tsx b/app/src/components/skills/RecentCallsSection.tsx deleted file mode 100644 index 0a47fb5b5c..0000000000 --- a/app/src/components/skills/RecentCallsSection.tsx +++ /dev/null @@ -1,355 +0,0 @@ -import { useCallback, useState } from 'react'; - -import { useT } from '../../lib/i18n/I18nContext'; -import { - getMeetCallDetail, - type MeetCallDetail, - type MeetCallRecord, - type MeetCallSummary, - type MeetCallTranscriptLine, -} 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). - * - * Each row is expandable: on first expand it lazily fetches the call's - * transcript + summary via `meet_agent_get_call_detail` so the list payload - * stays lean. Older calls recorded before the feature have no detail and show - * a "nothing captured" state. - * - * 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 => ( - - ))} -
- )} -
- ); -} - -type DetailStatus = 'idle' | 'loading' | 'loaded' | 'error'; - -/** - * True when `detail` carries a non-empty generated summary. The summary lands - * asynchronously after the transcript at call-end, so this gates whether a - * re-expand should refetch (still pending) or reuse the cache (already present). - */ -function hasSummaryDetail(detail: MeetCallDetail | null): boolean { - const summary = detail?.summary; - return ( - !!summary && - (summary.headline.trim().length > 0 || - summary.key_points.length > 0 || - summary.action_items.length > 0) - ); -} - -function RecentCallRow({ call }: { call: MeetCallRecord }) { - const { t } = useT(); - const [expanded, setExpanded] = useState(false); - const [status, setStatus] = useState('idle'); - const [detail, setDetail] = useState(null); - - 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); - - const loadDetail = useCallback(async () => { - setStatus('loading'); - try { - const result = await getMeetCallDetail(call.request_id); - setDetail(result); - setStatus('loaded'); - } catch (err) { - console.error('[recent-calls] failed to load call detail', call.request_id, err); - setStatus('error'); - } - }, [call.request_id]); - - const toggle = useCallback(() => { - setExpanded(prev => { - const next = !prev; - // Lazy-load on first expand. The transcript is persisted at call-end but - // the summary is generated asynchronously and patched in moments later, so - // re-expanding a row whose cached detail still lacks a summary refetches to - // pick it up. A complete (summary-present) detail is reused as-is. - if (next && (status === 'idle' || (status === 'loaded' && !hasSummaryDetail(detail)))) { - void loadDetail(); - } - return next; - }); - }, [status, detail, loadDetail]); - - return ( -
  • - - - {expanded && ( -
    - -
    - )} -
  • - ); -} - -function RecentCallDetailBody({ - status, - detail, - onRetry, -}: { - status: DetailStatus; - detail: MeetCallDetail | null; - onRetry: () => void; -}) { - const { t } = useT(); - - if (status === 'idle' || status === 'loading') { - return ( -

    - {t('skills.meetingBots.callDetailLoading')} -

    - ); - } - - if (status === 'error') { - return ( -

    - {t('skills.meetingBots.callDetailError')}{' '} - -

    - ); - } - - const summary = detail?.summary ?? null; - const transcript = detail?.transcript ?? []; - const hasSummary = hasSummaryDetail(detail); - - if (!hasSummary && transcript.length === 0) { - return ( -

    - {t('skills.meetingBots.callDetailEmpty')} -

    - ); - } - - return ( -
    - {hasSummary && summary && } - {transcript.length > 0 && } -
    - ); -} - -function CallSummary({ summary }: { summary: MeetCallSummary }) { - const { t } = useT(); - return ( -
    - {t('skills.meetingBots.callSummaryHeading')} - {summary.headline.trim() && ( -

    {summary.headline}

    - )} - {summary.key_points.length > 0 && ( -
    -

    - {t('skills.meetingBots.callKeyPointsHeading')} -

    -
      - {summary.key_points.map((point, i) => ( -
    • {point}
    • - ))} -
    -
    - )} - {summary.action_items.length > 0 && ( -
    -

    - {t('skills.meetingBots.callActionItemsHeading')} -

    -
      - {summary.action_items.map((item, i) => { - const meta = [ - item.assignee?.trim() || undefined, - item.kind === 'executable' ? item.tool_name?.trim() || undefined : undefined, - ].filter(Boolean); - return ( -
    • - - - {item.description} - {meta.length > 0 && ( - - {' '} - ({meta.join(' · ')}) - - )} - -
    • - ); - })} -
    -
    - )} -
    - ); -} - -function CallTranscript({ lines }: { lines: MeetCallTranscriptLine[] }) { - const { t } = useT(); - return ( -
    - {t('skills.meetingBots.callTranscriptHeading')} -
    - {lines.map((line, i) => ( -

    - {line.content} -

    - ))} -
    -
    - ); -} - -function SectionLabel({ children }: { children: React.ReactNode }) { - return ( -

    - {children} -

    - ); -} - -function Chevron({ expanded }: { expanded: boolean }) { - return ( - - ); -} - -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/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 4909f7d043..680b18515d 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -5088,6 +5088,17 @@ const messages: TranslationMap = { 'skills.meetingBots.activeMode': 'الرد عندما أناديه', 'skills.meetingBots.activeModeDesc': 'عند التفعيل، يرد البوت بصوت مسموع بعد أن تقول عبارة التنبيه. عند الإيقاف، يكتفي بالاستماع وتدوين النص.', + 'skills.meetingBots.history.allPlatforms': 'جميع المنصات', + 'skills.meetingBots.history.copyTranscript': 'نسخ', + 'skills.meetingBots.history.downloadTranscript': 'تنزيل', + 'skills.meetingBots.history.earlier': 'سابقًا', + 'skills.meetingBots.history.participantCount': '{count} مشارك', + 'skills.meetingBots.history.participantCountPlural': '{count} مشاركين', + 'skills.meetingBots.history.runWithOpenHuman': 'تشغيل مع OpenHuman', + 'skills.meetingBots.history.searchPlaceholder': 'البحث في المكالمات…', + 'skills.meetingBots.history.selectPrompt': 'اختر مكالمة لعرض ملخصها ونصها.', + 'skills.meetingBots.history.today': 'اليوم', + 'skills.meetingBots.history.yesterday': 'أمس', 'skills.resource.preview.closeAriaLabel': 'إغلاق المعاينة', 'skills.resource.preview.failed': 'فشلت المعاينة', 'skills.resource.preview.loading': 'جارٍ تحميل المعاينة…', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 1082d19dde..aaf7f7b88c 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -5193,6 +5193,17 @@ const messages: TranslationMap = { 'skills.meetingBots.activeMode': 'আমি ডাকলে উত্তর দেবে', 'skills.meetingBots.activeModeDesc': 'চালু থাকলে, আপনি ওয়েক ফ্রেজ বললে বটটি সশব্দে উত্তর দেয়। বন্ধ থাকলে, এটি শুধু শোনে ও প্রতিলিপি তৈরি করে।', + 'skills.meetingBots.history.allPlatforms': 'সব প্ল্যাটফর্ম', + 'skills.meetingBots.history.copyTranscript': 'কপি করুন', + 'skills.meetingBots.history.downloadTranscript': 'ডাউনলোড', + 'skills.meetingBots.history.earlier': 'আগে', + 'skills.meetingBots.history.participantCount': '{count} অংশগ্রহণকারী', + 'skills.meetingBots.history.participantCountPlural': '{count} অংশগ্রহণকারী', + 'skills.meetingBots.history.runWithOpenHuman': 'OpenHuman দিয়ে চালান', + 'skills.meetingBots.history.searchPlaceholder': 'কল খুঁজুন…', + 'skills.meetingBots.history.selectPrompt': 'সারাংশ এবং ট্রান্সক্রিপ্ট দেখতে একটি কল নির্বাচন করুন।', + 'skills.meetingBots.history.today': 'আজ', + 'skills.meetingBots.history.yesterday': 'গতকাল', 'skills.resource.preview.closeAriaLabel': 'প্রিভিউ বন্ধ করুন', 'skills.resource.preview.failed': 'প্রিভিউ ব্যর্থ', 'skills.resource.preview.loading': 'প্রিভিউ লোড হচ্ছে…', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index b661316a0a..8f8c8e6c10 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -5326,6 +5326,17 @@ const messages: TranslationMap = { 'skills.meetingBots.activeMode': 'Antworten, wenn ich es anspreche', 'skills.meetingBots.activeModeDesc': 'Wenn aktiviert, antwortet der Bot hörbar, nachdem du seinen Weckruf gesagt hast. Wenn deaktiviert, hört er nur zu und transkribiert.', + 'skills.meetingBots.history.allPlatforms': 'Alle Plattformen', + 'skills.meetingBots.history.copyTranscript': 'Kopieren', + 'skills.meetingBots.history.downloadTranscript': 'Herunterladen', + 'skills.meetingBots.history.earlier': 'Früher', + 'skills.meetingBots.history.participantCount': '{count} Teilnehmer', + 'skills.meetingBots.history.participantCountPlural': '{count} Teilnehmer', + 'skills.meetingBots.history.runWithOpenHuman': 'Mit OpenHuman ausführen', + 'skills.meetingBots.history.searchPlaceholder': 'Anrufe suchen…', + 'skills.meetingBots.history.selectPrompt': 'Wähle einen Anruf aus, um die Zusammenfassung und das Transkript zu sehen.', + 'skills.meetingBots.history.today': 'Heute', + 'skills.meetingBots.history.yesterday': 'Gestern', 'skills.resource.preview.closeAriaLabel': 'Vorschau schließen', 'skills.resource.preview.failed': 'Vorschau fehlgeschlagen', 'skills.resource.preview.loading': 'Vorschau wird geladen…', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 745d3bbecd..a7c77124db 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -5840,6 +5840,17 @@ const en: TranslationMap = { 'skills.meetingBots.activeMode': 'Respond when I address it', 'skills.meetingBots.activeModeDesc': 'When on, the bot speaks a reply after you say its wake phrase. When off, it only listens and transcribes.', + 'skills.meetingBots.history.allPlatforms': 'All platforms', + 'skills.meetingBots.history.copyTranscript': 'Copy', + 'skills.meetingBots.history.downloadTranscript': 'Download', + 'skills.meetingBots.history.earlier': 'Earlier', + 'skills.meetingBots.history.participantCount': '{count} participant', + 'skills.meetingBots.history.participantCountPlural': '{count} participants', + 'skills.meetingBots.history.runWithOpenHuman': 'Run with OpenHuman', + 'skills.meetingBots.history.searchPlaceholder': 'Search calls…', + 'skills.meetingBots.history.selectPrompt': 'Select a call to see its summary and transcript.', + 'skills.meetingBots.history.today': 'Today', + 'skills.meetingBots.history.yesterday': 'Yesterday', 'skills.resource.preview.closeAriaLabel': 'Close preview', 'skills.resource.preview.failed': 'Preview failed', 'skills.resource.preview.loading': 'Loading preview…', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 57411b5bfe..72805b1061 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -5291,6 +5291,17 @@ const messages: TranslationMap = { 'skills.meetingBots.activeMode': 'Responder cuando me dirija a él', 'skills.meetingBots.activeModeDesc': 'Si está activado, el bot responde en voz alta después de que digas su frase de activación. Si está desactivado, solo escucha y transcribe.', + 'skills.meetingBots.history.allPlatforms': 'Todas las plataformas', + 'skills.meetingBots.history.copyTranscript': 'Copiar', + 'skills.meetingBots.history.downloadTranscript': 'Descargar', + 'skills.meetingBots.history.earlier': 'Antes', + 'skills.meetingBots.history.participantCount': '{count} participante', + 'skills.meetingBots.history.participantCountPlural': '{count} participantes', + 'skills.meetingBots.history.runWithOpenHuman': 'Ejecutar con OpenHuman', + 'skills.meetingBots.history.searchPlaceholder': 'Buscar llamadas…', + 'skills.meetingBots.history.selectPrompt': 'Selecciona una llamada para ver su resumen y transcripción.', + 'skills.meetingBots.history.today': 'Hoy', + 'skills.meetingBots.history.yesterday': 'Ayer', 'skills.resource.preview.closeAriaLabel': 'Cerrar vista previa', 'skills.resource.preview.failed': 'Vista previa fallida', 'skills.resource.preview.loading': 'Cargando vista previa…', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 6c79a8b22c..89a6e7039d 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -5311,6 +5311,17 @@ const messages: TranslationMap = { 'skills.meetingBots.activeMode': 'Répondre quand je m’adresse à lui', 'skills.meetingBots.activeModeDesc': 'Activé, le bot répond à voix haute après que vous prononcez sa phrase d’activation. Désactivé, il se contente d’écouter et de transcrire.', + 'skills.meetingBots.history.allPlatforms': 'Toutes les plateformes', + 'skills.meetingBots.history.copyTranscript': 'Copier', + 'skills.meetingBots.history.downloadTranscript': 'Télécharger', + 'skills.meetingBots.history.earlier': 'Plus tôt', + 'skills.meetingBots.history.participantCount': '{count} participant', + 'skills.meetingBots.history.participantCountPlural': '{count} participants', + 'skills.meetingBots.history.runWithOpenHuman': "Exécuter avec OpenHuman", + 'skills.meetingBots.history.searchPlaceholder': 'Rechercher des appels…', + 'skills.meetingBots.history.selectPrompt': 'Sélectionnez un appel pour voir son résumé et sa transcription.', + 'skills.meetingBots.history.today': "Aujourd'hui", + 'skills.meetingBots.history.yesterday': 'Hier', 'skills.resource.preview.closeAriaLabel': "Fermer l'aperçu", 'skills.resource.preview.failed': "Échec de l'aperçu", 'skills.resource.preview.loading': "Chargement de l'aperçu…", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index d54886ee39..acc40e33f5 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -5198,6 +5198,17 @@ const messages: TranslationMap = { 'skills.meetingBots.activeMode': 'जब मैं बुलाऊँ तब जवाब दे', 'skills.meetingBots.activeModeDesc': 'चालू होने पर, वेक फ़्रेज़ कहने के बाद बॉट बोलकर जवाब देता है। बंद होने पर, यह सिर्फ़ सुनता और ट्रांसक्राइब करता है।', + 'skills.meetingBots.history.allPlatforms': 'सभी प्लेटफ़ॉर्म', + 'skills.meetingBots.history.copyTranscript': 'कॉपी करें', + 'skills.meetingBots.history.downloadTranscript': 'डाउनलोड', + 'skills.meetingBots.history.earlier': 'पहले', + 'skills.meetingBots.history.participantCount': '{count} प्रतिभागी', + 'skills.meetingBots.history.participantCountPlural': '{count} प्रतिभागी', + 'skills.meetingBots.history.runWithOpenHuman': 'OpenHuman के साथ चलाएँ', + 'skills.meetingBots.history.searchPlaceholder': 'कॉल खोजें…', + 'skills.meetingBots.history.selectPrompt': 'सारांश और ट्रांसक्रिप्ट देखने के लिए एक कॉल चुनें।', + 'skills.meetingBots.history.today': 'आज', + 'skills.meetingBots.history.yesterday': 'कल', 'skills.resource.preview.closeAriaLabel': 'प्रीव्यू बंद करें', 'skills.resource.preview.failed': 'पूर्वावलोकन विफल', 'skills.resource.preview.loading': 'प्रीव्यू लोड हो रहा है…', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 05e6027d3d..a6e11f9313 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -5212,6 +5212,17 @@ const messages: TranslationMap = { 'skills.meetingBots.activeMode': 'Tanggapi saat saya menyapa', 'skills.meetingBots.activeModeDesc': 'Saat aktif, bot menjawab dengan suara setelah Anda mengucapkan frasa pemicunya. Saat nonaktif, bot hanya mendengarkan dan mentranskripsikan.', + 'skills.meetingBots.history.allPlatforms': 'Semua platform', + 'skills.meetingBots.history.copyTranscript': 'Salin', + 'skills.meetingBots.history.downloadTranscript': 'Unduh', + 'skills.meetingBots.history.earlier': 'Sebelumnya', + 'skills.meetingBots.history.participantCount': '{count} peserta', + 'skills.meetingBots.history.participantCountPlural': '{count} peserta', + 'skills.meetingBots.history.runWithOpenHuman': 'Jalankan dengan OpenHuman', + 'skills.meetingBots.history.searchPlaceholder': 'Cari panggilan…', + 'skills.meetingBots.history.selectPrompt': 'Pilih panggilan untuk melihat ringkasan dan transkripnya.', + 'skills.meetingBots.history.today': 'Hari ini', + 'skills.meetingBots.history.yesterday': 'Kemarin', 'skills.resource.preview.closeAriaLabel': 'Tutup pratinjau', 'skills.resource.preview.failed': 'Pratinjau gagal', 'skills.resource.preview.loading': 'Memuat pratinjau...', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 86dc270b9b..e4d3a326ce 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -5280,6 +5280,17 @@ const messages: TranslationMap = { 'skills.meetingBots.activeMode': 'Rispondi quando mi rivolgo a lui', 'skills.meetingBots.activeModeDesc': 'Se attivo, il bot risponde ad alta voce dopo che pronunci la sua frase di attivazione. Se disattivato, si limita ad ascoltare e trascrivere.', + 'skills.meetingBots.history.allPlatforms': 'Tutte le piattaforme', + 'skills.meetingBots.history.copyTranscript': 'Copia', + 'skills.meetingBots.history.downloadTranscript': 'Scarica', + 'skills.meetingBots.history.earlier': 'Prima', + 'skills.meetingBots.history.participantCount': '{count} partecipante', + 'skills.meetingBots.history.participantCountPlural': '{count} partecipanti', + 'skills.meetingBots.history.runWithOpenHuman': 'Esegui con OpenHuman', + 'skills.meetingBots.history.searchPlaceholder': 'Cerca chiamate…', + 'skills.meetingBots.history.selectPrompt': 'Seleziona una chiamata per vedere il riepilogo e la trascrizione.', + 'skills.meetingBots.history.today': 'Oggi', + 'skills.meetingBots.history.yesterday': 'Ieri', 'skills.resource.preview.closeAriaLabel': 'Chiudi anteprima', 'skills.resource.preview.failed': 'Anteprima fallita', 'skills.resource.preview.loading': 'Caricamento anteprima…', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 52d54fc47e..263cda655d 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -5144,6 +5144,17 @@ const messages: TranslationMap = { 'skills.meetingBots.activeMode': '부르면 응답하기', 'skills.meetingBots.activeModeDesc': '켜면 호출 문구를 말한 뒤 봇이 소리 내어 답합니다. 끄면 듣고 기록만 합니다.', + 'skills.meetingBots.history.allPlatforms': '모든 플랫폼', + 'skills.meetingBots.history.copyTranscript': '복사', + 'skills.meetingBots.history.downloadTranscript': '다운로드', + 'skills.meetingBots.history.earlier': '이전', + 'skills.meetingBots.history.participantCount': '{count}명 참가자', + 'skills.meetingBots.history.participantCountPlural': '{count}명 참가자', + 'skills.meetingBots.history.runWithOpenHuman': 'OpenHuman으로 실행', + 'skills.meetingBots.history.searchPlaceholder': '통화 검색…', + 'skills.meetingBots.history.selectPrompt': '통화를 선택하면 요약과 전사를 볼 수 있습니다.', + 'skills.meetingBots.history.today': '오늘', + 'skills.meetingBots.history.yesterday': '어제', 'skills.resource.preview.closeAriaLabel': '미리보기 닫기', 'skills.resource.preview.failed': '미리보기 실패', 'skills.resource.preview.loading': '미리보기 불러오는 중…', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 3d0f050d8f..17ee956cc4 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -5267,6 +5267,17 @@ const messages: TranslationMap = { 'skills.meetingBots.activeMode': 'Odpowiadaj, gdy się do niego zwracam', 'skills.meetingBots.activeModeDesc': 'Gdy włączone, bot odpowiada na głos po wypowiedzeniu frazy aktywującej. Gdy wyłączone, tylko słucha i transkrybuje.', + 'skills.meetingBots.history.allPlatforms': 'Wszystkie platformy', + 'skills.meetingBots.history.copyTranscript': 'Kopiuj', + 'skills.meetingBots.history.downloadTranscript': 'Pobierz', + 'skills.meetingBots.history.earlier': 'Wcześniej', + 'skills.meetingBots.history.participantCount': '{count} uczestnik', + 'skills.meetingBots.history.participantCountPlural': '{count} uczestników', + 'skills.meetingBots.history.runWithOpenHuman': 'Uruchom z OpenHuman', + 'skills.meetingBots.history.searchPlaceholder': 'Szukaj połączeń…', + 'skills.meetingBots.history.selectPrompt': 'Wybierz połączenie, aby zobaczyć podsumowanie i transkrypt.', + 'skills.meetingBots.history.today': 'Dzisiaj', + 'skills.meetingBots.history.yesterday': 'Wczoraj', 'skills.resource.preview.closeAriaLabel': 'Zamknij podgląd', 'skills.resource.preview.failed': 'Podgląd nie powiódł się', 'skills.resource.preview.loading': 'Wczytywanie podglądu…', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 9a711c64ce..ad9d27a3b9 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -5282,6 +5282,17 @@ const messages: TranslationMap = { 'skills.meetingBots.activeMode': 'Responder quando eu falar com ele', 'skills.meetingBots.activeModeDesc': 'Quando ativado, o bot responde em voz alta depois que você diz a frase de ativação. Quando desativado, ele apenas ouve e transcreve.', + 'skills.meetingBots.history.allPlatforms': 'Todas as plataformas', + 'skills.meetingBots.history.copyTranscript': 'Copiar', + 'skills.meetingBots.history.downloadTranscript': 'Baixar', + 'skills.meetingBots.history.earlier': 'Antes', + 'skills.meetingBots.history.participantCount': '{count} participante', + 'skills.meetingBots.history.participantCountPlural': '{count} participantes', + 'skills.meetingBots.history.runWithOpenHuman': 'Executar com OpenHuman', + 'skills.meetingBots.history.searchPlaceholder': 'Pesquisar chamadas…', + 'skills.meetingBots.history.selectPrompt': 'Selecione uma chamada para ver seu resumo e transcrição.', + 'skills.meetingBots.history.today': 'Hoje', + 'skills.meetingBots.history.yesterday': 'Ontem', 'skills.resource.preview.closeAriaLabel': 'Fechar visualização', 'skills.resource.preview.failed': 'Falha na pré-visualização', 'skills.resource.preview.loading': 'Carregando visualização…', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 8bc9cfbc2d..9c2c80a378 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -5239,6 +5239,17 @@ const messages: TranslationMap = { 'skills.meetingBots.activeMode': 'Отвечать, когда я обращаюсь', 'skills.meetingBots.activeModeDesc': 'Когда включено, бот отвечает вслух после того, как вы произнесёте фразу-обращение. Когда выключено, он только слушает и расшифровывает.', + 'skills.meetingBots.history.allPlatforms': 'Все платформы', + 'skills.meetingBots.history.copyTranscript': 'Копировать', + 'skills.meetingBots.history.downloadTranscript': 'Скачать', + 'skills.meetingBots.history.earlier': 'Ранее', + 'skills.meetingBots.history.participantCount': '{count} участник', + 'skills.meetingBots.history.participantCountPlural': '{count} участников', + 'skills.meetingBots.history.runWithOpenHuman': 'Запустить с OpenHuman', + 'skills.meetingBots.history.searchPlaceholder': 'Поиск звонков…', + 'skills.meetingBots.history.selectPrompt': 'Выберите звонок для просмотра сводки и транскрипта.', + 'skills.meetingBots.history.today': 'Сегодня', + 'skills.meetingBots.history.yesterday': 'Вчера', 'skills.resource.preview.closeAriaLabel': 'Закрыть предпросмотр', 'skills.resource.preview.failed': 'Не удалось показать превью', 'skills.resource.preview.loading': 'Загрузка предпросмотра…', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 64527b0e1f..360a2273c6 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4937,6 +4937,17 @@ const messages: TranslationMap = { 'skills.meetingBots.activeMode': '当我呼叫时回应', 'skills.meetingBots.activeModeDesc': '开启后,说出唤醒词后机器人会出声回答。关闭后,它只聆听并转写。', + 'skills.meetingBots.history.allPlatforms': '所有平台', + 'skills.meetingBots.history.copyTranscript': '复制', + 'skills.meetingBots.history.downloadTranscript': '下载', + 'skills.meetingBots.history.earlier': '更早', + 'skills.meetingBots.history.participantCount': '{count} 位参与者', + 'skills.meetingBots.history.participantCountPlural': '{count} 位参与者', + 'skills.meetingBots.history.runWithOpenHuman': '使用 OpenHuman 运行', + 'skills.meetingBots.history.searchPlaceholder': '搜索通话…', + 'skills.meetingBots.history.selectPrompt': '选择一个通话以查看其摘要和转录。', + 'skills.meetingBots.history.today': '今天', + 'skills.meetingBots.history.yesterday': '昨天', 'skills.resource.preview.closeAriaLabel': '关闭预览', 'skills.resource.preview.failed': '预览失败', 'skills.resource.preview.loading': '加载预览中…', diff --git a/app/src/services/__tests__/meetCallService.test.ts b/app/src/services/__tests__/meetCallService.test.ts index bc2bbfd053..21d2f5327b 100644 --- a/app/src/services/__tests__/meetCallService.test.ts +++ b/app/src/services/__tests__/meetCallService.test.ts @@ -7,6 +7,7 @@ import { joinMeetCall, joinMeetViaBackendBot, listMeetCalls, + parseTranscriptLine, } from '../meetCallService'; vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn() })); @@ -255,3 +256,35 @@ describe('closeMeetCall', () => { expect(invoke).not.toHaveBeenCalled(); }); }); + +describe('parseTranscriptLine', () => { + it('parses a line with a [MM:SS] [Name] prefix', () => { + const result = parseTranscriptLine({ role: 'participant', content: '[1:23] [Alice] Hello there!' }); + expect(result.timestamp).toBe('1:23'); + expect(result.speaker).toBe('Alice'); + expect(result.text).toBe('Hello there!'); + expect(result.role).toBe('participant'); + }); + + it('returns null timestamp and speaker when prefix is absent', () => { + const result = parseTranscriptLine({ role: 'assistant', content: 'How can I help?' }); + expect(result.timestamp).toBeNull(); + expect(result.speaker).toBeNull(); + expect(result.text).toBe('How can I help?'); + expect(result.role).toBe('assistant'); + }); + + it('handles partial brackets — no match, returns full content', () => { + const result = parseTranscriptLine({ role: 'participant', content: '[1:23] missing second bracket' }); + expect(result.timestamp).toBeNull(); + expect(result.speaker).toBeNull(); + expect(result.text).toBe('[1:23] missing second bracket'); + }); + + it('handles malformed content gracefully', () => { + const result = parseTranscriptLine({ role: 'participant', content: 'just plain text' }); + expect(result.timestamp).toBeNull(); + expect(result.speaker).toBeNull(); + expect(result.text).toBe('just plain text'); + }); +}); diff --git a/app/src/services/meetCallService.ts b/app/src/services/meetCallService.ts index c68cd3a61d..5af734f640 100644 --- a/app/src/services/meetCallService.ts +++ b/app/src/services/meetCallService.ts @@ -218,6 +218,38 @@ export async function getMeetCallDetail(requestId: string): Promise Date: Tue, 30 Jun 2026 00:44:38 +0530 Subject: [PATCH 04/20] feat(meet): upcoming meetings table + meet_list_upcoming RPC Phase 2 of the meetings redesign. Add a full-width Upcoming table between the composer and history that lists calendar meetings with a conferencing link, the platform inferred from that link, invitee count, a relative/ absolute start time, and a per-meeting Auto/Ask/Skip join-policy toggle (local state this phase; persistence lands in Phase 3). A Join-now action appears on imminent meetings. Backend: new openhuman.meet_list_upcoming JSON-RPC fetches upcoming events via the existing Composio GOOGLECALENDAR_EVENTS_LIST path (reused, not a heartbeat refactor), extracts the join link from hangoutLink / conferenceData / description, infers the platform, and returns typed UpcomingMeeting records with the effective global join policy. Wired through agent_meetings ops/schemas + json_rpc_e2e coverage. --- .../components/meetings/JoinPolicyToggle.tsx | 73 ++ app/src/components/meetings/MeetingsPage.tsx | 3 + app/src/components/meetings/UpcomingTable.tsx | 495 ++++++++++++ .../__tests__/JoinPolicyToggle.test.tsx | 65 ++ .../meetings/__tests__/UpcomingTable.test.tsx | 197 +++++ .../__tests__/useUpcomingMeetings.test.ts | 140 ++++ .../meetings/useUpcomingMeetings.ts | 71 ++ app/src/lib/i18n/ar.ts | 21 + app/src/lib/i18n/bn.ts | 21 + app/src/lib/i18n/de.ts | 21 + app/src/lib/i18n/en.ts | 22 + app/src/lib/i18n/es.ts | 21 + app/src/lib/i18n/fr.ts | 21 + app/src/lib/i18n/hi.ts | 21 + app/src/lib/i18n/id.ts | 21 + app/src/lib/i18n/it.ts | 21 + app/src/lib/i18n/ko.ts | 21 + app/src/lib/i18n/pl.ts | 21 + app/src/lib/i18n/pt.ts | 21 + app/src/lib/i18n/ru.ts | 21 + app/src/lib/i18n/zh-CN.ts | 21 + .../__tests__/meetCallService.test.ts | 73 ++ app/src/services/meetCallService.ts | 51 ++ src/core/all.rs | 4 +- src/openhuman/agent_meetings/mod.rs | 1 + src/openhuman/agent_meetings/ops.rs | 63 +- src/openhuman/agent_meetings/schemas.rs | 56 +- src/openhuman/agent_meetings/types.rs | 47 ++ src/openhuman/agent_meetings/upcoming.rs | 734 ++++++++++++++++++ tests/json_rpc_e2e.rs | 80 ++ 30 files changed, 2445 insertions(+), 3 deletions(-) create mode 100644 app/src/components/meetings/JoinPolicyToggle.tsx create mode 100644 app/src/components/meetings/UpcomingTable.tsx create mode 100644 app/src/components/meetings/__tests__/JoinPolicyToggle.test.tsx create mode 100644 app/src/components/meetings/__tests__/UpcomingTable.test.tsx create mode 100644 app/src/components/meetings/__tests__/useUpcomingMeetings.test.ts create mode 100644 app/src/components/meetings/useUpcomingMeetings.ts create mode 100644 src/openhuman/agent_meetings/upcoming.rs diff --git a/app/src/components/meetings/JoinPolicyToggle.tsx b/app/src/components/meetings/JoinPolicyToggle.tsx new file mode 100644 index 0000000000..47c2239d38 --- /dev/null +++ b/app/src/components/meetings/JoinPolicyToggle.tsx @@ -0,0 +1,73 @@ +/** + * JoinPolicyToggle — 3-segment radio control for per-meeting join policy. + * + * Values: "auto" | "ask" | "skip" + * + * Phase 2: local state only. Phase 3 will add persistence. + */ +import { useT } from '../../lib/i18n/I18nContext'; + +export type JoinPolicy = 'auto' | 'ask' | 'skip'; + +export interface JoinPolicyToggleProps { + value: JoinPolicy; + onChange: (v: JoinPolicy) => void; + disabled?: boolean; + /** Compact variant: smaller text, tighter padding (default false). */ + compact?: boolean; +} + +const SEGMENTS: JoinPolicy[] = ['auto', 'ask', 'skip']; + +const KEY_MAP: Record = { + auto: 'skills.meetingBots.upcoming.auto', + ask: 'skills.meetingBots.upcoming.ask', + skip: 'skills.meetingBots.upcoming.skip', +}; + +export function JoinPolicyToggle({ + value, + onChange, + disabled = false, + compact = false, +}: JoinPolicyToggleProps) { + const { t } = useT(); + + return ( +
    + {SEGMENTS.map(seg => { + const isActive = seg === value; + return ( + + ); + })} +
    + ); +} diff --git a/app/src/components/meetings/MeetingsPage.tsx b/app/src/components/meetings/MeetingsPage.tsx index 9f44da6a81..c768c1bc50 100644 --- a/app/src/components/meetings/MeetingsPage.tsx +++ b/app/src/components/meetings/MeetingsPage.tsx @@ -18,6 +18,7 @@ import BetaBanner from '../ui/BetaBanner'; import { ActiveMeetingBanner } from './ActiveMeetingBanner'; import HistorySection from './HistorySection'; import { MeetComposer } from './MeetComposer'; +import { UpcomingTable } from './UpcomingTable'; const log = debug('meetings:page'); @@ -64,6 +65,8 @@ export default function MeetingsPage({ onToast }: MeetingsPageProps) { )} + +

    ); diff --git a/app/src/components/meetings/UpcomingTable.tsx b/app/src/components/meetings/UpcomingTable.tsx new file mode 100644 index 0000000000..acc7f51da0 --- /dev/null +++ b/app/src/components/meetings/UpcomingTable.tsx @@ -0,0 +1,495 @@ +/** + * UpcomingTable — full-width table of upcoming calendar meetings with + * conferencing links. + * + * Columns: WHEN / MEETING / PLATFORM / PEOPLE / JOIN POLICY / (action) + * + * Date-group separators: Today / Tomorrow / + * + * Imminent meetings (≤ 5 min until start) get an accent row and a + * "Join now" button. Other rows show a quieter join/open-link affordance. + * + * JOIN POLICY toggle: local state only (Phase 2). Phase 3 adds persistence. + */ +import debug from 'debug'; +import { useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { + joinMeetViaBackendBot, + type MeetingPlatform, + type UpcomingMeeting, +} from '../../services/meetCallService'; +import { + selectCustomPrimaryColor, + selectCustomSecondaryColor, + selectMascotColor, + selectSelectedMascotId, +} from '../../store/mascotSlice'; +import { selectPersonaDescription, selectPersonaDisplayName } from '../../store/personaSlice'; +import { useAppSelector } from '../../store/hooks'; +import Button from '../ui/Button'; +import { platformLabel, platformLogoUrl, inferPlatformFromUrl } from './meetingUtils'; +import { JoinPolicyToggle, type JoinPolicy } from './JoinPolicyToggle'; +import { useUpcomingMeetings } from './useUpcomingMeetings'; + +const log = debug('meetings:upcoming-table'); + +const IMMINENT_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function localDayKey(ms: number): string { + const d = new Date(ms); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +function todayKey(): string { + return localDayKey(Date.now()); +} + +function tomorrowKey(): string { + return localDayKey(Date.now() + 86_400_000); +} + +/** + * Format a future time as a relative label ("in 5m", "in 2h") plus an + * absolute time string (e.g. "14:30"). + */ +function formatWhen(ms: number): { relative: string; absolute: string } { + const diffMs = ms - Date.now(); + const absolute = new Date(ms).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + }); + + if (diffMs < 0) { + return { relative: 'now', absolute }; + } + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 60) { + return { relative: `in ${minutes}m`, absolute }; + } + const hours = Math.floor(minutes / 60); + return { relative: `in ${hours}h`, absolute }; +} + +function isImminent(startTimeMs: number): boolean { + return startTimeMs - Date.now() <= IMMINENT_THRESHOLD_MS; +} + +// --------------------------------------------------------------------------- +// Group meetings by local date +// --------------------------------------------------------------------------- + +interface DateGroup { + label: string; + meetings: UpcomingMeeting[]; +} + +function groupByDate( + meetings: UpcomingMeeting[], + todayLabel: string, + tomorrowLabel: string +): DateGroup[] { + const today = todayKey(); + const tomorrow = tomorrowKey(); + const buckets = new Map(); + + for (const m of meetings) { + const key = localDayKey(m.start_time_ms); + if (!buckets.has(key)) { + let label: string; + if (key === today) label = todayLabel; + else if (key === tomorrow) label = tomorrowLabel; + else { + label = new Date(m.start_time_ms).toLocaleDateString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + }); + } + buckets.set(key, { label, meetings: [] }); + } + buckets.get(key)!.meetings.push(m); + } + + return Array.from(buckets.values()); +} + +// --------------------------------------------------------------------------- +// Platform filter +// --------------------------------------------------------------------------- + +type PlatformFilter = MeetingPlatform | 'all'; + +function filterMeetings(meetings: UpcomingMeeting[], filter: PlatformFilter): UpcomingMeeting[] { + if (filter === 'all') return meetings; + return meetings.filter(m => m.platform === filter); +} + +// --------------------------------------------------------------------------- +// Row component +// --------------------------------------------------------------------------- + +interface MeetingRowProps { + meeting: UpcomingMeeting; + joinPolicy: JoinPolicy; + onJoinPolicyChange: (v: JoinPolicy) => void; + onJoin: (m: UpcomingMeeting) => void; + joining: boolean; +} + +function MeetingRow({ + meeting, + joinPolicy, + onJoinPolicyChange, + onJoin, + joining, +}: MeetingRowProps) { + const { t } = useT(); + const imminent = isImminent(meeting.start_time_ms); + const { relative, absolute } = formatWhen(meeting.start_time_ms); + + const platform = (meeting.platform ?? + (meeting.meet_url ? inferPlatformFromUrl(meeting.meet_url) ?? null : null)) as MeetingPlatform | null; + const logoUrl = platform ? platformLogoUrl(platform) : null; + const platformName = platform ? platformLabel(platform, t) : '—'; + + return ( + + {/* WHEN */} + +
    + + {relative} + + {absolute} +
    + + + {/* MEETING */} + + + {meeting.title} + + + + {/* PLATFORM */} + +
    + {logoUrl && ( + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + )} + {platformName} +
    + + + {/* PEOPLE */} + + {meeting.participant_count != null + ? t('skills.meetingBots.upcoming.participants').replace( + '{count}', + String(meeting.participant_count) + ) + : '—'} + + + {/* JOIN POLICY */} + + + + + {/* ACTION */} + + {imminent ? ( + + ) : meeting.meet_url ? ( + + ) : null} + + + ); +} + +// --------------------------------------------------------------------------- +// Loading skeleton +// --------------------------------------------------------------------------- + +function SkeletonRow() { + return ( + + {Array.from({ length: 6 }).map((_, i) => ( + +
    + + ))} + + ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export interface UpcomingTableProps { + lookaheadMinutes?: number; + limit?: number; +} + +export function UpcomingTable({ lookaheadMinutes, limit }: UpcomingTableProps) { + const { t } = useT(); + const { meetings, loading, error, refresh } = useUpcomingMeetings(lookaheadMinutes, limit); + + const [platformFilter, setPlatformFilter] = useState('all'); + const [joinPolicies, setJoinPolicies] = useState>({}); + const [joiningId, setJoiningId] = useState(null); + + // Persona / mascot selectors for bot join params — mirrors MeetComposer.tsx. + const personaDisplayName = useAppSelector(selectPersonaDisplayName); + const personaDescription = useAppSelector(selectPersonaDescription); + const selectedMascotId = useAppSelector(selectSelectedMascotId); + const mascotColor = useAppSelector(selectMascotColor); + const customPrimaryColor = useAppSelector(selectCustomPrimaryColor); + const customSecondaryColor = useAppSelector(selectCustomSecondaryColor); + + // Resolve bot join params the same way MeetComposer does. + const mascotId = selectedMascotId ?? (mascotColor === 'custom' ? undefined : mascotColor); + const riveColors = + mascotColor === 'custom' + ? { primaryColor: customPrimaryColor, secondaryColor: customSecondaryColor } + : undefined; + + const handleJoin = async (meeting: UpcomingMeeting) => { + if (!meeting.meet_url) return; + const platform = meeting.platform ?? inferPlatformFromUrl(meeting.meet_url) ?? undefined; + log('[upcoming] joining %s platform=%s', meeting.calendar_event_id, platform); + setJoiningId(meeting.calendar_event_id); + try { + await joinMeetViaBackendBot({ + meetUrl: meeting.meet_url, + platform: platform as MeetingPlatform | undefined, + agentName: personaDisplayName || undefined, + systemPrompt: personaDescription || undefined, + mascotId: mascotId || undefined, + listenOnly: true, + correlationId: meeting.calendar_event_id, + riveColors, + }); + } catch (err) { + log('[upcoming] join error: %s', err instanceof Error ? err.message : String(err)); + } finally { + setJoiningId(null); + } + }; + + const handleJoinPolicyChange = (id: string, v: JoinPolicy) => { + setJoinPolicies(prev => ({ ...prev, [id]: v })); + }; + + const filtered = filterMeetings(meetings, platformFilter); + const groups = groupByDate( + filtered, + t('skills.meetingBots.upcoming.today'), + t('skills.meetingBots.upcoming.tomorrow') + ); + + // Collect unique platforms for the filter dropdown. + const presentPlatforms = Array.from( + new Set( + meetings + .map(m => m.platform) + .filter((p): p is MeetingPlatform => p != null && p !== '') + ) + ); + + return ( +
    + {/* Table header bar */} +
    +

    + {t('skills.meetingBots.upcoming.heading')} +

    + +
    + {/* Platform filter */} + {presentPlatforms.length > 1 && ( + + )} + + {/* Refresh button */} + +
    +
    + + {/* Table */} +
    + + + + + + + + + + + + {loading && meetings.length === 0 && ( + <> + + + + + )} + + {!loading && error && ( + + + + )} + + {!loading && !error && filtered.length === 0 && ( + + + + )} + + {groups.flatMap(group => [ + /* Date-group separator row */ + + + , + /* Meeting rows for this group */ + ...group.meetings.map(m => { + const policy: JoinPolicy = + (joinPolicies[m.calendar_event_id] as JoinPolicy | undefined) ?? + ((m.join_policy as JoinPolicy) ?? 'ask'); + return ( + handleJoinPolicyChange(m.calendar_event_id, v)} + onJoin={handleJoin} + joining={joiningId === m.calendar_event_id} + /> + ); + }), + ])} + +
    + {t('skills.meetingBots.upcoming.when')} + + {t('skills.meetingBots.upcoming.meeting')} + + {t('skills.meetingBots.upcoming.platform')} + + {t('skills.meetingBots.upcoming.people')} + + {t('skills.meetingBots.upcoming.joinPolicy')} + +
    +

    + {t('skills.meetingBots.upcoming.error')} +

    + +
    +

    + {t('skills.meetingBots.upcoming.empty')} +

    +
    + {group.label} +
    +
    +
    + ); +} diff --git a/app/src/components/meetings/__tests__/JoinPolicyToggle.test.tsx b/app/src/components/meetings/__tests__/JoinPolicyToggle.test.tsx new file mode 100644 index 0000000000..ccaaad0705 --- /dev/null +++ b/app/src/components/meetings/__tests__/JoinPolicyToggle.test.tsx @@ -0,0 +1,65 @@ +import { cleanup, fireEvent, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import { JoinPolicyToggle } from '../JoinPolicyToggle'; + +describe('JoinPolicyToggle', () => { + afterEach(() => cleanup()); + + it('renders three segments: Auto, Ask, Skip', () => { + renderWithProviders( + + ); + expect(screen.getByRole('radio', { name: /auto/i })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /ask/i })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /skip/i })).toBeInTheDocument(); + }); + + it('marks the active segment as checked', () => { + renderWithProviders( + + ); + expect(screen.getByRole('radio', { name: /auto/i })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('radio', { name: /ask/i })).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByRole('radio', { name: /skip/i })).toHaveAttribute('aria-checked', 'false'); + }); + + it('calls onChange with the new value when a segment is clicked', () => { + const onChange = vi.fn(); + renderWithProviders( + + ); + fireEvent.click(screen.getByRole('radio', { name: /skip/i })); + expect(onChange).toHaveBeenCalledOnce(); + expect(onChange).toHaveBeenCalledWith('skip'); + }); + + it('does not call onChange when clicking the already-active segment', () => { + const onChange = vi.fn(); + renderWithProviders( + + ); + fireEvent.click(screen.getByRole('radio', { name: /auto/i })); + // Still fires — the parent can decide to ignore same-value changes. + expect(onChange).toHaveBeenCalledWith('auto'); + }); + + it('disables all buttons when disabled=true', () => { + renderWithProviders( + + ); + const buttons = screen.getAllByRole('radio'); + expect(buttons).toHaveLength(3); + buttons.forEach(btn => expect(btn).toBeDisabled()); + }); + + it('renders the radiogroup with a label', () => { + renderWithProviders( + + ); + // The radiogroup aria-label comes from the i18n key. + const group = screen.getByRole('radiogroup'); + expect(group).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/meetings/__tests__/UpcomingTable.test.tsx b/app/src/components/meetings/__tests__/UpcomingTable.test.tsx new file mode 100644 index 0000000000..a87b39770b --- /dev/null +++ b/app/src/components/meetings/__tests__/UpcomingTable.test.tsx @@ -0,0 +1,197 @@ +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import { UpcomingTable } from '../UpcomingTable'; + +// --------------------------------------------------------------------------- +// Mock the service so we control what meetings are returned. +// --------------------------------------------------------------------------- + +const listMock = vi.fn(); +const joinMock = vi.fn(); + +vi.mock('../../../services/meetCallService', async () => { + const actual = await vi.importActual( + '../../../services/meetCallService' + ); + return { + ...actual, + listUpcomingMeetings: (...args: unknown[]) => listMock(...args), + joinMeetViaBackendBot: (...args: unknown[]) => joinMock(...args), + }; +}); + +// --------------------------------------------------------------------------- +// Fixture data +// --------------------------------------------------------------------------- + +const NOW = Date.now(); + +function makeMeeting(overrides: Partial<{ + calendar_event_id: string; + title: string; + start_time_ms: number; + end_time_ms: number; + meet_url: string | null; + platform: string | null; + participant_count: number | null; + organizer: string | null; + join_policy: string; + calendar_source: string; +}> = {}) { + return { + calendar_event_id: 'evt-1', + title: 'Weekly Sync', + start_time_ms: NOW + 60 * 60 * 1000, // 1 hour from now + end_time_ms: NOW + 90 * 60 * 1000, + meet_url: 'https://meet.google.com/abc-def-ghi', + platform: 'gmeet', + participant_count: 4, + organizer: 'alice@example.com', + join_policy: 'ask', + calendar_source: 'google:alice@example.com', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('UpcomingTable', () => { + beforeEach(() => { + listMock.mockReset(); + joinMock.mockReset(); + }); + + afterEach(() => cleanup()); + + it('shows loading skeletons while fetching', () => { + // Let listMock hang indefinitely. + listMock.mockImplementation(() => new Promise(() => {})); + renderWithProviders(); + // Skeletons are animate-pulse rows — table is present. + expect(screen.getByRole('table')).toBeInTheDocument(); + }); + + it('renders the table heading', async () => { + listMock.mockResolvedValueOnce([]); + renderWithProviders(); + // heading key resolves to "Upcoming" in en locale + await waitFor(() => expect(screen.getByText(/upcoming/i)).toBeInTheDocument()); + }); + + it('shows empty state when no meetings are returned', async () => { + listMock.mockResolvedValueOnce([]); + renderWithProviders(); + await waitFor(() => + expect(screen.getByText(/no upcoming meetings/i)).toBeInTheDocument() + ); + }); + + it('renders a meeting row with title, platform, and participant count', async () => { + listMock.mockResolvedValueOnce([makeMeeting({ title: 'Design Review', participant_count: 7 })]); + renderWithProviders(); + await waitFor(() => expect(screen.getByText('Design Review')).toBeInTheDocument()); + // Platform label for 'gmeet' → 'Google Meet' + expect(screen.getByText(/google meet/i)).toBeInTheDocument(); + // participant count + expect(screen.getByText(/7 participants/i)).toBeInTheDocument(); + }); + + it('shows a date-group separator (Today)', async () => { + listMock.mockResolvedValueOnce([makeMeeting()]); + renderWithProviders(); + await waitFor(() => expect(screen.getByText(/today/i)).toBeInTheDocument()); + }); + + it('renders the JoinPolicyToggle for each meeting row', async () => { + listMock.mockResolvedValueOnce([makeMeeting()]); + renderWithProviders(); + await waitFor(() => expect(screen.getByRole('radiogroup')).toBeInTheDocument()); + expect(screen.getByRole('radio', { name: /ask/i })).toHaveAttribute('aria-checked', 'true'); + }); + + it('shows a "Join" button (not "Join now") for non-imminent meetings', async () => { + listMock.mockResolvedValueOnce([ + makeMeeting({ start_time_ms: NOW + 60 * 60 * 1000 }), // 1 hour away + ]); + renderWithProviders(); + await waitFor(() => { + const btn = screen.queryByRole('button', { name: /^join$/i }); + expect(btn).toBeInTheDocument(); + }); + expect(screen.queryByRole('button', { name: /join now/i })).not.toBeInTheDocument(); + }); + + it('shows a "Join now" primary button for imminent meetings (< 5 min)', async () => { + listMock.mockResolvedValueOnce([ + makeMeeting({ start_time_ms: NOW + 2 * 60 * 1000 }), // 2 min away + ]); + renderWithProviders(); + // The button has an aria-label for screen readers ("Join {title}") so + // we query by visible text content instead of accessible name. + await waitFor(() => + expect(screen.getByText('Join now')).toBeInTheDocument() + ); + }); + + it('shows error state and retry button when fetch fails', async () => { + listMock.mockRejectedValueOnce(new Error('Network fail')); + renderWithProviders(); + // Wait for the error state: the retry button is the reliable indicator + // (the error text uses a curly apostrophe that a straight-quote regex won't match). + await waitFor(() => + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument() + ); + // The error message is also present in the DOM (accept any apostrophe variant). + expect(screen.getByText(/load upcoming meetings/i)).toBeInTheDocument(); + }); + + it('retries on retry button click', async () => { + listMock + .mockRejectedValueOnce(new Error('Network fail')) + .mockResolvedValueOnce([makeMeeting({ title: 'After Retry' })]); + + renderWithProviders(); + await waitFor(() => screen.getByRole('button', { name: /retry/i })); + + fireEvent.click(screen.getByRole('button', { name: /retry/i })); + + await waitFor(() => expect(screen.getByText('After Retry')).toBeInTheDocument()); + }); + + it('renders a refresh button in the header', async () => { + listMock.mockResolvedValueOnce([]); + renderWithProviders(); + await waitFor(() => + expect(screen.getByRole('button', { name: /refresh/i })).toBeInTheDocument() + ); + }); + + it('calls joinMeetViaBackendBot when Join button is clicked', async () => { + joinMock.mockResolvedValueOnce({ meetUrl: 'https://meet.google.com/abc-def-ghi', platform: 'gmeet' }); + listMock.mockResolvedValueOnce([makeMeeting()]); + renderWithProviders(); + + const joinBtn = await screen.findByRole('button', { name: /^join$/i }); + fireEvent.click(joinBtn); + + await waitFor(() => expect(joinMock).toHaveBeenCalledOnce()); + expect(joinMock).toHaveBeenCalledWith( + expect.objectContaining({ + meetUrl: 'https://meet.google.com/abc-def-ghi', + listenOnly: true, + correlationId: 'evt-1', + }) + ); + }); + + it('does not show a join button for meetings without a conferencing URL', async () => { + listMock.mockResolvedValueOnce([makeMeeting({ meet_url: null })]); + renderWithProviders(); + await waitFor(() => expect(screen.getByText('Weekly Sync')).toBeInTheDocument()); + expect(screen.queryByRole('button', { name: /^join/i })).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/components/meetings/__tests__/useUpcomingMeetings.test.ts b/app/src/components/meetings/__tests__/useUpcomingMeetings.test.ts new file mode 100644 index 0000000000..c770d30984 --- /dev/null +++ b/app/src/components/meetings/__tests__/useUpcomingMeetings.test.ts @@ -0,0 +1,140 @@ +/** + * Tests for useUpcomingMeetings hook. + * + * Timer tests wrap vi.advanceTimersByTimeAsync in act() — this is the + * established project pattern (see CoreStateProvider.test.tsx) for settling + * async operations under fake timers without using waitFor (which uses real + * setTimeout internally and would hang when fake timers are active). + */ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useUpcomingMeetings } from '../useUpcomingMeetings'; + +const listMock = vi.fn(); + +vi.mock('../../../services/meetCallService', async () => { + const actual = await vi.importActual( + '../../../services/meetCallService' + ); + return { ...actual, listUpcomingMeetings: (...args: unknown[]) => listMock(...args) }; +}); + +const MEETING = { + calendar_event_id: 'evt-1', + title: 'Daily Standup', + start_time_ms: Date.now() + 20 * 60 * 1000, + end_time_ms: Date.now() + 50 * 60 * 1000, + meet_url: 'https://meet.google.com/abc-def-ghi', + platform: 'gmeet', + participant_count: 3, + organizer: 'alice@example.com', + join_policy: 'ask', + calendar_source: 'google:alice@example.com', +}; + +describe('useUpcomingMeetings', () => { + beforeEach(() => { + listMock.mockReset(); + }); + + it('starts in loading=true, meetings=[] state', async () => { + let resolve: (v: unknown) => void; + listMock.mockImplementation( + () => new Promise(r => { resolve = r; }) + ); + + const { result, unmount } = renderHook(() => useUpcomingMeetings()); + + expect(result.current.loading).toBe(true); + expect(result.current.meetings).toEqual([]); + expect(result.current.error).toBeNull(); + + await act(async () => { resolve!([]); }); + unmount(); + }); + + it('populates meetings on successful fetch', async () => { + listMock.mockResolvedValueOnce([MEETING]); + + const { result, unmount } = renderHook(() => useUpcomingMeetings()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.meetings).toEqual([MEETING]); + expect(result.current.error).toBeNull(); + unmount(); + }); + + it('sets error when the fetch rejects', async () => { + listMock.mockRejectedValueOnce(new Error('Network error')); + + const { result, unmount } = renderHook(() => useUpcomingMeetings()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBe('Network error'); + expect(result.current.meetings).toEqual([]); + unmount(); + }); + + it('re-fetches on refresh()', async () => { + listMock + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([MEETING]); + + const { result, unmount } = renderHook(() => useUpcomingMeetings()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.meetings).toEqual([]); + + await act(async () => { result.current.refresh(); }); + await waitFor(() => expect(result.current.meetings).toEqual([MEETING])); + unmount(); + }); + + it('passes lookaheadMinutes and limit to the service', async () => { + listMock.mockResolvedValueOnce([]); + + const { unmount } = renderHook(() => useUpcomingMeetings(120, 5)); + await waitFor(() => expect(listMock).toHaveBeenCalledWith(120, 5)); + unmount(); + }); + + it('polls again after 60 seconds', async () => { + vi.useFakeTimers(); + try { + listMock.mockResolvedValue([]); + + const { unmount } = renderHook(() => useUpcomingMeetings()); + + // Flush initial fetch + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + expect(listMock).toHaveBeenCalledTimes(1); + + // Advance 60 seconds to trigger the poll + await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); + expect(listMock).toHaveBeenCalledTimes(2); + unmount(); + } finally { + vi.useRealTimers(); + } + }); + + it('clears the interval on unmount', async () => { + vi.useFakeTimers(); + try { + listMock.mockResolvedValue([]); + + const { unmount } = renderHook(() => useUpcomingMeetings()); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + expect(listMock).toHaveBeenCalledTimes(1); + + unmount(); + listMock.mockClear(); + + // Advance past the poll interval — no further calls expected. + await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); + expect(listMock).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/app/src/components/meetings/useUpcomingMeetings.ts b/app/src/components/meetings/useUpcomingMeetings.ts new file mode 100644 index 0000000000..d28d59c6c5 --- /dev/null +++ b/app/src/components/meetings/useUpcomingMeetings.ts @@ -0,0 +1,71 @@ +/** + * Hook: fetch and periodically refresh upcoming calendar meetings. + * + * Polls every 60 s so the table stays fresh without manual refresh. + * Cleans up the poll interval on unmount and guards against setState + * after unmount. + */ +import debug from 'debug'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + listUpcomingMeetings, + type UpcomingMeeting, +} from '../../services/meetCallService'; + +const log = debug('meetings:upcoming'); + +const POLL_INTERVAL_MS = 60_000; + +export interface UseUpcomingMeetingsResult { + meetings: UpcomingMeeting[]; + loading: boolean; + error: string | null; + refresh: () => void; +} + +export function useUpcomingMeetings( + lookaheadMinutes?: number, + limit?: number +): UseUpcomingMeetingsResult { + const [meetings, setMeetings] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + + const fetchMeetings = useCallback(async () => { + log('[useUpcomingMeetings] fetching upcoming meetings'); + setLoading(true); + setError(null); + try { + const data = await listUpcomingMeetings(lookaheadMinutes, limit); + if (!mountedRef.current) return; + log('[useUpcomingMeetings] fetched %d meetings', data.length); + setMeetings(data); + } catch (err) { + if (!mountedRef.current) return; + const msg = err instanceof Error ? err.message : String(err); + log('[useUpcomingMeetings] fetch error: %s', msg); + setError(msg); + } finally { + if (mountedRef.current) setLoading(false); + } + }, [lookaheadMinutes, limit]); + + useEffect(() => { + mountedRef.current = true; + fetchMeetings(); + + const id = setInterval(() => { + log('[useUpcomingMeetings] poll tick'); + fetchMeetings(); + }, POLL_INTERVAL_MS); + + return () => { + mountedRef.current = false; + clearInterval(id); + }; + }, [fetchMeetings]); + + return { meetings, loading, error, refresh: fetchMeetings }; +} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 680b18515d..b8052169b3 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -5099,6 +5099,27 @@ const messages: TranslationMap = { 'skills.meetingBots.history.selectPrompt': 'اختر مكالمة لعرض ملخصها ونصها.', 'skills.meetingBots.history.today': 'اليوم', 'skills.meetingBots.history.yesterday': 'أمس', + 'skills.meetingBots.upcoming.heading': 'القادمة', + 'skills.meetingBots.upcoming.when': 'الوقت', + 'skills.meetingBots.upcoming.meeting': 'الاجتماع', + 'skills.meetingBots.upcoming.platform': 'المنصة', + 'skills.meetingBots.upcoming.people': 'الأشخاص', + 'skills.meetingBots.upcoming.joinPolicy': 'سياسة الانضمام', + 'skills.meetingBots.upcoming.joinNow': 'انضم الآن', + 'skills.meetingBots.upcoming.joinNowAriaLabel': 'انضم إلى {title}', + 'skills.meetingBots.upcoming.join': 'انضم', + 'skills.meetingBots.upcoming.auto': 'تلقائي', + 'skills.meetingBots.upcoming.ask': 'اسأل', + 'skills.meetingBots.upcoming.skip': 'تخطَّ', + 'skills.meetingBots.upcoming.today': 'اليوم', + 'skills.meetingBots.upcoming.tomorrow': 'غداً', + 'skills.meetingBots.upcoming.empty': 'لا توجد اجتماعات قادمة — قم بربط Google Calendar لرؤيتها هنا.', + 'skills.meetingBots.upcoming.error': 'تعذّر تحميل الاجتماعات القادمة.', + 'skills.meetingBots.upcoming.retry': 'إعادة المحاولة', + 'skills.meetingBots.upcoming.refresh': 'تحديث', + 'skills.meetingBots.upcoming.filterAll': 'كل المنصات', + 'skills.meetingBots.upcoming.participants': '{count} مشاركون', + 'skills.meetingBots.upcoming.imminent': 'يبدأ قريباً', 'skills.resource.preview.closeAriaLabel': 'إغلاق المعاينة', 'skills.resource.preview.failed': 'فشلت المعاينة', 'skills.resource.preview.loading': 'جارٍ تحميل المعاينة…', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index aaf7f7b88c..eeec9a045a 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -5204,6 +5204,27 @@ const messages: TranslationMap = { 'skills.meetingBots.history.selectPrompt': 'সারাংশ এবং ট্রান্সক্রিপ্ট দেখতে একটি কল নির্বাচন করুন।', 'skills.meetingBots.history.today': 'আজ', 'skills.meetingBots.history.yesterday': 'গতকাল', + 'skills.meetingBots.upcoming.heading': 'আসন্ন', + 'skills.meetingBots.upcoming.when': 'কখন', + 'skills.meetingBots.upcoming.meeting': 'মিটিং', + 'skills.meetingBots.upcoming.platform': 'প্ল্যাটফর্ম', + 'skills.meetingBots.upcoming.people': 'মানুষ', + 'skills.meetingBots.upcoming.joinPolicy': 'যোগদান নীতি', + 'skills.meetingBots.upcoming.joinNow': 'এখনই যোগ দিন', + 'skills.meetingBots.upcoming.joinNowAriaLabel': '{title}-তে যোগ দিন', + 'skills.meetingBots.upcoming.join': 'যোগ দিন', + 'skills.meetingBots.upcoming.auto': 'অটো', + 'skills.meetingBots.upcoming.ask': 'জিজ্ঞেস করুন', + 'skills.meetingBots.upcoming.skip': 'এড়িয়ে যান', + 'skills.meetingBots.upcoming.today': 'আজ', + 'skills.meetingBots.upcoming.tomorrow': 'আগামীকাল', + 'skills.meetingBots.upcoming.empty': 'কোনো আসন্ন মিটিং নেই — এখানে দেখতে Google Calendar সংযুক্ত করুন।', + 'skills.meetingBots.upcoming.error': 'আসন্ন মিটিং লোড করা যায়নি।', + 'skills.meetingBots.upcoming.retry': 'পুনরায় চেষ্টা করুন', + 'skills.meetingBots.upcoming.refresh': 'রিফ্রেশ', + 'skills.meetingBots.upcoming.filterAll': 'সব প্ল্যাটফর্ম', + 'skills.meetingBots.upcoming.participants': '{count} অংশগ্রহণকারী', + 'skills.meetingBots.upcoming.imminent': 'শীঘ্রই শুরু হচ্ছে', 'skills.resource.preview.closeAriaLabel': 'প্রিভিউ বন্ধ করুন', 'skills.resource.preview.failed': 'প্রিভিউ ব্যর্থ', 'skills.resource.preview.loading': 'প্রিভিউ লোড হচ্ছে…', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 8f8c8e6c10..bf9b4cb7b0 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -5337,6 +5337,27 @@ const messages: TranslationMap = { 'skills.meetingBots.history.selectPrompt': 'Wähle einen Anruf aus, um die Zusammenfassung und das Transkript zu sehen.', 'skills.meetingBots.history.today': 'Heute', 'skills.meetingBots.history.yesterday': 'Gestern', + 'skills.meetingBots.upcoming.heading': 'Bevorstehend', + 'skills.meetingBots.upcoming.when': 'Wann', + 'skills.meetingBots.upcoming.meeting': 'Meeting', + 'skills.meetingBots.upcoming.platform': 'Plattform', + 'skills.meetingBots.upcoming.people': 'Personen', + 'skills.meetingBots.upcoming.joinPolicy': 'Beitrittsstrategie', + 'skills.meetingBots.upcoming.joinNow': 'Jetzt beitreten', + 'skills.meetingBots.upcoming.joinNowAriaLabel': '{title} beitreten', + 'skills.meetingBots.upcoming.join': 'Beitreten', + 'skills.meetingBots.upcoming.auto': 'Auto', + 'skills.meetingBots.upcoming.ask': 'Fragen', + 'skills.meetingBots.upcoming.skip': 'Überspringen', + 'skills.meetingBots.upcoming.today': 'Heute', + 'skills.meetingBots.upcoming.tomorrow': 'Morgen', + 'skills.meetingBots.upcoming.empty': 'Keine bevorstehenden Meetings — verbinde Google Calendar, um sie hier zu sehen.', + 'skills.meetingBots.upcoming.error': 'Bevorstehende Meetings konnten nicht geladen werden.', + 'skills.meetingBots.upcoming.retry': 'Erneut versuchen', + 'skills.meetingBots.upcoming.refresh': 'Aktualisieren', + 'skills.meetingBots.upcoming.filterAll': 'Alle Plattformen', + 'skills.meetingBots.upcoming.participants': '{count} Teilnehmer', + 'skills.meetingBots.upcoming.imminent': 'Beginnt bald', 'skills.resource.preview.closeAriaLabel': 'Vorschau schließen', 'skills.resource.preview.failed': 'Vorschau fehlgeschlagen', 'skills.resource.preview.loading': 'Vorschau wird geladen…', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a7c77124db..99d1cc4273 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -5851,6 +5851,28 @@ const en: TranslationMap = { 'skills.meetingBots.history.selectPrompt': 'Select a call to see its summary and transcript.', 'skills.meetingBots.history.today': 'Today', 'skills.meetingBots.history.yesterday': 'Yesterday', + 'skills.meetingBots.upcoming.heading': 'Upcoming', + 'skills.meetingBots.upcoming.when': 'When', + 'skills.meetingBots.upcoming.meeting': 'Meeting', + 'skills.meetingBots.upcoming.platform': 'Platform', + 'skills.meetingBots.upcoming.people': 'People', + 'skills.meetingBots.upcoming.joinPolicy': 'Join Policy', + 'skills.meetingBots.upcoming.joinNow': 'Join now', + 'skills.meetingBots.upcoming.joinNowAriaLabel': 'Join {title}', + 'skills.meetingBots.upcoming.join': 'Join', + 'skills.meetingBots.upcoming.auto': 'Auto', + 'skills.meetingBots.upcoming.ask': 'Ask', + 'skills.meetingBots.upcoming.skip': 'Skip', + 'skills.meetingBots.upcoming.today': 'Today', + 'skills.meetingBots.upcoming.tomorrow': 'Tomorrow', + 'skills.meetingBots.upcoming.empty': + 'No upcoming meetings — connect Google Calendar to see them here.', + 'skills.meetingBots.upcoming.error': 'Couldn’t load upcoming meetings.', + 'skills.meetingBots.upcoming.retry': 'Retry', + 'skills.meetingBots.upcoming.refresh': 'Refresh', + 'skills.meetingBots.upcoming.filterAll': 'All platforms', + 'skills.meetingBots.upcoming.participants': '{count} participants', + 'skills.meetingBots.upcoming.imminent': 'Starting soon', 'skills.resource.preview.closeAriaLabel': 'Close preview', 'skills.resource.preview.failed': 'Preview failed', 'skills.resource.preview.loading': 'Loading preview…', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 72805b1061..9cbabaa222 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -5302,6 +5302,27 @@ const messages: TranslationMap = { 'skills.meetingBots.history.selectPrompt': 'Selecciona una llamada para ver su resumen y transcripción.', 'skills.meetingBots.history.today': 'Hoy', 'skills.meetingBots.history.yesterday': 'Ayer', + 'skills.meetingBots.upcoming.heading': 'Próximas', + 'skills.meetingBots.upcoming.when': 'Cuándo', + 'skills.meetingBots.upcoming.meeting': 'Reunión', + 'skills.meetingBots.upcoming.platform': 'Plataforma', + 'skills.meetingBots.upcoming.people': 'Personas', + 'skills.meetingBots.upcoming.joinPolicy': 'Política de unión', + 'skills.meetingBots.upcoming.joinNow': 'Unirse ahora', + 'skills.meetingBots.upcoming.joinNowAriaLabel': 'Unirse a {title}', + 'skills.meetingBots.upcoming.join': 'Unirse', + 'skills.meetingBots.upcoming.auto': 'Auto', + 'skills.meetingBots.upcoming.ask': 'Preguntar', + 'skills.meetingBots.upcoming.skip': 'Omitir', + 'skills.meetingBots.upcoming.today': 'Hoy', + 'skills.meetingBots.upcoming.tomorrow': 'Mañana', + 'skills.meetingBots.upcoming.empty': 'No hay reuniones próximas — conecta Google Calendar para verlas aquí.', + 'skills.meetingBots.upcoming.error': 'No se pudieron cargar las reuniones próximas.', + 'skills.meetingBots.upcoming.retry': 'Reintentar', + 'skills.meetingBots.upcoming.refresh': 'Actualizar', + 'skills.meetingBots.upcoming.filterAll': 'Todas las plataformas', + 'skills.meetingBots.upcoming.participants': '{count} participantes', + 'skills.meetingBots.upcoming.imminent': 'Comienza pronto', 'skills.resource.preview.closeAriaLabel': 'Cerrar vista previa', 'skills.resource.preview.failed': 'Vista previa fallida', 'skills.resource.preview.loading': 'Cargando vista previa…', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 89a6e7039d..1d290a2100 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -5322,6 +5322,27 @@ const messages: TranslationMap = { 'skills.meetingBots.history.selectPrompt': 'Sélectionnez un appel pour voir son résumé et sa transcription.', 'skills.meetingBots.history.today': "Aujourd'hui", 'skills.meetingBots.history.yesterday': 'Hier', + 'skills.meetingBots.upcoming.heading': 'À venir', + 'skills.meetingBots.upcoming.when': 'Quand', + 'skills.meetingBots.upcoming.meeting': 'Réunion', + 'skills.meetingBots.upcoming.platform': 'Plateforme', + 'skills.meetingBots.upcoming.people': 'Personnes', + 'skills.meetingBots.upcoming.joinPolicy': 'Politique de participation', + 'skills.meetingBots.upcoming.joinNow': 'Rejoindre maintenant', + 'skills.meetingBots.upcoming.joinNowAriaLabel': 'Rejoindre {title}', + 'skills.meetingBots.upcoming.join': 'Rejoindre', + 'skills.meetingBots.upcoming.auto': 'Auto', + 'skills.meetingBots.upcoming.ask': 'Demander', + 'skills.meetingBots.upcoming.skip': 'Ignorer', + 'skills.meetingBots.upcoming.today': "Aujourd'hui", + 'skills.meetingBots.upcoming.tomorrow': 'Demain', + 'skills.meetingBots.upcoming.empty': 'Aucune réunion à venir — connectez Google Calendar pour les voir ici.', + 'skills.meetingBots.upcoming.error': 'Impossible de charger les réunions à venir.', + 'skills.meetingBots.upcoming.retry': 'Réessayer', + 'skills.meetingBots.upcoming.refresh': 'Actualiser', + 'skills.meetingBots.upcoming.filterAll': 'Toutes les plateformes', + 'skills.meetingBots.upcoming.participants': '{count} participants', + 'skills.meetingBots.upcoming.imminent': 'Commence bientôt', 'skills.resource.preview.closeAriaLabel': "Fermer l'aperçu", 'skills.resource.preview.failed': "Échec de l'aperçu", 'skills.resource.preview.loading': "Chargement de l'aperçu…", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index acc40e33f5..20675ef403 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -5209,6 +5209,27 @@ const messages: TranslationMap = { 'skills.meetingBots.history.selectPrompt': 'सारांश और ट्रांसक्रिप्ट देखने के लिए एक कॉल चुनें।', 'skills.meetingBots.history.today': 'आज', 'skills.meetingBots.history.yesterday': 'कल', + 'skills.meetingBots.upcoming.heading': 'आगामी', + 'skills.meetingBots.upcoming.when': 'कब', + 'skills.meetingBots.upcoming.meeting': 'मीटिंग', + 'skills.meetingBots.upcoming.platform': 'प्लेटफ़ॉर्म', + 'skills.meetingBots.upcoming.people': 'लोग', + 'skills.meetingBots.upcoming.joinPolicy': 'जॉइन नीति', + 'skills.meetingBots.upcoming.joinNow': 'अभी जॉइन करें', + 'skills.meetingBots.upcoming.joinNowAriaLabel': '{title} जॉइन करें', + 'skills.meetingBots.upcoming.join': 'जॉइन करें', + 'skills.meetingBots.upcoming.auto': 'ऑटो', + 'skills.meetingBots.upcoming.ask': 'पूछें', + 'skills.meetingBots.upcoming.skip': 'छोड़ें', + 'skills.meetingBots.upcoming.today': 'आज', + 'skills.meetingBots.upcoming.tomorrow': 'कल', + 'skills.meetingBots.upcoming.empty': 'कोई आगामी मीटिंग नहीं — यहाँ देखने के लिए Google Calendar कनेक्ट करें।', + 'skills.meetingBots.upcoming.error': 'आगामी मीटिंग लोड नहीं हो सकीं।', + 'skills.meetingBots.upcoming.retry': 'पुनः प्रयास करें', + 'skills.meetingBots.upcoming.refresh': 'रिफ्रेश करें', + 'skills.meetingBots.upcoming.filterAll': 'सभी प्लेटफ़ॉर्म', + 'skills.meetingBots.upcoming.participants': '{count} प्रतिभागी', + 'skills.meetingBots.upcoming.imminent': 'जल्द शुरू हो रही है', 'skills.resource.preview.closeAriaLabel': 'प्रीव्यू बंद करें', 'skills.resource.preview.failed': 'पूर्वावलोकन विफल', 'skills.resource.preview.loading': 'प्रीव्यू लोड हो रहा है…', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index a6e11f9313..ebd59b548b 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -5223,6 +5223,27 @@ const messages: TranslationMap = { 'skills.meetingBots.history.selectPrompt': 'Pilih panggilan untuk melihat ringkasan dan transkripnya.', 'skills.meetingBots.history.today': 'Hari ini', 'skills.meetingBots.history.yesterday': 'Kemarin', + 'skills.meetingBots.upcoming.heading': 'Mendatang', + 'skills.meetingBots.upcoming.when': 'Kapan', + 'skills.meetingBots.upcoming.meeting': 'Rapat', + 'skills.meetingBots.upcoming.platform': 'Platform', + 'skills.meetingBots.upcoming.people': 'Orang', + 'skills.meetingBots.upcoming.joinPolicy': 'Kebijakan bergabung', + 'skills.meetingBots.upcoming.joinNow': 'Bergabung sekarang', + 'skills.meetingBots.upcoming.joinNowAriaLabel': 'Bergabung dengan {title}', + 'skills.meetingBots.upcoming.join': 'Bergabung', + 'skills.meetingBots.upcoming.auto': 'Otomatis', + 'skills.meetingBots.upcoming.ask': 'Tanya', + 'skills.meetingBots.upcoming.skip': 'Lewati', + 'skills.meetingBots.upcoming.today': 'Hari ini', + 'skills.meetingBots.upcoming.tomorrow': 'Besok', + 'skills.meetingBots.upcoming.empty': 'Tidak ada rapat mendatang — hubungkan Google Calendar untuk melihatnya di sini.', + 'skills.meetingBots.upcoming.error': 'Tidak dapat memuat rapat mendatang.', + 'skills.meetingBots.upcoming.retry': 'Coba lagi', + 'skills.meetingBots.upcoming.refresh': 'Segarkan', + 'skills.meetingBots.upcoming.filterAll': 'Semua platform', + 'skills.meetingBots.upcoming.participants': '{count} peserta', + 'skills.meetingBots.upcoming.imminent': 'Segera dimulai', 'skills.resource.preview.closeAriaLabel': 'Tutup pratinjau', 'skills.resource.preview.failed': 'Pratinjau gagal', 'skills.resource.preview.loading': 'Memuat pratinjau...', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index e4d3a326ce..3ccaf8d656 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -5291,6 +5291,27 @@ const messages: TranslationMap = { 'skills.meetingBots.history.selectPrompt': 'Seleziona una chiamata per vedere il riepilogo e la trascrizione.', 'skills.meetingBots.history.today': 'Oggi', 'skills.meetingBots.history.yesterday': 'Ieri', + 'skills.meetingBots.upcoming.heading': 'In arrivo', + 'skills.meetingBots.upcoming.when': 'Quando', + 'skills.meetingBots.upcoming.meeting': 'Riunione', + 'skills.meetingBots.upcoming.platform': 'Piattaforma', + 'skills.meetingBots.upcoming.people': 'Persone', + 'skills.meetingBots.upcoming.joinPolicy': 'Politica di partecipazione', + 'skills.meetingBots.upcoming.joinNow': 'Partecipa ora', + 'skills.meetingBots.upcoming.joinNowAriaLabel': 'Partecipa a {title}', + 'skills.meetingBots.upcoming.join': 'Partecipa', + 'skills.meetingBots.upcoming.auto': 'Auto', + 'skills.meetingBots.upcoming.ask': 'Chiedi', + 'skills.meetingBots.upcoming.skip': 'Salta', + 'skills.meetingBots.upcoming.today': 'Oggi', + 'skills.meetingBots.upcoming.tomorrow': 'Domani', + 'skills.meetingBots.upcoming.empty': 'Nessuna riunione in arrivo — collega Google Calendar per vederle qui.', + 'skills.meetingBots.upcoming.error': 'Impossibile caricare le riunioni in arrivo.', + 'skills.meetingBots.upcoming.retry': 'Riprova', + 'skills.meetingBots.upcoming.refresh': 'Aggiorna', + 'skills.meetingBots.upcoming.filterAll': 'Tutte le piattaforme', + 'skills.meetingBots.upcoming.participants': '{count} partecipanti', + 'skills.meetingBots.upcoming.imminent': 'Inizia presto', 'skills.resource.preview.closeAriaLabel': 'Chiudi anteprima', 'skills.resource.preview.failed': 'Anteprima fallita', 'skills.resource.preview.loading': 'Caricamento anteprima…', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 263cda655d..013ad1937f 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -5155,6 +5155,27 @@ const messages: TranslationMap = { 'skills.meetingBots.history.selectPrompt': '통화를 선택하면 요약과 전사를 볼 수 있습니다.', 'skills.meetingBots.history.today': '오늘', 'skills.meetingBots.history.yesterday': '어제', + 'skills.meetingBots.upcoming.heading': '예정된 회의', + 'skills.meetingBots.upcoming.when': '시간', + 'skills.meetingBots.upcoming.meeting': '회의', + 'skills.meetingBots.upcoming.platform': '플랫폼', + 'skills.meetingBots.upcoming.people': '참가자', + 'skills.meetingBots.upcoming.joinPolicy': '참여 정책', + 'skills.meetingBots.upcoming.joinNow': '지금 참여', + 'skills.meetingBots.upcoming.joinNowAriaLabel': '{title} 참여', + 'skills.meetingBots.upcoming.join': '참여', + 'skills.meetingBots.upcoming.auto': '자동', + 'skills.meetingBots.upcoming.ask': '묻기', + 'skills.meetingBots.upcoming.skip': '건너뛰기', + 'skills.meetingBots.upcoming.today': '오늘', + 'skills.meetingBots.upcoming.tomorrow': '내일', + 'skills.meetingBots.upcoming.empty': '예정된 회의가 없습니다 — Google Calendar를 연결하여 여기에서 확인하세요.', + 'skills.meetingBots.upcoming.error': '예정된 회의를 불러올 수 없습니다.', + 'skills.meetingBots.upcoming.retry': '다시 시도', + 'skills.meetingBots.upcoming.refresh': '새로 고침', + 'skills.meetingBots.upcoming.filterAll': '모든 플랫폼', + 'skills.meetingBots.upcoming.participants': '{count}명', + 'skills.meetingBots.upcoming.imminent': '곧 시작', 'skills.resource.preview.closeAriaLabel': '미리보기 닫기', 'skills.resource.preview.failed': '미리보기 실패', 'skills.resource.preview.loading': '미리보기 불러오는 중…', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 17ee956cc4..00b4e8dda6 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -5278,6 +5278,27 @@ const messages: TranslationMap = { 'skills.meetingBots.history.selectPrompt': 'Wybierz połączenie, aby zobaczyć podsumowanie i transkrypt.', 'skills.meetingBots.history.today': 'Dzisiaj', 'skills.meetingBots.history.yesterday': 'Wczoraj', + 'skills.meetingBots.upcoming.heading': 'Nadchodzące', + 'skills.meetingBots.upcoming.when': 'Kiedy', + 'skills.meetingBots.upcoming.meeting': 'Spotkanie', + 'skills.meetingBots.upcoming.platform': 'Platforma', + 'skills.meetingBots.upcoming.people': 'Osoby', + 'skills.meetingBots.upcoming.joinPolicy': 'Polityka dołączania', + 'skills.meetingBots.upcoming.joinNow': 'Dołącz teraz', + 'skills.meetingBots.upcoming.joinNowAriaLabel': 'Dołącz do {title}', + 'skills.meetingBots.upcoming.join': 'Dołącz', + 'skills.meetingBots.upcoming.auto': 'Auto', + 'skills.meetingBots.upcoming.ask': 'Zapytaj', + 'skills.meetingBots.upcoming.skip': 'Pomiń', + 'skills.meetingBots.upcoming.today': 'Dziś', + 'skills.meetingBots.upcoming.tomorrow': 'Jutro', + 'skills.meetingBots.upcoming.empty': 'Brak nadchodzących spotkań — połącz Google Calendar, aby je zobaczyć.', + 'skills.meetingBots.upcoming.error': 'Nie można załadować nadchodzących spotkań.', + 'skills.meetingBots.upcoming.retry': 'Ponów', + 'skills.meetingBots.upcoming.refresh': 'Odśwież', + 'skills.meetingBots.upcoming.filterAll': 'Wszystkie platformy', + 'skills.meetingBots.upcoming.participants': '{count} uczestników', + 'skills.meetingBots.upcoming.imminent': 'Zaczyna się wkrótce', 'skills.resource.preview.closeAriaLabel': 'Zamknij podgląd', 'skills.resource.preview.failed': 'Podgląd nie powiódł się', 'skills.resource.preview.loading': 'Wczytywanie podglądu…', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index ad9d27a3b9..86ba6411de 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -5293,6 +5293,27 @@ const messages: TranslationMap = { 'skills.meetingBots.history.selectPrompt': 'Selecione uma chamada para ver seu resumo e transcrição.', 'skills.meetingBots.history.today': 'Hoje', 'skills.meetingBots.history.yesterday': 'Ontem', + 'skills.meetingBots.upcoming.heading': 'Próximas', + 'skills.meetingBots.upcoming.when': 'Quando', + 'skills.meetingBots.upcoming.meeting': 'Reunião', + 'skills.meetingBots.upcoming.platform': 'Plataforma', + 'skills.meetingBots.upcoming.people': 'Pessoas', + 'skills.meetingBots.upcoming.joinPolicy': 'Política de participação', + 'skills.meetingBots.upcoming.joinNow': 'Participar agora', + 'skills.meetingBots.upcoming.joinNowAriaLabel': 'Participar de {title}', + 'skills.meetingBots.upcoming.join': 'Participar', + 'skills.meetingBots.upcoming.auto': 'Auto', + 'skills.meetingBots.upcoming.ask': 'Perguntar', + 'skills.meetingBots.upcoming.skip': 'Ignorar', + 'skills.meetingBots.upcoming.today': 'Hoje', + 'skills.meetingBots.upcoming.tomorrow': 'Amanhã', + 'skills.meetingBots.upcoming.empty': 'Sem reuniões próximas — conecte o Google Calendar para vê-las aqui.', + 'skills.meetingBots.upcoming.error': 'Não foi possível carregar as reuniões próximas.', + 'skills.meetingBots.upcoming.retry': 'Tentar novamente', + 'skills.meetingBots.upcoming.refresh': 'Atualizar', + 'skills.meetingBots.upcoming.filterAll': 'Todas as plataformas', + 'skills.meetingBots.upcoming.participants': '{count} participantes', + 'skills.meetingBots.upcoming.imminent': 'Começa em breve', 'skills.resource.preview.closeAriaLabel': 'Fechar visualização', 'skills.resource.preview.failed': 'Falha na pré-visualização', 'skills.resource.preview.loading': 'Carregando visualização…', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 9c2c80a378..83a4407d52 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -5250,6 +5250,27 @@ const messages: TranslationMap = { 'skills.meetingBots.history.selectPrompt': 'Выберите звонок для просмотра сводки и транскрипта.', 'skills.meetingBots.history.today': 'Сегодня', 'skills.meetingBots.history.yesterday': 'Вчера', + 'skills.meetingBots.upcoming.heading': 'Предстоящие', + 'skills.meetingBots.upcoming.when': 'Когда', + 'skills.meetingBots.upcoming.meeting': 'Встреча', + 'skills.meetingBots.upcoming.platform': 'Платформа', + 'skills.meetingBots.upcoming.people': 'Люди', + 'skills.meetingBots.upcoming.joinPolicy': 'Политика входа', + 'skills.meetingBots.upcoming.joinNow': 'Войти сейчас', + 'skills.meetingBots.upcoming.joinNowAriaLabel': 'Войти в {title}', + 'skills.meetingBots.upcoming.join': 'Войти', + 'skills.meetingBots.upcoming.auto': 'Авто', + 'skills.meetingBots.upcoming.ask': 'Спросить', + 'skills.meetingBots.upcoming.skip': 'Пропустить', + 'skills.meetingBots.upcoming.today': 'Сегодня', + 'skills.meetingBots.upcoming.tomorrow': 'Завтра', + 'skills.meetingBots.upcoming.empty': 'Нет предстоящих встреч — подключите Google Calendar, чтобы увидеть их здесь.', + 'skills.meetingBots.upcoming.error': 'Не удалось загрузить предстоящие встречи.', + 'skills.meetingBots.upcoming.retry': 'Повторить', + 'skills.meetingBots.upcoming.refresh': 'Обновить', + 'skills.meetingBots.upcoming.filterAll': 'Все платформы', + 'skills.meetingBots.upcoming.participants': '{count} участников', + 'skills.meetingBots.upcoming.imminent': 'Скоро начнётся', 'skills.resource.preview.closeAriaLabel': 'Закрыть предпросмотр', 'skills.resource.preview.failed': 'Не удалось показать превью', 'skills.resource.preview.loading': 'Загрузка предпросмотра…', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 360a2273c6..703c391b97 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4948,6 +4948,27 @@ const messages: TranslationMap = { 'skills.meetingBots.history.selectPrompt': '选择一个通话以查看其摘要和转录。', 'skills.meetingBots.history.today': '今天', 'skills.meetingBots.history.yesterday': '昨天', + 'skills.meetingBots.upcoming.heading': '即将举行', + 'skills.meetingBots.upcoming.when': '时间', + 'skills.meetingBots.upcoming.meeting': '会议', + 'skills.meetingBots.upcoming.platform': '平台', + 'skills.meetingBots.upcoming.people': '人员', + 'skills.meetingBots.upcoming.joinPolicy': '加入策略', + 'skills.meetingBots.upcoming.joinNow': '立即加入', + 'skills.meetingBots.upcoming.joinNowAriaLabel': '加入 {title}', + 'skills.meetingBots.upcoming.join': '加入', + 'skills.meetingBots.upcoming.auto': '自动', + 'skills.meetingBots.upcoming.ask': '询问', + 'skills.meetingBots.upcoming.skip': '跳过', + 'skills.meetingBots.upcoming.today': '今天', + 'skills.meetingBots.upcoming.tomorrow': '明天', + 'skills.meetingBots.upcoming.empty': '没有即将举行的会议 — 连接 Google Calendar 以在此查看。', + 'skills.meetingBots.upcoming.error': '无法加载即将举行的会议。', + 'skills.meetingBots.upcoming.retry': '重试', + 'skills.meetingBots.upcoming.refresh': '刷新', + 'skills.meetingBots.upcoming.filterAll': '所有平台', + 'skills.meetingBots.upcoming.participants': '{count} 位参与者', + 'skills.meetingBots.upcoming.imminent': '即将开始', 'skills.resource.preview.closeAriaLabel': '关闭预览', 'skills.resource.preview.failed': '预览失败', 'skills.resource.preview.loading': '加载预览中…', diff --git a/app/src/services/__tests__/meetCallService.test.ts b/app/src/services/__tests__/meetCallService.test.ts index 21d2f5327b..d1c1676880 100644 --- a/app/src/services/__tests__/meetCallService.test.ts +++ b/app/src/services/__tests__/meetCallService.test.ts @@ -7,6 +7,7 @@ import { joinMeetCall, joinMeetViaBackendBot, listMeetCalls, + listUpcomingMeetings, parseTranscriptLine, } from '../meetCallService'; @@ -257,6 +258,78 @@ describe('closeMeetCall', () => { }); }); +describe('listUpcomingMeetings', () => { + beforeEach(() => { + vi.mocked(callCoreRpc).mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const mockMeeting = { + calendar_event_id: 'evt-1', + title: 'Standup', + start_time_ms: Date.now() + 30 * 60 * 1000, + end_time_ms: Date.now() + 60 * 60 * 1000, + meet_url: 'https://meet.google.com/abc-def-ghi', + platform: 'gmeet', + participant_count: 4, + organizer: 'alice@example.com', + join_policy: 'ask', + calendar_source: 'google:alice@example.com', + }; + + it('calls openhuman.meet_list_upcoming with no params when no args given', async () => { + vi.mocked(callCoreRpc).mockResolvedValueOnce({ + ok: true, + meetings: [mockMeeting], + } as never); + + const result = await listUpcomingMeetings(); + + expect(callCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.meet_list_upcoming', + params: {}, + }); + expect(result).toEqual([mockMeeting]); + }); + + it('forwards lookahead_minutes and limit when provided', async () => { + vi.mocked(callCoreRpc).mockResolvedValueOnce({ + ok: true, + meetings: [], + } as never); + + await listUpcomingMeetings(120, 10); + + expect(callCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.meet_list_upcoming', + params: { lookahead_minutes: 120, limit: 10 }, + }); + }); + + it('returns an empty array when core returns no meetings', async () => { + vi.mocked(callCoreRpc).mockResolvedValueOnce({ + ok: true, + meetings: undefined, + } as never); + + const result = await listUpcomingMeetings(); + expect(result).toEqual([]); + }); + + it('throws when core returns ok: false', async () => { + vi.mocked(callCoreRpc).mockResolvedValueOnce({ ok: false } as never); + await expect(listUpcomingMeetings()).rejects.toThrow(/meet_list_upcoming/); + }); + + it('throws when core returns a falsy result', async () => { + vi.mocked(callCoreRpc).mockResolvedValueOnce(null as never); + await expect(listUpcomingMeetings()).rejects.toThrow(/meet_list_upcoming/); + }); +}); + describe('parseTranscriptLine', () => { it('parses a line with a [MM:SS] [Name] prefix', () => { const result = parseTranscriptLine({ role: 'participant', content: '[1:23] [Alice] Hello there!' }); diff --git a/app/src/services/meetCallService.ts b/app/src/services/meetCallService.ts index 5af734f640..7642e3bde4 100644 --- a/app/src/services/meetCallService.ts +++ b/app/src/services/meetCallService.ts @@ -405,6 +405,57 @@ function isApiErrorLike(value: unknown): value is { error?: unknown; message?: u return !!value && typeof value === 'object' && ('error' in value || 'message' in value); } +// --------------------------------------------------------------------------- +// Upcoming meetings (meet_list_upcoming RPC) +// --------------------------------------------------------------------------- + +/** + * One upcoming calendar meeting returned by `openhuman.meet_list_upcoming`. + * Mirrors `UpcomingMeeting` in `src/openhuman/agent_meetings/types.rs`. + */ +export interface UpcomingMeeting { + calendar_event_id: string; + title: string; + /** Unix milliseconds */ + start_time_ms: number; + /** Unix milliseconds */ + end_time_ms: number; + meet_url: string | null; + /** Platform slug: "gmeet" | "zoom" | "teams" | "webex" */ + platform: string | null; + participant_count: number | null; + organizer: string | null; + /** "auto" | "ask" | "skip" — local UI state only this phase */ + join_policy: string; + calendar_source: string; +} + +interface CoreListUpcomingResponse { + ok: boolean; + meetings: UpcomingMeeting[]; +} + +/** + * Fetch upcoming calendar meetings that have a conferencing link. + * Returns an empty array when no Google Calendar is connected. + */ +export async function listUpcomingMeetings( + lookaheadMinutes?: number, + limit?: number +): Promise { + const result = await callCoreRpc({ + method: 'openhuman.meet_list_upcoming', + params: { + ...(lookaheadMinutes != null ? { lookahead_minutes: lookaheadMinutes } : {}), + ...(limit != null ? { limit } : {}), + }, + }); + if (!result?.ok) { + throw new Error('Core rejected the meet_list_upcoming request.'); + } + return result.meetings ?? []; +} + export async function joinMeetingViaMascotBot( input: MascotJoinMeetingInput ): Promise { diff --git a/src/core/all.rs b/src/core/all.rs index 6eff82fd59..3b9eadad8d 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -627,7 +627,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> { ), "meet" => Some( "Validate Google Meet call-join requests and mint a request_id; the desktop \ - shell opens the embedded CEF webview that joins the call as an anonymous guest.", + shell opens the embedded CEF webview that joins the call as an anonymous guest. \ + Also provides meet_list_upcoming to fetch upcoming calendar meetings with \ + conferencing links from connected Google Calendar accounts.", ), "meet_agent" => Some( "Live agent loop for an open Google Meet call: shell streams inbound PCM, \ diff --git a/src/openhuman/agent_meetings/mod.rs b/src/openhuman/agent_meetings/mod.rs index d383aad18f..ef2c892494 100644 --- a/src/openhuman/agent_meetings/mod.rs +++ b/src/openhuman/agent_meetings/mod.rs @@ -23,6 +23,7 @@ pub mod schemas; pub mod store; pub mod summary; pub mod types; +pub mod upcoming; pub use schemas::{ all_controller_schemas as all_agent_meetings_controller_schemas, diff --git a/src/openhuman/agent_meetings/ops.rs b/src/openhuman/agent_meetings/ops.rs index 6aa3f24f74..d01d2906ca 100644 --- a/src/openhuman/agent_meetings/ops.rs +++ b/src/openhuman/agent_meetings/ops.rs @@ -292,7 +292,7 @@ fn validate_meeting_url(raw: &str) -> Result { Ok(url) } -fn infer_platform(url: &url::Url) -> &'static str { +pub(crate) fn infer_platform(url: &url::Url) -> &'static str { let host = url.host_str().unwrap_or(""); for (allowed, platform) in ALLOWED_HOSTS { if host == *allowed || host.ends_with(&format!(".{allowed}")) { @@ -700,6 +700,67 @@ pub async fn handle_notification_action(params: Map) -> Result) -> Result { + use crate::openhuman::config::schema::AutoJoinPolicy; + use super::types::{ListUpcomingRequest, ListUpcomingResponse}; + use super::upcoming::{fetch_upcoming_meetings, DEFAULT_LOOKAHEAD_MINUTES, DEFAULT_LIMIT}; + + let req: ListUpcomingRequest = serde_json::from_value(Value::Object(params)) + .map_err(|e| format!("[meet:upcoming] invalid params: {e}"))?; + + let lookahead_minutes = req.lookahead_minutes.unwrap_or(DEFAULT_LOOKAHEAD_MINUTES); + let limit = req.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, 100); + + tracing::debug!( + lookahead_minutes, + limit, + "[meet:upcoming] handle_list_upcoming called" + ); + + // Load config to get the auto_join_policy and composio settings. + let config = crate::openhuman::config::ops::load_config_with_timeout().await + .map_err(|e| format!("[meet:upcoming] config load failed: {e}"))?; + + // Map AutoJoinPolicy → the string the frontend/table uses for the default + // join_policy field. Phase 3 will add per-event overrides; for now every + // returned meeting carries the global setting. + let join_policy = match config.meet.auto_join_policy { + AutoJoinPolicy::Always => "auto", + AutoJoinPolicy::AskEachTime => "ask", + AutoJoinPolicy::Never => "skip", + }; + + let meetings = fetch_upcoming_meetings(&config, lookahead_minutes, limit, join_policy) + .await + .map_err(|e| format!("[meet:upcoming] fetch failed: {e}"))?; + + tracing::info!( + count = meetings.len(), + join_policy, + "[meet:upcoming] returning meetings" + ); + + let response = ListUpcomingResponse { + ok: true, + meetings, + }; + let outcome = RpcOutcome::new( + serde_json::to_value(response) + .map_err(|e| format!("[meet:upcoming] serialize failed: {e}"))?, + vec![], + ); + outcome.into_cli_compatible_json() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/openhuman/agent_meetings/schemas.rs b/src/openhuman/agent_meetings/schemas.rs index 1ee4f891cb..c686744eea 100644 --- a/src/openhuman/agent_meetings/schemas.rs +++ b/src/openhuman/agent_meetings/schemas.rs @@ -41,6 +41,11 @@ const DEFS: &[BackendMeetControllerDef] = &[ schema: schema_notification_action, handler: handle_notification_action_wrap, }, + BackendMeetControllerDef { + function: "list_upcoming", + schema: schema_list_upcoming, + handler: handle_list_upcoming_wrap, + }, ]; pub fn all_controller_schemas() -> Vec { @@ -279,6 +284,54 @@ fn handle_notification_action_wrap(params: Map) -> ControllerFutu Box::pin(async move { super::ops::handle_notification_action(params).await }) } +fn schema_list_upcoming() -> ControllerSchema { + ControllerSchema { + // NOTE: namespace is "meet" (not "agent_meetings") so the RPC name is + // `openhuman.meet_list_upcoming`. The handler lives here in the + // agent_meetings module because the logic is tightly coupled to the + // calendar/meeting infrastructure already present in this domain. + namespace: "meet", + function: "list_upcoming", + description: "List upcoming calendar meetings that have a conferencing link (Google Meet, \ + Zoom, Teams, Webex), fetched from the user's connected Google Calendar via \ + Composio. Returns an empty list when no calendar is connected. Sort order: \ + soonest first. Each record includes the inferred platform, attendee count, \ + organizer, and the global auto-join policy as the default join_policy.", + inputs: vec![ + FieldSchema { + name: "lookahead_minutes", + ty: TypeSchema::U64, + comment: "How many minutes ahead to look for meetings. Defaults to 480 (8 hours).", + required: false, + }, + FieldSchema { + name: "limit", + ty: TypeSchema::U64, + comment: "Maximum number of meetings to return. Defaults to 20. Clamped to [1, 100].", + required: false, + }, + ], + outputs: vec![ + FieldSchema { + name: "ok", + ty: TypeSchema::Bool, + comment: "Always true on success.", + required: true, + }, + FieldSchema { + name: "meetings", + ty: TypeSchema::Json, + comment: "Array of upcoming meeting records (may be empty).", + required: true, + }, + ], + } +} + +fn handle_list_upcoming_wrap(params: Map) -> ControllerFuture { + Box::pin(async move { super::ops::handle_list_upcoming(params).await }) +} + #[cfg(test)] mod tests { use super::*; @@ -301,7 +354,8 @@ mod tests { "leave", "harness_response", "speak", - "notification_action" + "notification_action", + "list_upcoming", ] ); } diff --git a/src/openhuman/agent_meetings/types.rs b/src/openhuman/agent_meetings/types.rs index bbce842300..f901d38fd2 100644 --- a/src/openhuman/agent_meetings/types.rs +++ b/src/openhuman/agent_meetings/types.rs @@ -153,6 +153,53 @@ pub struct BackendMeetSpeakRequest { pub correlation_id: Option, } +// --------------------------------------------------------------------------- +// meet_list_upcoming RPC types +// --------------------------------------------------------------------------- + +/// Inputs to `openhuman.meet_list_upcoming`. +#[derive(Debug, Clone, Deserialize)] +pub struct ListUpcomingRequest { + /// How many minutes ahead to look for meetings. Defaults to 480 (8 hours). + #[serde(default)] + pub lookahead_minutes: Option, + /// Maximum number of meetings to return. Defaults to 20. + #[serde(default)] + pub limit: Option, +} + +/// One upcoming calendar meeting that has a conferencing link. +#[derive(Debug, Clone, Serialize)] +pub struct UpcomingMeeting { + /// Calendar provider event id (stable dedupe key). + pub calendar_event_id: String, + /// Human-readable meeting title (from calendar event summary). + pub title: String, + /// Start time as Unix milliseconds. + pub start_time_ms: u64, + /// End time as Unix milliseconds. + pub end_time_ms: u64, + /// Conferencing URL (Google Meet, Zoom, Teams, Webex). + pub meet_url: Option, + /// Platform slug inferred from the URL host: gmeet, zoom, teams, webex. + pub platform: Option, + /// Number of attendees listed on the calendar event. + pub participant_count: Option, + /// Organizer display name or email, if present. + pub organizer: Option, + /// Join policy string: "auto" | "ask" | "skip" (mapped from MeetConfig.auto_join_policy). + pub join_policy: String, + /// Source integration slug, e.g. "googlecalendar". + pub calendar_source: String, +} + +/// Response from `openhuman.meet_list_upcoming`. +#[derive(Debug, Clone, Serialize)] +pub struct ListUpcomingResponse { + pub ok: bool, + pub meetings: Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/openhuman/agent_meetings/upcoming.rs b/src/openhuman/agent_meetings/upcoming.rs new file mode 100644 index 0000000000..13bd80956e --- /dev/null +++ b/src/openhuman/agent_meetings/upcoming.rs @@ -0,0 +1,734 @@ +//! Fetch upcoming calendar meetings via Composio for the +//! `openhuman.meet_list_upcoming` RPC. +//! +//! Independent of the heartbeat collectors path — two consumers, one Composio +//! access pattern. Does NOT touch the heartbeat planner or notification pipeline. +//! +//! ## Design note +//! +//! The heartbeat's `collect_calendar_events` helper is intentionally NOT shared +//! here (brief guidance: "Do NOT refactor the heartbeat collectors module to +//! share code — that risks regressing the notification planner"). We reuse only +//! the Composio client factory (`create_composio_client`) and the calendar query +//! defaults helper (`apply_calendar_query_defaults`) — both are already `pub` and +//! carry zero heartbeat logic. + +use std::collections::HashSet; + +use chrono::{DateTime, Utc}; +use serde_json::json; + +use crate::openhuman::composio::client::{ + create_composio_client, direct_execute, direct_list_connections, ComposioClientKind, +}; +use crate::openhuman::composio::types::ComposioConnection; +use crate::openhuman::config::Config; + +use super::types::UpcomingMeeting; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +pub(crate) const DEFAULT_LOOKAHEAD_MINUTES: u32 = 480; // 8 hours +pub(crate) const DEFAULT_LIMIT: u32 = 20; + +/// Supported meeting host patterns and their platform slugs. +const MEETING_HOST_PATTERNS: &[(&str, &str)] = &[ + ("meet.google.com", "gmeet"), + ("zoom.us", "zoom"), + ("teams.microsoft.com", "teams"), + ("webex.com", "webex"), +]; + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/// Fetch upcoming meetings across all connected Google Calendar accounts. +/// +/// Returns an empty `Vec` (not an error) when no calendar is connected — +/// this is expected for users who haven't linked Google Calendar yet. +pub(crate) async fn fetch_upcoming_meetings( + config: &Config, + lookahead_minutes: u32, + limit: u32, + join_policy: &str, +) -> Result, String> { + let now = Utc::now(); + let end_window = now + + chrono::Duration::minutes(i64::from(lookahead_minutes.max(1))); + + tracing::debug!( + lookahead_minutes, + limit, + now = %now, + end_window = %end_window, + "[meet:upcoming] fetch start" + ); + + // Build the mode-aware Composio client. Fails gracefully when the user + // is not signed in or has no Composio config — same pattern as the + // heartbeat planner. + let kind = match create_composio_client(config) { + Ok(k) => k, + Err(e) => { + tracing::info!( + error = %e, + "[meet:upcoming] Composio client unavailable — skipping (no calendar connected)" + ); + return Ok(Vec::new()); + } + }; + + // List connections and filter to active Google Calendar connections only. + let connections = fetch_connections(&kind).await; + let calendar_connections: Vec<_> = connections + .into_iter() + .filter(|c| c.is_active() && is_calendar_connection(c)) + .collect(); + + tracing::debug!( + count = calendar_connections.len(), + "[meet:upcoming] active calendar connections" + ); + + if calendar_connections.is_empty() { + return Ok(Vec::new()); + } + + let mut all_meetings: Vec = Vec::new(); + // Global dedup across connections: same event may appear via multiple accounts. + let mut seen_ids: HashSet = HashSet::new(); + + for conn in &calendar_connections { + let events = fetch_events_for_connection( + &kind, + conn, + &config.composio.entity_id, + now, + end_window, + join_policy, + &mut seen_ids, + ) + .await; + all_meetings.extend(events); + } + + // Sort soonest-first, apply limit. + all_meetings.sort_by_key(|m| m.start_time_ms); + all_meetings.truncate(limit as usize); + + tracing::info!( + total = all_meetings.len(), + "[meet:upcoming] fetch complete" + ); + + Ok(all_meetings) +} + +// --------------------------------------------------------------------------- +// Connection helpers +// --------------------------------------------------------------------------- + +async fn fetch_connections(kind: &ComposioClientKind) -> Vec { + match kind { + ComposioClientKind::Backend(client) => match client.list_connections().await { + Ok(resp) => { + tracing::debug!( + count = resp.connections.len(), + "[meet:upcoming] list_connections (backend) ok" + ); + resp.connections + } + Err(e) => { + tracing::warn!(error = %e, "[meet:upcoming] list_connections (backend) failed"); + Vec::new() + } + }, + ComposioClientKind::Direct(direct) => match direct_list_connections(direct).await { + Ok(resp) => { + tracing::debug!( + count = resp.connections.len(), + "[meet:upcoming] list_connections (direct) ok" + ); + resp.connections + } + Err(e) => { + tracing::warn!(error = %e, "[meet:upcoming] list_connections (direct) failed"); + Vec::new() + } + }, + } +} + +fn is_calendar_connection(conn: &ComposioConnection) -> bool { + let toolkit = conn.normalized_toolkit(); + toolkit == "googlecalendar" || toolkit == "google_calendar" || toolkit == "calendar" +} + +// --------------------------------------------------------------------------- +// Per-connection event fetch +// --------------------------------------------------------------------------- + +async fn fetch_events_for_connection( + kind: &ComposioClientKind, + conn: &ComposioConnection, + entity_id: &str, + now: DateTime, + end_window: DateTime, + join_policy: &str, + seen_ids: &mut HashSet, +) -> Vec { + let arguments = json!({ + "connectionId": conn.id, + "timeMin": now.to_rfc3339(), + "timeMax": end_window.to_rfc3339(), + "maxResults": DEFAULT_LIMIT.max(20), + }); + let iana = crate::openhuman::composio::googlecalendar_args::current_iana_timezone(); + let arguments = crate::openhuman::composio::googlecalendar_args::apply_calendar_query_defaults( + "GOOGLECALENDAR_EVENTS_LIST", + Some(arguments), + &iana, + ); + + tracing::debug!( + connection_id = %conn.id, + iana = %iana, + "[meet:upcoming] fetching GOOGLECALENDAR_EVENTS_LIST" + ); + + let resp = match kind { + ComposioClientKind::Backend(client) => { + client + .execute_tool("GOOGLECALENDAR_EVENTS_LIST", arguments) + .await + } + ComposioClientKind::Direct(direct) => { + direct_execute( + direct, + "GOOGLECALENDAR_EVENTS_LIST", + arguments, + entity_id, + None, + ) + .await + } + }; + + match resp { + Ok(r) if r.successful => { + let events = extract_upcoming_meetings(&r.data, now, end_window, join_policy, seen_ids); + tracing::debug!( + connection_id = %conn.id, + event_count = events.len(), + "[meet:upcoming] events with conferencing link extracted" + ); + events + } + Ok(r) => { + tracing::warn!( + connection_id = %conn.id, + error = %r.error.as_deref().unwrap_or("unsuccessful=true"), + "[meet:upcoming] GOOGLECALENDAR_EVENTS_LIST returned unsuccessful" + ); + Vec::new() + } + Err(e) => { + tracing::warn!( + connection_id = %conn.id, + error = %e, + "[meet:upcoming] GOOGLECALENDAR_EVENTS_LIST transport error" + ); + Vec::new() + } + } +} + +// --------------------------------------------------------------------------- +// Event extraction +// --------------------------------------------------------------------------- + +fn extract_upcoming_meetings( + data: &serde_json::Value, + start_window: DateTime, + end_window: DateTime, + join_policy: &str, + seen_ids: &mut HashSet, +) -> Vec { + let mut out = Vec::new(); + collect_recursive(data, start_window, end_window, join_policy, seen_ids, &mut out); + out +} + +fn collect_recursive( + value: &serde_json::Value, + start_window: DateTime, + end_window: DateTime, + join_policy: &str, + seen_ids: &mut HashSet, + out: &mut Vec, +) { + match value { + serde_json::Value::Array(items) => { + for item in items { + collect_recursive(item, start_window, end_window, join_policy, seen_ids, out); + } + } + serde_json::Value::Object(map) => { + if let Some(meeting) = + try_extract_meeting(map, start_window, end_window, join_policy) + { + // Global dedup: skip if this event id was already extracted from + // a different connection or an earlier part of the same response. + if seen_ids.insert(meeting.calendar_event_id.clone()) { + out.push(meeting); + } + } + // Recurse into children so we handle Composio envelope shapes + // (e.g. { "items": [...] }) without hardcoding the key name. + for child in map.values() { + collect_recursive(child, start_window, end_window, join_policy, seen_ids, out); + } + } + _ => {} + } +} + +/// Try to interpret a JSON object as a Google Calendar event with a conferencing +/// link. Returns `None` if: +/// - the object has no `start.dateTime` (all-day events, metadata objects), or +/// - the start time is outside `[start_window, end_window]`, or +/// - the event has no parseable meeting URL. +fn try_extract_meeting( + map: &serde_json::Map, + start_window: DateTime, + end_window: DateTime, + join_policy: &str, +) -> Option { + // Only timed events (start.dateTime). All-day events only have start.date. + let start_str = datetime_field(map, "start", "dateTime")?; + let start_dt = chrono::DateTime::parse_from_rfc3339(start_str).ok()?; + let start_utc = start_dt.with_timezone(&Utc); + + // Must be within the requested window. + if start_utc < start_window || start_utc > end_window { + return None; + } + + let start_ms = start_utc.timestamp_millis().max(0) as u64; + + let end_ms = datetime_field(map, "end", "dateTime") + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.timestamp_millis().max(0) as u64) + .unwrap_or_else(|| start_ms + 3_600_000); // Default 1-hour duration + + // Must have a conferencing URL. This is the filter: events without a + // meeting link are calendar items (appointments, reminders, OOO) that the + // Upcoming table doesn't need to show. + let meet_url = extract_meet_url_from_event(map)?; + + let platform = infer_platform_from_url(&meet_url).map(str::to_string); + + let title = map + .get("summary") + .or_else(|| map.get("title")) + .or_else(|| map.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("Untitled meeting") + .to_string(); + + let calendar_event_id = map + .get("id") + .or_else(|| map.get("eventId")) + .or_else(|| map.get("icalUID")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let participant_count = map + .get("attendees") + .and_then(|a| a.as_array()) + .map(|arr| arr.len() as u32); + + let organizer = map + .get("organizer") + .and_then(|o| { + o.get("displayName") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .or_else(|| o.get("email").and_then(|v| v.as_str())) + }) + .map(str::to_string); + + tracing::debug!( + calendar_event_id = %calendar_event_id, + title = %title, + start_ms, + platform = ?platform, + "[meet:upcoming] extracted meeting with conferencing link" + ); + + Some(UpcomingMeeting { + calendar_event_id, + title, + start_time_ms: start_ms, + end_time_ms: end_ms, + meet_url: Some(meet_url), + platform, + participant_count, + organizer, + join_policy: join_policy.to_string(), + calendar_source: "googlecalendar".to_string(), + }) +} + +// --------------------------------------------------------------------------- +// URL and platform helpers +// --------------------------------------------------------------------------- + +fn is_meeting_url(s: &str) -> bool { + MEETING_HOST_PATTERNS.iter().any(|(host, _)| s.contains(host)) +} + +pub(crate) fn infer_platform_from_url(url: &str) -> Option<&'static str> { + MEETING_HOST_PATTERNS + .iter() + .find(|(host, _)| url.contains(host)) + .map(|(_, platform)| *platform) +} + +/// Extract the first parseable meeting URL from a free-form text string. +fn extract_url_from_text(text: &str) -> Option { + text.split_whitespace() + .map(|tok| { + tok.trim_matches(|c: char| { + matches!( + c, + '(' | ')' | '[' | ']' | '<' | '>' | ',' | ';' | '"' | '\'' | '.' + ) + }) + }) + .filter(|tok| is_meeting_url(tok)) + .find_map(|tok| { + let parsed = url::Url::parse(tok).ok()?; + matches!(parsed.scheme(), "http" | "https").then(|| parsed.to_string()) + }) +} + +/// Extract the conferencing URL from a Google Calendar event object, checking +/// in priority order: +/// +/// 1. `hangoutLink` (Google Meet direct field) +/// 2. `conferenceData.entryPoints[].uri` +/// 3. `location` field (Zoom/Teams links often pasted here as free-form text) +/// 4. `description` field (fallback — some invites embed the link in the body) +fn extract_meet_url_from_event( + map: &serde_json::Map, +) -> Option { + // 1. hangoutLink + if let Some(link) = map.get("hangoutLink").and_then(|v| v.as_str()) { + if is_meeting_url(link) { + return Some(link.to_string()); + } + } + // 2. conferenceData.entryPoints[].uri + if let Some(entries) = map + .get("conferenceData") + .and_then(|cd| cd.get("entryPoints")) + .and_then(|ep| ep.as_array()) + { + for entry in entries { + if let Some(uri) = entry.get("uri").and_then(|v| v.as_str()) { + if is_meeting_url(uri) { + return Some(uri.to_string()); + } + } + } + } + // 3. location (free-form, e.g. "Zoom Meeting: https://zoom.us/j/123") + if let Some(loc) = map.get("location").and_then(|v| v.as_str()) { + if let Some(url) = extract_url_from_text(loc) { + return Some(url); + } + } + // 4. description (last resort) + if let Some(desc) = map.get("description").and_then(|v| v.as_str()) { + if let Some(url) = extract_url_from_text(desc) { + return Some(url); + } + } + None +} + +/// Pull a `start.dateTime` (or `end.dateTime`) string from an event map. +fn datetime_field<'a>( + map: &'a serde_json::Map, + outer: &str, + inner: &str, +) -> Option<&'a str> { + map.get(outer) + .and_then(|v| v.as_object()) + .and_then(|o| o.get(inner)) + .and_then(|v| v.as_str()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn window() -> (DateTime, DateTime) { + let now = Utc::now(); + let end = now + chrono::Duration::hours(8); + (now, end) + } + + fn future_event(offset_minutes: i64) -> serde_json::Value { + let start = Utc::now() + chrono::Duration::minutes(offset_minutes); + let end = start + chrono::Duration::hours(1); + json!({ + "id": format!("event-{offset_minutes}"), + "summary": format!("Meeting in {offset_minutes}m"), + "start": { "dateTime": start.to_rfc3339() }, + "end": { "dateTime": end.to_rfc3339() }, + "hangoutLink": "https://meet.google.com/abc-defg-hij" + }) + } + + // ── event JSON → UpcomingMeeting mapping ────────────────────── + + #[test] + fn extracts_meeting_from_event_with_hangout_link() { + let (start_window, end_window) = window(); + let event = future_event(30); + let map = event.as_object().unwrap(); + let meeting = try_extract_meeting(map, start_window, end_window, "ask").unwrap(); + assert_eq!(meeting.title, "Meeting in 30m"); + assert_eq!( + meeting.meet_url.as_deref(), + Some("https://meet.google.com/abc-defg-hij") + ); + assert_eq!(meeting.platform.as_deref(), Some("gmeet")); + assert_eq!(meeting.join_policy, "ask"); + assert_eq!(meeting.calendar_source, "googlecalendar"); + } + + #[test] + fn extracts_meeting_from_conference_data_entry_points() { + let (start_window, end_window) = window(); + let start = Utc::now() + chrono::Duration::minutes(60); + let end = start + chrono::Duration::hours(1); + let event = json!({ + "id": "ev-1", + "summary": "Zoom call", + "start": { "dateTime": start.to_rfc3339() }, + "end": { "dateTime": end.to_rfc3339() }, + "conferenceData": { + "entryPoints": [ + { "entryPointType": "video", "uri": "https://zoom.us/j/123456789" } + ] + } + }); + let map = event.as_object().unwrap(); + let meeting = try_extract_meeting(map, start_window, end_window, "ask").unwrap(); + assert_eq!(meeting.platform.as_deref(), Some("zoom")); + assert_eq!( + meeting.meet_url.as_deref(), + Some("https://zoom.us/j/123456789") + ); + } + + #[test] + fn skips_event_with_no_conferencing_link() { + let (start_window, end_window) = window(); + let start = Utc::now() + chrono::Duration::minutes(60); + let end = start + chrono::Duration::hours(1); + let event = json!({ + "id": "ev-noop", + "summary": "Lunch", + "start": { "dateTime": start.to_rfc3339() }, + "end": { "dateTime": end.to_rfc3339() }, + "location": "Office kitchen" + }); + let map = event.as_object().unwrap(); + assert!(try_extract_meeting(map, start_window, end_window, "ask").is_none()); + } + + #[test] + fn skips_all_day_event_without_datetime() { + let (start_window, end_window) = window(); + let event = json!({ + "id": "ev-allday", + "summary": "Holiday", + "start": { "date": "2026-06-30" }, + "end": { "date": "2026-07-01" }, + "hangoutLink": "https://meet.google.com/xyz" + }); + let map = event.as_object().unwrap(); + assert!(try_extract_meeting(map, start_window, end_window, "ask").is_none()); + } + + #[test] + fn skips_event_outside_window() { + let (start_window, end_window) = window(); + let far_future = Utc::now() + chrono::Duration::hours(24); + let end = far_future + chrono::Duration::hours(1); + let event = json!({ + "id": "ev-far", + "summary": "Future meeting", + "start": { "dateTime": far_future.to_rfc3339() }, + "end": { "dateTime": end.to_rfc3339() }, + "hangoutLink": "https://meet.google.com/abc" + }); + let map = event.as_object().unwrap(); + assert!(try_extract_meeting(map, start_window, end_window, "ask").is_none()); + } + + // ── platform inference ──────────────────────────────────────── + + #[test] + fn infers_platform_from_gmeet() { + assert_eq!( + infer_platform_from_url("https://meet.google.com/abc-defg-hij"), + Some("gmeet") + ); + } + + #[test] + fn infers_platform_from_zoom() { + assert_eq!( + infer_platform_from_url("https://zoom.us/j/123456789"), + Some("zoom") + ); + assert_eq!( + infer_platform_from_url("https://company.zoom.us/j/123"), + Some("zoom") + ); + } + + #[test] + fn infers_platform_from_teams() { + assert_eq!( + infer_platform_from_url("https://teams.microsoft.com/l/meetup-join/abc"), + Some("teams") + ); + } + + #[test] + fn infers_platform_from_webex() { + assert_eq!( + infer_platform_from_url("https://meet.webex.com/meet/abc"), + Some("webex") + ); + } + + #[test] + fn infers_platform_none_for_unknown_url() { + assert!(infer_platform_from_url("https://example.com/meeting").is_none()); + } + + // ── location field extraction ───────────────────────────────── + + #[test] + fn extracts_zoom_from_location_field() { + let (start_window, end_window) = window(); + let start = Utc::now() + chrono::Duration::minutes(30); + let end = start + chrono::Duration::hours(1); + let event = json!({ + "id": "ev-zoom", + "summary": "Zoom sync", + "start": { "dateTime": start.to_rfc3339() }, + "end": { "dateTime": end.to_rfc3339() }, + "location": "Zoom Meeting: https://zoom.us/j/987654321" + }); + let map = event.as_object().unwrap(); + let meeting = try_extract_meeting(map, start_window, end_window, "ask").unwrap(); + assert_eq!( + meeting.meet_url.as_deref(), + Some("https://zoom.us/j/987654321") + ); + assert_eq!(meeting.platform.as_deref(), Some("zoom")); + } + + // ── lookahead / limit / sort ────────────────────────────────── + + #[test] + fn extract_respects_window_and_sorts_by_start() { + let (start_window, end_window) = window(); + let events = json!([ + future_event(120), + future_event(30), + future_event(60), + ]); + let mut seen = HashSet::new(); + let mut out = Vec::new(); + collect_recursive( + &events, + start_window, + end_window, + "ask", + &mut seen, + &mut out, + ); + // All three are within the 8-hour window. + assert_eq!(out.len(), 3); + // They should NOT be sorted here (sorting is done in fetch_upcoming_meetings), + // but IDs should match what we inserted. + let ids: Vec<_> = out.iter().map(|m| m.calendar_event_id.as_str()).collect(); + // All ids should be present (order is input order for unsorted collection). + for id in ["event-120", "event-30", "event-60"] { + assert!(ids.contains(&id), "missing id: {id}"); + } + } + + #[test] + fn deduplicates_events_with_same_id() { + let (start_window, end_window) = window(); + let event = future_event(30); + let events = json!([event, event]); + let mut seen = HashSet::new(); + let mut out = Vec::new(); + collect_recursive( + &events, + start_window, + end_window, + "ask", + &mut seen, + &mut out, + ); + // Same id should only appear once. + assert_eq!(out.len(), 1); + } + + // ── attendee count and organizer ────────────────────────────── + + #[test] + fn extracts_attendee_count_and_organizer() { + let (start_window, end_window) = window(); + let start = Utc::now() + chrono::Duration::minutes(30); + let end = start + chrono::Duration::hours(1); + let event = json!({ + "id": "ev-people", + "summary": "Team standup", + "start": { "dateTime": start.to_rfc3339() }, + "end": { "dateTime": end.to_rfc3339() }, + "hangoutLink": "https://meet.google.com/xyz-abcd", + "attendees": [ + { "email": "alice@x.com" }, + { "email": "bob@x.com" }, + { "email": "carol@x.com" } + ], + "organizer": { "email": "alice@x.com", "displayName": "Alice" } + }); + let map = event.as_object().unwrap(); + let meeting = try_extract_meeting(map, start_window, end_window, "ask").unwrap(); + assert_eq!(meeting.participant_count, Some(3)); + assert_eq!(meeting.organizer.as_deref(), Some("Alice")); + } +} diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 4cd23bda56..f962dcbeee 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -13542,3 +13542,83 @@ async fn json_rpc_threads_token_usage_reads_persisted_thread_totals() { rpc_join.abort(); } + +/// `openhuman.meet_list_upcoming` — verifies the RPC is registered, accepts +/// optional params, and returns the correct envelope shape. +/// +/// In the test environment there is no backend session token, so +/// `create_composio_client` fails gracefully and the handler returns an +/// empty meetings list (ok=true, meetings=[]) rather than an error. +/// This validates the graceful no-calendar path without requiring a live +/// Composio connection. +#[tokio::test] +async fn json_rpc_meet_list_upcoming_returns_empty_when_no_calendar_connected() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + + write_min_config(&openhuman_home, "http://127.0.0.1:9"); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{rpc_addr}"); + + tokio::time::sleep(Duration::from_millis(50)).await; + + // --- no params: defaults apply, returns ok=true with empty meetings --- + let resp_default = post_json_rpc( + &rpc_base, + 9200, + "openhuman.meet_list_upcoming", + json!({}), + ) + .await; + let result = assert_no_jsonrpc_error(&resp_default, "meet_list_upcoming no-params"); + let body = result.get("result").unwrap_or(result); + assert_eq!( + body.get("ok"), + Some(&json!(true)), + "ok must be true when no calendar connected" + ); + let meetings = body + .get("meetings") + .and_then(|v| v.as_array()) + .expect("meetings array must be present"); + assert!( + meetings.is_empty(), + "meetings must be empty when no Composio client available" + ); + + // --- explicit lookahead_minutes + limit: still returns ok=true with empty --- + let resp_explicit = post_json_rpc( + &rpc_base, + 9201, + "openhuman.meet_list_upcoming", + json!({ "lookahead_minutes": 120, "limit": 5 }), + ) + .await; + let result2 = assert_no_jsonrpc_error(&resp_explicit, "meet_list_upcoming explicit params"); + let body2 = result2.get("result").unwrap_or(result2); + assert_eq!(body2.get("ok"), Some(&json!(true))); + assert!(body2 + .get("meetings") + .and_then(|v| v.as_array()) + .is_some_and(|arr| arr.is_empty())); + + // --- invalid param type: lookahead_minutes must be a number --- + let resp_bad = post_json_rpc( + &rpc_base, + 9202, + "openhuman.meet_list_upcoming", + json!({ "lookahead_minutes": "not-a-number" }), + ) + .await; + assert_jsonrpc_error(&resp_bad, "meet_list_upcoming bad lookahead type"); + + rpc_join.abort(); +} From c7c44835ba58a9be6c594779236a480e4f1ed533 Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Tue, 30 Jun 2026 01:24:37 +0530 Subject: [PATCH 05/20] feat(meet): persist per-meeting join policy + defaults drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 (final) of the meetings redesign. Make the Auto/Ask/Skip join policy persist and actually drive auto-join, with resolution order per-event override > per-platform default > global default. Backend: new meeting_event_policies SQLite table + meet_set_event_policy / meet_get_event_policies RPCs; add platform_auto_join_policies map to MeetConfig (serde-defaulted, backward compatible); resolve_effective_join_policy feeds both meet_list_upcoming (effective per-row policy) and the calendar auto-join candidate handler (policy source only — scheduler untouched); the heartbeat planner now threads the calendar_event_id through. Frontend: MeetDefaultsDrawer (gear button) for global + per-platform defaults and the existing listen-only/summarize/ingest settings; the Upcoming table's policy toggle persists via setEventPolicy with optimistic update + revert, and shows auto/ask hints per row. --- .../meetings/MeetDefaultsDrawer.tsx | 349 ++++++++++++++++++ app/src/components/meetings/MeetingsPage.tsx | 34 +- app/src/components/meetings/UpcomingTable.tsx | 40 +- .../__tests__/MeetDefaultsDrawer.test.tsx | 110 ++++++ .../meetings/__tests__/UpcomingTable.test.tsx | 35 ++ app/src/lib/i18n/ar.ts | 9 + app/src/lib/i18n/bn.ts | 9 + app/src/lib/i18n/de.ts | 9 + app/src/lib/i18n/en.ts | 9 + app/src/lib/i18n/es.ts | 9 + app/src/lib/i18n/fr.ts | 9 + app/src/lib/i18n/hi.ts | 9 + app/src/lib/i18n/id.ts | 9 + app/src/lib/i18n/it.ts | 9 + app/src/lib/i18n/ko.ts | 9 + app/src/lib/i18n/pl.ts | 9 + app/src/lib/i18n/pt.ts | 9 + app/src/lib/i18n/ru.ts | 9 + app/src/lib/i18n/zh-CN.ts | 9 + .../__tests__/meetCallService.test.ts | 57 +++ app/src/services/meetCallService.ts | 48 +++ app/src/utils/tauriCommands/config.ts | 4 + src/openhuman/agent_meetings/calendar.rs | 42 ++- src/openhuman/agent_meetings/ops.rs | 245 +++++++++++- src/openhuman/agent_meetings/schemas.rs | 81 ++++ src/openhuman/agent_meetings/store.rs | 101 +++++ src/openhuman/agent_meetings/types.rs | 31 ++ src/openhuman/config/ops/ui.rs | 8 + src/openhuman/config/schema/meet.rs | 24 ++ src/openhuman/config/schemas/controllers.rs | 28 +- src/openhuman/config/schemas/helpers.rs | 3 + .../subconscious/heartbeat/planner/mod.rs | 3 + tests/json_rpc_e2e.rs | 104 ++++++ 33 files changed, 1449 insertions(+), 24 deletions(-) create mode 100644 app/src/components/meetings/MeetDefaultsDrawer.tsx create mode 100644 app/src/components/meetings/__tests__/MeetDefaultsDrawer.test.tsx diff --git a/app/src/components/meetings/MeetDefaultsDrawer.tsx b/app/src/components/meetings/MeetDefaultsDrawer.tsx new file mode 100644 index 0000000000..89c3125ecd --- /dev/null +++ b/app/src/components/meetings/MeetDefaultsDrawer.tsx @@ -0,0 +1,349 @@ +/** + * MeetDefaultsDrawer — slide-over drawer for global and per-platform meeting defaults. + * + * Opened via the gear button in MeetingsPage. Uses the same settings primitives + * (SettingsSection / SettingsRow / SettingsSelect / SettingsSwitch) as + * MeetingSettingsPanel. Saves via the existing config_update_meet_settings RPC. + */ +import debug from 'debug'; +import { useEffect, useRef, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { + isTauri, + type MeetAutoJoinPolicy, + type MeetAutoSummarizePolicy, + openhumanGetMeetSettings, + openhumanUpdateMeetSettings, +} from '../../utils/tauriCommands'; +import { + SettingsRow, + SettingsSection, + SettingsSelect, + SettingsStatusLine, + SettingsSwitch, +} from '../settings/controls'; + +const log = debug('meetings:defaults-drawer'); + +export interface MeetDefaultsDrawerProps { + open: boolean; + onClose: () => void; +} + +// Platform slugs in display order +const PLATFORMS: Array<{ key: string; labelKey: string }> = [ + { key: 'gmeet', labelKey: 'skills.meetingBots.platforms.gmeet' }, + { key: 'zoom', labelKey: 'skills.meetingBots.platforms.zoom' }, + { key: 'teams', labelKey: 'skills.meetingBots.platforms.teams' }, + { key: 'webex', labelKey: 'skills.meetingBots.platforms.webex' }, +]; + +// Values for global auto-join select +const AUTO_JOIN_OPTIONS: MeetAutoJoinPolicy[] = ['ask_each_time', 'always', 'never']; +// Values for per-platform override (includes "default" meaning: use global) +type PlatformPolicy = MeetAutoJoinPolicy | 'default'; +const PLATFORM_OPTIONS: PlatformPolicy[] = ['default', 'ask_each_time', 'always', 'never']; + +const AUTO_JOIN_LABEL_KEY: Record = { + ask_each_time: 'settings.meetings.autoJoin.askEachTime', + always: 'settings.meetings.autoJoin.always', + never: 'settings.meetings.autoJoin.never', +}; + +const AUTO_SUMMARIZE_OPTIONS: MeetAutoSummarizePolicy[] = ['ask', 'always', 'never']; +const AUTO_SUMMARIZE_LABEL_KEY: Record = { + ask: 'settings.meetings.autoSummarize.ask', + always: 'settings.meetings.autoSummarize.always', + never: 'settings.meetings.autoSummarize.never', +}; + +export function MeetDefaultsDrawer({ open, onClose }: MeetDefaultsDrawerProps) { + const { t } = useT(); + + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [savedNote, setSavedNote] = useState(null); + + // Global settings + const [autoJoin, setAutoJoin] = useState('ask_each_time'); + const [autoSummarize, setAutoSummarize] = useState('ask'); + const [listenOnly, setListenOnly] = useState(true); + const [ingestTranscripts, setIngestTranscripts] = useState(false); + + // Per-platform overrides: key → MeetAutoJoinPolicy | undefined (undefined = use default) + const [platformPolicies, setPlatformPolicies] = useState>({}); + + const persistSeqRef = useRef(0); + + // Load settings when opened + useEffect(() => { + if (!open) return; + if (!isTauri()) { + setLoading(false); + return; + } + let cancelled = false; + setLoading(true); + setError(null); + const load = async () => { + log('load start'); + try { + const resp = await openhumanGetMeetSettings(); + if (cancelled) return; + const s = resp.result; + log('load ok auto_join=%s', s.auto_join_policy); + setAutoJoin(s.auto_join_policy); + setAutoSummarize(s.auto_summarize_policy); + setListenOnly(s.listen_only_default); + setIngestTranscripts(s.ingest_backend_transcripts); + // Build per-platform state: stored as "ask_each_time"|"always"|"never", display as that or "default" + const pp: Record = {}; + const stored = s.platform_auto_join_policies ?? {}; + for (const plat of PLATFORMS.map(p => p.key)) { + pp[plat] = (stored[plat] as MeetAutoJoinPolicy | undefined) ?? 'default'; + } + setPlatformPolicies(pp); + } catch (e) { + log('load failed err=%o', e); + if (!cancelled) setError(e instanceof Error ? e.message : t('settings.meetings.loadError')); + } finally { + if (!cancelled) setLoading(false); + } + }; + void load(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const persist = async ( + patch: Parameters[0], + onFailure?: () => void + ) => { + const seq = ++persistSeqRef.current; + if (!isTauri()) return; + log('persist patch=%o', patch); + setError(null); + setSavedNote(null); + setSaving(true); + try { + await openhumanUpdateMeetSettings(patch); + if (seq !== persistSeqRef.current) return; + setSavedNote(t('settings.meetings.saved')); + } catch (e) { + if (seq !== persistSeqRef.current) return; + onFailure?.(); + setError(e instanceof Error ? e.message : t('settings.meetings.saveError')); + } finally { + if (seq === persistSeqRef.current) setSaving(false); + } + }; + + const handleAutoJoinChange = (next: MeetAutoJoinPolicy) => { + const prev = autoJoin; + setAutoJoin(next); + void persist({ auto_join_policy: next }, () => setAutoJoin(prev)); + }; + + const handleAutoSummarizeChange = (next: MeetAutoSummarizePolicy) => { + const prev = autoSummarize; + setAutoSummarize(next); + void persist({ auto_summarize_policy: next }, () => setAutoSummarize(prev)); + }; + + const handleListenOnlyChange = (next: boolean) => { + const prev = listenOnly; + setListenOnly(next); + void persist({ listen_only_default: next }, () => setListenOnly(prev)); + }; + + const handleIngestChange = (next: boolean) => { + const prev = ingestTranscripts; + setIngestTranscripts(next); + void persist({ ingest_backend_transcripts: next }, () => setIngestTranscripts(prev)); + }; + + const handlePlatformPolicyChange = (platform: string, next: PlatformPolicy) => { + const prevValue = platformPolicies[platform] ?? 'default'; + const updated = { ...platformPolicies, [platform]: next }; + setPlatformPolicies(updated); + + // Build the map to persist: only include non-"default" entries + const toSave: Record = {}; + for (const [k, v] of Object.entries(updated)) { + if (v !== 'default') { + toSave[k] = v as MeetAutoJoinPolicy; + } + } + void persist( + { platform_auto_join_policies: toSave }, + () => setPlatformPolicies(current => ({ ...current, [platform]: prevValue })) + ); + }; + + if (!open) return null; + + return ( + <> + {/* Backdrop */} +