Skip to content

Commit c1715fc

Browse files
committed
feat: add RecordingView with live transcript and section scaffolding
- Add RecordingView component with comprehensive recording detail display - Display meeting header (title, platform, timestamp) - Scaffold 4 main sections with "Coming soon" placeholders: - Summary (AI-generated) - Action items (AI-extracted tasks) - Notes (user-written with timestamps) - Transcript (live or historical) - Implement live transcript with auto-scroll - Auto-scroll to bottom as new segments arrive - Manual scroll disables auto-scroll until near bottom - Only show auto-scroll hint for active recordings - Use native scrollable Box instead of ScrollArea for better control - Product-focused hierarchy: actionable info (summary/tasks) above reference (transcript) - Transcript constrained to 300px max-height to leave room for other sections
1 parent 6625edc commit c1715fc

File tree

2 files changed

+246
-0
lines changed

2 files changed

+246
-0
lines changed

src/renderer/features/notetaker/components/NotetakerView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useAllRecordings } from "@renderer/features/notetaker/hooks/useAllRecor
99
import { useNotetakerStore } from "@renderer/features/notetaker/stores/notetakerStore";
1010
import { useEffect } from "react";
1111
import { useHotkeys } from "react-hotkeys-hook";
12+
import { RecordingView } from "@/renderer/features/notetaker/components/RecordingView";
1213

