Files
noteflow/.hygeine/eslint.json
2026-01-06 08:03:04 +00:00

1 line
394 KiB
JSON

[{"filePath":"/home/trav/repos/noteflow/client/coverage/block-navigation.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/coverage/prettify.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/coverage/sorter.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/e2e-native-mac/app.spec.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/e2e-native-mac/fixtures.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/e2e-native-mac/test-helpers.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/eslint.config.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/playwright.config.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/postcss.config.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/App.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/cached-adapter.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/cached-adapter.ts","messages":[{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":1,"message":"Unsafe member access .length on an `error` typed value.","line":225,"column":52,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":225,"endColumn":58},{"ruleId":"@typescript-eslint/no-unsafe-argument","severity":1,"message":"Unsafe argument of type error typed assigned to a parameter of type `Iterable<unknown> | null | undefined`.","line":226,"column":34,"nodeType":"MemberExpression","messageId":"unsafeArgument","endLine":226,"endColumn":53}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// Cached read-only API adapter for offline mode\n\nimport { startTauriEventBridge } from '@/lib/tauri-events';\nimport { preferences } from '@/lib/preferences';\nimport { meetingCache } from '@/lib/cache/meeting-cache';\nimport type { NoteFlowAPI, TranscriptionStream } from './interface';\nimport type {\n AddAnnotationRequest,\n AddProjectMemberRequest,\n Annotation,\n AudioDeviceInfo,\n CancelDiarizationResult,\n CompleteCalendarAuthResponse,\n CreateMeetingRequest,\n CreateProjectRequest,\n DeleteWebhookResponse,\n DiarizationJobStatus,\n DisconnectOAuthResponse,\n ExportFormat,\n ExportResult,\n ExtractEntitiesResponse,\n ExtractedEntity,\n GetCalendarProvidersResponse,\n GetCurrentUserResponse,\n GetMeetingRequest,\n GetOAuthConnectionStatusResponse,\n GetProjectBySlugRequest,\n GetProjectRequest,\n GetSyncStatusResponse,\n GetWebhookDeliveriesResponse,\n InitiateCalendarAuthResponse,\n InstalledAppInfo,\n ListCalendarEventsResponse,\n ListMeetingsRequest,\n ListMeetingsResponse,\n ListProjectMembersRequest,\n ListProjectMembersResponse,\n ListProjectsRequest,\n ListProjectsResponse,\n ListSyncHistoryResponse,\n ListWebhooksResponse,\n ListWorkspacesResponse,\n Meeting,\n PlaybackInfo,\n Project,\n ProjectMembership,\n RegisteredWebhook,\n RegisterWebhookRequest,\n RemoveProjectMemberRequest,\n RemoveProjectMemberResponse,\n ServerInfo,\n StartIntegrationSyncResponse,\n Summary,\n SwitchWorkspaceResponse,\n TriggerStatus,\n UpdateAnnotationRequest,\n UpdateProjectMemberRoleRequest,\n UpdateProjectRequest,\n UpdateWebhookRequest,\n UserPreferences,\n} from './types';\nimport { initializeTauriAPI, isTauriEnvironment } from './tauri-adapter';\nimport { setAPIInstance } from './interface';\nimport { setConnectionMode, setConnectionServerUrl } from './connection-state';\nimport {\n offlineProjects,\n offlineServerInfo,\n offlineUser,\n offlineWorkspaces,\n} from './offline-defaults';\nimport { cachedObservabilityAPI } from './cached/observability';\n\nconst rejectReadOnly = async <T>(): Promise<T> => {\n throw new Error('Cached read-only mode: reconnect to enable write operations.');\n};\n\nasync function connectWithTauri(serverUrl?: string): Promise<ServerInfo> {\n if (!isTauriEnvironment()) {\n throw new Error('Tauri environment required to connect.');\n }\n const tauriAPI = await initializeTauriAPI();\n const info = await tauriAPI.connect(serverUrl);\n setAPIInstance(tauriAPI);\n setConnectionMode('connected');\n setConnectionServerUrl(serverUrl ?? null);\n await preferences.initialize();\n await startTauriEventBridge().catch(() => {\n // Event bridge initialization failed - non-critical, continue without bridge\n });\n return info;\n}\n\nexport const cachedAPI: NoteFlowAPI = {\n async getServerInfo(): Promise<ServerInfo> {\n return offlineServerInfo;\n },\n\n async connect(serverUrl?: string): Promise<ServerInfo> {\n try {\n return await connectWithTauri(serverUrl);\n } catch (error) {\n setConnectionMode('cached', error instanceof Error ? error.message : null);\n throw error;\n }\n },\n\n async disconnect(): Promise<void> {\n setConnectionMode('cached');\n },\n\n async isConnected(): Promise<boolean> {\n return false;\n },\n\n async getCurrentUser(): Promise<GetCurrentUserResponse> {\n return offlineUser;\n },\n\n async listWorkspaces(): Promise<ListWorkspacesResponse> {\n return offlineWorkspaces;\n },\n\n async switchWorkspace(workspaceId: string): Promise<SwitchWorkspaceResponse> {\n const workspace = offlineWorkspaces.workspaces.find((item) => item.id === workspaceId);\n return {\n success: Boolean(workspace),\n workspace,\n };\n },\n\n async createProject(_request: CreateProjectRequest): Promise<Project> {\n return rejectReadOnly();\n },\n\n async getProject(request: GetProjectRequest): Promise<Project> {\n const project = offlineProjects.projects.find((item) => item.id === request.project_id);\n if (!project) {\n throw new Error('Project not available in offline cache.');\n }\n return project;\n },\n\n async getProjectBySlug(request: GetProjectBySlugRequest): Promise<Project> {\n const project = offlineProjects.projects.find(\n (item) => item.workspace_id === request.workspace_id && item.slug === request.slug\n );\n if (!project) {\n throw new Error('Project not available in offline cache.');\n }\n return project;\n },\n\n async listProjects(request: ListProjectsRequest): Promise<ListProjectsResponse> {\n const projects = offlineProjects.projects.filter(\n (item) => item.workspace_id === request.workspace_id\n );\n return {\n projects,\n total_count: projects.length,\n };\n },\n\n async updateProject(_request: UpdateProjectRequest): Promise<Project> {\n return rejectReadOnly();\n },\n\n async archiveProject(_projectId: string): Promise<Project> {\n return rejectReadOnly();\n },\n\n async restoreProject(_projectId: string): Promise<Project> {\n return rejectReadOnly();\n },\n\n async deleteProject(_projectId: string): Promise<boolean> {\n return rejectReadOnly();\n },\n\n async setActiveProject(_request: { workspace_id: string; project_id?: string }): Promise<void> {\n return;\n },\n\n async getActiveProject(request: {\n workspace_id: string;\n }): Promise<{ project_id?: string; project: Project }> {\n const project =\n offlineProjects.projects.find((item) => item.workspace_id === request.workspace_id) ??\n offlineProjects.projects[0];\n if (!project) {\n throw new Error('No project available in offline cache.');\n }\n return { project_id: project.id, project };\n },\n\n async addProjectMember(_request: AddProjectMemberRequest): Promise<ProjectMembership> {\n return rejectReadOnly();\n },\n\n async updateProjectMemberRole(\n _request: UpdateProjectMemberRoleRequest\n ): Promise<ProjectMembership> {\n return rejectReadOnly();\n },\n\n async removeProjectMember(\n _request: RemoveProjectMemberRequest\n ): Promise<RemoveProjectMemberResponse> {\n return rejectReadOnly();\n },\n\n async listProjectMembers(\n _request: ListProjectMembersRequest\n ): Promise<ListProjectMembersResponse> {\n return { members: [], total_count: 0 };\n },\n\n async createMeeting(_request: CreateMeetingRequest): Promise<Meeting> {\n return rejectReadOnly();\n },\n\n async listMeetings(request: ListMeetingsRequest): Promise<ListMeetingsResponse> {\n const meetings = meetingCache.listMeetings();\n let filtered = meetings;\n\n if (request.project_ids && request.project_ids.length > 0) {\n const projectSet = new Set(request.project_ids);\n filtered = filtered.filter(\n (meeting) => meeting.project_id && projectSet.has(meeting.project_id)\n );\n } else if (request.project_id) {\n filtered = filtered.filter((meeting) => meeting.project_id === request.project_id);\n }\n\n if (request.states?.length) {\n filtered = filtered.filter((meeting) => request.states?.includes(meeting.state));\n }\n\n const sortOrder = request.sort_order ?? 'newest';\n filtered = [...filtered].sort((a, b) => {\n const diff = a.created_at - b.created_at;\n return sortOrder === 'oldest' ? diff : -diff;\n });\n\n const offset = request.offset ?? 0;\n const limit = request.limit ?? 50;\n const paged = filtered.slice(offset, offset + limit);\n\n return {\n meetings: paged,\n total_count: filtered.length,\n };\n },\n\n async getMeeting(request: GetMeetingRequest): Promise<Meeting> {\n const cached = meetingCache.getMeeting(request.meeting_id);\n if (!cached) {\n throw new Error('Meeting not available in offline cache.');\n }\n return cached;\n },\n\n async stopMeeting(_meetingId: string): Promise<Meeting> {\n return rejectReadOnly();\n },\n\n async deleteMeeting(_meetingId: string): Promise<boolean> {\n return rejectReadOnly();\n },\n\n async startTranscription(_meetingId: string): Promise<TranscriptionStream> {\n return rejectReadOnly();\n },\n\n async generateSummary(_meetingId: string, _forceRegenerate?: boolean): Promise<Summary> {\n return rejectReadOnly();\n },\n\n async grantCloudConsent(): Promise<void> {\n return rejectReadOnly();\n },\n\n async revokeCloudConsent(): Promise<void> {\n return rejectReadOnly();\n },\n\n async getCloudConsentStatus(): Promise<{ consentGranted: boolean }> {\n return { consentGranted: false };\n },\n\n async listAnnotations(_meetingId: string): Promise<Annotation[]> {\n return [];\n },\n\n async addAnnotation(_request: AddAnnotationRequest): Promise<Annotation> {\n return rejectReadOnly();\n },\n\n async getAnnotation(_annotationId: string): Promise<Annotation> {\n return rejectReadOnly();\n },\n\n async updateAnnotation(_request: UpdateAnnotationRequest): Promise<Annotation> {\n return rejectReadOnly();\n },\n\n async deleteAnnotation(_annotationId: string): Promise<boolean> {\n return rejectReadOnly();\n },\n\n async exportTranscript(_meetingId: string, _format: ExportFormat): Promise<ExportResult> {\n return rejectReadOnly();\n },\n async saveExportFile(\n _content: string,\n _defaultName: string,\n _extension: string\n ): Promise<boolean> {\n return rejectReadOnly();\n },\n async startPlayback(_meetingId: string, _startTime?: number): Promise<void> {\n return rejectReadOnly();\n },\n async pausePlayback(): Promise<void> {\n return rejectReadOnly();\n },\n async stopPlayback(): Promise<void> {\n return rejectReadOnly();\n },\n async seekPlayback(_position: number): Promise<PlaybackInfo> {\n return rejectReadOnly();\n },\n async getPlaybackState(): Promise<PlaybackInfo> {\n return rejectReadOnly();\n },\n async refineSpeakers(_meetingId: string, _numSpeakers?: number): Promise<DiarizationJobStatus> {\n return rejectReadOnly();\n },\n async getDiarizationJobStatus(_jobId: string): Promise<DiarizationJobStatus> {\n return rejectReadOnly();\n },\n async renameSpeaker(\n _meetingId: string,\n _oldSpeakerId: string,\n _newName: string\n ): Promise<boolean> {\n return rejectReadOnly();\n },\n async cancelDiarization(_jobId: string): Promise<CancelDiarizationResult> {\n return rejectReadOnly();\n },\n async getActiveDiarizationJobs(): Promise<DiarizationJobStatus[]> {\n return [];\n },\n async getPreferences(): Promise<UserPreferences> {\n return preferences.get();\n },\n async savePreferences(next: UserPreferences): Promise<void> {\n preferences.replace(next);\n },\n async listAudioDevices(): Promise<AudioDeviceInfo[]> {\n return [];\n },\n async getDefaultAudioDevice(_isInput: boolean): Promise<AudioDeviceInfo | null> {\n return null;\n },\n async selectAudioDevice(_deviceId: string, _isInput: boolean): Promise<void> {\n return rejectReadOnly();\n },\n async listInstalledApps(_options?: { commonOnly?: boolean }): Promise<InstalledAppInfo[]> {\n return [];\n },\n async setTriggerEnabled(_enabled: boolean): Promise<void> {\n return rejectReadOnly();\n },\n async snoozeTriggers(_minutes?: number): Promise<void> {\n return rejectReadOnly();\n },\n async resetSnooze(): Promise<void> {\n return rejectReadOnly();\n },\n\n async getTriggerStatus(): Promise<TriggerStatus> {\n return { enabled: false, is_snoozed: false };\n },\n async dismissTrigger(): Promise<void> {\n return rejectReadOnly();\n },\n async acceptTrigger(_title?: string): Promise<Meeting> {\n return rejectReadOnly();\n },\n async extractEntities(\n _meetingId: string,\n _forceRefresh?: boolean\n ): Promise<ExtractEntitiesResponse> {\n return { entities: [], total_count: 0, cached: true };\n },\n async updateEntity(\n _meetingId: string,\n _entityId: string,\n _text?: string,\n _category?: string\n ): Promise<ExtractedEntity> {\n return rejectReadOnly();\n },\n async deleteEntity(_meetingId: string, _entityId: string): Promise<boolean> {\n return rejectReadOnly();\n },\n async listCalendarEvents(\n _hoursAhead?: number,\n _limit?: number,\n _provider?: string\n ): Promise<ListCalendarEventsResponse> {\n return { events: [] };\n },\n async getCalendarProviders(): Promise<GetCalendarProvidersResponse> {\n return { providers: [] };\n },\n async initiateCalendarAuth(\n _provider: string,\n _redirectUri?: string\n ): Promise<InitiateCalendarAuthResponse> {\n return rejectReadOnly();\n },\n async completeCalendarAuth(\n _provider: string,\n _code: string,\n _state: string\n ): Promise<CompleteCalendarAuthResponse> {\n return rejectReadOnly();\n },\n async getOAuthConnectionStatus(_provider: string): Promise<GetOAuthConnectionStatusResponse> {\n return {\n connection: {\n provider: _provider,\n status: 'disconnected',\n email: '',\n expires_at: 0,\n error_message: 'Offline',\n integration_type: 'calendar',\n },\n };\n },\n async disconnectCalendar(_provider: string): Promise<DisconnectOAuthResponse> {\n return rejectReadOnly();\n },\n\n async registerWebhook(_request: RegisterWebhookRequest): Promise<RegisteredWebhook> {\n return rejectReadOnly();\n },\n async listWebhooks(_enabledOnly?: boolean): Promise<ListWebhooksResponse> {\n return { webhooks: [], total_count: 0 };\n },\n async updateWebhook(_request: UpdateWebhookRequest): Promise<RegisteredWebhook> {\n return rejectReadOnly();\n },\n async deleteWebhook(_webhookId: string): Promise<DeleteWebhookResponse> {\n return rejectReadOnly();\n },\n async getWebhookDeliveries(\n _webhookId: string,\n _limit?: number\n ): Promise<GetWebhookDeliveriesResponse> {\n return { deliveries: [], total_count: 0 };\n },\n async startIntegrationSync(_integrationId: string): Promise<StartIntegrationSyncResponse> {\n return rejectReadOnly();\n },\n async getSyncStatus(_syncRunId: string): Promise<GetSyncStatusResponse> {\n return rejectReadOnly();\n },\n async listSyncHistory(\n _integrationId: string,\n _limit?: number,\n _offset?: number\n ): Promise<ListSyncHistoryResponse> {\n return { runs: [], total_count: 0 };\n },\n ...cachedObservabilityAPI,\n};\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/cached/observability.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/connection-state.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/connection-state.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/constants.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/helpers.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/helpers.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/index.test.ts","messages":[{"ruleId":"@typescript-eslint/no-unsafe-return","severity":1,"message":"Unsafe return of a value of type `any`.","line":20,"column":47,"nodeType":"CallExpression","messageId":"unsafeReturn","endLine":20,"endColumn":74}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nconst setConnectionMode = vi.fn();\nconst setConnectionServerUrl = vi.fn();\nconst setAPIInstance = vi.fn();\nconst startReconnection = vi.fn();\nconst startTauriEventBridge = vi.fn().mockResolvedValue(undefined);\nconst preferences = {\n initialize: vi.fn().mockResolvedValue(undefined),\n getServerUrl: vi.fn(() => ''),\n};\nconst getConnectionState = vi.fn(() => ({ mode: 'cached' }));\n\nconst mockAPI = { kind: 'mock' };\nconst cachedAPI = { kind: 'cached' };\n\nlet initializeTauriAPI = vi.fn();\n\nvi.mock('./tauri-adapter', () => ({\n initializeTauriAPI: (...args: unknown[]) => initializeTauriAPI(...args),\n createTauriAPI: vi.fn(),\n isTauriEnvironment: vi.fn(),\n}));\n\nvi.mock('./mock-adapter', () => ({ mockAPI }));\nvi.mock('./cached-adapter', () => ({ cachedAPI }));\nvi.mock('./reconnection', () => ({ startReconnection }));\nvi.mock('./connection-state', () => ({\n setConnectionMode,\n setConnectionServerUrl,\n getConnectionState,\n}));\nvi.mock('./interface', () => ({ setAPIInstance }));\nvi.mock('@/lib/preferences', () => ({ preferences }));\nvi.mock('@/lib/tauri-events', () => ({ startTauriEventBridge }));\n\nasync function loadIndexModule(withWindow: boolean) {\n vi.resetModules();\n if (withWindow) {\n const mockWindow: unknown = {};\n vi.stubGlobal('window', mockWindow as Window);\n } else {\n vi.stubGlobal('window', undefined as unknown as Window);\n }\n return await import('./index');\n}\n\ndescribe('api/index initializeAPI', () => {\n beforeEach(() => {\n initializeTauriAPI = vi.fn();\n setConnectionMode.mockClear();\n setConnectionServerUrl.mockClear();\n setAPIInstance.mockClear();\n startReconnection.mockClear();\n startTauriEventBridge.mockClear();\n preferences.initialize.mockClear();\n preferences.getServerUrl.mockClear();\n preferences.getServerUrl.mockReturnValue('');\n });\n\n afterEach(() => {\n vi.unstubAllGlobals();\n });\n\n it('returns mock API when tauri is unavailable', async () => {\n initializeTauriAPI.mockRejectedValueOnce(new Error('no tauri'));\n const { initializeAPI } = await loadIndexModule(false);\n\n const api = await initializeAPI();\n\n expect(api).toBe(mockAPI);\n expect(setConnectionMode).toHaveBeenCalledWith('mock');\n expect(setAPIInstance).toHaveBeenCalledWith(mockAPI);\n });\n\n it('connects via tauri when available', async () => {\n const tauriAPI = { connect: vi.fn().mockResolvedValue({ version: '1.0.0' }) };\n initializeTauriAPI.mockResolvedValueOnce(tauriAPI);\n preferences.getServerUrl.mockReturnValue('http://example.com:50051');\n\n const { initializeAPI } = await loadIndexModule(false);\n const api = await initializeAPI();\n\n expect(api).toBe(tauriAPI);\n expect(tauriAPI.connect).toHaveBeenCalledWith('http://example.com:50051');\n expect(setConnectionMode).toHaveBeenCalledWith('connected');\n expect(preferences.initialize).toHaveBeenCalled();\n expect(startTauriEventBridge).toHaveBeenCalled();\n expect(startReconnection).toHaveBeenCalled();\n });\n\n it('falls back to cached mode when connect fails', async () => {\n const tauriAPI = { connect: vi.fn().mockRejectedValue(new Error('fail')) };\n initializeTauriAPI.mockResolvedValueOnce(tauriAPI);\n\n const { initializeAPI } = await loadIndexModule(false);\n const api = await initializeAPI();\n\n expect(api).toBe(tauriAPI);\n expect(setConnectionMode).toHaveBeenCalledWith('cached', 'fail');\n expect(preferences.initialize).toHaveBeenCalled();\n expect(startReconnection).toHaveBeenCalled();\n });\n\n it('uses a default message when connect fails with non-Error values', async () => {\n const tauriAPI = { connect: vi.fn().mockRejectedValue('boom') };\n initializeTauriAPI.mockResolvedValueOnce(tauriAPI);\n\n const { initializeAPI } = await loadIndexModule(false);\n const api = await initializeAPI();\n\n expect(api).toBe(tauriAPI);\n expect(setConnectionMode).toHaveBeenCalledWith('cached', 'Connection failed');\n });\n\n it('auto-initializes when window is present', async () => {\n initializeTauriAPI.mockRejectedValueOnce(new Error('no tauri'));\n\n const module = await loadIndexModule(true);\n\n await Promise.resolve();\n await Promise.resolve();\n\n expect(setConnectionMode).toHaveBeenCalledWith('cached');\n expect(setAPIInstance).toHaveBeenCalledWith(cachedAPI);\n expect(setConnectionMode).toHaveBeenCalledWith('mock');\n\n const windowApi = (globalThis.window as Window & Record<string, unknown>).__NOTEFLOW_API__;\n expect(windowApi).toBe(mockAPI);\n const connection = (globalThis.window as Window & Record<string, unknown>)\n .__NOTEFLOW_CONNECTION__;\n expect(connection).toBeDefined();\n expect(module).toBeDefined();\n });\n});\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/interface.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/mock-adapter.test.ts","messages":[{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":1,"message":"Unsafe assignment of an error typed value.","line":45,"column":11,"nodeType":"VariableDeclarator","messageId":"anyAssignment","endLine":45,"endColumn":64}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport type { FinalSegment } from './types';\n\nasync function loadMockAPI() {\n vi.resetModules();\n const module = await import('./mock-adapter');\n return module.mockAPI;\n}\n\nasync function flushTimers() {\n await vi.runAllTimersAsync();\n}\n\ndescribe('mockAPI', () => {\n beforeEach(() => {\n vi.useFakeTimers();\n vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));\n localStorage.clear();\n });\n\n afterEach(() => {\n vi.runOnlyPendingTimers();\n vi.useRealTimers();\n vi.clearAllMocks();\n });\n\n it('creates, lists, starts, stops, and deletes meetings', async () => {\n const mockAPI = await loadMockAPI();\n\n const createPromise = mockAPI.createMeeting({ title: 'Team Sync', metadata: { team: 'A' } });\n await flushTimers();\n const meeting = await createPromise;\n expect(meeting.title).toBe('Team Sync');\n\n const listPromise = mockAPI.listMeetings({\n states: ['created'],\n sort_order: 'newest',\n limit: 5,\n offset: 0,\n });\n await flushTimers();\n const list = await listPromise;\n expect(list.meetings.some((m) => m.id === meeting.id)).toBe(true);\n\n const stream = await mockAPI.startTranscription(meeting.id);\n expect(stream).toBeDefined();\n\n const getPromise = mockAPI.getMeeting({\n meeting_id: meeting.id,\n include_segments: false,\n include_summary: false,\n });\n await flushTimers();\n const fetched = await getPromise;\n expect(fetched.state).toBe('recording');\n\n const stopPromise = mockAPI.stopMeeting(meeting.id);\n await flushTimers();\n const stopped = await stopPromise;\n expect(stopped.state).toBe('stopped');\n\n const deletePromise = mockAPI.deleteMeeting(meeting.id);\n await flushTimers();\n const deleted = await deletePromise;\n expect(deleted).toBe(true);\n\n const missingPromise = mockAPI.getMeeting({\n meeting_id: meeting.id,\n include_segments: false,\n include_summary: false,\n });\n const missingExpectation = expect(missingPromise).rejects.toThrow('Meeting not found');\n await flushTimers();\n await missingExpectation;\n });\n\n it('manages annotations, summaries, and exports', async () => {\n const mockAPI = await loadMockAPI();\n\n const createPromise = mockAPI.createMeeting({ title: 'Annotations' });\n await flushTimers();\n const meeting = await createPromise;\n\n const addPromise = mockAPI.addAnnotation({\n meeting_id: meeting.id,\n annotation_type: 'note',\n text: 'Important',\n start_time: 1,\n end_time: 2,\n segment_ids: [1],\n });\n await flushTimers();\n const annotation = await addPromise;\n\n const listPromise = mockAPI.listAnnotations(meeting.id, 0.5, 2.5);\n await flushTimers();\n const list = await listPromise;\n expect(list).toHaveLength(1);\n\n const getPromise = mockAPI.getAnnotation(annotation.id);\n await flushTimers();\n const fetched = await getPromise;\n expect(fetched.text).toBe('Important');\n\n const updatePromise = mockAPI.updateAnnotation({\n annotation_id: annotation.id,\n text: 'Updated',\n annotation_type: 'decision',\n });\n await flushTimers();\n const updated = await updatePromise;\n expect(updated.text).toBe('Updated');\n expect(updated.annotation_type).toBe('decision');\n\n const deletePromise = mockAPI.deleteAnnotation(annotation.id);\n await flushTimers();\n const deleted = await deletePromise;\n expect(deleted).toBe(true);\n\n const missingPromise = mockAPI.getAnnotation('missing');\n const missingExpectation = expect(missingPromise).rejects.toThrow('Annotation not found');\n await flushTimers();\n await missingExpectation;\n\n const summaryPromise = mockAPI.generateSummary(meeting.id);\n await flushTimers();\n const summary = await summaryPromise;\n expect(summary.meeting_id).toBe(meeting.id);\n\n const exportMdPromise = mockAPI.exportTranscript(meeting.id, 'markdown');\n await flushTimers();\n const exportMd = await exportMdPromise;\n expect(exportMd.content).toContain('Summary');\n expect(exportMd.file_extension).toBe('.md');\n\n const exportHtmlPromise = mockAPI.exportTranscript(meeting.id, 'html');\n await flushTimers();\n const exportHtml = await exportHtmlPromise;\n expect(exportHtml.file_extension).toBe('.html');\n expect(exportHtml.content).toContain('<html>');\n });\n\n it('handles playback, consent, diarization, and speaker renames', async () => {\n const mockAPI = await loadMockAPI();\n\n const createPromise = mockAPI.createMeeting({ title: 'Playback' });\n await flushTimers();\n const meeting = await createPromise;\n\n const meetingPromise = mockAPI.getMeeting({\n meeting_id: meeting.id,\n include_segments: false,\n include_summary: false,\n });\n await flushTimers();\n const stored = await meetingPromise;\n\n const segment: FinalSegment = {\n segment_id: 1,\n text: 'Hello world',\n start_time: 0,\n end_time: 1,\n words: [],\n language: 'en',\n language_confidence: 0.99,\n avg_logprob: -0.2,\n no_speech_prob: 0.01,\n speaker_id: 'SPEAKER_00',\n speaker_confidence: 0.9,\n };\n stored.segments.push(segment);\n\n const renamePromise = mockAPI.renameSpeaker(meeting.id, 'SPEAKER_00', 'Alex');\n await flushTimers();\n const renamed = await renamePromise;\n expect(renamed).toBe(true);\n\n await mockAPI.startPlayback(meeting.id, 5);\n await mockAPI.pausePlayback();\n const seeked = await mockAPI.seekPlayback(10);\n expect(seeked.position).toBe(10);\n const playback = await mockAPI.getPlaybackState();\n expect(playback.is_paused).toBe(true);\n await mockAPI.stopPlayback();\n const stopped = await mockAPI.getPlaybackState();\n expect(stopped.meeting_id).toBeUndefined();\n\n const grantPromise = mockAPI.grantCloudConsent();\n await flushTimers();\n await grantPromise;\n const statusPromise = mockAPI.getCloudConsentStatus();\n await flushTimers();\n const status = await statusPromise;\n expect(status.consentGranted).toBe(true);\n\n const revokePromise = mockAPI.revokeCloudConsent();\n await flushTimers();\n await revokePromise;\n const statusAfterPromise = mockAPI.getCloudConsentStatus();\n await flushTimers();\n const statusAfter = await statusAfterPromise;\n expect(statusAfter.consentGranted).toBe(false);\n\n const diarizationPromise = mockAPI.refineSpeakers(meeting.id, 2);\n await flushTimers();\n const diarization = await diarizationPromise;\n expect(diarization.status).toBe('queued');\n\n const jobPromise = mockAPI.getDiarizationJobStatus(diarization.job_id);\n await flushTimers();\n const job = await jobPromise;\n expect(job.status).toBe('completed');\n\n const cancelPromise = mockAPI.cancelDiarization(diarization.job_id);\n await flushTimers();\n const cancel = await cancelPromise;\n expect(cancel.success).toBe(true);\n });\n\n it('returns current user and manages workspace switching', async () => {\n const mockAPI = await loadMockAPI();\n\n const userPromise = mockAPI.getCurrentUser();\n await flushTimers();\n const user = await userPromise;\n expect(user.display_name).toBe('Local User');\n\n const workspacesPromise = mockAPI.listWorkspaces();\n await flushTimers();\n const workspaces = await workspacesPromise;\n expect(workspaces.workspaces.length).toBeGreaterThan(0);\n\n const targetWorkspace = workspaces.workspaces[0];\n const switchPromise = mockAPI.switchWorkspace(targetWorkspace.id);\n await flushTimers();\n const switched = await switchPromise;\n expect(switched.success).toBe(true);\n expect(switched.workspace?.id).toBe(targetWorkspace.id);\n\n const missingPromise = mockAPI.switchWorkspace('missing-workspace');\n await flushTimers();\n const missing = await missingPromise;\n expect(missing.success).toBe(false);\n });\n\n it('handles webhooks, entities, sync, logs, metrics, and calendar flows', async () => {\n const mockAPI = await loadMockAPI();\n\n const registerPromise = mockAPI.registerWebhook({\n workspace_id: 'w1',\n name: 'Webhook',\n url: 'https://example.com',\n events: ['meeting.completed'],\n });\n await flushTimers();\n const webhook = await registerPromise;\n\n const listPromise = mockAPI.listWebhooks();\n await flushTimers();\n const list = await listPromise;\n expect(list.total_count).toBe(1);\n\n const updatePromise = mockAPI.updateWebhook({\n webhook_id: webhook.id,\n enabled: false,\n timeout_ms: 5000,\n });\n await flushTimers();\n const updated = await updatePromise;\n expect(updated.enabled).toBe(false);\n\n const updateRetriesPromise = mockAPI.updateWebhook({\n webhook_id: webhook.id,\n max_retries: 5,\n });\n await flushTimers();\n const updatedRetries = await updateRetriesPromise;\n expect(updatedRetries.max_retries).toBe(5);\n\n const enabledOnlyPromise = mockAPI.listWebhooks(true);\n await flushTimers();\n const enabledOnly = await enabledOnlyPromise;\n expect(enabledOnly.total_count).toBe(0);\n\n const deliveriesPromise = mockAPI.getWebhookDeliveries(webhook.id, 5);\n await flushTimers();\n const deliveries = await deliveriesPromise;\n expect(deliveries.total_count).toBe(0);\n\n const deletePromise = mockAPI.deleteWebhook(webhook.id);\n await flushTimers();\n const deleted = await deletePromise;\n expect(deleted.success).toBe(true);\n\n const updateMissingPromise = mockAPI.updateWebhook({\n webhook_id: 'missing',\n name: 'Missing',\n });\n const updateExpectation = expect(updateMissingPromise).rejects.toThrow(\n 'Webhook missing not found'\n );\n await flushTimers();\n await updateExpectation;\n\n const entitiesPromise = mockAPI.extractEntities('meeting');\n await flushTimers();\n const entities = await entitiesPromise;\n expect(entities.cached).toBe(false);\n\n const updateEntityPromise = mockAPI.updateEntity('meeting', 'e1', 'Entity', 'topic');\n await flushTimers();\n const updatedEntity = await updateEntityPromise;\n expect(updatedEntity.text).toBe('Entity');\n\n const updateEntityDefaultPromise = mockAPI.updateEntity('meeting', 'e2');\n await flushTimers();\n const updatedEntityDefault = await updateEntityDefaultPromise;\n expect(updatedEntityDefault.text).toBe('Mock Entity');\n\n const deleteEntityPromise = mockAPI.deleteEntity('meeting', 'e1');\n await flushTimers();\n const deletedEntity = await deleteEntityPromise;\n expect(deletedEntity).toBe(true);\n\n const syncPromise = mockAPI.startIntegrationSync('int-1');\n await flushTimers();\n const sync = await syncPromise;\n expect(sync.status).toBe('running');\n\n const statusPromise = mockAPI.getSyncStatus(sync.sync_run_id);\n await flushTimers();\n const status = await statusPromise;\n expect(status.status).toBe('success');\n\n const historyPromise = mockAPI.listSyncHistory('int-1', 3, 0);\n await flushTimers();\n const history = await historyPromise;\n expect(history.runs.length).toBeGreaterThan(0);\n\n const logsPromise = mockAPI.getRecentLogs({ limit: 5, level: 'error', source: 'api' });\n await flushTimers();\n const logs = await logsPromise;\n expect(logs.logs.length).toBeGreaterThan(0);\n\n const metricsPromise = mockAPI.getPerformanceMetrics({ history_limit: 5 });\n await flushTimers();\n const metrics = await metricsPromise;\n expect(metrics.history).toHaveLength(5);\n\n const triggerEnablePromise = mockAPI.setTriggerEnabled(true);\n await flushTimers();\n await triggerEnablePromise;\n const snoozePromise = mockAPI.snoozeTriggers(5);\n await flushTimers();\n await snoozePromise;\n const resetPromise = mockAPI.resetSnooze();\n await flushTimers();\n await resetPromise;\n const dismissPromise = mockAPI.dismissTrigger();\n await flushTimers();\n await dismissPromise;\n const triggerMeetingPromise = mockAPI.acceptTrigger('Trigger Meeting');\n await flushTimers();\n const triggerMeeting = await triggerMeetingPromise;\n expect(triggerMeeting.title).toContain('Trigger Meeting');\n\n const providersPromise = mockAPI.getCalendarProviders();\n await flushTimers();\n const providers = await providersPromise;\n expect(providers.providers.length).toBe(2);\n\n const authPromise = mockAPI.initiateCalendarAuth('google', 'https://redirect');\n await flushTimers();\n const auth = await authPromise;\n expect(auth.auth_url).toContain('http');\n\n const completePromise = mockAPI.completeCalendarAuth('google', 'code', auth.state);\n await flushTimers();\n const complete = await completePromise;\n expect(complete.success).toBe(true);\n\n const statusAuthPromise = mockAPI.getOAuthConnectionStatus('google');\n await flushTimers();\n const statusAuth = await statusAuthPromise;\n expect(statusAuth.connection.status).toBe('disconnected');\n\n const disconnectPromise = mockAPI.disconnectCalendar('google');\n await flushTimers();\n const disconnect = await disconnectPromise;\n expect(disconnect.success).toBe(true);\n\n const eventsPromise = mockAPI.listCalendarEvents(1, 5, 'google');\n await flushTimers();\n const events = await eventsPromise;\n expect(events.total_count).toBe(0);\n });\n\n it('covers additional mock adapter branches', async () => {\n const mockAPI = await loadMockAPI();\n\n const serverInfoPromise = mockAPI.getServerInfo();\n await flushTimers();\n await serverInfoPromise;\n await mockAPI.isConnected();\n\n const createPromise = mockAPI.createMeeting({ title: 'Branch Coverage' });\n await flushTimers();\n const meeting = await createPromise;\n\n const exportNoSummaryPromise = mockAPI.exportTranscript(meeting.id, 'markdown');\n await flushTimers();\n const exportNoSummary = await exportNoSummaryPromise;\n expect(exportNoSummary.content).not.toContain('Summary');\n\n meeting.segments.push({\n segment_id: 99,\n text: 'Segment text',\n start_time: 0,\n end_time: 1,\n words: [],\n language: 'en',\n language_confidence: 0.9,\n avg_logprob: -0.1,\n no_speech_prob: 0.01,\n speaker_id: 'SPEAKER_00',\n speaker_confidence: 0.8,\n });\n\n const exportHtmlPromise = mockAPI.exportTranscript(meeting.id, 'html');\n await flushTimers();\n await exportHtmlPromise;\n\n const listDefaultPromise = mockAPI.listMeetings({});\n await flushTimers();\n const listDefault = await listDefaultPromise;\n expect(listDefault.meetings.length).toBeGreaterThan(0);\n\n const listOldestPromise = mockAPI.listMeetings({\n sort_order: 'oldest',\n offset: 1,\n limit: 1,\n });\n await flushTimers();\n await listOldestPromise;\n\n const annotationPromise = mockAPI.addAnnotation({\n meeting_id: meeting.id,\n annotation_type: 'note',\n text: 'Branch',\n start_time: 1,\n end_time: 2,\n });\n await flushTimers();\n const annotation = await annotationPromise;\n\n const listNoFilterPromise = mockAPI.listAnnotations(meeting.id);\n await flushTimers();\n const listNoFilter = await listNoFilterPromise;\n expect(listNoFilter.length).toBeGreaterThan(0);\n\n const updatePromise = mockAPI.updateAnnotation({\n annotation_id: annotation.id,\n start_time: 0.5,\n end_time: 3.5,\n segment_ids: [1, 2, 3],\n });\n await flushTimers();\n const updated = await updatePromise;\n expect(updated.segment_ids).toEqual([1, 2, 3]);\n\n const missingDeletePromise = mockAPI.deleteAnnotation('missing');\n await flushTimers();\n const missingDelete = await missingDeletePromise;\n expect(missingDelete).toBe(false);\n\n const renamedMissingPromise = mockAPI.renameSpeaker(meeting.id, 'SPEAKER_99', 'Sam');\n await flushTimers();\n const renamedMissing = await renamedMissingPromise;\n expect(renamedMissing).toBe(false);\n\n await mockAPI.selectAudioDevice('input-1', true);\n await mockAPI.selectAudioDevice('output-1', false);\n await mockAPI.listAudioDevices();\n await mockAPI.getDefaultAudioDevice(true);\n\n await mockAPI.startPlayback(meeting.id);\n const playback = await mockAPI.getPlaybackState();\n expect(playback.position).toBe(0);\n\n await mockAPI.getTriggerStatus();\n\n const deleteMissingWebhookPromise = mockAPI.deleteWebhook('missing');\n await flushTimers();\n const deletedMissing = await deleteMissingWebhookPromise;\n expect(deletedMissing.success).toBe(false);\n\n const webhooksPromise = mockAPI.listWebhooks(false);\n await flushTimers();\n await webhooksPromise;\n\n const deliveriesPromise = mockAPI.getWebhookDeliveries('missing');\n await flushTimers();\n await deliveriesPromise;\n\n const connectPromise = mockAPI.connect('http://localhost');\n await flushTimers();\n await connectPromise;\n const prefsPromise = mockAPI.getPreferences();\n await flushTimers();\n const prefs = await prefsPromise;\n await mockAPI.savePreferences({ ...prefs, simulate_transcription: true });\n await mockAPI.saveExportFile('content', 'Meeting Notes', 'md');\n\n const disconnectPromise = mockAPI.disconnect();\n await flushTimers();\n await disconnectPromise;\n\n const historyDefaultPromise = mockAPI.listSyncHistory('int-1');\n await flushTimers();\n await historyDefaultPromise;\n\n const logsDefaultPromise = mockAPI.getRecentLogs();\n await flushTimers();\n await logsDefaultPromise;\n\n const metricsDefaultPromise = mockAPI.getPerformanceMetrics();\n await flushTimers();\n await metricsDefaultPromise;\n });\n});\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/mock-adapter.ts","messages":[{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":1,"message":"Unsafe member access .length on an `error` typed value.","line":604,"column":52,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":604,"endColumn":58},{"ruleId":"@typescript-eslint/no-unsafe-argument","severity":1,"message":"Unsafe argument of type error typed assigned to a parameter of type `Iterable<unknown> | null | undefined`.","line":605,"column":34,"nodeType":"MemberExpression","messageId":"unsafeArgument","endLine":605,"endColumn":53}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// Mock API Implementation for Browser Development\n\nimport { formatTime } from '@/lib/format';\nimport { SERVER_DEFAULTS } from '@/lib/config';\nimport { preferences } from '@/lib/preferences';\nimport { IdentityDefaults, OidcDocsUrls, Placeholders, Timing } from './constants';\nimport type { NoteFlowAPI } from './interface';\nimport {\n generateAnnotations,\n generateId,\n generateMeeting,\n generateMeetings,\n generateSummary,\n mockServerInfo,\n} from './mock-data';\nimport { MockTranscriptionStream } from './mock-transcription-stream';\nimport type {\n AddAnnotationRequest,\n AddProjectMemberRequest,\n Annotation,\n AudioDeviceInfo,\n CancelDiarizationResult,\n CompleteAuthLoginResponse,\n CompleteCalendarAuthResponse,\n ConnectionDiagnostics,\n CreateMeetingRequest,\n CreateProjectRequest,\n DeleteOidcProviderResponse,\n DeleteWebhookResponse,\n DiarizationJobStatus,\n DisconnectOAuthResponse,\n EffectiveServerUrl,\n ExportFormat,\n ExportResult,\n ExtractEntitiesResponse,\n ExtractedEntity,\n GetCalendarProvidersResponse,\n GetCurrentUserResponse,\n GetMeetingRequest,\n GetOAuthConnectionStatusResponse,\n GetProjectBySlugRequest,\n GetProjectRequest,\n GetPerformanceMetricsRequest,\n GetPerformanceMetricsResponse,\n GetRecentLogsRequest,\n GetRecentLogsResponse,\n GetSyncStatusResponse,\n GetUserIntegrationsResponse,\n GetWebhookDeliveriesResponse,\n InstalledAppInfo,\n InitiateAuthLoginResponse,\n InitiateCalendarAuthResponse,\n ListOidcPresetsResponse,\n ListOidcProvidersResponse,\n ListWorkspacesResponse,\n LogoutResponse,\n ListCalendarEventsResponse,\n ListMeetingsRequest,\n ListMeetingsResponse,\n ListProjectMembersRequest,\n ListProjectMembersResponse,\n ListProjectsRequest,\n ListProjectsResponse,\n ListSyncHistoryResponse,\n ListWebhooksResponse,\n LogEntry,\n LogLevel,\n LogSource,\n Meeting,\n OidcProviderApi,\n PerformanceMetricsPoint,\n PlaybackInfo,\n Project,\n ProjectMembership,\n RefreshOidcDiscoveryResponse,\n RegisteredWebhook,\n RegisterOidcProviderRequest,\n RegisterWebhookRequest,\n RemoveProjectMemberRequest,\n RemoveProjectMemberResponse,\n ServerInfo,\n StartIntegrationSyncResponse,\n SwitchWorkspaceResponse,\n Summary,\n SyncRunProto,\n TriggerStatus,\n UpdateAnnotationRequest,\n UpdateOidcProviderRequest,\n UpdateProjectMemberRoleRequest,\n UpdateProjectRequest,\n UpdateWebhookRequest,\n UserPreferences,\n WebhookDelivery,\n} from './types';\n\n// In-memory store\nconst meetings: Map<string, Meeting> = new Map();\nconst annotations: Map<string, Annotation[]> = new Map();\nconst webhooks: Map<string, RegisteredWebhook> = new Map();\nconst webhookDeliveries: Map<string, WebhookDelivery[]> = new Map();\nconst projects: Map<string, Project> = new Map();\nconst projectMemberships: Map<string, ProjectMembership[]> = new Map();\nconst activeProjectsByWorkspace: Map<string, string | null> = new Map();\nconst oidcProviders: Map<string, OidcProviderApi> = new Map();\nlet isInitialized = false;\nlet cloudConsentGranted = false;\nconst MEMORY_VARIANCE_MB = 2 * 1000;\nconst mockPlayback: PlaybackInfo = {\n meeting_id: undefined,\n position: 0,\n duration: 0,\n is_playing: false,\n is_paused: false,\n highlighted_segment: undefined,\n};\nconst mockUser: GetCurrentUserResponse = {\n user_id: IdentityDefaults.DEFAULT_USER_ID,\n workspace_id: IdentityDefaults.DEFAULT_WORKSPACE_ID,\n display_name: IdentityDefaults.DEFAULT_USER_NAME,\n email: 'local@noteflow.dev',\n is_authenticated: false,\n workspace_name: 'Personal',\n role: 'owner',\n};\nconst mockWorkspaces: ListWorkspacesResponse = {\n workspaces: [\n {\n id: IdentityDefaults.DEFAULT_WORKSPACE_ID,\n name: IdentityDefaults.DEFAULT_WORKSPACE_NAME,\n role: 'owner',\n is_default: true,\n },\n {\n id: '11111111-1111-1111-1111-111111111111',\n name: 'Team Space',\n role: 'member',\n },\n ],\n};\n\nfunction initializeStore() {\n if (isInitialized) {\n return;\n }\n\n const initialMeetings = generateMeetings(8);\n initialMeetings.forEach((meeting) => {\n meetings.set(meeting.id, meeting);\n annotations.set(meeting.id, generateAnnotations(meeting.id, 3));\n });\n\n const now = Math.floor(Date.now() / 1000);\n const defaultProjectName = IdentityDefaults.DEFAULT_PROJECT_NAME ?? 'General';\n\n mockWorkspaces.workspaces.forEach((workspace, index) => {\n const defaultProjectId =\n workspace.id === IdentityDefaults.DEFAULT_WORKSPACE_ID && IdentityDefaults.DEFAULT_PROJECT_ID\n ? IdentityDefaults.DEFAULT_PROJECT_ID\n : generateId();\n\n const defaultProject: Project = {\n id: defaultProjectId,\n workspace_id: workspace.id,\n name: defaultProjectName,\n slug: 'general',\n description: 'Default project for this workspace.',\n is_default: true,\n is_archived: false,\n settings: {},\n created_at: now,\n updated_at: now,\n };\n\n projects.set(defaultProject.id, defaultProject);\n projectMemberships.set(defaultProject.id, [\n {\n project_id: defaultProject.id,\n user_id: mockUser.user_id,\n role: 'admin',\n joined_at: now,\n },\n ]);\n activeProjectsByWorkspace.set(workspace.id, defaultProject.id);\n\n if (index === 0) {\n const sampleProjects = [\n {\n name: 'Growth Experiments',\n slug: 'growth-experiments',\n description: 'Conversion funnels and onboarding.',\n },\n {\n name: 'Platform Reliability',\n slug: 'platform-reliability',\n description: 'Infra upgrades and incident reviews.',\n },\n ];\n sampleProjects.forEach((sample, sampleIndex) => {\n const projectId = generateId();\n const project: Project = {\n id: projectId,\n workspace_id: workspace.id,\n name: sample.name,\n slug: sample.slug,\n description: sample.description,\n is_default: false,\n is_archived: false,\n settings: {},\n created_at: now - (sampleIndex + 1) * Timing.ONE_DAY_SECONDS,\n updated_at: now - (sampleIndex + 1) * Timing.ONE_DAY_SECONDS,\n };\n projects.set(projectId, project);\n projectMemberships.set(projectId, [\n {\n project_id: projectId,\n user_id: mockUser.user_id,\n role: 'editor',\n joined_at: now - 3600,\n },\n ]);\n });\n }\n });\n\n const primaryWorkspaceId =\n mockWorkspaces.workspaces[0]?.id ?? IdentityDefaults.DEFAULT_WORKSPACE_ID;\n const primaryProjectId =\n activeProjectsByWorkspace.get(primaryWorkspaceId) ?? IdentityDefaults.DEFAULT_PROJECT_ID;\n meetings.forEach((meeting) => {\n if (!meeting.project_id && primaryProjectId) {\n meeting.project_id = primaryProjectId;\n }\n });\n\n isInitialized = true;\n}\n\n// Delay helper for realistic API simulation\nconst delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n\nconst slugify = (value: string): string =>\n value\n .toLowerCase()\n .trim()\n .replace(/[_\\s]+/g, '-')\n .replace(/[^a-z0-9-]/g, '')\n .replace(/-+/g, '-')\n .replace(/^-|-$/g, '');\n\n// Helper to get meeting with initialization and error handling\nconst getMeetingOrThrow = (meetingId: string): Meeting => {\n initializeStore();\n const meeting = meetings.get(meetingId);\n if (!meeting) {\n throw new Error(`Meeting not found: ${meetingId}`);\n }\n return meeting;\n};\n\n// Helper to find annotation across all meetings\nconst findAnnotation = (\n annotationId: string\n): { annotation: Annotation; list: Annotation[]; index: number } | null => {\n for (const meetingAnnotations of annotations.values()) {\n const index = meetingAnnotations.findIndex((a) => a.id === annotationId);\n if (index !== -1) {\n return { annotation: meetingAnnotations[index], list: meetingAnnotations, index };\n }\n }\n return null;\n};\n\nexport const mockAPI: NoteFlowAPI = {\n async getServerInfo(): Promise<ServerInfo> {\n await delay(100);\n return { ...mockServerInfo };\n },\n\n async isConnected(): Promise<boolean> {\n return true;\n },\n\n async getEffectiveServerUrl(): Promise<EffectiveServerUrl> {\n const prefs = preferences.get();\n return {\n url: `${prefs.server_host}:${prefs.server_port}`,\n source: 'default',\n };\n },\n\n async getCurrentUser(): Promise<GetCurrentUserResponse> {\n await delay(50);\n return { ...mockUser };\n },\n\n async listWorkspaces(): Promise<ListWorkspacesResponse> {\n await delay(50);\n return {\n workspaces: mockWorkspaces.workspaces.map((workspace) => ({ ...workspace })),\n };\n },\n\n async switchWorkspace(workspaceId: string): Promise<SwitchWorkspaceResponse> {\n await delay(50);\n const workspace = mockWorkspaces.workspaces.find((item) => item.id === workspaceId);\n if (!workspace) {\n return { success: false };\n }\n return { success: true, workspace: { ...workspace } };\n },\n\n async initiateAuthLogin(\n _provider: string,\n _redirectUri?: string\n ): Promise<InitiateAuthLoginResponse> {\n await delay(100);\n return {\n auth_url: Placeholders.MOCK_OAUTH_URL,\n state: `mock_state_${Date.now()}`,\n };\n },\n\n async completeAuthLogin(\n provider: string,\n _code: string,\n _state: string\n ): Promise<CompleteAuthLoginResponse> {\n await delay(200);\n return {\n success: true,\n user_id: mockUser.user_id,\n workspace_id: mockUser.workspace_id,\n display_name: `${provider.charAt(0).toUpperCase() + provider.slice(1)} User`,\n email: `user@${provider}.com`,\n };\n },\n\n async logout(_provider?: string): Promise<LogoutResponse> {\n await delay(100);\n return { success: true, tokens_revoked: true };\n },\n\n async createProject(request: CreateProjectRequest): Promise<Project> {\n initializeStore();\n await delay(Timing.MOCK_API_DELAY_MS);\n\n const now = Math.floor(Date.now() / 1000);\n const projectId = generateId();\n const slug = request.slug ?? slugify(request.name);\n const project: Project = {\n id: projectId,\n workspace_id: request.workspace_id,\n name: request.name,\n slug,\n description: request.description,\n is_default: false,\n is_archived: false,\n settings: request.settings ?? {},\n created_at: now,\n updated_at: now,\n };\n projects.set(projectId, project);\n projectMemberships.set(projectId, [\n {\n project_id: projectId,\n user_id: mockUser.user_id,\n role: 'admin',\n joined_at: now,\n },\n ]);\n return project;\n },\n\n async getProject(request: GetProjectRequest): Promise<Project> {\n initializeStore();\n await delay(80);\n const project = projects.get(request.project_id);\n if (!project) {\n throw new Error('Project not found');\n }\n return { ...project };\n },\n\n async getProjectBySlug(request: GetProjectBySlugRequest): Promise<Project> {\n initializeStore();\n await delay(80);\n const project = Array.from(projects.values()).find(\n (item) => item.workspace_id === request.workspace_id && item.slug === request.slug\n );\n if (!project) {\n throw new Error('Project not found');\n }\n return { ...project };\n },\n\n async listProjects(request: ListProjectsRequest): Promise<ListProjectsResponse> {\n initializeStore();\n await delay(Timing.MOCK_API_DELAY_MS);\n let list = Array.from(projects.values()).filter(\n (item) => item.workspace_id === request.workspace_id\n );\n if (!request.include_archived) {\n list = list.filter((item) => !item.is_archived);\n }\n const total = list.length;\n const offset = request.offset ?? 0;\n const limit = request.limit ?? 50;\n list = list.slice(offset, offset + limit);\n return { projects: list.map((item) => ({ ...item })), total_count: total };\n },\n\n async updateProject(request: UpdateProjectRequest): Promise<Project> {\n initializeStore();\n await delay(Timing.MOCK_API_DELAY_MS);\n const project = projects.get(request.project_id);\n if (!project) {\n throw new Error('Project not found');\n }\n const updated: Project = {\n ...project,\n name: request.name ?? project.name,\n slug: request.slug ?? project.slug,\n description: request.description ?? project.description,\n settings: request.settings ?? project.settings,\n updated_at: Math.floor(Date.now() / 1000),\n };\n projects.set(updated.id, updated);\n return updated;\n },\n\n async archiveProject(projectId: string): Promise<Project> {\n initializeStore();\n await delay(Timing.MOCK_API_DELAY_MS);\n const project = projects.get(projectId);\n if (!project) {\n throw new Error('Project not found');\n }\n if (project.is_default) {\n throw new Error('Cannot archive default project');\n }\n const updated = {\n ...project,\n is_archived: true,\n archived_at: Math.floor(Date.now() / 1000),\n updated_at: Math.floor(Date.now() / 1000),\n };\n projects.set(projectId, updated);\n return updated;\n },\n\n async restoreProject(projectId: string): Promise<Project> {\n initializeStore();\n await delay(Timing.MOCK_API_DELAY_MS);\n const project = projects.get(projectId);\n if (!project) {\n throw new Error('Project not found');\n }\n const updated = {\n ...project,\n is_archived: false,\n archived_at: undefined,\n updated_at: Math.floor(Date.now() / 1000),\n };\n projects.set(projectId, updated);\n return updated;\n },\n\n async deleteProject(projectId: string): Promise<boolean> {\n initializeStore();\n await delay(Timing.MOCK_API_DELAY_MS);\n const project = projects.get(projectId);\n if (!project) {\n return false;\n }\n if (project.is_default) {\n throw new Error('Cannot delete default project');\n }\n projects.delete(projectId);\n projectMemberships.delete(projectId);\n return true;\n },\n\n async setActiveProject(request: { workspace_id: string; project_id?: string }): Promise<void> {\n initializeStore();\n await delay(60);\n const projectId = request.project_id?.trim() || null;\n if (projectId) {\n const project = projects.get(projectId);\n if (!project) {\n throw new Error('Project not found');\n }\n if (project.workspace_id !== request.workspace_id) {\n throw new Error('Project does not belong to workspace');\n }\n }\n activeProjectsByWorkspace.set(request.workspace_id, projectId);\n },\n\n async getActiveProject(request: {\n workspace_id: string;\n }): Promise<{ project_id?: string; project: Project }> {\n initializeStore();\n await delay(60);\n const activeId = activeProjectsByWorkspace.get(request.workspace_id) ?? null;\n const activeProject =\n (activeId && projects.get(activeId)) ||\n Array.from(projects.values()).find(\n (project) => project.workspace_id === request.workspace_id && project.is_default\n );\n if (!activeProject) {\n throw new Error('No project found for workspace');\n }\n return {\n project_id: activeId ?? undefined,\n project: { ...activeProject },\n };\n },\n\n async addProjectMember(request: AddProjectMemberRequest): Promise<ProjectMembership> {\n initializeStore();\n await delay(Timing.MOCK_API_DELAY_MS);\n const list = projectMemberships.get(request.project_id) ?? [];\n const membership: ProjectMembership = {\n project_id: request.project_id,\n user_id: request.user_id,\n role: request.role,\n joined_at: Math.floor(Date.now() / 1000),\n };\n const updated = [...list.filter((item) => item.user_id !== request.user_id), membership];\n projectMemberships.set(request.project_id, updated);\n return membership;\n },\n\n async updateProjectMemberRole(\n request: UpdateProjectMemberRoleRequest\n ): Promise<ProjectMembership> {\n initializeStore();\n await delay(Timing.MOCK_API_DELAY_MS);\n const list = projectMemberships.get(request.project_id) ?? [];\n const existing = list.find((item) => item.user_id === request.user_id);\n if (!existing) {\n throw new Error('Membership not found');\n }\n const updatedMembership = { ...existing, role: request.role };\n const updated = list.map((item) =>\n item.user_id === request.user_id ? updatedMembership : item\n );\n projectMemberships.set(request.project_id, updated);\n return updatedMembership;\n },\n\n async removeProjectMember(\n request: RemoveProjectMemberRequest\n ): Promise<RemoveProjectMemberResponse> {\n initializeStore();\n await delay(Timing.MOCK_API_DELAY_MS);\n const list = projectMemberships.get(request.project_id) ?? [];\n const next = list.filter((item) => item.user_id !== request.user_id);\n projectMemberships.set(request.project_id, next);\n return { success: next.length !== list.length };\n },\n\n async listProjectMembers(\n request: ListProjectMembersRequest\n ): Promise<ListProjectMembersResponse> {\n initializeStore();\n await delay(Timing.MOCK_API_DELAY_MS);\n const list = projectMemberships.get(request.project_id) ?? [];\n const offset = request.offset ?? 0;\n const limit = request.limit ?? 100;\n const slice = list.slice(offset, offset + limit);\n return { members: slice, total_count: list.length };\n },\n\n async createMeeting(request: CreateMeetingRequest): Promise<Meeting> {\n initializeStore();\n await delay(200);\n\n const workspaceId = IdentityDefaults.DEFAULT_WORKSPACE_ID;\n const fallbackProjectId =\n activeProjectsByWorkspace.get(workspaceId) ?? IdentityDefaults.DEFAULT_PROJECT_ID;\n\n const meeting = generateMeeting({\n title: request.title || `Meeting ${new Date().toLocaleDateString()}`,\n state: 'created',\n segments: [],\n summary: undefined,\n metadata: request.metadata || {},\n project_id: request.project_id ?? fallbackProjectId,\n });\n\n meetings.set(meeting.id, meeting);\n annotations.set(meeting.id, []);\n\n return meeting;\n },\n\n async listMeetings(request: ListMeetingsRequest): Promise<ListMeetingsResponse> {\n initializeStore();\n await delay(Timing.MOCK_API_DELAY_MS);\n\n let result = Array.from(meetings.values());\n\n if (request.project_ids && request.project_ids.length > 0) {\n const projectSet = new Set(request.project_ids);\n result = result.filter((meeting) => meeting.project_id && projectSet.has(meeting.project_id));\n } else if (request.project_id) {\n result = result.filter((meeting) => meeting.project_id === request.project_id);\n }\n\n // Filter by state\n const states = request.states ?? [];\n if (states.length > 0) {\n result = result.filter((m) => states.includes(m.state));\n }\n\n // Sort\n if (request.sort_order === 'oldest') {\n result.sort((a, b) => a.created_at - b.created_at);\n } else {\n result.sort((a, b) => b.created_at - a.created_at);\n }\n\n const total = result.length;\n\n // Pagination\n const offset = request.offset || 0;\n const limit = request.limit || 50;\n result = result.slice(offset, offset + limit);\n\n return {\n meetings: result,\n total_count: total,\n };\n },\n\n async getMeeting(request: GetMeetingRequest): Promise<Meeting> {\n await delay(100);\n return { ...getMeetingOrThrow(request.meeting_id) };\n },\n\n async stopMeeting(meetingId: string): Promise<Meeting> {\n await delay(200);\n const meeting = getMeetingOrThrow(meetingId);\n meeting.state = 'stopped';\n meeting.ended_at = Date.now() / 1000;\n meeting.duration_seconds = meeting.ended_at - (meeting.started_at || meeting.created_at);\n return { ...meeting };\n },\n\n async deleteMeeting(meetingId: string): Promise<boolean> {\n initializeStore();\n await delay(Timing.MOCK_API_DELAY_MS);\n\n const deleted = meetings.delete(meetingId);\n annotations.delete(meetingId);\n\n return deleted;\n },\n\n async startTranscription(meetingId: string): Promise<TranscriptionStream> {\n initializeStore();\n\n const meeting = meetings.get(meetingId);\n if (meeting) {\n meeting.state = 'recording';\n meeting.started_at = Date.now() / 1000;\n }\n\n return new MockTranscriptionStream(meetingId);\n },\n\n async generateSummary(meetingId: string, _forceRegenerate?: boolean): Promise<Summary> {\n await delay(Timing.TWO_SECONDS_MS); // Simulate AI processing\n const meeting = getMeetingOrThrow(meetingId);\n const summary = generateSummary(meetingId, meeting.segments);\n Object.assign(meeting, { summary, state: 'completed' });\n return summary;\n },\n\n // --- Cloud Consent ---\n\n async grantCloudConsent(): Promise<void> {\n await delay(100);\n cloudConsentGranted = true;\n },\n\n async revokeCloudConsent(): Promise<void> {\n await delay(100);\n cloudConsentGranted = false;\n },\n\n async getCloudConsentStatus(): Promise<{ consentGranted: boolean }> {\n await delay(50);\n return { consentGranted: cloudConsentGranted };\n },\n\n async listAnnotations(\n meetingId: string,\n startTime?: number,\n endTime?: number\n ): Promise<Annotation[]> {\n initializeStore();\n await delay(100);\n\n let result = annotations.get(meetingId) || [];\n\n if (startTime !== undefined) {\n result = result.filter((a) => a.start_time >= startTime);\n }\n if (endTime !== undefined) {\n result = result.filter((a) => a.end_time <= endTime);\n }\n\n return result;\n },\n\n async addAnnotation(request: AddAnnotationRequest): Promise<Annotation> {\n initializeStore();\n await delay(Timing.MOCK_API_DELAY_MS);\n\n const annotation: Annotation = {\n id: generateId(),\n meeting_id: request.meeting_id,\n annotation_type: request.annotation_type,\n text: request.text,\n start_time: request.start_time,\n end_time: request.end_time,\n segment_ids: request.segment_ids || [],\n created_at: Date.now() / 1000,\n };\n\n const meetingAnnotations = annotations.get(request.meeting_id) || [];\n meetingAnnotations.push(annotation);\n annotations.set(request.meeting_id, meetingAnnotations);\n\n return annotation;\n },\n\n async getAnnotation(annotationId: string): Promise<Annotation> {\n initializeStore();\n await delay(100);\n const found = findAnnotation(annotationId);\n if (!found) {\n throw new Error(`Annotation not found: ${annotationId}`);\n }\n return found.annotation;\n },\n\n async updateAnnotation(request: UpdateAnnotationRequest): Promise<Annotation> {\n initializeStore();\n await delay(Timing.MOCK_API_DELAY_MS);\n const found = findAnnotation(request.annotation_id);\n if (!found) {\n throw new Error(`Annotation not found: ${request.annotation_id}`);\n }\n const { annotation } = found;\n if (request.annotation_type) {\n annotation.annotation_type = request.annotation_type;\n }\n if (request.text) {\n annotation.text = request.text;\n }\n if (request.start_time !== undefined) {\n annotation.start_time = request.start_time;\n }\n if (request.end_time !== undefined) {\n annotation.end_time = request.end_time;\n }\n if (request.segment_ids) {\n annotation.segment_ids = request.segment_ids;\n }\n return annotation;\n },\n\n async deleteAnnotation(annotationId: string): Promise<boolean> {\n initializeStore();\n await delay(100);\n const found = findAnnotation(annotationId);\n if (!found) {\n return false;\n }\n found.list.splice(found.index, 1);\n return true;\n },\n\n async exportTranscript(meetingId: string, format: ExportFormat): Promise<ExportResult> {\n await delay(300);\n const meeting = getMeetingOrThrow(meetingId);\n const date = new Date(meeting.created_at * 1000).toLocaleString();\n const duration = `${Math.round(meeting.duration_seconds / 60)} minutes`;\n const transcriptLines = meeting.segments.map((s) => ({\n time: formatTime(s.start_time),\n speaker: s.speaker_id,\n text: s.text,\n }));\n\n if (format === 'markdown') {\n let content = `# ${meeting.title}\\n\\n**Date:** ${date}\\n**Duration:** ${duration}\\n\\n## Transcript\\n\\n`;\n content += transcriptLines.map((l) => `**[${l.time}] ${l.speaker}:** ${l.text}`).join('\\n\\n');\n if (meeting.summary) {\n content += `\\n\\n## Summary\\n\\n${meeting.summary.executive_summary}\\n\\n### Key Points\\n\\n`;\n content += meeting.summary.key_points.map((kp) => `- ${kp.text}`).join('\\n');\n content += `\\n\\n### Action Items\\n\\n`;\n content += meeting.summary.action_items\n .map((ai) => `- [ ] ${ai.text}${ai.assignee ? ` (${ai.assignee})` : ''}`)\n .join('\\n');\n }\n return { content, format_name: 'Markdown', file_extension: '.md' };\n }\n const htmlStyle =\n 'body { font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 2rem; } .segment { margin: 1rem 0; } .timestamp { color: #666; font-size: 0.875rem; } .speaker { font-weight: 600; color: #8b5cf6; }';\n const segments = transcriptLines\n .map(\n (l) =>\n `<div class=\"segment\"><span class=\"timestamp\">[${l.time}]</span> <span class=\"speaker\">${l.speaker}:</span> ${l.text}</div>`\n )\n .join('\\n');\n const content = `<!DOCTYPE html><html><head><title>${meeting.title}</title><style>${htmlStyle}</style></head><body><h1>${meeting.title}</h1><p><strong>Date:</strong> ${date}</p><p><strong>Duration:</strong> ${duration}</p><h2>Transcript</h2>${segments}</body></html>`;\n return { content, format_name: 'HTML', file_extension: '.html' };\n },\n\n async refineSpeakers(meetingId: string, _numSpeakers?: number): Promise<DiarizationJobStatus> {\n await delay(500);\n getMeetingOrThrow(meetingId); // Validate meeting exists\n setTimeout(() => {}, Timing.THREE_SECONDS_MS); // Simulate async job\n return { job_id: generateId(), status: 'queued', segments_updated: 0, speaker_ids: [] };\n },\n\n async getDiarizationJobStatus(jobId: string): Promise<DiarizationJobStatus> {\n await delay(100);\n return {\n job_id: jobId,\n status: 'completed',\n segments_updated: 15,\n speaker_ids: ['SPEAKER_00', 'SPEAKER_01', 'SPEAKER_02'],\n progress_percent: 100,\n };\n },\n\n async cancelDiarization(_jobId: string): Promise<CancelDiarizationResult> {\n await delay(100);\n return { success: true, error_message: '', status: 'cancelled' };\n },\n\n async getActiveDiarizationJobs(): Promise<DiarizationJobStatus[]> {\n await delay(100);\n // Return empty array for mock - no active jobs in mock environment\n return [];\n },\n\n async renameSpeaker(meetingId: string, oldSpeakerId: string, newName: string): Promise<boolean> {\n await delay(200);\n const meeting = getMeetingOrThrow(meetingId);\n const updated = meeting.segments.filter((s) => s.speaker_id === oldSpeakerId);\n updated.forEach((s) => {\n s.speaker_id = newName;\n });\n return updated.length > 0;\n },\n\n async connect(_serverUrl?: string): Promise<ServerInfo> {\n await delay(100);\n return { ...mockServerInfo };\n },\n\n async disconnect(): Promise<void> {\n await delay(50);\n },\n async getPreferences(): Promise<UserPreferences> {\n await delay(50);\n return preferences.get();\n },\n async savePreferences(updated: UserPreferences): Promise<void> {\n preferences.replace(updated);\n },\n async listAudioDevices(): Promise<AudioDeviceInfo[]> {\n return [];\n },\n async getDefaultAudioDevice(_isInput: boolean): Promise<AudioDeviceInfo | null> {\n return null;\n },\n async selectAudioDevice(deviceId: string, isInput: boolean): Promise<void> {\n preferences.setAudioDevice(isInput ? 'input' : 'output', deviceId);\n },\n async listInstalledApps(_options?: { commonOnly?: boolean }): Promise<InstalledAppInfo[]> {\n return [];\n },\n async saveExportFile(\n _content: string,\n _defaultName: string,\n _extension: string\n ): Promise<boolean> {\n return true;\n },\n async startPlayback(meetingId: string, startTime?: number): Promise<void> {\n Object.assign(mockPlayback, {\n meeting_id: meetingId,\n position: startTime ?? 0,\n is_playing: true,\n is_paused: false,\n });\n },\n async pausePlayback(): Promise<void> {\n Object.assign(mockPlayback, { is_playing: false, is_paused: true });\n },\n async stopPlayback(): Promise<void> {\n Object.assign(mockPlayback, {\n meeting_id: undefined,\n position: 0,\n duration: 0,\n is_playing: false,\n is_paused: false,\n highlighted_segment: undefined,\n });\n },\n async seekPlayback(position: number): Promise<PlaybackInfo> {\n mockPlayback.position = position;\n return { ...mockPlayback };\n },\n async getPlaybackState(): Promise<PlaybackInfo> {\n return { ...mockPlayback };\n },\n async setTriggerEnabled(_enabled: boolean): Promise<void> {\n await delay(10);\n },\n async snoozeTriggers(_minutes?: number): Promise<void> {\n await delay(10);\n },\n async resetSnooze(): Promise<void> {\n await delay(10);\n },\n async getTriggerStatus(): Promise<TriggerStatus> {\n return {\n enabled: false,\n is_snoozed: false,\n snooze_remaining_secs: undefined,\n pending_trigger: undefined,\n };\n },\n async dismissTrigger(): Promise<void> {\n await delay(10);\n },\n async acceptTrigger(title?: string): Promise<Meeting> {\n initializeStore();\n const meeting = generateMeeting({\n title: title || `Meeting ${new Date().toLocaleDateString()}`,\n state: 'created',\n segments: [],\n summary: undefined,\n metadata: {},\n });\n meetings.set(meeting.id, meeting);\n annotations.set(meeting.id, []);\n return meeting;\n },\n\n // ==========================================================================\n // Webhook Management\n // ==========================================================================\n\n async registerWebhook(request: RegisterWebhookRequest): Promise<RegisteredWebhook> {\n await delay(200);\n const now = Math.floor(Date.now() / 1000);\n const webhook: RegisteredWebhook = {\n id: generateId(),\n workspace_id: request.workspace_id,\n name: request.name || 'Webhook',\n url: request.url,\n events: request.events,\n enabled: true,\n timeout_ms: request.timeout_ms ?? Timing.TEN_SECONDS_MS,\n max_retries: request.max_retries ?? 3,\n created_at: now,\n updated_at: now,\n };\n webhooks.set(webhook.id, webhook);\n webhookDeliveries.set(webhook.id, []);\n return webhook;\n },\n\n async listWebhooks(enabledOnly?: boolean): Promise<ListWebhooksResponse> {\n await delay(100);\n let webhookList = Array.from(webhooks.values());\n if (enabledOnly) {\n webhookList = webhookList.filter((w) => w.enabled);\n }\n return {\n webhooks: webhookList,\n total_count: webhookList.length,\n };\n },\n\n async updateWebhook(request: UpdateWebhookRequest): Promise<RegisteredWebhook> {\n await delay(200);\n const webhook = webhooks.get(request.webhook_id);\n if (!webhook) {\n throw new Error(`Webhook ${request.webhook_id} not found`);\n }\n const updated: RegisteredWebhook = {\n ...webhook,\n ...(request.url !== undefined && { url: request.url }),\n ...(request.events !== undefined && { events: request.events }),\n ...(request.name !== undefined && { name: request.name }),\n ...(request.enabled !== undefined && { enabled: request.enabled }),\n ...(request.timeout_ms !== undefined && { timeout_ms: request.timeout_ms }),\n ...(request.max_retries !== undefined && { max_retries: request.max_retries }),\n updated_at: Math.floor(Date.now() / 1000),\n };\n webhooks.set(webhook.id, updated);\n return updated;\n },\n\n async deleteWebhook(webhookId: string): Promise<DeleteWebhookResponse> {\n await delay(100);\n const exists = webhooks.has(webhookId);\n if (exists) {\n webhooks.delete(webhookId);\n webhookDeliveries.delete(webhookId);\n }\n return { success: exists };\n },\n\n async getWebhookDeliveries(\n webhookId: string,\n limit?: number\n ): Promise<GetWebhookDeliveriesResponse> {\n await delay(100);\n const deliveries = webhookDeliveries.get(webhookId) || [];\n const limited = limit ? deliveries.slice(0, limit) : deliveries;\n return {\n deliveries: limited,\n total_count: deliveries.length,\n };\n },\n\n // Entity extraction stubs (NER not available in mock mode)\n async extractEntities(\n _meetingId: string,\n _forceRefresh?: boolean\n ): Promise<ExtractEntitiesResponse> {\n await delay(100);\n return { entities: [], total_count: 0, cached: false };\n },\n\n async updateEntity(\n _meetingId: string,\n entityId: string,\n text?: string,\n category?: string\n ): Promise<ExtractedEntity> {\n await delay(100);\n return {\n id: entityId,\n text: text || 'Mock Entity',\n category: category || 'other',\n segment_ids: [],\n confidence: 1.0,\n is_pinned: false,\n };\n },\n\n async deleteEntity(_meetingId: string, _entityId: string): Promise<boolean> {\n await delay(100);\n return true;\n },\n\n // --- Sprint 9: Integration Sync ---\n\n async startIntegrationSync(integrationId: string): Promise<StartIntegrationSyncResponse> {\n await delay(200);\n return {\n sync_run_id: `sync-${integrationId}-${Date.now()}`,\n status: 'running',\n };\n },\n\n async getSyncStatus(_syncRunId: string): Promise<GetSyncStatusResponse> {\n await delay(100);\n // Simulate completion after a brief delay\n return {\n status: 'success',\n items_synced: Math.floor(Math.random() * 50) + 10,\n items_total: 0,\n error_message: '',\n duration_ms: Math.floor(Math.random() * Timing.TWO_SECONDS_MS) + 500,\n };\n },\n\n async listSyncHistory(\n _integrationId: string,\n limit?: number,\n _offset?: number\n ): Promise<ListSyncHistoryResponse> {\n await delay(100);\n const now = Date.now();\n const mockRuns: SyncRunProto[] = Array.from({ length: Math.min(limit || 10, 10) }, (_, i) => ({\n id: `run-${i}`,\n integration_id: _integrationId,\n status: i === 0 ? 'running' : 'success',\n items_synced: Math.floor(Math.random() * 50) + 5,\n error_message: '',\n duration_ms: Math.floor(Math.random() * Timing.THREE_SECONDS_MS) + 1000,\n started_at: new Date(now - i * Timing.ONE_HOUR_MS).toISOString(),\n completed_at:\n i === 0 ? '' : new Date(now - i * Timing.ONE_HOUR_MS + Timing.TWO_SECONDS_MS).toISOString(),\n }));\n return { runs: mockRuns, total_count: mockRuns.length };\n },\n\n async getUserIntegrations(): Promise<GetUserIntegrationsResponse> {\n await delay(100);\n return {\n integrations: [\n {\n id: 'google-calendar-integration',\n name: 'Google Calendar',\n type: 'calendar',\n status: 'connected',\n workspace_id: 'workspace-1',\n },\n ],\n };\n },\n\n // --- Sprint 9: Observability ---\n\n async getRecentLogs(request?: GetRecentLogsRequest): Promise<GetRecentLogsResponse> {\n await delay(Timing.MOCK_API_DELAY_MS);\n const limit = request?.limit || 100;\n const levels: LogLevel[] = ['info', 'warning', 'error', 'debug'];\n const sources: LogSource[] = ['app', 'api', 'sync', 'auth', 'system'];\n const messages = [\n 'Application started successfully',\n 'User session initialized',\n 'API request completed',\n 'Background sync triggered',\n 'Cache refreshed',\n 'Configuration loaded',\n 'Connection established',\n 'Data validation passed',\n ];\n\n const now = Date.now();\n const logs: LogEntry[] = Array.from({ length: Math.min(limit, 50) }, (_, i) => {\n const level = request?.level || levels[Math.floor(Math.random() * levels.length)];\n const source = request?.source || sources[Math.floor(Math.random() * sources.length)];\n const traceId =\n i % 5 === 0 ? Math.random().toString(16).slice(2).padStart(32, '0') : undefined;\n const spanId = traceId ? Math.random().toString(16).slice(2).padStart(16, '0') : undefined;\n return {\n timestamp: new Date(now - i * Timing.THIRTY_SECONDS_MS).toISOString(),\n level,\n source,\n message: messages[Math.floor(Math.random() * messages.length)],\n details: i % 3 === 0 ? { request_id: `req-${i}` } : undefined,\n trace_id: traceId,\n span_id: spanId,\n };\n });\n\n return { logs, total_count: logs.length };\n },\n\n async getPerformanceMetrics(\n request?: GetPerformanceMetricsRequest\n ): Promise<GetPerformanceMetricsResponse> {\n await delay(100);\n const historyLimit: number = request?.history_limit ?? 60;\n const now = Date.now();\n\n // Generate mock historical data\n const history: PerformanceMetricsPoint[] = Array.from(\n { length: Math.min(historyLimit, 60) },\n (_, i) => ({\n timestamp: now - (historyLimit - 1 - i) * Timing.ONE_MINUTE_MS,\n cpu_percent: 20 + Math.random() * 40 + Math.sin(i / 3) * 15,\n memory_percent: 40 + Math.random() * 25 + Math.cos(i / 4) * 10,\n memory_mb: 4000 + Math.random() * MEMORY_VARIANCE_MB,\n disk_percent: 45 + Math.random() * 15,\n network_bytes_sent: Math.floor(Math.random() * 1000000),\n network_bytes_recv: Math.floor(Math.random() * 2000000),\n process_memory_mb: 200 + Math.random() * 100,\n active_connections: Math.floor(Math.random() * 10) + 1,\n })\n );\n\n const current = history[history.length - 1];\n\n return { current, history };\n },\n\n // --- Calendar Integration ---\n\n async listCalendarEvents(\n _hoursAhead?: number,\n _limit?: number,\n _provider?: string\n ): Promise<ListCalendarEventsResponse> {\n await delay(100);\n return { events: [], total_count: 0 };\n },\n\n async getCalendarProviders(): Promise<GetCalendarProvidersResponse> {\n await delay(100);\n return {\n providers: [\n {\n name: 'google',\n is_authenticated: false,\n display_name: 'Google Calendar',\n },\n {\n name: 'outlook',\n is_authenticated: false,\n display_name: 'Outlook Calendar',\n },\n ],\n };\n },\n\n async initiateCalendarAuth(\n _provider: string,\n _redirectUri?: string\n ): Promise<InitiateCalendarAuthResponse> {\n await delay(100);\n return {\n auth_url: Placeholders.MOCK_OAUTH_URL,\n state: `mock-state-${Date.now()}`,\n };\n },\n\n async completeCalendarAuth(\n _provider: string,\n _code: string,\n _state: string\n ): Promise<CompleteCalendarAuthResponse> {\n await delay(200);\n return {\n success: true,\n error_message: '',\n integration_id: `mock-integration-${Date.now()}`,\n };\n },\n\n async getOAuthConnectionStatus(_provider: string): Promise<GetOAuthConnectionStatusResponse> {\n await delay(50);\n return {\n connection: {\n provider: _provider,\n status: 'disconnected',\n email: '',\n expires_at: 0,\n error_message: '',\n integration_type: 'calendar',\n },\n };\n },\n\n async disconnectCalendar(_provider: string): Promise<DisconnectOAuthResponse> {\n await delay(100);\n return { success: true };\n },\n\n async runConnectionDiagnostics(): Promise<ConnectionDiagnostics> {\n await delay(100);\n return {\n clientConnected: false,\n serverUrl: `mock://localhost:${SERVER_DEFAULTS.PORT}`,\n serverInfo: null,\n calendarAvailable: false,\n calendarProviderCount: 0,\n calendarProviders: [],\n error: 'Running in mock mode - no real server connection',\n steps: [\n {\n name: 'Client Connection State',\n success: false,\n message: 'Mock adapter - no real gRPC client',\n durationMs: 1,\n },\n {\n name: 'Environment Check',\n success: true,\n message: 'Running in browser/mock mode',\n durationMs: 1,\n },\n ],\n };\n },\n\n // --- OIDC Provider Management (Sprint 17) ---\n\n async registerOidcProvider(request: RegisterOidcProviderRequest): Promise<OidcProviderApi> {\n await delay(200);\n const now = Date.now();\n const provider: OidcProviderApi = {\n id: generateId(),\n workspace_id: request.workspace_id,\n name: request.name,\n preset: request.preset,\n issuer_url: request.issuer_url,\n client_id: request.client_id,\n enabled: true,\n discovery: request.auto_discover\n ? {\n issuer: request.issuer_url,\n authorization_endpoint: `${request.issuer_url}/oauth2/authorize`,\n token_endpoint: `${request.issuer_url}/oauth2/token`,\n userinfo_endpoint: `${request.issuer_url}/oauth2/userinfo`,\n jwks_uri: `${request.issuer_url}/.well-known/jwks.json`,\n scopes_supported: ['openid', 'profile', 'email', 'groups'],\n claims_supported: ['sub', 'name', 'email', 'groups'],\n supports_pkce: true,\n }\n : undefined,\n claim_mapping: request.claim_mapping ?? {\n subject_claim: 'sub',\n email_claim: 'email',\n email_verified_claim: 'email_verified',\n name_claim: 'name',\n preferred_username_claim: 'preferred_username',\n groups_claim: 'groups',\n picture_claim: 'picture',\n },\n scopes: request.scopes.length > 0 ? request.scopes : ['openid', 'profile', 'email'],\n require_email_verified: request.require_email_verified ?? true,\n allowed_groups: request.allowed_groups,\n created_at: now,\n updated_at: now,\n discovery_refreshed_at: request.auto_discover ? now : undefined,\n warnings: [],\n };\n oidcProviders.set(provider.id, provider);\n return provider;\n },\n\n async listOidcProviders(\n _workspaceId?: string,\n enabledOnly?: boolean\n ): Promise<ListOidcProvidersResponse> {\n await delay(100);\n let providers = Array.from(oidcProviders.values());\n if (enabledOnly) {\n providers = providers.filter((p) => p.enabled);\n }\n return {\n providers,\n total_count: providers.length,\n };\n },\n\n async getOidcProvider(providerId: string): Promise<OidcProviderApi> {\n await delay(50);\n const provider = oidcProviders.get(providerId);\n if (!provider) {\n throw new Error(`OIDC provider not found: ${providerId}`);\n }\n return provider;\n },\n\n async updateOidcProvider(request: UpdateOidcProviderRequest): Promise<OidcProviderApi> {\n await delay(Timing.MOCK_API_DELAY_MS);\n const provider = oidcProviders.get(request.provider_id);\n if (!provider) {\n throw new Error(`OIDC provider not found: ${request.provider_id}`);\n }\n const updated: OidcProviderApi = {\n ...provider,\n name: request.name ?? provider.name,\n scopes: request.scopes.length > 0 ? request.scopes : provider.scopes,\n claim_mapping: request.claim_mapping ?? provider.claim_mapping,\n allowed_groups:\n request.allowed_groups.length > 0 ? request.allowed_groups : provider.allowed_groups,\n require_email_verified: request.require_email_verified ?? provider.require_email_verified,\n enabled: request.enabled ?? provider.enabled,\n updated_at: Date.now(),\n };\n oidcProviders.set(request.provider_id, updated);\n return updated;\n },\n\n async deleteOidcProvider(providerId: string): Promise<DeleteOidcProviderResponse> {\n await delay(100);\n const deleted = oidcProviders.delete(providerId);\n return { success: deleted };\n },\n\n async refreshOidcDiscovery(\n providerId?: string,\n _workspaceId?: string\n ): Promise<RefreshOidcDiscoveryResponse> {\n await delay(300);\n const results: Record<string, string> = {};\n let successCount = 0;\n let failureCount = 0;\n\n if (providerId) {\n const provider = oidcProviders.get(providerId);\n if (provider) {\n results[providerId] = '';\n successCount = 1;\n // Update discovery_refreshed_at\n oidcProviders.set(providerId, {\n ...provider,\n discovery_refreshed_at: Date.now(),\n });\n } else {\n results[providerId] = 'Provider not found';\n failureCount = 1;\n }\n } else {\n for (const [id, provider] of oidcProviders) {\n results[id] = '';\n successCount++;\n oidcProviders.set(id, {\n ...provider,\n discovery_refreshed_at: Date.now(),\n });\n }\n }\n\n return {\n results,\n success_count: successCount,\n failure_count: failureCount,\n };\n },\n\n async testOidcConnection(providerId: string): Promise<RefreshOidcDiscoveryResponse> {\n return this.refreshOidcDiscovery(providerId);\n },\n\n async listOidcPresets(): Promise<ListOidcPresetsResponse> {\n await delay(50);\n return {\n presets: [\n {\n preset: 'authentik',\n display_name: 'Authentik',\n description: 'goauthentik.io - Open source identity provider',\n default_scopes: ['openid', 'profile', 'email', 'groups'],\n documentation_url: OidcDocsUrls.AUTHENTIK,\n },\n {\n preset: 'authelia',\n display_name: 'Authelia',\n description: 'authelia.com - SSO & 2FA authentication server',\n default_scopes: ['openid', 'profile', 'email', 'groups'],\n documentation_url: OidcDocsUrls.AUTHELIA,\n },\n {\n preset: 'keycloak',\n display_name: 'Keycloak',\n description: 'keycloak.org - Open source identity management',\n default_scopes: ['openid', 'profile', 'email'],\n documentation_url: OidcDocsUrls.KEYCLOAK,\n },\n {\n preset: 'auth0',\n display_name: 'Auth0',\n description: 'auth0.com - Identity platform by Okta',\n default_scopes: ['openid', 'profile', 'email'],\n documentation_url: OidcDocsUrls.AUTH0,\n },\n {\n preset: 'okta',\n display_name: 'Okta',\n description: 'okta.com - Enterprise identity',\n default_scopes: ['openid', 'profile', 'email', 'groups'],\n documentation_url: OidcDocsUrls.OKTA,\n },\n {\n preset: 'azure_ad',\n display_name: 'Azure AD / Entra ID',\n description: 'Microsoft Entra ID (formerly Azure AD)',\n default_scopes: ['openid', 'profile', 'email'],\n documentation_url: OidcDocsUrls.AZURE_AD,\n },\n {\n preset: 'custom',\n display_name: 'Custom OIDC Provider',\n description: 'Any OIDC-compliant identity provider',\n default_scopes: ['openid', 'profile', 'email'],\n },\n ],\n };\n },\n};\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/mock-data.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/mock-data.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/mock-transcription-stream.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/mock-transcription-stream.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/offline-defaults.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/reconnection.test.ts","messages":[{"ruleId":"@typescript-eslint/no-unsafe-return","severity":1,"message":"Unsafe return of a value of type `any`.","line":20,"column":17,"nodeType":"CallExpression","messageId":"unsafeReturn","endLine":20,"endColumn":25},{"ruleId":"@typescript-eslint/no-unsafe-return","severity":1,"message":"Unsafe return of a value of type `any`.","line":24,"column":29,"nodeType":"CallExpression","messageId":"unsafeReturn","endLine":24,"endColumn":49}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nconst getAPI = vi.fn();\nconst isTauriEnvironment = vi.fn();\nconst getConnectionState = vi.fn();\nconst incrementReconnectAttempts = vi.fn();\nconst resetReconnectAttempts = vi.fn();\nconst setConnectionMode = vi.fn();\nconst setConnectionError = vi.fn();\nconst meetingCache = {\n invalidateAll: vi.fn(),\n updateServerStateVersion: vi.fn(),\n};\nconst preferences = {\n getServerUrl: vi.fn(() => ''),\n revalidateIntegrations: vi.fn(),\n};\n\nvi.mock('./interface', () => ({\n getAPI: () => getAPI(),\n}));\n\nvi.mock('./tauri-adapter', () => ({\n isTauriEnvironment: () => isTauriEnvironment(),\n}));\n\nvi.mock('./connection-state', () => ({\n getConnectionState,\n incrementReconnectAttempts,\n resetReconnectAttempts,\n setConnectionMode,\n setConnectionError,\n}));\n\nvi.mock('@/lib/cache/meeting-cache', () => ({\n meetingCache,\n}));\n\nvi.mock('@/lib/preferences', () => ({\n preferences,\n}));\n\nasync function loadReconnection() {\n vi.resetModules();\n return await import('./reconnection');\n}\n\ndescribe('reconnection', () => {\n beforeEach(() => {\n getAPI.mockReset();\n isTauriEnvironment.mockReset();\n getConnectionState.mockReset();\n incrementReconnectAttempts.mockReset();\n resetReconnectAttempts.mockReset();\n setConnectionMode.mockReset();\n setConnectionError.mockReset();\n meetingCache.invalidateAll.mockReset();\n meetingCache.updateServerStateVersion.mockReset();\n preferences.getServerUrl.mockReset();\n preferences.revalidateIntegrations.mockReset();\n preferences.getServerUrl.mockReturnValue('');\n });\n\n afterEach(async () => {\n const { stopReconnection } = await loadReconnection();\n stopReconnection();\n vi.unstubAllGlobals();\n });\n\n it('does not attempt reconnect when not in tauri', async () => {\n isTauriEnvironment.mockReturnValue(false);\n getConnectionState.mockReturnValue({ mode: 'cached', reconnectAttempts: 0 });\n\n const { startReconnection } = await loadReconnection();\n startReconnection();\n await Promise.resolve();\n\n expect(setConnectionMode).not.toHaveBeenCalled();\n });\n\n it('reconnects successfully and resets attempts', async () => {\n isTauriEnvironment.mockReturnValue(true);\n getConnectionState.mockReturnValue({ mode: 'cached', reconnectAttempts: 1 });\n const getServerInfo = vi.fn().mockResolvedValue({ state_version: 3 });\n const connect = vi.fn().mockResolvedValue(undefined);\n getAPI.mockReturnValue({\n connect,\n getServerInfo,\n });\n preferences.revalidateIntegrations.mockResolvedValue(undefined);\n preferences.getServerUrl.mockReturnValue('http://example.com:50051');\n\n const { startReconnection } = await loadReconnection();\n startReconnection();\n await Promise.resolve();\n await Promise.resolve();\n\n expect(resetReconnectAttempts).toHaveBeenCalled();\n expect(setConnectionMode).toHaveBeenCalledWith('connected');\n expect(setConnectionError).toHaveBeenCalledWith(null);\n expect(connect).toHaveBeenCalledWith('http://example.com:50051');\n expect(meetingCache.invalidateAll).toHaveBeenCalled();\n expect(getServerInfo).toHaveBeenCalled();\n expect(meetingCache.updateServerStateVersion).toHaveBeenCalledWith(3);\n expect(preferences.revalidateIntegrations).toHaveBeenCalled();\n });\n\n it('handles reconnect failures and schedules retry', async () => {\n isTauriEnvironment.mockReturnValue(true);\n getConnectionState.mockReturnValue({ mode: 'cached', reconnectAttempts: 0 });\n getAPI.mockReturnValue({ connect: vi.fn().mockRejectedValue(new Error('nope')) });\n\n const { startReconnection } = await loadReconnection();\n startReconnection();\n await Promise.resolve();\n\n expect(incrementReconnectAttempts).toHaveBeenCalled();\n expect(setConnectionMode).toHaveBeenCalledWith('cached', 'nope');\n });\n\n it('handles offline network state', async () => {\n isTauriEnvironment.mockReturnValue(true);\n getConnectionState.mockReturnValue({ mode: 'cached', reconnectAttempts: 0 });\n vi.stubGlobal('navigator', { onLine: false });\n\n const { startReconnection } = await loadReconnection();\n startReconnection();\n await Promise.resolve();\n\n expect(setConnectionMode).toHaveBeenCalledWith('cached', 'Network offline');\n });\n\n it('does not attempt reconnect when already connected or reconnecting', async () => {\n isTauriEnvironment.mockReturnValue(true);\n getAPI.mockReturnValue({ connect: vi.fn() });\n\n getConnectionState.mockReturnValue({ mode: 'connected', reconnectAttempts: 0 });\n const { startReconnection } = await loadReconnection();\n startReconnection();\n await Promise.resolve();\n expect(setConnectionMode).not.toHaveBeenCalledWith('reconnecting');\n\n getConnectionState.mockReturnValue({ mode: 'reconnecting', reconnectAttempts: 0 });\n startReconnection();\n await Promise.resolve();\n expect(setConnectionMode).not.toHaveBeenCalledWith('reconnecting');\n });\n\n it('uses fallback error message on non-Error failures', async () => {\n isTauriEnvironment.mockReturnValue(true);\n getConnectionState.mockReturnValue({ mode: 'cached', reconnectAttempts: 0 });\n getAPI.mockReturnValue({ connect: vi.fn().mockRejectedValue('nope') });\n\n const { startReconnection } = await loadReconnection();\n startReconnection();\n await Promise.resolve();\n\n expect(setConnectionMode).toHaveBeenCalledWith('cached', 'Reconnection failed');\n });\n\n it('syncs state when forceSyncState is called', async () => {\n const serverInfo = { state_version: 5 };\n const getServerInfo = vi.fn().mockResolvedValue(serverInfo);\n getAPI.mockReturnValue({ getServerInfo });\n preferences.revalidateIntegrations.mockResolvedValue(undefined);\n\n const { forceSyncState, onReconnected } = await loadReconnection();\n const callback = vi.fn();\n const unsubscribe = onReconnected(callback);\n\n await forceSyncState();\n\n expect(meetingCache.invalidateAll).toHaveBeenCalled();\n expect(getServerInfo).toHaveBeenCalled();\n expect(meetingCache.updateServerStateVersion).toHaveBeenCalledWith(5);\n expect(preferences.revalidateIntegrations).toHaveBeenCalled();\n expect(callback).toHaveBeenCalled();\n\n unsubscribe();\n });\n\n it('does not invoke unsubscribed reconnection callbacks', async () => {\n getAPI.mockReturnValue({ getServerInfo: vi.fn().mockResolvedValue({ state_version: 1 }) });\n preferences.revalidateIntegrations.mockResolvedValue(undefined);\n\n const { forceSyncState, onReconnected } = await loadReconnection();\n const callback = vi.fn();\n const unsubscribe = onReconnected(callback);\n\n unsubscribe();\n await forceSyncState();\n\n expect(callback).not.toHaveBeenCalled();\n });\n\n it('reports syncing state while integration revalidation is pending', async () => {\n let resolveRevalidate: (() => void) | undefined;\n const revalidatePromise = new Promise<void>((resolve) => {\n resolveRevalidate = resolve;\n });\n preferences.revalidateIntegrations.mockReturnValue(revalidatePromise);\n getAPI.mockReturnValue({ getServerInfo: vi.fn().mockResolvedValue({ state_version: 2 }) });\n\n const { forceSyncState, isSyncingState } = await loadReconnection();\n const syncPromise = forceSyncState();\n\n expect(isSyncingState()).toBe(true);\n\n resolveRevalidate?.();\n await syncPromise;\n\n expect(isSyncingState()).toBe(false);\n });\n});\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/reconnection.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/tauri-adapter.test.ts","messages":[{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":1,"message":"Unsafe assignment of an error typed value.","line":251,"column":11,"nodeType":"VariableDeclarator","messageId":"anyAssignment","endLine":251,"endColumn":54},{"ruleId":"@typescript-eslint/no-unsafe-call","severity":1,"message":"Unsafe call of a(n) `error` type typed value.","line":261,"column":5,"nodeType":"MemberExpression","messageId":"unsafeCall","endLine":261,"endColumn":16},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":1,"message":"Unsafe member access .send on an `error` typed value.","line":261,"column":12,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":261,"endColumn":16},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":1,"message":"Unsafe assignment of an error typed value.","line":278,"column":11,"nodeType":"VariableDeclarator","messageId":"anyAssignment","endLine":278,"endColumn":54},{"ruleId":"@typescript-eslint/no-unsafe-call","severity":1,"message":"Unsafe call of a(n) `error` type typed value.","line":286,"column":5,"nodeType":"MemberExpression","messageId":"unsafeCall","endLine":286,"endColumn":16},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":1,"message":"Unsafe member access .send on an `error` typed value.","line":286,"column":12,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":286,"endColumn":16},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":1,"message":"Unsafe assignment of an error typed value.","line":313,"column":11,"nodeType":"VariableDeclarator","messageId":"anyAssignment","endLine":313,"endColumn":54},{"ruleId":"@typescript-eslint/no-unsafe-call","severity":1,"message":"Unsafe call of a(n) `error` type typed value.","line":316,"column":11,"nodeType":"MemberExpression","messageId":"unsafeCall","endLine":316,"endColumn":26},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":1,"message":"Unsafe member access .onUpdate on an `error` typed value.","line":316,"column":18,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":316,"endColumn":26},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":1,"message":"Unsafe assignment of an error typed value.","line":363,"column":11,"nodeType":"VariableDeclarator","messageId":"anyAssignment","endLine":363,"endColumn":54},{"ruleId":"@typescript-eslint/no-unsafe-call","severity":1,"message":"Unsafe call of a(n) `error` type typed value.","line":365,"column":11,"nodeType":"MemberExpression","messageId":"unsafeCall","endLine":365,"endColumn":26},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":1,"message":"Unsafe member access .onUpdate on an `error` typed value.","line":365,"column":18,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":365,"endColumn":26},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":1,"message":"Unsafe assignment of an error typed value.","line":440,"column":11,"nodeType":"VariableDeclarator","messageId":"anyAssignment","endLine":440,"endColumn":54},{"ruleId":"@typescript-eslint/no-unsafe-call","severity":1,"message":"Unsafe call of a(n) `error` type typed value.","line":442,"column":11,"nodeType":"MemberExpression","messageId":"unsafeCall","endLine":442,"endColumn":26},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":1,"message":"Unsafe member access .onUpdate on an `error` typed value.","line":442,"column":18,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":442,"endColumn":26},{"ruleId":"@typescript-eslint/no-unsafe-call","severity":1,"message":"Unsafe call of a(n) `error` type typed value.","line":443,"column":5,"nodeType":"MemberExpression","messageId":"unsafeCall","endLine":443,"endColumn":17},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":1,"message":"Unsafe member access .close on an `error` typed value.","line":443,"column":12,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":443,"endColumn":17},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":1,"message":"Unsafe assignment of an error typed value.","line":454,"column":11,"nodeType":"VariableDeclarator","messageId":"anyAssignment","endLine":454,"endColumn":54},{"ruleId":"@typescript-eslint/no-unsafe-call","severity":1,"message":"Unsafe call of a(n) `error` type typed value.","line":455,"column":5,"nodeType":"MemberExpression","messageId":"unsafeCall","endLine":455,"endColumn":17},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":1,"message":"Unsafe member access .close on an `error` typed value.","line":455,"column":12,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":455,"endColumn":17}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":20,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { beforeEach, describe, expect, it, vi } from 'vitest';\n\nvi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }));\nvi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn() }));\n\nimport { invoke } from '@tauri-apps/api/core';\nimport { listen } from '@tauri-apps/api/event';\n\nimport {\n createTauriAPI,\n initializeTauriAPI,\n isTauriEnvironment,\n type TauriInvoke,\n type TauriListen,\n} from './tauri-adapter';\nimport type { AudioChunk, Meeting, Summary, TranscriptUpdate, UserPreferences } from './types';\nimport { meetingCache } from '@/lib/cache/meeting-cache';\n\ntype InvokeMock = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;\ntype ListenMock = (\n event: string,\n handler: (event: { payload: unknown }) => void\n) => Promise<() => void>;\n\nfunction createMocks() {\n const invoke = vi.fn<Parameters<InvokeMock>, ReturnType<InvokeMock>>();\n const listen = vi\n .fn<Parameters<ListenMock>, ReturnType<ListenMock>>()\n .mockResolvedValue(() => {});\n return { invoke, listen };\n}\n\nfunction buildMeeting(id: string): Meeting {\n return {\n id,\n title: `Meeting ${id}`,\n state: 'created',\n created_at: Date.now() / 1000,\n duration_seconds: 0,\n segments: [],\n metadata: {},\n };\n}\n\nfunction buildSummary(meetingId: string): Summary {\n return {\n meeting_id: meetingId,\n executive_summary: 'Test summary',\n key_points: [],\n action_items: [],\n model_version: 'test-v1',\n generated_at: Date.now() / 1000,\n };\n}\n\nfunction buildPreferences(aiTemplate?: UserPreferences['ai_template']): UserPreferences {\n return {\n server_host: 'localhost',\n server_port: '50051',\n simulate_transcription: false,\n default_export_format: 'markdown',\n default_export_location: '',\n completed_tasks: [],\n speaker_names: [],\n tags: [],\n ai_config: { provider: 'anthropic', model_id: 'claude-3-haiku' },\n audio_devices: { input_device_id: '', output_device_id: '' },\n ai_template: aiTemplate ?? {\n tone: 'professional',\n format: 'bullet_points',\n verbosity: 'balanced',\n },\n integrations: [],\n sync_notifications: { enabled: false, on_sync_complete: false, on_sync_error: false },\n sync_scheduler_paused: false,\n sync_history: [],\n meetings_project_scope: 'active',\n meetings_project_ids: [],\n tasks_project_scope: 'active',\n tasks_project_ids: [],\n };\n}\n\ndescribe('tauri-adapter mapping', () => {\n it('maps listMeetings args to snake_case', async () => {\n const { invoke, listen } = createMocks();\n invoke.mockResolvedValue({ meetings: [], total_count: 0 });\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n await api.listMeetings({\n states: ['recording'],\n limit: 5,\n offset: 10,\n sort_order: 'newest',\n });\n\n expect(invoke).toHaveBeenCalledWith('list_meetings', {\n states: [2],\n limit: 5,\n offset: 10,\n sort_order: 1,\n project_id: undefined,\n project_ids: [],\n });\n });\n\n it('maps identity commands with expected payloads', async () => {\n const { invoke, listen } = createMocks();\n invoke.mockResolvedValueOnce({ user_id: 'u1', display_name: 'Local User' });\n invoke.mockResolvedValueOnce({ workspaces: [] });\n invoke.mockResolvedValueOnce({ success: true });\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n await api.getCurrentUser();\n await api.listWorkspaces();\n await api.switchWorkspace('w1');\n\n expect(invoke).toHaveBeenCalledWith('get_current_user');\n expect(invoke).toHaveBeenCalledWith('list_workspaces');\n expect(invoke).toHaveBeenCalledWith('switch_workspace', { workspace_id: 'w1' });\n });\n\n it('maps auth login commands with expected payloads', async () => {\n const { invoke, listen } = createMocks();\n invoke.mockResolvedValueOnce({ auth_url: 'https://auth.example.com', state: 'state123' });\n invoke.mockResolvedValueOnce({\n success: true,\n user_id: 'u1',\n workspace_id: 'w1',\n display_name: 'Test User',\n email: 'test@example.com',\n });\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n\n const authResult = await api.initiateAuthLogin('google', 'noteflow://callback');\n expect(authResult).toEqual({ auth_url: 'https://auth.example.com', state: 'state123' });\n expect(invoke).toHaveBeenCalledWith('initiate_auth_login', {\n provider: 'google',\n redirect_uri: 'noteflow://callback',\n });\n\n const completeResult = await api.completeAuthLogin('google', 'auth-code', 'state123');\n expect(completeResult.success).toBe(true);\n expect(completeResult.user_id).toBe('u1');\n expect(invoke).toHaveBeenCalledWith('complete_auth_login', {\n provider: 'google',\n code: 'auth-code',\n state: 'state123',\n });\n });\n\n it('maps initiateAuthLogin without redirect_uri', async () => {\n const { invoke, listen } = createMocks();\n invoke.mockResolvedValueOnce({ auth_url: 'https://auth.example.com', state: 'state456' });\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n await api.initiateAuthLogin('outlook');\n\n expect(invoke).toHaveBeenCalledWith('initiate_auth_login', {\n provider: 'outlook',\n redirect_uri: undefined,\n });\n });\n\n it('maps logout command with optional provider', async () => {\n const { invoke, listen } = createMocks();\n invoke\n .mockResolvedValueOnce({ success: true, tokens_revoked: true })\n .mockResolvedValueOnce({ success: true, tokens_revoked: false, revocation_error: 'timeout' });\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n\n // Logout specific provider\n const result1 = await api.logout('google');\n expect(result1.success).toBe(true);\n expect(result1.tokens_revoked).toBe(true);\n expect(invoke).toHaveBeenCalledWith('logout', { provider: 'google' });\n\n // Logout all providers\n const result2 = await api.logout();\n expect(result2.success).toBe(true);\n expect(result2.tokens_revoked).toBe(false);\n expect(result2.revocation_error).toBe('timeout');\n expect(invoke).toHaveBeenCalledWith('logout', { provider: undefined });\n });\n\n it('handles completeAuthLogin failure response', async () => {\n const { invoke, listen } = createMocks();\n invoke.mockResolvedValueOnce({\n success: false,\n error_message: 'Invalid authorization code',\n });\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n const result = await api.completeAuthLogin('google', 'bad-code', 'state');\n\n expect(result.success).toBe(false);\n expect(result.error_message).toBe('Invalid authorization code');\n expect(result.user_id).toBeUndefined();\n });\n\n it('maps meeting and annotation args to snake_case', async () => {\n const { invoke, listen } = createMocks();\n const meeting = buildMeeting('m1');\n invoke.mockResolvedValueOnce(meeting).mockResolvedValueOnce({ id: 'a1' });\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n await api.getMeeting({ meeting_id: 'm1', include_segments: true, include_summary: true });\n await api.addAnnotation({\n meeting_id: 'm1',\n annotation_type: 'decision',\n text: 'Ship it',\n start_time: 1.25,\n end_time: 2.5,\n segment_ids: [1, 2],\n });\n\n expect(invoke).toHaveBeenCalledWith('get_meeting', {\n meeting_id: 'm1',\n include_segments: true,\n include_summary: true,\n });\n expect(invoke).toHaveBeenCalledWith('add_annotation', {\n meeting_id: 'm1',\n annotation_type: 2,\n text: 'Ship it',\n start_time: 1.25,\n end_time: 2.5,\n segment_ids: [1, 2],\n });\n });\n\n it('normalizes delete responses', async () => {\n const { invoke, listen } = createMocks();\n invoke.mockResolvedValueOnce({ success: true }).mockResolvedValueOnce(true);\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n await expect(api.deleteMeeting('m1')).resolves.toBe(true);\n await expect(api.deleteAnnotation('a1')).resolves.toBe(true);\n\n expect(invoke).toHaveBeenCalledWith('delete_meeting', { meeting_id: 'm1' });\n expect(invoke).toHaveBeenCalledWith('delete_annotation', { annotation_id: 'a1' });\n });\n\n it('sends audio chunk with snake_case keys', async () => {\n const { invoke, listen } = createMocks();\n invoke.mockResolvedValue(undefined);\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n const stream = await api.startTranscription('m1');\n\n const chunk: AudioChunk = {\n meeting_id: 'm1',\n audio_data: new Float32Array([0.25, -0.25]),\n timestamp: 12.34,\n sample_rate: 48000,\n channels: 2,\n };\n\n stream.send(chunk);\n\n expect(invoke).toHaveBeenCalledWith('start_recording', { meeting_id: 'm1' });\n expect(invoke).toHaveBeenCalledWith('send_audio_chunk', {\n meeting_id: 'm1',\n audio_data: [0.25, -0.25],\n timestamp: 12.34,\n sample_rate: 48000,\n channels: 2,\n });\n });\n\n it('sends audio chunk without optional fields', async () => {\n const { invoke, listen } = createMocks();\n invoke.mockResolvedValue(undefined);\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n const stream = await api.startTranscription('m2');\n\n const chunk: AudioChunk = {\n meeting_id: 'm2',\n audio_data: new Float32Array([0.1]),\n timestamp: 1.23,\n };\n\n stream.send(chunk);\n\n const call = invoke.mock.calls.find((item) => item[0] === 'send_audio_chunk');\n expect(call).toBeDefined();\n const args = call?.[1] as Record<string, unknown>;\n expect(args).toMatchObject({\n meeting_id: 'm2',\n timestamp: 1.23,\n });\n const audioData = args.audio_data as number[] | undefined;\n expect(audioData).toHaveLength(1);\n expect(audioData?.[0]).toBeCloseTo(0.1, 5);\n });\n\n it('forwards transcript updates with full segment payload', async () => {\n let capturedHandler: ((event: { payload: TranscriptUpdate }) => void) | null = null;\n const invoke = vi\n .fn<Parameters<InvokeMock>, ReturnType<InvokeMock>>()\n .mockResolvedValue(undefined);\n const listen = vi\n .fn<Parameters<ListenMock>, ReturnType<ListenMock>>()\n .mockImplementation((_event, handler) => {\n capturedHandler = handler as (event: { payload: TranscriptUpdate }) => void;\n return Promise.resolve(() => {});\n });\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n const stream = await api.startTranscription('m1');\n\n const callback = vi.fn();\n await stream.onUpdate(callback);\n\n const payload: TranscriptUpdate = {\n meeting_id: 'm1',\n update_type: 'final',\n partial_text: undefined,\n segment: {\n segment_id: 12,\n text: 'Hello world',\n start_time: 1.2,\n end_time: 2.3,\n words: [\n { word: 'Hello', start_time: 1.2, end_time: 1.6, probability: 0.9 },\n { word: 'world', start_time: 1.6, end_time: 2.3, probability: 0.92 },\n ],\n language: 'en',\n language_confidence: 0.99,\n avg_logprob: -0.2,\n no_speech_prob: 0.01,\n speaker_id: 'SPEAKER_00',\n speaker_confidence: 0.95,\n },\n server_timestamp: 123.45,\n };\n\n if (!capturedHandler) {\n throw new Error('Transcript update handler not registered');\n }\n\n capturedHandler({ payload });\n\n expect(callback).toHaveBeenCalledWith(payload);\n });\n\n it('ignores transcript updates for other meetings', async () => {\n let capturedHandler: ((event: { payload: TranscriptUpdate }) => void) | null = null;\n const invoke = vi\n .fn<Parameters<InvokeMock>, ReturnType<InvokeMock>>()\n .mockResolvedValue(undefined);\n const listen = vi\n .fn<Parameters<ListenMock>, ReturnType<ListenMock>>()\n .mockImplementation((_event, handler) => {\n capturedHandler = handler as (event: { payload: TranscriptUpdate }) => void;\n return Promise.resolve(() => {});\n });\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n const stream = await api.startTranscription('m1');\n const callback = vi.fn();\n await stream.onUpdate(callback);\n\n capturedHandler?.({\n payload: {\n meeting_id: 'other',\n update_type: 'partial',\n partial_text: 'nope',\n server_timestamp: 1,\n },\n });\n\n expect(callback).not.toHaveBeenCalled();\n });\n\n it('maps connection and export commands with snake_case args', async () => {\n const { invoke, listen } = createMocks();\n invoke.mockResolvedValue({ version: '1.0.0' });\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n await api.connect('localhost:50051');\n await api.saveExportFile('content', 'Meeting Notes', 'md');\n\n expect(invoke).toHaveBeenCalledWith('connect', { server_url: 'localhost:50051' });\n expect(invoke).toHaveBeenCalledWith('save_export_file', {\n content: 'content',\n default_name: 'Meeting Notes',\n extension: 'md',\n });\n });\n\n it('maps audio device selection with snake_case args', async () => {\n const { invoke, listen } = createMocks();\n invoke.mockResolvedValue([]);\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n await api.listAudioDevices();\n await api.selectAudioDevice('input:0:Mic', true);\n\n expect(invoke).toHaveBeenCalledWith('list_audio_devices');\n expect(invoke).toHaveBeenCalledWith('select_audio_device', {\n device_id: 'input:0:Mic',\n is_input: true,\n });\n });\n\n it('maps playback commands with snake_case args', async () => {\n const { invoke, listen } = createMocks();\n invoke.mockResolvedValue({\n meeting_id: 'm1',\n position: 0,\n duration: 0,\n is_playing: true,\n is_paused: false,\n });\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n await api.startPlayback('m1', 12.5);\n await api.seekPlayback(30);\n await api.getPlaybackState();\n\n expect(invoke).toHaveBeenCalledWith('start_playback', {\n meeting_id: 'm1',\n start_time: 12.5,\n });\n expect(invoke).toHaveBeenCalledWith('seek_playback', { position: 30 });\n expect(invoke).toHaveBeenCalledWith('get_playback_state');\n });\n\n it('stops transcription stream on close', async () => {\n const { invoke, listen } = createMocks();\n const unlisten = vi.fn();\n listen.mockResolvedValueOnce(unlisten);\n invoke.mockResolvedValue(undefined);\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n const stream = await api.startTranscription('m1');\n\n await stream.onUpdate(() => {});\n stream.close();\n\n expect(unlisten).toHaveBeenCalled();\n expect(invoke).toHaveBeenCalledWith('stop_recording', { meeting_id: 'm1' });\n });\n\n it('stops transcription stream even without listeners', async () => {\n const { invoke, listen } = createMocks();\n invoke.mockResolvedValue(undefined);\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n const stream = await api.startTranscription('m1');\n stream.close();\n\n expect(invoke).toHaveBeenCalledWith('stop_recording', { meeting_id: 'm1' });\n });\n\n it('only caches meetings when list includes items', async () => {\n const { invoke, listen } = createMocks();\n const cacheSpy = vi.spyOn(meetingCache, 'cacheMeetings');\n\n invoke.mockResolvedValueOnce({ meetings: [], total_count: 0 });\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n await api.listMeetings({});\n expect(cacheSpy).not.toHaveBeenCalled();\n\n invoke.mockResolvedValueOnce({ meetings: [buildMeeting('m1')], total_count: 1 });\n await api.listMeetings({});\n expect(cacheSpy).toHaveBeenCalled();\n });\n\n it('returns false when delete meeting fails', async () => {\n const { invoke, listen } = createMocks();\n invoke.mockResolvedValueOnce({ success: false });\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n const result = await api.deleteMeeting('m1');\n\n expect(result).toBe(false);\n });\n\n it('generates summary with template options when available', async () => {\n const { invoke, listen } = createMocks();\n const summary = buildSummary('m1');\n\n invoke\n .mockResolvedValueOnce(\n buildPreferences({ tone: 'casual', format: 'narrative', verbosity: 'concise' })\n )\n .mockResolvedValueOnce(summary);\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n const result = await api.generateSummary('m1', true);\n\n expect(result).toEqual(summary);\n expect(invoke).toHaveBeenCalledWith('generate_summary', {\n meeting_id: 'm1',\n force_regenerate: true,\n options: { tone: 'casual', format: 'narrative', verbosity: 'concise' },\n });\n });\n\n it('generates summary even if preferences lookup fails', async () => {\n const { invoke, listen } = createMocks();\n const summary = buildSummary('m2');\n\n invoke.mockRejectedValueOnce(new Error('no prefs')).mockResolvedValueOnce(summary);\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n const result = await api.generateSummary('m2');\n\n expect(result).toEqual(summary);\n expect(invoke).toHaveBeenCalledWith('generate_summary', {\n meeting_id: 'm2',\n force_regenerate: false,\n options: undefined,\n });\n });\n\n it('covers additional adapter commands', async () => {\n const { invoke, listen } = createMocks();\n\n const annotation = {\n id: 'a1',\n meeting_id: 'm1',\n annotation_type: 'note',\n text: 'Note',\n start_time: 0,\n end_time: 1,\n segment_ids: [],\n created_at: 1,\n };\n\n const annotationResponses: Array<\n (typeof annotation)[] | { annotations: (typeof annotation)[] }\n > = [{ annotations: [annotation] }, [annotation]];\n\n invoke.mockImplementation(async (cmd) => {\n switch (cmd) {\n case 'list_annotations':\n return annotationResponses.shift();\n case 'get_annotation':\n return annotation;\n case 'update_annotation':\n return annotation;\n case 'export_transcript':\n return { content: 'data', format_name: 'Markdown', file_extension: '.md' };\n case 'save_export_file':\n return true;\n case 'list_audio_devices':\n return [];\n case 'get_default_audio_device':\n return null;\n case 'get_preferences':\n return buildPreferences();\n case 'get_cloud_consent_status':\n return { consent_granted: true };\n case 'get_trigger_status':\n return {\n enabled: false,\n is_snoozed: false,\n snooze_remaining_secs: 0,\n pending_trigger: null,\n };\n case 'accept_trigger':\n return buildMeeting('m9');\n case 'extract_entities':\n return { entities: [], total_count: 0, cached: false };\n case 'update_entity':\n return { id: 'e1', text: 'Entity', category: 'other', segment_ids: [], confidence: 1 };\n case 'delete_entity':\n return true;\n case 'list_calendar_events':\n return { events: [], total_count: 0 };\n case 'get_calendar_providers':\n return { providers: [] };\n case 'initiate_oauth':\n return { auth_url: 'https://auth', state: 'state' };\n case 'complete_oauth':\n return { success: true, error_message: '', integration_id: 'int-123' };\n case 'get_oauth_connection_status':\n return {\n connection: {\n provider: 'google',\n status: 'disconnected',\n email: '',\n expires_at: 0,\n error_message: '',\n integration_type: 'calendar',\n },\n };\n case 'disconnect_oauth':\n return { success: true };\n case 'register_webhook':\n return {\n id: 'w1',\n workspace_id: 'w1',\n name: 'Webhook',\n url: 'https://example.com',\n events: ['meeting.completed'],\n enabled: true,\n timeout_ms: 1000,\n max_retries: 3,\n created_at: 1,\n updated_at: 1,\n };\n case 'list_webhooks':\n return { webhooks: [], total_count: 0 };\n case 'update_webhook':\n return {\n id: 'w1',\n workspace_id: 'w1',\n name: 'Webhook',\n url: 'https://example.com',\n events: ['meeting.completed'],\n enabled: false,\n timeout_ms: 1000,\n max_retries: 3,\n created_at: 1,\n updated_at: 2,\n };\n case 'delete_webhook':\n return { success: true };\n case 'get_webhook_deliveries':\n return { deliveries: [], total_count: 0 };\n case 'start_integration_sync':\n return { sync_run_id: 's1', status: 'running' };\n case 'get_sync_status':\n return { status: 'success', items_synced: 1, items_total: 1, error_message: '' };\n case 'list_sync_history':\n return { runs: [], total_count: 0 };\n case 'get_recent_logs':\n return { logs: [], total_count: 0 };\n case 'get_performance_metrics':\n return {\n current: {\n timestamp: 1,\n cpu_percent: 0,\n memory_percent: 0,\n memory_mb: 0,\n disk_percent: 0,\n network_bytes_sent: 0,\n network_bytes_recv: 0,\n process_memory_mb: 0,\n active_connections: 0,\n },\n history: [],\n };\n case 'refine_speakers':\n return { job_id: 'job', status: 'queued', segments_updated: 0, speaker_ids: [] };\n case 'get_diarization_status':\n return { job_id: 'job', status: 'completed', segments_updated: 1, speaker_ids: [] };\n case 'rename_speaker':\n return { success: true };\n case 'cancel_diarization':\n return { success: true, error_message: '', status: 'cancelled' };\n default:\n return undefined;\n }\n });\n\n const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);\n\n const list1 = await api.listAnnotations('m1');\n const list2 = await api.listAnnotations('m1');\n expect(list1).toHaveLength(1);\n expect(list2).toHaveLength(1);\n\n await api.getAnnotation('a1');\n await api.updateAnnotation({ annotation_id: 'a1', text: 'Updated' });\n await api.exportTranscript('m1', 'markdown');\n await api.saveExportFile('content', 'Meeting', 'md');\n await api.listAudioDevices();\n await api.getDefaultAudioDevice(true);\n await api.selectAudioDevice('mic', true);\n await api.getPreferences();\n await api.savePreferences(buildPreferences());\n await api.grantCloudConsent();\n await api.revokeCloudConsent();\n await api.getCloudConsentStatus();\n await api.pausePlayback();\n await api.stopPlayback();\n await api.setTriggerEnabled(true);\n await api.snoozeTriggers(5);\n await api.resetSnooze();\n await api.getTriggerStatus();\n await api.dismissTrigger();\n await api.acceptTrigger('Title');\n await api.extractEntities('m1', true);\n await api.updateEntity('m1', 'e1', 'Entity', 'other');\n await api.deleteEntity('m1', 'e1');\n await api.listCalendarEvents(2, 5, 'google');\n await api.getCalendarProviders();\n await api.initiateCalendarAuth('google', 'redirect');\n await api.completeCalendarAuth('google', 'code', 'state');\n await api.getOAuthConnectionStatus('google');\n await api.disconnectCalendar('google');\n await api.registerWebhook({\n workspace_id: 'w1',\n name: 'Webhook',\n url: 'https://example.com',\n events: ['meeting.completed'],\n });\n await api.listWebhooks();\n await api.updateWebhook({ webhook_id: 'w1', name: 'Webhook' });\n await api.deleteWebhook('w1');\n await api.getWebhookDeliveries('w1', 10);\n await api.startIntegrationSync('int-1');\n await api.getSyncStatus('sync');\n await api.listSyncHistory('int-1', 10, 0);\n await api.getRecentLogs({ limit: 10 });\n await api.getPerformanceMetrics({ history_limit: 5 });\n await api.refineSpeakers('m1', 2);\n await api.getDiarizationJobStatus('job');\n await api.renameSpeaker('m1', 'old', 'new');\n await api.cancelDiarization('job');\n });\n});\n\ndescribe('tauri-adapter environment', () => {\n const invokeMock = vi.mocked(invoke);\n const listenMock = vi.mocked(listen);\n\n beforeEach(() => {\n invokeMock.mockReset();\n listenMock.mockReset();\n });\n\n it('detects tauri environment flags', () => {\n // @ts-expect-error intentionally unset\n vi.stubGlobal('window', undefined);\n expect(isTauriEnvironment()).toBe(false);\n vi.unstubAllGlobals();\n expect(isTauriEnvironment()).toBe(false);\n\n // @ts-expect-error set tauri flag\n (window as Record<string, unknown>).__TAURI__ = {};\n expect(isTauriEnvironment()).toBe(true);\n delete (window as Record<string, unknown>).__TAURI__;\n\n // @ts-expect-error set tauri internals flag\n (window as Record<string, unknown>).__TAURI_INTERNALS__ = {};\n expect(isTauriEnvironment()).toBe(true);\n delete (window as Record<string, unknown>).__TAURI_INTERNALS__;\n\n // @ts-expect-error set legacy flag\n (window as Record<string, unknown>).isTauri = true;\n expect(isTauriEnvironment()).toBe(true);\n delete (window as Record<string, unknown>).isTauri;\n });\n\n it('initializes tauri api when available', async () => {\n invokeMock.mockResolvedValueOnce(true);\n listenMock.mockResolvedValue(() => {});\n\n const api = await initializeTauriAPI();\n expect(api).toBeDefined();\n expect(invokeMock).toHaveBeenCalledWith('is_connected');\n });\n\n it('throws when tauri api is unavailable', async () => {\n invokeMock.mockRejectedValueOnce(new Error('no tauri'));\n\n await expect(initializeTauriAPI()).rejects.toThrow('Not running in Tauri environment');\n });\n\n it('throws a helpful error when invoke rejects with non-Error', async () => {\n invokeMock.mockRejectedValueOnce('no tauri');\n await expect(initializeTauriAPI()).rejects.toThrow('Not running in Tauri environment');\n });\n});\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/tauri-adapter.ts","messages":[{"ruleId":"@typescript-eslint/no-unsafe-argument","severity":1,"message":"Unsafe argument of type error typed assigned to a parameter of type `string | undefined`.","line":610,"column":47,"nodeType":"MemberExpression","messageId":"unsafeArgument","endLine":610,"endColumn":60}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/** Tauri API adapter implementing NoteFlowAPI via Rust backend IPC. */\nimport type { NoteFlowAPI, TranscriptionStream } from './interface';\nimport { Timing } from './constants';\nimport { TauriCommands, TauriEvents } from './tauri-constants';\n\n// Re-export TauriEvents for external consumers\nexport { TauriEvents } from './tauri-constants';\nimport {\n annotationTypeToGrpcEnum,\n exportFormatToGrpc,\n normalizeAnnotationList,\n normalizeSuccessResponse,\n sortOrderToGrpcEnum,\n stateToGrpcEnum,\n} from './helpers';\nimport { meetingCache } from '@/lib/cache/meeting-cache';\nimport { addClientLog } from '@/lib/client-logs';\nimport { clientLog } from '@/lib/client-log-events';\nimport type {\n AddAnnotationRequest,\n Annotation,\n AudioChunk,\n AudioDeviceInfo,\n AddProjectMemberRequest,\n CancelDiarizationResult,\n CompleteAuthLoginResponse,\n CompleteCalendarAuthResponse,\n ConnectionDiagnostics,\n CreateMeetingRequest,\n CreateProjectRequest,\n DeleteOidcProviderResponse,\n DeleteWebhookResponse,\n DiarizationJobStatus,\n DisconnectOAuthResponse,\n EffectiveServerUrl,\n ExportFormat,\n ExportResult,\n ExtractEntitiesResponse,\n ExtractedEntity,\n GetCalendarProvidersResponse,\n GetCurrentUserResponse,\n GetActiveProjectRequest,\n GetActiveProjectResponse,\n GetMeetingRequest,\n GetOAuthConnectionStatusResponse,\n GetProjectBySlugRequest,\n GetProjectRequest,\n GetPerformanceMetricsRequest,\n GetPerformanceMetricsResponse,\n GetRecentLogsRequest,\n GetRecentLogsResponse,\n GetSyncStatusResponse,\n GetUserIntegrationsResponse,\n GetWebhookDeliveriesResponse,\n InstalledAppInfo,\n InitiateAuthLoginResponse,\n InitiateCalendarAuthResponse,\n ListOidcPresetsResponse,\n ListOidcProvidersResponse,\n ListWorkspacesResponse,\n LogoutResponse,\n ListCalendarEventsResponse,\n ListMeetingsRequest,\n ListMeetingsResponse,\n ListProjectMembersRequest,\n ListProjectMembersResponse,\n ListProjectsRequest,\n ListProjectsResponse,\n ListSyncHistoryResponse,\n ListWebhooksResponse,\n Meeting,\n OidcProviderApi,\n PlaybackInfo,\n Project,\n ProjectMembership,\n RefreshOidcDiscoveryResponse,\n RegisteredWebhook,\n RegisterOidcProviderRequest,\n RegisterWebhookRequest,\n RemoveProjectMemberRequest,\n RemoveProjectMemberResponse,\n ServerInfo,\n SetActiveProjectRequest,\n StartIntegrationSyncResponse,\n SwitchWorkspaceResponse,\n SummarizationOptions,\n Summary,\n TranscriptUpdate,\n TriggerStatus,\n UpdateAnnotationRequest,\n UpdateOidcProviderRequest,\n UpdateProjectMemberRoleRequest,\n UpdateProjectRequest,\n UpdateWebhookRequest,\n UserPreferences,\n} from './types';\n\n/** Type-safe wrapper for Tauri's invoke function. */\nexport type TauriInvoke = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;\n/** Type-safe wrapper for Tauri's event system. */\nexport type TauriListen = <T>(\n event: string,\n handler: (event: { payload: T }) => void\n) => Promise<() => void>;\n\n/** Error callback type for stream errors. */\nexport type StreamErrorCallback = (error: { code: string; message: string }) => void;\n\n/** Congestion state for UI feedback. */\nexport interface CongestionState {\n /** Whether the stream is currently showing congestion to the user. */\n isBuffering: boolean;\n /** Duration of congestion in milliseconds. */\n duration: number;\n}\n\n/** Congestion callback type for stream health updates. */\nexport type CongestionCallback = (state: CongestionState) => void;\n\n/** Consecutive failure threshold before emitting stream error. */\nexport const CONSECUTIVE_FAILURE_THRESHOLD = 3;\n\n/** Threshold in milliseconds before showing buffering indicator (2 seconds). */\nexport const CONGESTION_DISPLAY_THRESHOLD_MS = Timing.TWO_SECONDS_MS;\n\nconst RECORDING_BLOCKED_PREFIX = 'Recording blocked by app policy';\n\nfunction recordingBlockedDetails(error: unknown): {\n ruleId?: string;\n ruleLabel?: string;\n appName?: string;\n} | null {\n const message =\n error instanceof Error\n ? error.message\n : typeof error === 'string'\n ? error\n : JSON.stringify(error);\n\n if (!message.includes(RECORDING_BLOCKED_PREFIX)) {\n return null;\n }\n\n const details = message.split(RECORDING_BLOCKED_PREFIX)[1] ?? '';\n const cleaned = details.replace(/^\\\\s*:\\\\s*/, '');\n const parts = cleaned\n .split(',')\n .map((part) => part.trim())\n .filter(Boolean);\n\n const extracted: { ruleId?: string; ruleLabel?: string; appName?: string } = {};\n for (const part of parts) {\n if (part.startsWith('rule_id=')) {\n extracted.ruleId = part.replace('rule_id=', '').trim();\n } else if (part.startsWith('rule_label=')) {\n extracted.ruleLabel = part.replace('rule_label=', '').trim();\n } else if (part.startsWith('app_name=')) {\n extracted.appName = part.replace('app_name=', '').trim();\n }\n }\n\n return extracted;\n}\n\n/** Real-time transcription stream using Tauri events. */\nexport class TauriTranscriptionStream implements TranscriptionStream {\n private unlistenFn: (() => void) | null = null;\n private healthUnlistenFn: (() => void) | null = null;\n private errorCallback: StreamErrorCallback | null = null;\n private congestionCallback: CongestionCallback | null = null;\n private consecutiveFailures = 0;\n private hasEmittedError = false;\n\n /** Latest ack_sequence received from server (for debugging/monitoring). */\n private lastAckedSequence = 0;\n\n /** Timestamp when congestion started (null if not congested). */\n private congestionStartTime: number | null = null;\n\n /** Whether buffering indicator is currently shown. */\n private isShowingBuffering = false;\n\n constructor(\n private meetingId: string,\n private invoke: TauriInvoke,\n private listen: TauriListen\n ) {}\n\n /** Get the last acknowledged chunk sequence number. */\n getLastAckedSequence(): number {\n return this.lastAckedSequence;\n }\n\n send(chunk: AudioChunk): void {\n const args: Record<string, unknown> = {\n meeting_id: chunk.meeting_id,\n audio_data: Array.from(chunk.audio_data),\n timestamp: chunk.timestamp,\n };\n if (typeof chunk.sample_rate === 'number') {\n args.sample_rate = chunk.sample_rate;\n }\n if (typeof chunk.channels === 'number') {\n args.channels = chunk.channels;\n }\n\n this.invoke(TauriCommands.SEND_AUDIO_CHUNK, args)\n .then(() => {\n // Reset failure counter on success\n this.consecutiveFailures = 0;\n })\n .catch((err: unknown) => {\n this.consecutiveFailures++;\n const message = err instanceof Error ? err.message : String(err);\n // biome-ignore lint/suspicious/noConsole: Error logging for stream failures is intentional\n console.error(`[TauriTranscriptionStream] send_audio_chunk failed: ${message}`);\n\n // Emit error callback once after threshold consecutive failures\n if (\n this.consecutiveFailures >= CONSECUTIVE_FAILURE_THRESHOLD &&\n !this.hasEmittedError &&\n this.errorCallback\n ) {\n this.hasEmittedError = true;\n this.errorCallback({\n code: 'stream_send_failed',\n message: `Audio streaming interrupted after ${this.consecutiveFailures} failures: ${message}`,\n });\n }\n });\n }\n\n async onUpdate(callback: (update: TranscriptUpdate) => void): Promise<void> {\n this.unlistenFn = await this.listen<TranscriptUpdate>(\n TauriEvents.TRANSCRIPT_UPDATE,\n (event) => {\n if (event.payload.meeting_id === this.meetingId) {\n // Track latest ack_sequence for monitoring\n if (\n typeof event.payload.ack_sequence === 'number' &&\n event.payload.ack_sequence > this.lastAckedSequence\n ) {\n this.lastAckedSequence = event.payload.ack_sequence;\n }\n callback(event.payload);\n }\n }\n );\n }\n\n /** Register callback for stream errors (connection failures, etc.). */\n onError(callback: StreamErrorCallback): void {\n this.errorCallback = callback;\n }\n\n /** Register callback for congestion state updates (buffering indicator). */\n onCongestion(callback: CongestionCallback): void {\n this.congestionCallback = callback;\n // Start listening for stream_health events\n this.startHealthListener();\n }\n\n /** Start listening for stream_health events from the Rust backend. */\n private startHealthListener(): void {\n if (this.healthUnlistenFn) {\n return;\n } // Already listening\n\n this.listen<{\n meeting_id: string;\n is_congested: boolean;\n processing_delay_ms: number;\n queue_depth: number;\n congested_duration_ms: number;\n }>(TauriEvents.STREAM_HEALTH, (event) => {\n if (event.payload.meeting_id !== this.meetingId) {\n return;\n }\n\n const { is_congested } = event.payload;\n\n if (is_congested) {\n // Start tracking congestion if not already\n this.congestionStartTime ??= Date.now();\n const duration = Date.now() - this.congestionStartTime;\n\n // Only show buffering after threshold is exceeded\n if (duration >= CONGESTION_DISPLAY_THRESHOLD_MS && !this.isShowingBuffering) {\n this.isShowingBuffering = true;\n this.congestionCallback?.({ isBuffering: true, duration });\n } else if (this.isShowingBuffering) {\n // Update duration while showing\n this.congestionCallback?.({ isBuffering: true, duration });\n }\n } else {\n // Congestion cleared\n if (this.isShowingBuffering) {\n this.isShowingBuffering = false;\n this.congestionCallback?.({ isBuffering: false, duration: 0 });\n }\n this.congestionStartTime = null;\n }\n })\n .then((unlisten) => {\n this.healthUnlistenFn = unlisten;\n })\n .catch(() => {\n // Stream health listener failed - non-critical, monitoring degraded\n });\n }\n\n close(): void {\n if (this.unlistenFn) {\n this.unlistenFn();\n this.unlistenFn = null;\n }\n if (this.healthUnlistenFn) {\n this.healthUnlistenFn();\n this.healthUnlistenFn = null;\n }\n // Reset congestion state\n this.congestionStartTime = null;\n this.isShowingBuffering = false;\n\n this.invoke(TauriCommands.STOP_RECORDING, { meeting_id: this.meetingId }).catch(\n (err: unknown) => {\n const message = err instanceof Error ? err.message : String(err);\n // biome-ignore lint/suspicious/noConsole: Error logging for stream failures is intentional\n console.error(`[TauriTranscriptionStream] stop_recording failed: ${message}`);\n // Emit error so UI can show notification\n if (this.errorCallback) {\n this.errorCallback({\n code: 'stream_close_failed',\n message: `Failed to stop recording: ${message}`,\n });\n }\n }\n );\n }\n}\n\n/** Creates a Tauri API adapter instance. */\nexport function createTauriAPI(invoke: TauriInvoke, listen: TauriListen): NoteFlowAPI {\n return {\n async getServerInfo(): Promise<ServerInfo> {\n return invoke<ServerInfo>(TauriCommands.GET_SERVER_INFO);\n },\n async connect(serverUrl?: string): Promise<ServerInfo> {\n try {\n const info = await invoke<ServerInfo>(TauriCommands.CONNECT, { server_url: serverUrl });\n clientLog.connected(serverUrl);\n return info;\n } catch (error) {\n clientLog.connectionFailed(error instanceof Error ? error.message : String(error));\n throw error;\n }\n },\n async disconnect(): Promise<void> {\n await invoke(TauriCommands.DISCONNECT);\n clientLog.disconnected();\n },\n async isConnected(): Promise<boolean> {\n return invoke<boolean>(TauriCommands.IS_CONNECTED);\n },\n async getEffectiveServerUrl(): Promise<EffectiveServerUrl> {\n return invoke<EffectiveServerUrl>(TauriCommands.GET_EFFECTIVE_SERVER_URL);\n },\n\n async getCurrentUser(): Promise<GetCurrentUserResponse> {\n return invoke<GetCurrentUserResponse>(TauriCommands.GET_CURRENT_USER);\n },\n\n async listWorkspaces(): Promise<ListWorkspacesResponse> {\n return invoke<ListWorkspacesResponse>(TauriCommands.LIST_WORKSPACES);\n },\n\n async switchWorkspace(workspaceId: string): Promise<SwitchWorkspaceResponse> {\n return invoke<SwitchWorkspaceResponse>(TauriCommands.SWITCH_WORKSPACE, {\n workspace_id: workspaceId,\n });\n },\n\n async initiateAuthLogin(\n provider: string,\n redirectUri?: string\n ): Promise<InitiateAuthLoginResponse> {\n return invoke<InitiateAuthLoginResponse>(TauriCommands.INITIATE_AUTH_LOGIN, {\n provider,\n redirect_uri: redirectUri,\n });\n },\n\n async completeAuthLogin(\n provider: string,\n code: string,\n state: string\n ): Promise<CompleteAuthLoginResponse> {\n const response = await invoke<CompleteAuthLoginResponse>(TauriCommands.COMPLETE_AUTH_LOGIN, {\n provider,\n code,\n state,\n });\n clientLog.loginCompleted(provider);\n return response;\n },\n\n async logout(provider?: string): Promise<LogoutResponse> {\n const response = await invoke<LogoutResponse>(TauriCommands.LOGOUT, {\n provider,\n });\n clientLog.loggedOut(provider);\n return response;\n },\n\n async createProject(request: CreateProjectRequest): Promise<Project> {\n return invoke<Project>(TauriCommands.CREATE_PROJECT, {\n request,\n });\n },\n\n async getProject(request: GetProjectRequest): Promise<Project> {\n return invoke<Project>(TauriCommands.GET_PROJECT, {\n project_id: request.project_id,\n });\n },\n\n async getProjectBySlug(request: GetProjectBySlugRequest): Promise<Project> {\n return invoke<Project>(TauriCommands.GET_PROJECT_BY_SLUG, {\n workspace_id: request.workspace_id,\n slug: request.slug,\n });\n },\n\n async listProjects(request: ListProjectsRequest): Promise<ListProjectsResponse> {\n return invoke<ListProjectsResponse>(TauriCommands.LIST_PROJECTS, {\n workspace_id: request.workspace_id,\n include_archived: request.include_archived ?? false,\n limit: request.limit,\n offset: request.offset,\n });\n },\n\n async updateProject(request: UpdateProjectRequest): Promise<Project> {\n return invoke<Project>(TauriCommands.UPDATE_PROJECT, {\n request,\n });\n },\n\n async archiveProject(projectId: string): Promise<Project> {\n return invoke<Project>(TauriCommands.ARCHIVE_PROJECT, {\n project_id: projectId,\n });\n },\n\n async restoreProject(projectId: string): Promise<Project> {\n return invoke<Project>(TauriCommands.RESTORE_PROJECT, {\n project_id: projectId,\n });\n },\n\n async deleteProject(projectId: string): Promise<boolean> {\n const response = await invoke<{ success: boolean }>(TauriCommands.DELETE_PROJECT, {\n project_id: projectId,\n });\n return normalizeSuccessResponse(response);\n },\n\n async setActiveProject(request: SetActiveProjectRequest): Promise<void> {\n await invoke(TauriCommands.SET_ACTIVE_PROJECT, {\n workspace_id: request.workspace_id,\n project_id: request.project_id ?? '',\n });\n },\n\n async getActiveProject(request: GetActiveProjectRequest): Promise<GetActiveProjectResponse> {\n return invoke<GetActiveProjectResponse>(TauriCommands.GET_ACTIVE_PROJECT, {\n workspace_id: request.workspace_id,\n });\n },\n\n async addProjectMember(request: AddProjectMemberRequest): Promise<ProjectMembership> {\n return invoke<ProjectMembership>(TauriCommands.ADD_PROJECT_MEMBER, {\n request,\n });\n },\n\n async updateProjectMemberRole(\n request: UpdateProjectMemberRoleRequest\n ): Promise<ProjectMembership> {\n return invoke<ProjectMembership>(TauriCommands.UPDATE_PROJECT_MEMBER_ROLE, {\n request,\n });\n },\n\n async removeProjectMember(\n request: RemoveProjectMemberRequest\n ): Promise<RemoveProjectMemberResponse> {\n return invoke<RemoveProjectMemberResponse>(TauriCommands.REMOVE_PROJECT_MEMBER, {\n request,\n });\n },\n\n async listProjectMembers(\n request: ListProjectMembersRequest\n ): Promise<ListProjectMembersResponse> {\n return invoke<ListProjectMembersResponse>(TauriCommands.LIST_PROJECT_MEMBERS, {\n project_id: request.project_id,\n limit: request.limit,\n offset: request.offset,\n });\n },\n\n async createMeeting(request: CreateMeetingRequest): Promise<Meeting> {\n const meeting = await invoke<Meeting>(TauriCommands.CREATE_MEETING, {\n title: request.title,\n metadata: request.metadata ?? {},\n project_id: request.project_id,\n });\n meetingCache.cacheMeeting(meeting);\n clientLog.meetingCreated(meeting.id, meeting.title);\n return meeting;\n },\n async listMeetings(request: ListMeetingsRequest): Promise<ListMeetingsResponse> {\n const response = await invoke<ListMeetingsResponse>(TauriCommands.LIST_MEETINGS, {\n states: request.states?.map(stateToGrpcEnum) ?? [],\n limit: request.limit ?? 50,\n offset: request.offset ?? 0,\n sort_order: sortOrderToGrpcEnum(request.sort_order),\n project_id: request.project_id,\n project_ids: request.project_ids ?? [],\n });\n if (response.meetings?.length) {\n meetingCache.cacheMeetings(response.meetings);\n }\n return response;\n },\n async getMeeting(request: GetMeetingRequest): Promise<Meeting> {\n const meeting = await invoke<Meeting>(TauriCommands.GET_MEETING, {\n meeting_id: request.meeting_id,\n include_segments: request.include_segments ?? false,\n include_summary: request.include_summary ?? false,\n });\n meetingCache.cacheMeeting(meeting);\n return meeting;\n },\n async stopMeeting(meetingId: string): Promise<Meeting> {\n const meeting = await invoke<Meeting>(TauriCommands.STOP_MEETING, {\n meeting_id: meetingId,\n });\n meetingCache.cacheMeeting(meeting);\n clientLog.meetingStopped(meeting.id, meeting.title);\n return meeting;\n },\n async deleteMeeting(meetingId: string): Promise<boolean> {\n const result = normalizeSuccessResponse(\n await invoke<boolean | { success: boolean }>(TauriCommands.DELETE_MEETING, {\n meeting_id: meetingId,\n })\n );\n if (result) {\n meetingCache.removeMeeting(meetingId);\n clientLog.meetingDeleted(meetingId);\n }\n return result;\n },\n\n async startTranscription(meetingId: string): Promise<TranscriptionStream> {\n try {\n await invoke(TauriCommands.START_RECORDING, { meeting_id: meetingId });\n return new TauriTranscriptionStream(meetingId, invoke, listen);\n } catch (error) {\n const blocked = recordingBlockedDetails(error);\n if (blocked) {\n addClientLog({\n level: 'warning',\n source: 'system',\n message: RECORDING_BLOCKED_PREFIX,\n metadata: {\n rule_id: blocked.ruleId ?? '',\n rule_label: blocked.ruleLabel ?? '',\n app_name: blocked.appName ?? '',\n },\n });\n }\n throw error;\n }\n },\n\n async generateSummary(meetingId: string, forceRegenerate?: boolean): Promise<Summary> {\n let options: SummarizationOptions | undefined;\n try {\n const prefs = await invoke<UserPreferences>(TauriCommands.GET_PREFERENCES);\n if (prefs?.ai_template) {\n options = {\n tone: prefs.ai_template.tone,\n format: prefs.ai_template.format,\n verbosity: prefs.ai_template.verbosity,\n };\n }\n } catch {\n /* Preferences unavailable */\n }\n clientLog.summarizing(meetingId);\n try {\n const summary = await invoke<Summary>(TauriCommands.GENERATE_SUMMARY, {\n meeting_id: meetingId,\n force_regenerate: forceRegenerate ?? false,\n options,\n });\n clientLog.summaryGenerated(meetingId, summary.model);\n return summary;\n } catch (error) {\n clientLog.summaryFailed(meetingId, error instanceof Error ? error.message : String(error));\n throw error;\n }\n },\n\n async grantCloudConsent(): Promise<void> {\n await invoke(TauriCommands.GRANT_CLOUD_CONSENT);\n clientLog.cloudConsentGranted();\n },\n async revokeCloudConsent(): Promise<void> {\n await invoke(TauriCommands.REVOKE_CLOUD_CONSENT);\n clientLog.cloudConsentRevoked();\n },\n async getCloudConsentStatus(): Promise<{ consentGranted: boolean }> {\n return invoke<{ consent_granted: boolean }>(TauriCommands.GET_CLOUD_CONSENT_STATUS).then(\n (r) => ({ consentGranted: r.consent_granted })\n );\n },\n\n async listAnnotations(\n meetingId: string,\n startTime?: number,\n endTime?: number\n ): Promise<Annotation[]> {\n return normalizeAnnotationList(\n await invoke<Annotation[] | { annotations: Annotation[] }>(TauriCommands.LIST_ANNOTATIONS, {\n meeting_id: meetingId,\n start_time: startTime ?? 0,\n end_time: endTime ?? 0,\n })\n );\n },\n async addAnnotation(request: AddAnnotationRequest): Promise<Annotation> {\n return invoke<Annotation>(TauriCommands.ADD_ANNOTATION, {\n meeting_id: request.meeting_id,\n annotation_type: annotationTypeToGrpcEnum(request.annotation_type),\n text: request.text,\n start_time: request.start_time,\n end_time: request.end_time,\n segment_ids: request.segment_ids ?? [],\n });\n },\n async getAnnotation(annotationId: string): Promise<Annotation> {\n return invoke<Annotation>(TauriCommands.GET_ANNOTATION, { annotation_id: annotationId });\n },\n async updateAnnotation(request: UpdateAnnotationRequest): Promise<Annotation> {\n return invoke<Annotation>(TauriCommands.UPDATE_ANNOTATION, {\n annotation_id: request.annotation_id,\n annotation_type: request.annotation_type\n ? annotationTypeToGrpcEnum(request.annotation_type)\n : undefined,\n text: request.text,\n start_time: request.start_time,\n end_time: request.end_time,\n segment_ids: request.segment_ids,\n });\n },\n async deleteAnnotation(annotationId: string): Promise<boolean> {\n return normalizeSuccessResponse(\n await invoke<boolean | { success: boolean }>(TauriCommands.DELETE_ANNOTATION, {\n annotation_id: annotationId,\n })\n );\n },\n\n async exportTranscript(meetingId: string, format: ExportFormat): Promise<ExportResult> {\n clientLog.exportStarted(meetingId, format);\n try {\n const result = await invoke<ExportResult>(TauriCommands.EXPORT_TRANSCRIPT, {\n meeting_id: meetingId,\n format: exportFormatToGrpc(format),\n });\n clientLog.exportCompleted(meetingId, format);\n return result;\n } catch (error) {\n clientLog.exportFailed(meetingId, format, error instanceof Error ? error.message : String(error));\n throw error;\n }\n },\n async saveExportFile(\n content: string,\n defaultName: string,\n extension: string\n ): Promise<boolean> {\n return invoke<boolean>(TauriCommands.SAVE_EXPORT_FILE, {\n content,\n default_name: defaultName,\n extension,\n });\n },\n\n async startPlayback(meetingId: string, startTime?: number): Promise<void> {\n await invoke(TauriCommands.START_PLAYBACK, { meeting_id: meetingId, start_time: startTime });\n },\n async pausePlayback(): Promise<void> {\n await invoke(TauriCommands.PAUSE_PLAYBACK);\n },\n async stopPlayback(): Promise<void> {\n await invoke(TauriCommands.STOP_PLAYBACK);\n },\n async seekPlayback(position: number): Promise<PlaybackInfo> {\n return invoke<PlaybackInfo>(TauriCommands.SEEK_PLAYBACK, { position });\n },\n async getPlaybackState(): Promise<PlaybackInfo> {\n return invoke<PlaybackInfo>(TauriCommands.GET_PLAYBACK_STATE);\n },\n\n async refineSpeakers(meetingId: string, numSpeakers?: number): Promise<DiarizationJobStatus> {\n const status = await invoke<DiarizationJobStatus>(TauriCommands.REFINE_SPEAKERS, {\n meeting_id: meetingId,\n num_speakers: numSpeakers ?? 0,\n });\n clientLog.diarizationStarted(meetingId, status.job_id);\n return status;\n },\n async getDiarizationJobStatus(jobId: string): Promise<DiarizationJobStatus> {\n return invoke<DiarizationJobStatus>(TauriCommands.GET_DIARIZATION_STATUS, { job_id: jobId });\n },\n async renameSpeaker(\n meetingId: string,\n oldSpeakerId: string,\n newName: string\n ): Promise<boolean> {\n const result = await invoke<{ success: boolean }>(TauriCommands.RENAME_SPEAKER, {\n meeting_id: meetingId,\n old_speaker_id: oldSpeakerId,\n new_speaker_name: newName,\n });\n if (result.success) {\n clientLog.speakerRenamed(meetingId, oldSpeakerId, newName);\n }\n return result.success;\n },\n async cancelDiarization(jobId: string): Promise<CancelDiarizationResult> {\n return invoke<CancelDiarizationResult>(TauriCommands.CANCEL_DIARIZATION, { job_id: jobId });\n },\n async getActiveDiarizationJobs(): Promise<DiarizationJobStatus[]> {\n return invoke<DiarizationJobStatus[]>(TauriCommands.GET_ACTIVE_DIARIZATION_JOBS);\n },\n\n async getPreferences(): Promise<UserPreferences> {\n return invoke<UserPreferences>(TauriCommands.GET_PREFERENCES);\n },\n async savePreferences(preferences: UserPreferences): Promise<void> {\n await invoke(TauriCommands.SAVE_PREFERENCES, { preferences });\n },\n\n async listAudioDevices(): Promise<AudioDeviceInfo[]> {\n return invoke<AudioDeviceInfo[]>(TauriCommands.LIST_AUDIO_DEVICES);\n },\n async getDefaultAudioDevice(isInput: boolean): Promise<AudioDeviceInfo | null> {\n return invoke<AudioDeviceInfo | null>(TauriCommands.GET_DEFAULT_AUDIO_DEVICE, {\n is_input: isInput,\n });\n },\n async selectAudioDevice(deviceId: string, isInput: boolean): Promise<void> {\n await invoke(TauriCommands.SELECT_AUDIO_DEVICE, { device_id: deviceId, is_input: isInput });\n },\n\n async listInstalledApps(options?: { commonOnly?: boolean }): Promise<InstalledAppInfo[]> {\n return invoke<InstalledAppInfo[]>(TauriCommands.LIST_INSTALLED_APPS, {\n common_only: options?.commonOnly ?? false,\n });\n },\n\n async setTriggerEnabled(enabled: boolean): Promise<void> {\n await invoke(TauriCommands.SET_TRIGGER_ENABLED, { enabled });\n },\n async snoozeTriggers(minutes?: number): Promise<void> {\n await invoke(TauriCommands.SNOOZE_TRIGGERS, { minutes });\n clientLog.triggersSnoozed(minutes);\n },\n async resetSnooze(): Promise<void> {\n await invoke(TauriCommands.RESET_SNOOZE);\n clientLog.triggerSnoozeCleared();\n },\n async getTriggerStatus(): Promise<TriggerStatus> {\n return invoke<TriggerStatus>(TauriCommands.GET_TRIGGER_STATUS);\n },\n async dismissTrigger(): Promise<void> {\n await invoke(TauriCommands.DISMISS_TRIGGER);\n },\n async acceptTrigger(title?: string): Promise<Meeting> {\n return invoke<Meeting>(TauriCommands.ACCEPT_TRIGGER, { title });\n },\n\n async extractEntities(\n meetingId: string,\n forceRefresh?: boolean\n ): Promise<ExtractEntitiesResponse> {\n const response = await invoke<ExtractEntitiesResponse>(TauriCommands.EXTRACT_ENTITIES, {\n meeting_id: meetingId,\n force_refresh: forceRefresh ?? false,\n });\n clientLog.entitiesExtracted(meetingId, response.entities?.length ?? 0);\n return response;\n },\n async updateEntity(\n meetingId: string,\n entityId: string,\n text?: string,\n category?: string\n ): Promise<ExtractedEntity> {\n return invoke<ExtractedEntity>(TauriCommands.UPDATE_ENTITY, {\n meeting_id: meetingId,\n entity_id: entityId,\n text,\n category,\n });\n },\n async deleteEntity(meetingId: string, entityId: string): Promise<boolean> {\n return invoke<boolean>(TauriCommands.DELETE_ENTITY, {\n meeting_id: meetingId,\n entity_id: entityId,\n });\n },\n\n async listCalendarEvents(\n hoursAhead?: number,\n limit?: number,\n provider?: string\n ): Promise<ListCalendarEventsResponse> {\n return invoke<ListCalendarEventsResponse>(TauriCommands.LIST_CALENDAR_EVENTS, {\n hours_ahead: hoursAhead,\n limit,\n provider,\n });\n },\n async getCalendarProviders(): Promise<GetCalendarProvidersResponse> {\n return invoke<GetCalendarProvidersResponse>(TauriCommands.GET_CALENDAR_PROVIDERS);\n },\n async initiateCalendarAuth(\n provider: string,\n redirectUri?: string\n ): Promise<InitiateCalendarAuthResponse> {\n return invoke<InitiateCalendarAuthResponse>(TauriCommands.INITIATE_OAUTH, {\n provider,\n redirect_uri: redirectUri,\n });\n },\n async completeCalendarAuth(\n provider: string,\n code: string,\n state: string\n ): Promise<CompleteCalendarAuthResponse> {\n const response = await invoke<CompleteCalendarAuthResponse>(TauriCommands.COMPLETE_OAUTH, {\n provider,\n code,\n state,\n });\n clientLog.calendarConnected(provider);\n return response;\n },\n async getOAuthConnectionStatus(provider: string): Promise<GetOAuthConnectionStatusResponse> {\n return invoke<GetOAuthConnectionStatusResponse>(TauriCommands.GET_OAUTH_CONNECTION_STATUS, {\n provider,\n });\n },\n async disconnectCalendar(provider: string): Promise<DisconnectOAuthResponse> {\n const response = await invoke<DisconnectOAuthResponse>(TauriCommands.DISCONNECT_OAUTH, { provider });\n clientLog.calendarDisconnected(provider);\n return response;\n },\n\n async registerWebhook(r: RegisterWebhookRequest): Promise<RegisteredWebhook> {\n const webhook = await invoke<RegisteredWebhook>(TauriCommands.REGISTER_WEBHOOK, { request: r });\n clientLog.webhookRegistered(webhook.id, webhook.name);\n return webhook;\n },\n async listWebhooks(enabledOnly?: boolean): Promise<ListWebhooksResponse> {\n return invoke<ListWebhooksResponse>(TauriCommands.LIST_WEBHOOKS, {\n enabled_only: enabledOnly ?? false,\n });\n },\n async updateWebhook(r: UpdateWebhookRequest): Promise<RegisteredWebhook> {\n return invoke<RegisteredWebhook>(TauriCommands.UPDATE_WEBHOOK, { request: r });\n },\n async deleteWebhook(webhookId: string): Promise<DeleteWebhookResponse> {\n const response = await invoke<DeleteWebhookResponse>(TauriCommands.DELETE_WEBHOOK, { webhook_id: webhookId });\n clientLog.webhookDeleted(webhookId);\n return response;\n },\n async getWebhookDeliveries(\n webhookId: string,\n limit?: number\n ): Promise<GetWebhookDeliveriesResponse> {\n return invoke<GetWebhookDeliveriesResponse>(TauriCommands.GET_WEBHOOK_DELIVERIES, {\n webhook_id: webhookId,\n limit: limit ?? 50,\n });\n },\n\n // Integration Sync (Sprint 9)\n async startIntegrationSync(integrationId: string): Promise<StartIntegrationSyncResponse> {\n return invoke<StartIntegrationSyncResponse>(TauriCommands.START_INTEGRATION_SYNC, {\n integration_id: integrationId,\n });\n },\n async getSyncStatus(syncRunId: string): Promise<GetSyncStatusResponse> {\n return invoke<GetSyncStatusResponse>(TauriCommands.GET_SYNC_STATUS, {\n sync_run_id: syncRunId,\n });\n },\n async listSyncHistory(\n integrationId: string,\n limit?: number,\n offset?: number\n ): Promise<ListSyncHistoryResponse> {\n return invoke<ListSyncHistoryResponse>(TauriCommands.LIST_SYNC_HISTORY, {\n integration_id: integrationId,\n limit,\n offset,\n });\n },\n async getUserIntegrations(): Promise<GetUserIntegrationsResponse> {\n return invoke<GetUserIntegrationsResponse>(TauriCommands.GET_USER_INTEGRATIONS);\n },\n\n // Observability (Sprint 9)\n async getRecentLogs(request?: GetRecentLogsRequest): Promise<GetRecentLogsResponse> {\n return invoke<GetRecentLogsResponse>(TauriCommands.GET_RECENT_LOGS, {\n limit: request?.limit,\n level: request?.level,\n source: request?.source,\n });\n },\n async getPerformanceMetrics(\n request?: GetPerformanceMetricsRequest\n ): Promise<GetPerformanceMetricsResponse> {\n return invoke<GetPerformanceMetricsResponse>(TauriCommands.GET_PERFORMANCE_METRICS, {\n history_limit: request?.history_limit,\n });\n },\n\n // --- Diagnostics ---\n\n async runConnectionDiagnostics(): Promise<ConnectionDiagnostics> {\n return invoke<ConnectionDiagnostics>(TauriCommands.RUN_CONNECTION_DIAGNOSTICS);\n },\n\n // --- OIDC Provider Management (Sprint 17) ---\n\n async registerOidcProvider(request: RegisterOidcProviderRequest): Promise<OidcProviderApi> {\n return invoke<OidcProviderApi>(TauriCommands.REGISTER_OIDC_PROVIDER, { request });\n },\n\n async listOidcProviders(\n workspaceId?: string,\n enabledOnly?: boolean\n ): Promise<ListOidcProvidersResponse> {\n return invoke<ListOidcProvidersResponse>(TauriCommands.LIST_OIDC_PROVIDERS, {\n workspace_id: workspaceId,\n enabled_only: enabledOnly ?? false,\n });\n },\n\n async getOidcProvider(providerId: string): Promise<OidcProviderApi> {\n return invoke<OidcProviderApi>(TauriCommands.GET_OIDC_PROVIDER, {\n provider_id: providerId,\n });\n },\n\n async updateOidcProvider(request: UpdateOidcProviderRequest): Promise<OidcProviderApi> {\n return invoke<OidcProviderApi>(TauriCommands.UPDATE_OIDC_PROVIDER, { request });\n },\n\n async deleteOidcProvider(providerId: string): Promise<DeleteOidcProviderResponse> {\n return invoke<DeleteOidcProviderResponse>(TauriCommands.DELETE_OIDC_PROVIDER, {\n provider_id: providerId,\n });\n },\n\n async refreshOidcDiscovery(\n providerId?: string,\n workspaceId?: string\n ): Promise<RefreshOidcDiscoveryResponse> {\n return invoke<RefreshOidcDiscoveryResponse>(TauriCommands.REFRESH_OIDC_DISCOVERY, {\n provider_id: providerId,\n workspace_id: workspaceId,\n });\n },\n\n async testOidcConnection(providerId: string): Promise<RefreshOidcDiscoveryResponse> {\n return invoke<RefreshOidcDiscoveryResponse>(TauriCommands.TEST_OIDC_CONNECTION, {\n provider_id: providerId,\n });\n },\n\n async listOidcPresets(): Promise<ListOidcPresetsResponse> {\n return invoke<ListOidcPresetsResponse>(TauriCommands.LIST_OIDC_PRESETS);\n },\n };\n}\n\n/** Check if running in a Tauri environment (synchronous hint). */\nexport function isTauriEnvironment(): boolean {\n if (typeof window === 'undefined') {\n return false;\n }\n // Tauri 2.x injects __TAURI_INTERNALS__ into the window\n // Check multiple possible indicators\n return '__TAURI_INTERNALS__' in window || '__TAURI__' in window || 'isTauri' in window;\n}\n\n/** Dynamically import Tauri APIs and create the adapter. */\nexport async function initializeTauriAPI(): Promise<NoteFlowAPI> {\n // Try to import Tauri APIs - this will fail in browser but succeed in Tauri\n try {\n const { invoke } = await import('@tauri-apps/api/core');\n const { listen } = await import('@tauri-apps/api/event');\n // Test if invoke actually works by calling a simple command\n await invoke('is_connected');\n return createTauriAPI(invoke, listen);\n } catch (error) {\n throw new Error(\n `Not running in Tauri environment: ${error instanceof Error ? error.message : 'unknown error'}`\n );\n }\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/tauri-constants.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/tauri-constants.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/tauri-transcription-stream.test.ts","messages":[{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":1,"message":"Unsafe assignment of an `any` value.","line":37,"column":11,"nodeType":"Property","messageId":"anyAssignment","endLine":37,"endColumn":87},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":1,"message":"Unsafe assignment of an `any` value.","line":92,"column":9,"nodeType":"Property","messageId":"anyAssignment","endLine":92,"endColumn":60},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":1,"message":"Unsafe assignment of an `any` value.","line":165,"column":11,"nodeType":"Property","messageId":"anyAssignment","endLine":165,"endColumn":61}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { beforeEach, describe, expect, it, vi } from 'vitest';\nimport {\n CONSECUTIVE_FAILURE_THRESHOLD,\n TauriEvents,\n TauriTranscriptionStream,\n type TauriInvoke,\n type TauriListen,\n} from './tauri-adapter';\nimport { TauriCommands } from './tauri-constants';\n\ndescribe('TauriTranscriptionStream', () => {\n let mockInvoke: TauriInvoke;\n let mockListen: TauriListen;\n let stream: TauriTranscriptionStream;\n\n beforeEach(() => {\n mockInvoke = vi.fn().mockResolvedValue(undefined);\n mockListen = vi.fn().mockResolvedValue(() => {});\n stream = new TauriTranscriptionStream('meeting-123', mockInvoke, mockListen);\n });\n\n describe('send()', () => {\n it('calls invoke with correct command and args', async () => {\n const chunk = {\n meeting_id: 'meeting-123',\n audio_data: new Float32Array([0.5, 1.0]),\n timestamp: 1.5,\n sample_rate: 48000,\n channels: 2,\n };\n\n stream.send(chunk);\n\n await vi.waitFor(() => {\n expect(mockInvoke).toHaveBeenCalledWith(TauriCommands.SEND_AUDIO_CHUNK, {\n meeting_id: 'meeting-123',\n audio_data: expect.arrayContaining([expect.any(Number), expect.any(Number)]),\n timestamp: 1.5,\n sample_rate: 48000,\n channels: 2,\n });\n });\n });\n\n it('resets consecutive failures on successful send', async () => {\n const errorCallback = vi.fn();\n const failingInvoke = vi.fn().mockRejectedValue(new Error('Network error'));\n const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke, mockListen);\n failingStream.onError(errorCallback);\n\n // Send twice (below threshold of 3)\n failingStream.send({\n meeting_id: 'meeting-123',\n audio_data: new Float32Array([0.1]),\n timestamp: 1,\n });\n failingStream.send({\n meeting_id: 'meeting-123',\n audio_data: new Float32Array([0.1]),\n timestamp: 2,\n });\n\n await vi.waitFor(() => {\n expect(failingInvoke).toHaveBeenCalledTimes(2);\n });\n\n // Error should NOT be emitted yet (only 2 failures)\n expect(errorCallback).not.toHaveBeenCalled();\n });\n\n it('emits error after threshold consecutive failures', async () => {\n const errorCallback = vi.fn();\n const failingInvoke = vi.fn().mockRejectedValue(new Error('Connection lost'));\n const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke, mockListen);\n failingStream.onError(errorCallback);\n\n // Send enough chunks to exceed threshold\n for (let i = 0; i < CONSECUTIVE_FAILURE_THRESHOLD + 1; i++) {\n failingStream.send({\n meeting_id: 'meeting-123',\n audio_data: new Float32Array([0.1]),\n timestamp: i,\n });\n }\n\n await vi.waitFor(() => {\n expect(errorCallback).toHaveBeenCalledTimes(1);\n });\n\n expect(errorCallback).toHaveBeenCalledWith({\n code: 'stream_send_failed',\n message: expect.stringContaining('Connection lost'),\n });\n });\n\n it('only emits error once even with more failures', async () => {\n const errorCallback = vi.fn();\n const failingInvoke = vi.fn().mockRejectedValue(new Error('Network error'));\n const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke, mockListen);\n failingStream.onError(errorCallback);\n\n // Send many chunks\n for (let i = 0; i < 10; i++) {\n failingStream.send({\n meeting_id: 'meeting-123',\n audio_data: new Float32Array([0.1]),\n timestamp: i,\n });\n }\n\n await vi.waitFor(() => {\n expect(failingInvoke).toHaveBeenCalledTimes(10);\n });\n\n // Wait a bit more for all promises to settle\n await new Promise((r) => setTimeout(r, 100));\n\n // Error should only be emitted once\n expect(errorCallback).toHaveBeenCalledTimes(1);\n });\n\n it('logs errors to console', async () => {\n const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n const failingInvoke = vi.fn().mockRejectedValue(new Error('Test error'));\n const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke, mockListen);\n\n failingStream.send({\n meeting_id: 'meeting-123',\n audio_data: new Float32Array([0.1]),\n timestamp: 1,\n });\n\n await vi.waitFor(() => {\n expect(consoleSpy).toHaveBeenCalledWith(\n expect.stringContaining('[TauriTranscriptionStream] send_audio_chunk failed:')\n );\n });\n\n consoleSpy.mockRestore();\n });\n });\n\n describe('close()', () => {\n it('calls stop_recording command', async () => {\n stream.close();\n\n await vi.waitFor(() => {\n expect(mockInvoke).toHaveBeenCalledWith(TauriCommands.STOP_RECORDING, {\n meeting_id: 'meeting-123',\n });\n });\n });\n\n it('emits error on close failure', async () => {\n const errorCallback = vi.fn();\n const failingInvoke = vi.fn().mockRejectedValue(new Error('Failed to stop'));\n const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke, mockListen);\n failingStream.onError(errorCallback);\n\n failingStream.close();\n\n await vi.waitFor(() => {\n expect(errorCallback).toHaveBeenCalledWith({\n code: 'stream_close_failed',\n message: expect.stringContaining('Failed to stop'),\n });\n });\n });\n\n it('logs close errors to console', async () => {\n const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n const failingInvoke = vi.fn().mockRejectedValue(new Error('Stop failed'));\n const failingStream = new TauriTranscriptionStream('meeting-123', failingInvoke, mockListen);\n\n failingStream.close();\n\n await vi.waitFor(() => {\n expect(consoleSpy).toHaveBeenCalledWith(\n expect.stringContaining('[TauriTranscriptionStream] stop_recording failed:')\n );\n });\n\n consoleSpy.mockRestore();\n });\n });\n\n describe('onUpdate()', () => {\n it('registers listener for transcript updates', async () => {\n const callback = vi.fn();\n await stream.onUpdate(callback);\n\n expect(mockListen).toHaveBeenCalledWith(TauriEvents.TRANSCRIPT_UPDATE, expect.any(Function));\n });\n });\n\n describe('onError()', () => {\n it('registers error callback', () => {\n const callback = vi.fn();\n stream.onError(callback);\n\n // No immediate call\n expect(callback).not.toHaveBeenCalled();\n });\n });\n});\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/transcription-stream.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/types/core.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/types/diagnostics.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/types/enums.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/types/errors.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/types/errors.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/types/features/calendar.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/types/features/identity.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/types/features/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/types/features/ner.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/types/features/observability.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/types/features/oidc.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/types/features/sync.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/types/features/webhooks.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/types/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/types/projects.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/api/types/requests.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/NavLink.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/analytics/analytics-utils.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/analytics/log-entry.tsx","messages":[{"ruleId":"react-refresh/only-export-components","severity":1,"message":"Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.","line":38,"column":14,"nodeType":"Identifier","messageId":"namedExport","endLine":38,"endColumn":56}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Log entry component for displaying individual or grouped log entries.\n */\n\nimport { format } from 'date-fns';\nimport { AlertCircle, AlertTriangle, Bug, ChevronDown, Info, type LucideIcon } from 'lucide-react';\nimport type { LogLevel, LogSource } from '@/api/types';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';\nimport { formatRelativeTimeMs } from '@/lib/format';\nimport { toFriendlyMessage } from '@/lib/log-messages';\nimport type { SummarizedLog } from '@/lib/log-summarizer';\nimport { cn } from '@/lib/utils';\n\ntype LogOrigin = 'client' | 'server';\ntype ViewMode = 'friendly' | 'technical';\n\nexport interface LogEntryData {\n id: string;\n timestamp: number;\n level: LogLevel;\n source: LogSource;\n message: string;\n details?: string;\n metadata?: Record<string, unknown>;\n traceId?: string;\n spanId?: string;\n origin: LogOrigin;\n}\n\nexport interface LevelConfig {\n icon: LucideIcon;\n color: string;\n bgColor: string;\n}\n\nexport const levelConfig: Record<LogLevel, LevelConfig> = {\n info: { icon: Info, color: 'text-blue-500', bgColor: 'bg-blue-500/10' },\n warning: { icon: AlertTriangle, color: 'text-amber-500', bgColor: 'bg-amber-500/10' },\n error: { icon: AlertCircle, color: 'text-red-500', bgColor: 'bg-red-500/10' },\n debug: { icon: Bug, color: 'text-purple-500', bgColor: 'bg-purple-500/10' },\n};\n\nconst sourceColors: Record<LogSource, string> = {\n app: 'bg-chart-1/20 text-chart-1',\n api: 'bg-chart-2/20 text-chart-2',\n sync: 'bg-chart-3/20 text-chart-3',\n auth: 'bg-chart-4/20 text-chart-4',\n system: 'bg-chart-5/20 text-chart-5',\n};\n\nexport interface LogEntryProps {\n summarized: SummarizedLog<LogEntryData>;\n viewMode: ViewMode;\n isExpanded: boolean;\n onToggleExpanded: () => void;\n}\n\nexport function LogEntry({ summarized, viewMode, isExpanded, onToggleExpanded }: LogEntryProps) {\n const {log} = summarized;\n const config = levelConfig[log.level];\n const Icon = config.icon;\n const hasDetails = log.details || log.metadata || log.traceId || log.spanId;\n\n // Get display message based on view mode\n const displayMessage =\n viewMode === 'friendly'\n ? toFriendlyMessage(log.message, (log.metadata as Record<string, string>) ?? {})\n : log.message;\n\n // Get display timestamp based on view mode\n const displayTimestamp =\n viewMode === 'friendly'\n ? formatRelativeTimeMs(log.timestamp)\n : format(new Date(log.timestamp), 'HH:mm:ss.SSS');\n\n return (\n <Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>\n <div\n className={cn(\n 'rounded-lg border p-3 transition-colors',\n log.level === 'error' && 'border-red-500/30 bg-red-500/5',\n log.level === 'warning' && 'border-amber-500/30 bg-amber-500/5'\n )}\n >\n <div className=\"flex items-start gap-3\">\n <div className={`p-1.5 rounded ${config.bgColor} mt-0.5`}>\n <Icon className={`h-3.5 w-3.5 ${config.color}`} />\n </div>\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-center gap-2 flex-wrap\">\n <span\n className={cn(\n 'text-xs text-muted-foreground',\n viewMode === 'technical' && 'font-mono'\n )}\n >\n {displayTimestamp}\n </span>\n {viewMode === 'technical' && (\n <>\n <Badge variant=\"outline\" className={cn('text-xs', sourceColors[log.source])}>\n {log.source}\n </Badge>\n <Badge variant=\"secondary\">\n {log.origin}\n </Badge>\n </>\n )}\n {summarized.isGroup && summarized.count > 1 && (\n <Badge variant=\"outline\" className=\"text-xs bg-primary/10\">\n {summarized.count}x\n </Badge>\n )}\n </div>\n <p className=\"text-sm mt-1\">{displayMessage}</p>\n {viewMode === 'friendly' && summarized.isGroup && summarized.count > 1 && (\n <p className=\"text-xs text-muted-foreground mt-1\">{summarized.count} similar events</p>\n )}\n </div>\n {(hasDetails || viewMode === 'friendly') && (\n <CollapsibleTrigger asChild>\n <Button variant=\"ghost\" size=\"sm\" className=\"shrink-0\" aria-label=\"Toggle log details\">\n <ChevronDown\n className={cn('h-4 w-4 transition-transform', isExpanded && 'rotate-180')}\n />\n </Button>\n </CollapsibleTrigger>\n )}\n </div>\n\n <CollapsibleContent>\n <LogEntryDetails\n log={log}\n summarized={summarized}\n viewMode={viewMode}\n sourceColors={sourceColors}\n />\n </CollapsibleContent>\n </div>\n </Collapsible>\n );\n}\n\ninterface LogEntryDetailsProps {\n log: LogEntryData;\n summarized: SummarizedLog<LogEntryData>;\n viewMode: ViewMode;\n sourceColors: Record<LogSource, string>;\n}\n\nfunction LogEntryDetails({ log, summarized, viewMode, sourceColors }: LogEntryDetailsProps) {\n return (\n <div className=\"mt-3 pt-3 border-t space-y-2\">\n {/* Technical details shown when expanded in friendly mode */}\n {viewMode === 'friendly' && (\n <div className=\"text-xs text-muted-foreground space-y-1\">\n <p className=\"font-mono\">{log.message}</p>\n <div className=\"flex flex-wrap gap-2\">\n <Badge variant=\"outline\" className={cn('text-xs', sourceColors[log.source])}>\n {log.source}\n </Badge>\n <Badge variant=\"secondary\">\n {log.origin}\n </Badge>\n <span className=\"font-mono\">{format(new Date(log.timestamp), 'HH:mm:ss.SSS')}</span>\n </div>\n </div>\n )}\n {(log.traceId || log.spanId) && (\n <div className=\"flex flex-wrap gap-2 text-xs\">\n {log.traceId && (\n <Badge variant=\"secondary\" className=\"font-mono\">\n trace {log.traceId}\n </Badge>\n )}\n {log.spanId && (\n <Badge variant=\"secondary\" className=\"font-mono\">\n span {log.spanId}\n </Badge>\n )}\n </div>\n )}\n {log.details && <p className=\"text-sm text-muted-foreground\">{log.details}</p>}\n {log.metadata && (\n <pre className=\"text-xs bg-muted/50 p-2 rounded overflow-x-auto\">\n {JSON.stringify(log.metadata, null, 2)}\n </pre>\n )}\n {/* Show grouped logs if this is a group */}\n {summarized.isGroup && summarized.groupedLogs && summarized.groupedLogs.length > 1 && (\n <div className=\"mt-2 pt-2 border-t\">\n <p className=\"text-xs text-muted-foreground mb-2\">All {summarized.count} events:</p>\n <div className=\"space-y-1 max-h-32 overflow-y-auto\">\n {summarized.groupedLogs.map((groupedLog) => (\n <div key={groupedLog.id} className=\"text-xs font-mono text-muted-foreground\">\n {format(new Date(groupedLog.timestamp), 'HH:mm:ss.SSS')} - {groupedLog.message}\n </div>\n ))}\n </div>\n </div>\n )}\n </div>\n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/analytics/log-timeline.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/analytics/logs-tab.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/analytics/logs-tab.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/analytics/performance-tab.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/analytics/performance-tab.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/analytics/speech-analysis-tab.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/annotation-type-badge.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/api-mode-indicator.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/api-mode-indicator.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/app-layout.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/app-sidebar.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/calendar-connection-panel.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/calendar-events-panel.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/connection-status.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/empty-state.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/entity-highlight.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/entity-highlight.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/entity-management-panel.test.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'layout' is defined but never used. Allowed unused args must match /^_/u.","line":9,"column":23,"nodeType":null,"messageId":"unusedVar","endLine":9,"endColumn":29},{"ruleId":"@typescript-eslint/no-unsafe-return","severity":1,"message":"Unsafe return of a value of type `any`.","line":51,"column":47,"nodeType":"CallExpression","messageId":"unsafeReturn","endLine":51,"endColumn":74},{"ruleId":"@typescript-eslint/no-unsafe-return","severity":1,"message":"Unsafe return of a value of type `any`.","line":52,"column":52,"nodeType":"CallExpression","messageId":"unsafeReturn","endLine":52,"endColumn":84},{"ruleId":"@typescript-eslint/no-unsafe-return","severity":1,"message":"Unsafe return of a value of type `any`.","line":53,"column":52,"nodeType":"CallExpression","messageId":"unsafeReturn","endLine":53,"endColumn":84},{"ruleId":"@typescript-eslint/no-unsafe-return","severity":1,"message":"Unsafe return of a value of type `any`.","line":55,"column":22,"nodeType":"CallExpression","messageId":"unsafeReturn","endLine":55,"endColumn":35},{"ruleId":"@typescript-eslint/no-unsafe-return","severity":1,"message":"Unsafe return of a value of type `any`.","line":60,"column":34,"nodeType":"CallExpression","messageId":"unsafeReturn","endLine":60,"endColumn":48}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":6,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { act, fireEvent, render, screen } from '@testing-library/react';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport { EntityManagementPanel } from './entity-management-panel';\nimport type { Entity } from '@/types/entity';\n\nvi.mock('framer-motion', () => ({\n AnimatePresence: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n motion: {\n div: ({ children, layout, ...rest }: { children: React.ReactNode; layout?: unknown }) => (\n <div {...rest}>{children}</div>\n ),\n },\n}));\n\nvi.mock('@/components/ui/scroll-area', () => ({\n ScrollArea: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n}));\n\nvi.mock('@/components/ui/sheet', () => ({\n Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n SheetTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n SheetHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n SheetTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n}));\n\nvi.mock('@/components/ui/dialog', () => ({\n Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>\n open ? <div>{children}</div> : null,\n DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n}));\n\nvi.mock('@/components/ui/select', () => ({\n Select: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n SelectTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n SelectValue: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n SelectItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n}));\n\nconst addEntityAndNotify = vi.fn();\nconst updateEntityWithPersist = vi.fn();\nconst deleteEntityWithPersist = vi.fn();\nconst subscribeToEntities = vi.fn(() => () => {});\nconst getEntities = vi.fn();\n\nvi.mock('@/lib/entity-store', () => ({\n addEntityAndNotify: (...args: unknown[]) => addEntityAndNotify(...args),\n updateEntityWithPersist: (...args: unknown[]) => updateEntityWithPersist(...args),\n deleteEntityWithPersist: (...args: unknown[]) => deleteEntityWithPersist(...args),\n subscribeToEntities: (...args: unknown[]) => subscribeToEntities(...args),\n getEntities: () => getEntities(),\n}));\n\nconst toast = vi.fn();\nvi.mock('@/hooks/use-toast', () => ({\n toast: (...args: unknown[]) => toast(...args),\n}));\n\nconst baseEntities: Entity[] = [\n {\n id: 'e1',\n text: 'API',\n aliases: ['api'],\n category: 'technical',\n description: 'Core API platform',\n source: 'Docs',\n extractedAt: new Date(),\n },\n {\n id: 'e2',\n text: 'Roadmap',\n aliases: [],\n category: 'product',\n description: 'Product roadmap',\n source: 'Plan',\n extractedAt: new Date(),\n },\n];\n\ndescribe('EntityManagementPanel', () => {\n beforeEach(() => {\n getEntities.mockReturnValue([...baseEntities]);\n });\n\n afterEach(() => {\n vi.clearAllMocks();\n });\n\n it('filters entities by search query', () => {\n render(<EntityManagementPanel />);\n\n expect(screen.getByText('API')).toBeInTheDocument();\n expect(screen.getByText('Roadmap')).toBeInTheDocument();\n\n const searchInput = screen.getByPlaceholderText('Search entities...');\n fireEvent.change(searchInput, { target: { value: 'api' } });\n\n expect(screen.getByText('API')).toBeInTheDocument();\n expect(screen.queryByText('Roadmap')).not.toBeInTheDocument();\n\n fireEvent.change(searchInput, { target: { value: 'nomatch' } });\n expect(screen.getByText('No matching entities found')).toBeInTheDocument();\n });\n\n it('adds, edits, and deletes entities when persisted', async () => {\n updateEntityWithPersist.mockResolvedValue(undefined);\n deleteEntityWithPersist.mockResolvedValue(undefined);\n\n render(<EntityManagementPanel meetingId=\"m1\" />);\n\n const addEntityButtons = screen.getAllByRole('button', { name: 'Add Entity' });\n await act(async () => {\n fireEvent.click(addEntityButtons[0]);\n });\n\n fireEvent.change(screen.getByLabelText('Text *'), { target: { value: 'New' } });\n fireEvent.change(screen.getByLabelText('Aliases (comma-separated)'), {\n target: { value: 'new, alias' },\n });\n fireEvent.change(screen.getByLabelText('Description *'), {\n target: { value: 'New description' },\n });\n\n const submitButtons = screen.getAllByRole('button', { name: 'Add Entity' });\n await act(async () => {\n fireEvent.click(submitButtons[1]);\n });\n expect(addEntityAndNotify).toHaveBeenCalledWith({\n text: 'New',\n aliases: ['new', 'alias'],\n category: 'other',\n description: 'New description',\n source: undefined,\n });\n\n const editButtons = screen.getAllByRole('button', { name: 'Edit entity' });\n await act(async () => {\n fireEvent.click(editButtons[0]);\n });\n\n fireEvent.change(screen.getByLabelText('Text *'), { target: { value: 'API v2' } });\n fireEvent.change(screen.getByLabelText('Description *'), {\n target: { value: 'Updated' },\n });\n\n await act(async () => {\n fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));\n });\n\n expect(updateEntityWithPersist).toHaveBeenCalledWith('m1', 'e1', {\n text: 'API v2',\n category: 'technical',\n });\n\n const deleteButtons = screen.getAllByRole('button', { name: 'Delete entity' });\n await act(async () => {\n fireEvent.click(deleteButtons[0]);\n });\n\n await act(async () => {\n fireEvent.click(screen.getByRole('button', { name: 'Delete' }));\n });\n\n expect(deleteEntityWithPersist).toHaveBeenCalledWith('m1', 'e1');\n expect(toast).toHaveBeenCalled();\n });\n\n it('handles update errors and non-persisted edits', async () => {\n updateEntityWithPersist.mockRejectedValueOnce(new Error('nope'));\n\n render(<EntityManagementPanel />);\n\n const editButtons = screen.getAllByRole('button', { name: 'Edit entity' });\n await act(async () => {\n fireEvent.click(editButtons[0]);\n });\n\n fireEvent.change(screen.getByLabelText('Text *'), { target: { value: 'API v3' } });\n fireEvent.change(screen.getByLabelText('Description *'), {\n target: { value: 'Updated' },\n });\n\n await act(async () => {\n fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));\n });\n\n expect(updateEntityWithPersist).not.toHaveBeenCalled();\n expect(toast).toHaveBeenCalled();\n });\n\n it('shows delete error toast on failure', async () => {\n deleteEntityWithPersist.mockRejectedValueOnce(new Error('fail'));\n\n render(<EntityManagementPanel meetingId=\"m1\" />);\n\n const deleteButtons = screen.getAllByRole('button', { name: 'Delete entity' });\n await act(async () => {\n fireEvent.click(deleteButtons[0]);\n });\n\n await act(async () => {\n fireEvent.click(screen.getByRole('button', { name: 'Delete' }));\n });\n\n expect(deleteEntityWithPersist).toHaveBeenCalledWith('m1', 'e1');\n expect(toast).toHaveBeenCalled();\n });\n});\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/entity-management-panel.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/error-boundary.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/integration-config-panel.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/meeting-card.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/meeting-state-badge.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/offline-banner.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/offline-banner.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/preferences-sync-bridge.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/preferences-sync-status.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/preferences-sync-status.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/priority-badge.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/processing-status.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/processing-status.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/projects/ProjectList.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/projects/ProjectMembersPanel.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/projects/ProjectScopeFilter.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/projects/ProjectSettingsPanel.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/projects/ProjectSidebar.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/projects/ProjectSwitcher.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/audio-device-selector.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/audio-device-selector.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/audio-level-meter.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/audio-level-meter.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/buffering-indicator.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/buffering-indicator.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/confidence-indicator.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/confidence-indicator.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/idle-state.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/idle-state.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/index.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/listening-state.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/partial-text-display.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/recording-components.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/recording-header.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/recording-header.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/speaker-distribution.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/speaker-distribution.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/stat-card.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/stat-card.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/stats-content.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/transcript-segment-card.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/vad-indicator.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/recording/vad-indicator.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/settings/ai-config-section.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/settings/audio-devices-section.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/settings/connection-diagnostics-panel.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/settings/developer-options-section.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/settings/export-ai-section.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/settings/export-ai-section.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/settings/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/settings/integrations-section.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/settings/provider-config-card.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/settings/quick-actions-section.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/settings/recording-app-policy-section.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/settings/server-connection-section.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/simulation-confirmation-dialog.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/speaker-badge.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/speaker-badge.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/stats-card.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/sync-control-panel.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/sync-history-log.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/sync-status-indicator.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/tauri-event-listener.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/timestamped-notes-editor.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/timestamped-notes-editor.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/top-bar.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/accordion.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/alert-dialog.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/alert.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/aspect-ratio.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/avatar.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/badge.tsx","messages":[],"suppressedMessages":[{"ruleId":"react-refresh/only-export-components","severity":1,"message":"Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.","line":51,"column":17,"nodeType":"Identifier","messageId":"namedExport","endLine":51,"endColumn":30,"suppressions":[{"kind":"directive","justification":""}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/breadcrumb.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/button.tsx","messages":[],"suppressedMessages":[{"ruleId":"react-refresh/only-export-components","severity":1,"message":"Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.","line":60,"column":18,"nodeType":"Identifier","messageId":"namedExport","endLine":60,"endColumn":32,"suppressions":[{"kind":"directive","justification":""}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/calendar.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/card.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/carousel.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/chart.tsx","messages":[],"suppressedMessages":[{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":1,"message":"Unsafe assignment of an `any` value.","line":175,"column":19,"nodeType":"VariableDeclarator","messageId":"anyAssignment","endLine":175,"endColumn":76,"suppressions":[{"kind":"directive","justification":""}]},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":1,"message":"Unsafe member access .fill on an `any` value.","line":175,"column":58,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":175,"endColumn":62,"suppressions":[{"kind":"directive","justification":""}]},{"ruleId":"@typescript-eslint/no-unsafe-argument","severity":1,"message":"Unsafe argument of type `any` assigned to a parameter of type `Payload<ValueType, NameType>[]`.","line":186,"column":65,"nodeType":"MemberExpression","messageId":"unsafeArgument","endLine":186,"endColumn":77,"suppressions":[{"kind":"directive","justification":""}]},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":1,"message":"Unsafe assignment of an `any` value.","line":206,"column":31,"nodeType":"Property","messageId":"anyAssignment","endLine":206,"endColumn":59,"suppressions":[{"kind":"directive","justification":""}]},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":1,"message":"Unsafe assignment of an `any` value.","line":207,"column":31,"nodeType":"Property","messageId":"anyAssignment","endLine":207,"endColumn":63,"suppressions":[{"kind":"directive","justification":""}]},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":1,"message":"Unsafe assignment of an `any` value.","line":274,"column":18,"nodeType":"MemberExpression","messageId":"anyAssignment","endLine":274,"endColumn":28,"suppressions":[{"kind":"directive","justification":""}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/checkbox.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/collapsible.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/command.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/context-menu.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/dialog.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/drawer.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/dropdown-menu.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/dropdown-menu.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/form.tsx","messages":[],"suppressedMessages":[{"ruleId":"react-refresh/only-export-components","severity":1,"message":"Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.","line":164,"column":3,"nodeType":"Identifier","messageId":"namedExport","endLine":164,"endColumn":15,"suppressions":[{"kind":"directive","justification":""}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/hover-card.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/input-otp.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/input.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/label.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/menubar.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/navigation-menu.tsx","messages":[],"suppressedMessages":[{"ruleId":"react-refresh/only-export-components","severity":1,"message":"Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.","line":113,"column":3,"nodeType":"Identifier","messageId":"namedExport","endLine":113,"endColumn":29,"suppressions":[{"kind":"directive","justification":""}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/pagination.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/popover.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/progress.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/radio-group.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/resizable.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/resizable.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/scroll-area.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/search-icon.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/select.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/separator.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/sheet.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/sidebar.tsx","messages":[],"suppressedMessages":[{"ruleId":"react-refresh/only-export-components","severity":1,"message":"Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.","line":735,"column":3,"nodeType":"Identifier","messageId":"namedExport","endLine":735,"endColumn":13,"suppressions":[{"kind":"directive","justification":""}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/skeleton.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/slider.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/sonner.tsx","messages":[],"suppressedMessages":[{"ruleId":"react-refresh/only-export-components","severity":1,"message":"Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.","line":28,"column":19,"nodeType":"Identifier","messageId":"namedExport","endLine":28,"endColumn":24,"suppressions":[{"kind":"directive","justification":""}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/status-badge.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/switch.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/table.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/tabs.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/textarea.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/toast.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/toaster.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/toggle-group.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/toggle.tsx","messages":[],"suppressedMessages":[{"ruleId":"react-refresh/only-export-components","severity":1,"message":"Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.","line":43,"column":18,"nodeType":"Identifier","messageId":"namedExport","endLine":43,"endColumn":32,"suppressions":[{"kind":"directive","justification":""}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/tooltip.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/ui-components.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/ui/use-toast.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/upcoming-meetings.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/webhook-settings-panel.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/workspace-switcher.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/components/workspace-switcher.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/contexts/connection-context.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx","messages":[{"ruleId":"react-refresh/only-export-components","severity":1,"message":"Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.","line":72,"column":17,"nodeType":"Identifier","messageId":"namedExport","endLine":72,"endColumn":35}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// Connection context for offline/cached read-only mode\n// (Sprint GAP-007: Simulation Mode Clarity - expose mode and simulation state)\n\nimport { createContext, useContext, useEffect, useMemo, useState } from 'react';\nimport {\n type ConnectionMode,\n type ConnectionState,\n getConnectionState,\n setConnectionMode,\n setConnectionServerUrl,\n subscribeConnectionState,\n} from '@/api/connection-state';\nimport { TauriEvents } from '@/api/tauri-adapter';\nimport { useTauriEvent } from '@/lib/tauri-events';\nimport { preferences } from '@/lib/preferences';\n\ninterface ConnectionHelpers {\n state: ConnectionState;\n /** The current connection mode (connected, disconnected, cached, mock, reconnecting) */\n mode: ConnectionMode;\n isConnected: boolean;\n isReadOnly: boolean;\n isReconnecting: boolean;\n /** Whether simulation mode is enabled in preferences */\n isSimulating: boolean;\n}\n\nconst ConnectionContext = createContext<ConnectionHelpers | null>(null);\n\nexport function ConnectionProvider({ children }: { children: React.ReactNode }) {\n const [state, setState] = useState<ConnectionState>(() => getConnectionState());\n // Sprint GAP-007: Track simulation mode from preferences\n const [isSimulating, setIsSimulating] = useState(() => preferences.get().simulate_transcription);\n\n useEffect(() => subscribeConnectionState(setState), []);\n\n // Sprint GAP-007: Subscribe to preference changes for simulation mode\n useEffect(() => {\n return preferences.subscribe((prefs) => {\n setIsSimulating(prefs.simulate_transcription);\n });\n }, []);\n\n useTauriEvent(\n TauriEvents.CONNECTION_CHANGE,\n (payload) => {\n if (payload.is_connected) {\n setConnectionMode('connected');\n setConnectionServerUrl(payload.server_url);\n return;\n }\n setConnectionMode('cached', payload.error ?? null);\n setConnectionServerUrl(payload.server_url);\n },\n []\n );\n\n const value = useMemo<ConnectionHelpers>(() => {\n const isConnected = state.mode === 'connected';\n const isReconnecting = state.mode === 'reconnecting';\n const isReadOnly =\n state.mode === 'cached' ||\n state.mode === 'disconnected' ||\n state.mode === 'mock' ||\n state.mode === 'reconnecting';\n return { state, mode: state.mode, isConnected, isReadOnly, isReconnecting, isSimulating };\n }, [state, isSimulating]);\n\n return <ConnectionContext.Provider value={value}>{children}</ConnectionContext.Provider>;\n}\n\nexport function useConnectionState(): ConnectionHelpers {\n const context = useContext(ConnectionContext);\n if (!context) {\n const state = getConnectionState();\n return {\n state,\n mode: state.mode,\n isConnected: false,\n isReadOnly: true,\n isReconnecting: false,\n isSimulating: preferences.get().simulate_transcription,\n };\n }\n return context;\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/contexts/project-context.tsx","messages":[{"ruleId":"react-refresh/only-export-components","severity":1,"message":"Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.","line":256,"column":17,"nodeType":"Identifier","messageId":"namedExport","endLine":256,"endColumn":28}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// Project context for managing active project selection and project data\n\nimport { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';\nimport { IdentityDefaults } from '@/api/constants';\nimport { getAPI } from '@/api/interface';\nimport type { CreateProjectRequest, Project, UpdateProjectRequest } from '@/api/types';\nimport { useWorkspace } from '@/contexts/workspace-context';\n\ninterface ProjectContextValue {\n projects: Project[];\n activeProject: Project | null;\n switchProject: (projectId: string) => void;\n refreshProjects: () => Promise<void>;\n createProject: (\n request: Omit<CreateProjectRequest, 'workspace_id'> & { workspace_id?: string }\n ) => Promise<Project>;\n updateProject: (request: UpdateProjectRequest) => Promise<Project>;\n archiveProject: (projectId: string) => Promise<Project>;\n restoreProject: (projectId: string) => Promise<Project>;\n deleteProject: (projectId: string) => Promise<boolean>;\n isLoading: boolean;\n error: string | null;\n}\n\nconst STORAGE_KEY_PREFIX = 'noteflow_active_project_id';\n\nconst ProjectContext = createContext<ProjectContextValue | null>(null);\n\nfunction storageKey(workspaceId: string): string {\n return `${STORAGE_KEY_PREFIX}:${workspaceId}`;\n}\n\nfunction readStoredProjectId(workspaceId: string): string | null {\n try {\n return localStorage.getItem(storageKey(workspaceId));\n } catch {\n return null;\n }\n}\n\nfunction persistProjectId(workspaceId: string, projectId: string): void {\n try {\n localStorage.setItem(storageKey(workspaceId), projectId);\n } catch {\n // Ignore storage failures\n }\n}\n\nfunction resolveActiveProject(projects: Project[], preferredId: string | null): Project | null {\n if (!projects.length) {\n return null;\n }\n const activeCandidates = projects.filter((project) => !project.is_archived);\n if (preferredId) {\n const match = activeCandidates.find((project) => project.id === preferredId);\n if (match) {\n return match;\n }\n }\n const defaultProject = activeCandidates.find((project) => project.is_default);\n return defaultProject ?? activeCandidates[0] ?? null;\n}\n\nfunction fallbackProject(workspaceId: string): Project {\n return {\n id: IdentityDefaults.DEFAULT_PROJECT_ID,\n workspace_id: workspaceId,\n name: IdentityDefaults.DEFAULT_PROJECT_NAME,\n slug: 'general',\n description: 'Default project',\n is_default: true,\n is_archived: false,\n settings: {},\n created_at: 0,\n updated_at: 0,\n };\n}\n\nexport function ProjectProvider({ children }: { children: React.ReactNode }) {\n const { currentWorkspace } = useWorkspace();\n const [projects, setProjects] = useState<Project[]>([]);\n const [activeProjectId, setActiveProjectId] = useState<string | null>(null);\n const [isLoading, setIsLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const loadProjects = useCallback(async () => {\n if (!currentWorkspace) {\n return;\n }\n setIsLoading(true);\n setError(null);\n try {\n const response = await getAPI().listProjects({\n workspace_id: currentWorkspace.id,\n include_archived: true,\n limit: 200,\n offset: 0,\n });\n let preferredId = readStoredProjectId(currentWorkspace.id);\n try {\n const activeResponse = await getAPI().getActiveProject({\n workspace_id: currentWorkspace.id,\n });\n const activeId = activeResponse.project_id ?? activeResponse.project?.id;\n if (activeId) {\n preferredId = activeId;\n }\n } catch {\n // Ignore active project lookup failures (offline or unsupported)\n }\n const available = response.projects.length\n ? response.projects\n : [fallbackProject(currentWorkspace.id)];\n setProjects(available);\n const resolved = resolveActiveProject(available, preferredId);\n setActiveProjectId(resolved?.id ?? null);\n if (resolved) {\n persistProjectId(currentWorkspace.id, resolved.id);\n }\n } catch (err) {\n setError(err instanceof Error ? err.message : 'Failed to load projects');\n const fallback = fallbackProject(currentWorkspace.id);\n setProjects([fallback]);\n setActiveProjectId(fallback.id);\n persistProjectId(currentWorkspace.id, fallback.id);\n } finally {\n setIsLoading(false);\n }\n }, [currentWorkspace]);\n\n useEffect(() => {\n void loadProjects();\n }, [loadProjects]);\n\n const switchProject = useCallback(\n (projectId: string) => {\n if (!currentWorkspace) {\n return;\n }\n setActiveProjectId(projectId);\n persistProjectId(currentWorkspace.id, projectId);\n void getAPI()\n .setActiveProject({ workspace_id: currentWorkspace.id, project_id: projectId })\n .catch(() => {\n // Failed to persist active project - context state already updated\n });\n },\n [currentWorkspace]\n );\n\n const createProject = useCallback(\n async (\n request: Omit<CreateProjectRequest, 'workspace_id'> & { workspace_id?: string }\n ): Promise<Project> => {\n const workspaceId = request.workspace_id ?? currentWorkspace?.id;\n if (!workspaceId) {\n throw new Error('Workspace is required to create a project');\n }\n const project = await getAPI().createProject({ ...request, workspace_id: workspaceId });\n setProjects((prev) => [project, ...prev]);\n switchProject(project.id);\n return project;\n },\n [currentWorkspace, switchProject]\n );\n\n const updateProject = useCallback(async (request: UpdateProjectRequest): Promise<Project> => {\n const updated = await getAPI().updateProject(request);\n setProjects((prev) => prev.map((project) => (project.id === updated.id ? updated : project)));\n return updated;\n }, []);\n\n const archiveProject = useCallback(\n async (projectId: string): Promise<Project> => {\n const updated = await getAPI().archiveProject(projectId);\n const nextProjects = projects.map((project) =>\n project.id === updated.id ? updated : project\n );\n setProjects(nextProjects);\n if (activeProjectId === projectId && currentWorkspace) {\n const nextActive = resolveActiveProject(nextProjects, null);\n if (nextActive) {\n switchProject(nextActive.id);\n }\n }\n return updated;\n },\n [activeProjectId, currentWorkspace, projects, switchProject]\n );\n\n const restoreProject = useCallback(async (projectId: string): Promise<Project> => {\n const updated = await getAPI().restoreProject(projectId);\n setProjects((prev) => prev.map((project) => (project.id === updated.id ? updated : project)));\n return updated;\n }, []);\n\n const deleteProject = useCallback(\n async (projectId: string): Promise<boolean> => {\n const deleted = await getAPI().deleteProject(projectId);\n if (deleted) {\n setProjects((prev) => prev.filter((project) => project.id !== projectId));\n if (activeProjectId === projectId && currentWorkspace) {\n const next = resolveActiveProject(\n projects.filter((project) => project.id !== projectId),\n null\n );\n if (next) {\n switchProject(next.id);\n }\n }\n }\n return deleted;\n },\n [activeProjectId, currentWorkspace, projects, switchProject]\n );\n\n const activeProject = useMemo(() => {\n if (!activeProjectId) {\n return null;\n }\n return projects.find((project) => project.id === activeProjectId) ?? null;\n }, [activeProjectId, projects]);\n\n const value = useMemo<ProjectContextValue>(\n () => ({\n projects,\n activeProject,\n switchProject,\n refreshProjects: loadProjects,\n createProject,\n updateProject,\n archiveProject,\n restoreProject,\n deleteProject,\n isLoading,\n error,\n }),\n [\n projects,\n activeProject,\n switchProject,\n loadProjects,\n createProject,\n updateProject,\n archiveProject,\n restoreProject,\n deleteProject,\n isLoading,\n error,\n ]\n );\n\n return <ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>;\n}\n\nexport function useProjects(): ProjectContextValue {\n const context = useContext(ProjectContext);\n if (!context) {\n throw new Error('useProjects must be used within ProjectProvider');\n }\n return context;\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/contexts/workspace-context.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx","messages":[{"ruleId":"react-refresh/only-export-components","severity":1,"message":"Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.","line":149,"column":17,"nodeType":"Identifier","messageId":"namedExport","endLine":149,"endColumn":29}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// Workspace context for managing current user/workspace identity\n\nimport { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';\nimport { IdentityDefaults } from '@/api/constants';\nimport { getAPI } from '@/api/interface';\nimport type { GetCurrentUserResponse, Workspace } from '@/api/types';\n\ninterface WorkspaceContextValue {\n currentWorkspace: Workspace | null;\n workspaces: Workspace[];\n currentUser: GetCurrentUserResponse | null;\n switchWorkspace: (workspaceId: string) => Promise<void>;\n isLoading: boolean;\n error: string | null;\n}\n\nconst STORAGE_KEY = 'noteflow_current_workspace_id';\nconst fallbackUser: GetCurrentUserResponse = {\n user_id: IdentityDefaults.DEFAULT_USER_ID,\n display_name: IdentityDefaults.DEFAULT_USER_NAME,\n};\nconst fallbackWorkspace: Workspace = {\n id: IdentityDefaults.DEFAULT_WORKSPACE_ID,\n name: IdentityDefaults.DEFAULT_WORKSPACE_NAME,\n role: 'owner',\n is_default: true,\n};\n\nconst WorkspaceContext = createContext<WorkspaceContextValue | null>(null);\n\nfunction readStoredWorkspaceId(): string | null {\n try {\n return localStorage.getItem(STORAGE_KEY);\n } catch {\n return null;\n }\n}\n\nfunction persistWorkspaceId(workspaceId: string): void {\n try {\n localStorage.setItem(STORAGE_KEY, workspaceId);\n } catch {\n // Ignore storage failures (private mode or blocked)\n }\n}\n\nfunction resolveWorkspace(workspaces: Workspace[], preferredId: string | null): Workspace | null {\n if (!workspaces.length) {\n return null;\n }\n if (preferredId) {\n const byId = workspaces.find((workspace) => workspace.id === preferredId);\n if (byId) {\n return byId;\n }\n }\n const defaultWorkspace = workspaces.find((workspace) => workspace.is_default);\n return defaultWorkspace ?? workspaces[0] ?? null;\n}\n\nexport function WorkspaceProvider({ children }: { children: React.ReactNode }) {\n const [currentWorkspace, setCurrentWorkspace] = useState<Workspace | null>(null);\n const [workspaces, setWorkspaces] = useState<Workspace[]>([]);\n const [currentUser, setCurrentUser] = useState<GetCurrentUserResponse | null>(null);\n const [isLoading, setIsLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const loadContext = useCallback(async () => {\n setIsLoading(true);\n setError(null);\n try {\n const api = getAPI();\n const [user, workspaceResponse] = await Promise.all([\n api.getCurrentUser(),\n api.listWorkspaces(),\n ]);\n\n const availableWorkspaces =\n workspaceResponse.workspaces.length > 0\n ? workspaceResponse.workspaces\n : [fallbackWorkspace];\n\n setCurrentUser(user ?? fallbackUser);\n setWorkspaces(availableWorkspaces);\n\n const storedId = readStoredWorkspaceId();\n const selected = resolveWorkspace(availableWorkspaces, storedId);\n setCurrentWorkspace(selected);\n if (selected) {\n persistWorkspaceId(selected.id);\n }\n } catch (err) {\n setError(err instanceof Error ? err.message : 'Failed to load workspace context');\n setCurrentUser(fallbackUser);\n setWorkspaces([fallbackWorkspace]);\n setCurrentWorkspace(fallbackWorkspace);\n persistWorkspaceId(fallbackWorkspace.id);\n } finally {\n setIsLoading(false);\n }\n }, []);\n\n useEffect(() => {\n void loadContext();\n }, [loadContext]);\n\n const switchWorkspace = useCallback(\n async (workspaceId: string) => {\n if (!workspaceId) {\n return;\n }\n setIsLoading(true);\n setError(null);\n try {\n const api = getAPI();\n const response = await api.switchWorkspace(workspaceId);\n const selected =\n response.workspace ?? workspaces.find((workspace) => workspace.id === workspaceId);\n if (!response.success || !selected) {\n throw new Error('Workspace not found');\n }\n setCurrentWorkspace(selected);\n persistWorkspaceId(selected.id);\n } catch (err) {\n setError(err instanceof Error ? err.message : 'Failed to switch workspace');\n throw err;\n } finally {\n setIsLoading(false);\n }\n },\n [workspaces]\n );\n\n const value = useMemo<WorkspaceContextValue>(\n () => ({\n currentWorkspace,\n workspaces,\n currentUser,\n switchWorkspace,\n isLoading,\n error,\n }),\n [currentWorkspace, workspaces, currentUser, switchWorkspace, isLoading, error]\n );\n\n return <WorkspaceContext.Provider value={value}>{children}</WorkspaceContext.Provider>;\n}\n\nexport function useWorkspace(): WorkspaceContextValue {\n const context = useContext(WorkspaceContext);\n if (!context) {\n throw new Error('useWorkspace must be used within WorkspaceProvider');\n }\n return context;\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/post-processing/events.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/post-processing/state.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-audio-devices.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-audio-devices.ts","messages":[{"ruleId":"@typescript-eslint/no-unsafe-argument","severity":1,"message":"Unsafe argument of type error typed assigned to a parameter of type `string`.","line":217,"column":22,"nodeType":"MemberExpression","messageId":"unsafeArgument","endLine":217,"endColumn":52},{"ruleId":"@typescript-eslint/no-unsafe-argument","severity":1,"message":"Unsafe argument of type error typed assigned to a parameter of type `string`.","line":285,"column":22,"nodeType":"MemberExpression","messageId":"unsafeArgument","endLine":285,"endColumn":51},{"ruleId":"@typescript-eslint/no-unsafe-argument","severity":1,"message":"Unsafe argument of type error typed assigned to a parameter of type `string`.","line":318,"column":22,"nodeType":"MemberExpression","messageId":"unsafeArgument","endLine":318,"endColumn":53}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Shared Audio Device Management Hook\n *\n * Provides audio device enumeration, selection, and testing functionality.\n * Used by both Settings page and Recording page.\n *\n */\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { initializeAPI } from '@/api';\nimport { TauriCommands, Timing } from '@/api/constants';\nimport { isTauriEnvironment, TauriEvents } from '@/api/tauri-adapter';\nimport { toast } from '@/hooks/use-toast';\nimport { AudioConfig } from '@/lib/config';\nimport { preferences } from '@/lib/preferences';\nimport { type AudioTestLevelEvent, useTauriEvent } from '@/lib/tauri-events';\n\nexport interface AudioDevice {\n deviceId: string;\n label: string;\n kind: 'audioinput' | 'audiooutput';\n}\n\ninterface UseAudioDevicesOptions {\n /** Auto-load devices on mount */\n autoLoad?: boolean;\n /** Show toast notifications */\n showToasts?: boolean;\n}\n\ninterface UseAudioDevicesReturn {\n // Device lists\n inputDevices: AudioDevice[];\n outputDevices: AudioDevice[];\n\n // Selected devices\n selectedInputDevice: string;\n selectedOutputDevice: string;\n\n // State\n isLoading: boolean;\n hasPermission: boolean | null;\n\n // Actions\n loadDevices: () => Promise<void>;\n setInputDevice: (deviceId: string) => void;\n setOutputDevice: (deviceId: string) => void;\n\n // Testing\n isTestingInput: boolean;\n isTestingOutput: boolean;\n inputLevel: number;\n startInputTest: () => Promise<void>;\n stopInputTest: () => Promise<void>;\n testOutputDevice: () => Promise<void>;\n}\n\n/**\n * Hook for managing audio device selection and testing\n */\nexport function useAudioDevices(options: UseAudioDevicesOptions = {}): UseAudioDevicesReturn {\n const { autoLoad = false, showToasts = true } = options;\n\n // Device lists\n const [inputDevices, setInputDevices] = useState<AudioDevice[]>([]);\n const [outputDevices, setOutputDevices] = useState<AudioDevice[]>([]);\n\n // Selected devices (from preferences)\n const [selectedInputDevice, setSelectedInputDevice] = useState<string>(\n preferences.get().audio_devices.input_device_id\n );\n const [selectedOutputDevice, setSelectedOutputDevice] = useState<string>(\n preferences.get().audio_devices.output_device_id\n );\n\n // State\n const [isLoading, setIsLoading] = useState(false);\n const [hasPermission, setHasPermission] = useState<boolean | null>(null);\n\n // Testing state\n const [isTestingInput, setIsTestingInput] = useState(false);\n const [isTestingOutput, setIsTestingOutput] = useState(false);\n const [inputLevel, setInputLevel] = useState(0);\n\n // Refs for audio context\n const audioContextRef = useRef<AudioContext | null>(null);\n const analyserRef = useRef<AnalyserNode | null>(null);\n const mediaStreamRef = useRef<MediaStream | null>(null);\n const animationFrameRef = useRef<number | null>(null);\n const autoLoadRef = useRef(false);\n\n /**\n * Load available audio devices\n * Uses Web Audio API for browser, Tauri command for desktop\n */\n const loadDevices = useCallback(async () => {\n setIsLoading(true);\n\n try {\n if (isTauriEnvironment()) {\n const api = await initializeAPI();\n const devices = await api.listAudioDevices();\n const inputs = devices\n .filter((device) => device.is_input)\n .map((device) => ({\n deviceId: device.id,\n label: device.name,\n kind: 'audioinput' as const,\n }));\n const outputs = devices\n .filter((device) => !device.is_input)\n .map((device) => ({\n deviceId: device.id,\n label: device.name,\n kind: 'audiooutput' as const,\n }));\n\n setHasPermission(true);\n setInputDevices(inputs);\n setOutputDevices(outputs);\n\n if (inputs.length > 0 && !selectedInputDevice) {\n setSelectedInputDevice(inputs[0].deviceId);\n preferences.setAudioDevice('input', inputs[0].deviceId);\n await api.selectAudioDevice(inputs[0].deviceId, true);\n }\n if (outputs.length > 0 && !selectedOutputDevice) {\n setSelectedOutputDevice(outputs[0].deviceId);\n preferences.setAudioDevice('output', outputs[0].deviceId);\n await api.selectAudioDevice(outputs[0].deviceId, false);\n }\n return;\n }\n\n // Request permission first\n const permissionStream = await navigator.mediaDevices.getUserMedia({ audio: true });\n setHasPermission(true);\n\n const devices = await navigator.mediaDevices.enumerateDevices();\n\n for (const track of permissionStream.getTracks()) {\n track.stop();\n }\n\n const inputs = devices\n .filter((d) => d.kind === 'audioinput')\n .map((d, i) => ({\n deviceId: d.deviceId,\n label: d.label || `Microphone ${i + 1}`,\n kind: 'audioinput' as const,\n }));\n\n const outputs = devices\n .filter((d) => d.kind === 'audiooutput')\n .map((d, i) => ({\n deviceId: d.deviceId,\n label: d.label || `Speaker ${i + 1}`,\n kind: 'audiooutput' as const,\n }));\n\n setInputDevices(inputs);\n setOutputDevices(outputs);\n\n // Auto-select first device if none selected\n if (inputs.length > 0 && !selectedInputDevice) {\n setSelectedInputDevice(inputs[0].deviceId);\n preferences.setAudioDevice('input', inputs[0].deviceId);\n }\n if (outputs.length > 0 && !selectedOutputDevice) {\n setSelectedOutputDevice(outputs[0].deviceId);\n preferences.setAudioDevice('output', outputs[0].deviceId);\n }\n } catch (_error) {\n setHasPermission(false);\n if (showToasts) {\n toast({\n title: 'Audio access denied',\n description: 'Please allow audio access to detect devices',\n variant: 'destructive',\n });\n }\n } finally {\n setIsLoading(false);\n }\n }, [selectedInputDevice, selectedOutputDevice, showToasts]);\n\n /**\n * Set the selected input device and persist to preferences\n */\n const setInputDevice = useCallback((deviceId: string) => {\n setSelectedInputDevice(deviceId);\n preferences.setAudioDevice('input', deviceId);\n if (isTauriEnvironment()) {\n void initializeAPI().then((api) => api.selectAudioDevice(deviceId, true));\n }\n }, []);\n\n /**\n * Set the selected output device and persist to preferences\n */\n const setOutputDevice = useCallback((deviceId: string) => {\n setSelectedOutputDevice(deviceId);\n preferences.setAudioDevice('output', deviceId);\n if (isTauriEnvironment()) {\n void initializeAPI().then((api) => api.selectAudioDevice(deviceId, false));\n }\n }, []);\n\n /**\n * Start testing the selected input device (microphone level visualization)\n */\n const startInputTest = useCallback(async () => {\n if (isTauriEnvironment()) {\n try {\n const { invoke } = await import('@tauri-apps/api/core');\n setIsTestingInput(true);\n await invoke(TauriCommands.START_INPUT_TEST, {\n device_id: selectedInputDevice || null,\n });\n if (showToasts) {\n toast({ title: 'Input test started', description: 'Speak into your microphone' });\n }\n } catch (err) {\n if (showToasts) {\n toast({\n title: 'Failed to test input',\n description: String(err),\n variant: 'destructive',\n });\n }\n setIsTestingInput(false);\n }\n return;\n }\n // Browser implementation\n try {\n setIsTestingInput(true);\n\n const stream = await navigator.mediaDevices.getUserMedia({\n audio: { deviceId: selectedInputDevice ? { exact: selectedInputDevice } : undefined },\n });\n mediaStreamRef.current = stream;\n\n audioContextRef.current = new AudioContext();\n analyserRef.current = audioContextRef.current.createAnalyser();\n const source = audioContextRef.current.createMediaStreamSource(stream);\n source.connect(analyserRef.current);\n analyserRef.current.fftSize = AudioConfig.FFT_SIZE;\n\n const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);\n\n const updateLevel = () => {\n if (!analyserRef.current) {\n return;\n }\n analyserRef.current.getByteFrequencyData(dataArray);\n const avg = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;\n setInputLevel(avg / 255);\n animationFrameRef.current = requestAnimationFrame(updateLevel);\n };\n updateLevel();\n\n if (showToasts) {\n toast({ title: 'Input test started', description: 'Speak into your microphone' });\n }\n } catch {\n if (showToasts) {\n toast({\n title: 'Failed to test input',\n description: 'Could not access microphone',\n variant: 'destructive',\n });\n }\n setIsTestingInput(false);\n }\n }, [selectedInputDevice, showToasts]);\n\n /**\n * Stop the input device test\n */\n const stopInputTest = useCallback(async () => {\n if (isTauriEnvironment()) {\n try {\n const { invoke } = await import('@tauri-apps/api/core');\n await invoke(TauriCommands.STOP_INPUT_TEST);\n } catch {\n // Tauri invoke failed - stop test command is non-critical cleanup\n }\n }\n\n setIsTestingInput(false);\n setInputLevel(0);\n\n if (animationFrameRef.current) {\n cancelAnimationFrame(animationFrameRef.current);\n animationFrameRef.current = null;\n }\n if (mediaStreamRef.current) {\n for (const track of mediaStreamRef.current.getTracks()) {\n track.stop();\n }\n mediaStreamRef.current = null;\n }\n if (audioContextRef.current) {\n audioContextRef.current.close();\n audioContextRef.current = null;\n }\n }, []);\n\n /**\n * Test the output device by playing a tone\n */\n const testOutputDevice = useCallback(async () => {\n if (isTauriEnvironment()) {\n try {\n const { invoke } = await import('@tauri-apps/api/core');\n setIsTestingOutput(true);\n await invoke(TauriCommands.START_OUTPUT_TEST, {\n device_id: selectedOutputDevice || null,\n });\n if (showToasts) {\n toast({ title: 'Output test', description: 'Playing test tone' });\n }\n // Output test auto-stops after 2 seconds\n setTimeout(() => setIsTestingOutput(false), Timing.TWO_SECONDS_MS);\n } catch (err) {\n if (showToasts) {\n toast({\n title: 'Failed to test output',\n description: String(err),\n variant: 'destructive',\n });\n }\n setIsTestingOutput(false);\n }\n return;\n }\n // Browser implementation\n setIsTestingOutput(true);\n try {\n const audioContext = new AudioContext();\n const oscillator = audioContext.createOscillator();\n const gainNode = audioContext.createGain();\n\n oscillator.connect(gainNode);\n gainNode.connect(audioContext.destination);\n\n oscillator.frequency.setValueAtTime(440, audioContext.currentTime);\n gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);\n gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5);\n\n oscillator.start(audioContext.currentTime);\n oscillator.stop(audioContext.currentTime + 0.5);\n\n if (showToasts) {\n toast({ title: 'Output test', description: 'Playing test tone' });\n }\n\n setTimeout(() => {\n setIsTestingOutput(false);\n audioContext.close();\n }, 500);\n } catch {\n if (showToasts) {\n toast({\n title: 'Failed to test output',\n description: 'Could not play audio',\n variant: 'destructive',\n });\n }\n setIsTestingOutput(false);\n }\n }, [selectedOutputDevice, showToasts]);\n\n // Listen for audio test level events from Tauri backend\n useTauriEvent(\n TauriEvents.AUDIO_TEST_LEVEL,\n useCallback(\n (event: AudioTestLevelEvent) => {\n if (isTestingInput) {\n setInputLevel(event.level);\n }\n },\n [isTestingInput]\n ),\n [isTestingInput]\n );\n\n // Auto-load devices on mount if requested\n useEffect(() => {\n if (!autoLoad) {\n autoLoadRef.current = false;\n return;\n }\n if (autoLoadRef.current) {\n return;\n }\n autoLoadRef.current = true;\n void loadDevices();\n }, [autoLoad, loadDevices]);\n\n // Cleanup on unmount\n useEffect(() => {\n return () => {\n void stopInputTest();\n };\n }, [stopInputTest]);\n\n return {\n // Device lists\n inputDevices,\n outputDevices,\n\n // Selected devices\n selectedInputDevice,\n selectedOutputDevice,\n\n // State\n isLoading,\n hasPermission,\n\n // Actions\n loadDevices,\n setInputDevice,\n setOutputDevice,\n\n // Testing\n isTestingInput,\n isTestingOutput,\n inputLevel,\n startInputTest,\n stopInputTest,\n testOutputDevice,\n };\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-auth-flow.ts","messages":[{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":1,"message":"Unsafe assignment of an `any` value.","line":198,"column":19,"nodeType":"VariableDeclarator","messageId":"anyAssignment","endLine":198,"endColumn":67},{"ruleId":"@typescript-eslint/no-unsafe-call","severity":1,"message":"Unsafe call of a(n) `any` typed value.","line":199,"column":19,"nodeType":"MemberExpression","messageId":"unsafeCall","endLine":199,"endColumn":29},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":1,"message":"Unsafe member access .open on an `any` value.","line":199,"column":25,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":199,"endColumn":29}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// User authentication flow hook for OAuth-based login\n// Follows the same patterns as use-oauth-flow.ts for calendar integrations\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { getAPI } from '@/api/interface';\nimport { isTauriEnvironment } from '@/api/tauri-adapter';\nimport type { GetCurrentUserResponse } from '@/api/types';\nimport { toast } from '@/hooks/use-toast';\n\nexport type AuthFlowStatus =\n | 'idle'\n | 'initiating'\n | 'awaiting_callback'\n | 'completing'\n | 'authenticated'\n | 'error';\n\nexport interface AuthFlowState {\n status: AuthFlowStatus;\n provider: string | null;\n authUrl: string | null;\n error: string | null;\n user: GetCurrentUserResponse | null;\n}\n\ninterface UseAuthFlowReturn {\n state: AuthFlowState;\n initiateLogin: (provider: string, redirectUri?: string) => Promise<void>;\n completeLogin: (provider: string, code: string, state: string) => Promise<boolean>;\n checkAuthStatus: () => Promise<GetCurrentUserResponse | null>;\n logout: (provider?: string) => Promise<boolean>;\n reset: () => void;\n}\n\nconst initialState: AuthFlowState = {\n status: 'idle',\n provider: null,\n authUrl: null,\n error: null,\n user: null,\n};\n\n/** Extract OAuth callback URL parameters (code/state). */\nfunction extractOAuthCallback(url: string): { code: string; state: string } | null {\n // Support both /auth/callback and /oauth/callback patterns\n if (!url.includes('noteflow://') || !url.includes('/callback')) {\n return null;\n }\n try {\n const callbackUrl = new URL(url);\n const code = callbackUrl.searchParams.get('code');\n const oauthState = callbackUrl.searchParams.get('state');\n if (code && oauthState) {\n return { code, state: oauthState };\n }\n } catch {\n // Invalid URL\n }\n return null;\n}\n\nexport function useAuthFlow(): UseAuthFlowReturn {\n const [state, setState] = useState<AuthFlowState>(initialState);\n const pendingStateRef = useRef<string | null>(null);\n const processingRef = useRef<boolean>(false); // Guard against race conditions\n const stateRef = useRef<AuthFlowState>(initialState);\n stateRef.current = state;\n\n // Listen for OAuth callback via deep link (Tauri v2)\n useEffect(() => {\n if (!isTauriEnvironment()) {\n return;\n }\n\n let cleanup: (() => void) | undefined;\n\n const setupDeepLinkListener = async () => {\n try {\n // Dynamic import to avoid bundling issues in browser\n type DeepLinkModule = { onOpenUrl: (cb: (urls: string[]) => void) => Promise<() => void> };\n const deepLink = (await import('@tauri-apps/plugin-deep-link')) as DeepLinkModule;\n cleanup = await deepLink.onOpenUrl((urls: string[]) => {\n void handleDeepLinkCallback(urls);\n });\n } catch {\n // Deep link plugin not available - OAuth callback won't be handled automatically\n }\n };\n\n const handleDeepLinkCallback = async (urls: string[]) => {\n // Prevent concurrent processing of callbacks (race condition guard)\n if (processingRef.current) {\n return;\n }\n\n const currentState = stateRef.current;\n for (const url of urls) {\n const params = extractOAuthCallback(url);\n if (params && currentState.status === 'awaiting_callback' && currentState.provider) {\n // Reject if no pending state exists (CSRF protection)\n if (!pendingStateRef.current) {\n toast({\n title: 'Authentication Error',\n description: 'No pending authentication request',\n variant: 'destructive',\n });\n continue;\n }\n\n // Validate state matches pending state (CSRF protection)\n if (params.state !== pendingStateRef.current) {\n toast({\n title: 'Authentication Error',\n description: 'State mismatch - possible CSRF attack',\n variant: 'destructive',\n });\n continue;\n }\n\n const { provider } = currentState;\n processingRef.current = true;\n\n // Complete the login flow\n const api = getAPI();\n setState((prev) => ({ ...prev, status: 'completing' }));\n\n try {\n const response = await api.completeAuthLogin(provider, params.code, params.state);\n if (response.success) {\n const userInfo = await api.getCurrentUser();\n setState((prev) => ({\n ...prev,\n status: 'authenticated',\n user: userInfo,\n }));\n toast({\n title: 'Logged In',\n description: `Successfully logged in with ${provider}`,\n });\n } else {\n throw new Error(response.error_message || 'Login failed');\n }\n } catch (error) {\n const errorMessage =\n error instanceof Error ? error.message : 'Failed to complete login';\n setState((prev) => ({\n ...prev,\n status: 'error',\n error: errorMessage,\n }));\n toast({\n title: 'Login Failed',\n description: errorMessage,\n variant: 'destructive',\n });\n } finally {\n pendingStateRef.current = null;\n processingRef.current = false;\n }\n }\n }\n };\n\n void setupDeepLinkListener();\n\n return () => {\n if (cleanup) {\n cleanup();\n }\n };\n }, []);\n\n const initiateLogin = useCallback(async (provider: string, redirectUri?: string) => {\n setState((prev) => ({\n ...prev,\n status: 'initiating',\n provider,\n error: null,\n }));\n\n try {\n const api = getAPI();\n const response = await api.initiateAuthLogin(provider, redirectUri);\n\n if (response.auth_url) {\n // Store state token for CSRF validation when callback arrives\n pendingStateRef.current = response.state;\n\n setState((prev) => ({\n ...prev,\n status: 'awaiting_callback',\n authUrl: response.auth_url,\n }));\n\n // Open auth URL in default browser\n if (isTauriEnvironment()) {\n try {\n const shell = await import('@tauri-apps/plugin-shell');\n await shell.open(response.auth_url);\n } catch {\n // Fallback if shell plugin not available\n window.open(response.auth_url, '_blank');\n }\n } else {\n window.open(response.auth_url, '_blank');\n }\n } else {\n throw new Error('No auth URL returned from server');\n }\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : 'Failed to initiate login';\n setState((prev) => ({\n ...prev,\n status: 'error',\n error: errorMessage,\n }));\n toast({\n title: 'Login Error',\n description: errorMessage,\n variant: 'destructive',\n });\n }\n }, []);\n\n const completeLogin = useCallback(\n async (provider: string, code: string, oauthState: string): Promise<boolean> => {\n setState((prev) => ({\n ...prev,\n status: 'completing',\n error: null,\n }));\n\n try {\n const api = getAPI();\n const response = await api.completeAuthLogin(provider, code, oauthState);\n\n if (response.success) {\n const userInfo = await api.getCurrentUser();\n setState((prev) => ({\n ...prev,\n status: 'authenticated',\n user: userInfo,\n }));\n toast({\n title: 'Logged In',\n description: `Successfully logged in with ${provider}`,\n });\n return true;\n } else {\n throw new Error(response.error_message || 'Login failed');\n }\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : 'Failed to complete login';\n setState((prev) => ({\n ...prev,\n status: 'error',\n error: errorMessage,\n }));\n toast({\n title: 'Login Failed',\n description: errorMessage,\n variant: 'destructive',\n });\n return false;\n }\n },\n []\n );\n\n const checkAuthStatus = useCallback(async (): Promise<GetCurrentUserResponse | null> => {\n try {\n const api = getAPI();\n const userInfo = await api.getCurrentUser();\n\n setState((prev) => ({\n ...prev,\n user: userInfo,\n status: userInfo.is_authenticated ? 'authenticated' : 'idle',\n provider: userInfo.auth_provider ?? prev.provider,\n }));\n\n return userInfo;\n } catch {\n return null;\n }\n }, []);\n\n const logout = useCallback(async (provider?: string): Promise<boolean> => {\n try {\n const api = getAPI();\n const response = await api.logout(provider);\n\n if (response.success) {\n setState(initialState);\n toast({\n title: 'Logged Out',\n description: 'You have been logged out',\n });\n return true;\n }\n return false;\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : 'Failed to logout';\n toast({\n title: 'Logout Failed',\n description: errorMessage,\n variant: 'destructive',\n });\n return false;\n }\n }, []);\n\n const reset = useCallback(() => {\n setState(initialState);\n }, []);\n\n return {\n state,\n initiateLogin,\n completeLogin,\n checkAuthStatus,\n logout,\n reset,\n };\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-calendar-sync.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-cloud-consent.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-cloud-consent.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-diarization.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-diarization.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-entity-extraction.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-guarded-mutation.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-guarded-mutation.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-integration-sync.test.ts","messages":[{"ruleId":"@typescript-eslint/no-unsafe-call","severity":1,"message":"Unsafe call of a(n) `error` type typed value.","line":55,"column":8,"nodeType":"MemberExpression","messageId":"unsafeCall","endLine":55,"endColumn":21}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { act, renderHook } from '@testing-library/react';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport * as apiInterface from '@/api/interface';\nimport type { Integration } from '@/api/types';\nimport { preferences } from '@/lib/preferences';\nimport { toast } from '@/hooks/use-toast';\nimport { SYNC_POLL_INTERVAL_MS, SYNC_TIMEOUT_MS } from '@/lib/timing-constants';\nimport { useIntegrationSync } from './use-integration-sync';\n\n// Mock the API module\nvi.mock('@/api/interface', () => ({\n getAPI: vi.fn(),\n}));\n\n// Mock preferences\nvi.mock('@/lib/preferences', () => ({\n preferences: {\n getSyncNotifications: vi.fn(() => ({\n enabled: false,\n notify_on_success: false,\n notify_on_error: false,\n notify_via_toast: false,\n })),\n isSyncSchedulerPaused: vi.fn(() => false),\n setSyncSchedulerPaused: vi.fn(),\n addSyncHistoryEvent: vi.fn(),\n updateIntegration: vi.fn(),\n },\n}));\n\n// Mock toast\nvi.mock('@/hooks/use-toast', () => ({\n toast: vi.fn(),\n}));\n\n// Mock generateId\nvi.mock('@/api/mock-data', () => ({\n generateId: vi.fn(() => 'test-id'),\n}));\n\nfunction createMockIntegration(overrides: Partial<Integration> = {}): Integration {\n const base: Integration = {\n id: 'int-1',\n integration_id: 'int-1',\n name: 'Test Calendar',\n type: 'calendar',\n status: 'connected',\n last_sync: null,\n calendar_config: {\n provider: 'google',\n sync_interval_minutes: 15,\n },\n };\n const integration: Integration = { ...base, ...overrides };\n if (!Object.hasOwn(overrides, 'integration_id')) {\n integration.integration_id = integration.id;\n }\n return integration;\n}\n\ndescribe('useIntegrationSync', () => {\n const mockAPI = {\n startIntegrationSync: vi.fn(),\n getSyncStatus: vi.fn(),\n listSyncHistory: vi.fn(),\n };\n\n beforeEach(() => {\n vi.useFakeTimers();\n vi.mocked(apiInterface.getAPI).mockReturnValue(\n mockAPI as unknown as ReturnType<typeof apiInterface.getAPI>\n );\n vi.mocked(preferences.getSyncNotifications).mockReturnValue({\n enabled: false,\n notify_on_success: false,\n notify_on_error: false,\n notify_via_toast: false,\n });\n vi.clearAllMocks();\n vi.mocked(preferences.isSyncSchedulerPaused).mockReturnValue(false);\n });\n\n afterEach(() => {\n vi.useRealTimers();\n vi.restoreAllMocks();\n });\n\n describe('initialization', () => {\n it('starts with empty sync states', () => {\n const { result } = renderHook(() => useIntegrationSync());\n\n expect(result.current.syncStates).toEqual({});\n expect(result.current.isSchedulerRunning).toBe(false);\n expect(result.current.isPaused).toBe(false);\n });\n });\n\n describe('startScheduler', () => {\n it('initializes sync states for connected calendar integrations', () => {\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration({ id: 'cal-1', name: 'Google Calendar' })];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n expect(result.current.isSchedulerRunning).toBe(true);\n expect(result.current.syncStates['cal-1']).toBeDefined();\n expect(result.current.syncStates['cal-1'].status).toBe('idle');\n expect(result.current.syncStates['cal-1'].integrationName).toBe('Google Calendar');\n });\n\n it('ignores disconnected integrations', () => {\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [\n createMockIntegration({ id: 'cal-1', status: 'disconnected' }),\n createMockIntegration({ id: 'cal-2', status: 'connected' }),\n ];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n expect(result.current.syncStates['cal-1']).toBeUndefined();\n expect(result.current.syncStates['cal-2']).toBeDefined();\n });\n\n it('ignores non-syncable integration types', () => {\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [\n createMockIntegration({ id: 'int-1', type: 'webhook' as Integration['type'] }),\n createMockIntegration({ id: 'cal-1', type: 'calendar' }),\n ];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n expect(result.current.syncStates['int-1']).toBeUndefined();\n expect(result.current.syncStates['cal-1']).toBeDefined();\n });\n\n it('ignores integrations without server IDs', () => {\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration({ id: 'cal-1', integration_id: undefined })];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n expect(result.current.syncStates['cal-1']).toBeUndefined();\n });\n\n it('ignores PKM integrations with sync disabled', () => {\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [\n createMockIntegration({\n id: 'pkm-1',\n type: 'pkm',\n pkm_config: { sync_enabled: false },\n }),\n ];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n expect(result.current.syncStates['pkm-1']).toBeUndefined();\n });\n\n it('initializes PKM integrations with last sync timestamps', () => {\n vi.setSystemTime(new Date(2024, 0, 1, 0, 0, 0));\n const { result } = renderHook(() => useIntegrationSync());\n\n const lastSync = Date.now() - 60 * 60 * 1000;\n const integrations = [\n createMockIntegration({\n id: 'pkm-1',\n type: 'pkm',\n last_sync: lastSync,\n pkm_config: { sync_enabled: true },\n }),\n ];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n const state = result.current.syncStates['pkm-1'];\n expect(state).toBeDefined();\n expect(state.nextSync).toBe(lastSync + 30 * 60 * 1000);\n });\n\n it('schedules initial sync when never synced and not paused', async () => {\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'success',\n items_synced: 1,\n duration_ms: 100,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n\n const integration = createMockIntegration({ id: 'cal-1', last_sync: null });\n act(() => {\n result.current.startScheduler([integration]);\n });\n await act(async () => {\n await vi.advanceTimersByTimeAsync(5000);\n });\n\n expect(mockAPI.startIntegrationSync).toHaveBeenCalledWith(integration.integration_id);\n });\n\n it('does not schedule initial sync when paused', async () => {\n vi.mocked(preferences.isSyncSchedulerPaused).mockReturnValue(true);\n const { result } = renderHook(() => useIntegrationSync());\n\n act(() => {\n result.current.startScheduler([createMockIntegration({ id: 'cal-1', last_sync: null })]);\n });\n\n await act(async () => {\n await vi.advanceTimersByTimeAsync(5000);\n });\n\n expect(mockAPI.startIntegrationSync).not.toHaveBeenCalled();\n });\n });\n\n describe('stopScheduler', () => {\n it('stops the scheduler and clears intervals', () => {\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration()];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n expect(result.current.isSchedulerRunning).toBe(true);\n\n act(() => {\n result.current.stopScheduler();\n });\n\n expect(result.current.isSchedulerRunning).toBe(false);\n });\n });\n\n describe('pauseScheduler', () => {\n it('pauses the scheduler', async () => {\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration()];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n act(() => {\n result.current.pauseScheduler();\n });\n\n expect(result.current.isPaused).toBe(true);\n });\n });\n\n describe('resumeScheduler', () => {\n it('resumes a paused scheduler', () => {\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration()];\n\n act(() => {\n result.current.startScheduler(integrations);\n result.current.pauseScheduler();\n });\n\n expect(result.current.isPaused).toBe(true);\n\n act(() => {\n result.current.resumeScheduler();\n });\n\n expect(result.current.isPaused).toBe(false);\n });\n });\n\n describe('triggerSync', () => {\n it('returns early when integration is missing', async () => {\n const { result } = renderHook(() => useIntegrationSync());\n\n act(() => {\n result.current.startScheduler([createMockIntegration({ id: 'cal-1' })]);\n });\n\n await act(async () => {\n await result.current.triggerSync('missing');\n });\n\n expect(mockAPI.startIntegrationSync).not.toHaveBeenCalled();\n });\n\n it('returns early for unsupported integration types', async () => {\n const { result } = renderHook(() => useIntegrationSync());\n const webhookIntegration = createMockIntegration({\n id: 'webhook-1',\n type: 'webhook' as Integration['type'],\n });\n\n act(() => {\n result.current.startScheduler([webhookIntegration]);\n });\n\n await act(async () => {\n await result.current.triggerSync('webhook-1');\n });\n\n expect(mockAPI.startIntegrationSync).not.toHaveBeenCalled();\n });\n it('sets syncing status and calls API', async () => {\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'success',\n items_synced: 10,\n duration_ms: 500,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration({ id: 'cal-1' })];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n // Trigger sync\n let syncPromise: Promise<void>;\n act(() => {\n syncPromise = result.current.triggerSync('cal-1');\n });\n\n // Should be syncing\n expect(result.current.syncStates['cal-1'].status).toBe('syncing');\n\n // Complete the sync\n await act(async () => {\n await syncPromise;\n });\n\n expect(mockAPI.startIntegrationSync).toHaveBeenCalledWith(integrations[0].integration_id);\n });\n\n it('updates state to success on successful sync', async () => {\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'success',\n items_synced: 5,\n duration_ms: 300,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration({ id: 'cal-1' })];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n expect(result.current.syncStates['cal-1'].status).toBe('success');\n expect(result.current.syncStates['cal-1'].lastSync).toBeDefined();\n expect(result.current.syncStates['cal-1'].nextSync).toBeDefined();\n });\n\n it('updates state to error on failed sync', async () => {\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'error',\n error_message: 'Connection timeout',\n duration_ms: 5000,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration({ id: 'cal-1' })];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n expect(result.current.syncStates['cal-1'].status).toBe('error');\n expect(result.current.syncStates['cal-1'].error).toBe('Connection timeout');\n });\n\n it('uses fallback error message when sync error is missing', async () => {\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'error',\n error_message: '',\n duration_ms: 5000,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n\n act(() => {\n result.current.startScheduler([createMockIntegration({ id: 'cal-1' })]);\n });\n\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n expect(result.current.syncStates['cal-1'].status).toBe('error');\n expect(result.current.syncStates['cal-1'].error).toBe('Sync failed');\n });\n\n it('handles API errors gracefully', async () => {\n mockAPI.startIntegrationSync.mockRejectedValue(new Error('Network error'));\n\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration({ id: 'cal-1' })];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n expect(result.current.syncStates['cal-1'].status).toBe('error');\n expect(result.current.syncStates['cal-1'].error).toBe('Network error');\n });\n\n it('does not sync when paused', async () => {\n mockAPI.startIntegrationSync.mockResolvedValue({ sync_run_id: 'run-1' });\n\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration({ id: 'cal-1' })];\n\n act(() => {\n result.current.startScheduler(integrations);\n result.current.pauseScheduler();\n });\n\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n // API should not be called when paused\n expect(mockAPI.startIntegrationSync).not.toHaveBeenCalled();\n });\n\n it('times out when sync never completes', async () => {\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'running',\n items_synced: 0,\n duration_ms: 0,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration({ id: 'cal-1' })];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n let syncPromise: Promise<void>;\n act(() => {\n syncPromise = result.current.triggerSync('cal-1');\n });\n\n await act(async () => {\n await vi.advanceTimersByTimeAsync(SYNC_TIMEOUT_MS + SYNC_POLL_INTERVAL_MS);\n await syncPromise;\n });\n\n expect(result.current.syncStates['cal-1'].status).toBe('error');\n expect(result.current.syncStates['cal-1'].error).toBe('Sync timed out');\n });\n });\n\n describe('notifications', () => {\n it('shows toast on successful sync when enabled and outside quiet hours', async () => {\n vi.setSystemTime(new Date(2024, 0, 1, 20, 0, 0));\n vi.mocked(preferences.getSyncNotifications).mockReturnValue({\n enabled: true,\n notify_on_success: true,\n notify_on_error: true,\n notify_via_toast: true,\n notify_via_email: false,\n quiet_hours_enabled: true,\n quiet_hours_start: '09:00',\n quiet_hours_end: '17:00',\n });\n\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'success',\n items_synced: 1,\n duration_ms: 100,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n act(() => {\n result.current.startScheduler([createMockIntegration({ id: 'cal-1' })]);\n });\n\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n expect(toast).toHaveBeenCalled();\n });\n\n it('shows error toast when error notifications are enabled', async () => {\n vi.mocked(preferences.getSyncNotifications).mockReturnValue({\n enabled: true,\n notify_on_success: true,\n notify_on_error: true,\n notify_via_toast: true,\n notify_via_email: true,\n notification_email: 'user@example.com',\n quiet_hours_enabled: false,\n });\n\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'error',\n error_message: 'Boom',\n duration_ms: 100,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n act(() => {\n result.current.startScheduler([createMockIntegration({ id: 'cal-1' })]);\n });\n\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n expect(toast).toHaveBeenCalled();\n });\n\n it('returns early when notifications are disabled', async () => {\n vi.mocked(preferences.getSyncNotifications).mockReturnValue({\n enabled: false,\n notify_on_success: true,\n notify_on_error: true,\n notify_via_toast: true,\n quiet_hours_enabled: false,\n });\n\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'success',\n items_synced: 1,\n duration_ms: 100,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n act(() => {\n result.current.startScheduler([createMockIntegration({ id: 'cal-1' })]);\n });\n\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n expect(toast).not.toHaveBeenCalled();\n });\n it('suppresses toast notifications during quiet hours', async () => {\n vi.setSystemTime(new Date(2024, 0, 1, 23, 0, 0));\n vi.mocked(preferences.getSyncNotifications).mockReturnValue({\n enabled: true,\n notify_on_success: true,\n notify_on_error: true,\n notify_via_toast: true,\n notify_via_email: false,\n quiet_hours_enabled: true,\n quiet_hours_start: '22:00',\n quiet_hours_end: '08:00',\n });\n\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'success',\n items_synced: 1,\n duration_ms: 100,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n const integrations = [createMockIntegration({ id: 'cal-1' })];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n expect(toast).not.toHaveBeenCalled();\n });\n\n it('skips toast when notifications disabled', async () => {\n vi.mocked(preferences.getSyncNotifications).mockReturnValue({\n enabled: true,\n notify_on_success: true,\n notify_on_error: true,\n notify_via_toast: false,\n notify_via_email: true,\n notification_email: 'user@example.com',\n quiet_hours_enabled: false,\n });\n\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'success',\n items_synced: 1,\n duration_ms: 100,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n const integrations = [createMockIntegration({ id: 'cal-1' })];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n expect(toast).not.toHaveBeenCalled();\n });\n });\n\n describe('triggerSyncAll', () => {\n it('triggers sync for all integrations', async () => {\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'success',\n items_synced: 1,\n duration_ms: 100,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [\n createMockIntegration({ id: 'cal-1' }),\n createMockIntegration({ id: 'cal-2' }),\n ];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n await act(async () => {\n await result.current.triggerSyncAll();\n });\n\n expect(mockAPI.startIntegrationSync).toHaveBeenCalledWith(integrations[0].integration_id);\n expect(mockAPI.startIntegrationSync).toHaveBeenCalledWith(integrations[1].integration_id);\n });\n\n it('does not sync when paused', async () => {\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration({ id: 'cal-1' })];\n\n act(() => {\n result.current.startScheduler(integrations);\n result.current.pauseScheduler();\n });\n\n await act(async () => {\n await result.current.triggerSyncAll();\n });\n\n expect(mockAPI.startIntegrationSync).not.toHaveBeenCalled();\n });\n });\n\n describe('sync polling', () => {\n it('handles multiple sync status calls', async () => {\n vi.useRealTimers(); // Use real timers for this async test\n\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n\n // Return success immediately\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'success',\n items_synced: 10,\n duration_ms: 1500,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration({ id: 'cal-1' })];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n // Should have called getSyncStatus at least once\n expect(mockAPI.getSyncStatus).toHaveBeenCalled();\n expect(result.current.syncStates['cal-1'].status).toBe('success');\n });\n\n it('polls until sync completes when initial status is running', async () => {\n vi.useRealTimers();\n\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n\n // First call returns running, second returns success\n let callCount = 0;\n mockAPI.getSyncStatus.mockImplementation(() => {\n callCount++;\n if (callCount === 1) {\n return Promise.resolve({\n status: 'running',\n items_synced: 0,\n duration_ms: 0,\n });\n }\n return Promise.resolve({\n status: 'success',\n items_synced: 5,\n duration_ms: 200,\n });\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration({ id: 'cal-1' })];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n expect(mockAPI.getSyncStatus).toHaveBeenCalledTimes(2);\n expect(result.current.syncStates['cal-1'].status).toBe('success');\n });\n\n it('completes sync and updates last sync time', async () => {\n vi.useRealTimers();\n\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'success',\n items_synced: 42,\n duration_ms: 1000,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration({ id: 'cal-1' })];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n const beforeSync = Date.now();\n\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n // Verify lastSync was updated to a recent timestamp\n const state = result.current.syncStates['cal-1'];\n expect(state.lastSync).toBeDefined();\n expect(state.lastSync).toBeGreaterThanOrEqual(beforeSync);\n });\n });\n\n describe('multiple syncs', () => {\n it('allows sequential syncs to complete independently', async () => {\n vi.useRealTimers();\n\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'success',\n items_synced: 5,\n duration_ms: 100,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration({ id: 'cal-1' })];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n // First sync\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n expect(result.current.syncStates['cal-1'].status).toBe('success');\n const firstSyncTime = result.current.syncStates['cal-1'].lastSync;\n expect(firstSyncTime).not.toBeNull();\n\n // Wait a bit\n await new Promise((resolve) => setTimeout(resolve, 10));\n\n // Second sync\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n expect(result.current.syncStates['cal-1'].status).toBe('success');\n const secondSyncTime = result.current.syncStates['cal-1'].lastSync;\n\n // Second sync should have a later timestamp (firstSyncTime verified non-null above)\n expect(secondSyncTime).toBeGreaterThan(firstSyncTime as number);\n expect(mockAPI.startIntegrationSync).toHaveBeenCalledTimes(2);\n });\n });\n\n describe('sync state transitions', () => {\n it('transitions through idle -> syncing -> success', async () => {\n vi.useRealTimers();\n\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'success',\n items_synced: 3,\n duration_ms: 500,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration({ id: 'cal-1' })];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n // Initial state should be idle\n expect(result.current.syncStates['cal-1'].status).toBe('idle');\n\n // Start sync\n let syncPromise: Promise<void>;\n act(() => {\n syncPromise = result.current.triggerSync('cal-1');\n });\n\n // Should be syncing immediately after triggering\n expect(result.current.syncStates['cal-1'].status).toBe('syncing');\n\n await act(async () => {\n await syncPromise;\n });\n\n // Should be success after completion\n expect(result.current.syncStates['cal-1'].status).toBe('success');\n });\n\n it('transitions through idle -> syncing -> error on failure', async () => {\n vi.useRealTimers();\n\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'error',\n error_message: 'Token expired',\n duration_ms: 100,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration({ id: 'cal-1' })];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n expect(result.current.syncStates['cal-1'].status).toBe('idle');\n\n let syncPromise: Promise<void>;\n act(() => {\n syncPromise = result.current.triggerSync('cal-1');\n });\n\n expect(result.current.syncStates['cal-1'].status).toBe('syncing');\n\n await act(async () => {\n await syncPromise;\n });\n\n expect(result.current.syncStates['cal-1'].status).toBe('error');\n expect(result.current.syncStates['cal-1'].error).toBe('Token expired');\n });\n\n it('can recover from error and sync successfully', async () => {\n vi.useRealTimers();\n\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n\n // First sync fails\n mockAPI.getSyncStatus.mockResolvedValueOnce({\n status: 'error',\n error_message: 'Network error',\n duration_ms: 100,\n });\n\n // Second sync succeeds\n mockAPI.getSyncStatus.mockResolvedValueOnce({\n status: 'success',\n items_synced: 10,\n duration_ms: 500,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration({ id: 'cal-1' })];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n // First sync - should fail\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n expect(result.current.syncStates['cal-1'].status).toBe('error');\n\n // Second sync - should succeed\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n expect(result.current.syncStates['cal-1'].status).toBe('success');\n });\n });\n\n describe('next sync scheduling', () => {\n it('calculates next sync time based on interval', async () => {\n vi.useRealTimers();\n\n mockAPI.startIntegrationSync.mockResolvedValue({\n sync_run_id: 'run-1',\n status: 'running',\n });\n\n mockAPI.getSyncStatus.mockResolvedValue({\n status: 'success',\n items_synced: 1,\n duration_ms: 100,\n });\n\n const { result } = renderHook(() => useIntegrationSync());\n\n const integrations = [\n createMockIntegration({\n id: 'cal-1',\n calendar_config: {\n provider: 'google',\n sync_interval_minutes: 30,\n },\n }),\n ];\n\n act(() => {\n result.current.startScheduler(integrations);\n });\n\n const beforeSync = Date.now();\n\n await act(async () => {\n await result.current.triggerSync('cal-1');\n });\n\n const state = result.current.syncStates['cal-1'];\n expect(state.nextSync).toBeDefined();\n expect(typeof state.nextSync).toBe('number');\n\n // Next sync should be in the future (timestamp is a number)\n expect(state.nextSync).toBeGreaterThan(beforeSync);\n\n // Next sync should be approximately 30 minutes (configured interval) in the future\n const expectedNextSync = beforeSync + 30 * 60 * 1000;\n // Allow some tolerance for test execution time\n expect(state.nextSync).toBeGreaterThanOrEqual(expectedNextSync - 1000);\n expect(state.nextSync).toBeLessThanOrEqual(expectedNextSync + 5000);\n });\n });\n\n describe('cleanup', () => {\n it('clears intervals on unmount', async () => {\n vi.useRealTimers(); // Use real timers for unmount test\n\n const { result, unmount } = renderHook(() => useIntegrationSync());\n\n const integrations = [createMockIntegration()];\n\n await act(async () => {\n result.current.startScheduler(integrations);\n });\n\n // Scheduler should be running\n expect(result.current.isSchedulerRunning).toBe(true);\n\n // Unmount should clear intervals\n unmount();\n\n // No errors should occur - test passes if we get here\n });\n });\n});\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-integration-sync.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-integration-validation.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-meeting-reminders.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-mobile.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-oauth-flow.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-oauth-flow.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-oidc-providers.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-oidc-providers.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-panel-preferences.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-panel-preferences.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-post-processing.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-post-processing.ts","messages":[{"ruleId":"@typescript-eslint/no-unsafe-argument","severity":1,"message":"Unsafe argument of type `any` assigned to a parameter of type `MeetingState`.","line":425,"column":67,"nodeType":"Identifier","messageId":"unsafeArgument","endLine":425,"endColumn":79},{"ruleId":"@typescript-eslint/no-unsafe-argument","severity":1,"message":"Unsafe argument of type `any` assigned to a parameter of type `ProcessingStatus | undefined`.","line":425,"column":81,"nodeType":"Identifier","messageId":"unsafeArgument","endLine":425,"endColumn":97}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Post-Processing Orchestration Hook (GAP-W05)\n *\n * Manages post-recording processing lifecycle:\n * - Summary generation\n * - Entity extraction\n * - Speaker diarization\n *\n * Runs all three processing steps in parallel using Promise.allSettled,\n * allowing each step to succeed or fail independently.\n */\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { initializeAPI } from '@/api';\nimport type { DiarizationJobStatus } from '@/api/types';\nimport { toast } from '@/hooks/use-toast';\nimport { usePostProcessingEvents } from './post-processing/events';\nimport type {\n PostProcessingState,\n StepState,\n UsePostProcessingOptions,\n UsePostProcessingReturn,\n} from './post-processing/state';\nimport {\n computeOverallStatus,\n DEFAULT_POLL_INTERVAL_MS,\n INITIAL_STATE,\n INITIAL_STEP_STATE,\n MAX_POLL_DURATION_MS,\n MAX_POLL_INTERVAL_MS,\n POLL_BACKOFF_MULTIPLIER,\n shouldAutoStartProcessing,\n} from './post-processing/state';\n\nexport type {\n PostProcessingState,\n StepState,\n UsePostProcessingOptions,\n UsePostProcessingReturn,\n} from './post-processing/state';\n\n\n/**\n * Hook for orchestrating post-processing after recording stops\n */\nexport function usePostProcessing(options: UsePostProcessingOptions = {}): UsePostProcessingReturn {\n const {\n onComplete,\n onStepError,\n onStepComplete,\n showToasts = true,\n numSpeakers,\n pollInterval = DEFAULT_POLL_INTERVAL_MS,\n } = options;\n\n const [state, setState] = useState<PostProcessingState>(INITIAL_STATE);\n\n // Refs for async operation management\n const isMountedRef = useRef(true);\n const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const currentPollIntervalRef = useRef(pollInterval);\n const pollStartTimeRef = useRef<number | null>(null);\n const diarizationJobIdRef = useRef<string | null>(null);\n const completedMeetingRef = useRef<string | null>(null);\n\n /** Stop diarization polling */\n const stopPolling = useCallback(() => {\n if (pollTimeoutRef.current) {\n clearTimeout(pollTimeoutRef.current);\n pollTimeoutRef.current = null;\n }\n currentPollIntervalRef.current = pollInterval;\n pollStartTimeRef.current = null;\n }, [pollInterval]);\n\n /** Update a specific step's state */\n const updateStepState = useCallback(\n (step: 'summary' | 'entities' | 'diarization', update: Partial<StepState>) => {\n setState((prev) => {\n const newStepState = { ...prev[step], ...update };\n const newState = { ...prev, [step]: newStepState };\n newState.overallStatus = computeOverallStatus(\n step === 'summary' ? newStepState : newState.summary,\n step === 'entities' ? newStepState : newState.entities,\n step === 'diarization' ? newStepState : newState.diarization\n );\n newState.isActive = newState.overallStatus === 'processing';\n return newState;\n });\n },\n []\n );\n\n /** Poll for diarization job status */\n const pollDiarization = useCallback(\n async (jobId: string) => {\n if (!isMountedRef.current) {\n return;\n }\n\n // Check max poll duration\n if (pollStartTimeRef.current !== null) {\n const elapsed = Date.now() - pollStartTimeRef.current;\n if (elapsed > MAX_POLL_DURATION_MS) {\n stopPolling();\n const error = 'Diarization polling timed out';\n updateStepState('diarization', {\n status: 'failed',\n error,\n completedAt: Date.now(),\n });\n onStepError?.('diarization', error);\n return;\n }\n }\n\n try {\n const api = await initializeAPI();\n const status: DiarizationJobStatus = await api.getDiarizationJobStatus(jobId);\n\n if (!isMountedRef.current) {\n return;\n }\n\n if (status.status === 'completed') {\n stopPolling();\n updateStepState('diarization', {\n status: 'completed',\n completedAt: Date.now(),\n });\n onStepComplete?.('diarization');\n if (showToasts) {\n toast({\n title: 'Speaker detection complete',\n description: `Identified ${status.speaker_ids?.length ?? 0} speakers`,\n });\n }\n return;\n }\n\n if (status.status === 'failed') {\n stopPolling();\n const error = status.error_message || 'Diarization failed';\n updateStepState('diarization', {\n status: 'failed',\n error,\n completedAt: Date.now(),\n });\n onStepError?.('diarization', error);\n if (showToasts) {\n toast({\n title: 'Speaker detection failed',\n description: error,\n variant: 'destructive',\n });\n }\n return;\n }\n\n if (status.status === 'cancelled') {\n stopPolling();\n updateStepState('diarization', {\n status: 'skipped',\n completedAt: Date.now(),\n });\n return;\n }\n\n // Continue polling with backoff\n currentPollIntervalRef.current = Math.min(\n currentPollIntervalRef.current * POLL_BACKOFF_MULTIPLIER,\n MAX_POLL_INTERVAL_MS\n );\n pollTimeoutRef.current = setTimeout(\n () => pollDiarization(jobId),\n currentPollIntervalRef.current\n );\n } catch {\n // Network error - continue polling\n currentPollIntervalRef.current = Math.min(\n currentPollIntervalRef.current * POLL_BACKOFF_MULTIPLIER,\n MAX_POLL_INTERVAL_MS\n );\n pollTimeoutRef.current = setTimeout(\n () => pollDiarization(jobId),\n currentPollIntervalRef.current\n );\n }\n },\n [onStepComplete, onStepError, showToasts, stopPolling, updateStepState]\n );\n\n /** Run summary generation */\n const runSummary = useCallback(\n async (meetingId: string): Promise<void> => {\n updateStepState('summary', {\n status: 'running',\n startedAt: Date.now(),\n error: null,\n });\n\n try {\n const api = await initializeAPI();\n await api.generateSummary(meetingId, false);\n\n if (!isMountedRef.current) {\n return;\n }\n\n updateStepState('summary', {\n status: 'completed',\n completedAt: Date.now(),\n });\n onStepComplete?.('summary');\n if (showToasts) {\n toast({\n title: 'Summary generated',\n description: 'Meeting summary is ready',\n });\n }\n } catch (error) {\n if (!isMountedRef.current) {\n return;\n }\n\n const message = error instanceof Error ? error.message : 'Summary generation failed';\n updateStepState('summary', {\n status: 'failed',\n error: message,\n completedAt: Date.now(),\n });\n onStepError?.('summary', message);\n if (showToasts) {\n toast({\n title: 'Summary failed',\n description: message,\n variant: 'destructive',\n });\n }\n }\n },\n [onStepComplete, onStepError, showToasts, updateStepState]\n );\n\n /** Run entity extraction */\n const runEntities = useCallback(\n async (meetingId: string): Promise<void> => {\n updateStepState('entities', {\n status: 'running',\n startedAt: Date.now(),\n error: null,\n });\n\n try {\n const api = await initializeAPI();\n await api.extractEntities(meetingId, false);\n\n if (!isMountedRef.current) {\n return;\n }\n\n updateStepState('entities', {\n status: 'completed',\n completedAt: Date.now(),\n });\n onStepComplete?.('entities');\n if (showToasts) {\n toast({\n title: 'Entities extracted',\n description: 'Named entities identified',\n });\n }\n } catch (error) {\n if (!isMountedRef.current) {\n return;\n }\n\n const message = error instanceof Error ? error.message : 'Entity extraction failed';\n updateStepState('entities', {\n status: 'failed',\n error: message,\n completedAt: Date.now(),\n });\n onStepError?.('entities', message);\n if (showToasts) {\n toast({\n title: 'Entity extraction failed',\n description: message,\n variant: 'destructive',\n });\n }\n }\n },\n [onStepComplete, onStepError, showToasts, updateStepState]\n );\n\n /** Run speaker diarization */\n const runDiarization = useCallback(\n async (meetingId: string): Promise<void> => {\n updateStepState('diarization', {\n status: 'running',\n startedAt: Date.now(),\n error: null,\n });\n\n try {\n const api = await initializeAPI();\n const response = await api.refineSpeakers(meetingId, numSpeakers);\n\n if (!isMountedRef.current) {\n return;\n }\n\n // If job is queued or running, start polling\n if (response.status === 'queued' || response.status === 'running') {\n diarizationJobIdRef.current = response.job_id;\n pollStartTimeRef.current = Date.now();\n pollTimeoutRef.current = setTimeout(() => pollDiarization(response.job_id), pollInterval);\n return;\n }\n\n // If already completed\n if (response.status === 'completed') {\n updateStepState('diarization', {\n status: 'completed',\n completedAt: Date.now(),\n });\n onStepComplete?.('diarization');\n if (showToasts) {\n toast({\n title: 'Speaker detection complete',\n description: `Identified ${response.speaker_ids?.length ?? 0} speakers`,\n });\n }\n return;\n }\n\n // If failed\n if (response.status === 'failed') {\n const error = response.error_message || 'Diarization failed';\n updateStepState('diarization', {\n status: 'failed',\n error,\n completedAt: Date.now(),\n });\n onStepError?.('diarization', error);\n if (showToasts) {\n toast({\n title: 'Speaker detection failed',\n description: error,\n variant: 'destructive',\n });\n }\n }\n } catch (error) {\n if (!isMountedRef.current) {\n return;\n }\n\n const message = error instanceof Error ? error.message : 'Failed to start diarization';\n updateStepState('diarization', {\n status: 'failed',\n error: message,\n completedAt: Date.now(),\n });\n onStepError?.('diarization', message);\n if (showToasts) {\n toast({\n title: 'Speaker detection failed',\n description: message,\n variant: 'destructive',\n });\n }\n }\n },\n [\n numSpeakers,\n onStepComplete,\n onStepError,\n pollDiarization,\n pollInterval,\n showToasts,\n updateStepState,\n ]\n );\n\n /** Start all processing steps in parallel */\n const start = useCallback(\n async (meetingId: string): Promise<void> => {\n // Reset state\n stopPolling();\n completedMeetingRef.current = null;\n setState({\n ...INITIAL_STATE,\n meetingId,\n summary: { ...INITIAL_STEP_STATE, status: 'pending' },\n entities: { ...INITIAL_STEP_STATE, status: 'pending' },\n diarization: { ...INITIAL_STEP_STATE, status: 'pending' },\n overallStatus: 'processing',\n isActive: true,\n });\n\n // Run all steps in parallel - each succeeds/fails independently\n await Promise.allSettled([\n runSummary(meetingId),\n runEntities(meetingId),\n runDiarization(meetingId),\n ]);\n\n // Note: diarization may still be polling - final completion handled there\n },\n [runDiarization, runEntities, runSummary, stopPolling]\n );\n\n /** Reset all state */\n const reset = useCallback(() => {\n stopPolling();\n diarizationJobIdRef.current = null;\n completedMeetingRef.current = null;\n setState(INITIAL_STATE);\n }, [stopPolling]);\n\n /** Check if auto-start should trigger */\n const shouldAutoStart = useCallback(\n (meetingState, processingStatus) => shouldAutoStartProcessing(meetingState, processingStatus),\n []\n );\n\n // Call onComplete when processing finishes\n useEffect(() => {\n if (\n state.meetingId &&\n (state.overallStatus === 'completed' ||\n state.overallStatus === 'partial' ||\n state.overallStatus === 'failed')\n ) {\n // Only call if all steps are in terminal state (diarization polling complete)\n const allTerminal =\n ['completed', 'failed', 'skipped'].includes(state.summary.status) &&\n ['completed', 'failed', 'skipped'].includes(state.entities.status) &&\n ['completed', 'failed', 'skipped'].includes(state.diarization.status);\n\n if (allTerminal && completedMeetingRef.current !== state.meetingId) {\n completedMeetingRef.current = state.meetingId;\n onComplete?.(state);\n }\n }\n }, [state, onComplete]);\n\n usePostProcessingEvents({\n meetingId: state.meetingId,\n updateStepState,\n stopPolling,\n onStepComplete,\n onStepError,\n });\n\n // Cleanup on unmount\n useEffect(() => {\n isMountedRef.current = true;\n return () => {\n isMountedRef.current = false;\n stopPolling();\n };\n }, [stopPolling]);\n\n return {\n state,\n start,\n reset,\n shouldAutoStart,\n };\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-preferences-sync.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-project-members.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-project.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-recording-app-policy.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-secure-integration-secrets.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-toast.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-toast.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/hooks/use-webhooks.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/ai-models.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/ai-providers.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/cache/meeting-cache.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/cache/meeting-cache.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/client-log-events.integration.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/client-log-events.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/client-log-events.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/client-logs.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/client-logs.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/config/app-config.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/config/config.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/config/defaults.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/config/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/config/provider-endpoints.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/config/server.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/crypto.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/crypto.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/cva.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/cva.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/default-integrations.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/entity-store.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/entity-store.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/format.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/format.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/integration-utils.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/integration-utils.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/log-converters.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/log-converters.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/log-group-summarizer.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/log-group-summarizer.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/log-groups.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/log-groups.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/log-messages.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/log-messages.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/log-summarizer.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/log-summarizer.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/object-utils.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/object-utils.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/preferences-sync.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/preferences-sync.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/preferences-validation.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/preferences.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/preferences.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/speaker-utils.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/speaker-utils.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/status-constants.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/styles.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/tauri-events.test.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/tauri-events.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/time.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/timing-constants.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/utils.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/lib/utils.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/main.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/Analytics.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/Home.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/Index.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/MeetingDetail.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/Meetings.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/NotFound.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/People.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/ProjectSettings.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/Projects.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/Recording.logic.test.tsx","messages":[{"ruleId":"@typescript-eslint/no-unsafe-return","severity":1,"message":"Unsafe return of a value of type `any`.","line":102,"column":34,"nodeType":"CallExpression","messageId":"unsafeReturn","endLine":102,"endColumn":48}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\nimport type { TranscriptUpdate } from '@/api/types';\nimport { TauriEvents } from '@/api/tauri-adapter';\n\nlet isTauri = false;\nlet simulateTranscription = false;\nlet isConnected = true;\nlet params: { id?: string } = { id: 'new' };\n\nconst navigate = vi.fn();\nconst guard = vi.fn(async (fn: () => Promise<void>) => fn());\n\nconst apiInstance = {\n createMeeting: vi.fn(),\n getMeeting: vi.fn(),\n startTranscription: vi.fn(),\n stopMeeting: vi.fn(),\n};\n\nconst mockApiInstance = {\n createMeeting: vi.fn(),\n startTranscription: vi.fn(),\n stopMeeting: vi.fn(),\n};\n\nconst stream = {\n onUpdate: vi.fn(),\n close: vi.fn(),\n};\n\nconst mockStreamOnUpdate = vi.fn();\nconst mockStreamClose = vi.fn();\n\nlet panelPrefs = {\n showNotesPanel: true,\n showStatsPanel: true,\n notesPanelSize: 25,\n statsPanelSize: 25,\n transcriptPanelSize: 50,\n};\n\nconst setShowNotesPanel = vi.fn();\nconst setShowStatsPanel = vi.fn();\nconst setNotesPanelSize = vi.fn();\nconst setStatsPanelSize = vi.fn();\nconst setTranscriptPanelSize = vi.fn();\n\nconst tauriHandlers: Record<string, (payload: unknown) => void> = {};\n\nvi.mock('react-router-dom', async () => {\n const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');\n return {\n ...actual,\n useNavigate: () => navigate,\n useParams: () => params,\n };\n});\n\nvi.mock('@/api', () => ({\n getAPI: () => apiInstance,\n mockAPI: mockApiInstance,\n isTauriEnvironment: () => isTauri,\n}));\n\nvi.mock('@/api/mock-transcription-stream', () => ({\n MockTranscriptionStream: class MockTranscriptionStream {\n meetingId: string;\n constructor(meetingId: string) {\n this.meetingId = meetingId;\n }\n onUpdate = mockStreamOnUpdate;\n close = mockStreamClose;\n },\n}));\n\nvi.mock('@/contexts/connection-context', () => ({\n useConnectionState: () => ({ isConnected }),\n}));\n\nvi.mock('@/contexts/project-context', () => ({\n useProjects: () => ({ activeProject: { id: 'p1' } }),\n}));\n\nvi.mock('@/hooks/use-panel-preferences', () => ({\n usePanelPreferences: () => ({\n ...panelPrefs,\n setShowNotesPanel,\n setShowStatsPanel,\n setNotesPanelSize,\n setStatsPanelSize,\n setTranscriptPanelSize,\n }),\n}));\n\nvi.mock('@/hooks/use-guarded-mutation', () => ({\n useGuardedMutation: () => ({ guard }),\n}));\n\nconst toast = vi.fn();\nvi.mock('@/hooks/use-toast', () => ({\n toast: (...args: unknown[]) => toast(...args),\n}));\n\nvi.mock('@/lib/preferences', () => ({\n preferences: {\n get: () => ({\n server_host: 'localhost',\n server_port: '50051',\n simulate_transcription: simulateTranscription,\n }),\n },\n}));\n\nvi.mock('@/lib/tauri-events', () => ({\n useTauriEvent: (_event: string, handler: (payload: unknown) => void) => {\n tauriHandlers[_event] = handler;\n },\n}));\n\nvi.mock('framer-motion', () => ({\n AnimatePresence: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n}));\n\nvi.mock('@/components/recording', () => ({\n RecordingHeader: ({\n recordingState,\n meetingTitle,\n setMeetingTitle,\n onStartRecording,\n onStopRecording,\n elapsedTime,\n }: {\n recordingState: string;\n meetingTitle: string;\n setMeetingTitle: (title: string) => void;\n onStartRecording: () => void;\n onStopRecording: () => void;\n elapsedTime: number;\n }) => (\n <div>\n <div data-testid=\"recording-state\">{recordingState}</div>\n <div data-testid=\"meeting-title\">{meetingTitle}</div>\n <div data-testid=\"elapsed-time\">{elapsedTime}</div>\n <button type=\"button\" onClick={() => setMeetingTitle('Updated')}>\n Set Title\n </button>\n <button type=\"button\" onClick={onStartRecording}>\n Start Recording\n </button>\n <button type=\"button\" onClick={onStopRecording}>\n Stop Recording\n </button>\n </div>\n ),\n IdleState: () => <div>Idle</div>,\n ListeningState: () => <div>Listening</div>,\n PartialTextDisplay: ({\n text,\n onTogglePin,\n }: {\n text: string;\n onTogglePin: (id: string) => void;\n }) => (\n <div>\n <div data-testid=\"partial-text\">{text}</div>\n <button type=\"button\" onClick={() => onTogglePin('entity-1')}>\n Toggle Pin\n </button>\n </div>\n ),\n TranscriptSegmentCard: ({\n segment,\n onTogglePin,\n }: {\n segment: { text: string };\n onTogglePin: (id: string) => void;\n }) => (\n <div>\n <div data-testid=\"segment-text\">{segment.text}</div>\n <button type=\"button\" onClick={() => onTogglePin('entity-2')}>\n Toggle Pin 2\n </button>\n </div>\n ),\n StatsContent: ({ isRecording, audioLevel }: { isRecording: boolean; audioLevel: number }) => (\n <div data-testid=\"stats\">\n {isRecording ? 'recording' : 'idle'}:{audioLevel}\n </div>\n ),\n VADIndicator: ({ isActive }: { isActive: boolean }) => (\n <div data-testid=\"vad\">{isActive ? 'on' : 'off'}</div>\n ),\n}));\n\nvi.mock('@/components/timestamped-notes-editor', () => ({\n TimestampedNotesEditor: () => <div data-testid=\"notes-editor\" />,\n}));\n\nvi.mock('@/components/ui/resizable', () => ({\n ResizablePanelGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n ResizablePanel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n ResizableHandle: () => <div data-testid=\"resize-handle\" />,\n}));\n\nconst buildMeeting = (id: string, state: string = 'created', title = 'Meeting') => ({\n id,\n project_id: 'p1',\n title,\n state,\n created_at: Date.now() / 1000,\n duration_seconds: 0,\n segments: [],\n metadata: {},\n});\n\ndescribe('RecordingPage logic', () => {\n beforeEach(() => {\n isTauri = false;\n simulateTranscription = false;\n isConnected = true;\n params = { id: 'new' };\n panelPrefs = {\n showNotesPanel: true,\n showStatsPanel: true,\n notesPanelSize: 25,\n statsPanelSize: 25,\n transcriptPanelSize: 50,\n };\n\n apiInstance.createMeeting.mockReset();\n apiInstance.getMeeting.mockReset();\n apiInstance.startTranscription.mockReset();\n apiInstance.stopMeeting.mockReset();\n mockApiInstance.createMeeting.mockReset();\n mockApiInstance.startTranscription.mockReset();\n mockApiInstance.stopMeeting.mockReset();\n stream.onUpdate.mockReset();\n stream.close.mockReset();\n mockStreamOnUpdate.mockReset();\n mockStreamClose.mockReset();\n guard.mockClear();\n navigate.mockClear();\n toast.mockClear();\n });\n\n afterEach(() => {\n Object.keys(tauriHandlers).forEach((key) => {\n delete tauriHandlers[key];\n });\n });\n\n it('shows desktop-only message when not running in tauri without simulation', async () => {\n isTauri = false;\n simulateTranscription = false;\n\n const { default: RecordingPage } = await import('./Recording');\n render(<RecordingPage />);\n\n expect(screen.getByText('Desktop recording only')).toBeInTheDocument();\n });\n\n it('starts and stops recording via guard', async () => {\n isTauri = true;\n simulateTranscription = false;\n isConnected = true;\n\n apiInstance.createMeeting.mockResolvedValue(buildMeeting('m1'));\n apiInstance.startTranscription.mockResolvedValue(stream);\n apiInstance.stopMeeting.mockResolvedValue(buildMeeting('m1', 'stopped'));\n\n const { default: RecordingPage } = await import('./Recording');\n render(<RecordingPage />);\n\n await act(async () => {\n fireEvent.click(screen.getByRole('button', { name: 'Start Recording' }));\n });\n\n expect(guard).toHaveBeenCalled();\n expect(apiInstance.createMeeting).toHaveBeenCalled();\n await waitFor(() => expect(apiInstance.startTranscription).toHaveBeenCalledWith('m1'));\n await waitFor(() => expect(stream.onUpdate).toHaveBeenCalled());\n\n const updateCallback = stream.onUpdate.mock.calls[0]?.[0] as (update: TranscriptUpdate) => void;\n await act(async () => {\n updateCallback({\n meeting_id: 'm1',\n update_type: 'partial',\n partial_text: 'Hello',\n server_timestamp: 1,\n });\n });\n await waitFor(() => expect(screen.getByTestId('partial-text')).toHaveTextContent('Hello'));\n\n await act(async () => {\n updateCallback({\n meeting_id: 'm1',\n update_type: 'final',\n segment: {\n segment_id: 1,\n text: 'Final',\n start_time: 0,\n end_time: 1,\n words: [],\n language: 'en',\n language_confidence: 1,\n avg_logprob: -0.1,\n no_speech_prob: 0,\n speaker_id: 'SPEAKER_00',\n speaker_confidence: 0.9,\n },\n server_timestamp: 2,\n });\n });\n await waitFor(() => expect(screen.getByTestId('segment-text')).toHaveTextContent('Final'));\n\n await act(async () => {\n updateCallback({ meeting_id: 'm1', update_type: 'vad_start', server_timestamp: 3 });\n });\n await waitFor(() => expect(screen.getByTestId('vad')).toHaveTextContent('on'));\n await act(async () => {\n updateCallback({ meeting_id: 'm1', update_type: 'vad_end', server_timestamp: 4 });\n });\n await waitFor(() => expect(screen.getByTestId('vad')).toHaveTextContent('off'));\n\n await act(async () => {\n tauriHandlers[TauriEvents.RECORDING_TIMER]?.({ meeting_id: 'm1', elapsed_seconds: 12 });\n });\n await waitFor(() => expect(screen.getByTestId('elapsed-time')).toHaveTextContent('12'));\n\n await act(async () => {\n fireEvent.click(screen.getByRole('button', { name: 'Stop Recording' }));\n });\n\n expect(stream.close).toHaveBeenCalled();\n expect(apiInstance.stopMeeting).toHaveBeenCalledWith('m1');\n expect(navigate).toHaveBeenCalledWith('/projects/p1/meetings/m1');\n });\n\n it('uses mock API when simulating offline', async () => {\n isTauri = false;\n simulateTranscription = true;\n isConnected = false;\n\n mockApiInstance.createMeeting.mockResolvedValue(buildMeeting('m2'));\n mockApiInstance.startTranscription.mockResolvedValue(stream);\n\n const { default: RecordingPage } = await import('./Recording');\n render(<RecordingPage />);\n\n await act(async () => {\n fireEvent.click(screen.getByRole('button', { name: 'Start Recording' }));\n });\n\n expect(mockApiInstance.createMeeting).toHaveBeenCalled();\n expect(apiInstance.createMeeting).not.toHaveBeenCalled();\n });\n\n it('uses mock transcription stream when simulating while connected', async () => {\n isTauri = true;\n simulateTranscription = true;\n isConnected = true;\n\n apiInstance.createMeeting.mockResolvedValue(buildMeeting('m3'));\n\n const { default: RecordingPage } = await import('./Recording');\n render(<RecordingPage />);\n\n await act(async () => {\n fireEvent.click(screen.getByRole('button', { name: 'Start Recording' }));\n });\n\n expect(apiInstance.createMeeting).toHaveBeenCalled();\n expect(apiInstance.startTranscription).not.toHaveBeenCalled();\n await waitFor(() => expect(mockStreamOnUpdate).toHaveBeenCalled());\n });\n\n it('auto-starts existing meeting and respects terminal state', async () => {\n isTauri = true;\n simulateTranscription = false;\n isConnected = true;\n params = { id: 'm4' };\n\n apiInstance.getMeeting.mockResolvedValue(buildMeeting('m4', 'completed', 'Existing'));\n\n const { default: RecordingPage } = await import('./Recording');\n render(<RecordingPage />);\n\n await waitFor(() => expect(apiInstance.getMeeting).toHaveBeenCalled());\n await waitFor(() => expect(apiInstance.startTranscription).not.toHaveBeenCalled());\n await waitFor(() => expect(screen.getByTestId('recording-state')).toHaveTextContent('idle'));\n });\n\n it('auto-starts existing meeting when state allows', async () => {\n isTauri = true;\n simulateTranscription = false;\n isConnected = true;\n params = { id: 'm5' };\n\n apiInstance.getMeeting.mockResolvedValue(buildMeeting('m5', 'created', 'Existing'));\n apiInstance.startTranscription.mockResolvedValue(stream);\n\n const { default: RecordingPage } = await import('./Recording');\n render(<RecordingPage />);\n\n await waitFor(() => expect(apiInstance.startTranscription).toHaveBeenCalledWith('m5'));\n await waitFor(() => expect(screen.getByTestId('meeting-title')).toHaveTextContent('Existing'));\n });\n\n it('renders collapsed panels when hidden', async () => {\n isTauri = true;\n simulateTranscription = true;\n isConnected = false;\n panelPrefs.showNotesPanel = false;\n panelPrefs.showStatsPanel = false;\n\n mockApiInstance.createMeeting.mockResolvedValue(buildMeeting('m6'));\n mockApiInstance.startTranscription.mockResolvedValue(stream);\n\n const { default: RecordingPage } = await import('./Recording');\n render(<RecordingPage />);\n\n await act(async () => {\n fireEvent.click(screen.getByRole('button', { name: 'Start Recording' }));\n });\n\n await act(async () => {\n fireEvent.click(screen.getByTitle('Expand notes panel'));\n fireEvent.click(screen.getByTitle('Expand stats panel'));\n });\n\n expect(setShowNotesPanel).toHaveBeenCalledWith(true);\n expect(setShowStatsPanel).toHaveBeenCalledWith(true);\n });\n});\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/Recording.test.tsx","messages":[{"ruleId":"@typescript-eslint/no-unsafe-return","severity":1,"message":"Unsafe return of a value of type `any`.","line":36,"column":34,"nodeType":"CallExpression","messageId":"unsafeReturn","endLine":36,"endColumn":52}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { fireEvent, render, screen, waitFor } from '@testing-library/react';\nimport { createMemoryRouter, RouterProvider } from 'react-router-dom';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport { ConnectionProvider } from '@/contexts/connection-context';\nimport { ProjectProvider } from '@/contexts/project-context';\nimport { WorkspaceProvider } from '@/contexts/workspace-context';\nimport RecordingPage from '@/pages/Recording';\n\n// Mock the API module with controllable functions\nconst mockConnect = vi.fn();\nconst mockCreateMeeting = vi.fn();\nconst mockStartTranscription = vi.fn();\nconst mockIsTauriEnvironment = vi.fn(() => false);\n\nvi.mock('@/api', async (importOriginal) => {\n const actual = await importOriginal<typeof import('@/api')>();\n return {\n ...actual,\n getAPI: vi.fn(() => ({\n listWorkspaces: vi.fn().mockResolvedValue({ workspaces: [] }),\n listProjects: vi.fn().mockResolvedValue({ projects: [], total_count: 0 }),\n getActiveProject: vi.fn().mockResolvedValue({ project_id: '' }),\n setActiveProject: vi.fn().mockResolvedValue(undefined),\n connect: mockConnect,\n createMeeting: mockCreateMeeting,\n startTranscription: mockStartTranscription,\n })),\n isTauriEnvironment: () => mockIsTauriEnvironment(),\n };\n});\n\n// Mock toast\nconst mockToast = vi.fn();\nvi.mock('@/hooks/use-toast', () => ({\n toast: (...args: unknown[]) => mockToast(...args),\n}));\n\n// Mock connection context to control isConnected state\nconst mockIsConnected = vi.fn(() => true);\nvi.mock('@/contexts/connection-context', async (importOriginal) => {\n const actual = await importOriginal<typeof import('@/contexts/connection-context')>();\n return {\n ...actual,\n useConnectionState: () => ({\n state: {\n mode: mockIsConnected() ? 'connected' : 'cached',\n disconnectedAt: null,\n reconnectAttempts: 0,\n },\n isConnected: mockIsConnected(),\n isReadOnly: !mockIsConnected(),\n isReconnecting: false,\n }),\n };\n});\n\nfunction Wrapper({ children }: { children: React.ReactNode }) {\n return (\n <ConnectionProvider>\n <WorkspaceProvider>\n <ProjectProvider>{children}</ProjectProvider>\n </WorkspaceProvider>\n </ConnectionProvider>\n );\n}\n\ndescribe('RecordingPage', () => {\n beforeEach(() => {\n mockIsTauriEnvironment.mockReturnValue(false);\n mockIsConnected.mockReturnValue(true);\n });\n\n afterEach(() => {\n localStorage.clear();\n vi.clearAllMocks();\n });\n\n it('shows desktop-only message when not running in Tauri', () => {\n mockIsTauriEnvironment.mockReturnValue(false);\n localStorage.setItem('noteflow_preferences', JSON.stringify({ simulate_transcription: false }));\n const router = createMemoryRouter([{ path: '/recording/:id', element: <RecordingPage /> }], {\n initialEntries: ['/recording/new'],\n future: {\n v7_startTransition: true,\n v7_relativeSplatPath: true,\n },\n });\n\n render(\n <Wrapper>\n <RouterProvider\n router={router}\n future={{ v7_startTransition: true, v7_relativeSplatPath: true }}\n />\n </Wrapper>\n );\n\n expect(screen.getByText('Desktop recording only')).toBeInTheDocument();\n expect(\n screen.getByText(/Recording and live transcription are available in the desktop app/i)\n ).toBeInTheDocument();\n });\n\n it('allows simulated recording when enabled in preferences', () => {\n mockIsTauriEnvironment.mockReturnValue(false);\n localStorage.setItem('noteflow_preferences', JSON.stringify({ simulate_transcription: true }));\n const router = createMemoryRouter([{ path: '/recording/:id', element: <RecordingPage /> }], {\n initialEntries: ['/recording/new'],\n future: {\n v7_startTransition: true,\n v7_relativeSplatPath: true,\n },\n });\n\n render(\n <Wrapper>\n <RouterProvider\n router={router}\n future={{ v7_startTransition: true, v7_relativeSplatPath: true }}\n />\n </Wrapper>\n );\n\n expect(screen.getByRole('button', { name: /Start Recording/i })).toBeInTheDocument();\n });\n});\n\ndescribe('RecordingPage - GAP-006 Connection Bootstrapping', () => {\n beforeEach(() => {\n mockIsTauriEnvironment.mockReturnValue(true);\n });\n\n afterEach(() => {\n localStorage.clear();\n vi.clearAllMocks();\n });\n\n it('attempts preflight connect when starting recording while disconnected', async () => {\n // Set up disconnected state\n mockIsConnected.mockReturnValue(false);\n\n // Mock successful connect\n mockConnect.mockResolvedValue({ version: '1.0.0' });\n mockCreateMeeting.mockResolvedValue({ id: 'test-meeting', title: 'Test', state: 'created' });\n mockStartTranscription.mockResolvedValue({\n onUpdate: vi.fn(),\n close: vi.fn(),\n });\n\n localStorage.setItem('noteflow_preferences', JSON.stringify({ simulate_transcription: false }));\n const router = createMemoryRouter([{ path: '/recording/:id', element: <RecordingPage /> }], {\n initialEntries: ['/recording/new'],\n future: { v7_startTransition: true, v7_relativeSplatPath: true },\n });\n\n render(\n <Wrapper>\n <RouterProvider\n router={router}\n future={{ v7_startTransition: true, v7_relativeSplatPath: true }}\n />\n </Wrapper>\n );\n\n // Click start recording button\n const startButton = screen.getByRole('button', { name: /Start Recording/i });\n fireEvent.click(startButton);\n\n // Wait for connect to be called\n await waitFor(() => {\n expect(mockConnect).toHaveBeenCalled();\n });\n });\n\n it('shows error toast when preflight connect fails', async () => {\n // Set up disconnected state\n mockIsConnected.mockReturnValue(false);\n\n // Mock failed connect\n mockConnect.mockRejectedValue(new Error('Connection refused'));\n\n localStorage.setItem('noteflow_preferences', JSON.stringify({ simulate_transcription: false }));\n const router = createMemoryRouter([{ path: '/recording/:id', element: <RecordingPage /> }], {\n initialEntries: ['/recording/new'],\n future: { v7_startTransition: true, v7_relativeSplatPath: true },\n });\n\n render(\n <Wrapper>\n <RouterProvider\n router={router}\n future={{ v7_startTransition: true, v7_relativeSplatPath: true }}\n />\n </Wrapper>\n );\n\n // Click start recording button\n const startButton = screen.getByRole('button', { name: /Start Recording/i });\n fireEvent.click(startButton);\n\n // Wait for error toast to be shown\n await waitFor(() => {\n expect(mockToast).toHaveBeenCalledWith(\n expect.objectContaining({\n title: 'Connection failed',\n variant: 'destructive',\n })\n );\n });\n\n // Verify createMeeting was NOT called (recording should not proceed)\n expect(mockCreateMeeting).not.toHaveBeenCalled();\n });\n\n it('skips preflight connect when already connected', async () => {\n // Set up connected state\n mockIsConnected.mockReturnValue(true);\n\n mockCreateMeeting.mockResolvedValue({ id: 'test-meeting', title: 'Test', state: 'created' });\n mockStartTranscription.mockResolvedValue({\n onUpdate: vi.fn(),\n close: vi.fn(),\n });\n\n localStorage.setItem('noteflow_preferences', JSON.stringify({ simulate_transcription: false }));\n const router = createMemoryRouter([{ path: '/recording/:id', element: <RecordingPage /> }], {\n initialEntries: ['/recording/new'],\n future: { v7_startTransition: true, v7_relativeSplatPath: true },\n });\n\n render(\n <Wrapper>\n <RouterProvider\n router={router}\n future={{ v7_startTransition: true, v7_relativeSplatPath: true }}\n />\n </Wrapper>\n );\n\n // Click start recording button\n const startButton = screen.getByRole('button', { name: /Start Recording/i });\n fireEvent.click(startButton);\n\n // Wait for createMeeting to be called (connect should be skipped)\n await waitFor(() => {\n expect(mockCreateMeeting).toHaveBeenCalled();\n });\n\n // Verify connect was NOT called (already connected)\n expect(mockConnect).not.toHaveBeenCalled();\n });\n});\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/Recording.tsx","messages":[{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":1,"message":"Unsafe assignment of an error typed value.","line":222,"column":11,"nodeType":"AssignmentExpression","messageId":"anyAssignment","endLine":222,"endColumn":63},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":1,"message":"Unsafe assignment of an error typed value.","line":292,"column":11,"nodeType":"AssignmentExpression","messageId":"anyAssignment","endLine":292,"endColumn":68}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// Live Recording Page\n\nimport { AnimatePresence } from 'framer-motion';\nimport {\n BarChart3,\n PanelLeftClose,\n PanelLeftOpen,\n PanelRightClose,\n PanelRightOpen,\n} from 'lucide-react';\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { useNavigate, useParams } from 'react-router-dom';\nimport { getAPI, isTauriEnvironment, mockAPI, type TranscriptionStream } from '@/api';\nimport { TauriEvents } from '@/api/tauri-adapter';\nimport type { FinalSegment, Meeting, TranscriptUpdate } from '@/api/types';\nimport {\n IdleState,\n ListeningState,\n PartialTextDisplay,\n RecordingHeader,\n StatsContent,\n TranscriptSegmentCard,\n VADIndicator,\n} from '@/components/recording';\nimport { type NoteEdit, TimestampedNotesEditor } from '@/components/timestamped-notes-editor';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';\nimport { useConnectionState } from '@/contexts/connection-context';\nimport { useProjects } from '@/contexts/project-context';\nimport { usePanelPreferences } from '@/hooks/use-panel-preferences';\nimport { useGuardedMutation } from '@/hooks/use-guarded-mutation';\nimport { toast } from '@/hooks/use-toast';\nimport { preferences } from '@/lib/preferences';\nimport { useTauriEvent } from '@/lib/tauri-events';\n\ntype RecordingState = 'idle' | 'starting' | 'recording' | 'paused' | 'stopping';\n\nexport default function RecordingPage() {\n const navigate = useNavigate();\n const { id } = useParams<{ id: string }>();\n const isNewRecording = !id || id === 'new';\n const { activeProject } = useProjects();\n\n // Recording state\n const [recordingState, setRecordingState] = useState<RecordingState>('idle');\n const [meeting, setMeeting] = useState<Meeting | null>(null);\n const [meetingTitle, setMeetingTitle] = useState('');\n\n // Transcription state\n const [segments, setSegments] = useState<FinalSegment[]>([]);\n const [partialText, setPartialText] = useState('');\n const [isVadActive, setIsVadActive] = useState(false);\n const [audioLevel, setAudioLevel] = useState<number | null>(null);\n\n // Notes state\n const [notes, setNotes] = useState<NoteEdit[]>([]);\n\n // Panel preferences (persisted to localStorage)\n const {\n showNotesPanel,\n showStatsPanel,\n notesPanelSize,\n statsPanelSize,\n transcriptPanelSize,\n setShowNotesPanel,\n setShowStatsPanel,\n setNotesPanelSize,\n setStatsPanelSize,\n setTranscriptPanelSize,\n } = usePanelPreferences();\n\n // Entity highlighting state\n const [pinnedEntities, setPinnedEntities] = useState<Set<string>>(new Set());\n\n const handleTogglePinEntity = (entityId: string) => {\n setPinnedEntities((prev) => {\n const next = new Set(prev);\n if (next.has(entityId)) {\n next.delete(entityId);\n } else {\n next.add(entityId);\n }\n return next;\n });\n };\n\n // Timer\n const [elapsedTime, setElapsedTime] = useState(0);\n const [hasTauriTimer, setHasTauriTimer] = useState(false);\n const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);\n const isTauri = isTauriEnvironment();\n // Sprint GAP-007: Get mode for ApiModeIndicator in RecordingHeader\n const { isConnected, mode: connectionMode } = useConnectionState();\n const { guard } = useGuardedMutation();\n const simulateTranscription = preferences.get().simulate_transcription;\n\n // Transcription stream\n const streamRef = useRef<TranscriptionStream | null>(null);\n const transcriptEndRef = useRef<HTMLDivElement>(null);\n\n // Auto-scroll to bottom\n useEffect(() => {\n transcriptEndRef.current?.scrollIntoView({ behavior: 'smooth' });\n }, []);\n\n // Timer effect\n useEffect(() => {\n if (recordingState === 'idle') {\n setHasTauriTimer(false);\n }\n const clearTimer = () => {\n if (timerRef.current) {\n clearInterval(timerRef.current);\n timerRef.current = null;\n }\n };\n if (isTauri && hasTauriTimer) {\n clearTimer();\n return;\n }\n if (recordingState === 'recording') {\n timerRef.current = setInterval(() => setElapsedTime((prev) => prev + 1), 1000);\n } else {\n clearTimer();\n }\n return clearTimer;\n }, [recordingState, hasTauriTimer, isTauri]);\n\n useEffect(() => {\n if (recordingState !== 'recording') {\n setAudioLevel(null);\n }\n }, [recordingState]);\n\n useTauriEvent(\n TauriEvents.AUDIO_LEVEL,\n (payload) => {\n if (payload.meeting_id !== meeting?.id) {\n return;\n }\n setAudioLevel(payload.level);\n },\n [meeting?.id]\n );\n\n useTauriEvent(\n TauriEvents.RECORDING_TIMER,\n (payload) => {\n if (payload.meeting_id !== meeting?.id) {\n return;\n }\n setHasTauriTimer(true);\n setElapsedTime(payload.elapsed_seconds);\n },\n [meeting?.id]\n );\n\n // Handle transcript updates\n // Toast helpers\n const toastSuccess = useCallback(\n (title: string, description: string) => toast({ title, description }),\n []\n );\n const toastError = useCallback(\n (title: string) => toast({ title, description: 'Please try again', variant: 'destructive' }),\n []\n );\n\n const handleTranscriptUpdate = useCallback((update: TranscriptUpdate) => {\n if (update.update_type === 'partial') {\n setPartialText(update.partial_text || '');\n } else if (update.update_type === 'final' && update.segment) {\n const seg = update.segment;\n setSegments((prev) => [...prev, seg]);\n setPartialText('');\n } else if (update.update_type === 'vad_start') {\n setIsVadActive(true);\n } else if (update.update_type === 'vad_end') {\n setIsVadActive(false);\n }\n }, []);\n\n // Start recording\n const startRecording = async () => {\n const shouldSimulate = preferences.get().simulate_transcription;\n\n // GAP-006: Preflight connect if disconnected (defense in depth)\n // Must happen BEFORE guard, since guard blocks when disconnected.\n // Rust also auto-connects, but this provides explicit UX feedback.\n let didPreflightConnect = false;\n if (!shouldSimulate && !isConnected) {\n try {\n await getAPI().connect();\n didPreflightConnect = true;\n } catch {\n toast({\n title: 'Connection failed',\n description: 'Unable to connect to server. Please check your network and try again.',\n variant: 'destructive',\n });\n return;\n }\n }\n\n const runStart = async () => {\n setRecordingState('starting');\n\n try {\n const api = shouldSimulate && !isConnected ? mockAPI : getAPI();\n const newMeeting = await api.createMeeting({\n title: meetingTitle || `Recording ${new Date().toLocaleString()}`,\n project_id: activeProject?.id,\n });\n setMeeting(newMeeting);\n\n let stream: TranscriptionStream;\n if (shouldSimulate && isConnected) {\n const { MockTranscriptionStream } = await import('@/api/mock-transcription-stream');\n stream = new MockTranscriptionStream(newMeeting.id);\n } else {\n stream = await api.startTranscription(newMeeting.id);\n }\n\n streamRef.current = stream;\n stream.onUpdate(handleTranscriptUpdate);\n\n setRecordingState('recording');\n toastSuccess(\n 'Recording started',\n shouldSimulate ? 'Simulation is active' : 'Transcription is now active'\n );\n } catch (_error) {\n setRecordingState('idle');\n toastError('Failed to start recording');\n }\n };\n\n if (shouldSimulate || didPreflightConnect) {\n // Either simulating, or we just successfully connected via preflight\n await runStart();\n } else {\n // Already connected - use guard as a safety check\n await guard(runStart, {\n title: 'Offline mode',\n message: 'Recording requires an active server connection.',\n });\n }\n };\n\n // Auto-start recording for existing meeting (trigger accept flow)\n useEffect(() => {\n if (!isTauri || isNewRecording || !id || recordingState !== 'idle') {\n return;\n }\n const startExistingRecording = async () => {\n const shouldSimulate = preferences.get().simulate_transcription;\n setRecordingState('starting');\n try {\n // GAP-006: Preflight connect if disconnected (defense in depth)\n if (!isConnected && !shouldSimulate) {\n try {\n await getAPI().connect();\n } catch {\n setRecordingState('idle');\n toast({\n title: 'Connection failed',\n description: 'Unable to connect to server. Please check your network and try again.',\n variant: 'destructive',\n });\n return;\n }\n }\n\n const api = shouldSimulate && !isConnected ? mockAPI : getAPI();\n const existingMeeting = await api.getMeeting({\n meeting_id: id,\n include_segments: false,\n include_summary: false,\n });\n setMeeting(existingMeeting);\n setMeetingTitle(existingMeeting.title);\n if (!['created', 'recording'].includes(existingMeeting.state)) {\n setRecordingState('idle');\n return;\n }\n let stream: TranscriptionStream;\n if (shouldSimulate && isConnected) {\n const { MockTranscriptionStream } = await import('@/api/mock-transcription-stream');\n stream = new MockTranscriptionStream(existingMeeting.id);\n } else {\n stream = await api.startTranscription(existingMeeting.id);\n }\n streamRef.current = stream;\n stream.onUpdate(handleTranscriptUpdate);\n setRecordingState('recording');\n toastSuccess(\n 'Recording started',\n shouldSimulate ? 'Simulation is active' : 'Transcription is now active'\n );\n } catch (_error) {\n setRecordingState('idle');\n toastError('Failed to start recording');\n }\n };\n void startExistingRecording();\n }, [\n handleTranscriptUpdate,\n id,\n isNewRecording,\n isTauri,\n isConnected,\n recordingState,\n toastError,\n toastSuccess,\n ]);\n\n // Stop recording\n const stopRecording = async () => {\n if (!meeting) {\n return;\n }\n const shouldSimulate = preferences.get().simulate_transcription;\n const runStop = async () => {\n setRecordingState('stopping');\n try {\n streamRef.current?.close();\n streamRef.current = null;\n const api = shouldSimulate && !isConnected ? mockAPI : getAPI();\n const stoppedMeeting = await api.stopMeeting(meeting.id);\n setMeeting(stoppedMeeting);\n toastSuccess(\n 'Recording stopped',\n shouldSimulate ? 'Simulation finished' : 'Your meeting has been saved'\n );\n const projectId = meeting.project_id ?? activeProject?.id;\n navigate(projectId ? `/projects/${projectId}/meetings/${meeting.id}` : '/projects');\n } catch (_error) {\n setRecordingState('recording');\n toastError('Failed to stop recording');\n }\n };\n\n if (shouldSimulate) {\n await runStop();\n } else {\n await guard(runStop, {\n title: 'Offline mode',\n message: 'Stopping a recording requires an active server connection.',\n });\n }\n };\n\n // Cleanup on unmount\n useEffect(() => {\n return () => {\n streamRef.current?.close();\n };\n }, []);\n\n if (!isTauri && !simulateTranscription) {\n return (\n <div className=\"h-full flex items-center justify-center p-6 bg-background\">\n <Card className=\"max-w-lg w-full\">\n <CardContent className=\"p-6 space-y-2\">\n <h2 className=\"text-lg font-semibold text-foreground\">Desktop recording only</h2>\n <p className=\"text-sm text-muted-foreground\">\n Recording and live transcription are available in the desktop app. Use the web app for\n administration, configuration, and reporting.\n </p>\n </CardContent>\n </Card>\n </div>\n );\n }\n\n return (\n <div className=\"h-full flex flex-col bg-background\">\n <RecordingHeader\n recordingState={recordingState}\n meeting={meeting}\n meetingTitle={meetingTitle}\n setMeetingTitle={setMeetingTitle}\n simulateTranscription={simulateTranscription}\n connectionMode={connectionMode}\n elapsedTime={elapsedTime}\n onStartRecording={startRecording}\n onStopRecording={stopRecording}\n />\n\n {/* Content */}\n <ResizablePanelGroup direction=\"horizontal\" className=\"flex-1\">\n {/* Transcript Panel */}\n <ResizablePanel\n defaultSize={transcriptPanelSize}\n minSize={30}\n onResize={setTranscriptPanelSize}\n >\n <div className=\"h-full overflow-auto p-6\">\n {recordingState === 'idle' ? (\n <IdleState />\n ) : (\n <div className=\"max-w-3xl mx-auto space-y-4\">\n {/* VAD Indicator */}\n <VADIndicator isActive={isVadActive} isRecording={recordingState === 'recording'} />\n\n {/* Transcript */}\n <div className=\"space-y-3\">\n <AnimatePresence mode=\"popLayout\">\n {segments.map((segment) => (\n <TranscriptSegmentCard\n key={segment.segment_id}\n segment={segment}\n meetingId={meeting?.id}\n pinnedEntities={pinnedEntities}\n onTogglePin={handleTogglePinEntity}\n />\n ))}\n </AnimatePresence>\n <PartialTextDisplay\n text={partialText}\n pinnedEntities={pinnedEntities}\n onTogglePin={handleTogglePinEntity}\n />\n <div ref={transcriptEndRef} />\n </div>\n\n {/* Empty State */}\n {segments.length === 0 && !partialText && recordingState === 'recording' && (\n <ListeningState />\n )}\n </div>\n )}\n </div>\n </ResizablePanel>\n\n {/* Notes Panel */}\n {recordingState !== 'idle' && showNotesPanel && (\n <>\n <ResizableHandle withHandle />\n <ResizablePanel\n defaultSize={notesPanelSize}\n minSize={15}\n maxSize={40}\n onResize={setNotesPanelSize}\n >\n <div className=\"h-full flex flex-col border-l border-border bg-card/50\">\n <div className=\"flex-1 flex flex-col p-4 min-h-0\">\n <div className=\"flex items-center justify-between mb-3\">\n <h3 className=\"font-medium text-foreground text-sm\">Notes</h3>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => setShowNotesPanel(false)}\n className=\"h-7 w-7 p-0\"\n title=\"Collapse notes panel\"\n >\n <PanelRightClose className=\"h-4 w-4\" />\n </Button>\n </div>\n <div className=\"flex-1 min-h-0\">\n <TimestampedNotesEditor\n elapsedTime={elapsedTime}\n isRecording={recordingState === 'recording'}\n notes={notes}\n onNotesChange={setNotes}\n />\n </div>\n </div>\n </div>\n </ResizablePanel>\n </>\n )}\n\n {/* Collapsed Notes Panel */}\n {recordingState !== 'idle' && !showNotesPanel && (\n <div className=\"shrink-0 w-12 border-l border-border bg-card/50 flex flex-col items-center pt-4\">\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => setShowNotesPanel(true)}\n className=\"h-8 w-8 p-0\"\n title=\"Expand notes panel\"\n >\n <PanelRightOpen className=\"h-4 w-4\" />\n </Button>\n <span className=\"text-[10px] text-muted-foreground mt-2 [writing-mode:vertical-rl] rotate-180\">\n Notes\n </span>\n </div>\n )}\n\n {/* Stats Panel */}\n {recordingState !== 'idle' && showStatsPanel && (\n <>\n <ResizableHandle withHandle />\n <ResizablePanel\n defaultSize={statsPanelSize}\n minSize={15}\n maxSize={35}\n onResize={setStatsPanelSize}\n >\n <div className=\"h-full flex flex-col border-l border-border bg-card/50 overflow-auto\">\n <div className=\"p-4 space-y-4\">\n <div className=\"flex items-center justify-between\">\n <h3 className=\"font-medium text-foreground\">Recording Stats</h3>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => setShowStatsPanel(false)}\n className=\"h-7 w-7 p-0\"\n title=\"Collapse stats panel\"\n >\n <PanelLeftClose className=\"h-4 w-4\" />\n </Button>\n </div>\n <StatsContent\n elapsedTime={elapsedTime}\n segments={segments}\n meetingId={meeting?.id}\n isRecording={recordingState === 'recording'}\n isVadActive={isVadActive}\n audioLevel={audioLevel}\n />\n </div>\n </div>\n </ResizablePanel>\n </>\n )}\n\n {/* Collapsed Stats Panel */}\n {recordingState !== 'idle' && !showStatsPanel && (\n <div className=\"shrink-0 w-12 border-l border-border bg-card/50 flex flex-col items-center pt-4\">\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => setShowStatsPanel(true)}\n className=\"h-8 w-8 p-0\"\n title=\"Expand stats panel\"\n >\n <PanelLeftOpen className=\"h-4 w-4\" />\n </Button>\n <span className=\"text-[10px] text-muted-foreground mt-2 [writing-mode:vertical-rl] rotate-180\">\n <BarChart3 className=\"h-3 w-3 mb-1\" />\n Stats\n </span>\n </div>\n )}\n </ResizablePanelGroup>\n </div>\n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/Settings.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/Tasks.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/settings/AITab.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/settings/AudioTab.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/settings/DiagnosticsTab.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/settings/IntegrationsTab.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/pages/settings/StatusTab.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/test/code-quality.test.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/test/mocks/tauri-plugin-deep-link.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/test/mocks/tauri-plugin-shell.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/test/setup.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/test/vitest.d.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/types/entity.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/types/navigator.d.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/types/task.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/types/window.d.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/src/vite-env.d.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/tailwind.config.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/vite.config.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/vitest.config.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/wdio.conf.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/trav/repos/noteflow/client/wdio.mac.conf.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]}]