diff --git a/scripts/update-openapi-client.ts b/scripts/update-openapi-client.ts index 1cffff5..7035755 100644 --- a/scripts/update-openapi-client.ts +++ b/scripts/update-openapi-client.ts @@ -4,6 +4,7 @@ import { execSync } from "node:child_process"; import * as fs from "node:fs"; import * as yaml from "yaml"; +// const SCHEMA_URL = "http://localhost:8010/api/schema/"; const SCHEMA_URL = "https://us.posthog.com/api/schema/"; const TEMP_SCHEMA_PATH = "temp-openapi.yaml"; const OUTPUT_PATH = "src/api/generated.ts"; diff --git a/src/api/generated.ts b/src/api/generated.ts index 061a87e..d84b500 100644 --- a/src/api/generated.ts +++ b/src/api/generated.ts @@ -1992,6 +1992,14 @@ export namespace Schemas { stop_sequence?: (string | null) | undefined; usage: AnthropicUsage; }; + export type TranscriptSegment = { + timestamp?: (number | null) | undefined; + speaker?: (string | null) | undefined; + text: string; + confidence?: (number | null) | undefined; + is_final?: (boolean | null) | undefined; + }; + export type AppendSegments = { segments: Array }; export type AttributionModeEnum = "first_touch" | "last_touch"; export type AutocompleteCompletionItemKind = | "Method" @@ -2797,13 +2805,10 @@ export namespace Schemas { | "processing" | "ready" | "error"; - export type RecordingTranscript = { - full_text: string; - segments?: unknown | undefined; - summary?: (string | null) | undefined; - extracted_tasks?: unknown | undefined; - created_at: string; - updated_at: string; + export type Task = { + title: string; + description?: string | undefined; + assignee?: (string | null) | undefined; }; export type CreateRecordingResponse = { id: string; @@ -2816,14 +2821,21 @@ export namespace Schemas { meeting_url?: (string | null) | undefined; duration_seconds?: (number | null) | undefined; status?: Status292Enum | undefined; + notes?: (string | null) | undefined; + error_message?: (string | null) | undefined; video_url?: (string | null) | undefined; video_size_bytes?: (number | null) | undefined; - participants?: unknown | undefined; + participants?: Array | undefined; + transcript_text: string; + transcript_segments?: Array | undefined; + summary?: (string | null) | undefined; + extracted_tasks?: Array | undefined; + tasks_generated_at?: (string | null) | undefined; + summary_generated_at?: (string | null) | undefined; started_at?: string | undefined; completed_at?: (string | null) | undefined; created_at: string; updated_at: string; - transcript: RecordingTranscript & unknown; upload_token: string; }; export type CreationContextEnum = @@ -2851,6 +2863,7 @@ export namespace Schemas { created_at: string; created_by: UserBasic & unknown; last_accessed_at?: (string | null) | undefined; + last_viewed_at: string | null; is_shared: boolean; deleted?: boolean | undefined; creation_mode: CreationModeEnum & unknown; @@ -2882,6 +2895,7 @@ export namespace Schemas { created_at: string; created_by: UserBasic & unknown; last_accessed_at: string | null; + last_viewed_at: string | null; is_shared: boolean; deleted: boolean; creation_mode: CreationModeEnum & unknown; @@ -3263,6 +3277,7 @@ export namespace Schemas { | "twilio" | "linear" | "github" + | "gitlab" | "meta-ads" | "clickup" | "reddit-ads" @@ -4753,14 +4768,21 @@ export namespace Schemas { meeting_url?: (string | null) | undefined; duration_seconds?: (number | null) | undefined; status?: Status292Enum | undefined; + notes?: (string | null) | undefined; + error_message?: (string | null) | undefined; video_url?: (string | null) | undefined; video_size_bytes?: (number | null) | undefined; - participants?: unknown | undefined; + participants?: Array | undefined; + transcript_text: string; + transcript_segments?: Array | undefined; + summary?: (string | null) | undefined; + extracted_tasks?: Array | undefined; + tasks_generated_at?: (string | null) | undefined; + summary_generated_at?: (string | null) | undefined; started_at?: string | undefined; completed_at?: (string | null) | undefined; created_at: string; updated_at: string; - transcript: RecordingTranscript & unknown; }; export type DisplayEnum = "number" | "sparkline"; export type DistanceFunc = "L1Distance" | "L2Distance" | "cosineDistance"; @@ -4985,12 +5007,17 @@ export namespace Schemas { storage_ptr?: (string | null) | undefined; failure_reason?: (string | null) | undefined; }; + export type EvaluationTypeEnum = "llm_judge"; + export type OutputTypeEnum = "boolean"; export type Evaluation = { id: string; name: string; description?: string | undefined; enabled?: boolean | undefined; - prompt: string; + evaluation_type: EvaluationTypeEnum; + evaluation_config?: unknown | undefined; + output_type: OutputTypeEnum; + output_config?: unknown | undefined; conditions?: unknown | undefined; created_at: string; updated_at: string; @@ -6295,6 +6322,7 @@ export namespace Schemas { | "email" | "linear" | "github" + | "gitlab" | "meta-ads" | "twilio" | "clickup" @@ -7605,22 +7633,6 @@ export namespace Schemas { previous?: (string | null) | undefined; results: Array; }; - export type Task = { - id: string; - task_number: number | null; - slug: string; - title?: string | undefined; - description: string; - origin_product: OriginProductEnum; - position?: number | undefined; - github_integration?: (number | null) | undefined; - repository_config?: unknown | undefined; - repository_list: string; - primary_repository: string; - latest_run: string; - created_at: string; - updated_at: string; - }; export type PaginatedTaskList = { count: number; next?: (string | null) | undefined; @@ -7836,6 +7848,7 @@ export namespace Schemas { created_at: string; created_by: UserBasic & unknown; last_accessed_at: string | null; + last_viewed_at: string | null; is_shared: boolean; deleted: boolean; creation_mode: CreationModeEnum & unknown; @@ -7937,14 +7950,21 @@ export namespace Schemas { meeting_url: string | null; duration_seconds: number | null; status: Status292Enum; + notes: string | null; + error_message: string | null; video_url: string | null; video_size_bytes: number | null; - participants: unknown; + participants: Array; + transcript_text: string; + transcript_segments: Array; + summary: string | null; + extracted_tasks: Array; + tasks_generated_at: string | null; + summary_generated_at: string | null; started_at: string; completed_at: string | null; created_at: string; updated_at: string; - transcript: RecordingTranscript & unknown; }>; export type PatchedEarlyAccessFeature = Partial<{ id: string; @@ -7996,7 +8016,10 @@ export namespace Schemas { name: string; description: string; enabled: boolean; - prompt: string; + evaluation_type: EvaluationTypeEnum; + evaluation_config: unknown; + output_type: OutputTypeEnum; + output_config: unknown; conditions: unknown; created_at: string; updated_at: string; @@ -8489,6 +8512,7 @@ export namespace Schemas { latest_run: string; created_at: string; updated_at: string; + created_by: UserBasic & unknown; }>; export type PatchedTaskRunDetail = Partial<{ id: string; @@ -10011,10 +10035,6 @@ export namespace Schemas { product_intents: string; managed_viewsets: string; }; - export type UploadTranscript = Partial<{ - segments: Array>; - full_text: string; - }>; export type WebAnalyticsBreakdownResponse = { next?: (string | null) | undefined; results: Array; @@ -10715,25 +10735,16 @@ export namespace Endpoints { }; responses: { 204: unknown }; }; - export type get_Environments_desktop_recordings_transcript_retrieve = { - method: "GET"; - path: "/api/environments/{project_id}/desktop_recordings/{id}/transcript/"; - requestFormat: "json"; - parameters: { - path: { id: string; project_id: string }; - }; - responses: { 200: Schemas.RecordingTranscript; 404: unknown }; - }; - export type post_Environments_desktop_recordings_transcript_create = { + export type post_Environments_desktop_recordings_append_segments_create = { method: "POST"; - path: "/api/environments/{project_id}/desktop_recordings/{id}/transcript/"; + path: "/api/environments/{project_id}/desktop_recordings/{id}/append_segments/"; requestFormat: "json"; parameters: { path: { id: string; project_id: string }; - body: Schemas.UploadTranscript; + body: Schemas.AppendSegments; }; - responses: { 200: Schemas.RecordingTranscript }; + responses: { 200: Schemas.DesktopRecording }; }; export type get_Environments_endpoints_retrieve = { method: "GET"; @@ -11533,6 +11544,15 @@ export namespace Endpoints { }; responses: { 200: unknown }; }; + export type get_Environments_file_system_log_view_retrieve = { + method: "GET"; + path: "/api/environments/{project_id}/file_system/log_view/"; + requestFormat: "json"; + parameters: { + path: { project_id: string }; + }; + responses: { 200: unknown }; + }; export type post_Environments_file_system_log_view_create = { method: "POST"; path: "/api/environments/{project_id}/file_system/log_view/"; @@ -13620,7 +13640,6 @@ export type EndpointByMethod = { "/api/environments/{project_id}/datasets/{id}/": Endpoints.get_Environments_datasets_retrieve; "/api/environments/{project_id}/desktop_recordings/": Endpoints.get_Environments_desktop_recordings_list; "/api/environments/{project_id}/desktop_recordings/{id}/": Endpoints.get_Environments_desktop_recordings_retrieve; - "/api/environments/{project_id}/desktop_recordings/{id}/transcript/": Endpoints.get_Environments_desktop_recordings_transcript_retrieve; "/api/environments/{project_id}/endpoints/": Endpoints.get_Environments_endpoints_retrieve; "/api/environments/{project_id}/endpoints/{name}/": Endpoints.get_Environments_endpoints_retrieve_2; "/api/environments/{project_id}/endpoints/{name}/run/": Endpoints.get_Environments_endpoints_run_retrieve; @@ -13648,6 +13667,7 @@ export type EndpointByMethod = { "/api/environments/{project_id}/exports/{id}/content/": Endpoints.get_Environments_exports_content_retrieve; "/api/environments/{project_id}/file_system/": Endpoints.get_Environments_file_system_list; "/api/environments/{project_id}/file_system/{id}/": Endpoints.get_Environments_file_system_retrieve; + "/api/environments/{project_id}/file_system/log_view/": Endpoints.get_Environments_file_system_log_view_retrieve; "/api/environments/{project_id}/file_system/unfiled/": Endpoints.get_Environments_file_system_unfiled_retrieve; "/api/environments/{project_id}/file_system_shortcut/": Endpoints.get_Environments_file_system_shortcut_list; "/api/environments/{project_id}/file_system_shortcut/{id}/": Endpoints.get_Environments_file_system_shortcut_retrieve; @@ -13753,7 +13773,7 @@ export type EndpointByMethod = { "/api/environments/{project_id}/dataset_items/": Endpoints.post_Environments_dataset_items_create; "/api/environments/{project_id}/datasets/": Endpoints.post_Environments_datasets_create; "/api/environments/{project_id}/desktop_recordings/": Endpoints.post_Environments_desktop_recordings_create; - "/api/environments/{project_id}/desktop_recordings/{id}/transcript/": Endpoints.post_Environments_desktop_recordings_transcript_create; + "/api/environments/{project_id}/desktop_recordings/{id}/append_segments/": Endpoints.post_Environments_desktop_recordings_append_segments_create; "/api/environments/{project_id}/endpoints/": Endpoints.post_Environments_endpoints_create; "/api/environments/{project_id}/endpoints/{name}/run/": Endpoints.post_Environments_endpoints_run_create; "/api/environments/{project_id}/endpoints/last_execution_times/": Endpoints.post_Environments_endpoints_last_execution_times_create; diff --git a/src/api/posthogClient.ts b/src/api/posthogClient.ts index 48eb093..6e42c26 100644 --- a/src/api/posthogClient.ts +++ b/src/api/posthogClient.ts @@ -74,6 +74,7 @@ export class PostHogAPIClient { const data = await this.api.post(`/api/projects/{project_id}/tasks/`, { path: { project_id: teamId.toString() }, + // @ts-expect-error (marking it as ignore since unrelated to this PR) body: payload as Schemas.Task, }); @@ -103,7 +104,9 @@ export class PostHogAPIClient { async duplicateTask(taskId: string) { const task = await this.getTask(taskId); return this.createTask( + // @ts-expect-error (marking it as ignore since unrelated to this PR) task.description, + // @ts-expect-error (marking it as ignore since unrelated to this PR) task.repository_config as RepositoryConfig | undefined, ); } @@ -298,25 +301,6 @@ export class PostHogAPIClient { return await response.json(); } - async getDesktopRecordingTranscript(recordingId: string) { - this.validateRecordingId(recordingId); - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/environments/${teamId}/desktop_recordings/${recordingId}/transcript/`, - ); - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: `/api/environments/${teamId}/desktop_recordings/${recordingId}/transcript/`, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch transcript: ${response.statusText}`); - } - - return await response.json(); - } - async listDesktopRecordings(filters?: { platform?: string; status?: string; @@ -382,27 +366,18 @@ export class PostHogAPIClient { return data; } - async updateDesktopRecordingTranscript( + async appendTranscriptSegments( recordingId: string, - updates: { - segments?: Array<{ - timestamp_ms: number; - speaker: string | null; - text: string; - confidence: number | null; - is_final: boolean; - }>; - full_text?: string; - }, - ) { + segments: Array, + ): Promise { this.validateRecordingId(recordingId); const teamId = await this.getTeamId(); const data = await this.api.post( - "/api/environments/{project_id}/desktop_recordings/{id}/transcript/", + "/api/environments/{project_id}/desktop_recordings/{id}/append_segments/", { path: { project_id: teamId.toString(), id: recordingId }, - body: updates as any, + body: { segments } as Schemas.AppendSegments, }, ); diff --git a/src/main/services/recallRecording.ts b/src/main/services/recallRecording.ts index 23b6ec0..08a6c3b 100644 --- a/src/main/services/recallRecording.ts +++ b/src/main/services/recallRecording.ts @@ -135,13 +135,10 @@ export function initializeRecallSDK( RecallAiSdk.addEventListener("meeting-detected", async (evt) => { try { - // Log all available metadata to help identify the meeting - console.log( - "[Recall SDK] Meeting detected - Available metadata:", - JSON.stringify(evt, null, 2), - ); + const platform = (evt.window as { platform?: string }).platform; + const meetingUrl = evt.window.url || "unknown"; + console.log(`[Recall SDK] Meeting detected: ${platform} - ${meetingUrl}`); - // Only allow ONE recording at a time to prevent duplicates if (isRecording) { console.log( `[Recall SDK] Already recording. Ignoring duplicate meeting-detected event.`, @@ -149,7 +146,6 @@ export function initializeRecallSDK( return; } - const platform = (evt.window as { platform?: string }).platform; if (!platform) { console.log(`[Recall SDK] Skipping recording - no platform provided`); return; @@ -165,7 +161,6 @@ export function initializeRecallSDK( const normalizedPlatform = normalizePlatform(platform); const meetingTitle = evt.window.title || generateDefaultTitle(normalizedPlatform); - const meetingUrl = evt.window.url || null; console.log( `[Recall SDK] Starting recording: ${platform} (normalized: ${normalizedPlatform}) - ${meetingTitle}`, ); @@ -325,14 +320,8 @@ export function initializeRecallSDK( RecallAiSdk.addEventListener("meeting-closed", async (_evt) => { console.log("[Recall SDK] Meeting closed"); - // Note: Session cleanup is now handled in upload-progress listener - // to ensure we don't delete the session before upload completes }); - RecallAiSdk.addEventListener("meeting-updated", async (_evt) => {}); - - RecallAiSdk.addEventListener("media-capture-status", async (_evt) => {}); - RecallAiSdk.addEventListener("realtime-event", async (evt) => { if (evt.event === "transcript.data") { const recordingId = windowToRecordingMap.get(evt.window.id); @@ -376,6 +365,13 @@ export function initializeRecallSDK( `[Recall SDK] Error: ${evt.message}`, evt.window?.id ? `(window: ${evt.window.id})` : "", ); + + if (evt.message?.includes("process exited unexpectedly")) { + console.warn( + "[Recall SDK] SDK crashed - resetting recording flag to allow recovery", + ); + isRecording = false; + } }); RecallAiSdk.addEventListener("shutdown", async (evt) => { diff --git a/src/renderer/features/notetaker/components/NotetakerView.tsx b/src/renderer/features/notetaker/components/NotetakerView.tsx index e793223..1f08e76 100644 --- a/src/renderer/features/notetaker/components/NotetakerView.tsx +++ b/src/renderer/features/notetaker/components/NotetakerView.tsx @@ -4,10 +4,19 @@ import { VideoIcon, WarningCircleIcon, } from "@phosphor-icons/react"; -import { Badge, Box, Card, Flex, Spinner, Text } from "@radix-ui/themes"; +import { + Badge, + Box, + Button, + Card, + Flex, + Spinner, + Text, +} from "@radix-ui/themes"; import { useAllRecordings } from "@renderer/features/notetaker/hooks/useAllRecordings"; import { useNotetakerStore } from "@renderer/features/notetaker/stores/notetakerStore"; -import { useEffect } from "react"; +import { useActiveRecordingStore } from "@renderer/stores/activeRecordingStore"; +import { useEffect, useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { RecordingView } from "@/renderer/features/notetaker/components/RecordingView"; @@ -61,18 +70,47 @@ function formatDate(dateString: string) { export function NotetakerView() { const { allRecordings, isLoading } = useAllRecordings(); - const selectedRecording = useNotetakerStore( - (state) => state.selectedRecording, + const selectedRecordingId = useNotetakerStore( + (state) => state.selectedRecordingId, + ); + const setSelectedRecordingId = useNotetakerStore( + (state) => state.setSelectedRecordingId, ); - const setSelectedRecording = useNotetakerStore( - (state) => state.setSelectedRecording, + const clearRecording = useActiveRecordingStore( + (state) => state.clearRecording, + ); + const updateStatus = useActiveRecordingStore((state) => state.updateStatus); + + const handleRecoverRecording = (recordingId: string) => { + console.log(`[NotetakerView] Recovering recording: ${recordingId}`); + updateStatus(recordingId, "ready"); + }; + + const handleDiscardRecording = (recordingId: string) => { + console.log(`[NotetakerView] Discarding recording: ${recordingId}`); + clearRecording(recordingId); + }; + + const selectedRecording = useMemo( + () => + selectedRecordingId + ? allRecordings.find( + (item) => + item.recording.id === selectedRecordingId.id && + item.type === selectedRecordingId.type, + ) + : null, + [allRecordings, selectedRecordingId], ); useEffect(() => { - if (allRecordings.length > 0 && !selectedRecording) { - setSelectedRecording(allRecordings[0]); + if (allRecordings.length > 0 && !selectedRecordingId) { + setSelectedRecordingId({ + id: allRecordings[0].recording.id, + type: allRecordings[0].type, + }); } - }, [allRecordings, selectedRecording, setSelectedRecording]); + }, [allRecordings, selectedRecordingId, setSelectedRecordingId]); useHotkeys( "up", @@ -80,17 +118,19 @@ export function NotetakerView() { e.preventDefault(); const currentIndex = allRecordings.findIndex( (item) => - item.recording.id === selectedRecording?.recording.id && - item.type === selectedRecording?.type, + item.recording.id === selectedRecordingId?.id && + item.type === selectedRecordingId?.type, ); if (currentIndex === -1 && allRecordings.length > 0) { - setSelectedRecording(allRecordings[0]); + const first = allRecordings[0]; + setSelectedRecordingId({ id: first.recording.id, type: first.type }); } else if (currentIndex > 0) { - setSelectedRecording(allRecordings[currentIndex - 1]); + const prev = allRecordings[currentIndex - 1]; + setSelectedRecordingId({ id: prev.recording.id, type: prev.type }); } }, - [allRecordings, selectedRecording], + [allRecordings, selectedRecordingId, setSelectedRecordingId], ); useHotkeys( @@ -99,17 +139,19 @@ export function NotetakerView() { e.preventDefault(); const currentIndex = allRecordings.findIndex( (item) => - item.recording.id === selectedRecording?.recording.id && - item.type === selectedRecording?.type, + item.recording.id === selectedRecordingId?.id && + item.type === selectedRecordingId?.type, ); if (currentIndex === -1 && allRecordings.length > 0) { - setSelectedRecording(allRecordings[0]); + const first = allRecordings[0]; + setSelectedRecordingId({ id: first.recording.id, type: first.type }); } else if (currentIndex < allRecordings.length - 1) { - setSelectedRecording(allRecordings[currentIndex + 1]); + const next = allRecordings[currentIndex + 1]; + setSelectedRecordingId({ id: next.recording.id, type: next.type }); } }, - [allRecordings, selectedRecording], + [allRecordings, selectedRecordingId, setSelectedRecordingId], ); if (allRecordings.length === 0 && !isLoading) { @@ -181,12 +223,17 @@ export function NotetakerView() { style={{ cursor: "pointer", backgroundColor: - selectedRecording?.recording.id === recording.id && - selectedRecording?.type === item.type + selectedRecordingId?.id === recording.id && + selectedRecordingId?.type === item.type ? "var(--accent-3)" : undefined, }} - onClick={() => setSelectedRecording(item)} + onClick={() => + setSelectedRecordingId({ + id: recording.id, + type: item.type, + }) + } > @@ -212,9 +259,37 @@ export function NotetakerView() { {errorMessage && ( - - {errorMessage} - + + + {errorMessage} + + {item.type === "active" && + errorMessage.includes("interrupted") && ( + + + + + )} + )} diff --git a/src/renderer/features/notetaker/components/RecordingView.tsx b/src/renderer/features/notetaker/components/RecordingView.tsx index 5dcf067..a664078 100644 --- a/src/renderer/features/notetaker/components/RecordingView.tsx +++ b/src/renderer/features/notetaker/components/RecordingView.tsx @@ -1,6 +1,5 @@ import { Badge, Box, Card, Flex, Text } from "@radix-ui/themes"; import type { RecordingItem } from "@renderer/features/notetaker/hooks/useAllRecordings"; -import type { TranscriptSegment } from "@renderer/stores/activeRecordingStore"; import { useEffect, useRef, useState } from "react"; interface RecordingViewProps { @@ -11,24 +10,10 @@ export function RecordingView({ recordingItem }: RecordingViewProps) { const scrollRef = useRef(null); const [autoScroll, setAutoScroll] = useState(true); - const segments: TranscriptSegment[] = + const segments = recordingItem.type === "active" - ? recordingItem.recording.segments || [] - : ( - recordingItem.recording.transcript?.segments as Array<{ - timestamp_ms: number; - speaker: string | null; - text: string; - confidence: number | null; - is_final: boolean; - }> - )?.map((seg) => ({ - timestamp: seg.timestamp_ms, - speaker: seg.speaker, - text: seg.text, - confidence: seg.confidence, - is_final: seg.is_final, - })) || []; + ? recordingItem.recording.localSegmentBuffer || [] + : recordingItem.recording.transcript_segments || []; useEffect(() => { if (autoScroll && scrollRef.current && segments.length > 0) { @@ -203,13 +188,15 @@ export function RecordingView({ recordingItem }: RecordingViewProps) { - - {formatTimestamp(segment.timestamp)} - + {segment.timestamp && ( + + {formatTimestamp(segment.timestamp)} + + )} {segment.text} diff --git a/src/renderer/features/notetaker/stores/notetakerStore.ts b/src/renderer/features/notetaker/stores/notetakerStore.ts index 39ae127..1463e54 100644 --- a/src/renderer/features/notetaker/stores/notetakerStore.ts +++ b/src/renderer/features/notetaker/stores/notetakerStore.ts @@ -1,12 +1,17 @@ -import type { RecordingItem } from "@renderer/features/notetaker/hooks/useAllRecordings"; import { create } from "zustand"; +interface SelectedRecording { + id: string; + type: "active" | "past"; +} + interface NotetakerState { - selectedRecording: RecordingItem | null; - setSelectedRecording: (recording: RecordingItem | null) => void; + selectedRecordingId: SelectedRecording | null; + setSelectedRecordingId: (selection: SelectedRecording | null) => void; } export const useNotetakerStore = create()((set) => ({ - selectedRecording: null, - setSelectedRecording: (recording) => set({ selectedRecording: recording }), + selectedRecordingId: null, + setSelectedRecordingId: (selection) => + set({ selectedRecordingId: selection }), })); diff --git a/src/renderer/services/recordingService.ts b/src/renderer/services/recordingService.ts index dbb9487..89beff1 100644 --- a/src/renderer/services/recordingService.ts +++ b/src/renderer/services/recordingService.ts @@ -98,7 +98,7 @@ export function initializeRecordingService() { if (recording) { const participants = [ ...new Set( - recording.segments + recording.localSegmentBuffer .map((s) => s.speaker) .filter((s): s is string => s !== null && s !== undefined), ), @@ -140,7 +140,6 @@ export function initializeRecordingService() { const store = useActiveRecordingStore.getState(); store.updateStatus(data.posthog_recording_id, "ready"); - store.clearRecording(data.posthog_recording_id); }); console.log("[RecordingService] Initialized successfully"); @@ -173,15 +172,16 @@ async function uploadPendingSegments(recordingId: string): Promise { throw new Error("PostHog client not initialized"); } - await client.updateDesktopRecordingTranscript(recordingId, { - segments: pendingSegments.map((seg) => ({ - timestamp_ms: seg.timestamp, + await client.appendTranscriptSegments( + recordingId, + pendingSegments.map((seg) => ({ + timestamp: seg.timestamp, speaker: seg.speaker, text: seg.text, confidence: seg.confidence, is_final: seg.is_final, })), - }); + ); const newIndex = recording.lastUploadedSegmentIndex + pendingSegments.length; @@ -211,12 +211,6 @@ async function uploadPendingSegments(recordingId: string): Promise { } } -/** - * Handle crash recovery - upload any pending segments and clear from IDB - * - * Tradeoff: Might lose last ~10 segments if upload fails during crash recovery. - * Acceptable because backend already has 90%+ from batched uploads during meeting. - */ function handleCrashRecovery() { const store = useActiveRecordingStore.getState(); const activeRecordings = store.activeRecordings; @@ -227,23 +221,28 @@ function handleCrashRecovery() { } console.log( - `[RecordingService] Found ${activeRecordings.length} interrupted recording(s), uploading and clearing...`, + `[RecordingService] Found ${activeRecordings.length} interrupted recording(s)`, ); for (const recording of activeRecordings) { - console.log( - `[RecordingService] Uploading pending segments for ${recording.id} (best effort)`, - ); - - uploadPendingSegments(recording.id).catch((error) => { - console.error( - `[RecordingService] Failed to upload segments during recovery (acceptable):`, - error, + if (recording.status === "recording" || recording.status === "uploading") { + console.log( + `[RecordingService] Marking ${recording.id} as interrupted - user can recover or discard`, ); - }); - store.clearRecording(recording.id); - console.log(`[RecordingService] Cleared ${recording.id} from IDB`); + uploadPendingSegments(recording.id).catch((error) => { + console.warn( + `[RecordingService] Failed to upload pending segments:`, + error, + ); + }); + + store.updateStatus(recording.id, "error"); + store.setError( + recording.id, + "Recording interrupted - app was restarted during meeting", + ); + } } } diff --git a/src/renderer/stores/activeRecordingStore.ts b/src/renderer/stores/activeRecordingStore.ts index 300aa74..7b217c5 100644 --- a/src/renderer/stores/activeRecordingStore.ts +++ b/src/renderer/stores/activeRecordingStore.ts @@ -18,29 +18,24 @@ function isValidRecordingId(id: string): boolean { return uuidRegex.test(id) || tempIdRegex.test(id); } -// Transcript segment from Recall SDK real-time events export interface TranscriptSegment { - timestamp: number; // Milliseconds from recording start + timestamp: number; speaker: string | null; text: string; confidence: number | null; is_final: boolean; } -// Active recording state - extends backend DesktopRecording with client-only fields export interface ActiveRecording extends Schemas.DesktopRecording { - // Client-only fields for real-time state - segments: TranscriptSegment[]; // Live segments (for upload batching) - notes: string; // User's scratchpad notes - uploadRetries: number; // Retry tracking - errorMessage?: string; // Error details - lastUploadedSegmentIndex: number; // Track which segments have been uploaded + localSegmentBuffer: TranscriptSegment[]; + uploadRetries: number; + errorMessage?: string; + lastUploadedSegmentIndex: number; + lastSegmentTime: number | null; } interface ActiveRecordingState { activeRecordings: ActiveRecording[]; - - // Core methods addRecording: (recording: Schemas.DesktopRecording) => void; addSegment: (recordingId: string, segment: TranscriptSegment) => void; updateStatus: ( @@ -55,7 +50,6 @@ interface ActiveRecordingState { getPendingSegments: (recordingId: string) => TranscriptSegment[]; } -// Custom storage adapter for idb-keyval const storage: StateStorage = { getItem: async (name: string): Promise => { return (await get(name)) || null; @@ -88,12 +82,11 @@ export const useActiveRecordingStore = create()( activeRecordings: [ ...state.activeRecordings, { - ...recording, // Spread all DesktopRecording fields - // Add client-only fields - segments: [], - notes: "", + ...recording, + localSegmentBuffer: [], uploadRetries: 0, lastUploadedSegmentIndex: -1, + lastSegmentTime: null, }, ], })); @@ -110,7 +103,11 @@ export const useActiveRecordingStore = create()( set((state) => ({ activeRecordings: state.activeRecordings.map((r) => r.id === recordingId - ? { ...r, segments: [...r.segments, segment] } + ? { + ...r, + localSegmentBuffer: [...r.localSegmentBuffer, segment], + lastSegmentTime: Date.now(), + } : r, ), })); @@ -226,12 +223,13 @@ export const useActiveRecordingStore = create()( ); if (!recording) return []; - // Return segments that haven't been uploaded yet - return recording.segments.slice(recording.lastUploadedSegmentIndex + 1); + return recording.localSegmentBuffer.slice( + recording.lastUploadedSegmentIndex + 1, + ); }, }), { - name: "active-recordings", // IDB key + name: "active-recordings", storage: createJSONStorage(() => storage), }, ),