MeetingsDebuggerTab.tsx20.1 KBView on GitHub 'use client';
import {
Check,
ChevronDown,
ChevronRight,
Copy,
ExternalLink,
FileText,
Loader2,
Search,
Video,
Zap,
HelpCircle,
Star,
} from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { useLocation, useParams } from 'react-router';
import { useTRPC } from '@/providers/query-provider';
import { useQuery } from '@tanstack/react-query';
import React, { useMemo, useState } from 'react';
import { cn } from '@/styles/stylingUtils';
import { format } from 'date-fns';
import { JsonTreeView } from './JsonTreeView';
type CallFlagEntry = {
type: 'important' | 'risk' | 'share';
startMs: number;
endMs: number;
createdAt: number;
};
interface RecallTranscriptWord {
text: string;
start_time: number;
end_time: number;
}
interface RecallTranscriptSegment {
speaker: string;
words: RecallTranscriptWord[];
}
type SessionRow = {
sdkUploadId: string;
userId: string;
conversationId: string | null;
title: string | null;
notes: string | null;
callFlags: CallFlagEntry[] | null;
createdAt: Date;
updatedAt: Date;
};
const FLAG_META = {
important: { icon: Zap, color: '#eab308', label: 'Important', bg: '#fefce8' },
risk: { icon: HelpCircle, color: '#3b82f6', label: 'Risk', bg: '#eff6ff' },
share: { icon: Star, color: '#a855f7', label: 'Share', bg: '#faf5ff' },
} as const;
function formatMs(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function FlagTimeline({ flags }: { flags: CallFlagEntry[] }) {
if (flags.length === 0) return null;
const totalMs = Math.max(...flags.map((f) => f.endMs), 1);
// Deduplicate by startMs+type to avoid stacked dots
const unique = flags.filter(
(f, i, arr) => arr.findIndex((x) => x.startMs === f.startMs && x.type === f.type) === i,
);
return (
<div className="rounded bg-white p-2 dark:bg-gray-800">
<span className="text-gray-500 dark:text-gray-400">
Flags ({flags.length} total, {unique.length} unique positions)
</span>
<div className="relative mt-2 h-5">
{/* Track */}
<div className="absolute left-0 right-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-gray-200 dark:bg-gray-700" />
{/* Markers */}
{unique.map((f, i) => {
const meta = FLAG_META[f.type];
const pct = (f.startMs / totalMs) * 100;
return (
<div
key=[redacted]
title={`${meta.label} @ ${formatMs(f.startMs)}`}
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-default rounded-full border border-white dark:border-gray-800"
style={{ left: `${pct}%`, backgroundColor: meta.color }}
/>
);
})}
</div>
{/* Legend */}
<div className="mt-2 flex items-center gap-3">
{(['important', 'risk', 'share'] as const)
.filter((t) => flags.some((f) => f.type === t))
.map((t) => {
const meta = FLAG_META[t];
const count = flags.filter((f) => f.type === t).length;
return (
<span key={t} className="flex items-center gap-1 text-[10px]">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: meta.color }}
/>
{meta.label} ×{count}
</span>
);
})}
<span className="ml-auto text-[10px] text-gray-400">{formatMs(totalMs)} total</span>
</div>
</div>
);
}
interface MeetingsTabProps {
onCopy: (text: string, id: string) => void;
copiedId: string | null;
}
export const MeetingsDebuggerTab: React.FC<MeetingsTabProps> = ({ onCopy, copiedId }) => {
const trpc = useTRPC();
const location = useLocation();
const params = useParams<{ sdkUploadId?: string }>();
// Detect if we're viewing a specific meeting
const activeSdkUploadId = params.sdkUploadId ?? null;
const { data: sessionsRaw, isLoading } = useQuery(trpc.recall.listCallSessions.queryOptions());
const sessions = (sessionsRaw as unknown as SessionRow[]) ?? [];
const { data: activeSessionRaw, isLoading: isLoadingActive } = useQuery({
...trpc.recall.getCallSession.queryOptions({ sdkUploadId: activeSdkUploadId ?? '' }),
enabled: !!activeSdkUploadId,
});
const activeSession = activeSessionRaw as unknown as SessionRow | null | undefined;
const { data: activeRecordingRaw, isLoading: isLoadingRecording } = useQuery({
...trpc.recall.getRecording.queryOptions({ sdkUploadId: activeSdkUploadId ?? '' }),
enabled: !!activeSdkUploadId,
});
const [searchQuery, setSearchQuery] = useState('');
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const filteredSessions = useMemo(() => {
if (!searchQuery) return sessions;
const q = searchQuery.toLowerCase();
return sessions.filter(
(s) =>
s.sdkUploadId.toLowerCase().includes(q) ||
(s.title ?? '').toLowerCase().includes(q) ||
(s.conversationId ?? '').toLowerCase().includes(q),
);
}, [sessions, searchQuery]);
const toggleExpanded = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const safeStringify = (obj: unknown) => {
try {
return JSON.stringify(obj, null, 2);
} catch {
return '[unserializable]';
}
};
return (
<div className="flex h-full flex-col">
{/* ── Active meeting panel ── */}
{activeSdkUploadId && (
<div className="border-b border-gray-200 bg-rose-50/50 p-3 dark:border-gray-700 dark:bg-rose-950/20">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Video className="h-4 w-4 text-rose-600 dark:text-rose-400" />
<span className="font-medium text-rose-900 dark:text-rose-100">Current Meeting</span>
{(isLoadingActive || isLoadingRecording) && (
<Loader2 className="h-3 w-3 animate-spin text-rose-400" />
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() =>
onCopy(
safeStringify({
route: location.pathname,
sdkUploadId: activeSdkUploadId,
session: activeSession,
recording: activeRecordingRaw,
}),
'activeMeetingFull',
)
}
className="rounded p-0.5 hover:bg-rose-200 dark:hover:bg-rose-800"
title="Copy all"
>
{copiedId === 'activeMeetingFull' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
</div>
<div className="space-y-1.5 text-xs">
{/* sdkUploadId */}
<div className="flex items-center justify-between rounded bg-white p-2 dark:bg-gray-800">
<div>
<span className="text-gray-500 dark:text-gray-400">sdkUploadId</span>
<div className="font-mono">{activeSdkUploadId}</div>
</div>
<button
onClick={() => onCopy(activeSdkUploadId, 'activeSdkUploadId')}
className="rounded p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700"
>
{copiedId === 'activeSdkUploadId' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
{/* title */}
{activeSession?.title && (
<div className="rounded bg-white p-2 dark:bg-gray-800">
<span className="text-gray-500 dark:text-gray-400">Title</span>
<div className="font-medium">{activeSession.title}</div>
</div>
)}
{/* conversationId */}
{activeSession?.conversationId && (
<div className="flex items-center justify-between rounded bg-white p-2 dark:bg-gray-800">
<div>
<span className="text-gray-500 dark:text-gray-400">conversationId</span>
<div className="font-mono">{activeSession.conversationId}</div>
</div>
<button
onClick={() => onCopy(activeSession.conversationId!, 'activeConversationId')}
className="rounded p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700"
>
{copiedId === 'activeConversationId' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
)}
{/* recording status */}
{activeRecordingRaw && (
<div className="rounded bg-white p-2 dark:bg-gray-800">
<span className="text-gray-500 dark:text-gray-400">Recording status</span>
<div className="flex items-center gap-2">
<span
className={cn(
'rounded px-1.5 py-0.5 font-mono',
['complete', 'done'].includes(
(activeRecordingRaw as { status?: string }).status ?? '',
)
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300',
)}
>
{(activeRecordingRaw as { status?: string }).status ?? 'unknown'}
</span>
<span
className={cn(
'rounded px-1.5 py-0.5 font-mono',
(activeRecordingRaw as { video_mixed_mp4?: { status?: string } })
?.video_mixed_mp4?.status === 'done'
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
)}
>
video:{' '}
{(activeRecordingRaw as { video_mixed_mp4?: { status?: string } })
?.video_mixed_mp4?.status ?? 'none'}
</span>
</div>
</div>
)}
{/* call flags timeline */}
{activeSession?.callFlags && activeSession.callFlags.length > 0 && (
<FlagTimeline flags={activeSession.callFlags as CallFlagEntry[]} />
)}
{/* transcript */}
{(() => {
const transcript = (
activeRecordingRaw as { transcript?: RecallTranscriptSegment[] } | undefined
)?.transcript;
if (!transcript || transcript.length === 0) return null;
return (
<div className="rounded bg-white p-2 dark:bg-gray-800">
<div className="flex items-center justify-between">
<span className="text-gray-500 dark:text-gray-400">
Transcript ({transcript.length} segments)
</span>
<button
onClick={() =>
onCopy(
transcript
.map(
(seg) => `[${seg.speaker}] ${seg.words.map((w) => w.text).join(' ')}`,
)
.join('\n'),
'activeTranscript',
)
}
className="rounded p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700"
>
{copiedId === 'activeTranscript' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
<div className="mt-1.5 max-h-40 space-y-1.5 overflow-y-auto">
{transcript.map((seg, i) => {
const startMs = (seg.words[0]?.start_time ?? 0) * 1000;
return (
<div key=[redacted] className="flex gap-2">
<span className="shrink-0 font-mono text-gray-400">
{formatMs(startMs)}
</span>
<span className="flex-1">
<span className="font-medium text-gray-600 dark:text-gray-300">
{seg.speaker}:
</span>{' '}
{seg.words.map((w) => w.text).join(' ')}
</span>
</div>
);
})}
</div>
</div>
);
})()}
{/* created at */}
{activeSession?.createdAt && (
<div className="rounded bg-white p-2 dark:bg-gray-800">
<span className="text-gray-500 dark:text-gray-400">Created</span>
<div className="font-mono">
{format(new Date(activeSession.createdAt), 'MMM d, yyyy · h:mm a')}
</div>
</div>
)}
</div>
</div>
)}
{!activeSdkUploadId && (
<div className="border-b border-gray-200 bg-gray-50 p-2.5 dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<ExternalLink className="h-3 w-3" />
Open a meeting from /meetings to see its details here
</div>
</div>
)}
{/* ── Search ── */}
<div className="border-b border-gray-200 p-2 dark:border-gray-700">
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search sessions…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded border border-gray-300 bg-white py-1 pl-7 pr-2 text-xs placeholder:text-gray-400 focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
</div>
{/* ── Session list ── */}
<div className="flex-1 overflow-y-auto p-2">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
</div>
) : filteredSessions.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<FileText className="h-6 w-6 text-gray-300" />
<p className="text-xs text-gray-500">
{searchQuery ? 'No sessions match' : 'No recorded sessions'}
</p>
</div>
) : (
<div className="space-y-2">
{filteredSessions.map((session) => {
const isExpanded = expandedIds.has(session.sdkUploadId);
const isActive = session.sdkUploadId === activeSdkUploadId;
const flags = (session.callFlags ?? []) as CallFlagEntry[];
return (
<div
key=[redacted]
className={cn(
'rounded-lg border bg-gray-50 dark:bg-gray-950',
isActive
? 'border-rose-400 dark:border-rose-600'
: 'border-gray-200 dark:border-gray-700',
)}
>
<div
className={cn(
'flex cursor-pointer items-center justify-between p-2 hover:bg-gray-100 dark:hover:bg-gray-900/80',
isExpanded ? 'rounded-t-lg' : 'rounded-lg',
)}
onClick={() => toggleExpanded(session.sdkUploadId)}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center gap-1.5">
{isActive && (
<span className="rounded bg-rose-500 px-1 py-0.5 text-xs font-bold text-white">
OPEN
</span>
)}
<span className="truncate text-xs font-medium">
{session.title ?? 'Untitled Meeting'}
</span>
</div>
<div className="flex items-center gap-2 font-mono text-xs text-gray-500 dark:text-gray-400">
<span className="truncate">{session.sdkUploadId}</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span>{format(new Date(session.createdAt), 'MMM d · h:mm a')}</span>
{session.conversationId && (
<span className="rounded bg-purple-100 px-1 py-0.5 font-mono text-purple-700 dark:bg-purple-900 dark:text-purple-300">
conv linked
</span>
)}
{flags.length > 0 && (
<span className="flex items-center gap-0.5">
<Zap className="h-3 w-3 text-yellow-500" />
{flags.length}
</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
onCopy(safeStringify(session), `session-${session.sdkUploadId}`);
}}
className="rounded p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700"
>
{copiedId === `session-${session.sdkUploadId}` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</div>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="rounded-b-lg p-2 pt-0" onClick={(e) => e.stopPropagation()}>
<JsonTreeView data={session} onCopy={onCopy} copiedId={copiedId} defaultExpandDepth={2} className="max-h-64" />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="border-t border-gray-200 p-2 text-center text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400">
{searchQuery ? `${filteredSessions.length} of ${sessions.length}` : `${sessions.length}`}{' '}
sessions
</div>
</div>
);
};