Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/api/posthogClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
30 changes: 30 additions & 0 deletions src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,36 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.invoke("notetaker:get-recording", recordingId),
notetakerDeleteRecording: (recordingId: string): Promise<void> =>
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<void> =>
ipcRenderer.invoke("shell:create", sessionId, cwd),
Expand Down
213 changes: 213 additions & 0 deletions src/renderer/features/notetaker/components/LiveTranscriptView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
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<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);

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(
`[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]);

// 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 (
<Box p="4">
<Card>
<Text color="gray" size="2">
Waiting for transcript...
</Text>
</Card>
</Box>
);
}

return (
<Box
p="4"
style={{ height: "100%", display: "flex", flexDirection: "column" }}
>
<Flex justify="between" mb="3">
<Text size="2" weight="bold">
Live transcript
</Text>
<Flex gap="2">
{pendingCount > 0 && (
<Badge color="blue" radius="full">
{pendingCount} pending upload
</Badge>
)}
<Badge color="green" radius="full">
{segments.length} segments
</Badge>
</Flex>
</Flex>

<ScrollArea
type="auto"
scrollbars="vertical"
style={{ flex: 1, maxHeight: "60vh" }} // Limited height
ref={scrollRef as never}
onScroll={handleScroll}
>
<Flex direction="column" gap="1">
{segments.map((segment, idx) => {
const prevSegment = idx > 0 ? segments[idx - 1] : null;
const isSameSpeaker = prevSegment?.speaker === segment.speaker;

return (
<Flex
key={`${segment.timestamp}-${idx}`}
gap="2"
py="1"
px="2"
style={{
backgroundColor:
idx % 2 === 0 ? "var(--gray-2)" : "transparent",
}}
>
{/* Speaker name (only show if different from previous) */}
<Box style={{ minWidth: "100px", flexShrink: 0 }}>
{!isSameSpeaker && segment.speaker && (
<Text
size="1"
weight="bold"
style={{ color: getSpeakerColor(segment.speaker) }}
>
{segment.speaker}
</Text>
)}
</Box>

{/* Timestamp + Text */}
<Flex direction="column" gap="1" style={{ flex: 1 }}>
<Flex align="baseline" gap="2">
<Text
size="1"
color="gray"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{formatTimestamp(segment.timestamp)}
</Text>
<Text size="2" style={{ lineHeight: "1.5" }}>
{segment.text}
</Text>
</Flex>
</Flex>
</Flex>
);
})}
</Flex>
</ScrollArea>

{!autoScroll && (
<Box mt="2">
<Text size="1" color="gray">
Scroll to bottom to enable auto-scroll
</Text>
</Box>
)}
</Box>
);
}

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")}`;
}

// 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];
}
Loading
Loading