import { useT } from "@agent-native/core/client/i18n"; import { IconChevronDown, IconChevronUp, IconNotes, IconSearch, IconX, } from "@tabler/icons-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; export interface TranscriptSegment { startMs: number; endMs?: number; text: string; speaker?: string | null; source?: "mic" | "system" | null; } interface TranscriptBubblesProps { segments: TranscriptSegment[]; isLive: boolean; recordingId?: string | null; onSeek: (ms: number) => void; /** * Imperative ref hook: parent can scroll a particular segment into view. * Receives a function (segmentIndex) => void. */ registerScrollTo?: (fn: (segmentIndex: number) => void) => void; } function formatTimestamp(ms: number): string { const total = Math.floor(ms / 1000); const m = Math.floor(total / 60); const s = total % 60; return `${m}:${s.toString().padStart(2, "0")}`; } interface BubbleGroup { source: "mic" | "system"; segments: { seg: TranscriptSegment; index: number }[]; } // Splits `text` into plain/matched runs for a case-insensitive substring // highlight. Returns the original text as a single run when there's no query // or no match, so callers can render uniformly either way. function highlightRuns( text: string, query: string, ): Array<{ text: string; match: boolean }> { if (!query) return [{ text, match: false }]; const lower = text.toLowerCase(); const needle = query.toLowerCase(); const runs: Array<{ text: string; match: boolean }> = []; let cursor = 0; while (cursor < text.length) { const at = lower.indexOf(needle, cursor); if (at === -1) { runs.push({ text: text.slice(cursor), match: false }); break; } if (at > cursor) runs.push({ text: text.slice(cursor, at), match: false }); runs.push({ text: text.slice(at, at + needle.length), match: true }); cursor = at + needle.length; } return runs.length ? runs : [{ text, match: false }]; } function groupConsecutive(segments: TranscriptSegment[]): BubbleGroup[] { const groups: BubbleGroup[] = []; segments.forEach((seg, index) => { // Default unknown source to "system" (Them) — Granola convention. const source: "mic" | "system" = seg.source === "mic" ? "mic" : "system"; const last = groups[groups.length - 1]; if (last && last.source === source) { last.segments.push({ seg, index }); } else { groups.push({ source, segments: [{ seg, index }] }); } }); return groups; } export function TranscriptBubbles({ segments, isLive, recordingId, onSeek, registerScrollTo, }: TranscriptBubblesProps) { const t = useT(); const containerRef = useRef(null); const liveEndRef = useRef(null); const userPausedRef = useRef(false); const segmentRefs = useRef>({}); const flashTimeoutRef = useRef(null); const searchInputRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [matchCursor, setMatchCursor] = useState(0); const groups = useMemo(() => groupConsecutive(segments), [segments]); const normalizedQuery = searchQuery.trim().toLowerCase(); const matchIndexes = useMemo(() => { if (!normalizedQuery) return []; const out: number[] = []; segments.forEach((seg, index) => { if (seg.text.toLowerCase().includes(normalizedQuery)) out.push(index); }); return out; }, [segments, normalizedQuery]); // Keep the cursor in range as matches change (typing narrows the set). useEffect(() => { setMatchCursor(0); }, [normalizedQuery]); const scrollToSegmentRef = useRef<((segmentIndex: number) => void) | null>( null, ); // Only scroll when the resolved target actually changes — during a live // meeting the segments array (and thus matchIndexes) gets a new identity on // every poll, and re-scrolling each time would fight the user's scrolling. const lastSearchScrollRef = useRef(null); useEffect(() => { if (!normalizedQuery || !matchIndexes.length) { lastSearchScrollRef.current = null; return; } const target = matchIndexes[matchCursor % matchIndexes.length]; const scrollKey = `${normalizedQuery}:${target}`; if (lastSearchScrollRef.current === scrollKey) return; lastSearchScrollRef.current = scrollKey; scrollToSegmentRef.current?.(target); }, [normalizedQuery, matchIndexes, matchCursor]); useEffect(() => { if (searchOpen) { const raf = requestAnimationFrame(() => searchInputRef.current?.focus()); return () => cancelAnimationFrame(raf); } }, [searchOpen]); const closeSearch = () => { setSearchOpen(false); setSearchQuery(""); }; const goToMatch = (dir: 1 | -1) => { if (!matchIndexes.length) return; setMatchCursor( (prev) => (prev + dir + matchIndexes.length) % matchIndexes.length, ); }; useEffect(() => { const el = containerRef.current; if (!el) return; const handler = () => { const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; userPausedRef.current = distanceFromBottom > 80; }; el.addEventListener("scroll", handler, { passive: true }); return () => el.removeEventListener("scroll", handler); }, []); useEffect(() => { if (isLive && !userPausedRef.current) { liveEndRef.current?.scrollIntoView({ behavior: "smooth" }); } }, [isLive, segments.length]); // Shared imperative scroll-and-flash, used by both the parent's bullet-jump // wiring (registerScrollTo) and in-panel search navigation below. const scrollToAndFlash = useRef((segmentIndex: number) => { const node = segmentRefs.current[segmentIndex]; if (!node) return; node.scrollIntoView({ behavior: "smooth", block: "center" }); // Yellow-flash highlight for ~1.5s. node.classList.add("ring-2", "ring-yellow-400/70", "bg-yellow-400/10"); if (flashTimeoutRef.current) window.clearTimeout(flashTimeoutRef.current); flashTimeoutRef.current = window.setTimeout(() => { node.classList.remove("ring-2", "ring-yellow-400/70", "bg-yellow-400/10"); }, 1500); }).current; useEffect(() => { scrollToSegmentRef.current = scrollToAndFlash; }, [scrollToAndFlash]); useEffect(() => { if (!registerScrollTo) return; registerScrollTo(scrollToAndFlash); }, [registerScrollTo, scrollToAndFlash]); if (segments.length === 0) { if (isLive) { return (
{t("transcriptBubbles.listening")}
); } return (
{t("transcriptBubbles.noTranscript")} {t("transcriptBubbles.liveTranscriptDescription")}
); } const activeMatchIndex = matchIndexes.length ? matchIndexes[matchCursor % matchIndexes.length] : null; return (
{searchOpen ? ( <> setSearchQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); closeSearch(); } else if (e.key === "Enter") { e.preventDefault(); goToMatch(e.shiftKey ? -1 : 1); } }} onBlur={() => { if (!searchQuery.trim()) closeSearch(); }} placeholder={t("transcriptBubbles.searchPlaceholder")} className="h-7 flex-1 text-xs" /> {normalizedQuery && ( {matchIndexes.length > 0 ? t("transcriptBubbles.searchMatchCount", { current: (matchCursor % matchIndexes.length) + 1, total: matchIndexes.length, }) : t("transcriptBubbles.searchNoMatches")} )} ) : ( )}
{groups.map((group, gi) => { const isMe = group.source === "mic"; return (
{group.segments.map(({ seg, index }) => { const clickable = !!recordingId; return (
{ segmentRefs.current[index] = el; }} role={clickable ? "button" : undefined} tabIndex={clickable ? 0 : -1} onClick={() => clickable && onSeek(seg.startMs)} onKeyDown={(e) => { if ( clickable && (e.key === "Enter" || e.key === " ") ) { e.preventDefault(); onSeek(seg.startMs); } }} className={cn( "group/segment -mx-1 rounded-md px-1 py-1 text-left text-sm leading-relaxed text-foreground transition-colors", clickable && "cursor-pointer hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", )} > {seg.speaker && !isMe && ( {seg.speaker} )} {normalizedQuery ? highlightRuns(seg.text, normalizedQuery).map( (run, ri) => run.match ? ( {run.text} ) : ( {run.text} ), ) : seg.text}
{formatTimestamp(seg.startMs)}
); })}
); })}
); }