From 1cd4c33f474a227a853405915c6eaf91992d353f Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Mon, 27 Oct 2025 12:52:41 -0300 Subject: [PATCH 1/4] feat: real-time transcript streaming with TanStack Query Refactored Notetaker from Dexie local-first to TanStack Query backend-first architecture with smart caching and real-time transcript streaming. **Architecture Changes:** - Removed Dexie (IndexedDB) dependency - Migrated to TanStack Query for server state management - Backend-first with PostHog as source of truth - Local buffering with batched uploads (10 segments / 10s) **Key Features:** - Real-time transcript segments from Recall.ai AssemblyAI stream - Smart polling: 2s during active recording, 10s for ready recordings - Local buffer prevents duplicate uploads - Optimistic UI updates with pending upload badge - Automatic query invalidation and cache management - Transcript view limited to 60vh (room for notes/action items) **Query Keys (unique to avoid collision with legacy Recordings):** - notetaker-recordings - notetaker-recording - notetaker-transcript **Files Added:** - src/renderer/features/notetaker/hooks/useRecordings.ts - src/renderer/features/notetaker/hooks/useTranscript.ts - src/renderer/features/notetaker/components/LiveTranscriptView.tsx **Files Removed:** - src/renderer/features/notetaker/stores/notetakerStore.ts - src/renderer/services/db.ts (Dexie schema) - src/renderer/services/transcriptSync.ts **Dependencies Removed:** - dexie - dexie-react-hooks --- src/api/posthogClient.ts | 33 ++++ src/main/preload.ts | 30 +++ .../components/LiveTranscriptView.tsx | 170 +++++++++++++++++ .../notetaker/components/NotetakerView.tsx | 146 +++++++++----- .../features/notetaker/hooks/useRecordings.ts | 72 +++++++ .../features/notetaker/hooks/useTranscript.ts | 179 ++++++++++++++++++ .../notetaker/stores/notetakerStore.ts | 78 -------- src/renderer/types/electron.d.ts | 18 ++ 8 files changed, 596 insertions(+), 130 deletions(-) create mode 100644 src/renderer/features/notetaker/components/LiveTranscriptView.tsx create mode 100644 src/renderer/features/notetaker/hooks/useRecordings.ts create mode 100644 src/renderer/features/notetaker/hooks/useTranscript.ts delete mode 100644 src/renderer/features/notetaker/stores/notetakerStore.ts diff --git a/src/api/posthogClient.ts b/src/api/posthogClient.ts index bee8858e..3712e0e8 100644 --- a/src/api/posthogClient.ts +++ b/src/api/posthogClient.ts @@ -400,4 +400,37 @@ export class PostHogAPIClient { return await response.json(); } + + async uploadDesktopRecordingTranscript( + recordingId: string, + transcript: { + full_text: string; + segments: Array<{ + timestamp: number; + speaker: string | null; + text: string; + confidence: number | null; + }>; + }, + ) { + this.validateRecordingId(recordingId); + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/environments/${teamId}/desktop_recordings/${recordingId}/upload_transcript/`, + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: `/api/environments/${teamId}/desktop_recordings/${recordingId}/upload_transcript/`, + parameters: { + body: transcript, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to upload transcript: ${response.statusText}`); + } + + return await response.json(); + } } diff --git a/src/main/preload.ts b/src/main/preload.ts index 346bf153..658a3ec4 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -202,6 +202,36 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("notetaker:get-recording", recordingId), notetakerDeleteRecording: (recordingId: string): Promise => ipcRenderer.invoke("notetaker:delete-recording", recordingId), + // Real-time transcript listener + onTranscriptSegment: ( + listener: (segment: { + posthog_recording_id: string; + timestamp: number; + speaker: string | null; + text: string; + confidence: number | null; + is_final: boolean; + }) => void, + ): (() => void) => { + const channel = "recall:transcript-segment"; + const wrapped = (_event: IpcRendererEvent, segment: unknown) => + listener(segment as never); + ipcRenderer.on(channel, wrapped); + return () => ipcRenderer.removeListener(channel, wrapped); + }, + // Meeting ended listener (trigger sync) + onMeetingEnded: ( + listener: (event: { + posthog_recording_id: string; + platform: string; + }) => void, + ): (() => void) => { + const channel = "recall:meeting-ended"; + const wrapped = (_event: IpcRendererEvent, event: unknown) => + listener(event as never); + ipcRenderer.on(channel, wrapped); + return () => ipcRenderer.removeListener(channel, wrapped); + }, // Shell API shellCreate: (sessionId: string, cwd?: string): Promise => ipcRenderer.invoke("shell:create", sessionId, cwd), diff --git a/src/renderer/features/notetaker/components/LiveTranscriptView.tsx b/src/renderer/features/notetaker/components/LiveTranscriptView.tsx new file mode 100644 index 00000000..0dda2a54 --- /dev/null +++ b/src/renderer/features/notetaker/components/LiveTranscriptView.tsx @@ -0,0 +1,170 @@ +import { Badge, Box, Card, Flex, ScrollArea, Text } from "@radix-ui/themes"; +import { useEffect, useRef, useState } from "react"; +import { useLiveTranscript } from "../hooks/useTranscript"; + +interface LiveTranscriptViewProps { + posthogRecordingId: string; // PostHog UUID +} + +export function LiveTranscriptView({ + posthogRecordingId, +}: LiveTranscriptViewProps) { + const scrollRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + + const { segments, addSegment, forceUpload, pendingCount } = + useLiveTranscript(posthogRecordingId); + + // Listen for new transcript segments from IPC + useEffect(() => { + console.log( + `[LiveTranscript] Setting up listener for recording ${posthogRecordingId}`, + ); + + const cleanup = window.electronAPI.onTranscriptSegment((segment) => { + console.log( + `[LiveTranscript] Received segment for ${segment.posthog_recording_id}:`, + segment.text, + ); + + if (segment.posthog_recording_id !== posthogRecordingId) { + console.log( + `[LiveTranscript] Ignoring segment - not for this recording (${posthogRecordingId})`, + ); + return; // Not for this recording + } + + console.log("[LiveTranscript] Adding segment to local buffer"); + + // Add to local buffer + addSegment({ + timestamp: segment.timestamp, + speaker: segment.speaker, + text: segment.text, + confidence: segment.confidence, + }); + }); + + return cleanup; + }, [posthogRecordingId, addSegment]); + + // Listen for meeting-ended event to force upload remaining segments + useEffect(() => { + console.log( + `[LiveTranscript] Setting up meeting-ended listener for ${posthogRecordingId}`, + ); + + const cleanup = window.electronAPI.onMeetingEnded((event) => { + if (event.posthog_recording_id === posthogRecordingId) { + console.log(`[LiveTranscript] Meeting ended, force uploading segments`); + forceUpload(); + } + }); + + return cleanup; + }, [posthogRecordingId, forceUpload]); + + // Auto-scroll to bottom when new segments arrive + useEffect(() => { + if (autoScroll && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [autoScroll]); + + // Detect manual scroll to disable auto-scroll + const handleScroll = () => { + if (!scrollRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; + const isNearBottom = scrollHeight - scrollTop - clientHeight < 50; + setAutoScroll(isNearBottom); + }; + + if (!segments || segments.length === 0) { + return ( + + + + Waiting for transcript... + + + + ); + } + + return ( + + + + Live transcript + + + {pendingCount > 0 && ( + + {pendingCount} pending upload + + )} + + {segments.length} segments + + + + + + + {segments.map((segment, idx) => ( + + + + {segment.speaker && ( + + {segment.speaker} + + )} + + {formatTimestamp(segment.timestamp)} + + + {segment.text} + + + ))} + + + + {!autoScroll && ( + + + Scroll to bottom to enable auto-scroll + + + )} + + ); +} + +function formatTimestamp(milliseconds: number): string { + const totalSeconds = Math.floor(milliseconds / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + } + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} diff --git a/src/renderer/features/notetaker/components/NotetakerView.tsx b/src/renderer/features/notetaker/components/NotetakerView.tsx index 6010876e..11734728 100644 --- a/src/renderer/features/notetaker/components/NotetakerView.tsx +++ b/src/renderer/features/notetaker/components/NotetakerView.tsx @@ -1,4 +1,5 @@ import { + ArrowClockwiseIcon, CheckCircleIcon, ClockIcon, TrashIcon, @@ -11,12 +12,13 @@ import { Button, Card, Flex, - ScrollArea, Spinner, Text, } from "@radix-ui/themes"; -import { useEffect } from "react"; -import { useNotetakerStore } from "../stores/notetakerStore"; +import { useState } from "react"; +import { useDeleteRecording, useRecordings } from "../hooks/useRecordings"; +import { useUploadTranscript } from "../hooks/useTranscript"; +import { LiveTranscriptView } from "./LiveTranscriptView"; function getStatusIcon( status: "recording" | "uploading" | "processing" | "ready" | "error", @@ -74,21 +76,33 @@ function formatDuration(seconds: number | null) { } export function NotetakerView() { - const { - recordings, - isLoading, - error, - fetchRecordings, - deleteRecording, - selectedRecordingId, - setSelectedRecording, - } = useNotetakerStore(); - - useEffect(() => { - fetchRecordings(); - const interval = setInterval(fetchRecordings, 10000); - return () => clearInterval(interval); - }, [fetchRecordings]); + const { data: recordings = [], isLoading, error, refetch } = useRecordings(); + const deleteMutation = useDeleteRecording(); + const uploadTranscriptMutation = useUploadTranscript(); + const [selectedRecordingId, setSelectedRecordingId] = useState( + null, + ); + + const handleRetry = (recordingId: string) => { + // For retry, we need to fetch local segments and upload + // Since we're now backend-first, this will trigger a re-sync + uploadTranscriptMutation.mutate({ + recordingId, + segments: [], // Empty will fetch from backend + }); + }; + + const handleDelete = (recordingId: string) => { + if (confirm("Delete this recording?")) { + deleteMutation.mutate(recordingId, { + onSuccess: () => { + if (selectedRecordingId === recordingId) { + setSelectedRecordingId(null); + } + }, + }); + } + }; if (isLoading && recordings.length === 0) { return ( @@ -106,8 +120,8 @@ export function NotetakerView() { - {error} - + {String(error)} + ); @@ -127,23 +141,31 @@ export function NotetakerView() { ); } + const selectedRecording = recordings.find( + (r) => r.id === selectedRecordingId, + ); + return ( - - - - - Notetaker - - - {recordings.length} recording{recordings.length !== 1 ? "s" : ""} - - + + {/* Left sidebar: Recordings list */} + + + + + Notetaker + + + {recordings.length} recording{recordings.length !== 1 ? "s" : ""} + + - {recordings.map((recording) => ( setSelectedRecording(recording.id)} + onClick={() => setSelectedRecordingId(recording.id)} > @@ -190,25 +212,45 @@ export function NotetakerView() { - + + {recording.status === "error" && ( + + )} + + ))} - - - + + + + {/* Right panel: Live transcript view */} + {selectedRecordingId && selectedRecording && ( + + + + )} + ); } diff --git a/src/renderer/features/notetaker/hooks/useRecordings.ts b/src/renderer/features/notetaker/hooks/useRecordings.ts new file mode 100644 index 00000000..ea20f354 --- /dev/null +++ b/src/renderer/features/notetaker/hooks/useRecordings.ts @@ -0,0 +1,72 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAuthStore } from "../../../stores/authStore"; + +export interface Recording { + id: string; + platform: string; + title: string | null; + status: "recording" | "uploading" | "processing" | "ready" | "error"; + created_at: string; + duration: number | null; + video_url: string | null; + recall_recording_id: string | null; +} + +export function useRecordings() { + const { client } = useAuthStore(); + + return useQuery({ + queryKey: ["notetaker-recordings"], // Unique key to avoid collision with legacy recordings + queryFn: async () => { + if (!client) throw new Error("Not authenticated"); + const recordings = await client.listDesktopRecordings(); + return recordings as Recording[]; + }, + enabled: !!client, + refetchInterval: 10000, // Poll every 10s + staleTime: 5000, // Consider stale after 5s + }); +} + +export function useRecording(recordingId: string | null) { + const { client } = useAuthStore(); + + return useQuery({ + queryKey: ["notetaker-recording", recordingId], // Unique key + queryFn: async () => { + if (!client || !recordingId) + throw new Error("Not authenticated or no recording ID"); + const recording = await client.getDesktopRecording(recordingId); + return recording as Recording; + }, + enabled: !!client && !!recordingId, + refetchInterval: (query) => { + const status = query.state.data?.status; + // Poll faster during active states + if ( + status === "recording" || + status === "uploading" || + status === "processing" + ) { + return 2000; // 2s + } + return 30000; // 30s for ready/error + }, + }); +} + +export function useDeleteRecording() { + const { client } = useAuthStore(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (recordingId: string) => { + if (!client) throw new Error("Not authenticated"); + await client.deleteDesktopRecording(recordingId); + }, + onSuccess: () => { + // Invalidate recordings list + queryClient.invalidateQueries({ queryKey: ["notetaker-recordings"] }); + }, + }); +} diff --git a/src/renderer/features/notetaker/hooks/useTranscript.ts b/src/renderer/features/notetaker/hooks/useTranscript.ts new file mode 100644 index 00000000..862838c9 --- /dev/null +++ b/src/renderer/features/notetaker/hooks/useTranscript.ts @@ -0,0 +1,179 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useAuthStore } from "../../../stores/authStore"; + +export interface TranscriptSegment { + timestamp: number; // Milliseconds from start + speaker: string | null; + text: string; + confidence: number | null; +} + +interface TranscriptData { + full_text: string; + segments: TranscriptSegment[]; +} + +export function useTranscript(recordingId: string | null) { + const { client } = useAuthStore(); + + return useQuery({ + queryKey: ["notetaker-transcript", recordingId], // Unique key + queryFn: async () => { + if (!client || !recordingId) + throw new Error("Not authenticated or no recording ID"); + const transcript = + await client.getDesktopRecordingTranscript(recordingId); + return transcript as TranscriptData; + }, + enabled: !!client && !!recordingId, + refetchInterval: 5000, // Poll every 5s to get updates + staleTime: 2000, + }); +} + +export function useUploadTranscript() { + const { client } = useAuthStore(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + recordingId, + segments, + }: { + recordingId: string; + segments: TranscriptSegment[]; + }) => { + if (!client) throw new Error("Not authenticated"); + + const full_text = segments.map((s) => s.text).join(" "); + + return await client.uploadDesktopRecordingTranscript(recordingId, { + full_text, + segments, + }); + }, + onSuccess: (_data, variables) => { + // Invalidate transcript query to refetch + queryClient.invalidateQueries({ + queryKey: ["notetaker-transcript", variables.recordingId], + }); + queryClient.invalidateQueries({ + queryKey: ["notetaker-recording", variables.recordingId], + }); + queryClient.invalidateQueries({ queryKey: ["notetaker-recordings"] }); + }, + }); +} + +/** + * Hook for managing real-time transcript with local buffering and batched uploads + */ +export function useLiveTranscript(posthogRecordingId: string | null) { + const [localSegments, setLocalSegments] = useState([]); + const [pendingUpload, setPendingUpload] = useState([]); + const uploadMutation = useUploadTranscript(); + const { data: serverTranscript } = useTranscript(posthogRecordingId); + + // Keep track of uploaded segment timestamps to avoid duplicates + const uploadedTimestamps = useRef(new Set()); + + // Merge server transcript with local segments + const allSegments = [ + ...(serverTranscript?.segments || []), + ...localSegments, + ].sort((a, b) => a.timestamp - b.timestamp); + + // Add new segment from IPC + const addSegment = useCallback((segment: TranscriptSegment) => { + // Check if already uploaded or in local buffer + if (uploadedTimestamps.current.has(segment.timestamp)) { + return; + } + + setLocalSegments((prev) => { + // Avoid duplicates in local buffer + const exists = prev.some((s) => s.timestamp === segment.timestamp); + if (exists) return prev; + return [...prev, segment]; + }); + + setPendingUpload((prev) => [...prev, segment]); + }, []); + + const uploadSegments = useCallback(() => { + if (!posthogRecordingId || pendingUpload.length === 0) return; + + const toUpload = [...pendingUpload]; + + uploadMutation.mutate( + { + recordingId: posthogRecordingId, + segments: toUpload, + }, + { + onSuccess: () => { + // Mark as uploaded + for (const seg of toUpload) { + uploadedTimestamps.current.add(seg.timestamp); + } + + // Remove from local buffer after successful upload + setLocalSegments((prev) => + prev.filter( + (s) => !toUpload.some((u) => u.timestamp === s.timestamp), + ), + ); + + // Clear pending + setPendingUpload([]); + }, + onError: (error) => { + console.error("[LiveTranscript] Failed to upload segments:", error); + // Keep in pending for retry + }, + }, + ); + }, [posthogRecordingId, pendingUpload, uploadMutation]); + + // Batch upload every 10 segments or 10 seconds + useEffect(() => { + if (!posthogRecordingId || pendingUpload.length === 0) return; + + const shouldUpload = pendingUpload.length >= 10; + + const timer = setTimeout(() => { + if (pendingUpload.length > 0) { + uploadSegments(); + } + }, 10000); // 10s + + if (shouldUpload) { + uploadSegments(); + } + + return () => clearTimeout(timer); + }, [pendingUpload.length, posthogRecordingId, uploadSegments]); + + // Force upload (e.g., when meeting ends) + const forceUpload = useCallback(() => { + if (pendingUpload.length > 0) { + uploadSegments(); + } + }, [pendingUpload, uploadSegments]); + + // Reset when recording changes + useEffect(() => { + setLocalSegments([]); + setPendingUpload([]); + uploadedTimestamps.current.clear(); + }, []); + + return { + segments: allSegments, + addSegment, + forceUpload, + isUploading: uploadMutation.isPending, + pendingCount: pendingUpload.length, + }; +} diff --git a/src/renderer/features/notetaker/stores/notetakerStore.ts b/src/renderer/features/notetaker/stores/notetakerStore.ts deleted file mode 100644 index b603a37e..00000000 --- a/src/renderer/features/notetaker/stores/notetakerStore.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { create } from "zustand"; - -export interface DesktopRecording { - id: string; - platform: string; - title: string | null; - status: "recording" | "uploading" | "processing" | "ready" | "error"; - duration: number | null; - created_at: string; - video_url: string | null; - recall_recording_id: string | null; -} - -interface NotetakerState { - recordings: DesktopRecording[]; - isLoading: boolean; - error: string | null; - selectedRecordingId: string | null; - - fetchRecordings: () => Promise; - setSelectedRecording: (id: string | null) => void; - deleteRecording: (id: string) => Promise; - refreshRecording: (id: string) => Promise; -} - -export const useNotetakerStore = create((set, get) => ({ - recordings: [], - isLoading: false, - error: null, - selectedRecordingId: null, - - fetchRecordings: async () => { - set({ isLoading: true, error: null }); - try { - const recordings = await window.electronAPI.notetakerGetRecordings(); - set({ recordings, isLoading: false }); - } catch (error) { - console.error("[Notetaker] Failed to fetch recordings:", error); - set({ - error: - error instanceof Error ? error.message : "Failed to fetch recordings", - isLoading: false, - }); - } - }, - - setSelectedRecording: (id) => set({ selectedRecordingId: id }), - - deleteRecording: async (id: string) => { - try { - await window.electronAPI.notetakerDeleteRecording(id); - const { recordings } = get(); - set({ - recordings: recordings.filter((r) => r.id !== id), - selectedRecordingId: - get().selectedRecordingId === id ? null : get().selectedRecordingId, - }); - } catch (error) { - console.error("[Notetaker] Failed to delete recording:", error); - set({ - error: - error instanceof Error ? error.message : "Failed to delete recording", - }); - } - }, - - refreshRecording: async (id: string) => { - try { - const recording = await window.electronAPI.notetakerGetRecording(id); - const { recordings } = get(); - set({ - recordings: recordings.map((r) => (r.id === id ? recording : r)), - }); - } catch (error) { - console.error("[Notetaker] Failed to refresh recording:", error); - } - }, -})); diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 95dac860..02730f74 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -148,6 +148,24 @@ export interface IElectronAPI { recall_recording_id: string | null; }>; notetakerDeleteRecording: (recordingId: string) => Promise; + // Real-time transcript listener + onTranscriptSegment: ( + listener: (segment: { + posthog_recording_id: string; + timestamp: number; + speaker: string | null; + text: string; + confidence: number | null; + is_final: boolean; + }) => void, + ) => () => void; + // Meeting ended listener (trigger sync) + onMeetingEnded: ( + listener: (event: { + posthog_recording_id: string; + platform: string; + }) => void, + ) => () => void; // Shell API shellCreate: (sessionId: string, cwd?: string) => Promise; shellWrite: (sessionId: string, data: string) => Promise; From 6f42e14c8ed3c9b2d69433e0db6e00a81a26f0e5 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Mon, 27 Oct 2025 12:56:29 -0300 Subject: [PATCH 2/4] fix: auto-scroll when new transcript segments arrive --- .../notetaker/components/LiveTranscriptView.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/renderer/features/notetaker/components/LiveTranscriptView.tsx b/src/renderer/features/notetaker/components/LiveTranscriptView.tsx index 0dda2a54..9d051c83 100644 --- a/src/renderer/features/notetaker/components/LiveTranscriptView.tsx +++ b/src/renderer/features/notetaker/components/LiveTranscriptView.tsx @@ -15,6 +15,13 @@ export function LiveTranscriptView({ const { segments, addSegment, forceUpload, pendingCount } = useLiveTranscript(posthogRecordingId); + // Auto-scroll to bottom when new segments arrive + useEffect(() => { + if (autoScroll && scrollRef.current && segments.length > 0) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [segments.length, autoScroll]); + // Listen for new transcript segments from IPC useEffect(() => { console.log( @@ -64,13 +71,6 @@ export function LiveTranscriptView({ return cleanup; }, [posthogRecordingId, forceUpload]); - // Auto-scroll to bottom when new segments arrive - useEffect(() => { - if (autoScroll && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [autoScroll]); - // Detect manual scroll to disable auto-scroll const handleScroll = () => { if (!scrollRef.current) return; From dad6e4081c3909e8bb81c74881486aa160545703 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Mon, 27 Oct 2025 12:58:36 -0300 Subject: [PATCH 3/4] feat: improve transcript readability with compact layout and speaker colors - More compact layout: removed Cards, reduced spacing - Speaker names only show when speaker changes (not repeated) - Consistent color-coding for each speaker - Alternating row backgrounds for better scanning - Timestamp inline with text for better use of space - 100px fixed-width speaker column for alignment --- .../components/LiveTranscriptView.tsx | 79 ++++++++++++++----- 1 file changed, 61 insertions(+), 18 deletions(-) diff --git a/src/renderer/features/notetaker/components/LiveTranscriptView.tsx b/src/renderer/features/notetaker/components/LiveTranscriptView.tsx index 9d051c83..7e8a764f 100644 --- a/src/renderer/features/notetaker/components/LiveTranscriptView.tsx +++ b/src/renderer/features/notetaker/components/LiveTranscriptView.tsx @@ -120,29 +120,53 @@ export function LiveTranscriptView({ ref={scrollRef as never} onScroll={handleScroll} > - - {segments.map((segment, idx) => ( - - - - {segment.speaker && ( - + + {segments.map((segment, idx) => { + const prevSegment = idx > 0 ? segments[idx - 1] : null; + const isSameSpeaker = prevSegment?.speaker === segment.speaker; + + return ( + + {/* Speaker name (only show if different from previous) */} + + {!isSameSpeaker && segment.speaker && ( + {segment.speaker} )} - - {formatTimestamp(segment.timestamp)} - + + + {/* Timestamp + Text */} + + + + {formatTimestamp(segment.timestamp)} + + + {segment.text} + + - {segment.text} - - ))} + ); + })} @@ -168,3 +192,22 @@ function formatTimestamp(milliseconds: number): string { } return `${minutes}:${seconds.toString().padStart(2, "0")}`; } + +// Consistent color assignment for speakers +function getSpeakerColor(speaker: string): string { + const colors = [ + "var(--blue-11)", + "var(--green-11)", + "var(--orange-11)", + "var(--purple-11)", + "var(--pink-11)", + "var(--cyan-11)", + ]; + + // Simple hash function to consistently map speaker to color + let hash = 0; + for (let i = 0; i < speaker.length; i++) { + hash = speaker.charCodeAt(i) + ((hash << 5) - hash); + } + return colors[Math.abs(hash) % colors.length]; +} From 7d86d35df6925a347a4c7451b8e0468250dd8989 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Mon, 27 Oct 2025 13:15:49 -0300 Subject: [PATCH 4/4] fix: disable transcript polling since we use real-time IPC events Removed refetchInterval from useTranscript query since we get real-time updates via IPC events from Recall.ai. Query still fetches once on mount for historical segments but doesn't continuously poll the backend. --- .../features/notetaker/hooks/useTranscript.ts | 168 +++++++++--------- 1 file changed, 85 insertions(+), 83 deletions(-) diff --git a/src/renderer/features/notetaker/hooks/useTranscript.ts b/src/renderer/features/notetaker/hooks/useTranscript.ts index 862838c9..ba69f72d 100644 --- a/src/renderer/features/notetaker/hooks/useTranscript.ts +++ b/src/renderer/features/notetaker/hooks/useTranscript.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useAuthStore } from "../../../stores/authStore"; export interface TranscriptSegment { @@ -27,8 +27,8 @@ export function useTranscript(recordingId: string | null) { return transcript as TranscriptData; }, enabled: !!client && !!recordingId, - refetchInterval: 5000, // Poll every 5s to get updates - staleTime: 2000, + refetchInterval: false, // Disabled: we get real-time updates via IPC events + staleTime: Infinity, // Never consider stale since we update via IPC }); } @@ -67,113 +67,115 @@ export function useUploadTranscript() { } /** - * Hook for managing real-time transcript with local buffering and batched uploads + * Hook for managing real-time transcript with TanStack Query cache and batched uploads */ export function useLiveTranscript(posthogRecordingId: string | null) { - const [localSegments, setLocalSegments] = useState([]); - const [pendingUpload, setPendingUpload] = useState([]); + const queryClient = useQueryClient(); const uploadMutation = useUploadTranscript(); const { data: serverTranscript } = useTranscript(posthogRecordingId); - // Keep track of uploaded segment timestamps to avoid duplicates - const uploadedTimestamps = useRef(new Set()); + // Track pending segments for upload + const pendingSegmentsRef = useRef([]); + const uploadTimerRef = useRef(); + const posthogRecordingIdRef = useRef(posthogRecordingId); + posthogRecordingIdRef.current = posthogRecordingId; + + // Upload pending segments helper + const doUpload = useCallback(() => { + if ( + !posthogRecordingIdRef.current || + pendingSegmentsRef.current.length === 0 + ) + return; - // Merge server transcript with local segments - const allSegments = [ - ...(serverTranscript?.segments || []), - ...localSegments, - ].sort((a, b) => a.timestamp - b.timestamp); + const toUpload = [...pendingSegmentsRef.current]; + pendingSegmentsRef.current = []; - // Add new segment from IPC - const addSegment = useCallback((segment: TranscriptSegment) => { - // Check if already uploaded or in local buffer - if (uploadedTimestamps.current.has(segment.timestamp)) { - return; + if (uploadTimerRef.current) { + clearTimeout(uploadTimerRef.current); + uploadTimerRef.current = undefined; } - setLocalSegments((prev) => { - // Avoid duplicates in local buffer - const exists = prev.some((s) => s.timestamp === segment.timestamp); - if (exists) return prev; - return [...prev, segment]; + uploadMutation.mutate({ + recordingId: posthogRecordingIdRef.current, + segments: toUpload, }); + }, [uploadMutation]); + + // Add new segment from IPC - optimistically update cache + const addSegment = useCallback( + (segment: TranscriptSegment) => { + if (!posthogRecordingId) return; + + // Optimistically update TanStack Query cache + queryClient.setQueryData( + ["notetaker-transcript", posthogRecordingId], + (old) => { + const existingSegments = old?.segments || []; + + // Check if segment already exists + const exists = existingSegments.some( + (s) => s.timestamp === segment.timestamp, + ); + if (exists) return old; - setPendingUpload((prev) => [...prev, segment]); - }, []); - - const uploadSegments = useCallback(() => { - if (!posthogRecordingId || pendingUpload.length === 0) return; - - const toUpload = [...pendingUpload]; - - uploadMutation.mutate( - { - recordingId: posthogRecordingId, - segments: toUpload, - }, - { - onSuccess: () => { - // Mark as uploaded - for (const seg of toUpload) { - uploadedTimestamps.current.add(seg.timestamp); - } - - // Remove from local buffer after successful upload - setLocalSegments((prev) => - prev.filter( - (s) => !toUpload.some((u) => u.timestamp === s.timestamp), - ), + // Add to cache + const newSegments = [...existingSegments, segment].sort( + (a, b) => a.timestamp - b.timestamp, ); - // Clear pending - setPendingUpload([]); - }, - onError: (error) => { - console.error("[LiveTranscript] Failed to upload segments:", error); - // Keep in pending for retry + return { + full_text: newSegments.map((s) => s.text).join(" "), + segments: newSegments, + }; }, - }, - ); - }, [posthogRecordingId, pendingUpload, uploadMutation]); - - // Batch upload every 10 segments or 10 seconds - useEffect(() => { - if (!posthogRecordingId || pendingUpload.length === 0) return; - - const shouldUpload = pendingUpload.length >= 10; - - const timer = setTimeout(() => { - if (pendingUpload.length > 0) { - uploadSegments(); + ); + + // Track for batched upload + const alreadyPending = pendingSegmentsRef.current.some( + (s) => s.timestamp === segment.timestamp, + ); + if (!alreadyPending) { + pendingSegmentsRef.current.push(segment); } - }, 10000); // 10s - if (shouldUpload) { - uploadSegments(); - } + // Clear existing timer + if (uploadTimerRef.current) { + clearTimeout(uploadTimerRef.current); + } - return () => clearTimeout(timer); - }, [pendingUpload.length, posthogRecordingId, uploadSegments]); + // Upload immediately if we have 10 segments + if (pendingSegmentsRef.current.length >= 10) { + doUpload(); + } else { + // Otherwise schedule upload in 10 seconds + uploadTimerRef.current = setTimeout(() => { + doUpload(); + }, 10000); + } + }, + [posthogRecordingId, queryClient, doUpload], + ); // Force upload (e.g., when meeting ends) const forceUpload = useCallback(() => { - if (pendingUpload.length > 0) { - uploadSegments(); - } - }, [pendingUpload, uploadSegments]); + doUpload(); + }, [doUpload]); - // Reset when recording changes + // Cleanup timer on unmount useEffect(() => { - setLocalSegments([]); - setPendingUpload([]); - uploadedTimestamps.current.clear(); + return () => { + if (uploadTimerRef.current) { + clearTimeout(uploadTimerRef.current); + } + }; }, []); return { - segments: allSegments, + segments: serverTranscript?.segments || [], addSegment, forceUpload, isUploading: uploadMutation.isPending, - pendingCount: pendingUpload.length, + pendingCount: pendingSegmentsRef.current.length, }; }