diff --git a/app/src/components/composio/toolkitMeta.tsx b/app/src/components/composio/toolkitMeta.tsx index e81829a4b4..ad69dcd5f1 100644 --- a/app/src/components/composio/toolkitMeta.tsx +++ b/app/src/components/composio/toolkitMeta.tsx @@ -282,7 +282,8 @@ function ComposioLogoBadge({ ); } -function composioLogoUrl(slug: string): string { +/** Composio-hosted logo URL for a given toolkit slug. */ +export function composioLogoUrl(slug: string): string { return `https://logos.composio.dev/api/${slug}`; } diff --git a/app/src/components/meetings/ActionItemChecklist.tsx b/app/src/components/meetings/ActionItemChecklist.tsx new file mode 100644 index 0000000000..78d57a4c7f --- /dev/null +++ b/app/src/components/meetings/ActionItemChecklist.tsx @@ -0,0 +1,90 @@ +/** + * 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')} +

+ +
+ ); +} + +export default ActionItemChecklist; 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/HistoryDetail.tsx b/app/src/components/meetings/HistoryDetail.tsx new file mode 100644 index 0000000000..6aae6e7acc --- /dev/null +++ b/app/src/components/meetings/HistoryDetail.tsx @@ -0,0 +1,273 @@ +/** + * 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 ActionItemChecklist from './ActionItemChecklist'; +import { inferPlatformFromUrl, platformLabel, platformLogoUrl } from './meetingUtils'; +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; +} + +/** + * Bundles a loaded detail result with the request_id it was fetched for. + * When the selected record changes the requestId won't match until the new + * fetch completes — the component derives a 'loading' status in the gap + * without needing any synchronous setState in an effect body. + */ +interface LoadedResult { + requestId: string; + status: DetailStatus; + detail: MeetCallDetail | null; +} + +export function HistoryDetail({ record }: HistoryDetailProps) { + const { t } = useT(); + + // Keyed state: bundles status+detail with the request_id they belong to. + // "Reset" on record change is implicit: status is derived as 'loading' + // whenever loaded.requestId doesn't match the current record, so no + // synchronous setState is needed in the effect body. + const [loaded, setLoaded] = useState({ + requestId: '', + status: 'idle', + detail: null, + }); + + // Tracks the latest requested request_id so stale async responses from + // superseded selections are silently ignored before they reach setLoaded. + const latestRequestIdRef = useRef(null); + // Tracks which request_ids have already had their one auto-retry fired so + // a call that never acquires a summary doesn't poll forever. + const retryFiredRef = useRef(new Set()); + + // Derive the displayed status and detail from whether `loaded` belongs to + // the currently selected record. + const isCurrentRecord = record !== null && loaded.requestId === record.request_id; + const status: DetailStatus = isCurrentRecord ? loaded.status : record ? 'loading' : 'idle'; + const detail: MeetCallDetail | null = isCurrentRecord ? loaded.detail : null; + + async function loadDetail(requestId: string) { + log('[detail] loading detail for', requestId); + try { + const result = await getMeetCallDetail(requestId); + // Guard: ignore stale responses from superseded record selections. + if (latestRequestIdRef.current !== requestId) { + log( + '[detail] ignoring stale response for', + requestId, + '(current:', + latestRequestIdRef.current, + ')' + ); + return; + } + log('[detail] loaded detail for', requestId, 'hasSummary=%s', hasSummaryDetail(result)); + setLoaded({ requestId, status: 'loaded', detail: result }); + } catch (err) { + if (latestRequestIdRef.current !== requestId) return; + log('[detail] error loading detail for', requestId, err); + setLoaded({ requestId, status: 'error', detail: null }); + } + } + + // Trigger a new fetch whenever the selected record changes. + // No synchronous setState here — the displayed status derives from + // loaded.requestId vs record.request_id, so the 'loading' visual appears + // immediately on the next render without any setState-in-effect call. + // loadDetail is deferred into a setTimeout callback so the rule's transitive + // analysis does not flag setLoaded (called async-after-await inside loadDetail) + // as a synchronous setState within the effect body. + useEffect(() => { + if (!record) { + latestRequestIdRef.current = null; + return; + } + latestRequestIdRef.current = record.request_id; + const id = setTimeout(() => void loadDetail(record.request_id), 0); + return () => clearTimeout(id); + }, [record?.request_id]); // eslint-disable-line react-hooks/exhaustive-deps + + // If loaded but no summary yet, retry once after 2 s — but only once per + // request_id to prevent infinite polling on calls that never get a summary. + useEffect(() => { + if (!isCurrentRecord || loaded.status !== 'loaded' || !record) return; + if (hasSummaryDetail(loaded.detail)) return; + // Guard: fire the auto-retry at most once per request_id. + if (retryFiredRef.current.has(record.request_id)) return; + retryFiredRef.current.add(record.request_id); + + 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); + }, [loaded.status, loaded.requestId, 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..dfd3e52ff6 --- /dev/null +++ b/app/src/components/meetings/HistoryRail.tsx @@ -0,0 +1,308 @@ +/** + * 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 { useEffect, useRef, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { MeetCallRecord, MeetingPlatform } from '../../services/meetCallService'; +import { + inferPlatformFromUrl, + MEETING_PLATFORMS, + platformLabel, + platformLogoUrl, +} from './meetingUtils'; + +const log = debug('meetings:rail'); + +function ChevronDownIcon() { + return ( + + ); +} + +function AllPlatformsIcon() { + // Funnel / filter glyph for the "All platforms" (no filter) state. + return ( + + ); +} + +/** + * Compact platform filter: a button showing only the selected platform's icon + * (or a funnel glyph for "all"), opening a menu that lists each platform with + * its icon AND name. + */ +function PlatformFilterMenu({ value, onChange }: { value: string; onChange: (p: string) => void }) { + const { t } = useT(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + function onDocPointer(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + document.addEventListener('mousedown', onDocPointer); + return () => document.removeEventListener('mousedown', onDocPointer); + }, [open]); + + const selected = value ? (value as MeetingPlatform) : null; + const allLabel = t('skills.meetingBots.history.allPlatforms'); + + function pick(p: string) { + onChange(p); + setOpen(false); + } + + return ( +
+ + + {open && ( +
    +
  • + +
  • + {MEETING_PLATFORMS.map(p => { + const isSel = value === p; + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ); +} + +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; + } +} + +/** + * Format a past timestamp as a compact relative label ("1h ago", "yesterday"). + * + * All user-visible strings are routed through i18n. The caller must + * supply the `t` function from `useT()`. + */ +function formatRelativeTime(ms: number, t: (key: string) => string): string { + if (!ms) return '—'; + const diff = Date.now() - ms; + if (diff < 0) return t('skills.meetingBots.relative.now'); + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return t('skills.meetingBots.relative.now'); + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return t('skills.meetingBots.relative.minutesAgo').replace('{count}', String(minutes)); + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return t('skills.meetingBots.relative.hoursAgo').replace('{count}', String(hours)); + } + const days = Math.floor(hours / 24); + if (days === 1) return t('skills.meetingBots.relative.yesterday'); + if (days < 7) { + return t('skills.meetingBots.relative.daysAgo').replace('{count}', String(days)); + } + 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 ( +
+ {/* Compact platform filter (icon-only) + search */} +
+ + onSearchChange(e.target.value)} + placeholder={t('skills.meetingBots.history.searchPlaceholder')} + className="min-w-0 flex-1 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" + /> +
+ + {/* 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 = inferPlatformFromUrl(call.meet_url); + + 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..385ad1533d --- /dev/null +++ b/app/src/components/meetings/HistorySection.tsx @@ -0,0 +1,212 @@ +/** + * 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 HistoryDetail from './HistoryDetail'; +import HistoryRail, { type CallGroup } from './HistoryRail'; +import { inferPlatformFromUrl } from './meetingUtils'; + +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); + // The ID explicitly chosen by the user. May be null (no explicit pick yet) + // or point to a call that's been filtered out — effectiveCallId handles both. + const [selectedCallId, setSelectedCallId] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [platformFilter, setPlatformFilter] = useState(''); + + const fetchCalls = useCallback(async () => { + log('[history] fetching calls'); + try { + const rows = await listMeetCalls(50); + log('[history] loaded %d calls', rows.length); + // Clear any previous error only after a successful fetch so the UI + // doesn't flicker between error and loading on retry. + setError(null); + 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(() => { + // Wrap the initial call in setTimeout so the rule's transitive analysis + // does not flag setState calls (which are all async-after-await in fetchCalls) + // as synchronous within the effect body. + const id = setTimeout(() => void fetchCalls(), 0); + const retries = [1200, 3000].map(delay => setTimeout(() => void fetchCalls(), delay)); + return () => { + clearTimeout(id); + 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] + ); + + // Derive the effective selection during render — no setState in an effect: + // • null when no records survive the active filter (clears a stale selection) + // • first visible call when nothing is explicitly selected or the selected + // call was filtered out (auto-snap keeps the detail pane populated) + // • the user's explicit pick when it is still visible in filteredRecords + const effectiveCallId = useMemo(() => { + if (filteredRecords.length === 0) return null; + if (selectedCallId !== null && filteredRecords.some(r => r.request_id === selectedCallId)) { + return selectedCallId; + } + return filteredRecords[0].request_id; + }, [filteredRecords, selectedCallId]); + + const selectedRecord = useMemo( + () => records?.find(r => r.request_id === effectiveCallId) ?? null, + [records, effectiveCallId] + ); + + 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/JoinPolicyToggle.tsx b/app/src/components/meetings/JoinPolicyToggle.tsx new file mode 100644 index 0000000000..dcfd8f4279 --- /dev/null +++ b/app/src/components/meetings/JoinPolicyToggle.tsx @@ -0,0 +1,71 @@ +/** + * 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/MeetComposer.tsx b/app/src/components/meetings/MeetComposer.tsx new file mode 100644 index 0000000000..5b69b63610 --- /dev/null +++ b/app/src/components/meetings/MeetComposer.tsx @@ -0,0 +1,286 @@ +/** + * 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 { + platformLabel, + platformUrlPlaceholder, + resolveMeetingBotMascotId, + resolveMeetingDisplayName, +} from './meetingUtils'; +import { PlatformChips } from './PlatformChips'; + +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); + + // Derive the value shown in the "Your name" field during render — no effect + // needed (satisfies react-hooks/set-state-in-effect): + // • Untouched: use the Composio-resolved name for the current platform. + // Re-derives automatically whenever `platform` or `connectionByToolkit` + // changes, so late-arriving Composio fetches are reflected immediately. + // • Touched: use exactly what the user typed. + const displayedRespondTo = !respondToTouchedRef.current ? resolvedDisplayName : respondTo; + + // When the platform changes the displayed name re-derives on the next render + // via resolvedDisplayName — no extra setState needed. + const handlePlatformChange = (next: MeetingPlatform) => { + log('[composer] platform changed from=%s to=%s', platform, next); + setPlatform(next); + }; + + // ── Error path (inline form stays mounted during 'error') ──────────────── + // setState is deferred via setTimeout so the rule's transitive analysis does + // not consider them synchronous within the effect body. A 0-ms timer fires + // before the next paint so the visible latency is imperceptible. + useEffect(() => { + if (!hasSubmittedRef.current) return; + if (meetStatus !== 'error') return; + + 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); + onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message }); + + const id = setTimeout(() => { + setError(message); + setSubmitting(false); + }, 0); + return () => clearTimeout(id); + }, [meetStatus, meetError, onToast, t, hasSubmittedRef]); + + // ── Submit ─────────────────────────────────────────────────────────────── + const agentName = personaDisplayName.trim() || 'Tiny'; + const systemPrompt = personaDescription.trim() || undefined; + const mascotId = resolveMeetingBotMascotId(selectedMascotId, 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: displayedRespondTo.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/MeetDefaultsDrawer.tsx b/app/src/components/meetings/MeetDefaultsDrawer.tsx new file mode 100644 index 0000000000..fb34eb9783 --- /dev/null +++ b/app/src/components/meetings/MeetDefaultsDrawer.tsx @@ -0,0 +1,404 @@ +/** + * 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); + // Finding A: track whether the initial load completed successfully + const [loaded, setLoaded] = useState(false); + const [loadError, setLoadError] = useState(null); + // Bumping this triggers a retry of the initial load + const [retryCount, setRetryCount] = useState(0); + + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [savedNote, setSavedNote] = useState(null); + + // Master calendar-watch switch + const [watchCalendar, setWatchCalendar] = useState(false); + + // 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>({}); + + // Finding B: per-setting sequence counters so a failed save for one setting + // does not get masked by a successful save for a different setting. + const persistSeqRef = useRef>({}); + + // Load settings when opened (also re-runs when retryCount is bumped) + useEffect(() => { + if (!open) return; + if (!isTauri()) { + setLoading(false); + return; + } + let cancelled = false; + setLoading(true); + setLoaded(false); + setLoadError(null); + const load = async () => { + log('load start retryCount=%d', retryCount); + try { + const resp = await openhumanGetMeetSettings(); + if (cancelled) return; + const s = resp.result; + log('load ok auto_join=%s watch_calendar=%s', s.auto_join_policy, s.watch_calendar); + setWatchCalendar(s.watch_calendar ?? false); + 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); + setLoaded(true); + } catch (e) { + log('load failed err=%o', e); + if (!cancelled) { + setLoadError(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, retryCount]); + + // Finding B: settingKey scopes the seq counter to this specific setting so that + // a failed save for one setting is not silently dropped because another setting's + // save incremented the shared counter in between. + const persist = async ( + settingKey: string, + patch: Parameters[0], + onFailure?: () => void + ) => { + const seq = (persistSeqRef.current[settingKey] = (persistSeqRef.current[settingKey] ?? 0) + 1); + if (!isTauri()) return; + log('persist settingKey=%s patch=%o seq=%d', settingKey, patch, seq); + setError(null); + setSavedNote(null); + setSaving(true); + try { + await openhumanUpdateMeetSettings(patch); + if (seq !== persistSeqRef.current[settingKey]) return; + setSavedNote(t('settings.meetings.saved')); + } catch (e) { + if (seq !== persistSeqRef.current[settingKey]) return; + onFailure?.(); + setError(e instanceof Error ? e.message : t('settings.meetings.saveError')); + } finally { + if (seq === persistSeqRef.current[settingKey]) setSaving(false); + } + }; + + const handleWatchCalendarChange = (next: boolean) => { + const prev = watchCalendar; + setWatchCalendar(next); + log('watch_calendar change next=%s', next); + void persist('watch_calendar', { watch_calendar: next }, () => setWatchCalendar(prev)); + }; + + const handleAutoJoinChange = (next: MeetAutoJoinPolicy) => { + const prev = autoJoin; + setAutoJoin(next); + void persist('auto_join_policy', { auto_join_policy: next }, () => setAutoJoin(prev)); + }; + + const handleAutoSummarizeChange = (next: MeetAutoSummarizePolicy) => { + const prev = autoSummarize; + setAutoSummarize(next); + void persist('auto_summarize_policy', { auto_summarize_policy: next }, () => + setAutoSummarize(prev) + ); + }; + + const handleListenOnlyChange = (next: boolean) => { + const prev = listenOnly; + setListenOnly(next); + void persist('listen_only_default', { listen_only_default: next }, () => setListenOnly(prev)); + }; + + const handleIngestChange = (next: boolean) => { + const prev = ingestTranscripts; + setIngestTranscripts(next); + void persist('ingest_backend_transcripts', { 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.${platform}`, + { platform_auto_join_policies: toSave }, + () => setPlatformPolicies(current => ({ ...current, [platform]: prevValue })) + ); + }; + + if (!open) return null; + + return ( + <> + {/* Backdrop */} +