1314
function getStatusIcon(
1415
status: "recording" | "uploading" | "processing" | "ready" | "error",
@@ -223,6 +224,8 @@ export function NotetakerView() {
223224
</Flex>
224225
</Flex>
225226
</Box>
227+
228+
{selectedRecording && <RecordingView recordingItem={selectedRecording} />}
226229
</Flex>
227230
);
228231
}
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import { Badge, Box, Card, Flex, Text } from "@radix-ui/themes";
2+
import type { RecordingItem } from "@renderer/features/notetaker/hooks/useAllRecordings";
3+
import type { TranscriptSegment } from "@renderer/stores/activeRecordingStore";
4+
import { useEffect, useRef, useState } from "react";
5+
6+
interface RecordingViewProps {
7+
recordingItem: RecordingItem;
8+
}
9+
10+
export function RecordingView({ recordingItem }: RecordingViewProps) {
11+
const scrollRef = useRef<HTMLDivElement>(null);
12+
const [autoScroll, setAutoScroll] = useState(true);
13+
14+
const segments: TranscriptSegment[] =
15+
recordingItem.type === "active"
16+
? recordingItem.recording.segments || []
17+
: (
18+
recordingItem.recording.transcript?.segments as Array<{
19+
timestamp_ms: number;
20+
speaker: string | null;
21+
text: string;
22+
confidence: number | null;
23+
is_final: boolean;
24+
}>
25+
)?.map((seg) => ({
26+
timestamp: seg.timestamp_ms,
27+
speaker: seg.speaker,
28+
text: seg.text,
29+
confidence: seg.confidence,
30+
is_final: seg.is_final,
31+
})) || [];
32+
33+
useEffect(() => {
34+
if (autoScroll && scrollRef.current && segments.length > 0) {
35+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
36+
}
37+
}, [segments.length, autoScroll]);
38+
39+
const handleScroll = () => {
40+
if (!scrollRef.current) return;
41+
42+
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
43+
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
44+
setAutoScroll(isNearBottom);
45+
};
46+
47+
const isPastRecording = recordingItem.type === "past";
48+
const hasSegments = segments.length > 0;
49+
50+
return (
51+
<Box
52+
p="4"
53+
className="flex flex-1 flex-col gap-4 overflow-y-auto overflow-x-hidden"
54+
>
55+
{/* Meeting Header */}
56+
<Flex direction="column" gap="2">
57+
<Text size="4" weight="bold">
58+
{recordingItem.recording.meeting_title || "Untitled meeting"}
59+
</Text>
60+
<Flex gap="2">
61+
<Badge color="gray" variant="soft">
62+
{recordingItem.recording.platform}
63+
</Badge>
64+
<Text size="2" color="gray">
65+
{new Date(
66+
recordingItem.recording.created_at || new Date(),
67+
).toLocaleString()}
68+
</Text>
69+
</Flex>
70+
</Flex>
71+
72+
{/* Summary - only for past recordings */}
73+
{isPastRecording && (
74+
<Flex direction="column" gap="2">
75+
<Text size="2" weight="bold">
76+
Summary
77+
</Text>
78+
<Card>
79+
<Flex align="center" justify="center" py="4">
80+
<Text size="2" color="gray">
81+
Coming soon
82+
</Text>
83+
</Flex>
84+
</Card>
85+
</Flex>
86+
)}
87+
88+
{/* Action items - only for past recordings */}
89+
{isPastRecording && (
90+
<Flex direction="column" gap="2">
91+
<Text size="2" weight="bold">
92+
Action items
93+
</Text>
94+
<Card>
95+
<Flex align="center" justify="center" py="4">
96+
<Text size="2" color="gray">
97+
Coming soon
98+
</Text>
99+
</Flex>
100+
</Card>
101+
</Flex>
102+
)}
103+
104+
{/* Notes - only for past recordings */}
105+
{isPastRecording && (
106+
<Flex direction="column" gap="2">
107+
<Text size="2" weight="bold">
108+
Notes
109+
</Text>
110+
<Card>
111+
<Flex align="center" justify="center" py="4">
112+
<Text size="2" color="gray">
113+
Coming soon
114+
</Text>
115+
</Flex>
116+
</Card>
117+
</Flex>
118+
)}
119+
120+
{/* Transcript */}
121+
<Flex direction="column" gap="2">
122+
<Flex justify="between" align="center">
123+
<Text size="2" weight="bold">
124+
{isPastRecording ? "Transcript" : "Live transcript"}
125+
</Text>
126+
{hasSegments && (
127+
<Badge color="gray" radius="full" size="1" variant="soft">
128+
{segments.length} segments
129+
</Badge>
130+
)}
131+
</Flex>
132+
133+
{hasSegments ? (
134+
<Box
135+
ref={scrollRef}
136+
onScroll={handleScroll}
137+
style={{
138+
maxHeight: "300px",
139+
minHeight: "200px",
140+
border: "1px solid var(--gray-5)",
141+
borderRadius: "var(--radius-3)",
142+
overflowY: "auto",
143+
}}
144+
>
145+
<Flex direction="column" gap="1">
146+
{segments.map((segment, idx) => {
147+
const prevSegment = idx > 0 ? segments[idx - 1] : null;
148+
const isSameSpeaker = prevSegment?.speaker === segment.speaker;
149+
150+
return (
151+
<Flex
152+
key={`${segment.timestamp}-${idx}`}
153+
gap="2"
154+
py="1"
155+
px="2"
156+
style={{
157+
backgroundColor:
158+
idx % 2 === 0 ? "var(--gray-2)" : "transparent",
159+
}}
160+
>
161+
<Box style={{ minWidth: "100px", flexShrink: 0 }}>
162+
{!isSameSpeaker && segment.speaker && (
163+
<Text
164+
size="1"
165+
weight="bold"
166+
style={{ color: getSpeakerColor(segment.speaker) }}
167+
>
168+
{segment.speaker}
169+
</Text>
170+
)}
171+
</Box>
172+
173+
<Flex direction="column" gap="1" style={{ flex: 1 }}>
174+
<Flex align="baseline" gap="2">
175+
<Text
176+
size="1"
177+
color="gray"
178+
style={{ fontVariantNumeric: "tabular-nums" }}
179+
>
180+
{formatTimestamp(segment.timestamp)}
181+
</Text>
182+
<Text size="2" style={{ lineHeight: "1.5" }}>
183+
{segment.text}
184+
</Text>
185+
</Flex>
186+
</Flex>
187+
</Flex>
188+
);
189+
})}
190+
</Flex>
191+
</Box>
192+
) : (
193+
<Card>
194+
<Flex align="center" justify="center" py="4">
195+
<Text size="2" color="gray">
196+
{isPastRecording
197+
? "No transcript available"
198+
: "Waiting for transcript..."}
199+
</Text>
200+
</Flex>
201+
</Card>
202+
)}
203+
204+
{!isPastRecording && !autoScroll && hasSegments && (
205+
<Text size="1" color="gray">
206+
Scroll to bottom to enable auto-scroll
207+
</Text>
208+
)}
209+
</Flex>
210+
</Box>
211+
);
212+
}
213+
214+
function formatTimestamp(milliseconds: number): string {
215+
const totalSeconds = Math.floor(milliseconds / 1000);
216+
const hours = Math.floor(totalSeconds / 3600);
217+
const minutes = Math.floor((totalSeconds % 3600) / 60);
218+
const seconds = totalSeconds % 60;
219+
220+
if (hours > 0) {
221+
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
222+
}
223+
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
224+
}
225+
226+
// Consistent color assignment for speakers
227+
function getSpeakerColor(speaker: string): string {
228+
const colors = [
229+
"var(--blue-11)",
230+
"var(--green-11)",
231+
"var(--orange-11)",
232+
"var(--purple-11)",
233+
"var(--pink-11)",
234+
"var(--cyan-11)",
235+
];
236+
237+
// Simple hash function to consistently map speaker to color
238+
let hash = 0;
239+
for (let i = 0; i < speaker.length; i++) {
240+
hash = speaker.charCodeAt(i) + ((hash << 5) - hash);
241+
}
242+
return colors[Math.abs(hash) % colors.length];
243+
}

0 commit comments

Comments
 (0)