From 23e8873bc23f6fee128120465c4b79acac63f511 Mon Sep 17 00:00:00 2001 From: Travis Vasceannie Date: Sat, 24 Jan 2026 10:40:30 +0000 Subject: [PATCH] fixed ai --- .claudectx/codefixes.md | 2830 +++++------------ ...4_019bee3a-b7a8-7a51-92ff-33bf5941daaf.txt | 108 + .cupcake/opencode.json | 5 + .../policies/opencode/ban_stdlib_logger.rego | 10 + .../opencode/block_assertion_roulette.rego | 10 + .../policies/opencode/block_biome_ignore.rego | 10 + .../block_broad_exception_handler.rego | 10 + .../block_code_quality_test_edits.rego | 10 + .../block_code_quality_test_serena.rego | 10 + ...block_code_quality_test_serena_plugin.rego | 10 + .../opencode/block_datetime_now_fallback.rego | 10 + .../opencode/block_default_value_swallow.rego | 10 + .../opencode/block_duplicate_fixtures.rego | 10 + .../block_linter_config_frontend.rego | 10 + .../opencode/block_linter_config_python.rego | 10 + .../opencode/block_magic_numbers.rego | 10 + .../opencode/block_makefile_edit.rego | 10 + .../opencode/block_silent_none_return.rego | 10 + .../block_test_loops_conditionals.rego | 10 + .../opencode/block_tests_quality.rego | 10 + .../policies/opencode/prevent_any_type.rego | 10 + .../opencode/prevent_type_suppression.rego | 51 +- .../opencode/warn_baselines_edit.rego | 10 + .../policies/opencode/warn_large_file.rego | 10 + .opencode/plugin/cupcake.js | 16 +- .opencode/plugins/cupcake.js | 388 +++ client/e2e/analytics.spec.ts | 9 + client/e2e/cloud-consent.spec.ts | 378 +++ client/e2e/settings-ui.spec.ts | 36 +- client/e2e/ui-integration.spec.ts | 26 +- client/eslint.config.js | 2 +- client/package-lock.json | 6 +- client/src-tauri/src/audio/capture.rs | 6 +- client/src-tauri/src/audio/devices.rs | 4 +- .../src/audio/drift_compensation/resampler.rs | 9 +- client/src-tauri/src/audio/mixer.rs | 81 +- client/src-tauri/src/audio/mixer_helpers.rs | 74 + client/src-tauri/src/audio/mod.rs | 7 +- .../src-tauri/src/audio/windows_loopback.rs | 11 +- client/src-tauri/src/commands/assistant.rs | 55 +- .../src/commands/recording/capture.rs | 24 +- .../src/commands/recording/dual_capture.rs | 118 +- .../src-tauri/src/commands/recording/mod.rs | 2 +- .../src/commands/recording/session.rs | 2 +- .../src/commands/recording/session/chunks.rs | 16 +- .../commands/recording/session/processing.rs | 30 +- .../src/commands/recording/session/start.rs | 15 +- client/src-tauri/src/commands/summary.rs | 40 +- client/src-tauri/src/commands/tasks.rs | 10 +- client/src-tauri/src/commands/testing.rs | 29 +- client/src-tauri/src/grpc/client/analytics.rs | 3 + client/src-tauri/src/grpc/client/assistant.rs | 82 +- .../src-tauri/src/grpc/client/converters.rs | 7 +- client/src-tauri/src/grpc/client/meetings.rs | 48 +- client/src-tauri/src/grpc/client/tasks.rs | 29 +- client/src-tauri/src/grpc/client_tests.rs | 57 +- client/src-tauri/src/grpc/noteflow.rs | 176 +- .../src/grpc/proto_compliance_tests.rs | 5 + .../src/grpc/streaming/converters.rs | 4 + .../src-tauri/src/grpc/streaming/manager.rs | 18 +- client/src-tauri/src/grpc/streaming/mod.rs | 3 + .../src-tauri/src/grpc/streaming/stream_io.rs | 1 + client/src-tauri/src/grpc/types/analytics.rs | 3 + client/src-tauri/src/grpc/types/assistant.rs | 24 + client/src-tauri/src/grpc/types/core.rs | 14 +- client/src-tauri/src/grpc/types/enums.rs | 62 + client/src-tauri/src/grpc/types/tasks.rs | 17 + client/src-tauri/src/lib.rs | 5 +- client/src-tauri/src/state/mod.rs | 2 +- client/src-tauri/src/state/recording_types.rs | 3 + client/src/App.tsx | 11 +- client/src/api/adapters/cached/templates.ts | 21 +- client/src/api/adapters/mock/index.test.ts | 40 +- client/src/api/adapters/mock/index.ts | 96 +- client/src/api/adapters/tauri/constants.ts | 3 + .../adapters/tauri/sections/meetings.test.ts | 61 +- .../api/adapters/tauri/sections/meetings.ts | 15 +- .../adapters/tauri/sections/sections.test.ts | 14 + .../adapters/tauri/sections/summarization.ts | 71 +- .../src/api/adapters/tauri/sections/tasks.ts | 24 +- client/src/api/interface.ts | 23 +- client/src/api/interfaces/domains.ts | 5 +- client/src/api/types/core.ts | 24 + client/src/api/types/enums.ts | 10 + client/src/api/types/features/analytics.ts | 3 + client/src/api/types/features/tasks.ts | 16 + .../components/common/error-boundary.test.tsx | 13 +- .../src/components/common/nav-link.test.tsx | 10 +- .../analytics/analytics-utils.test.ts | 14 +- .../features/analytics/analytics-utils.ts | 8 +- .../features/analytics/entities-tab.tsx | 34 +- .../features/analytics/meetings-tab.tsx | 168 +- .../analytics/speech-analysis-tab.tsx | 456 +-- .../analytics/speech-analysis-utils.ts | 375 +++ .../features/assistant/assistant-dialog.tsx | 276 ++ .../components/features/assistant/index.ts | 1 + .../calendar/upcoming-meetings.test.tsx | 6 +- .../connectivity/api-mode-indicator.tsx | 3 +- .../connectivity/connection-status.test.tsx | 2 +- .../features/settings/ai-config-section.tsx | 444 +-- .../features/settings/cloud-ai-toggle.tsx | 132 +- .../settings/provider-config-card.tsx | 46 +- .../features/settings/use-ai-config-state.ts | 393 +++ client/src/components/layout/app-sidebar.tsx | 5 + .../components/ui/markdown-editor.test.tsx | 33 +- client/src/contexts/project-context.test.tsx | 37 +- client/src/hooks/audio/use-asr-config.test.ts | 36 +- .../src/hooks/audio/use-audio-devices.test.ts | 167 +- .../src/hooks/auth/use-cloud-consent.test.ts | 70 +- client/src/hooks/auth/use-cloud-consent.ts | 118 +- client/src/hooks/data/use-async-data.test.tsx | 34 +- client/src/hooks/data/use-async-data.ts | 57 +- .../hooks/processing/use-assistant.test.ts | 9 +- client/src/hooks/processing/use-assistant.ts | 12 +- .../recording/use-recording-session.test.tsx | 58 +- client/src/hooks/ui/use-mobile.test.tsx | 8 +- client/src/lib/constants/timing.ts | 3 + client/src/lib/observability/client.ts | 6 +- client/src/lib/observability/messages.test.ts | 45 + client/src/lib/observability/messages.ts | 49 +- client/src/lib/storage/crypto.test.ts | 28 +- client/src/lib/storage/crypto.ts | 35 +- client/src/lib/ui/styles.ts | 1 + client/src/pages/Analytics.test.tsx | 3 + client/src/pages/Analytics.tsx | 15 +- client/src/pages/Home.behavior.test.tsx | 122 +- client/src/pages/Home.test.tsx | 19 +- client/src/pages/Home.tsx | 28 +- client/src/pages/MeetingDetail.test.tsx | 21 +- client/src/pages/Meetings.test.tsx | 48 +- client/src/pages/Meetings.tsx | 9 +- client/src/pages/People.tsx | 84 +- client/src/pages/Recording.behavior.test.tsx | 20 +- client/src/pages/Recording.test.tsx | 53 +- client/src/pages/Settings.tsx | 52 +- client/src/pages/Tasks.test.tsx | 108 +- client/src/pages/meeting-detail/ask-panel.tsx | 5 +- .../src/pages/meeting-detail/index.test.tsx | 48 +- client/src/pages/meeting-detail/index.tsx | 465 ++- .../meeting-detail/summary-panel.test.tsx | 35 + .../pages/meeting-detail/summary-panel.tsx | 126 +- .../pages/meeting-detail/transcript-row.tsx | 2 +- .../meeting-detail/use-meeting-detail.test.ts | 4 +- client/src/pages/settings/AITab.tsx | 5 - client/vite.config.ts | 2 +- compose.yaml | 2 + docs/sprints/sprint-25-langgraph/EXECUTION.md | 278 ++ docs/sprints/sprint-25-langgraph/IDEATION.md | 379 +++ docs/sprints/sprint-25-langgraph/README.md | 333 ++ .../sprint-25-foundation/README.md | 590 ++++ .../sprint-26-meeting-qa/README.md | 530 +++ .../sprint-27-cross-meeting/README.md | 87 + .../sprint-28-advanced/README.md | 146 + package-lock.json | 18 + package.json | 1 + scripts/run_cupcake_tests.sh | 14 +- .../services/assistant/assistant_service.py | 81 +- .../services/embedding/__init__.py | 8 + .../services/embedding/_embedding.py | 139 + .../summarization/_consent_manager.py | 75 +- .../summarization/summarization_service.py | 68 +- .../services/voice_profile/__init__.py | 11 + .../services/voice_profile/service.py | 204 ++ src/noteflow/cli/__main__.py | 19 +- src/noteflow/cli/_utils.py | 20 + src/noteflow/cli/constants.py | 10 + src/noteflow/cli/embeddings/__init__.py | 310 ++ src/noteflow/cli/models/__init__.py | 4 +- src/noteflow/cli/models/_parser.py | 14 +- src/noteflow/cli/retention.py | 4 +- src/noteflow/config/constants/core.py | 3 + src/noteflow/config/constants/domain.py | 23 +- src/noteflow/config/constants/errors.py | 24 +- src/noteflow/config/settings/_features.py | 7 + src/noteflow/config/settings/_main.py | 8 +- src/noteflow/config/settings/_triggers.py | 16 +- src/noteflow/domain/ai/ports.py | 9 +- src/noteflow/domain/constants/ai.py | 6 + src/noteflow/domain/entities/analytics.py | 17 + src/noteflow/domain/entities/segment.py | 5 + src/noteflow/domain/identity/__init__.py | 2 + src/noteflow/domain/identity/entities.py | 18 + .../ports/repositories/identity/__init__.py | 4 + .../repositories/identity/_voice_profile.py | 34 + .../domain/ports/repositories/transcript.py | 24 + src/noteflow/domain/ports/unit_of_work.py | 17 +- src/noteflow/domain/value_objects.py | 39 +- src/noteflow/grpc/config/config.py | 4 + src/noteflow/grpc/mixins/_servicer_state.py | 4 + src/noteflow/grpc/mixins/analytics_mixin.py | 12 + src/noteflow/grpc/mixins/assistant.py | 24 +- .../grpc/mixins/converters/__init__.py | 19 +- .../grpc/mixins/converters/_domain.py | 92 +- .../grpc/mixins/converters/_export.py | 24 + .../grpc/mixins/converters/_streaming.py | 63 + .../grpc/mixins/diarization/_refinement.py | 75 +- src/noteflow/grpc/mixins/diarization_job.py | 5 +- .../grpc/mixins/meeting/_post_processing.py | 128 +- .../grpc/mixins/meeting/_title_generation.py | 156 + src/noteflow/grpc/mixins/streaming/_asr.py | 80 +- .../grpc/mixins/streaming/_metadata.py | 21 + src/noteflow/grpc/mixins/streaming/_mixin.py | 36 +- .../mixins/streaming/_processing/__init__.py | 7 +- .../streaming/_processing/_vad_processing.py | 43 +- .../grpc/mixins/summarization/_consent.py | 62 +- src/noteflow/grpc/mixins/tasks.py | 95 +- src/noteflow/grpc/proto/noteflow.proto | 64 +- src/noteflow/grpc/proto/noteflow_pb2.py | 930 +++--- src/noteflow/grpc/proto/noteflow_pb2.pyi | 101 +- src/noteflow/grpc/proto/noteflow_pb2_grpc.py | 45 +- src/noteflow/grpc/proto/noteflow_pb2_grpc.pyi | 126 +- src/noteflow/grpc/server/__init__.py | 13 +- .../grpc/server/internal/bootstrap.py | 9 + src/noteflow/grpc/server/internal/services.py | 9 + src/noteflow/grpc/service.py | 2 + src/noteflow/grpc/startup/ai_adapters.py | 134 + src/noteflow/grpc/startup/services.py | 138 +- src/noteflow/grpc/startup/startup.py | 56 +- src/noteflow/infrastructure/ai/__init__.py | 57 +- .../infrastructure/ai/_langgraph_compat.py | 30 +- src/noteflow/infrastructure/ai/_utils.py | 56 + .../infrastructure/ai/adapters/__init__.py | 8 + .../ai/adapters/ollama_embedder.py | 168 + .../infrastructure/ai/adapters/ollama_llm.py | 143 + .../ai/adapters/openai_embedder.py | 173 + .../infrastructure/ai/adapters/openai_llm.py | 156 + src/noteflow/infrastructure/ai/cache.py | 3 +- src/noteflow/infrastructure/ai/constants.py | 17 + .../infrastructure/ai/graphs/meeting_qa.py | 11 +- .../infrastructure/ai/graphs/summarization.py | 5 +- .../infrastructure/ai/graphs/workspace_qa.py | 9 +- src/noteflow/infrastructure/ai/interrupts.py | 18 +- .../ai/nodes/annotation_suggester.py | 7 +- .../infrastructure/asr/segmenter/_types.py | 3 + .../infrastructure/asr/segmenter/segmenter.py | 198 +- .../infrastructure/auth/oidc_discovery.py | 30 +- .../infrastructure/auth/oidc_registry.py | 13 +- .../converters/orm_converters.py | 25 +- .../infrastructure/logging/processors.py | 55 +- src/noteflow/infrastructure/ner/mapper.py | 3 +- .../persistence/memory/repositories/core.py | 11 + ...9z0a1b2c3_add_speaker_analytics_columns.py | 106 + .../persistence/models/__init__.py | 2 + .../persistence/models/_base.py | 3 + .../persistence/models/_strings.py | 1 + .../persistence/models/core/meeting.py | 4 + .../persistence/models/identity/__init__.py | 9 +- .../persistence/models/identity/identity.py | 47 +- .../persistence/repositories/__init__.py | 2 + .../repositories/_analytics_fetch.py | 77 + .../repositories/_analytics_overview.py | 76 + .../repositories/_analytics_queries.py | 106 +- .../repositories/analytics_repo.py | 62 +- .../repositories/identity/__init__.py | 2 + .../identity/voice_profile_repo.py | 77 + .../persistence/repositories/meeting_repo.py | 30 +- .../persistence/repositories/segment_repo.py | 15 + .../unit_of_work/_optional_repos_mixin.py | 8 + .../persistence/unit_of_work/unit_of_work.py | 5 + .../cloud_provider/cloud_provider.py | 4 +- .../summarization/ollama_provider.py | 7 +- test_any_import.py | 10 + .../application/test_summarization_service.py | 8 +- tests/cli/test_models.py | 8 +- .../mixins/meeting/test_title_generation.py | 235 ++ tests/grpc/test_cloud_consent.py | 6 +- tests/infrastructure/asr/test_segmenter.py | 50 +- .../auth/test_oidc_discovery.py | 9 +- .../integration/test_analytics_repository.py | 8 + tsconfig.json | 4 + 270 files changed, 13342 insertions(+), 5070 deletions(-) create mode 100644 .cupcake/debug/2026-01-24_04-19-44_019bee3a-b7a8-7a51-92ff-33bf5941daaf.txt create mode 100644 .cupcake/opencode.json create mode 100644 .opencode/plugins/cupcake.js create mode 100644 client/e2e/cloud-consent.spec.ts create mode 100644 client/src-tauri/src/audio/mixer_helpers.rs create mode 100644 client/src/components/features/analytics/speech-analysis-utils.ts create mode 100644 client/src/components/features/assistant/assistant-dialog.tsx create mode 100644 client/src/components/features/assistant/index.ts create mode 100644 client/src/components/features/settings/use-ai-config-state.ts create mode 100644 docs/sprints/sprint-25-langgraph/EXECUTION.md create mode 100644 docs/sprints/sprint-25-langgraph/IDEATION.md create mode 100644 docs/sprints/sprint-25-langgraph/README.md create mode 100644 docs/sprints/sprint-25-langgraph/sprint-25-foundation/README.md create mode 100644 docs/sprints/sprint-25-langgraph/sprint-26-meeting-qa/README.md create mode 100644 docs/sprints/sprint-25-langgraph/sprint-27-cross-meeting/README.md create mode 100644 docs/sprints/sprint-25-langgraph/sprint-28-advanced/README.md create mode 100644 src/noteflow/application/services/embedding/__init__.py create mode 100644 src/noteflow/application/services/embedding/_embedding.py create mode 100644 src/noteflow/application/services/voice_profile/__init__.py create mode 100644 src/noteflow/application/services/voice_profile/service.py create mode 100644 src/noteflow/cli/_utils.py create mode 100644 src/noteflow/cli/embeddings/__init__.py create mode 100644 src/noteflow/domain/constants/ai.py create mode 100644 src/noteflow/domain/ports/repositories/identity/_voice_profile.py create mode 100644 src/noteflow/grpc/mixins/converters/_export.py create mode 100644 src/noteflow/grpc/mixins/converters/_streaming.py create mode 100644 src/noteflow/grpc/mixins/meeting/_title_generation.py create mode 100644 src/noteflow/grpc/mixins/streaming/_metadata.py create mode 100644 src/noteflow/grpc/startup/ai_adapters.py create mode 100644 src/noteflow/infrastructure/ai/_utils.py create mode 100644 src/noteflow/infrastructure/ai/adapters/__init__.py create mode 100644 src/noteflow/infrastructure/ai/adapters/ollama_embedder.py create mode 100644 src/noteflow/infrastructure/ai/adapters/ollama_llm.py create mode 100644 src/noteflow/infrastructure/ai/adapters/openai_embedder.py create mode 100644 src/noteflow/infrastructure/ai/adapters/openai_llm.py create mode 100644 src/noteflow/infrastructure/persistence/migrations/versions/x8y9z0a1b2c3_add_speaker_analytics_columns.py create mode 100644 src/noteflow/infrastructure/persistence/repositories/_analytics_fetch.py create mode 100644 src/noteflow/infrastructure/persistence/repositories/_analytics_overview.py create mode 100644 src/noteflow/infrastructure/persistence/repositories/identity/voice_profile_repo.py create mode 100644 test_any_import.py create mode 100644 tests/grpc/mixins/meeting/test_title_generation.py diff --git a/.claudectx/codefixes.md b/.claudectx/codefixes.md index af74b2c..1ff1dce 100644 --- a/.claudectx/codefixes.md +++ b/.claudectx/codefixes.md @@ -1,5 +1,5 @@ > git -c user.useConfigOnly=true commit --quiet --allow-empty-message --file - -Running pre-commit quality checks... +Running pre-commit quality checks... === TypeScript Type Check === cd client && npm run type-check @@ -20,14 +20,14 @@ cd client && npm run test:quality > vitest run src/test/code-quality.test.ts - RUN  v4.0.17 /home/trav/repos/noteflow/client + RUN v4.0.17 /home/trav/repos/noteflow/client - ✓ src/test/code-quality.test.ts (28 tests) 155ms + ✓ src/test/code-quality.test.ts (28 tests) 244ms - Test Files  1 passed (1) - Tests  28 passed (28) - Start at  07:30:17 - Duration  843ms (transform 260ms, setup 363ms, import 29ms, tests 155ms, environment 229ms) + Test Files 1 passed (1) + Tests 28 passed (28) + Start at 09:52:07 + Duration 1.20s (transform 370ms, setup 551ms, import 60ms, tests 244ms, environment 257ms) === TypeScript Coverage (Vitest) === cd client && npm run test -- --coverage @@ -36,176 +36,24 @@ cd client && npm run test -- --coverage > vitest run --coverage - RUN  v4.0.17 /home/trav/repos/noteflow/client - Coverage enabled with v8 + RUN v4.0.17 /home/trav/repos/noteflow/client + Coverage enabled with v8 -stderr | src/pages/Tasks.test.tsx > TasksPage > shows loading skeletons when fetching tasks -⚠️ React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. -⚠️ React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. - -stderr | src/hooks/recording/use-recording-session.test.tsx > useRecordingSession > closes streams when unmounted before transcription resolves -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - -stderr | src/hooks/recording/use-recording-session.test.tsx > useRecordingSession > skips start error handling when unmounted -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - -stderr | src/hooks/audio/use-asr-config.test.ts > useAsrConfig > updateConfig > updates config when job completes with new configuration -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - -stderr | src/hooks/recording/use-recording-session.test.tsx > useRecordingSession > skips stop updates when unmounted before stop resolves -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - -stderr | src/hooks/recording/use-recording-session.test.tsx > useRecordingSession > skips stop error handling when unmounted -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - -stderr | src/pages/Home.test.tsx > HomePage > renders greeting text -⚠️ React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. -⚠️ React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. - -stderr | src/hooks/audio/use-asr-config.test.ts > useAsrConfig > updateConfig > refreshes config when job completes without new configuration -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - -stderr | src/pages/MeetingDetail.test.tsx > MeetingDetailPage > shows loading skeleton while fetching -⚠️ React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. -⚠️ React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. - -stderr | src/pages/MeetingDetail.test.tsx > MeetingDetailPage > shows loading skeleton while fetching -Warning: An update to MeetingDetailPage inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at MeetingDetailPage (/home/trav/repos/noteflow/client/src/pages/meeting-detail/index.tsx:51:55) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at Routes (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1273:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/MeetingDetail.test.tsx:195:31) -Warning: An update to MeetingDetailPage inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at MeetingDetailPage (/home/trav/repos/noteflow/client/src/pages/meeting-detail/index.tsx:51:55) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at Routes (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1273:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/MeetingDetail.test.tsx:195:31) - -stderr | src/hooks/audio/use-asr-config.test.ts > useAsrConfig > updateConfig > stops reconfiguring and reports failure status -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - -stderr | src/hooks/audio/use-asr-config.test.ts > useAsrConfig > updateConfig > stops reconfiguring and reports cancelled status -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - - ✓ src/hooks/recording/use-recording-session.test.tsx (26 tests) 492ms - ✓ src/components/features/analytics/performance-tab.test.tsx (16 tests) 486ms - ✓ src/hooks/audio/use-asr-config.test.ts (12 tests) 615ms - ✓ src/hooks/sync/use-integration-sync.test.ts (43 tests) 598ms - ✓ polls until sync completes when initial status is running  504ms - ✓ src/pages/Recording.logic.test.tsx (7 tests | 2 skipped) 656ms - ✓ shows desktop-only message when not running in tauri without simulation  411ms -stderr | src/pages/Tasks.test.tsx > TasksPage > renders kanban view and forwards updates -Warning: An update to TasksPage inside a test was not wrapped in act(...). + ❯ src/api/adapters/tauri/sections/meetings.test.ts (10 tests | 1 failed) 59ms + ✓ creates meetings and caches them 14ms + ✓ lists meetings and caches results 10ms + ✓ gets meetings and caches them 3ms + ✓ stops meetings and logs 3ms + ✓ deletes meetings and removes cached items 3ms + × starts transcription and returns a stream 6ms + ✓ records failures when transcription fails 4ms + ✓ resets stream state and logs metadata 2ms + ✓ generates summaries using preference templates 9ms + ✓ logs summary failures 3ms + ✓ src/hooks/auth/use-cloud-consent.test.ts (14 tests) 765ms + ✓ src/hooks/auth/use-huggingface-token.test.ts (17 tests) 933ms +stderr | src/pages/Tasks.test.tsx > TasksPage > renders kanban view and forwards updates +Warning: An update to TasksPage inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...): @@ -232,742 +80,21 @@ This ensures that you're testing the behavior the user would see in the browser. at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - ✓ src/hooks/auth/use-cloud-consent.test.ts (14 tests) 722ms -stderr | src/pages/Home.test.tsx > HomePage > renders recently recorded section header -Warning: An update to ProjectProvider inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Home.test.tsx:92:31) -Warning: An update to ProjectProvider inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Home.test.tsx:92:31) -Warning: An update to ProjectProvider inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Home.test.tsx:92:31) -Warning: An update to HomePage inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at HomePage (/home/trav/repos/noteflow/client/src/pages/Home.tsx:40:69) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at Routes (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1273:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Home.test.tsx:92:31) -Warning: An update to HomePage inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at HomePage (/home/trav/repos/noteflow/client/src/pages/Home.tsx:40:69) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at Routes (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1273:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Home.test.tsx:92:31) - -stderr | src/pages/Recording.test.tsx > RecordingPage > shows desktop-only message when not running in Tauri -Warning: An update to ProjectProvider inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Recording.test.tsx:93:20) -Warning: An update to ProjectProvider inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Recording.test.tsx:93:20) -Warning: An update to ProjectProvider inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Recording.test.tsx:93:20) - -stderr | src/pages/Home.test.tsx > HomePage > renders action items section header -Warning: An update to ProjectProvider inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Home.test.tsx:92:31) -Warning: An update to ProjectProvider inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Home.test.tsx:92:31) -Warning: An update to ProjectProvider inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Home.test.tsx:92:31) -Warning: An update to HomePage inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at HomePage (/home/trav/repos/noteflow/client/src/pages/Home.tsx:40:69) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at Routes (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1273:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Home.test.tsx:92:31) -Warning: An update to HomePage inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at HomePage (/home/trav/repos/noteflow/client/src/pages/Home.tsx:40:69) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at Routes (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1273:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Home.test.tsx:92:31) - - ✓ src/pages/Tasks.test.tsx (12 tests) 569ms - ✓ src/hooks/auth/use-huggingface-token.test.ts (17 tests) 885ms -stderr | src/pages/Recording.test.tsx > RecordingPage > allows simulated recording when enabled in preferences -Warning: An update to ProjectProvider inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Recording.test.tsx:93:20) -Warning: An update to ProjectProvider inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Recording.test.tsx:93:20) -Warning: An update to ProjectProvider inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Recording.test.tsx:93:20) - - ✓ src/components/features/entities/entity-management-panel.test.tsx (4 tests) 622ms - ✓ adds, edits, and deletes entities when persisted  385ms -stderr | src/pages/Home.test.tsx > HomePage > has view all link for meetings -Warning: An update to ProjectProvider inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Home.test.tsx:92:31) -Warning: An update to ProjectProvider inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Home.test.tsx:92:31) -Warning: An update to ProjectProvider inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Home.test.tsx:92:31) -Warning: An update to HomePage inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at HomePage (/home/trav/repos/noteflow/client/src/pages/Home.tsx:40:69) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at Routes (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1273:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Home.test.tsx:92:31) -Warning: An update to HomePage inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at HomePage (/home/trav/repos/noteflow/client/src/pages/Home.tsx:40:69) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at Routes (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1273:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Home.test.tsx:92:31) - - ✓ src/pages/Home.test.tsx (8 tests) 675ms - ✓ shows empty state when no meetings  328ms - ✓ src/pages/MeetingDetail.test.tsx (7 tests) 580ms -TypeError: Cannot read properties of undefined (reading 'icon') - at ApiModeIndicator (/home/trav/repos/noteflow/client/src/components/features/connectivity/api-mode-indicator.tsx:108:23) - at renderWithHooks (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:15486:18) - at mountIndeterminateComponent (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:20103:13) - at beginWork (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:21626:16) - at HTMLUnknownElement.callCallback (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:4164:14) - at HTMLUnknownElement.callTheUserObjectsOperation (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/generated/EventListener.js:26:30) - at innerInvokeEventListeners (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:360:16) - at invokeEventListeners (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:296:3) - at HTMLUnknownElementImpl._dispatch (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:243:9) - at HTMLUnknownElementImpl.dispatchEvent (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:114:17) -TypeError: Cannot read properties of undefined (reading 'icon') - at ApiModeIndicator (/home/trav/repos/noteflow/client/src/components/features/connectivity/api-mode-indicator.tsx:108:23) - at renderWithHooks (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:15486:18) - at mountIndeterminateComponent (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:20103:13) - at beginWork (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:21626:16) - at HTMLUnknownElement.callCallback (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:4164:14) - at HTMLUnknownElement.callTheUserObjectsOperation (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/generated/EventListener.js:26:30) - at innerInvokeEventListeners (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:360:16) - at invokeEventListeners (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:296:3) - at HTMLUnknownElementImpl._dispatch (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:243:9) - at HTMLUnknownElementImpl.dispatchEvent (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:114:17) -stderr | src/pages/Recording.test.tsx > RecordingPage - GAP-006 Connection Bootstrapping > attempts preflight connect when starting recording while disconnected -Error handled by React Router default ErrorBoundary: TypeError: Cannot read properties of undefined (reading 'icon') - at ApiModeIndicator (/home/trav/repos/noteflow/client/src/components/features/connectivity/api-mode-indicator.tsx:108:23) - at renderWithHooks (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:15486:18) - at mountIndeterminateComponent (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:20103:13) - at beginWork (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:21626:16) - at beginWork$1 (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:27465:14) - at performUnitOfWork (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26599:12) - at workLoopSync (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26505:5) - at renderRootSync (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26473:7) - at performSyncWorkOnRoot (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26124:20) - at flushSyncCallbacks (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:12042:22) -Error handled by React Router default ErrorBoundary: TypeError: Cannot read properties of undefined (reading 'icon') - at ApiModeIndicator (/home/trav/repos/noteflow/client/src/components/features/connectivity/api-mode-indicator.tsx:108:23) - at renderWithHooks (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:15486:18) - at mountIndeterminateComponent (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:20103:13) - at beginWork (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:21626:16) - at beginWork$1 (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:27465:14) - at performUnitOfWork (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26599:12) - at workLoopSync (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26505:5) - at renderRootSync (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26473:7) - at recoverFromConcurrentError (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:25889:20) - at performSyncWorkOnRoot (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26135:20) -The above error occurred in the component: - - at ApiModeIndicator (/home/trav/repos/noteflow/client/src/components/features/connectivity/api-mode-indicator.tsx:68:33) - at div - at UnifiedStatusRow (/home/trav/repos/noteflow/client/src/components/features/recording/unified-status-row.tsx:10:29) - at div - at div - at div - at div - at RecordingHeader (/home/trav/repos/noteflow/client/src/components/features/recording/recording-header.tsx:20:28) - at div - at RecordingPage (/home/trav/repos/noteflow/client/src/pages/Recording.tsx:50:59) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at RenderErrorBoundary (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:486:7) - at DataRoutes (/home/trav/repos/noteflow/client/node_modules/react-router-dom/dist/umd/react-router-dom.development.js:684:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at RouterProvider (/home/trav/repos/noteflow/client/node_modules/react-router-dom/dist/umd/react-router-dom.development.js:455:7) - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Recording.test.tsx:93:20) - -React will try to recreate this component tree from scratch using the error boundary you provided, RenderErrorBoundary. -React Router caught the following error during render TypeError: Cannot read properties of undefined (reading 'icon') - at ApiModeIndicator (/home/trav/repos/noteflow/client/src/components/features/connectivity/api-mode-indicator.tsx:108:23) - at renderWithHooks (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:15486:18) - at mountIndeterminateComponent (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:20103:13) - at beginWork (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:21626:16) - at beginWork$1 (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:27465:14) - at performUnitOfWork (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26599:12) - at workLoopSync (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26505:5) - at renderRootSync (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26473:7) - at recoverFromConcurrentError (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:25889:20) - at performSyncWorkOnRoot (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26135:20) { - componentStack: '\n' + - ' at ApiModeIndicator (/home/trav/repos/noteflow/client/src/components/features/connectivity/api-mode-indicator.tsx:68:33)\n' + - ' at div\n' + - ' at UnifiedStatusRow (/home/trav/repos/noteflow/client/src/components/features/recording/unified-status-row.tsx:10:29)\n' + - ' at div\n' + - ' at div\n' + - ' at div\n' + - ' at div\n' + - ' at RecordingHeader (/home/trav/repos/noteflow/client/src/components/features/recording/recording-header.tsx:20:28)\n' + - ' at div\n' + - ' at RecordingPage (/home/trav/repos/noteflow/client/src/pages/Recording.tsx:50:59)\n' + - ' at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7)\n' + - ' at RenderErrorBoundary (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:486:7)\n' + - ' at DataRoutes (/home/trav/repos/noteflow/client/node_modules/react-router-dom/dist/umd/react-router-dom.development.js:684:7)\n' + - ' at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17)\n' + - ' at RouterProvider (/home/trav/repos/noteflow/client/node_modules/react-router-dom/dist/umd/react-router-dom.development.js:455:7)\n' + - ' at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28)\n' + - ' at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30)\n' + - ' at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31)\n' + - ' at Wrapper (/home/trav/repos/noteflow/client/src/pages/Recording.test.tsx:93:20)' -} - - ✓ src/hooks/processing/use-post-processing.test.ts (30 tests) 967ms -TypeError: Cannot read properties of undefined (reading 'icon') - at ApiModeIndicator (/home/trav/repos/noteflow/client/src/components/features/connectivity/api-mode-indicator.tsx:108:23) - at renderWithHooks (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:15486:18) - at mountIndeterminateComponent (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:20103:13) - at beginWork (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:21626:16) - at HTMLUnknownElement.callCallback (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:4164:14) - at HTMLUnknownElement.callTheUserObjectsOperation (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/generated/EventListener.js:26:30) - at innerInvokeEventListeners (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:360:16) - at invokeEventListeners (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:296:3) - at HTMLUnknownElementImpl._dispatch (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:243:9) - at HTMLUnknownElementImpl.dispatchEvent (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:114:17) -TypeError: Cannot read properties of undefined (reading 'icon') - at ApiModeIndicator (/home/trav/repos/noteflow/client/src/components/features/connectivity/api-mode-indicator.tsx:108:23) - at renderWithHooks (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:15486:18) - at mountIndeterminateComponent (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:20103:13) - at beginWork (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:21626:16) - at HTMLUnknownElement.callCallback (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:4164:14) - at HTMLUnknownElement.callTheUserObjectsOperation (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/generated/EventListener.js:26:30) - at innerInvokeEventListeners (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:360:16) - at invokeEventListeners (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:296:3) - at HTMLUnknownElementImpl._dispatch (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:243:9) - at HTMLUnknownElementImpl.dispatchEvent (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:114:17) -stderr | src/pages/Recording.test.tsx > RecordingPage - GAP-006 Connection Bootstrapping > skips preflight connect when already connected -Error handled by React Router default ErrorBoundary: TypeError: Cannot read properties of undefined (reading 'icon') - at ApiModeIndicator (/home/trav/repos/noteflow/client/src/components/features/connectivity/api-mode-indicator.tsx:108:23) - at renderWithHooks (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:15486:18) - at mountIndeterminateComponent (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:20103:13) - at beginWork (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:21626:16) - at beginWork$1 (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:27465:14) - at performUnitOfWork (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26599:12) - at workLoopSync (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26505:5) - at renderRootSync (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26473:7) - at performSyncWorkOnRoot (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26124:20) - at flushSyncCallbacks (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:12042:22) -Error handled by React Router default ErrorBoundary: TypeError: Cannot read properties of undefined (reading 'icon') - at ApiModeIndicator (/home/trav/repos/noteflow/client/src/components/features/connectivity/api-mode-indicator.tsx:108:23) - at renderWithHooks (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:15486:18) - at mountIndeterminateComponent (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:20103:13) - at beginWork (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:21626:16) - at beginWork$1 (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:27465:14) - at performUnitOfWork (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26599:12) - at workLoopSync (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26505:5) - at renderRootSync (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26473:7) - at recoverFromConcurrentError (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:25889:20) - at performSyncWorkOnRoot (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26135:20) -The above error occurred in the component: - - at ApiModeIndicator (/home/trav/repos/noteflow/client/src/components/features/connectivity/api-mode-indicator.tsx:68:33) - at div - at UnifiedStatusRow (/home/trav/repos/noteflow/client/src/components/features/recording/unified-status-row.tsx:10:29) - at div - at div - at div - at div - at RecordingHeader (/home/trav/repos/noteflow/client/src/components/features/recording/recording-header.tsx:20:28) - at div - at RecordingPage (/home/trav/repos/noteflow/client/src/pages/Recording.tsx:50:59) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at RenderErrorBoundary (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:486:7) - at DataRoutes (/home/trav/repos/noteflow/client/node_modules/react-router-dom/dist/umd/react-router-dom.development.js:684:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at RouterProvider (/home/trav/repos/noteflow/client/node_modules/react-router-dom/dist/umd/react-router-dom.development.js:455:7) - at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28) - at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30) - at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Recording.test.tsx:93:20) - -React will try to recreate this component tree from scratch using the error boundary you provided, RenderErrorBoundary. -React Router caught the following error during render TypeError: Cannot read properties of undefined (reading 'icon') - at ApiModeIndicator (/home/trav/repos/noteflow/client/src/components/features/connectivity/api-mode-indicator.tsx:108:23) - at renderWithHooks (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:15486:18) - at mountIndeterminateComponent (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:20103:13) - at beginWork (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:21626:16) - at beginWork$1 (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:27465:14) - at performUnitOfWork (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26599:12) - at workLoopSync (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26505:5) - at renderRootSync (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26473:7) - at recoverFromConcurrentError (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:25889:20) - at performSyncWorkOnRoot (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:26135:20) { - componentStack: '\n' + - ' at ApiModeIndicator (/home/trav/repos/noteflow/client/src/components/features/connectivity/api-mode-indicator.tsx:68:33)\n' + - ' at div\n' + - ' at UnifiedStatusRow (/home/trav/repos/noteflow/client/src/components/features/recording/unified-status-row.tsx:10:29)\n' + - ' at div\n' + - ' at div\n' + - ' at div\n' + - ' at div\n' + - ' at RecordingHeader (/home/trav/repos/noteflow/client/src/components/features/recording/recording-header.tsx:20:28)\n' + - ' at div\n' + - ' at RecordingPage (/home/trav/repos/noteflow/client/src/pages/Recording.tsx:50:59)\n' + - ' at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7)\n' + - ' at RenderErrorBoundary (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:486:7)\n' + - ' at DataRoutes (/home/trav/repos/noteflow/client/node_modules/react-router-dom/dist/umd/react-router-dom.development.js:684:7)\n' + - ' at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17)\n' + - ' at RouterProvider (/home/trav/repos/noteflow/client/node_modules/react-router-dom/dist/umd/react-router-dom.development.js:455:7)\n' + - ' at ProjectProvider (/home/trav/repos/noteflow/client/src/contexts/project-context.tsx:59:28)\n' + - ' at WorkspaceProvider (/home/trav/repos/noteflow/client/src/contexts/workspace-context.tsx:49:30)\n' + - ' at ConnectionProvider (/home/trav/repos/noteflow/client/src/contexts/connection-context.tsx:18:31)\n' + - ' at Wrapper (/home/trav/repos/noteflow/client/src/pages/Recording.test.tsx:93:20)' -} - - ✓ src/pages/Recording.test.tsx (5 tests) 730ms -stderr | src/pages/Meetings.test.tsx > MeetingsPage > renders page title -⚠️ React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. -⚠️ React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. -Warning: An update to MeetingsPage inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at MeetingsPage (/home/trav/repos/noteflow/client/src/pages/Meetings.tsx:46:62) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at Routes (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1273:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Meetings.test.tsx:147:31) -Warning: An update to MeetingsPage inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at MeetingsPage (/home/trav/repos/noteflow/client/src/pages/Meetings.tsx:46:62) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at Routes (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1273:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Meetings.test.tsx:147:31) -Warning: An update to MeetingsPage inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at MeetingsPage (/home/trav/repos/noteflow/client/src/pages/Meetings.tsx:46:62) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at Routes (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1273:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Meetings.test.tsx:147:31) -Warning: An update to MeetingsPage inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at MeetingsPage (/home/trav/repos/noteflow/client/src/pages/Meetings.tsx:46:62) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at Routes (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1273:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Meetings.test.tsx:147:31) - -stderr | src/pages/Meetings.test.tsx > MeetingsPage > shows loading skeletons while fetching -Warning: An update to MeetingsPage inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at MeetingsPage (/home/trav/repos/noteflow/client/src/pages/Meetings.tsx:46:62) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at Routes (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1273:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Meetings.test.tsx:147:31) -Warning: An update to MeetingsPage inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at MeetingsPage (/home/trav/repos/noteflow/client/src/pages/Meetings.tsx:46:62) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at Routes (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1273:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Meetings.test.tsx:147:31) -Warning: An update to MeetingsPage inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at MeetingsPage (/home/trav/repos/noteflow/client/src/pages/Meetings.tsx:46:62) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at Routes (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1273:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Meetings.test.tsx:147:31) -Warning: An update to MeetingsPage inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at MeetingsPage (/home/trav/repos/noteflow/client/src/pages/Meetings.tsx:46:62) - at RenderedRoute (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:539:7) - at Routes (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1273:7) - at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) - at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - at QueryClientProvider (file:///home/trav/repos/noteflow/client/node_modules/@tanstack/react-query/build/modern/QueryClientProvider.js:20:3) - at Wrapper (/home/trav/repos/noteflow/client/src/pages/Meetings.test.tsx:147:31) - - ✓ src/api/adapters/tauri/stream.test.ts (19 tests) 424ms - ✓ src/components/features/meetings/processing-status.test.tsx (22 tests) 342ms - ✓ src/components/features/projects/ProjectScopeFilter.test.tsx (5 tests) 430ms - ✓ src/components/features/settings/export-ai-section.test.tsx (22 tests) 485ms - ✓ src/pages/meeting-detail/header.test.tsx (8 tests) 411ms - ✓ src/components/features/notes/timestamped-notes-editor.test.tsx (4 tests) 595ms - ✓ src/pages/meeting-detail/ask-panel.test.tsx (5 tests) 343ms - ✓ src/components/features/recording/audio-device-selector.test.tsx (3 tests) 362ms - ✓ src/pages/Analytics.test.tsx (5 tests) 309ms - ✓ src/pages/Meetings.test.tsx (11 tests) 637ms -stderr | src/hooks/data/use-async-data.test.tsx > useAsyncData > supports refetch and reset -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) + ✓ src/pages/Recording.logic.test.tsx (7 tests | 2 skipped) 1185ms + ✓ shows desktop-only message when not running in tauri without simulation 806ms + ✓ src/pages/Tasks.test.tsx (12 tests) 789ms + ✓ filters tasks by priority and search query 311ms + ✓ src/components/features/entities/entity-management-panel.test.tsx (4 tests) 836ms + ✓ adds, edits, and deletes entities when persisted 481ms + ✓ src/components/features/analytics/logs-tab.test.tsx (16 tests) 1161ms + ✓ src/hooks/sync/use-integration-sync.test.ts (43 tests) 805ms + ✓ polls until sync completes when initial status is running 503ms + ✓ src/hooks/processing/use-post-processing.test.ts (30 tests) 1129ms + ✓ src/pages/MeetingDetail.test.tsx (7 tests) 927ms + ✓ src/pages/Meetings.test.tsx (11 tests) 1033ms + ✓ src/pages/Recording.test.tsx (5 tests) 1028ms + ✓ src/components/features/settings/export-ai-section.test.tsx (22 tests) 671ms +stderr | src/hooks/recording/use-recording-session.test.tsx > useRecordingSession > closes streams when unmounted before transcription resolves Warning: An update to TestComponent inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...): @@ -980,31 +107,8 @@ act(() => { This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -stderr | src/pages/meeting-detail/use-meeting-detail.test.ts > useMeetingDetail > refreshes meeting after post-processing completes -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - -stderr | src/hooks/data/use-async-data.test.tsx > useAsyncData > supports refetch and reset -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) + ✓ src/components/features/meetings/processing-status.test.tsx (22 tests) 616ms +stderr | src/hooks/recording/use-recording-session.test.tsx > useRecordingSession > skips start error handling when unmounted Warning: An update to TestComponent inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...): @@ -1017,18 +121,9 @@ act(() => { This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -stderr | src/hooks/data/use-async-data.test.tsx > useAsyncData > handles mutation success and errors -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) + ✓ src/components/common/dialogs/confirmation-dialog.test.tsx (2 tests) 574ms + ✓ renders content and handles confirm 403ms +stderr | src/hooks/recording/use-recording-session.test.tsx > useRecordingSession > skips stop updates when unmounted before stop resolves Warning: An update to TestComponent inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...): @@ -1041,18 +136,7 @@ act(() => { This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -stderr | src/hooks/data/use-async-data.test.tsx > useAsyncData > handles mutation success and errors -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +stderr | src/hooks/recording/use-recording-session.test.tsx > useRecordingSession > skips stop error handling when unmounted Warning: An update to TestComponent inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...): @@ -1065,139 +149,18 @@ act(() => { This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -stderr | src/hooks/data/use-async-data.test.tsx > useAsyncData > handles mutation success and errors -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - -stderr | src/hooks/data/use-async-data.test.tsx > useAsyncData > handles mutation success and errors -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - -stderr | src/hooks/data/use-async-data.test.tsx > useAsyncData > resets mutation state -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - -stderr | src/hooks/data/use-async-data.test.tsx > useAsyncData > resets mutation state -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - - ✓ src/pages/meeting-detail/use-meeting-detail.test.ts (7 tests) 350ms -stderr | src/hooks/data/use-async-data.test.tsx > useAsyncData > resets mutation state -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - - ✓ src/hooks/data/use-async-data.test.tsx (6 tests) 355ms + ✓ src/components/features/analytics/performance-tab.test.tsx (16 tests) 905ms + ✓ src/hooks/recording/use-recording-session.test.tsx (26 tests) 676ms + ✓ src/pages/meeting-detail/header.test.tsx (8 tests) 553ms + ✓ src/pages/Home.test.tsx (8 tests) 889ms + ✓ has view all link for meetings 460ms + ✓ src/components/features/notes/timestamped-notes-editor.test.tsx (4 tests) 804ms + ✓ saves notes manually and shows history 341ms + ✓ src/components/features/projects/ProjectScopeFilter.test.tsx (5 tests) 563ms + ✓ src/components/features/recording/audio-device-selector.test.tsx (3 tests) 577ms + ✓ renders empty device list state 315ms + ✓ src/components/features/connectivity/server-switch-confirmation-dialog.test.tsx (2 tests) 395ms + ✓ renders server details and confirms 314ms Error: useProjects must be used within ProjectProvider at Module.useProjects (/home/trav/repos/noteflow/client/src/contexts/project-state.ts:25:11) at ProjectConsumer (/home/trav/repos/noteflow/client/src/contexts/project-context.test.tsx:124:3) @@ -1220,40 +183,474 @@ Error: useProjects must be used within ProjectProvider at innerInvokeEventListeners (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:360:16) at invokeEventListeners (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:296:3) at HTMLUnknownElementImpl._dispatch (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:243:9) -stderr | src/contexts/project-context.test.tsx > ProjectProvider > throws when useProjects is used outside provider -The above error occurred in the component: +stderr | src/contexts/project-context.test.tsx > ProjectProvider > throws when useProjects is used outside provider +The above error occurred in the component: at ProjectConsumer (/home/trav/repos/noteflow/client/src/contexts/project-context.test.tsx:194:21) at ErrorBoundary (/home/trav/repos/noteflow/client/src/contexts/project-context.test.tsx:188:9) React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary. - ✓ src/components/features/workspace/workspace-switcher.test.tsx (2 tests) 392ms - ✓ lists workspaces and switches on selection  380ms -stderr | src/pages/Home.behavior.test.tsx > HomePage behavior > logs when meeting load fails -⚠️ React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. -⚠️ React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + ✓ src/api/adapters/tauri/stream.test.ts (19 tests) 516ms + ✓ src/contexts/project-context.test.tsx (9 tests) 615ms + ✓ src/lib/preferences/storage.test.ts (14 tests) 552ms + ✓ src/components/features/calendar/upcoming-meetings.test.tsx (7 tests) 417ms +stderr | src/hooks/audio/use-asr-config.test.ts > useAsrConfig > updateConfig > refreshes config when job completes without new configuration +Warning: You seem to have overlapping act() calls, this is not supported. Be sure to await previous act() calls before making a new one. - ✓ src/components/features/analytics/logs-tab.test.tsx (16 tests) 1058ms - ✓ src/components/ui/dropdown-menu.test.tsx (1 test) 319ms - ✓ renders menu content and items when open  307ms -stderr | src/components/features/calendar/upcoming-meetings.test.tsx > UpcomingMeetings > renders skeleton when loading and calendars connected -⚠️ React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. -⚠️ React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. +stderr | src/hooks/audio/use-asr-config.test.ts > useAsrConfig > updateConfig > refreshes config when job completes without new configuration +Warning: You seem to have overlapping act() calls, this is not supported. Be sure to await previous act() calls before making a new one. - ✓ src/contexts/project-context.test.tsx (9 tests) 366ms - ✓ src/components/features/calendar/calendar-connection-panel.test.tsx (3 tests) 222ms - ✓ src/components/common/dialogs/confirmation-dialog.test.tsx (2 tests) 375ms - ✓ src/api/adapters/mock/index.test.ts (12 tests) 191ms - ✓ src/components/features/connectivity/server-switch-confirmation-dialog.test.tsx (2 tests) 283ms -stderr | src/pages/Recording.behavior.test.tsx > RecordingPage behavior > renders idle state when not recording -⚠️ React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. -⚠️ React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. + ✓ src/components/ui/dropdown-menu.test.tsx (1 test) 472ms + ✓ renders menu content and items when open 468ms + ✓ src/pages/meeting-detail/index.test.tsx (12 tests) 379ms +stderr | src/hooks/audio/use-audio-devices.test.ts > useAudioDevices > rethrows input selection errors in tauri mode +Warning: An update to TestComponent inside a test was not wrapped in act(...). - ✓ src/pages/Home.behavior.test.tsx (4 tests) 368ms - ✓ src/components/features/calendar/upcoming-meetings.test.tsx (7 tests) 252ms -stderr | src/pages/Recording.behavior.test.tsx > RecordingPage behavior > toggles pinned entities from transcript cards -Warning: An update to RecordingPage inside a test was not wrapped in act(...). +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) + +stderr | src/hooks/audio/use-audio-devices.test.ts > useAudioDevices > rethrows output selection errors in tauri mode +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) + +stderr | src/hooks/audio/use-audio-devices.test.ts > useAudioDevices > rethrows system device selection errors in tauri mode +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) + + ✓ src/pages/Analytics.test.tsx (5 tests) 683ms + ✓ requests speech analysis data when switching tabs 418ms +stderr | src/hooks/audio/use-audio-devices.test.ts > useAudioDevices > rethrows dual capture toggle errors +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) + +stderr | src/hooks/audio/use-audio-devices.test.ts > useAudioDevices > rethrows audio mix errors +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) + + ✓ src/pages/meeting-detail/ask-panel.test.tsx (5 tests) 716ms + ✓ renders empty state and disables submit for blank input 524ms +stderr | src/hooks/audio/use-audio-devices.test.ts > useAudioDevices > ignores tauri audio test events when not testing input +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) + + ✓ src/components/features/analytics/log-timeline.test.tsx (3 tests) 430ms + ✓ renders groups with badges, gaps, and hidden count 324ms +stderr | src/hooks/data/use-async-data.test.tsx > useAsyncData > resets mutation state +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) + + ✓ src/components/features/workspace/workspace-switcher.test.tsx (2 tests) 418ms + ✓ lists workspaces and switches on selection 373ms + ✓ src/components/features/sync/preferences-sync-status.test.tsx (2 tests) 244ms + ✓ src/hooks/audio/use-audio-devices.test.ts (33 tests) 294ms + ✓ src/hooks/data/use-async-data.test.tsx (6 tests) 249ms +stderr | src/pages/meeting-detail/use-meeting-detail.test.ts > useMeetingDetail > refreshes meeting after post-processing completes +Warning: An update to TestComponent inside a test was not wrapped in act(...). + +When testing, code that causes React state updates should be wrapped into act(...): + +act(() => { + /* fire events that update state */ +}); +/* assert on the output */ + +This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act + at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) + +stderr | src/pages/Recording.behavior.test.tsx > RecordingPage behavior > toggles pinned entities from transcript cards +Warning: An update to RecordingPage inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...): @@ -1267,10 +664,9 @@ This ensures that you're testing the behavior the user would see in the browser. at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - ✓ src/pages/meeting-detail/index.test.tsx (12 tests) 227ms - ✓ src/components/features/analytics/log-timeline.test.tsx (3 tests) 245ms -stderr | src/pages/Recording.behavior.test.tsx > RecordingPage behavior > toggles pinned entities from transcript cards -Warning: An update to RecordingPage inside a test was not wrapped in act(...). + ✓ src/pages/meeting-detail/use-meeting-detail.test.ts (7 tests) 447ms +stderr | src/pages/Recording.behavior.test.tsx > RecordingPage behavior > toggles pinned entities from transcript cards +Warning: An update to RecordingPage inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...): @@ -1284,8 +680,8 @@ This ensures that you're testing the behavior the user would see in the browser. at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) -stderr | src/pages/Recording.behavior.test.tsx > RecordingPage behavior > jumps to live when indicator is clicked -Warning: An update to RecordingPage inside a test was not wrapped in act(...). +stderr | src/pages/Recording.behavior.test.tsx > RecordingPage behavior > jumps to live when indicator is clicked +Warning: An update to RecordingPage inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...): @@ -1299,11 +695,12 @@ This ensures that you're testing the behavior the user would see in the browser. at Router (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1207:17) at MemoryRouter (/home/trav/repos/noteflow/client/node_modules/react-router/dist/umd/react-router.development.js:1101:7) - ✓ src/pages/Recording.behavior.test.tsx (11 tests) 215ms - ✓ src/components/features/entities/entity-highlight.test.tsx (3 tests) 219ms - ✓ src/components/ui/sheet.test.tsx (1 test) 228ms - ✓ src/components/ui/dialog.test.tsx (1 test) 361ms - ✓ renders dialog content with title, description, and overlay  360ms + ✓ src/pages/Recording.behavior.test.tsx (11 tests) 527ms + ✓ src/components/features/calendar/calendar-connection-panel.test.tsx (3 tests) 427ms + ✓ src/pages/NotFound.test.tsx (3 tests) 257ms + ✓ src/api/adapters/mock/index.test.ts (12 tests) 486ms + ✓ src/components/ui/sheet.test.tsx (1 test) 221ms + ✓ src/test/code-quality.test.ts (28 tests) 335ms Error: Kaboom at Thrower (/home/trav/repos/noteflow/client/src/components/common/error-boundary.test.tsx:7:9) at renderWithHooks (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:15486:18) @@ -1315,6 +712,7 @@ Error: Kaboom at invokeEventListeners (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:296:3) at HTMLUnknownElementImpl._dispatch (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:243:9) at HTMLUnknownElementImpl.dispatchEvent (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:114:17) + ✓ src/components/ui/dialog.test.tsx (1 test) 276ms Error: Kaboom at Thrower (/home/trav/repos/noteflow/client/src/components/common/error-boundary.test.tsx:7:9) at renderWithHooks (/home/trav/repos/noteflow/client/node_modules/react-dom/cjs/react-dom.development.js:15486:18) @@ -1326,423 +724,45 @@ Error: Kaboom at invokeEventListeners (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:296:3) at HTMLUnknownElementImpl._dispatch (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:243:9) at HTMLUnknownElementImpl.dispatchEvent (/home/trav/repos/noteflow/client/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:114:17) - ✓ src/hooks/recording/use-recording-app-policy.test.tsx (5 tests) 187ms - ✓ src/components/common/error-boundary.test.tsx (2 tests) 151ms - ✓ src/api/adapters/tauri/__tests__/transcription-mapping.test.ts (8 tests) 191ms - ✓ src/pages/NotFound.test.tsx (3 tests) 182ms - ✓ src/pages/meeting-detail/summary-panel.test.tsx (4 tests) 248ms - ✓ src/pages/meeting-detail/entities-panel.test.tsx (2 tests) 212ms - ✓ src/contexts/workspace-context.test.tsx (2 tests) 189ms - ✓ src/components/features/recording/recording-header.test.tsx (3 tests) 258ms - ✓ src/components/features/recording/jump-to-live-indicator.test.tsx (1 test) 161ms - ✓ src/components/features/sync/preferences-sync-status.test.tsx (2 tests) 257ms - ✓ src/lib/preferences/storage.test.ts (14 tests) 173ms - ✓ src/test/code-quality.test.ts (28 tests) 246ms -stderr | src/components/common/nav-link.test.tsx > NavLink > applies active class when route matches -⚠️ React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition. -⚠️ React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath. - - ✓ src/components/common/nav-link.test.tsx (2 tests) 194ms - ✓ src/hooks/data/use-project-members.test.tsx (3 tests) 128ms - ✓ src/hooks/data/use-guarded-mutation.test.tsx (2 tests) 146ms -stderr | src/hooks/audio/use-audio-devices.test.ts > useAudioDevices > rethrows input selection errors in tauri mode -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - -stderr | src/hooks/audio/use-audio-devices.test.ts > useAudioDevices > rethrows output selection errors in tauri mode -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - -stderr | src/hooks/audio/use-audio-devices.test.ts > useAudioDevices > rethrows system device selection errors in tauri mode -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - -stderr | src/hooks/audio/use-audio-devices.test.ts > useAudioDevices > rethrows dual capture toggle errors -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - -stderr | src/hooks/audio/use-audio-devices.test.ts > useAudioDevices > rethrows audio mix errors -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - - ✓ src/hooks/audio/use-streaming-config.test.tsx (2 tests) 130ms -stderr | src/hooks/audio/use-audio-devices.test.ts > useAudioDevices > ignores tauri audio test events when not testing input -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) -Warning: An update to TestComponent inside a test was not wrapped in act(...). - -When testing, code that causes React state updates should be wrapped into act(...): - -act(() => { - /* fire events that update state */ -}); -/* assert on the output */ - -This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act - at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - -stderr | src/components/ui/markdown-editor.test.tsx > MarkdownEditor > renders toolbar and executes formatting commands -Warning: Received `false` for a non-boolean attribute `pressed`. + ✓ src/lib/ai-providers/strategies/strategies.test.ts (99 tests) 231ms + ✓ src/hooks/processing/use-diarization.test.ts (26 tests) 152ms + ✓ src/pages/meeting-detail/summary-panel.test.tsx (5 tests) 231ms + ✓ src/pages/Home.behavior.test.tsx (4 tests) 782ms + ✓ renders active recording and uses project meeting path 533ms + ✓ src/components/common/error-boundary.test.tsx (2 tests) 657ms + ✓ renders fallback UI on error and reloads 601ms + ✓ src/components/features/analytics/logs-tab-list.test.tsx (4 tests) 126ms + ✓ src/hooks/recording/use-recording-app-policy.test.tsx (5 tests) 221ms + ✓ src/components/ui/resizable.test.tsx (1 test) 131ms + ✓ src/components/features/recording/recording-header.test.tsx (3 tests) 262ms + ✓ src/api/adapters/tauri/__tests__/transcription-mapping.test.ts (8 tests) 205ms + ✓ src/contexts/workspace-context.test.tsx (2 tests) 178ms + ✓ src/pages/meeting-detail/entities-panel.test.tsx (2 tests) 419ms + ✓ renders empty state with extract button 369ms + ✓ src/components/common/nav-link.test.tsx (2 tests) 224ms + ✓ src/components/features/connectivity/api-mode-indicator.test.tsx (3 tests) 89ms + ✓ src/lib/state/entities.test.ts (47 tests) 116ms + ✓ src/components/features/entities/entity-highlight.test.tsx (3 tests) 364ms + ✓ src/hooks/data/use-project-members.test.tsx (3 tests) 144ms + ✓ src/components/features/recording/notes-quick-actions.test.tsx (2 tests) 55ms + ✓ src/hooks/data/use-guarded-mutation.test.tsx (2 tests) 172ms + ✓ src/components/features/settings/integrations-section/use-integration-handlers.test.tsx (28 tests) 227ms + ✓ src/components/features/recording/jump-to-live-indicator.test.tsx (1 test) 251ms + ✓ src/lib/preferences/api.test.ts (8 tests) 224ms + ✓ src/lib/utils/format.test.ts (40 tests) 110ms + ✓ src/hooks/auth/use-auth-flow.test.tsx (6 tests) 119ms + ✓ src/hooks/audio/use-streaming-config.test.tsx (2 tests) 133ms + ✓ src/components/features/recording/audio-level-meter.test.tsx (10 tests) 147ms + ✓ src/components/features/calendar/calendar-events-panel.test.tsx (4 tests) 257ms +stderr | src/components/ui/markdown-editor.test.tsx > MarkdownEditor > renders toolbar and executes formatting commands +Warning: Received `false` for a non-boolean attribute `pressed`. If you want to write it to the DOM, pass a string instead: pressed="false" or pressed={value.toString()}. If you used to conditionally omit it with pressed={condition && value}, pass pressed={condition ? value : undefined} instead. at button - at Toggle (/home/trav/repos/noteflow/client/src/components/ui/markdown-editor.test.tsx:52:20) - at div - at TooltipTrigger (/home/trav/repos/noteflow/client/src/components/ui/markdown-editor.test.tsx:27:28) - at div + at Toggle (/home/trav/repos/noteflow/client/src/components/ui/markdown-editor.test.tsx:48:20) + at TooltipTrigger (/home/trav/repos/noteflow/client/src/components/ui/markdown-editor.test.tsx:23:28) at Tooltip (/home/trav/repos/noteflow/client/src/components/ui/markdown-editor.test.tsx:20:21) at ToolbarButton (/home/trav/repos/noteflow/client/src/components/ui/markdown-editor.tsx:25:32) at div @@ -1750,17 +770,12 @@ If you used to conditionally omit it with pressed={condition && value}, pass pre at div at MarkdownEditor (/home/trav/repos/noteflow/client/src/components/ui/markdown-editor.tsx:228:31) - ✓ src/components/ui/resizable.test.tsx (1 test) 154ms - ✓ src/hooks/audio/use-audio-devices.test.ts (33 tests) 121ms - ✓ src/components/ui/markdown-editor.test.tsx (7 tests) 107ms - ✓ src/lib/preferences/api.test.ts (8 tests) 122ms - ✓ src/hooks/processing/use-diarization.test.ts (26 tests) 64ms - ✓ src/integration/recording-session.integration.test.tsx (5 tests) 62ms - ✓ src/components/features/calendar/calendar-events-panel.test.tsx (4 tests) 94ms - ✓ src/hooks/auth/use-auth-flow.test.tsx (6 tests) 79ms - ✓ src/lib/ai-providers/strategies/strategies.test.ts (99 tests) 26ms -stderr | src/hooks/ui/use-mobile.test.tsx > useIsMobile > updates when matchMedia change fires -Warning: An update to TestComponent inside a test was not wrapped in act(...). + ✓ src/components/ui/markdown-editor.test.tsx (7 tests) 134ms + ✓ src/api/adapters/cached/index.test.ts (8 tests) 102ms + ✓ src/hooks/auth/use-oidc-providers.test.ts (23 tests) 88ms + ✓ src/components/ui/ui-components.test.tsx (5 tests) 88ms +stderr | src/hooks/ui/use-mobile.test.tsx > useIsMobile > updates when matchMedia change fires +Warning: An update to TestComponent inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...): @@ -1772,31 +787,45 @@ act(() => { This ensures that you're testing the behavior the user would see in the browser. Learn more at https://reactjs.org/link/wrap-tests-with-act at TestComponent (/home/trav/repos/noteflow/client/node_modules/@testing-library/react/dist/pure.js:328:5) - ✓ src/components/features/analytics/logs-tab-list.test.tsx (4 tests) 77ms - ✓ src/hooks/ui/use-mobile.test.tsx (2 tests) 77ms - ✓ src/components/features/recording/audio-level-meter.test.tsx (10 tests) 90ms - ✓ src/components/features/connectivity/connection-status.test.tsx (4 tests) 51ms - ✓ src/hooks/processing/use-assistant.test.ts (5 tests) 29ms - ✓ src/lib/integrations/oauth.test.ts (6 tests) 32ms - ✓ src/components/common/badges/speaker-badge.test.tsx (5 tests) 46ms - ✓ src/hooks/auth/use-oauth-flow.test.ts (22 tests) 68ms - ✓ src/components/features/settings/integrations-section/use-integration-handlers.test.tsx (28 tests) 57ms - ✓ src/components/ui/slider.test.tsx (1 test) 56ms - ✓ src/components/features/recording/unified-status-row.test.tsx (4 tests) 55ms - ✓ src/hooks/auth/use-oidc-providers.test.ts (23 tests) 59ms - ✓ src/api/adapters/cached/index.test.ts (8 tests) 45ms - ✓ src/lib/preferences/tauri.test.ts (8 tests) 38ms - ✓ src/components/ui/ui-components.test.tsx (5 tests) 43ms - ✓ src/components/features/recording/recording-components.test.tsx (4 tests) 50ms - ✓ src/hooks/sync/use-preferences-sync.test.tsx (4 tests) 42ms - ✓ src/lib/utils/format.test.ts (40 tests) 48ms - ✓ src/hooks/sync/use-meeting-reminders.test.tsx (5 tests) 36ms - ✓ src/components/features/connectivity/offline-banner.test.tsx (5 tests) 63ms - ✓ src/components/features/recording/notes-quick-actions.test.tsx (2 tests) 39ms - ✓ src/components/features/recording/stat-card.test.tsx (5 tests) 46ms - ✓ src/api/core/reconnection.test.ts (21 tests) 27ms -stderr | src/pages/meeting-detail/transcript-row.test.tsx > MeetingTranscriptRow > applies selected styles and disables animation when requested -Warning: Received `false` for a non-boolean attribute `initial`. + ✓ src/hooks/auth/use-oauth-flow.test.ts (22 tests) 171ms + ✓ src/components/common/stats-card.test.tsx (3 tests) 35ms + ✓ src/lib/observability/messages.test.ts (60 tests) 123ms + ✓ src/hooks/ui/use-mobile.test.tsx (2 tests) 89ms + ✓ src/integration/recording-session.integration.test.tsx (5 tests) 110ms + ✓ src/api/core/reconnection.test.ts (21 tests) 89ms + ✓ src/components/features/recording/confidence-indicator.test.tsx (7 tests) 68ms + ✓ src/lib/preferences/tauri.test.ts (8 tests) 70ms + ✓ src/hooks/processing/use-assistant.test.ts (5 tests) 49ms + ✓ src/lib/observability/events.test.ts (32 tests) 78ms + ✓ src/components/features/recording/stat-card.test.tsx (5 tests) 82ms + ✓ src/components/features/recording/idle-state.test.tsx (5 tests) 66ms + ✓ src/components/ui/select.test.tsx (2 tests) 60ms + ✓ src/lib/observability/events.integration.test.ts (24 tests) 67ms + ✓ src/components/features/recording/transcript-segment-actions.test.tsx (3 tests) 84ms + ✓ src/components/features/connectivity/offline-banner.test.tsx (5 tests) 87ms + ✓ src/components/common/badges/speaker-badge.test.tsx (5 tests) 164ms + ✓ src/components/common/badges/annotation-type-badge.test.tsx (2 tests) 49ms + ✓ src/hooks/sync/use-preferences-sync.test.tsx (4 tests) 61ms + ✓ src/api/types/errors.test.ts (37 tests) 59ms + ✓ src/lib/integrations/oauth.test.ts (6 tests) 98ms + ✓ src/components/features/recording/buffering-indicator.test.tsx (4 tests) 117ms + ✓ src/components/features/connectivity/connection-status.test.tsx (4 tests) 189ms + ✓ src/hooks/sync/use-integration-validation.test.tsx (3 tests) 36ms + ✓ src/hooks/sync/use-calendar-sync.test.tsx (7 tests) 95ms + ✓ src/hooks/processing/use-entity-extraction.test.tsx (5 tests) 51ms + ✓ src/contexts/connection-context.test.tsx (4 tests) 169ms + ✓ src/hooks/sync/use-webhooks.test.tsx (3 tests) 26ms + ✓ src/components/features/entities/animated-transcription.test.tsx (3 tests) 34ms + ✓ src/components/ui/slider.test.tsx (1 test) 64ms + ✓ src/hooks/sync/use-meeting-reminders.test.tsx (5 tests) 159ms + ✓ src/lib/preferences/sync.test.ts (13 tests) 89ms + ✓ src/components/features/recording/unified-status-row.test.tsx (4 tests) 120ms + ✓ src/hooks/ui/use-toast.test.ts (5 tests) 59ms + ✓ src/api/index.test.ts (5 tests) 56ms + ✓ src/lib/observability/group-summarizer.test.ts (27 tests) 70ms + ✓ src/components/features/recording/vad-indicator.test.tsx (6 tests) 91ms +stderr | src/pages/meeting-detail/transcript-row.test.tsx > MeetingTranscriptRow > applies selected styles and disables animation when requested +Warning: Received `false` for a non-boolean attribute `initial`. If you want to write it to the DOM, pass a string instead: initial="false" or initial={value.toString()}. @@ -1805,482 +834,201 @@ If you used to conditionally omit it with initial={condition && value}, pass ini at div (/home/trav/repos/noteflow/client/src/pages/meeting-detail/transcript-row.test.tsx:4:21) at MeetingTranscriptRow (/home/trav/repos/noteflow/client/src/pages/meeting-detail/transcript-row.tsx:14:107) - ✓ src/pages/meeting-detail/transcript-row.test.tsx (2 tests) 39ms - ✓ src/components/features/connectivity/api-mode-indicator.test.tsx (3 tests) 49ms - ✓ src/contexts/connection-context.test.tsx (4 tests) 64ms - ✓ src/components/features/recording/speaker-distribution.test.tsx (2 tests) 38ms - ✓ src/components/features/entities/animated-transcription.test.tsx (3 tests) 41ms - ✓ src/components/features/recording/in-transcript-search.test.tsx (1 test) 41ms - ✓ src/components/common/badges/annotation-type-badge.test.tsx (2 tests) 43ms - ✓ src/components/common/stats-card.test.tsx (3 tests) 33ms - ✓ src/components/ui/select.test.tsx (2 tests) 35ms - ✓ src/components/features/recording/idle-state.test.tsx (5 tests) 37ms - ✓ src/components/features/recording/transcript-segment-actions.test.tsx (3 tests) 41ms - ✓ src/components/features/recording/buffering-indicator.test.tsx (4 tests) 37ms - ✓ src/components/features/recording/confidence-indicator.test.tsx (7 tests) 41ms - ✓ src/hooks/sync/use-calendar-sync.test.tsx (7 tests) 62ms - ✓ src/hooks/sync/use-integration-validation.test.tsx (3 tests) 33ms - ✓ src/api/index.test.ts (5 tests) 29ms - ✓ src/components/features/recording/vad-indicator.test.tsx (6 tests) 30ms - ✓ src/hooks/processing/use-entity-extraction.test.tsx (5 tests) 30ms - ✓ src/lib/system/events.test.tsx (6 tests) 21ms - ✓ src/lib/state/entities.test.ts (47 tests) 20ms - ✓ src/pages/Projects.test.tsx (1 test) 43ms - ✓ src/lib/utils/polling.test.ts (8 tests) 21ms - ✓ src/lib/utils/async.test.ts (7 tests) 18ms - ✓ src/hooks/auth/use-secure-integration-secrets.test.tsx (3 tests) 21ms - ✓ src/lib/preferences/validation-events.test.ts (3 tests) 11ms + ✓ src/lib/observability/groups.test.ts (18 tests) 37ms + ✓ src/pages/meeting-detail/transcript-row.test.tsx (2 tests) 67ms + ✓ src/lib/storage/crypto.test.ts (14 tests) 46ms + ✓ src/hooks/auth/use-secure-integration-secrets.test.tsx (3 tests) 48ms + ✓ src/components/features/recording/speaker-distribution.test.tsx (2 tests) 63ms + ✓ src/components/features/recording/recording-components.test.tsx (4 tests) 144ms + ✓ src/lib/audio/device-ids.test.ts (21 tests) 51ms + ✓ src/lib/observability/summarizer.test.ts (27 tests) 60ms + ✓ src/lib/cache/meeting-cache.test.ts (18 tests) 38ms + ✓ src/hooks/processing/events.test.tsx (2 tests) 54ms + ✓ src/components/features/recording/in-transcript-search.test.tsx (1 test) 42ms + ✓ src/hooks/ui/use-recording-panels.test.tsx (3 tests) 24ms + ✓ src/lib/observability/converters.test.ts (18 tests) 35ms + ✓ src/pages/Projects.test.tsx (1 test) 61ms + ✓ src/lib/ai-providers/strategies/google.test.ts (3 tests) 13ms + ✓ src/hooks/ui/use-panel-preferences.test.ts (4 tests) 39ms + ✓ src/lib/utils/polling.test.ts (8 tests) 154ms + ✓ src/api/core/helpers.test.ts (13 tests) 43ms + ✓ src/lib/utils/async.test.ts (7 tests) 44ms + ✓ src/lib/system/events.test.tsx (6 tests) 44ms + ✓ src/hooks/data/use-project.test.tsx (2 tests) 18ms + ✓ src/api/adapters/tauri/__tests__/misc-mapping.test.ts (8 tests) 32ms + ✓ src/api/adapters/cached/audio.test.ts (2 tests) 11ms Not implemented: navigation to another Document - ✓ src/hooks/ui/use-toast.test.ts (5 tests) 20ms - ✓ src/lib/utils/download.test.ts (3 tests) 18ms - ✓ src/api/adapters/tauri/__tests__/environment.test.ts (3 tests) 24ms - ✓ src/hooks/sync/use-webhooks.test.tsx (3 tests) 22ms - ✓ src/lib/observability/messages.test.ts (52 tests) 23ms - ✓ src/hooks/ui/use-panel-preferences.test.ts (4 tests) 17ms - ✓ src/hooks/processing/events.test.tsx (2 tests) 31ms - ✓ src/hooks/data/use-project.test.tsx (2 tests) 15ms - ✓ src/hooks/ui/use-recording-panels.test.tsx (3 tests) 26ms - ✓ src/lib/observability/events.test.ts (32 tests) 14ms - ✓ src/api/adapters/tauri/__tests__/misc-mapping.test.ts (8 tests) 14ms - ✓ src/api/adapters/tauri/sections/meetings.test.ts (10 tests) 20ms - ✓ src/lib/cache/meeting-cache.test.ts (18 tests) 12ms - ✓ src/hooks/ui/use-animated-words.test.ts (3 tests) 15ms - ✓ src/lib/storage/utils.test.ts (7 tests) 13ms - ✓ src/lib/ai-providers/strategies/google.test.ts (3 tests) 14ms - ✓ src/lib/preferences/sync.test.ts (13 tests) 13ms - ✓ src/lib/ai-providers/model-catalog-utils.test.ts (4 tests) 8ms - ✓ src/api/core/helpers.test.ts (13 tests) 5ms - ✓ src/api/adapters/tauri/sections/sections.test.ts (7 tests) 8ms - ✓ src/api/adapters/tauri/constants.test.ts (4 tests) 5ms - ✓ src/api/types/errors.test.ts (37 tests) 7ms - ✓ src/api/adapters/mock/data.test.ts (6 tests) 8ms - ✓ src/api/adapters/cached/projects.test.ts (3 tests) 12ms - ✓ src/lib/storage/crypto.test.ts (14 tests) 18ms - ✓ src/lib/utils/index.test.ts (7 tests) 7ms - ✓ src/lib/observability/events.integration.test.ts (24 tests) 11ms - ✓ src/api/adapters/tauri/__tests__/core-mapping.test.ts (8 tests) 13ms - ✓ src/api/adapters/mock/stream.test.ts (5 tests) 11ms - ✓ src/api/adapters/cached/templates.test.ts (2 tests) 8ms - ✓ src/lib/audio/device-ids.test.ts (21 tests) 13ms - ✓ src/lib/audio/device-persistence.integration.test.ts (8 tests) 5ms - ✓ src/lib/observability/summarizer.test.ts (27 tests) 15ms - ✓ src/lib/observability/group-summarizer.test.ts (27 tests) 7ms - ✓ src/lib/observability/groups.test.ts (18 tests) 7ms - ✓ src/lib/preferences/validation.test.ts (3 tests) 7ms - ✓ src/lib/observability/converters.test.ts (18 tests) 9ms - ✓ src/lib/observability/client.test.ts (2 tests) 8ms - ✓ src/api/adapters/cached/triggers.test.ts (2 tests) 4ms - ✓ src/lib/utils/event-emitter.test.ts (3 tests) 10ms - ✓ src/api/adapters/tauri/sections/summarization.test.ts (2 tests) 7ms - ✓ src/lib/preferences/tags.test.ts (1 test) 4ms - ✓ src/lib/utils/object.test.ts (5 tests) 4ms - ✓ src/lib/ai-providers/strategies/custom.test.ts (4 tests) 9ms - ✓ src/api/adapters/cached/apps.test.ts (2 tests) 4ms - ✓ src/lib/audio/speaker.test.ts (2 tests) 10ms - ✓ src/api/adapters/cached/audio.test.ts (2 tests) 6ms - ✓ src/contexts/storage.test.ts (4 tests) 4ms - ✓ src/lib/preferences/integrations.test.ts (2 tests) 5ms - ✓ src/pages/meeting-detail/constants.test.ts (2 tests) 3ms - ✓ src/components/features/settings/integrations-section/helpers.test.ts (2 tests) 4ms - ✓ src/lib/utils/id.test.ts (2 tests) 3ms - ✓ src/api/adapters/tauri/utils.test.ts (2 tests) 3ms - ✓ src/lib/config/config.test.ts (4 tests) 3ms - ✓ src/lib/integrations/utils.test.ts (6 tests) 3ms - ✓ src/lib/config/app-config.test.ts (4 tests) 3ms - ✓ src/lib/ui/cva.test.ts (1 test) 3ms - ✓ src/api/core/connection.test.ts (3 tests) 3ms - ✓ src/components/features/analytics/analytics-utils.test.ts (3 tests) 3ms - ✓ src/lib/preferences/local-only-keys.test.ts (2 tests) 2ms - ✓ src/components/features/recording/index.test.ts (1 test) 2ms + ✓ src/api/adapters/tauri/utils.test.ts (2 tests) 14ms + ✓ src/lib/utils/download.test.ts (3 tests) 42ms + ✓ src/api/adapters/tauri/__tests__/core-mapping.test.ts (8 tests) 35ms + ✓ src/lib/observability/client.test.ts (2 tests) 8ms + ✓ src/lib/audio/device-persistence.integration.test.ts (8 tests) 34ms + ✓ src/api/adapters/tauri/__tests__/environment.test.ts (3 tests) 39ms + ✓ src/api/adapters/mock/data.test.ts (6 tests) 21ms + ✓ src/api/adapters/tauri/sections/sections.test.ts (7 tests) 29ms + ✓ src/lib/storage/utils.test.ts (7 tests) 33ms + ✓ src/lib/ai-providers/model-catalog-utils.test.ts (4 tests) 29ms + ✓ src/hooks/ui/use-animated-words.test.ts (3 tests) 37ms + ✓ src/lib/preferences/validation.test.ts (3 tests) 16ms + ✓ src/api/adapters/cached/projects.test.ts (3 tests) 11ms + ✓ src/lib/preferences/validation-events.test.ts (3 tests) 15ms + ✓ src/lib/preferences/integrations.test.ts (2 tests) 12ms + ✓ src/lib/utils/index.test.ts (7 tests) 27ms + ✓ src/lib/utils/object.test.ts (5 tests) 24ms + ✓ src/api/adapters/tauri/constants.test.ts (4 tests) 10ms + ✓ src/lib/integrations/utils.test.ts (6 tests) 14ms + ✓ src/lib/ai-providers/strategies/custom.test.ts (4 tests) 16ms + ❯ src/hooks/audio/use-asr-config.test.ts (12 tests | 5 failed) 21415ms + ✓ starts with isLoading true 15ms + ✓ loads configuration successfully 61ms + ✓ handles load error 58ms + ✓ refreshes configuration 56ms + ✓ starts reconfiguration job successfully 59ms + ✓ handles rejected update 55ms + ✓ handles API error during update 56ms + × updates config when job completes with new configuration 5016ms + × refreshes config when job completes without new configuration 5008ms + × stops reconfiguring and reports failure status 5007ms + × stops reconfiguring and reports cancelled status 5003ms + × can be called without error 1018ms + ✓ src/api/adapters/cached/apps.test.ts (2 tests) 10ms + ✓ src/api/adapters/cached/templates.test.ts (2 tests) 10ms + ✓ src/lib/config/app-config.test.ts (4 tests) 12ms + ✓ src/api/adapters/cached/triggers.test.ts (2 tests) 12ms + ✓ src/lib/config/config.test.ts (4 tests) 9ms + ✓ src/lib/utils/event-emitter.test.ts (3 tests) 10ms + ✓ src/lib/audio/speaker.test.ts (2 tests) 32ms + ✓ src/contexts/storage.test.ts (4 tests) 19ms + ✓ src/components/features/settings/integrations-section/helpers.test.ts (2 tests) 15ms + ✓ src/api/adapters/tauri/sections/summarization.test.ts (2 tests) 9ms + ✓ src/api/core/connection.test.ts (3 tests) 7ms + ✓ src/lib/utils/id.test.ts (2 tests) 7ms + ✓ src/components/features/analytics/analytics-utils.test.ts (3 tests) 11ms + ✓ src/lib/preferences/local-only-keys.test.ts (2 tests) 8ms + ✓ src/pages/meeting-detail/constants.test.ts (2 tests) 7ms + ✓ src/lib/preferences/tags.test.ts (1 test) 6ms + ✓ src/lib/ui/cva.test.ts (1 test) 4ms + ✓ src/components/features/recording/index.test.ts (1 test) 4ms + ❯ src/api/adapters/mock/stream.test.ts (5 tests | 5 failed) 50069ms + × emits VAD and transcript updates 10043ms + × stops emitting after close 10009ms + × emits vad_end when activity stops 10005ms + × does not emit when no callback registered 10005ms + × accepts audio chunks without throwing 10005ms - Test Files  175 passed (175) - Tests  1530 passed | 2 skipped (1532) - Start at  07:30:18 - Duration  16.29s (transform 10.02s, setup 49.79s, import 24.26s, tests 25.50s, environment 63.11s) +⎯⎯⎯⎯⎯⎯ Failed Tests 11 ⎯⎯⎯⎯⎯⎯⎯ - % Coverage report from v8 --------------------|---------|----------|---------|---------|------------------- -File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s --------------------|---------|----------|---------|---------|------------------- -All files | 91.77 | 78.88 | 91.94 | 91.86 | - api | 77.41 | 46.42 | 44.44 | 77.41 | - index.ts | 77.41 | 46.42 | 44.44 | 77.41 | ...34-135,168-187 - ...dapters/cached | 91.7 | 71.73 | 90.75 | 92.02 | - annotations.ts | 100 | 100 | 100 | 100 | - apps.ts | 100 | 100 | 100 | 100 | - asr.ts | 100 | 100 | 100 | 100 | - audio.ts | 100 | 100 | 100 | 100 | - base.ts | 82.75 | 37.5 | 69.23 | 82.14 | 26,35,77,107-113 - calendar.ts | 77.77 | 100 | 75 | 77.77 | 64-77 - defaults.ts | 100 | 100 | 100 | 100 | - diarization.ts | 83.33 | 100 | 80 | 83.33 | 30 - entities.ts | 100 | 100 | 100 | 100 | - huggingface.ts | 100 | 100 | 100 | 100 | - index.ts | 100 | 100 | 100 | 100 | - meetings.ts | 82.35 | 66.66 | 83.33 | 84.37 | 21,46-48,51 - observability.ts | 66.66 | 100 | 50 | 66.66 | 19,44 - playback.ts | 100 | 100 | 100 | 100 | - preferences.ts | 100 | 100 | 100 | 100 | - projects.ts | 100 | 100 | 100 | 100 | - readonly.ts | 100 | 100 | 100 | 100 | - streaming.ts | 100 | 100 | 100 | 100 | - templates.ts | 100 | 100 | 100 | 100 | - triggers.ts | 100 | 100 | 100 | 100 | - webhooks.ts | 100 | 100 | 100 | 100 | - api/adapters/mock | 95.7 | 79.06 | 96.98 | 95.65 | - data.ts | 100 | 93.1 | 100 | 100 | 155-160 - index.ts | 95.87 | 78.15 | 97.77 | 95.84 | ...1829-1831,1846 - stream.ts | 89.36 | 75 | 81.81 | 89.13 | 69,84,135-141 - ...adapters/tauri | 86.63 | 81.91 | 81.81 | 85.63 | - api.ts | 100 | 100 | 100 | 100 | - constants.ts | 100 | 100 | 100 | 100 | - environment.ts | 69.23 | 100 | 66.66 | 69.23 | 39-45,51-57 - index.ts | 0 | 0 | 0 | 0 | - stream.ts | 87.14 | 79.1 | 78.26 | 85.82 | ...65,306,320-321 - utils.ts | 90.32 | 85 | 100 | 90.32 | 32-35 - ...auri/__tests__ | 83.33 | 80 | 83.33 | 83.33 | - test-utils.ts | 83.33 | 80 | 83.33 | 83.33 | 23,27 - ...tauri/sections | 90.9 | 63.2 | 90.9 | 90.9 | - analytics.ts | 100 | 100 | 100 | 100 | - annotations.ts | 100 | 75 | 100 | 100 | 40-49 - apps.ts | 100 | 50 | 100 | 100 | 15-18 - asr.ts | 100 | 100 | 100 | 100 | - audio.ts | 100 | 50 | 100 | 100 | 84-85 - calendar.ts | 84.61 | 100 | 77.77 | 84.61 | 74-83 - core.ts | 62.5 | 100 | 57.14 | 62.5 | 38,46-58,74-81 - diarization.ts | 83.33 | 50 | 83.33 | 83.33 | 22,48 - entities.ts | 100 | 50 | 100 | 100 | 18-20 - exporting.ts | 77.77 | 100 | 100 | 77.77 | 23-24 - integrations.ts | 80 | 100 | 80 | 80 | 38 - meetings.ts | 97.77 | 73.68 | 90 | 97.77 | 135 - observability.ts | 75 | 100 | 75 | 75 | 32 - oidc.ts | 100 | 50 | 100 | 100 | 36 - playback.ts | 100 | 100 | 100 | 100 | - preferences.ts | 100 | 50 | 100 | 100 | 24-25 - projects.ts | 100 | 100 | 100 | 100 | - summarization.ts | 87.5 | 42.85 | 85.71 | 87.5 | 137-147 - tasks.ts | 100 | 100 | 100 | 100 | - triggers.ts | 100 | 100 | 100 | 100 | - webhooks.ts | 90 | 68.75 | 100 | 90 | 22,25 - api/core | 91.8 | 82.6 | 90.62 | 91.7 | - connection.ts | 91.3 | 100 | 88.88 | 90.9 | 59-60 - constants.ts | 100 | 100 | 100 | 100 | - errors.ts | 89.02 | 82.89 | 90.9 | 89.02 | ...25,178,195,208 - helpers.ts | 91.3 | 90.9 | 88.46 | 91.11 | 239-240,255-256 - reconnection.ts | 94.18 | 75 | 94.44 | 94.11 | 73,90,98,114,207 - components/common | 100 | 87.5 | 100 | 100 | - empty-state.tsx | 100 | 100 | 100 | 100 | - ...-boundary.tsx | 100 | 75 | 100 | 100 | 31 - index.ts | 0 | 0 | 0 | 0 | - nav-link.tsx | 100 | 75 | 100 | 100 | 18 - stats-card.tsx | 100 | 100 | 100 | 100 | - .../common/badges | 100 | 90.47 | 100 | 100 | - ...ype-badge.tsx | 100 | 100 | 100 | 100 | - ...ity-badge.tsx | 100 | 100 | 100 | 100 | - ...ker-badge.tsx | 100 | 88.88 | 100 | 100 | 39,48 - ...common/dialogs | 100 | 100 | 100 | 100 | - ...on-dialog.tsx | 100 | 100 | 100 | 100 | - ...ures/analytics | 85.39 | 76.38 | 78.08 | 85.18 | - ...ard-title.tsx | 100 | 100 | 100 | 100 | - ...tics-utils.ts | 100 | 95 | 100 | 100 | 55 - ...try-config.ts | 100 | 100 | 100 | 100 | - log-entry.tsx | 91.66 | 84.31 | 66.66 | 91.66 | 190 - log-timeline.tsx | 80.95 | 79.24 | 81.81 | 80.95 | ...90,202-212,263 - ...-tab-list.tsx | 62.5 | 81.81 | 60 | 62.5 | 102,122,136 - logs-tab.tsx | 81.13 | 60.6 | 69.44 | 81 | ...12,273-398,443 - ...mance-tab.tsx | 97.36 | 76.27 | 100 | 97.22 | 179 - ...tures/calendar | 89.84 | 82.26 | 91.3 | 90 | - ...ion-panel.tsx | 96.15 | 82.85 | 100 | 96.15 | 57 - ...nts-panel.tsx | 78.72 | 72.72 | 80 | 78.26 | ...0,66-67,85,156 - index.ts | 0 | 0 | 0 | 0 | - ...-meetings.tsx | 96.36 | 88.7 | 92.59 | 97.91 | 418 - ...s/connectivity | 90.69 | 95.83 | 85.71 | 90.69 | - ...indicator.tsx | 90 | 93.33 | 100 | 90 | 127 - ...on-status.tsx | 86.95 | 96.87 | 75 | 86.95 | 26,44-45 - index.ts | 0 | 0 | 0 | 0 | - ...ne-banner.tsx | 100 | 95.23 | 100 | 100 | 52 - ...on-dialog.tsx | 100 | 100 | 100 | 100 | - ...tures/entities | 83.22 | 75.45 | 71.15 | 84.41 | - ...scription.tsx | 97.14 | 88.46 | 85.71 | 97.05 | 60 - ...highlight.tsx | 83.33 | 80 | 70 | 83.33 | ...28,150-163,195 - ...ent-panel.tsx | 77.38 | 66.66 | 68.57 | 79.48 | ...30,382,429-453 - index.ts | 0 | 0 | 0 | 0 | - ...tures/meetings | 97.36 | 88.37 | 90.9 | 97.29 | - index.ts | 0 | 0 | 0 | 0 | - meeting-card.tsx | 83.33 | 63.63 | 50 | 83.33 | 47 - ...ate-badge.tsx | 100 | 50 | 100 | 100 | 34 - ...ng-status.tsx | 100 | 100 | 100 | 100 | - ...features/notes | 82.85 | 67.64 | 85.18 | 83.65 | - index.ts | 0 | 0 | 0 | 0 | - ...es-editor.tsx | 82.85 | 67.64 | 85.18 | 83.65 | ...09,222,237-239 - ...tures/projects | 78.12 | 80 | 70.58 | 79.31 | - ...opeFilter.tsx | 78.12 | 80 | 70.58 | 79.31 | 60-63,72-80,105 - ...ures/recording | 92.24 | 83.46 | 97.87 | 91.74 | - ...-selector.tsx | 73.91 | 63.63 | 80 | 72.72 | 57-62,69 - ...vel-meter.tsx | 100 | 100 | 100 | 100 | - ...indicator.tsx | 100 | 100 | 100 | 100 | - ...indicator.tsx | 100 | 100 | 100 | 100 | - idle-state.tsx | 100 | 100 | 100 | 100 | - ...pt-search.tsx | 100 | 100 | 100 | 100 | - index.ts | 0 | 0 | 0 | 0 | - ...indicator.tsx | 100 | 100 | 100 | 100 | - ...ing-state.tsx | 100 | 100 | 100 | 100 | - notes-panel.tsx | 100 | 50 | 100 | 100 | 31 - ...k-actions.tsx | 100 | 100 | 100 | 100 | - ...t-display.tsx | 100 | 100 | 100 | 100 | - ...ng-header.tsx | 100 | 100 | 100 | 100 | - ...tribution.tsx | 100 | 100 | 100 | 100 | - stat-card.tsx | 100 | 100 | 100 | 100 | - ...s-content.tsx | 100 | 100 | 100 | 100 | - stats-panel.tsx | 100 | 50 | 100 | 100 | 38 - ...t-actions.tsx | 66.66 | 83.33 | 100 | 66.66 | 13-14,20 - ...ment-card.tsx | 100 | 60 | 100 | 100 | 31-32 - ...tatus-row.tsx | 100 | 100 | 100 | 100 | - ...indicator.tsx | 100 | 100 | 100 | 100 | - ...tures/settings | 84.52 | 78.94 | 70.58 | 84.52 | - ...i-section.tsx | 84.52 | 78.94 | 70.58 | 84.52 | ...30,283,333-365 - ...ations-section | 98.32 | 85.47 | 100 | 98.28 | - helpers.ts | 100 | 100 | 100 | 100 | - ...n-handlers.ts | 98.19 | 84.68 | 100 | 98.14 | 99,138,142 - .../features/sync | 100 | 91.66 | 100 | 100 | - ...nc-status.tsx | 100 | 91.66 | 100 | 100 | 39 - ...ures/workspace | 100 | 83.33 | 100 | 100 | - index.ts | 0 | 0 | 0 | 0 | - ...-switcher.tsx | 100 | 83.33 | 100 | 100 | 31 - components/ui | 94.68 | 94.87 | 86.95 | 95.63 | - alert-dialog.tsx | 100 | 100 | 100 | 100 | - alert.tsx | 90 | 100 | 66.66 | 90 | 31 - badge.tsx | 100 | 100 | 100 | 100 | - button.tsx | 100 | 100 | 100 | 100 | - card.tsx | 94.44 | 100 | 83.33 | 94.44 | 58 - checkbox.tsx | 100 | 100 | 100 | 100 | - collapsible.tsx | 100 | 100 | 100 | 100 | - dialog.tsx | 95.45 | 100 | 83.33 | 100 | - ...down-menu.tsx | 100 | 100 | 100 | 100 | - input.tsx | 100 | 100 | 100 | 100 | - label.tsx | 100 | 100 | 100 | 100 | - ...itor-utils.ts | 80 | 84.21 | 85.71 | 84.21 | 26,85-86 - ...wn-editor.tsx | 68.42 | 100 | 53.84 | 68.42 | 110-153 - popover.tsx | 100 | 100 | 100 | 100 | - resizable.tsx | 100 | 100 | 100 | 100 | - scroll-area.tsx | 100 | 100 | 100 | 100 | - search-icon.tsx | 100 | 100 | 100 | 100 | - select.tsx | 100 | 100 | 100 | 100 | - separator.tsx | 100 | 100 | 100 | 100 | - sheet.tsx | 95.65 | 100 | 83.33 | 100 | - skeleton.tsx | 88.88 | 100 | 80 | 88.88 | 29 - slider.tsx | 100 | 100 | 100 | 100 | - switch.tsx | 100 | 100 | 100 | 100 | - tabs.tsx | 100 | 100 | 100 | 100 | - textarea.tsx | 100 | 100 | 100 | 100 | - toggle-group.tsx | 100 | 75 | 100 | 100 | 41 - toggle.tsx | 100 | 100 | 100 | 100 | - tooltip.tsx | 100 | 100 | 100 | 100 | - contexts | 95.06 | 77.65 | 96.55 | 94.63 | - ...n-context.tsx | 100 | 87.5 | 100 | 100 | 39 - ...tion-state.ts | 100 | 100 | 100 | 100 | - ...t-context.tsx | 96.55 | 81.81 | 96.77 | 96.11 | 27,70,125,134 - project-state.ts | 100 | 100 | 100 | 100 | - storage.ts | 100 | 100 | 100 | 100 | - ...e-context.tsx | 90.76 | 64 | 91.66 | 90.47 | ...10,112,117-118 - ...pace-state.ts | 80 | 50 | 100 | 80 | 18 - hooks | 0 | 0 | 0 | 0 | - index.ts | 0 | 0 | 0 | 0 | - hooks/audio | 93.33 | 72.31 | 96.47 | 93.33 | - index.ts | 0 | 0 | 0 | 0 | - ...asr-config.ts | 83.16 | 57.14 | 88 | 82.75 | ...40,150,184-195 - ...es.helpers.ts | 75 | 68.75 | 100 | 73.68 | 35-46,65 - ...io-devices.ts | 96.44 | 72.51 | 100 | 96.31 | ...33,275,461-465 - ...ices.types.ts | 0 | 0 | 0 | 0 | - ...io-testing.ts | 97.36 | 82.97 | 100 | 97.34 | 108,170-171 - ...ing-config.ts | 100 | 100 | 100 | 100 | - hooks/auth | 89.7 | 64.67 | 92.85 | 89.58 | - index.ts | 0 | 0 | 0 | 0 | - use-auth-flow.ts | 90.74 | 63.82 | 90.47 | 90.81 | ...22-131,148,302 - ...ud-consent.ts | 100 | 100 | 100 | 100 | - ...face-token.ts | 94.2 | 68 | 100 | 92.59 | 83-86 - ...oauth-flow.ts | 75.39 | 48.97 | 80 | 75.43 | ...95,202,238,317 - ...-providers.ts | 100 | 88 | 100 | 100 | 202,266-293 - ...on-secrets.ts | 92.85 | 66.66 | 100 | 92.85 | 42,80,101,154 - hooks/data | 100 | 85.1 | 100 | 100 | - index.ts | 0 | 0 | 0 | 0 | - ...async-data.ts | 100 | 82.6 | 100 | 100 | 134-158,245-252 - ...d-mutation.ts | 100 | 100 | 100 | 100 | - ...ct-members.ts | 100 | 78.57 | 100 | 100 | 31-41 - use-project.ts | 100 | 100 | 100 | 100 | - hooks/processing | 87.52 | 71.87 | 91.13 | 87.36 | - events.ts | 90.47 | 70 | 100 | 90.47 | 37,63 - index.ts | 0 | 0 | 0 | 0 | - state.ts | 86.48 | 86.66 | 87.5 | 87.09 | 113-117,156 - use-assistant.ts | 96.29 | 90.9 | 100 | 96 | 84 - ...iarization.ts | 83.24 | 63.28 | 86.2 | 82.85 | ...06-459,485-486 - ...extraction.ts | 95.45 | 82.35 | 100 | 94.73 | 96,114 - ...processing.ts | 88.81 | 75 | 88.23 | 88.81 | ...89,227,280,366 - hooks/recording | 93.08 | 82.2 | 92.42 | 93.13 | - index.ts | 0 | 0 | 0 | 0 | - ...app-policy.ts | 84.42 | 63.63 | 86.11 | 84.21 | ...88,307,324,328 - ...ng-session.ts | 97.77 | 91.19 | 100 | 97.73 | ...88,399,421,435 - hooks/sync | 90.31 | 75.37 | 92.3 | 90.32 | - index.ts | 0 | 0 | 0 | 0 | - ...ifications.ts | 90.9 | 87.87 | 100 | 90.9 | 50,53 - ...endar-sync.ts | 95.16 | 80 | 93.75 | 94.73 | 180,191,213 - ...ation-sync.ts | 91.37 | 82.92 | 96.42 | 91.44 | ...06-336,354,358 - ...validation.ts | 100 | 73.68 | 100 | 100 | 52-71 - ...-reminders.ts | 79.09 | 60 | 81.81 | 80.55 | ...95,215,252,263 - ...ences-sync.ts | 93.87 | 72.41 | 88.23 | 93.18 | 50,79,83 - use-webhooks.ts | 96.36 | 80 | 100 | 96 | 205-210 - hooks/ui | 88.33 | 78.87 | 88 | 89.47 | - index.ts | 0 | 0 | 0 | 0 | - ...ated-words.ts | 100 | 100 | 100 | 100 | - use-mobile.tsx | 100 | 100 | 100 | 100 | - ...references.ts | 70 | 53.33 | 62.5 | 72.3 | ...24,160,164,172 - ...ing-panels.ts | 100 | 100 | 100 | 100 | - use-toast.ts | 100 | 95 | 100 | 100 | 175 - lib/ai-providers | 96.25 | 96.82 | 92.85 | 96.15 | - constants.ts | 90 | 100 | 66.66 | 88.88 | 56 - ...alog-utils.ts | 97.14 | 96.82 | 100 | 97.1 | 58,117 - ...ers/strategies | 85.71 | 76.51 | 82.92 | 87.62 | - anthropic.ts | 87.5 | 83.33 | 100 | 87.5 | 38,47 - azure.ts | 78.12 | 69.23 | 75 | 78.12 | 41,55-77,139 - custom.ts | 81.48 | 68.18 | 71.42 | 87.5 | 55,91,122 - deepgram.ts | 82.5 | 76.47 | 80 | 82.5 | 20,98,106-127 - elevenlabs.ts | 89.47 | 80 | 100 | 89.47 | 34,51 - google.ts | 89.47 | 80 | 80 | 94.44 | 47 - index.ts | 100 | 100 | 100 | 100 | - ollama.ts | 93.33 | 75 | 100 | 93.33 | 32 - openai.ts | 87.5 | 80 | 75 | 92.85 | 92-93 - types.ts | 100 | 100 | 100 | 100 | - lib/audio | 88.13 | 84.74 | 79.16 | 88.07 | - device-ids.ts | 90 | 89.74 | 91.66 | 90.74 | 103,116-120 - speaker.ts | 86.2 | 75 | 66.66 | 85.45 | ...07,111-112,119 - lib/cache | 87.97 | 59.37 | 93.54 | 87.66 | - meeting-cache.ts | 87.97 | 59.37 | 93.54 | 87.66 | ...46,157,410-412 - lib/config | 100 | 100 | 100 | 100 | - app-config.ts | 100 | 100 | 100 | 100 | - defaults.ts | 100 | 100 | 100 | 100 | - index.ts | 0 | 0 | 0 | 0 | - ...-endpoints.ts | 100 | 100 | 100 | 100 | - server.ts | 100 | 100 | 100 | 100 | - lib/constants | 100 | 100 | 100 | 100 | - timing.ts | 100 | 100 | 100 | 100 | - lib/integrations | 87.93 | 80.85 | 90 | 87.71 | - defaults.ts | 100 | 100 | 100 | 100 | - oauth.ts | 93.1 | 93.33 | 100 | 92.85 | 55,96 - utils.ts | 77.27 | 75 | 66.66 | 77.27 | 20-22,49-51,57 - lib/observability | 95.64 | 85.95 | 98 | 95.68 | - client.ts | 94.11 | 86.66 | 100 | 94.02 | 32,49,55,79 - converters.ts | 100 | 100 | 100 | 100 | - debug.ts | 91.3 | 80 | 100 | 91.3 | 31,49 - errors.ts | 100 | 80 | 100 | 100 | 29 - events.ts | 97.05 | 55.55 | 96.96 | 97.05 | 173 - ...summarizer.ts | 92.7 | 82.96 | 100 | 92.64 | ...46,183,216,237 - groups.ts | 99.02 | 91.93 | 100 | 98.97 | 209 - messages.ts | 98.88 | 95.45 | 97.97 | 98.85 | 34,254 - sanitizer.ts | 75 | 65.21 | 83.33 | 76.66 | ...39,43,63,71,79 - summarizer.ts | 100 | 100 | 100 | 100 | - lib/preferences | 91.95 | 76.48 | 93.29 | 92 | - api.ts | 90.18 | 77.77 | 88.04 | 91.02 | ...03,412,429,432 - constants.ts | 100 | 100 | 100 | 100 | - core.ts | 82.05 | 56.41 | 100 | 82.05 | ...3-74,79,84,100 - index.ts | 0 | 0 | 0 | 0 | - integrations.ts | 100 | 100 | 100 | 100 | - ...-only-keys.ts | 100 | 100 | 100 | 100 | - storage.ts | 93.06 | 80.21 | 100 | 92.92 | ...06,133,299,318 - sync.ts | 95.37 | 74.13 | 92.3 | 95.32 | 61,77,82,108,132 - tags.ts | 100 | 83.33 | 100 | 100 | 45 - tauri.ts | 89 | 77.27 | 100 | 88.88 | ...24,253-260,274 - ...ion-events.ts | 100 | 100 | 100 | 100 | - lib/state | 96.8 | 88.23 | 95.45 | 96.62 | - entities.ts | 96.8 | 88.23 | 95.45 | 96.62 | 79,132,254 - lib/storage | 88.93 | 68.88 | 96.42 | 88.83 | - crypto.ts | 90.24 | 79.31 | 95 | 90.18 | ...58,427,435,439 - keys.ts | 100 | 100 | 100 | 100 | - utils.ts | 79.54 | 50 | 100 | 79.06 | ...10,123,128,142 - lib/system | 84.5 | 68 | 93.75 | 84.5 | - events.ts | 84.5 | 68 | 93.75 | 84.5 | ...42,259,309-312 - lib/ui | 100 | 100 | 100 | 100 | - cva.ts | 0 | 0 | 0 | 0 | - styles.ts | 100 | 100 | 100 | 100 | - lib/utils | 90.71 | 75.48 | 92.75 | 90.6 | - async.ts | 93.87 | 72.72 | 88.23 | 93.87 | 111,171-172 - download.ts | 100 | 100 | 100 | 100 | - event-emitter.ts | 100 | 57.14 | 100 | 100 | 83-134,156 - format.ts | 76.25 | 61.22 | 85.71 | 76.25 | ...18-119,176-179 - id.ts | 100 | 100 | 100 | 100 | - index.ts | 100 | 100 | 100 | 100 | - object.ts | 100 | 93.75 | 100 | 100 | 37 - polling.ts | 91.74 | 85.71 | 94.11 | 91.74 | ...52-253,269,322 - time.ts | 100 | 100 | 100 | 100 | - pages | 98.26 | 87.82 | 99 | 98.54 | - Analytics.tsx | 100 | 92.85 | 100 | 100 | 42 - Home.tsx | 100 | 100 | 100 | 100 | - ...ingDetail.tsx | 0 | 0 | 0 | 0 | - Meetings.tsx | 93.54 | 73.8 | 100 | 93.1 | 56-59 - NotFound.tsx | 100 | 100 | 100 | 100 | - Projects.tsx | 100 | 100 | 100 | 100 | - Recording.tsx | 99 | 97.22 | 95.45 | 100 | 160,255 - Tasks.tsx | 100 | 84.61 | 100 | 100 | ...57,395,403,432 - ...meeting-detail | 96.79 | 87.97 | 98.33 | 97.23 | - ask-panel.tsx | 96.87 | 93.75 | 100 | 96.66 | 45 - constants.ts | 100 | 100 | 100 | 100 | - ...ies-panel.tsx | 100 | 83.33 | 100 | 100 | 34-50 - header.tsx | 100 | 100 | 100 | 100 | - index.tsx | 98.18 | 90.32 | 100 | 98.11 | 85 - ...ary-panel.tsx | 100 | 91.66 | 100 | 100 | 44 - ...cript-row.tsx | 100 | 100 | 100 | 100 | - ...ing-detail.ts | 93.84 | 76.59 | 92.3 | 95.23 | 112,169,183 --------------------|---------|----------|---------|---------|------------------- -✓ TypeScript quality checks passed -info: component 'rustfmt' for target 'x86_64-unknown-linux-gnu' is up to date -info: component 'clippy' for target 'x86_64-unknown-linux-gnu' is up to date -=== Clippy === -cd client/src-tauri && cargo clippy --message-format=json -- -D warnings > /home/trav/repos/noteflow/.hygeine/clippy.json 2>&1 -=== Rust Code Quality === -./client/src-tauri/scripts/code_quality.sh --output /home/trav/repos/noteflow/.hygeine/rust_code_quality.txt -=== Rust/Tauri Code Quality Checks === + FAIL src/hooks/audio/use-asr-config.test.ts > useAsrConfig > updateConfig > updates config when job completes with new configuration +Error: Test timed out in 5000ms. +If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". + ❯ src/hooks/audio/use-asr-config.test.ts:177:5 + 175| }); + 176| + 177| it('updates config when job completes with new configuration', asy… + | ^ + 178| vi.useFakeTimers(); + 179| mockAPI.getAsrConfiguration.mockResolvedValue(mockConfig); -Checking for magic numbers... -OK: No obvious magic numbers found +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/11]⎯ -Checking for repeated string literals... -OK: No excessively repeated strings found + FAIL src/hooks/audio/use-asr-config.test.ts > useAsrConfig > updateConfig > refreshes config when job completes without new configuration +Error: Test timed out in 5000ms. +If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". + ❯ src/hooks/audio/use-asr-config.test.ts:217:5 + 215| }); + 216| + 217| it('refreshes config when job completes without new configuration'… + | ^ + 218| vi.useFakeTimers(); + 219| const updatedConfig = { ...mockConfig, modelSize: 'small' as con… -Checking for TODO/FIXME comments... -OK: No TODO/FIXME comments found +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/11]⎯ -Checking for unused imports and dead code (clippy)... -OK: No unused imports or dead code detected + FAIL src/hooks/audio/use-asr-config.test.ts > useAsrConfig > updateConfig > stops reconfiguring and reports failure status +Error: Test timed out in 5000ms. +If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". + ❯ src/hooks/audio/use-asr-config.test.ts:260:5 + 258| }); + 259| + 260| it('stops reconfiguring and reports failure status', async () => { + | ^ + 261| vi.useFakeTimers(); + 262| mockAPI.getAsrConfiguration.mockResolvedValue(mockConfig); -Checking for long functions... -OK: No excessively long functions found +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/11]⎯ -Checking for deep nesting... -OK: No excessively deep nesting found + FAIL src/hooks/audio/use-asr-config.test.ts > useAsrConfig > updateConfig > stops reconfiguring and reports cancelled status +Error: Test timed out in 5000ms. +If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". + ❯ src/hooks/audio/use-asr-config.test.ts:299:5 + 297| }); + 298| + 299| it('stops reconfiguring and reports cancelled status', async () =>… + | ^ + 300| vi.useFakeTimers(); + 301| mockAPI.getAsrConfiguration.mockResolvedValue(mockConfig); -Checking for unwrap() usage... -OK: No unwrap() calls found +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/11]⎯ -Checking for excessive clone() usage... -OK: No excessive clone() usage detected + FAIL src/hooks/audio/use-asr-config.test.ts > useAsrConfig > cancelPolling > can be called without error +TypeError: Cannot read properties of null (reading 'isLoading') -Checking for functions with long parameter lists... -OK: No functions with excessive parameters found +Ignored nodes: comments, script, style + + + +
+ + + ❯ src/hooks/audio/use-asr-config.test.ts:346:31 + 344| + 345| await waitFor(() => { + 346| expect(result.current.isLoading).toBe(false); + | ^ + 347| }); + 348| + ❯ runWithExpensiveErrorDiagnosticsDisabled node_modules/@testing-library/dom/dist/config.js:47:12 + ❯ checkCallback node_modules/@testing-library/dom/dist/wait-for.js:124:77 + ❯ Timeout.checkRealTimersCallback node_modules/@testing-library/dom/dist/wait-for.js:118:16 -Checking for duplicated error messages... -OK: No duplicated error messages found +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/11]⎯ -Checking module file sizes... -OK: All files within size limits + FAIL src/api/adapters/mock/stream.test.ts > MockTranscriptionStream > emits VAD and transcript updates + FAIL src/api/adapters/mock/stream.test.ts > MockTranscriptionStream > stops emitting after close + FAIL src/api/adapters/mock/stream.test.ts > MockTranscriptionStream > emits vad_end when activity stops + FAIL src/api/adapters/mock/stream.test.ts > MockTranscriptionStream > does not emit when no callback registered + FAIL src/api/adapters/mock/stream.test.ts > MockTranscriptionStream > accepts audio chunks without throwing +Error: Hook timed out in 10000ms. +If this is a long-running hook, pass a timeout value as the last argument or configure it globally with "hookTimeout". + ❯ src/test/setup.ts:37:1 + 35| + 36| // Clean up client logs after each test to prevent timer-related errors + 37| afterEach(async () => { + | ^ + 38| await act(async () => { + 39| await Promise.resolve(); -Checking for scattered helper functions... -OK: Helper functions reasonably centralized (9 files) +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/11]⎯ -=== Summary === -Errors: 0 -Warnings: 0 -Code quality checks passed! -✓ Rust quality checks passed -=== Basedpyright (Python Lint) === -make: *** [Makefile:151: lint-py] Error 1 -✗ Quality checks failed -Fix the issues above or use 'git commit --no-verify' to bypass + FAIL src/api/adapters/tauri/sections/meetings.test.ts > createMeetingApi > starts transcription and returns a stream +TypeError: __vite_ssr_import_5__.TauriTranscriptionStream is not a constructor + ❯ Object.startTranscription src/api/adapters/tauri/sections/meetings.ts:120:16 + 118| transcription_api_key: transcriptionKey, + 119| }); + 120| return new TauriTranscriptionStream(meetingId, invoke, listen); + | ^ + 121| } catch (error) { + 122| const details = extractErrorDetails(error, 'Failed to start re… + ❯ src/api/adapters/tauri/sections/meetings.test.ts:204:20 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/11]⎯ + + + Test Files 3 failed | 172 passed (175) + Tests 11 failed | 1528 passed | 2 skipped (1541) + Start at 09:52:08 + Duration 53.14s (transform 14.08s, setup 85.42s, import 25.26s, tests 109.97s, environment 78.92s) + +make: *** [Makefile:106: coverage-ts] Error 1 +✗ Quality checks failed +Fix the issues above or use 'git commit --no-verify' to bypass diff --git a/.cupcake/debug/2026-01-24_04-19-44_019bee3a-b7a8-7a51-92ff-33bf5941daaf.txt b/.cupcake/debug/2026-01-24_04-19-44_019bee3a-b7a8-7a51-92ff-33bf5941daaf.txt new file mode 100644 index 0000000..99b4fd6 --- /dev/null +++ b/.cupcake/debug/2026-01-24_04-19-44_019bee3a-b7a8-7a51-92ff-33bf5941daaf.txt @@ -0,0 +1,108 @@ +===== Claude Code Event [2026-01-24 04:19:44] [019bee3a-b7a8-7a51-92ff-33bf5941daaf] ===== +Event Type: PreToolUse +Tool: Edit +Session ID: test + +----- Raw Event ----- +{ + "args": { + "filePath": "/home/trav/repos/noteflow/test_any_import.py", + "newString": "new", + "oldString": "old" + }, + "cwd": "/home/trav/repos/noteflow/workspaces/mission-d09e1117", + "hook_event_name": "PreToolUse", + "is_symlink": false, + "original_file_path": "/home/trav/repos/noteflow/test_any_import.py", + "resolved_file_path": "/home/trav/repos/noteflow/test_any_import.py", + "session_id": "test", + "tool": "edit", + "tool_input": { + "filePath": "/home/trav/repos/noteflow/test_any_import.py", + "newString": "new", + "oldString": "old" + }, + "tool_name": "Edit" +} + +----- Enriched Input (passed to WASM) ----- +{ + "args": { + "filePath": "/home/trav/repos/noteflow/test_any_import.py", + "newString": "new", + "oldString": "old" + }, + "builtin_config": { + "git_block_no_verify": { + "exceptions": [], + "message": "Git operations with --no-verify are not permitted" + }, + "rulebook_security_guardrails": { + "message": "Cupcake configuration files are protected from modification", + "protected_paths": [ + ".cupcake/", + ".git/hooks/" + ] + } + }, + "cwd": "/home/trav/repos/noteflow/workspaces/mission-d09e1117", + "hook_event_name": "PreToolUse", + "is_symlink": false, + "original_file_path": "/home/trav/repos/noteflow/test_any_import.py", + "resolved_file_path": "/home/trav/repos/noteflow/test_any_import.py", + "session_id": "test", + "tool": "edit", + "tool_input": { + "filePath": "/home/trav/repos/noteflow/test_any_import.py", + "newString": "new", + "oldString": "old" + }, + "tool_name": "Edit" +} + +----- Routing ----- +Matched: Yes (20 policies) +- cupcake.policies.opencode.prevent_type_suppression +- cupcake.policies.opencode.block_tests_quality +- cupcake.policies.opencode.block_duplicate_fixtures +- cupcake.policies.opencode.warn_baselines_edit +- cupcake.policies.opencode.block_broad_exception_handler +- cupcake.policies.opencode.block_code_quality_test_edits +- cupcake.policies.opencode.block_test_loops_conditionals +- cupcake.policies.opencode.block_linter_config_frontend +- cupcake.policies.opencode.prevent_any_type +- cupcake.policies.opencode.block_magic_numbers +- cupcake.policies.opencode.warn_large_file +- cupcake.policies.opencode.block_makefile_edit +- cupcake.policies.opencode.block_default_value_swallow +- cupcake.policies.opencode.block_silent_none_return +- cupcake.policies.opencode.block_datetime_now_fallback +- cupcake.policies.opencode.block_linter_config_python +- cupcake.policies.opencode.block_biome_ignore +- cupcake.policies.opencode.ban_stdlib_logger +- cupcake.policies.builtins.rulebook_security_guardrails +- cupcake.policies.opencode.block_assertion_roulette + +----- Signals ----- +Configured: None + +----- WASM Evaluation ----- +Decision Set: + Halts: 0 + Denials: 0 + Blocks: 0 + Asks: 0 + Context: 0 + +----- Synthesis ----- +Final Decision: Allow + +----- Response to Claude ----- +{ + "decision": "allow" +} + +----- Actions ----- +Configured: None + +===== End Event [04:19:44.168] Duration: 6ms ===== diff --git a/.cupcake/opencode.json b/.cupcake/opencode.json new file mode 100644 index 0000000..0ca5d92 --- /dev/null +++ b/.cupcake/opencode.json @@ -0,0 +1,5 @@ +{ + "enabled": true, + "logLevel": "warn", + "showToasts": false +} diff --git a/.cupcake/policies/opencode/ban_stdlib_logger.rego b/.cupcake/policies/opencode/ban_stdlib_logger.rego index 1eac212..b525fa1 100644 --- a/.cupcake/policies/opencode/ban_stdlib_logger.rego +++ b/.cupcake/policies/opencode/ban_stdlib_logger.rego @@ -29,6 +29,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -39,6 +41,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -72,6 +76,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -86,6 +93,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/block_assertion_roulette.rego b/.cupcake/policies/opencode/block_assertion_roulette.rego index 57671b8..94eca3b 100644 --- a/.cupcake/policies/opencode/block_assertion_roulette.rego +++ b/.cupcake/policies/opencode/block_assertion_roulette.rego @@ -28,6 +28,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -38,6 +40,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -71,6 +75,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -85,6 +92,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/block_biome_ignore.rego b/.cupcake/policies/opencode/block_biome_ignore.rego index 7245f9a..7297250 100644 --- a/.cupcake/policies/opencode/block_biome_ignore.rego +++ b/.cupcake/policies/opencode/block_biome_ignore.rego @@ -28,6 +28,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -38,6 +40,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -71,6 +75,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -85,6 +92,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/block_broad_exception_handler.rego b/.cupcake/policies/opencode/block_broad_exception_handler.rego index 2952581..350584b 100644 --- a/.cupcake/policies/opencode/block_broad_exception_handler.rego +++ b/.cupcake/policies/opencode/block_broad_exception_handler.rego @@ -28,6 +28,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -38,6 +40,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -71,6 +75,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -85,6 +92,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/block_code_quality_test_edits.rego b/.cupcake/policies/opencode/block_code_quality_test_edits.rego index 43a799a..3d874a6 100644 --- a/.cupcake/policies/opencode/block_code_quality_test_edits.rego +++ b/.cupcake/policies/opencode/block_code_quality_test_edits.rego @@ -28,6 +28,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -38,6 +40,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -71,6 +75,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -85,6 +92,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/block_code_quality_test_serena.rego b/.cupcake/policies/opencode/block_code_quality_test_serena.rego index 2e02da9..3ad3c6f 100644 --- a/.cupcake/policies/opencode/block_code_quality_test_serena.rego +++ b/.cupcake/policies/opencode/block_code_quality_test_serena.rego @@ -34,6 +34,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -44,6 +46,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -77,6 +81,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -91,6 +98,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/block_code_quality_test_serena_plugin.rego b/.cupcake/policies/opencode/block_code_quality_test_serena_plugin.rego index ae646ff..8dea437 100644 --- a/.cupcake/policies/opencode/block_code_quality_test_serena_plugin.rego +++ b/.cupcake/policies/opencode/block_code_quality_test_serena_plugin.rego @@ -34,6 +34,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -44,6 +46,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -77,6 +81,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -91,6 +98,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/block_datetime_now_fallback.rego b/.cupcake/policies/opencode/block_datetime_now_fallback.rego index 69cac45..470198f 100644 --- a/.cupcake/policies/opencode/block_datetime_now_fallback.rego +++ b/.cupcake/policies/opencode/block_datetime_now_fallback.rego @@ -28,6 +28,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -38,6 +40,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -71,6 +75,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -85,6 +92,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/block_default_value_swallow.rego b/.cupcake/policies/opencode/block_default_value_swallow.rego index 8d9ab04..ceb480f 100644 --- a/.cupcake/policies/opencode/block_default_value_swallow.rego +++ b/.cupcake/policies/opencode/block_default_value_swallow.rego @@ -28,6 +28,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -38,6 +40,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -71,6 +75,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -85,6 +92,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/block_duplicate_fixtures.rego b/.cupcake/policies/opencode/block_duplicate_fixtures.rego index 285cc67..97e3138 100644 --- a/.cupcake/policies/opencode/block_duplicate_fixtures.rego +++ b/.cupcake/policies/opencode/block_duplicate_fixtures.rego @@ -28,6 +28,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -38,6 +40,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -71,6 +75,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -85,6 +92,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/block_linter_config_frontend.rego b/.cupcake/policies/opencode/block_linter_config_frontend.rego index d13d178..bc6f2d4 100644 --- a/.cupcake/policies/opencode/block_linter_config_frontend.rego +++ b/.cupcake/policies/opencode/block_linter_config_frontend.rego @@ -28,6 +28,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -38,6 +40,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -71,6 +75,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -85,6 +92,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/block_linter_config_python.rego b/.cupcake/policies/opencode/block_linter_config_python.rego index 2587495..3f639b9 100644 --- a/.cupcake/policies/opencode/block_linter_config_python.rego +++ b/.cupcake/policies/opencode/block_linter_config_python.rego @@ -28,6 +28,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -38,6 +40,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -71,6 +75,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -85,6 +92,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/block_magic_numbers.rego b/.cupcake/policies/opencode/block_magic_numbers.rego index 107defc..de9d199 100644 --- a/.cupcake/policies/opencode/block_magic_numbers.rego +++ b/.cupcake/policies/opencode/block_magic_numbers.rego @@ -28,6 +28,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -38,6 +40,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -71,6 +75,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -85,6 +92,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/block_makefile_edit.rego b/.cupcake/policies/opencode/block_makefile_edit.rego index 1bddece..f7664d5 100644 --- a/.cupcake/policies/opencode/block_makefile_edit.rego +++ b/.cupcake/policies/opencode/block_makefile_edit.rego @@ -28,6 +28,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -38,6 +40,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -71,6 +75,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -85,6 +92,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/block_silent_none_return.rego b/.cupcake/policies/opencode/block_silent_none_return.rego index 3a9d762..987d647 100644 --- a/.cupcake/policies/opencode/block_silent_none_return.rego +++ b/.cupcake/policies/opencode/block_silent_none_return.rego @@ -28,6 +28,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -38,6 +40,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -71,6 +75,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -85,6 +92,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/block_test_loops_conditionals.rego b/.cupcake/policies/opencode/block_test_loops_conditionals.rego index 3c806d0..ca743cf 100644 --- a/.cupcake/policies/opencode/block_test_loops_conditionals.rego +++ b/.cupcake/policies/opencode/block_test_loops_conditionals.rego @@ -28,6 +28,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -38,6 +40,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -71,6 +75,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -85,6 +92,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/block_tests_quality.rego b/.cupcake/policies/opencode/block_tests_quality.rego index cb2ffd0..216ae10 100644 --- a/.cupcake/policies/opencode/block_tests_quality.rego +++ b/.cupcake/policies/opencode/block_tests_quality.rego @@ -28,6 +28,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -38,6 +40,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -71,6 +75,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -85,6 +92,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/prevent_any_type.rego b/.cupcake/policies/opencode/prevent_any_type.rego index 21713f1..b9cc4a4 100644 --- a/.cupcake/policies/opencode/prevent_any_type.rego +++ b/.cupcake/policies/opencode/prevent_any_type.rego @@ -29,6 +29,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -39,6 +41,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -72,6 +76,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -86,6 +93,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/prevent_type_suppression.rego b/.cupcake/policies/opencode/prevent_type_suppression.rego index 0511f96..14018fa 100644 --- a/.cupcake/policies/opencode/prevent_type_suppression.rego +++ b/.cupcake/policies/opencode/prevent_type_suppression.rego @@ -29,6 +29,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -39,6 +41,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -72,6 +76,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -86,6 +93,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText @@ -102,17 +112,33 @@ is_python_file(path) if { endswith(path, ".pyi") } +is_python_patch(patch_text) if { + contains(patch_text, ".py") +} + +is_python_patch(patch_text) if { + contains(patch_text, ".pyi") +} + # Regex patterns indicating type suppression directives type_suppression_patterns := [ - `#\s*type:\s*ignore(\[[^\]]+\])?\b`, - `#\s*pyright:\s*ignore(\[[^\]]+\])?\b`, - `#\s*mypy:\s*ignore(\[[^\]]+\])?\b`, - `#\s*pyre-ignore\b`, - `#\s*pyre-fixme\b`, - `#\s*pyrefly:\s*ignore(\[[^\]]+\])?\b`, - `#\s*basedpyright:\s*ignore(\[[^\]]+\])?\b`, - `#\s*noqa\b`, - `#\s*noqa:\s*\w+`, + `(?i)#\s*type:\s*ignore(\[[^\]]+\])?\b`, + `(?i)#\s*pyright:\s*ignore(\[[^\]]+\])?\b`, + `(?i)#\s*pyright:\s*report\w+\s*=\s*false\b`, + `(?i)#\s*mypy:\s*ignore(\[[^\]]+\])?\b`, + `(?i)#\s*mypy:\s*ignore-errors\b`, + `(?i)#\s*mypy:\s*disable-error-code\s*=\s*[\w,\\s-]+\b`, + `(?i)#\s*pyre-ignore\b`, + `(?i)#\s*pyre-fixme\b`, + `(?i)#\s*pyrefly:\s*ignore(\[[^\]]+\])?\b`, + `(?i)#\s*basedpyright:\s*ignore(\[[^\]]+\])?\b`, + `(?i)#\s*noqa\b`, + `(?i)#\s*noqa:\s*[\w\\s,]+`, + `(?i)#\s*ruff:\s*noqa\b`, + `(?i)#\s*ruff:\s*noqa:\s*[\w\\s,]+`, + `(?i)#\s*flake8:\s*noqa\b`, + `(?i)#\s*pylint:\s*disable\s*=\s*.+`, + `(?i)#\s*pylint:\s*skip-file\b`, ] # Block Write/Edit operations that introduce type suppression in Python files @@ -124,7 +150,7 @@ deny contains decision if { file_path := lower(resolved_file_path) is_python_file(file_path) - content := new_content + content := lower(new_content) content != null some pattern in type_suppression_patterns @@ -141,8 +167,9 @@ deny contains decision if { input.hook_event_name == "PreToolUse" tool_name in {"Patch", "ApplyPatch"} - content := patch_content + content := lower(patch_content) content != null + is_python_patch(content) some pattern in type_suppression_patterns regex.match(pattern, content) @@ -162,7 +189,7 @@ deny contains decision if { file_path := lower(edit_path(edit)) is_python_file(file_path) - content := edit_new_content(edit) + content := lower(edit_new_content(edit)) content != null some pattern in type_suppression_patterns diff --git a/.cupcake/policies/opencode/warn_baselines_edit.rego b/.cupcake/policies/opencode/warn_baselines_edit.rego index 591ee69..43b2449 100644 --- a/.cupcake/policies/opencode/warn_baselines_edit.rego +++ b/.cupcake/policies/opencode/warn_baselines_edit.rego @@ -28,6 +28,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -38,6 +40,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -71,6 +75,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -85,6 +92,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.cupcake/policies/opencode/warn_large_file.rego b/.cupcake/policies/opencode/warn_large_file.rego index 7145497..d28d0c6 100644 --- a/.cupcake/policies/opencode/warn_large_file.rego +++ b/.cupcake/policies/opencode/warn_large_file.rego @@ -28,6 +28,8 @@ resolved_file_path := input.resolved_file_path if { new_content := tool_input.new_string if { tool_input.new_string != null +} else := tool_input.newString if { + tool_input.newString != null } else := tool_input.newText if { tool_input.newText != null } else := tool_input.new_text if { @@ -38,6 +40,8 @@ new_content := tool_input.new_string if { old_content := tool_input.old_string if { tool_input.old_string != null +} else := tool_input.oldString if { + tool_input.oldString != null } else := tool_input.oldText if { tool_input.oldText != null } else := tool_input.old_text if { @@ -71,6 +75,9 @@ edit_path(edit) := path if { edit_new_content(edit) := content if { edit.new_string != null content := edit.new_string +} else := content if { + edit.newString != null + content := edit.newString } else := content if { edit.newText != null content := edit.newText @@ -85,6 +92,9 @@ edit_new_content(edit) := content if { edit_old_content(edit) := content if { edit.old_string != null content := edit.old_string +} else := content if { + edit.oldString != null + content := edit.oldString } else := content if { edit.oldText != null content := edit.oldText diff --git a/.opencode/plugin/cupcake.js b/.opencode/plugin/cupcake.js index b9ae1e8..10e200d 100644 --- a/.opencode/plugin/cupcake.js +++ b/.opencode/plugin/cupcake.js @@ -87,10 +87,15 @@ async function executeCupcake(config, event) { console.error(`[cupcake] DEBUG: Executing cupcake`); console.error(`[cupcake] DEBUG: Event:`, eventJson); } - const proc = Bun.spawn([config.cupcakePath, "eval", "--harness", config.harness], { + const args = [config.cupcakePath, "eval", "--harness", config.harness]; + if (config.logLevel === "debug") { + args.push("--debug-files"); + } + const proc = Bun.spawn(args, { stdin: "pipe", stdout: "pipe", - stderr: "ignore" + stderr: "pipe", + cwd: event.cwd || void 0 }); proc.stdin.write(eventJson); proc.stdin.end(); @@ -105,11 +110,14 @@ async function executeCupcake(config, event) { }, config.timeoutMs); }); try { - const [stdout, exitCode] = await Promise.race([ - Promise.all([new Response(proc.stdout).text(), proc.exited]), + const [stdout, stderr, exitCode] = await Promise.race([ + Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]), timeoutPromise ]); const elapsed = Date.now() - startTime; + if (config.logLevel === "debug" && stderr) { + console.error(`[cupcake] DEBUG: stderr:\n${stderr}`); + } if (config.logLevel === "debug") { console.error(`[cupcake] DEBUG: Cupcake response (${elapsed}ms):`, stdout); } diff --git a/.opencode/plugins/cupcake.js b/.opencode/plugins/cupcake.js new file mode 100644 index 0000000..10e200d --- /dev/null +++ b/.opencode/plugins/cupcake.js @@ -0,0 +1,388 @@ +/** + * Cupcake OpenCode Plugin + * + * Install: Copy this file to .opencode/plugin/cupcake.js + * + * This plugin integrates Cupcake policy enforcement with OpenCode. + * It intercepts tool executions and evaluates them against your policies. + */ + +// src/types.ts +var DEFAULT_CONFIG = { + enabled: true, + cupcakePath: "cupcake", + harness: "opencode", + logLevel: "warn", + // Default to warn - info/debug are noisy in TUI + timeoutMs: 5e3, + failMode: "closed", + cacheDecisions: false, + showToasts: true, + toastDurationMs: 5e3 +}; +function getToastVariant(decision) { + switch (decision) { + case "allow": + return "success"; + case "ask": + return "warning"; + case "deny": + case "block": + return "error"; + default: + return "info"; + } +} + +// src/event-builder.ts +function normalizeTool(tool) { + return tool; +} +function buildPreToolUseEvent(sessionId, cwd, tool, args, agent, messageId, callId) { + const event = { + hook_event_name: "PreToolUse", + session_id: sessionId, + cwd, + tool: normalizeTool(tool), + args + }; + if (agent) { + event.agent = agent; + } + if (messageId) { + event.message_id = messageId; + } + if (callId) { + event.call_id = callId; + } + return event; +} +function buildPermissionEvent(sessionId, cwd, permissionId, permissionType, title, metadata, pattern, messageId, callId) { + const event = { + hook_event_name: "PermissionRequest", + session_id: sessionId, + cwd, + permission_id: permissionId, + permission_type: permissionType, + title, + metadata + }; + if (pattern) { + event.pattern = pattern; + } + if (messageId) { + event.message_id = messageId; + } + if (callId) { + event.call_id = callId; + } + return event; +} + +// src/executor.ts +async function executeCupcake(config, event) { + const startTime = Date.now(); + const eventJson = JSON.stringify(event); + if (config.logLevel === "debug") { + console.error(`[cupcake] DEBUG: Executing cupcake`); + console.error(`[cupcake] DEBUG: Event:`, eventJson); + } + const args = [config.cupcakePath, "eval", "--harness", config.harness]; + if (config.logLevel === "debug") { + args.push("--debug-files"); + } + const proc = Bun.spawn(args, { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + cwd: event.cwd || void 0 + }); + proc.stdin.write(eventJson); + proc.stdin.end(); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + proc.kill(); + reject( + new Error( + `Policy evaluation timed out after ${config.timeoutMs}ms. Consider optimizing policies or increasing timeout.` + ) + ); + }, config.timeoutMs); + }); + try { + const [stdout, stderr, exitCode] = await Promise.race([ + Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]), + timeoutPromise + ]); + const elapsed = Date.now() - startTime; + if (config.logLevel === "debug" && stderr) { + console.error(`[cupcake] DEBUG: stderr:\n${stderr}`); + } + if (config.logLevel === "debug") { + console.error(`[cupcake] DEBUG: Cupcake response (${elapsed}ms):`, stdout); + } + if (exitCode !== 0) { + const error = new Error(`Cupcake exited with code ${exitCode}`); + if (config.failMode === "open") { + console.error(`[cupcake] ERROR: ${error.message}`); + console.error(`[cupcake] WARN: Allowing operation in fail-open mode.`); + return { decision: "allow" }; + } + throw error; + } + const response = JSON.parse(stdout); + if (config.logLevel === "debug") { + console.error(`[cupcake] DEBUG: Decision: ${response.decision} (${elapsed}ms)`); + } + return response; + } catch (error) { + if (config.failMode === "open") { + console.error(`[cupcake] ERROR: ${error.message}`); + console.error(`[cupcake] WARN: Allowing operation in fail-open mode.`); + return { decision: "allow" }; + } + throw error; + } +} + +// src/enforcer.ts +function formatDecision(response) { + const { decision, reason, rule_id, severity } = response; + let title; + let message; + let blocked = false; + switch (decision) { + case "allow": + title = "Allowed"; + message = reason || "Operation allowed by policy"; + break; + case "deny": + case "block": + title = "Policy Violation"; + message = reason || `Operation blocked by policy`; + blocked = true; + break; + case "ask": + title = "Approval Required"; + message = reason || "This operation requires approval"; + blocked = true; + break; + default: + title = "Unknown Decision"; + message = `Policy returned unknown decision: ${decision}`; + blocked = true; + } + if (rule_id || severity) { + const details = []; + if (rule_id) details.push(`Rule: ${rule_id}`); + if (severity) details.push(`Severity: ${severity}`); + message += ` +(${details.join(", ")})`; + } + return { + blocked, + title, + message, + variant: getToastVariant(decision), + decision, + ruleId: rule_id, + severity + }; +} +function formatErrorMessage(formatted) { + let message = ""; + if (formatted.decision === "deny" || formatted.decision === "block") { + message += "\u274C Policy Violation\n\n"; + } else if (formatted.decision === "ask") { + message += "\u26A0\uFE0F Approval Required\n\n"; + } + message += formatted.message; + if (formatted.decision === "ask") { + message += "\n\nNote: This operation requires manual approval. "; + message += "To proceed, review the policy and temporarily disable it if appropriate, "; + message += "then re-run the command."; + } + return message; +} + +// src/index.ts +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +function loadConfig(directory) { + const configPath = join(directory, ".cupcake", "opencode.json"); + if (existsSync(configPath)) { + try { + const configData = readFileSync(configPath, "utf-8"); + const userConfig = JSON.parse(configData); + return { ...DEFAULT_CONFIG, ...userConfig }; + } catch (error) { + console.error(`[cupcake] WARN: Failed to load config from ${configPath}: ${error.message}`); + console.error(`[cupcake] WARN: Using default configuration`); + } + } + return DEFAULT_CONFIG; +} +async function showToast(client, config, title, message, variant) { + if (!config.showToasts || !client) { + return; + } + try { + await client.tui.showToast({ + body: { + title, + message, + variant, + duration: config.toastDurationMs + } + }); + } catch (error) { + if (config.logLevel === "debug") { + console.error(`[cupcake] DEBUG: Failed to show toast: ${error.message}`); + } + } +} +function log(config, level, message, ...args) { + const levels = ["debug", "info", "warn", "error"]; + const configLevel = levels.indexOf(config.logLevel); + const messageLevel = levels.indexOf(level); + if (messageLevel >= configLevel) { + const prefix = `[cupcake] ${level.toUpperCase()}:`; + if (args.length > 0) { + console.error(prefix, message, ...args); + } else { + console.error(prefix, message); + } + } +} +var CupcakePlugin = async ({ directory, client }) => { + const config = loadConfig(directory); + if (!config.enabled) { + log(config, "debug", "Plugin is disabled in configuration"); + return {}; + } + log(config, "debug", "Cupcake plugin initialized"); + return { + /** + * Hook: tool.execute.before + * + * Fired before any tool execution. This is where we enforce policies. + * Throwing an error blocks the tool execution. + */ + "tool.execute.before": async (input, output) => { + try { + log(config, "debug", `tool.execute.before fired for ${input.tool}`); + log(config, "debug", "Args:", output.args); + const event = buildPreToolUseEvent( + input.sessionID || "unknown", + directory, + input.tool, + output.args, + void 0, + // agent - not provided in current hook + void 0, + // messageId - not provided in current hook + input.callID + ); + const response = await executeCupcake(config, event); + const formatted = formatDecision(response); + if (formatted.decision !== "allow") { + await showToast(client, config, formatted.title, formatted.message, formatted.variant); + } + if (formatted.blocked) { + throw new Error(formatErrorMessage(formatted)); + } + log(config, "debug", "Allowing tool execution"); + } catch (error) { + throw error; + } + }, + /** + * Hook: permission.ask + * + * Fired when OpenCode needs to request permission for an operation. + * This integrates with OpenCode's native permission UI. + * + * - Set output.status = "allow" to auto-approve + * - Set output.status = "deny" to auto-deny + * - Leave as "ask" to show native permission dialog + */ + "permission.ask": async (input, output) => { + try { + log(config, "debug", `permission.ask fired for ${input.type}`); + log(config, "debug", "Permission:", input); + const event = buildPermissionEvent( + input.sessionID, + directory, + input.id, + input.type, + input.title, + input.metadata, + input.pattern, + input.messageID, + input.callID + ); + const response = await executeCupcake(config, event); + switch (response.decision) { + case "allow": + output.status = "allow"; + log(config, "debug", `Auto-allowing permission: ${input.type}`); + break; + case "deny": + case "block": + output.status = "deny"; + log(config, "debug", `Auto-denying permission: ${input.type}`); + await showToast( + client, + config, + "Permission Denied", + response.reason || `Permission ${input.type} blocked by policy`, + "error" + ); + break; + case "ask": + default: + output.status = "ask"; + log(config, "debug", `Deferring permission to user: ${input.type}`); + if (response.reason) { + await showToast(client, config, "Approval Recommended", response.reason, "warning"); + } + break; + } + } catch (error) { + log(config, "error", `Permission evaluation failed: ${error.message}`); + output.status = "ask"; + } + }, + /** + * Hook: tool.execute.after + * + * Fired after tool execution. Used for audit logging. + * Cannot prevent execution (already happened). + */ + "tool.execute.after": async (input, output) => { + log(config, "debug", `tool.execute.after fired for ${input.tool}`); + log(config, "debug", "Output:", output.output?.substring(0, 200)); + }, + /** + * Hook: event + * + * Fired for all OpenCode events. Used for comprehensive audit logging. + */ + event: async ({ event }) => { + if (config.logLevel !== "debug") { + return; + } + const auditEvents = [ + "tool.executed", + "permission.replied", + "file.edited", + "session.created", + "session.aborted" + ]; + if (auditEvents.includes(event.type)) { + log(config, "debug", `Audit event: ${event.type}`, event.properties); + } + } + }; +}; +export { CupcakePlugin }; diff --git a/client/e2e/analytics.spec.ts b/client/e2e/analytics.spec.ts index 9c912cd..808e828 100644 --- a/client/e2e/analytics.spec.ts +++ b/client/e2e/analytics.spec.ts @@ -32,6 +32,9 @@ interface AnalyticsOverview { total_words: number; total_segments: number; speaker_count: number; + user_speaking_time: number; + attendee_speaking_time: number; + unknown_speaking_time: number; } interface SpeakerStat { @@ -85,6 +88,9 @@ test.describe('analytics api integration', () => { expect(result).toHaveProperty('total_words'); expect(result).toHaveProperty('total_segments'); expect(result).toHaveProperty('speaker_count'); + expect(result).toHaveProperty('user_speaking_time'); + expect(result).toHaveProperty('attendee_speaking_time'); + expect(result).toHaveProperty('unknown_speaking_time'); expect(Array.isArray(result.daily)).toBe(true); expect(typeof result.total_meetings).toBe('number'); @@ -92,6 +98,9 @@ test.describe('analytics api integration', () => { expect(typeof result.total_words).toBe('number'); expect(typeof result.total_segments).toBe('number'); expect(typeof result.speaker_count).toBe('number'); + expect(typeof result.user_speaking_time).toBe('number'); + expect(typeof result.attendee_speaking_time).toBe('number'); + expect(typeof result.unknown_speaking_time).toBe('number'); }); test('getAnalyticsOverview daily stats have valid structure', async ({ page }) => { diff --git a/client/e2e/cloud-consent.spec.ts b/client/e2e/cloud-consent.spec.ts new file mode 100644 index 0000000..1721b94 --- /dev/null +++ b/client/e2e/cloud-consent.spec.ts @@ -0,0 +1,378 @@ +import { expect, test } from '@playwright/test'; +import { + callAPI, + E2E_TIMEOUTS, + navigateTo, + waitForAPI, + waitForLoadingComplete, + waitForToast, +} from './fixtures'; + +const shouldRun = process.env.NOTEFLOW_E2E === '1'; + +interface CloudConsentStatus { + transcriptionConsent: boolean; + summaryConsent: boolean; + embeddingConsent: boolean; +} + +test.describe('Cloud Consent UI Toggles', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable end-to-end tests.'); + + test.beforeEach(async ({ page }) => { + await navigateTo(page, '/settings?tab=ai'); + await waitForLoadingComplete(page); + await waitForAPI(page); + }); + + test('displays cloud toggle switches in each provider config card header', async ({ page }) => { + const aiConfigCard = page.locator('text=AI Configuration').first(); + await expect(aiConfigCard).toBeVisible(); + + const transcriptionToggle = page + .locator('[aria-label="Cloud transcription toggle"]') + .or(page.locator('[aria-label="Enable cloud transcription"]')); + const summaryToggle = page + .locator('[aria-label="Cloud summary toggle"]') + .or(page.locator('[aria-label="Enable cloud summary"]')); + const embeddingToggle = page + .locator('[aria-label="Cloud embedding toggle"]') + .or(page.locator('[aria-label="Enable cloud embedding"]')); + + await expect(transcriptionToggle.first()).toBeVisible({ timeout: E2E_TIMEOUTS.ELEMENT_VISIBILITY_MS }); + await expect(summaryToggle.first()).toBeVisible({ timeout: E2E_TIMEOUTS.ELEMENT_VISIBILITY_MS }); + await expect(embeddingToggle.first()).toBeVisible({ timeout: E2E_TIMEOUTS.ELEMENT_VISIBILITY_MS }); + }); + + test('clicking cloud toggle updates consent state via API', async ({ page }) => { + const initialStatus = await callAPI(page, 'getCloudConsentStatus'); + + const summaryToggle = page.locator('[aria-label="Enable cloud summary"]').first(); + await expect(summaryToggle).toBeVisible(); + + const wasEnabled = initialStatus.summaryConsent; + await summaryToggle.click(); + await page.waitForTimeout(500); + + const afterToggle = await callAPI(page, 'getCloudConsentStatus'); + expect(afterToggle.summaryConsent).toBe(!wasEnabled); + + await summaryToggle.click(); + await page.waitForTimeout(500); + + const afterRevert = await callAPI(page, 'getCloudConsentStatus'); + expect(afterRevert.summaryConsent).toBe(wasEnabled); + }); + + test('cloud toggle visual state updates after click', async ({ page }) => { + const transcriptionToggle = page.locator('[aria-label="Enable cloud transcription"]').first(); + await expect(transcriptionToggle).toBeVisible(); + + const initialState = await transcriptionToggle.getAttribute('data-state'); + + await transcriptionToggle.click(); + await page.waitForTimeout(500); + + const stateAfterClick = await transcriptionToggle.getAttribute('data-state'); + expect(stateAfterClick).not.toBe(initialState); + + await transcriptionToggle.click(); + await page.waitForTimeout(500); + + const stateAfterSecondClick = await transcriptionToggle.getAttribute('data-state'); + expect(stateAfterSecondClick).toBe(initialState); + }); + + test('card header shows cloud indicator when any feature enabled', async ({ page }) => { + const aiConfigHeader = page.locator('text=AI Configuration').first(); + await expect(aiConfigHeader).toBeVisible(); + + const summaryToggle = page.locator('[aria-label="Enable cloud summary"]').first(); + await expect(summaryToggle).toBeVisible(); + + await summaryToggle.click(); + await page.waitForTimeout(500); + + const status = await callAPI(page, 'getCloudConsentStatus'); + expect(status.summaryConsent).toBe(true); + }); + + test('all three feature toggles work independently', async ({ page }) => { + await callAPI(page, 'revokeCloudConsentFeature', 'transcription'); + await callAPI(page, 'revokeCloudConsentFeature', 'summary'); + await callAPI(page, 'revokeCloudConsentFeature', 'embedding'); + + const status1 = await callAPI(page, 'getCloudConsentStatus'); + expect(status1.transcriptionConsent).toBe(false); + expect(status1.summaryConsent).toBe(false); + expect(status1.embeddingConsent).toBe(false); + + await callAPI(page, 'grantCloudConsentFeature', 'transcription'); + const status2 = await callAPI(page, 'getCloudConsentStatus'); + expect(status2.transcriptionConsent).toBe(true); + expect(status2.summaryConsent).toBe(false); + expect(status2.embeddingConsent).toBe(false); + + await callAPI(page, 'grantCloudConsentFeature', 'embedding'); + const status3 = await callAPI(page, 'getCloudConsentStatus'); + expect(status3.transcriptionConsent).toBe(true); + expect(status3.summaryConsent).toBe(false); + expect(status3.embeddingConsent).toBe(true); + + await callAPI(page, 'revokeCloudConsentFeature', 'transcription'); + await callAPI(page, 'revokeCloudConsentFeature', 'embedding'); + }); +}); + +test.describe('Cloud Provider Configuration Flow', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable end-to-end tests.'); + + test.beforeEach(async ({ page }) => { + await navigateTo(page, '/settings?tab=ai'); + await waitForLoadingComplete(page); + await waitForAPI(page); + }); + + test('expanding accordion shows provider configuration options', async ({ page }) => { + const summaryAccordion = page.locator('button:has-text("Summary")').first(); + await summaryAccordion.click(); + await page.waitForTimeout(300); + + await expect(page.locator('text=Provider').first()).toBeVisible(); + await expect(page.locator('text=Model').first()).toBeVisible(); + await expect(page.locator('text=Base URL').first()).toBeVisible(); + await expect(page.locator('text=API Key').first()).toBeVisible(); + await expect(page.locator('button:has-text("Test Connection")').first()).toBeVisible(); + }); + + test('provider selection changes available options', async ({ page }) => { + const summaryAccordion = page.locator('button:has-text("Summary")').first(); + await summaryAccordion.click(); + await page.waitForTimeout(300); + + const providerSelect = page.locator('button[role="combobox"]').first(); + await providerSelect.click(); + await page.waitForTimeout(200); + + const ollamaOption = page.locator('[role="option"]:has-text("Ollama")'); + if (await ollamaOption.isVisible()) { + await ollamaOption.click(); + await page.waitForTimeout(300); + + const baseUrlInput = page.locator('input[placeholder*="api"]').first(); + const baseUrlValue = await baseUrlInput.inputValue(); + expect(baseUrlValue).toContain('localhost'); + } + }); + + test('API key input exists in expanded accordion', async ({ page }) => { + const summaryAccordion = page.locator('button:has-text("Summary")').first(); + await summaryAccordion.click(); + await page.waitForTimeout(300); + + const apiKeyLabel = page.locator('text=API Key').first(); + await expect(apiKeyLabel).toBeVisible(); + + const apiKeyInput = page.locator('input[type="password"], input[placeholder*="API key"]').first(); + const inputVisible = await apiKeyInput.isVisible().catch(() => false); + expect(inputVisible).toBe(true); + }); + + test('test connection button triggers endpoint validation', async ({ page }) => { + const summaryAccordion = page.locator('button:has-text("Summary")').first(); + await summaryAccordion.click(); + await page.waitForTimeout(300); + + const providerSelect = page.locator('button[role="combobox"]').first(); + await providerSelect.click(); + await page.waitForTimeout(200); + + const ollamaOption = page.locator('[role="option"]:has-text("Ollama")'); + if (await ollamaOption.isVisible()) { + await ollamaOption.click(); + await page.waitForTimeout(300); + + const testButton = page.locator('button:has-text("Test Connection")').first(); + await testButton.click(); + + const toastAppeared = await waitForToast(page, undefined, E2E_TIMEOUTS.TOAST_VISIBILITY_MS) + .then(() => true) + .catch(() => false); + + if (toastAppeared) { + const toast = page.locator('[data-sonner-toast], [role="status"]').first(); + await expect(toast).toBeVisible(); + } + } + }); +}); + +test.describe('Cloud Consent State Persistence', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable end-to-end tests.'); + + test('consent state persists within same page session', async ({ page }) => { + await navigateTo(page, '/settings?tab=ai'); + await waitForAPI(page); + + await callAPI(page, 'grantCloudConsentFeature', 'summary'); + const statusBefore = await callAPI(page, 'getCloudConsentStatus'); + expect(statusBefore.summaryConsent).toBe(true); + + await callAPI(page, 'grantCloudConsentFeature', 'transcription'); + const statusAfter = await callAPI(page, 'getCloudConsentStatus'); + expect(statusAfter.summaryConsent).toBe(true); + expect(statusAfter.transcriptionConsent).toBe(true); + + await callAPI(page, 'revokeCloudConsentFeature', 'summary'); + await callAPI(page, 'revokeCloudConsentFeature', 'transcription'); + }); + + test('consent state changes are immediately reflected', async ({ page }) => { + await navigateTo(page, '/settings?tab=ai'); + await waitForAPI(page); + + const statusInitial = await callAPI(page, 'getCloudConsentStatus'); + + await callAPI(page, 'grantCloudConsentFeature', 'embedding'); + const statusAfterGrant = await callAPI(page, 'getCloudConsentStatus'); + expect(statusAfterGrant.embeddingConsent).toBe(true); + + await callAPI(page, 'revokeCloudConsentFeature', 'embedding'); + const statusAfterRevoke = await callAPI(page, 'getCloudConsentStatus'); + expect(statusAfterRevoke.embeddingConsent).toBe(statusInitial.embeddingConsent); + }); +}); + +test.describe('Cloud Consent Error Handling', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable end-to-end tests.'); + + test('API methods handle feature names correctly', async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + const validFeatures = ['transcription', 'summary', 'embedding'] as const; + + for (const feature of validFeatures) { + await callAPI(page, 'grantCloudConsentFeature', feature); + const status = await callAPI(page, 'getCloudConsentStatus'); + + if (feature === 'transcription') { + expect(status.transcriptionConsent).toBe(true); + } + if (feature === 'summary') { + expect(status.summaryConsent).toBe(true); + } + if (feature === 'embedding') { + expect(status.embeddingConsent).toBe(true); + } + + await callAPI(page, 'revokeCloudConsentFeature', feature); + } + }); + + test('UI handles loading state during consent toggle', async ({ page }) => { + await navigateTo(page, '/settings?tab=ai'); + await waitForLoadingComplete(page); + await waitForAPI(page); + + const summaryToggle = page.locator('[aria-label="Enable cloud summary"]').first(); + await expect(summaryToggle).toBeVisible(); + + await summaryToggle.click(); + + await page.waitForTimeout(100); + const isDisabled = await summaryToggle.isDisabled(); + expect(typeof isDisabled).toBe('boolean'); + }); +}); + +test.describe('Cloud + Local Provider Routing', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable end-to-end tests.'); + + test('with cloud disabled, local provider should be used', async ({ page }) => { + await navigateTo(page, '/settings?tab=ai'); + await waitForAPI(page); + + await callAPI(page, 'revokeCloudConsentFeature', 'summary'); + + const status = await callAPI(page, 'getCloudConsentStatus'); + expect(status.summaryConsent).toBe(false); + }); + + test('with cloud enabled, cloud provider config is active', async ({ page }) => { + await navigateTo(page, '/settings?tab=ai'); + await waitForAPI(page); + + await callAPI(page, 'grantCloudConsentFeature', 'summary'); + + const status = await callAPI(page, 'getCloudConsentStatus'); + expect(status.summaryConsent).toBe(true); + + const summaryAccordion = page.locator('button:has-text("Summary")').first(); + await summaryAccordion.click(); + await page.waitForTimeout(300); + + const providerSelect = page.locator('button[role="combobox"]').first(); + await providerSelect.click(); + await page.waitForTimeout(200); + + const anthropicOption = page.locator('[role="option"]:has-text("Anthropic")'); + const openaiOption = page.locator('[role="option"]:has-text("OpenAI")'); + + const hasCloudProviders = + (await anthropicOption.isVisible().catch(() => false)) || + (await openaiOption.isVisible().catch(() => false)); + + expect(hasCloudProviders).toBe(true); + + await page.keyboard.press('Escape'); + await callAPI(page, 'revokeCloudConsentFeature', 'summary'); + }); +}); + +test.describe('Integration: Cloud Consent + Recording Flow', () => { + test.skip(!shouldRun, 'Set NOTEFLOW_E2E=1 to enable end-to-end tests.'); + + test('recording page respects cloud consent settings', async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + await callAPI(page, 'grantCloudConsentFeature', 'transcription'); + const consentStatus = await callAPI(page, 'getCloudConsentStatus'); + expect(consentStatus.transcriptionConsent).toBe(true); + + await navigateTo(page, '/recording/new'); + await waitForLoadingComplete(page); + + const mainContent = page.locator('main'); + await expect(mainContent).toBeVisible(); + + const hasTranscriptionAPI = await page.evaluate(() => { + const api = window.__NOTEFLOW_API__; + return api && typeof api.startTranscription === 'function'; + }); + expect(hasTranscriptionAPI).toBe(true); + + await callAPI(page, 'revokeCloudConsentFeature', 'transcription'); + }); + + test('meeting creation works with cloud consent enabled', async ({ page }) => { + await navigateTo(page, '/'); + await waitForAPI(page); + + await callAPI(page, 'grantCloudConsentFeature', 'summary'); + + try { + const meeting = await callAPI<{ id: string; title: string }>(page, 'createMeeting', { + title: `E2E Cloud Test ${Date.now()}`, + }); + + expect(meeting).toHaveProperty('id'); + expect(meeting.id).toBeTruthy(); + + await callAPI(page, 'deleteMeeting', { meeting_id: meeting.id }); + } finally { + await callAPI(page, 'revokeCloudConsentFeature', 'summary'); + } + }); +}); diff --git a/client/e2e/settings-ui.spec.ts b/client/e2e/settings-ui.spec.ts index 9ac538f..7c96b05 100644 --- a/client/e2e/settings-ui.spec.ts +++ b/client/e2e/settings-ui.spec.ts @@ -351,23 +351,31 @@ test.describe('Cloud Consent API', () => { await navigateTo(page, '/'); await waitForAPI(page); + interface CloudConsentStatus { + transcriptionConsent: boolean; + summaryConsent: boolean; + embeddingConsent: boolean; + } + try { - // Get initial status - const initialStatus = await callAPI<{ consentGranted: boolean }>( - page, - 'getCloudConsentStatus' - ); - expect(typeof initialStatus.consentGranted).toBe('boolean'); + const initialStatus = await callAPI(page, 'getCloudConsentStatus'); + expect(typeof initialStatus.summaryConsent).toBe('boolean'); + expect(typeof initialStatus.transcriptionConsent).toBe('boolean'); + expect(typeof initialStatus.embeddingConsent).toBe('boolean'); - // Grant consent - await callAPI(page, 'grantCloudConsent'); - const afterGrant = await callAPI<{ consentGranted: boolean }>(page, 'getCloudConsentStatus'); - expect(afterGrant.consentGranted).toBe(true); + await callAPI(page, 'grantCloudConsentFeature', 'summary'); + const afterGrant = await callAPI(page, 'getCloudConsentStatus'); + expect(afterGrant.summaryConsent).toBe(true); - // Revoke consent - await callAPI(page, 'revokeCloudConsent'); - const afterRevoke = await callAPI<{ consentGranted: boolean }>(page, 'getCloudConsentStatus'); - expect(afterRevoke.consentGranted).toBe(false); + await callAPI(page, 'revokeCloudConsentFeature', 'summary'); + const afterRevoke = await callAPI(page, 'getCloudConsentStatus'); + expect(afterRevoke.summaryConsent).toBe(false); + + await callAPI(page, 'grantCloudConsentFeature', 'transcription'); + const afterTranscription = await callAPI(page, 'getCloudConsentStatus'); + expect(afterTranscription.transcriptionConsent).toBe(true); + + await callAPI(page, 'revokeCloudConsentFeature', 'transcription'); } catch { test.skip(); } diff --git a/client/e2e/ui-integration.spec.ts b/client/e2e/ui-integration.spec.ts index 44d42f2..3463b07 100644 --- a/client/e2e/ui-integration.spec.ts +++ b/client/e2e/ui-integration.spec.ts @@ -92,18 +92,26 @@ test.describe('cloud consent integration', () => { await navigateTo(page, '/'); await waitForAPI(page); - const initialStatus = await callAPI<{ consentGranted: boolean }>(page, 'getCloudConsentStatus'); + interface CloudConsentStatus { + transcriptionConsent: boolean; + summaryConsent: boolean; + embeddingConsent: boolean; + } - expect(initialStatus).toHaveProperty('consentGranted'); - expect(typeof initialStatus.consentGranted).toBe('boolean'); + const initialStatus = await callAPI(page, 'getCloudConsentStatus'); - await callAPI(page, 'grantCloudConsent'); - const afterGrant = await callAPI<{ consentGranted: boolean }>(page, 'getCloudConsentStatus'); - expect(afterGrant.consentGranted).toBe(true); + expect(initialStatus).toHaveProperty('summaryConsent'); + expect(initialStatus).toHaveProperty('transcriptionConsent'); + expect(initialStatus).toHaveProperty('embeddingConsent'); + expect(typeof initialStatus.summaryConsent).toBe('boolean'); - await callAPI(page, 'revokeCloudConsent'); - const afterRevoke = await callAPI<{ consentGranted: boolean }>(page, 'getCloudConsentStatus'); - expect(afterRevoke.consentGranted).toBe(false); + await callAPI(page, 'grantCloudConsentFeature', 'summary'); + const afterGrant = await callAPI(page, 'getCloudConsentStatus'); + expect(afterGrant.summaryConsent).toBe(true); + + await callAPI(page, 'revokeCloudConsentFeature', 'summary'); + const afterRevoke = await callAPI(page, 'getCloudConsentStatus'); + expect(afterRevoke.summaryConsent).toBe(false); }); }); diff --git a/client/eslint.config.js b/client/eslint.config.js index f73eb91..22540ae 100644 --- a/client/eslint.config.js +++ b/client/eslint.config.js @@ -5,7 +5,7 @@ import globals from 'globals'; import tseslint from 'typescript-eslint'; export default tseslint.config( - { ignores: ['dist', 'src-tauri', 'e2e-native', 'e2e'] }, + { ignores: ['dist', 'src-tauri', 'e2e-native', 'e2e', 'coverage', '**/coverage/**'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], diff --git a/client/package-lock.json b/client/package-lock.json index b4a458b..4a847b4 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -6730,9 +6730,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001765", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", - "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", "dev": true, "funding": [ { diff --git a/client/src-tauri/src/audio/capture.rs b/client/src-tauri/src/audio/capture.rs index 4d47512..289c88c 100644 --- a/client/src-tauri/src/audio/capture.rs +++ b/client/src-tauri/src/audio/capture.rs @@ -216,7 +216,11 @@ mod tests { fn calculate_rms_u16_midpoint_near_zero() { let samples = vec![MIDPOINT_U16; SAMPLE_COUNT]; let rms = calculate_rms_u16(&samples); - assert!(rms < NEAR_ZERO_THRESHOLD, "Midpoint RMS {} should be near 0", rms); + assert!( + rms < NEAR_ZERO_THRESHOLD, + "Midpoint RMS {} should be near 0", + rms + ); } #[test] diff --git a/client/src-tauri/src/audio/devices.rs b/client/src-tauri/src/audio/devices.rs index 4e06015..bc0086d 100644 --- a/client/src-tauri/src/audio/devices.rs +++ b/client/src-tauri/src/audio/devices.rs @@ -89,7 +89,9 @@ pub fn get_default_output_device() -> Option { /// Check if a device name matches known loopback/virtual audio patterns. pub fn matches_loopback_device(name: &str) -> bool { let lower = name.to_lowercase(); - LOOPBACK_PATTERNS.iter().any(|pattern| lower.contains(pattern)) + LOOPBACK_PATTERNS + .iter() + .any(|pattern| lower.contains(pattern)) } /// List all loopback/virtual audio input devices. diff --git a/client/src-tauri/src/audio/drift_compensation/resampler.rs b/client/src-tauri/src/audio/drift_compensation/resampler.rs index e65c2ff..f1946c2 100644 --- a/client/src-tauri/src/audio/drift_compensation/resampler.rs +++ b/client/src-tauri/src/audio/drift_compensation/resampler.rs @@ -4,7 +4,9 @@ //! smooth, artifact-free resampling with gradual ratio transitions. use crate::constants::drift_compensation::{RATIO_BYPASS_THRESHOLD, RATIO_SLEW_RATE}; -use rubato::{Resampler, SincFixedIn, SincInterpolationParameters, SincInterpolationType, WindowFunction}; +use rubato::{ + Resampler, SincFixedIn, SincInterpolationParameters, SincInterpolationType, WindowFunction, +}; const DEFAULT_CHUNK_SIZE: usize = 1024; const RESAMPLER_SINC_LEN: usize = 256; @@ -156,7 +158,10 @@ impl AdaptiveResampler { fn try_update_resampler_ratio(&mut self) { if let Some(ref mut resampler) = self.resampler { // rubato's SincFixedIn supports ratio updates via Resampler trait - if resampler.set_resample_ratio(self.current_ratio, false).is_err() { + if resampler + .set_resample_ratio(self.current_ratio, false) + .is_err() + { // If ratio update fails, rebuild if let Err(e) = self.rebuild_resampler() { tracing::warn!(error = %e, "Failed to rebuild resampler"); diff --git a/client/src-tauri/src/audio/mixer.rs b/client/src-tauri/src/audio/mixer.rs index 05c64c0..1c9dc3b 100644 --- a/client/src-tauri/src/audio/mixer.rs +++ b/client/src-tauri/src/audio/mixer.rs @@ -15,6 +15,7 @@ //! - Applies slew-rate limited resampling to the secondary stream //! - Provides metrics and throttled logging for diagnostics +use super::mixer_helpers::{drain_both_sources, drain_single_source}; use crate::audio::drift_compensation::{ AdaptiveResampler, DriftDetector, DriftMetrics, DriftMetricsSnapshot, }; @@ -28,6 +29,15 @@ const DEFAULT_SAMPLE_RATE_HZ: u32 = 48_000; /// Default buffer capacity in samples per source (~1 second at 48kHz). const DEFAULT_BUFFER_CAPACITY: usize = DEFAULT_SAMPLE_RATE_HZ as usize; +#[derive(Debug, Clone)] +pub struct MixedAudioChunk { + pub mixed: Vec, + pub primary_rms: f32, + pub secondary_rms: f32, + pub primary_samples: usize, + pub secondary_samples: usize, +} + /// Audio mixer that combines two input streams. /// /// The mixer maintains ring buffers for each source and outputs mixed audio @@ -153,6 +163,13 @@ impl AudioMixer { /// detector with current buffer levels and adjusts the resampler ratio /// if drift is detected. pub fn drain_mixed(&self, max_samples: usize) -> Vec { + self.drain_mixed_with_levels(max_samples) + .map(|chunk| chunk.mixed) + .unwrap_or_default() + } + + /// Drain mixed audio and compute per-source RMS levels. + pub fn drain_mixed_with_levels(&self, max_samples: usize) -> Option { let mut primary = self.primary_buffer.lock(); let mut secondary = self.secondary_buffer.lock(); @@ -169,39 +186,46 @@ impl AudioMixer { } if available_primary == 0 && available_secondary == 0 { - return Vec::new(); + return None; } // If one source is empty, return the other with gain applied if available_secondary == 0 { - let count = available_primary.min(max_samples); - return primary - .drain(..count) - .map(|s| s * primary_gain) - .collect(); + let drained = drain_single_source(&mut primary, primary_gain, max_samples); + return Some(MixedAudioChunk { + mixed: drained.mixed, + primary_rms: drained.rms, + secondary_rms: 0.0, + primary_samples: drained.samples, + secondary_samples: 0, + }); } if available_primary == 0 { - let count = available_secondary.min(max_samples); - return secondary - .drain(..count) - .map(|s| s * secondary_gain) - .collect(); + let drained = drain_single_source(&mut secondary, secondary_gain, max_samples); + return Some(MixedAudioChunk { + mixed: drained.mixed, + primary_rms: 0.0, + secondary_rms: drained.rms, + primary_samples: 0, + secondary_samples: drained.samples, + }); } - // Mix both sources - let count = available_primary.min(available_secondary).min(max_samples); - let mut mixed = Vec::with_capacity(count); - - for _ in 0..count { - let p = primary.pop_front().unwrap_or(0.0) * primary_gain; - let s = secondary.pop_front().unwrap_or(0.0) * secondary_gain; - // Simple additive mixing with soft clipping - let combined = (p + s).clamp(-1.0, 1.0); - mixed.push(combined); - } - - mixed + let drained = drain_both_sources( + &mut primary, + &mut secondary, + primary_gain, + secondary_gain, + max_samples, + ); + Some(MixedAudioChunk { + mixed: drained.mixed, + primary_rms: drained.primary_rms, + secondary_rms: drained.secondary_rms, + primary_samples: drained.samples, + secondary_samples: drained.samples, + }) } /// Update drift compensation based on buffer levels. @@ -215,7 +239,9 @@ impl AudioMixer { self.secondary_resampler.lock().set_target_ratio(ratio); // Record adjustment in metrics - self.drift_metrics.lock().record_adjustment(drift_ppm, ratio); + self.drift_metrics + .lock() + .record_adjustment(drift_ppm, ratio); tracing::debug!( drift_ppm, @@ -436,10 +462,7 @@ mod tests { mixer.push_primary(&large_buffer); let metrics = mixer.drift_metrics(); - assert!( - metrics.overflow_count > 0, - "Should have recorded overflows" - ); + assert!(metrics.overflow_count > 0, "Should have recorded overflows"); } #[test] diff --git a/client/src-tauri/src/audio/mixer_helpers.rs b/client/src-tauri/src/audio/mixer_helpers.rs new file mode 100644 index 0000000..c821acd --- /dev/null +++ b/client/src-tauri/src/audio/mixer_helpers.rs @@ -0,0 +1,74 @@ +use std::collections::VecDeque; + +pub(crate) struct DrainSingleResult { + pub mixed: Vec, + pub rms: f32, + pub samples: usize, +} + +pub(crate) struct DrainMixedResult { + pub mixed: Vec, + pub primary_rms: f32, + pub secondary_rms: f32, + pub samples: usize, +} + +pub(crate) fn drain_single_source( + buffer: &mut VecDeque, + gain: f32, + max_samples: usize, +) -> DrainSingleResult { + let count = buffer.len().min(max_samples); + let mut sum_sq = 0.0; + let mixed: Vec = buffer + .drain(..count) + .map(|sample| { + let scaled = sample * gain; + sum_sq += scaled * scaled; + scaled + }) + .collect(); + let rms = rms_from_sum(sum_sq, count); + DrainSingleResult { + mixed, + rms, + samples: count, + } +} + +pub(crate) fn drain_both_sources( + primary: &mut VecDeque, + secondary: &mut VecDeque, + primary_gain: f32, + secondary_gain: f32, + max_samples: usize, +) -> DrainMixedResult { + let count = primary.len().min(secondary.len()).min(max_samples); + let mut mixed = Vec::with_capacity(count); + let mut primary_sum_sq = 0.0; + let mut secondary_sum_sq = 0.0; + + for _ in 0..count { + let primary_sample = primary.pop_front().unwrap_or(0.0) * primary_gain; + let secondary_sample = secondary.pop_front().unwrap_or(0.0) * secondary_gain; + primary_sum_sq += primary_sample * primary_sample; + secondary_sum_sq += secondary_sample * secondary_sample; + let combined = (primary_sample + secondary_sample).clamp(-1.0, 1.0); + mixed.push(combined); + } + + DrainMixedResult { + mixed, + primary_rms: rms_from_sum(primary_sum_sq, count), + secondary_rms: rms_from_sum(secondary_sum_sq, count), + samples: count, + } +} + +fn rms_from_sum(sum_sq: f32, count: usize) -> f32 { + if count > 0 { + (sum_sq / count as f32).sqrt() + } else { + 0.0 + } +} diff --git a/client/src-tauri/src/audio/mod.rs b/client/src-tauri/src/audio/mod.rs index e0138d9..ca4a810 100644 --- a/client/src-tauri/src/audio/mod.rs +++ b/client/src-tauri/src/audio/mod.rs @@ -5,15 +5,18 @@ mod devices; pub mod drift_compensation; pub mod loader; pub mod mixer; +mod mixer_helpers; mod playback; #[cfg(target_os = "windows")] pub mod windows_loopback; pub use capture::*; pub use devices::*; -pub use drift_compensation::{AdaptiveResampler, DriftDetector, DriftMetrics, DriftMetricsSnapshot}; +pub use drift_compensation::{ + AdaptiveResampler, DriftDetector, DriftMetrics, DriftMetricsSnapshot, +}; pub use loader::*; -pub use mixer::AudioMixer; +pub use mixer::{AudioMixer, MixedAudioChunk}; pub use playback::*; #[cfg(target_os = "windows")] pub use windows_loopback::*; diff --git a/client/src-tauri/src/audio/windows_loopback.rs b/client/src-tauri/src/audio/windows_loopback.rs index d37078d..cc21a47 100644 --- a/client/src-tauri/src/audio/windows_loopback.rs +++ b/client/src-tauri/src/audio/windows_loopback.rs @@ -189,18 +189,17 @@ where { let hr = initialize_mta(); if hr != 0 { - return Err(Error::AudioCapture(format!("WASAPI init failed: HRESULT 0x{:08X}", hr))); + return Err(Error::AudioCapture(format!( + "WASAPI init failed: HRESULT 0x{:08X}", + hr + ))); } let result: Result<()> = (|| { let enumerator = DeviceEnumerator::new() .map_err(|err| Error::AudioCapture(format!("WASAPI enumerator error: {err}")))?; - let device = lookup_render_device( - &enumerator, - &meeting_id, - output_device_name.as_deref(), - )?; + let device = lookup_render_device(&enumerator, &meeting_id, output_device_name.as_deref())?; let device_name = device .get_friendlyname() diff --git a/client/src-tauri/src/commands/assistant.rs b/client/src-tauri/src/commands/assistant.rs index 59b53dd..c0863e2 100644 --- a/client/src-tauri/src/commands/assistant.rs +++ b/client/src-tauri/src/commands/assistant.rs @@ -5,30 +5,42 @@ use std::sync::Arc; use tauri::{AppHandle, Emitter, State}; use crate::error::Result; -use crate::events::{event_names, AssistantChunkEvent}; -use crate::grpc::types::assistant::AskAssistantResponse; +use crate::events::event_names; +use crate::grpc::types::assistant::{AskAssistantResponse, AssistantConfig}; use crate::state::AppState; -const DEFAULT_TOP_K: i32 = 8; +#[derive(Debug, Clone, serde::Deserialize)] +pub struct AssistantRequestConfig { + pub meeting_id: Option, + pub thread_id: Option, + pub allow_web: Option, + pub top_k: Option, + pub summary_api_key: Option, + pub embedding_api_key: Option, +} + +fn build_assistant_config(config: AssistantRequestConfig) -> AssistantConfig { + AssistantConfig { + meeting_id: config.meeting_id, + thread_id: config.thread_id, + allow_web: config.allow_web.unwrap_or(false), + top_k: config.top_k.unwrap_or(AssistantConfig::default().top_k), + summary_api_key: config.summary_api_key, + embedding_api_key: config.embedding_api_key, + } +} #[tauri::command(rename_all = "snake_case")] pub async fn ask_assistant( state: State<'_, Arc>, question: String, - meeting_id: Option, - thread_id: Option, - allow_web: Option, - top_k: Option, + config: AssistantRequestConfig, ) -> Result { + let config = build_assistant_config(config); + state .grpc_client - .ask_assistant( - &question, - meeting_id, - thread_id, - allow_web.unwrap_or(false), - top_k.unwrap_or(DEFAULT_TOP_K), - ) + .ask_assistant(&question, &config) .await } @@ -37,23 +49,18 @@ pub async fn stream_assistant( app: AppHandle, state: State<'_, Arc>, question: String, - meeting_id: Option, - thread_id: Option, - allow_web: Option, - top_k: Option, + config: AssistantRequestConfig, ) -> Result<()> { + let config = build_assistant_config(config); let grpc_client = state.grpc_client.clone(); let app_clone = app.clone(); grpc_client .stream_assistant( &question, - meeting_id, - thread_id, - allow_web.unwrap_or(false), - top_k.unwrap_or(DEFAULT_TOP_K), - move |chunk: AssistantChunkEvent| { - let _ = app_clone.emit(event_names::ASSISTANT_CHUNK, &chunk); + &config, + |chunk| { + let _ = app_clone.emit(event_names::ASSISTANT_CHUNK, chunk); }, ) .await diff --git a/client/src-tauri/src/commands/recording/capture.rs b/client/src-tauri/src/commands/recording/capture.rs index f20b731..a2f4feb 100644 --- a/client/src-tauri/src/commands/recording/capture.rs +++ b/client/src-tauri/src/commands/recording/capture.rs @@ -13,10 +13,10 @@ use tokio::sync::mpsc; use crate::constants::audio as audio_constants; use crate::error::{Error, Result}; use crate::events::{event_names, AudioWarningEvent}; -use crate::state::{AppState, AudioSamplesChunk}; +use crate::state::{AppState, AudioSamplesChunk, AudioSource}; use super::device::{resolve_input_device, select_input_config}; -use super::process_audio_samples; +use super::{process_audio_samples, AudioProcessingInput}; /// Configuration for audio capture thread. pub struct CaptureConfig { @@ -121,12 +121,7 @@ pub fn start_native_capture( }; std::thread::spawn(move || { - let result = capture_thread_main( - ctx, - config, - stop_rx, - ready_tx.clone(), - ); + let result = capture_thread_main(ctx, config, stop_rx, ready_tx.clone()); if let Err(err) = result { let _ = ready_tx.send(Err(err)); } @@ -307,11 +302,14 @@ fn process_captured_buffer( let processed = process_audio_samples( ctx.state, ctx.app, - ctx.meeting_id, - chunk, - timestamp, - ctx.sample_rate, - ctx.channels, + AudioProcessingInput { + meeting_id: ctx.meeting_id, + audio_data: chunk, + timestamp, + sample_rate: ctx.sample_rate, + channels: ctx.channels, + audio_source: AudioSource::Mic, + }, ); if let Err(err) = ctx.capture_tx.try_send(processed) { diff --git a/client/src-tauri/src/commands/recording/dual_capture.rs b/client/src-tauri/src/commands/recording/dual_capture.rs index e5081c6..d59db72 100644 --- a/client/src-tauri/src/commands/recording/dual_capture.rs +++ b/client/src-tauri/src/commands/recording/dual_capture.rs @@ -11,20 +11,20 @@ use std::time::Duration; use tauri::{AppHandle, Emitter}; use tokio::sync::mpsc; -use crate::audio::{calculate_rms, normalize_for_asr, rms_to_db, AudioMixer}; -use crate::constants::audio as audio_constants; -use crate::events::{event_names, SystemAudioLevelEvent}; -use crate::error::{Error, Result}; -use crate::helpers::normalize_db_level; -use crate::state::{AppState, AudioSamplesChunk}; +use crate::audio::{calculate_rms, normalize_for_asr, rms_to_db, AudioMixer, MixedAudioChunk}; #[cfg(target_os = "windows")] use crate::audio::{ matches_wasapi_loopback_device_id, start_wasapi_loopback_capture, WasapiLoopbackHandle, WASAPI_LOOPBACK_DEVICE_NAME, }; +use crate::constants::audio as audio_constants; +use crate::error::{Error, Result}; +use crate::events::{event_names, SystemAudioLevelEvent}; +use crate::helpers::normalize_db_level; +use crate::state::{AppState, AudioSamplesChunk, AudioSource}; use super::device::{resolve_input_device, select_input_config}; -use super::process_audio_samples; +use super::{process_audio_samples, AudioProcessingInput}; /// Configuration for dual audio capture. pub struct DualCaptureConfig { @@ -45,6 +45,9 @@ pub struct DualCaptureConfig { pub channels: u16, } +const SOURCE_DOMINANCE_RATIO: f32 = 1.2; +const SOURCE_RMS_FLOOR: f32 = 0.001; + /// Handle for stopping dual capture. pub struct DualCaptureHandle { /// Send to stop the capture thread @@ -95,12 +98,7 @@ pub fn start_dual_capture( }; std::thread::spawn(move || { - let result = dual_capture_thread_main( - ctx, - config, - stop_rx, - ready_tx.clone(), - ); + let result = dual_capture_thread_main(ctx, config, stop_rx, ready_tx.clone()); if let Err(err) = result { let _ = ready_tx.send(Err(err)); } @@ -131,7 +129,9 @@ fn dual_capture_thread_main( let mic_device = resolve_input_device(config.mic_device_id.as_deref()) .ok_or_else(|| Error::AudioCapture("No microphone device available".to_string()))?; - let mic_name = mic_device.name().unwrap_or_else(|_| "".to_string()); + let mic_name = mic_device + .name() + .unwrap_or_else(|_| "".to_string()); let system_device = resolve_input_device(config.system_device_id.as_deref()); #[cfg(target_os = "windows")] @@ -225,9 +225,7 @@ fn dual_capture_thread_main( .as_deref() .and_then(output_device_name_from_id) .map(|name| name.to_string()); - let output_device_name = parsed_name - .clone() - .or_else(|| raw_output_device_id.clone()); + let output_device_name = parsed_name.clone().or_else(|| raw_output_device_id.clone()); tracing::info!( meeting_id = %ctx.meeting_id, @@ -256,7 +254,8 @@ fn dual_capture_thread_main( } else { let system_device = system_device .ok_or_else(|| Error::AudioCapture("No system audio device available".to_string()))?; - let system_config = select_input_config(&system_device, config.sample_rate, config.channels)?; + let system_config = + select_input_config(&system_device, config.sample_rate, config.channels)?; let system_sample_rate = system_config.sample_rate().0; // Validate sample rates match - mixing different rates causes audio distortion @@ -315,7 +314,6 @@ fn dual_capture_thread_main( // Main loop: drain mixer and send to capture channel let poll_interval = Duration::from_millis(20); // 50Hz polling let mut frames_sent: u64 = 0; - let mut accumulator: Vec = Vec::with_capacity(samples_per_chunk * 2); loop { if stop_rx.recv_timeout(poll_interval).is_ok() { @@ -326,37 +324,41 @@ fn dual_capture_thread_main( } // Drain mixed audio and accumulate - let mixed = mixer.drain_mixed(samples_per_chunk); - if !mixed.is_empty() { - accumulator.extend(&mixed); + let mixed = mixer.drain_mixed_with_levels(samples_per_chunk); + let Some(mixed_chunk) = mixed else { + continue; + }; + if mixed_chunk.mixed.is_empty() { + continue; } - // Send chunks when we have enough accumulated - while accumulator.len() >= samples_per_chunk { - let chunk: Vec = accumulator.drain(..samples_per_chunk).collect(); - let frames_in_chunk = (chunk.len() / channels as usize) as u64; - let timestamp = frames_sent as f64 / sample_rate as f64; - frames_sent += frames_in_chunk; + let frames_in_chunk = (mixed_chunk.mixed.len() / channels as usize) as u64; + let timestamp = frames_sent as f64 / sample_rate as f64; + frames_sent += frames_in_chunk; - let mut stream_chunk = chunk.clone(); - // Normalize audio levels for better ASR performance - // System audio from WASAPI loopback often comes in at very low levels - let _gain_applied = normalize_for_asr(&mut stream_chunk); + let audio_source = classify_audio_source(&mixed_chunk); - let mut processed = process_audio_samples( - &ctx.state, - &ctx.app, - &ctx.meeting_id, - chunk, + let mut stream_chunk = mixed_chunk.mixed.clone(); + // Normalize audio levels for better ASR performance + // System audio from WASAPI loopback often comes in at very low levels + let _gain_applied = normalize_for_asr(&mut stream_chunk); + + let mut processed = process_audio_samples( + &ctx.state, + &ctx.app, + AudioProcessingInput { + meeting_id: &ctx.meeting_id, + audio_data: mixed_chunk.mixed, timestamp, sample_rate, channels, - ); - processed.samples = stream_chunk; + audio_source, + }, + ); + processed.samples = stream_chunk; - if let Err(err) = ctx.capture_tx.try_send(processed) { - tracing::warn!("Dropping mixed audio chunk: {}", err); - } + if let Err(err) = ctx.capture_tx.try_send(processed) { + tracing::warn!("Dropping mixed audio chunk: {}", err); } } @@ -394,6 +396,31 @@ fn emit_system_audio_level(app: &AppHandle, level: f32) { } } +fn classify_audio_source(chunk: &MixedAudioChunk) -> AudioSource { + let has_primary = chunk.primary_samples > 0; + let has_secondary = chunk.secondary_samples > 0; + if has_primary && !has_secondary { + return AudioSource::Mic; + } + if has_secondary && !has_primary { + return AudioSource::System; + } + + let primary_rms = chunk.primary_rms; + let secondary_rms = chunk.secondary_rms; + if primary_rms < SOURCE_RMS_FLOOR && secondary_rms < SOURCE_RMS_FLOOR { + return AudioSource::Unspecified; + } + + if primary_rms >= secondary_rms * SOURCE_DOMINANCE_RATIO { + return AudioSource::Mic; + } + if secondary_rms >= primary_rms * SOURCE_DOMINANCE_RATIO { + return AudioSource::System; + } + AudioSource::Mixed +} + #[cfg(target_os = "windows")] fn output_device_name_from_id(device_id: &str) -> Option<&str> { let mut parts = device_id.splitn(3, ':'); @@ -422,12 +449,7 @@ where { match sample_format { cpal::SampleFormat::F32 => device - .build_input_stream( - config, - move |data: &[f32], _| on_data(data), - err_fn, - None, - ) + .build_input_stream(config, move |data: &[f32], _| on_data(data), err_fn, None) .map_err(|err| Error::AudioCapture(err.to_string())), cpal::SampleFormat::I16 => { let mut buffer = Vec::new(); diff --git a/client/src-tauri/src/commands/recording/mod.rs b/client/src-tauri/src/commands/recording/mod.rs index 61acd1c..5bea8ce 100644 --- a/client/src-tauri/src/commands/recording/mod.rs +++ b/client/src-tauri/src/commands/recording/mod.rs @@ -20,5 +20,5 @@ mod tests; // Re-export for use by other modules pub use device::decode_input_device_id; pub use session::{send_audio_chunk, start_recording, stop_recording}; -pub(crate) use session::{emit_error, process_audio_samples}; +pub(crate) use session::{AudioProcessingInput, emit_error, process_audio_samples}; pub use stream_state::{get_stream_state, reset_stream_state}; diff --git a/client/src-tauri/src/commands/recording/session.rs b/client/src-tauri/src/commands/recording/session.rs index 7f8a6d9..6492a53 100644 --- a/client/src-tauri/src/commands/recording/session.rs +++ b/client/src-tauri/src/commands/recording/session.rs @@ -7,7 +7,7 @@ pub(crate) mod start; pub(crate) mod stop; pub(crate) use errors::emit_error; -pub(crate) use processing::process_audio_samples; +pub(crate) use processing::{AudioProcessingInput, process_audio_samples}; pub use chunks::send_audio_chunk; pub use start::start_recording; pub use stop::stop_recording; diff --git a/client/src-tauri/src/commands/recording/session/chunks.rs b/client/src-tauri/src/commands/recording/session/chunks.rs index 8d890e4..2076b40 100644 --- a/client/src-tauri/src/commands/recording/session/chunks.rs +++ b/client/src-tauri/src/commands/recording/session/chunks.rs @@ -6,8 +6,9 @@ use tauri::{AppHandle, State}; use crate::error::{Error, Result}; use crate::state::AppState; +use crate::state::AudioSource; -use super::processing::process_audio_samples; +use super::processing::{AudioProcessingInput, process_audio_samples}; /// Send an audio chunk to the active recording. #[tauri::command(rename_all = "snake_case")] @@ -48,11 +49,14 @@ pub async fn send_audio_chunk( let chunk = process_audio_samples( state.inner(), &app, - &meeting_id, - audio_data, - timestamp, - resolved_sample_rate, - resolved_channels, + AudioProcessingInput { + meeting_id: &meeting_id, + audio_data, + timestamp, + sample_rate: resolved_sample_rate, + channels: resolved_channels, + audio_source: AudioSource::Unspecified, + }, ); // Send audio to the capture channel diff --git a/client/src-tauri/src/commands/recording/session/processing.rs b/client/src-tauri/src/commands/recording/session/processing.rs index b8e5fe6..5f62580 100644 --- a/client/src-tauri/src/commands/recording/session/processing.rs +++ b/client/src-tauri/src/commands/recording/session/processing.rs @@ -8,20 +8,34 @@ use crate::constants::collections as collection_constants; use crate::events::{event_names, AudioLevelEvent}; use crate::grpc::types::results::TimestampedAudio; use crate::helpers::normalize_db_level; -use crate::state::{AppState, AudioSamplesChunk}; +use crate::state::{AppState, AudioSamplesChunk, AudioSource}; use super::super::audio::downmix_to_mono; +/// Input bundle for audio processing. +pub(crate) struct AudioProcessingInput<'a> { + pub meeting_id: &'a str, + pub audio_data: Vec, + pub timestamp: f64, + pub sample_rate: u32, + pub channels: u16, + pub audio_source: AudioSource, +} + /// Process audio samples: calculate levels, emit events, buffer for playback. pub(crate) fn process_audio_samples( state: &AppState, app: &AppHandle, - meeting_id: &str, - audio_data: Vec, - timestamp: f64, - sample_rate: u32, - channels: u16, + input: AudioProcessingInput<'_>, ) -> AudioSamplesChunk { + let AudioProcessingInput { + meeting_id, + audio_data, + timestamp, + sample_rate, + channels, + audio_source, + } = input; let sample_rate = sample_rate.max(1); let channels = channels.max(1); @@ -85,6 +99,7 @@ pub(crate) fn process_audio_samples( timestamp, sample_rate, channels, + audio_source, } } @@ -124,8 +139,7 @@ mod tests { let state = AppState::new(); - let iterations = - (collection_constants::MAX_SESSION_AUDIO_SAMPLES / CHUNK_SAMPLES) + 2; + let iterations = (collection_constants::MAX_SESSION_AUDIO_SAMPLES / CHUNK_SAMPLES) + 2; let mut timestamp = 0.0_f64; let seconds_per_sample = 1.0 / SAMPLE_RATE as f64; diff --git a/client/src-tauri/src/commands/recording/session/start.rs b/client/src-tauri/src/commands/recording/session/start.rs index 2a06797..f4c5955 100644 --- a/client/src-tauri/src/commands/recording/session/start.rs +++ b/client/src-tauri/src/commands/recording/session/start.rs @@ -18,8 +18,8 @@ use crate::constants::audio as audio_constants; use crate::error::{Error, Result}; use crate::events::{event_names, RecordingTimerEvent}; use crate::grpc::streaming::{AudioStreamChunk, StreamManager}; -use crate::helpers::is_wsl; -use crate::state::{AppState, AudioSamplesChunk, RecordingSession}; +use crate::is_wsl; +use crate::state::{AppState, AudioSamplesChunk, AudioSource, RecordingSession}; use crate::triggers::get_foreground_app_identity; use super::errors::emit_error; @@ -167,6 +167,7 @@ pub async fn start_recording( stream_manager: State<'_, Arc>, app: AppHandle, meeting_id: String, + transcription_api_key: Option, ) -> Result<()> { // Check if already recording if state.is_recording() { @@ -281,12 +282,17 @@ pub async fn start_recording( timestamp: 0.0, sample_rate: bootstrap_sample_rate, channels: bootstrap_channels, + audio_source: AudioSource::Unspecified, }); tracing::debug!("Sending bootstrap audio chunk to establish stream"); - // Start the gRPC stream let audio_tx = match stream_manager - .start_streaming(meeting_id.to_string(), app.clone(), bootstrap_chunk) + .start_streaming( + meeting_id.to_string(), + app.clone(), + bootstrap_chunk, + transcription_api_key, + ) .await { Ok(audio_tx) => audio_tx, @@ -318,6 +324,7 @@ pub async fn start_recording( timestamp: chunk.timestamp, sample_rate: chunk.sample_rate as i32, channels: chunk.channels as i32, + audio_source: chunk.audio_source, }; if audio_tx_clone.send(stream_chunk).await.is_err() { diff --git a/client/src-tauri/src/commands/summary.rs b/client/src-tauri/src/commands/summary.rs index f137431..d9250b7 100644 --- a/client/src-tauri/src/commands/summary.rs +++ b/client/src-tauri/src/commands/summary.rs @@ -66,31 +66,59 @@ impl From for pb::SummarizationOptions { } } -/// Cloud consent status response. #[derive(Debug, Clone, serde::Serialize)] pub struct ConsentStatus { pub consent_granted: bool, + pub transcription_consent: bool, + pub summary_consent: bool, + pub embedding_consent: bool, } -/// Grant consent for cloud-based summarization. #[tauri::command(rename_all = "snake_case")] pub async fn grant_cloud_consent(state: State<'_, Arc>) -> Result<()> { state.grpc_client.grant_cloud_consent().await?; Ok(()) } -/// Revoke consent for cloud-based summarization. #[tauri::command(rename_all = "snake_case")] pub async fn revoke_cloud_consent(state: State<'_, Arc>) -> Result<()> { state.grpc_client.revoke_cloud_consent().await?; Ok(()) } -/// Get current cloud consent status. +#[tauri::command(rename_all = "snake_case")] +pub async fn grant_cloud_consent_feature( + state: State<'_, Arc>, + feature: String, +) -> Result<()> { + state + .grpc_client + .grant_cloud_consent_feature(&feature) + .await?; + Ok(()) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn revoke_cloud_consent_feature( + state: State<'_, Arc>, + feature: String, +) -> Result<()> { + state + .grpc_client + .revoke_cloud_consent_feature(&feature) + .await?; + Ok(()) +} + #[tauri::command(rename_all = "snake_case")] pub async fn get_cloud_consent_status(state: State<'_, Arc>) -> Result { - let consent_granted = state.grpc_client.get_cloud_consent_status().await?; - Ok(ConsentStatus { consent_granted }) + let status = state.grpc_client.get_cloud_consent_status().await?; + Ok(ConsentStatus { + consent_granted: status.summary_consent, + transcription_consent: status.transcription_consent, + summary_consent: status.summary_consent, + embedding_consent: status.embedding_consent, + }) } /// Generate a summary for a meeting. diff --git a/client/src-tauri/src/commands/tasks.rs b/client/src-tauri/src/commands/tasks.rs index 9f840c2..e50dff7 100644 --- a/client/src-tauri/src/commands/tasks.rs +++ b/client/src-tauri/src/commands/tasks.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use tauri::State; use crate::error::Result; -use crate::grpc::types::tasks::{ListTasksResult, Task}; +use crate::grpc::types::tasks::{CreateTaskInput, ListTasksResult, Task}; use crate::state::AppState; #[tauri::command(rename_all = "snake_case")] @@ -37,3 +37,11 @@ pub async fn update_task( .update_task(task_id, text, status, assignee_person_id, due_date, priority) .await } + +#[tauri::command(rename_all = "snake_case")] +pub async fn create_task( + state: State<'_, Arc>, + request: CreateTaskInput, +) -> Result { + state.grpc_client.create_task(request).await +} diff --git a/client/src-tauri/src/commands/testing.rs b/client/src-tauri/src/commands/testing.rs index 1badaa8..d39b456 100644 --- a/client/src-tauri/src/commands/testing.rs +++ b/client/src-tauri/src/commands/testing.rs @@ -10,9 +10,10 @@ use std::time::Duration; use tauri::{AppHandle, State}; use tokio::time::sleep; +use crate::commands::recording::AudioProcessingInput; use crate::error::{Error, Result}; use crate::grpc::streaming::StreamManager; -use crate::state::AppState; +use crate::state::{AppState, AudioSource}; use super::audio::list_audio_devices; @@ -173,11 +174,14 @@ pub async fn inject_test_audio( let chunk = crate::commands::recording::process_audio_samples( state.inner(), &app, - &meeting_id, - chunk_data, - timestamp, - sample_rate, - 1, // mono + AudioProcessingInput { + meeting_id: &meeting_id, + audio_data: chunk_data, + timestamp, + sample_rate, + channels: 1, + audio_source: AudioSource::Unspecified, + }, ); // Send to recording session with backpressure so we don't drop chunks. @@ -301,11 +305,14 @@ pub async fn inject_test_tone( let chunk = crate::commands::recording::process_audio_samples( state.inner(), &app, - &meeting_id, - chunk_data, - timestamp, - sample_rate, - 1, + AudioProcessingInput { + meeting_id: &meeting_id, + audio_data: chunk_data, + timestamp, + sample_rate, + channels: 1, + audio_source: AudioSource::Unspecified, + }, ); let audio_tx = { diff --git a/client/src-tauri/src/grpc/client/analytics.rs b/client/src-tauri/src/grpc/client/analytics.rs index bd177ac..7ca9768 100644 --- a/client/src-tauri/src/grpc/client/analytics.rs +++ b/client/src-tauri/src/grpc/client/analytics.rs @@ -33,6 +33,9 @@ impl GrpcClient { total_words: response.total_words, total_segments: response.total_segments, speaker_count: response.speaker_count, + user_speaking_time: response.user_speaking_time, + attendee_speaking_time: response.attendee_speaking_time, + unknown_speaking_time: response.unknown_speaking_time, }) } diff --git a/client/src-tauri/src/grpc/client/assistant.rs b/client/src-tauri/src/grpc/client/assistant.rs index fba70f1..9eeb3b2 100644 --- a/client/src-tauri/src/grpc/client/assistant.rs +++ b/client/src-tauri/src/grpc/client/assistant.rs @@ -1,16 +1,20 @@ //! Assistant operations extension trait (Sprint 26) use tokio_stream::StreamExt; +use tonic::Request; use tracing::instrument; use crate::error::Result; use crate::events::AssistantChunkEvent; use crate::grpc::noteflow as pb; -use crate::grpc::types::assistant::AskAssistantResponse; +use crate::grpc::types::assistant::{AskAssistantResponse, AssistantConfig}; use crate::grpc::types::enums::AnnotationType; use super::core::GrpcClient; +const HEADER_AI_SUMMARY_KEY: &str = "x-ai-summary-key"; +const HEADER_AI_EMBEDDING_KEY: &str = "x-ai-embedding-key"; + fn convert_citation(proto: pb::SegmentCitationProto) -> crate::grpc::types::assistant::SegmentCitation { crate::grpc::types::assistant::SegmentCitation { meeting_id: proto.meeting_id, @@ -46,27 +50,46 @@ fn convert_chunk_to_event(chunk: pb::StreamAssistantChunk) -> AssistantChunkEven } } +fn add_ai_key_metadata( + request: &mut Request, + summary_api_key: &Option, + embedding_api_key: &Option, +) { + let metadata = request.metadata_mut(); + if let Some(key) = summary_api_key { + if !key.is_empty() { + if let Ok(value) = key.parse() { + metadata.insert(HEADER_AI_SUMMARY_KEY, value); + } + } + } + if let Some(key) = embedding_api_key { + if !key.is_empty() { + if let Ok(value) = key.parse() { + metadata.insert(HEADER_AI_EMBEDDING_KEY, value); + } + } + } +} + impl GrpcClient { - #[instrument(skip(self), fields(meeting_id = ?meeting_id, thread_id = ?thread_id))] + #[instrument(skip(self), fields(meeting_id = ?config.meeting_id, thread_id = ?config.thread_id))] pub async fn ask_assistant( &self, question: &str, - meeting_id: Option, - thread_id: Option, - allow_web: bool, - top_k: i32, + config: &AssistantConfig, ) -> Result { let mut client = self.get_client()?; - let response = client - .ask_assistant(pb::AskAssistantRequest { - question: question.to_string(), - meeting_id, - thread_id, - allow_web, - top_k, - }) - .await? - .into_inner(); + let mut request = Request::new(pb::AskAssistantRequest { + question: question.to_string(), + meeting_id: config.meeting_id.clone(), + thread_id: config.thread_id.clone(), + allow_web: config.allow_web, + top_k: config.top_k, + }); + add_ai_key_metadata(&mut request, &config.summary_api_key, &config.embedding_api_key); + + let response = client.ask_assistant(request).await?.into_inner(); Ok(AskAssistantResponse { answer: response.answer, @@ -80,30 +103,27 @@ impl GrpcClient { }) } - #[instrument(skip(self, on_chunk), fields(meeting_id = ?meeting_id, thread_id = ?thread_id))] + #[instrument(skip(self, on_chunk), fields(meeting_id = ?config.meeting_id, thread_id = ?config.thread_id))] pub async fn stream_assistant( &self, question: &str, - meeting_id: Option, - thread_id: Option, - allow_web: bool, - top_k: i32, + config: &AssistantConfig, mut on_chunk: F, ) -> Result<()> where F: FnMut(AssistantChunkEvent) + Send, { let mut client = self.get_client()?; - let mut stream = client - .stream_assistant(pb::AskAssistantRequest { - question: question.to_string(), - meeting_id, - thread_id, - allow_web, - top_k, - }) - .await? - .into_inner(); + let mut request = Request::new(pb::AskAssistantRequest { + question: question.to_string(), + meeting_id: config.meeting_id.clone(), + thread_id: config.thread_id.clone(), + allow_web: config.allow_web, + top_k: config.top_k, + }); + add_ai_key_metadata(&mut request, &config.summary_api_key, &config.embedding_api_key); + + let mut stream = client.stream_assistant(request).await?.into_inner(); while let Some(chunk_result) = stream.next().await { let chunk = chunk_result?; diff --git a/client/src-tauri/src/grpc/client/converters.rs b/client/src-tauri/src/grpc/client/converters.rs index 16ce788..04df4ce 100644 --- a/client/src-tauri/src/grpc/client/converters.rs +++ b/client/src-tauri/src/grpc/client/converters.rs @@ -3,11 +3,11 @@ use crate::grpc::noteflow as pb; use crate::grpc::types::calendar::{CalendarEvent, CalendarProvider}; use crate::grpc::types::core::{ - ActionItem, Annotation, KeyPoint, Meeting, Segment, ServerInfo, Summary, - SummarizationTemplate, SummarizationTemplateVersion, WordTiming, + ActionItem, Annotation, KeyPoint, Meeting, Segment, ServerInfo, SummarizationTemplate, + SummarizationTemplateVersion, Summary, WordTiming, }; use crate::grpc::types::enums::{ - AnnotationType, ExportFormat, MeetingState, Priority, ProjectRole, + AnnotationType, ExportFormat, MeetingState, Priority, ProjectRole, SpeakerRole, }; use crate::grpc::types::identity::WorkspaceSettings; use crate::grpc::types::projects::{ @@ -94,6 +94,7 @@ pub fn convert_segment(s: pb::FinalSegment) -> Segment { no_speech_prob: s.no_speech_prob, speaker_id: s.speaker_id, speaker_confidence: s.speaker_confidence, + speaker_role: SpeakerRole::from(s.speaker_role), } } diff --git a/client/src-tauri/src/grpc/client/meetings.rs b/client/src-tauri/src/grpc/client/meetings.rs index 435d2d4..39178f0 100644 --- a/client/src-tauri/src/grpc/client/meetings.rs +++ b/client/src-tauri/src/grpc/client/meetings.rs @@ -6,6 +6,7 @@ use tracing::instrument; use crate::error::Result; use crate::grpc::noteflow as pb; use crate::grpc::types::core::{ + CloudConsentStatus, GetSummarizationTemplateResult, ListMeetingsResponse, ListSummarizationTemplateVersionsResult, @@ -157,35 +158,60 @@ impl GrpcClient { Ok(info) } - /// Grant consent for cloud-based summarization. #[instrument(skip(self))] pub async fn grant_cloud_consent(&self) -> Result<()> { - let mut client = self.get_client()?; - client - .grant_cloud_consent(pb::GrantCloudConsentRequest {}) - .await?; - Ok(()) + self.grant_cloud_consent_feature("summary").await } - /// Revoke consent for cloud-based summarization. #[instrument(skip(self))] pub async fn revoke_cloud_consent(&self) -> Result<()> { + self.revoke_cloud_consent_feature("summary").await + } + + #[instrument(skip(self))] + pub async fn grant_cloud_consent_feature(&self, feature: &str) -> Result<()> { let mut client = self.get_client()?; + let proto_feature = match feature { + "transcription" => pb::CloudConsentFeature::Transcription, + "embedding" => pb::CloudConsentFeature::Embedding, + _ => pb::CloudConsentFeature::Summary, + }; client - .revoke_cloud_consent(pb::RevokeCloudConsentRequest {}) + .grant_cloud_consent(pb::GrantCloudConsentRequest { + feature: proto_feature as i32, + }) .await?; Ok(()) } - /// Get current cloud consent status. #[instrument(skip(self))] - pub async fn get_cloud_consent_status(&self) -> Result { + pub async fn revoke_cloud_consent_feature(&self, feature: &str) -> Result<()> { + let mut client = self.get_client()?; + let proto_feature = match feature { + "transcription" => pb::CloudConsentFeature::Transcription, + "embedding" => pb::CloudConsentFeature::Embedding, + _ => pb::CloudConsentFeature::Summary, + }; + client + .revoke_cloud_consent(pb::RevokeCloudConsentRequest { + feature: proto_feature as i32, + }) + .await?; + Ok(()) + } + + #[instrument(skip(self))] + pub async fn get_cloud_consent_status(&self) -> Result { let mut client = self.get_client()?; let response = client .get_cloud_consent_status(pb::GetCloudConsentStatusRequest {}) .await? .into_inner(); - Ok(response.consent_granted) + Ok(CloudConsentStatus { + transcription_consent: response.transcription_consent, + summary_consent: response.summary_consent, + embedding_consent: response.embedding_consent, + }) } // ----------------------------------------------------------------------- diff --git a/client/src-tauri/src/grpc/client/tasks.rs b/client/src-tauri/src/grpc/client/tasks.rs index c7c940e..20481b8 100644 --- a/client/src-tauri/src/grpc/client/tasks.rs +++ b/client/src-tauri/src/grpc/client/tasks.rs @@ -1,6 +1,6 @@ use crate::error::Result; use crate::grpc::noteflow as pb; -use crate::grpc::types::tasks::{ListTasksResult, Task, TaskStatus, TaskWithMeeting}; +use crate::grpc::types::tasks::{CreateTaskInput, ListTasksResult, Task, TaskStatus, TaskWithMeeting}; use super::core::GrpcClient; @@ -66,6 +66,33 @@ impl GrpcClient { .map(map_task) .ok_or_else(|| crate::error::Error::InvalidInput("Task not found".into())) } + + pub async fn create_task( + &self, + request: CreateTaskInput, + ) -> Result { + let mut client = self.get_client()?; + let response = client + .create_task(pb::CreateTaskRequest { + text: request.text, + meeting_id: request.meeting_id, + action_item_id: request.action_item_id.unwrap_or(0), + status: request + .status + .map(|s| string_to_task_status_proto(&s)) + .unwrap_or(0), + assignee_person_id: request.assignee_person_id.unwrap_or_default(), + due_date: request.due_date.unwrap_or(0.0), + priority: request.priority.unwrap_or(0), + }) + .await? + .into_inner(); + + response + .task + .map(map_task) + .ok_or_else(|| crate::error::Error::InvalidInput("Task not created".into())) + } } fn string_to_task_status_proto(s: &str) -> i32 { diff --git a/client/src-tauri/src/grpc/client_tests.rs b/client/src-tauri/src/grpc/client_tests.rs index d7b715a..de43d1c 100644 --- a/client/src-tauri/src/grpc/client_tests.rs +++ b/client/src-tauri/src/grpc/client_tests.rs @@ -8,7 +8,7 @@ mod tests { Annotation, Meeting, MeetingInfo, Segment, ServerInfo, WordTiming, }; use crate::grpc::types::enums::{ - AnnotationType, ExportFormat, JobStatus, MeetingState, Priority, UpdateType, + AnnotationType, ExportFormat, JobStatus, MeetingState, Priority, SpeakerRole, UpdateType, }; use crate::grpc::types::results::{AudioDeviceInfo, ExportResult, TimestampedAudio}; @@ -111,6 +111,7 @@ mod tests { no_speech_prob: 0.01, speaker_id: "SPEAKER_00".to_string(), speaker_confidence: 0.95, + speaker_role: SpeakerRole::Unspecified, words: vec![], }; @@ -286,9 +287,9 @@ mod tests { #[test] fn grpc_client_initial_state() { - use std::sync::Arc; use crate::grpc::{ConnectionState, GrpcClient}; use crate::identity::IdentityManager; + use std::sync::Arc; let identity = Arc::new(IdentityManager::new()); let client = GrpcClient::new("localhost:50051", identity); @@ -300,9 +301,9 @@ mod tests { #[test] fn grpc_client_endpoint_normalization() { - use std::sync::Arc; use crate::grpc::GrpcClient; use crate::identity::IdentityManager; + use std::sync::Arc; let identity = Arc::new(IdentityManager::new()); @@ -325,9 +326,9 @@ mod tests { #[test] fn grpc_client_empty_endpoint() { - use std::sync::Arc; use crate::grpc::GrpcClient; use crate::identity::IdentityManager; + use std::sync::Arc; let identity = Arc::new(IdentityManager::new()); @@ -345,17 +346,19 @@ mod tests { #[test] fn identity_interceptor_injects_required_headers() { - use std::sync::Arc; - use tonic::Request; - use tonic::service::Interceptor; use crate::grpc::client::IdentityInterceptor; use crate::identity::IdentityManager; + use std::sync::Arc; + use tonic::service::Interceptor; + use tonic::Request; let identity = Arc::new(IdentityManager::new()); let mut interceptor = IdentityInterceptor::new(identity); let request = Request::new(()); - let result = interceptor.call(request).expect("Interceptor should succeed"); + let result = interceptor + .call(request) + .expect("Interceptor should succeed"); let metadata = result.metadata(); assert!( @@ -374,18 +377,20 @@ mod tests { #[test] fn identity_interceptor_uses_local_defaults() { - use std::sync::Arc; - use tonic::Request; - use tonic::service::Interceptor; + use crate::constants::identity as identity_config; use crate::grpc::client::IdentityInterceptor; use crate::identity::IdentityManager; - use crate::constants::identity as identity_config; + use std::sync::Arc; + use tonic::service::Interceptor; + use tonic::Request; let identity = Arc::new(IdentityManager::new()); let mut interceptor = IdentityInterceptor::new(identity); let request = Request::new(()); - let result = interceptor.call(request).expect("Interceptor should succeed"); + let result = interceptor + .call(request) + .expect("Interceptor should succeed"); let metadata = result.metadata(); let user_id = metadata @@ -406,17 +411,19 @@ mod tests { #[test] fn identity_interceptor_omits_auth_when_not_authenticated() { - use std::sync::Arc; - use tonic::Request; - use tonic::service::Interceptor; use crate::grpc::client::IdentityInterceptor; use crate::identity::IdentityManager; + use std::sync::Arc; + use tonic::service::Interceptor; + use tonic::Request; let identity = Arc::new(IdentityManager::new()); let mut interceptor = IdentityInterceptor::new(identity); let request = Request::new(()); - let result = interceptor.call(request).expect("Interceptor should succeed"); + let result = interceptor + .call(request) + .expect("Interceptor should succeed"); let metadata = result.metadata(); assert!( @@ -427,18 +434,20 @@ mod tests { #[test] fn identity_interceptor_generates_unique_request_ids() { - use std::sync::Arc; - use tonic::Request; - use tonic::service::Interceptor; use crate::grpc::client::IdentityInterceptor; use crate::identity::IdentityManager; + use std::sync::Arc; + use tonic::service::Interceptor; + use tonic::Request; let identity = Arc::new(IdentityManager::new()); let mut interceptor = IdentityInterceptor::new(identity); // Make two requests let request1 = Request::new(()); - let result1 = interceptor.call(request1).expect("First call should succeed"); + let result1 = interceptor + .call(request1) + .expect("First call should succeed"); let id1 = result1 .metadata() .get("x-request-id") @@ -448,7 +457,9 @@ mod tests { .to_string(); let request2 = Request::new(()); - let result2 = interceptor.call(request2).expect("Second call should succeed"); + let result2 = interceptor + .call(request2) + .expect("Second call should succeed"); let id2 = result2 .metadata() .get("x-request-id") @@ -465,9 +476,9 @@ mod tests { #[test] fn identity_interceptor_debug_output() { - use std::sync::Arc; use crate::grpc::client::IdentityInterceptor; use crate::identity::IdentityManager; + use std::sync::Arc; let identity = Arc::new(IdentityManager::new()); let interceptor = IdentityInterceptor::new(identity); diff --git a/client/src-tauri/src/grpc/noteflow.rs b/client/src-tauri/src/grpc/noteflow.rs index 12c0f71..61850ec 100644 --- a/client/src-tauri/src/grpc/noteflow.rs +++ b/client/src-tauri/src/grpc/noteflow.rs @@ -19,6 +19,9 @@ pub struct AudioChunk { /// Sequence number for acknowledgment tracking (monotonically increasing per stream) #[prost(int64, tag = "6")] pub chunk_sequence: i64, + /// Dominant audio source for this chunk (mic/system/mixed) + #[prost(enumeration = "AudioSource", tag = "7")] + pub audio_source: i32, } /// Congestion information for backpressure signaling (Phase 3) #[derive(Clone, Copy, PartialEq, ::prost::Message)] @@ -92,6 +95,12 @@ pub struct FinalSegment { /// Speaker assignment confidence (0.0-1.0) #[prost(float, tag = "11")] pub speaker_confidence: f32, + /// Dominant audio source for this segment + #[prost(enumeration = "AudioSource", tag = "12")] + pub audio_source: i32, + /// User vs attendee classification based on voice profile matching + #[prost(enumeration = "SpeakerRole", tag = "13")] + pub speaker_role: i32, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct WordTiming { @@ -1376,20 +1385,35 @@ pub struct GetWebhookDeliveriesResponse { pub total_count: i32, } #[derive(Clone, Copy, PartialEq, ::prost::Message)] -pub struct GrantCloudConsentRequest {} +pub struct GrantCloudConsentRequest { + /// Feature to grant consent for. If unspecified, grants for summary (backward compatible). + #[prost(enumeration = "CloudConsentFeature", tag = "1")] + pub feature: i32, +} #[derive(Clone, Copy, PartialEq, ::prost::Message)] pub struct GrantCloudConsentResponse {} #[derive(Clone, Copy, PartialEq, ::prost::Message)] -pub struct RevokeCloudConsentRequest {} +pub struct RevokeCloudConsentRequest { + /// Feature to revoke consent for. If unspecified, revokes for summary (backward compatible). + #[prost(enumeration = "CloudConsentFeature", tag = "1")] + pub feature: i32, +} #[derive(Clone, Copy, PartialEq, ::prost::Message)] pub struct RevokeCloudConsentResponse {} #[derive(Clone, Copy, PartialEq, ::prost::Message)] pub struct GetCloudConsentStatusRequest {} #[derive(Clone, Copy, PartialEq, ::prost::Message)] pub struct GetCloudConsentStatusResponse { - /// Whether cloud consent is currently granted + /// Whether cloud consent is currently granted (DEPRECATED: use per-feature fields) #[prost(bool, tag = "1")] pub consent_granted: bool, + /// Per-feature consent status + #[prost(bool, tag = "2")] + pub transcription_consent: bool, + #[prost(bool, tag = "3")] + pub summary_consent: bool, + #[prost(bool, tag = "4")] + pub embedding_consent: bool, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct SetHuggingFaceTokenRequest { @@ -2455,6 +2479,28 @@ pub struct UpdateTaskRequest { pub priority: i32, } #[derive(Clone, PartialEq, ::prost::Message)] +pub struct CreateTaskRequest { + #[prost(string, tag = "1")] + pub text: ::prost::alloc::string::String, + #[prost(string, optional, tag = "2")] + pub meeting_id: ::core::option::Option<::prost::alloc::string::String>, + #[prost(int32, tag = "3")] + pub action_item_id: i32, + #[prost(enumeration = "TaskStatusProto", tag = "4")] + pub status: i32, + #[prost(string, tag = "5")] + pub assignee_person_id: ::prost::alloc::string::String, + #[prost(double, tag = "6")] + pub due_date: f64, + #[prost(int32, tag = "7")] + pub priority: i32, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CreateTaskResponse { + #[prost(message, optional, tag = "1")] + pub task: ::core::option::Option, +} +#[derive(Clone, PartialEq, ::prost::Message)] pub struct UpdateTaskResponse { #[prost(message, optional, tag = "1")] pub task: ::core::option::Option, @@ -2495,6 +2541,12 @@ pub struct GetAnalyticsOverviewResponse { pub total_segments: i32, #[prost(int32, tag = "6")] pub speaker_count: i32, + #[prost(double, tag = "7")] + pub user_speaking_time: f64, + #[prost(double, tag = "8")] + pub attendee_speaking_time: f64, + #[prost(double, tag = "9")] + pub unknown_speaking_time: f64, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct SpeakerStatProto { @@ -2703,6 +2755,67 @@ impl UpdateType { } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] +pub enum AudioSource { + Unspecified = 0, + Mic = 1, + System = 2, + Mixed = 3, +} +impl AudioSource { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "AUDIO_SOURCE_UNSPECIFIED", + Self::Mic => "AUDIO_SOURCE_MIC", + Self::System => "AUDIO_SOURCE_SYSTEM", + Self::Mixed => "AUDIO_SOURCE_MIXED", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "AUDIO_SOURCE_UNSPECIFIED" => Some(Self::Unspecified), + "AUDIO_SOURCE_MIC" => Some(Self::Mic), + "AUDIO_SOURCE_SYSTEM" => Some(Self::System), + "AUDIO_SOURCE_MIXED" => Some(Self::Mixed), + _ => None, + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum SpeakerRole { + Unspecified = 0, + User = 1, + Attendee = 2, +} +impl SpeakerRole { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "SPEAKER_ROLE_UNSPECIFIED", + Self::User => "SPEAKER_ROLE_USER", + Self::Attendee => "SPEAKER_ROLE_ATTENDEE", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "SPEAKER_ROLE_UNSPECIFIED" => Some(Self::Unspecified), + "SPEAKER_ROLE_USER" => Some(Self::User), + "SPEAKER_ROLE_ATTENDEE" => Some(Self::Attendee), + _ => None, + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] pub enum MeetingState { Unspecified = 0, /// Created but not started @@ -3064,6 +3177,39 @@ impl ProcessingStepStatus { } } } +/// Cloud consent feature types for per-feature consent management +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum CloudConsentFeature { + Unspecified = 0, + Transcription = 1, + Summary = 2, + Embedding = 3, +} +impl CloudConsentFeature { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "CLOUD_CONSENT_FEATURE_UNSPECIFIED", + Self::Transcription => "CLOUD_CONSENT_FEATURE_TRANSCRIPTION", + Self::Summary => "CLOUD_CONSENT_FEATURE_SUMMARY", + Self::Embedding => "CLOUD_CONSENT_FEATURE_EMBEDDING", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "CLOUD_CONSENT_FEATURE_UNSPECIFIED" => Some(Self::Unspecified), + "CLOUD_CONSENT_FEATURE_TRANSCRIPTION" => Some(Self::Transcription), + "CLOUD_CONSENT_FEATURE_SUMMARY" => Some(Self::Summary), + "CLOUD_CONSENT_FEATURE_EMBEDDING" => Some(Self::Embedding), + _ => None, + } + } +} /// Project role within a project (access control) #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] @@ -5253,6 +5399,30 @@ pub mod note_flow_service_client { .insert(GrpcMethod::new("noteflow.NoteFlowService", "ListTasks")); self.inner.unary(req, path, codec).await } + pub async fn create_task( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/CreateTask", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("noteflow.NoteFlowService", "CreateTask")); + self.inner.unary(req, path, codec).await + } pub async fn update_task( &mut self, request: impl tonic::IntoRequest, diff --git a/client/src-tauri/src/grpc/proto_compliance_tests.rs b/client/src-tauri/src/grpc/proto_compliance_tests.rs index db721bb..d96cb0a 100644 --- a/client/src-tauri/src/grpc/proto_compliance_tests.rs +++ b/client/src-tauri/src/grpc/proto_compliance_tests.rs @@ -321,6 +321,8 @@ mod tests { no_speech_prob: 0.01, speaker_id: "SPEAKER_00".into(), speaker_confidence: 0.95, + audio_source: pb::AudioSource::Unspecified as i32, + speaker_role: pb::SpeakerRole::Unspecified as i32, }; let o = types::Segment { segment_id: p.segment_id, @@ -334,6 +336,7 @@ mod tests { no_speech_prob: p.no_speech_prob, speaker_id: p.speaker_id.clone(), speaker_confidence: p.speaker_confidence, + speaker_role: types::SpeakerRole::from(p.speaker_role), }; assert_eq!(o.segment_id, p.segment_id); assert!((o.speaker_confidence - p.speaker_confidence).abs() < f32::EPSILON); @@ -509,6 +512,7 @@ mod tests { sample_rate: 16000, channels: 1, chunk_sequence: 42, + audio_source: pb::AudioSource::Unspecified as i32, }; let o = types::AudioChunk { meeting_id: p.meeting_id.clone(), @@ -558,6 +562,7 @@ mod tests { no_speech_prob: 0.0, speaker_id: String::new(), speaker_confidence: 0.0, + speaker_role: types::SpeakerRole::Unspecified, words: vec![], }; let _ = types::WordTiming { diff --git a/client/src-tauri/src/grpc/streaming/converters.rs b/client/src-tauri/src/grpc/streaming/converters.rs index c157516..c662529 100644 --- a/client/src-tauri/src/grpc/streaming/converters.rs +++ b/client/src-tauri/src/grpc/streaming/converters.rs @@ -1,5 +1,6 @@ use crate::grpc::noteflow as pb; use crate::grpc::types::core::{Segment, WordTiming}; +use crate::grpc::types::enums::SpeakerRole; pub(super) fn convert_segment(s: pb::FinalSegment) -> Segment { Segment { @@ -14,6 +15,7 @@ pub(super) fn convert_segment(s: pb::FinalSegment) -> Segment { no_speech_prob: s.no_speech_prob, speaker_id: s.speaker_id, speaker_confidence: s.speaker_confidence, + speaker_role: SpeakerRole::from(s.speaker_role), } } @@ -65,6 +67,8 @@ mod tests { no_speech_prob: 0.02, speaker_id: "SPEAKER_00".to_string(), speaker_confidence: 0.87, + audio_source: pb::AudioSource::Unspecified as i32, + speaker_role: pb::SpeakerRole::Unspecified as i32, }; let converted = convert_segment(segment); diff --git a/client/src-tauri/src/grpc/streaming/manager.rs b/client/src-tauri/src/grpc/streaming/manager.rs index d61788c..cbadcb6 100644 --- a/client/src-tauri/src/grpc/streaming/manager.rs +++ b/client/src-tauri/src/grpc/streaming/manager.rs @@ -96,6 +96,7 @@ pub struct AudioStreamChunk { pub timestamp: f64, pub sample_rate: i32, pub channels: i32, + pub audio_source: crate::grpc::types::enums::AudioSource, } impl StreamManager { @@ -127,6 +128,7 @@ impl StreamManager { meeting_id: String, app_handle: AppHandle, bootstrap_chunk: Option, + transcription_api_key: Option, ) -> Result> { // Atomically check and transition to Starting state to prevent race conditions. // Hold write lock during entire check-and-set operation. @@ -162,7 +164,7 @@ impl StreamManager { // Now we're guaranteed to be the only task setting up the stream. // If setup fails, we must reset state to Idle. let setup_result = self - .setup_streaming(meeting_id.clone(), app_handle, bootstrap_chunk) + .setup_streaming(meeting_id.clone(), app_handle, bootstrap_chunk, transcription_api_key) .await; match setup_result { @@ -192,6 +194,7 @@ impl StreamManager { meeting_id: String, app_handle: AppHandle, bootstrap_chunk: Option, + transcription_api_key: Option, ) -> Result> { let streaming_config = &config().streaming; let mut grpc_client = self.client.get_client().map_err(|err| { @@ -261,7 +264,14 @@ impl StreamManager { // Start the bidirectional stream WITHOUT a fixed timeout. // Long-running streams (15 min - 3+ hours) use keepalive pings for liveness, // and activity-based monitoring for detecting stale streams. - let request = tonic::Request::new(outbound); + let mut request = tonic::Request::new(outbound); + + if let Some(key) = transcription_api_key { + if let Ok(value) = key.parse::>() { + request.metadata_mut().insert("x-ai-transcription-key", value); + } + } + let response = grpc_client .stream_transcription(request) .await @@ -487,7 +497,3 @@ pub struct StreamStateInfo { /// Seconds since last activity (chunk sent/received) pub last_activity_secs_ago: Option, } - -#[cfg(test)] -#[path = "manager_tests.rs"] -mod tests; diff --git a/client/src-tauri/src/grpc/streaming/mod.rs b/client/src-tauri/src/grpc/streaming/mod.rs index d361f1c..d5a30d1 100644 --- a/client/src-tauri/src/grpc/streaming/mod.rs +++ b/client/src-tauri/src/grpc/streaming/mod.rs @@ -6,3 +6,6 @@ mod manager; mod stream_io; pub use manager::{AudioStreamChunk, StreamManager, StreamStateInfo}; + +#[cfg(test)] +mod manager_tests; diff --git a/client/src-tauri/src/grpc/streaming/stream_io.rs b/client/src-tauri/src/grpc/streaming/stream_io.rs index 8601c1e..1f3fef3 100644 --- a/client/src-tauri/src/grpc/streaming/stream_io.rs +++ b/client/src-tauri/src/grpc/streaming/stream_io.rs @@ -40,6 +40,7 @@ pub(super) fn create_audio_chunk( sample_rate, channels, chunk_sequence, + audio_source: i32::from(chunk.audio_source), } } diff --git a/client/src-tauri/src/grpc/types/analytics.rs b/client/src-tauri/src/grpc/types/analytics.rs index 027c855..37cf1c4 100644 --- a/client/src-tauri/src/grpc/types/analytics.rs +++ b/client/src-tauri/src/grpc/types/analytics.rs @@ -16,6 +16,9 @@ pub struct AnalyticsOverview { pub total_words: i32, pub total_segments: i32, pub speaker_count: i32, + pub user_speaking_time: f64, + pub attendee_speaking_time: f64, + pub unknown_speaking_time: f64, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/client/src-tauri/src/grpc/types/assistant.rs b/client/src-tauri/src/grpc/types/assistant.rs index 0579bcf..e3ff6fc 100644 --- a/client/src-tauri/src/grpc/types/assistant.rs +++ b/client/src-tauri/src/grpc/types/assistant.rs @@ -4,6 +4,30 @@ use serde::{Deserialize, Serialize}; use super::enums::AnnotationType; +/// Configuration for AI assistant requests +#[derive(Debug, Clone)] +pub struct AssistantConfig { + pub meeting_id: Option, + pub thread_id: Option, + pub allow_web: bool, + pub top_k: i32, + pub summary_api_key: Option, + pub embedding_api_key: Option, +} + +impl Default for AssistantConfig { + fn default() -> Self { + Self { + meeting_id: None, + thread_id: None, + allow_web: false, + top_k: 8, + summary_api_key: None, + embedding_api_key: None, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SegmentCitation { pub meeting_id: String, diff --git a/client/src-tauri/src/grpc/types/core.rs b/client/src-tauri/src/grpc/types/core.rs index ae8438c..28ef25e 100644 --- a/client/src-tauri/src/grpc/types/core.rs +++ b/client/src-tauri/src/grpc/types/core.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use crate::helpers::{new_id, now_timestamp}; -use super::enums::{AnnotationType, MeetingState, Priority, UpdateType}; +use super::enums::{AnnotationType, MeetingState, Priority, SpeakerRole, UpdateType}; // ============================================================================ // Server Info @@ -189,6 +189,7 @@ pub struct Segment { pub no_speech_prob: f32, pub speaker_id: String, pub speaker_confidence: f32, + pub speaker_role: SpeakerRole, pub words: Vec, } @@ -336,3 +337,14 @@ pub struct ListMeetingsResponse { pub meetings: Vec, pub total_count: i32, } + +// ============================================================================ +// Cloud Consent Status +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CloudConsentStatus { + pub transcription_consent: bool, + pub summary_consent: bool, + pub embedding_consent: bool, +} diff --git a/client/src-tauri/src/grpc/types/enums.rs b/client/src-tauri/src/grpc/types/enums.rs index ae350e5..a15c71e 100644 --- a/client/src-tauri/src/grpc/types/enums.rs +++ b/client/src-tauri/src/grpc/types/enums.rs @@ -199,6 +199,68 @@ impl From for i32 { } } +// ============================================================================ +// Audio Source +// ============================================================================ + +/// Audio source for a chunk or segment (matches proto enum) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum AudioSource { + #[default] + Unspecified = 0, + Mic = 1, + System = 2, + Mixed = 3, +} + +impl From for AudioSource { + fn from(value: i32) -> Self { + match value { + 1 => Self::Mic, + 2 => Self::System, + 3 => Self::Mixed, + _ => Self::Unspecified, + } + } +} + +impl From for i32 { + fn from(source: AudioSource) -> Self { + source as i32 + } +} + +// ============================================================================ +// Speaker Role +// ============================================================================ + +/// Speaker role classification (matches proto enum) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum SpeakerRole { + #[default] + Unspecified = 0, + User = 1, + Attendee = 2, +} + +impl From for SpeakerRole { + fn from(value: i32) -> Self { + match value { + 1 => Self::User, + 2 => Self::Attendee, + _ => Self::Unspecified, + } + } +} + +impl From for i32 { + fn from(role: SpeakerRole) -> Self { + role as i32 + } +} + // ============================================================================ // Workspace Role // ============================================================================ diff --git a/client/src-tauri/src/grpc/types/tasks.rs b/client/src-tauri/src/grpc/types/tasks.rs index 70b1062..97122d2 100644 --- a/client/src-tauri/src/grpc/types/tasks.rs +++ b/client/src-tauri/src/grpc/types/tasks.rs @@ -27,6 +27,23 @@ pub struct Task { pub completed_at: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateTaskInput { + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub meeting_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action_item_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assignee_person_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub due_date: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub priority: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaskWithMeeting { pub task: Task, diff --git a/client/src-tauri/src/lib.rs b/client/src-tauri/src/lib.rs index 878f33b..49aa2e5 100644 --- a/client/src-tauri/src/lib.rs +++ b/client/src-tauri/src/lib.rs @@ -95,6 +95,8 @@ macro_rules! app_invoke_handler { commands::restore_summarization_template_version, commands::grant_cloud_consent, commands::revoke_cloud_consent, + commands::grant_cloud_consent_feature, + commands::revoke_cloud_consent_feature, commands::get_cloud_consent_status, // Export (2 commands) commands::export_transcript, @@ -198,8 +200,9 @@ macro_rules! app_invoke_handler { commands::reset_test_recording_state, commands::inject_test_audio, commands::inject_test_tone, - // Tasks (2 commands) - Bugfinder Sprint + // Tasks (3 commands) - Bugfinder Sprint commands::list_tasks, + commands::create_task, commands::update_task, // Analytics (3 commands) - Bugfinder Sprint commands::get_analytics_overview, diff --git a/client/src-tauri/src/state/mod.rs b/client/src-tauri/src/state/mod.rs index d2d8137..4e450fb 100644 --- a/client/src-tauri/src/state/mod.rs +++ b/client/src-tauri/src/state/mod.rs @@ -29,7 +29,7 @@ pub use preferences::{ AppMatcher, AppMatcherKind, AppMatcherOs, AudioConfig, AudioDevicePrefs, RecordingAppPolicy, RecordingAppRule, UserPreferences, }; -pub use recording_types::{AudioSamplesChunk, RecordingSession}; +pub use recording_types::{AudioSamplesChunk, AudioSource, RecordingSession}; pub use shutdown::ShutdownManager; pub use status::{AppStatus, PlaybackInfo, TriggerStatus}; pub use trigger_types::{PendingTrigger, TriggerDecision, TriggerSignal, TriggerState}; diff --git a/client/src-tauri/src/state/recording_types.rs b/client/src-tauri/src/state/recording_types.rs index 7121020..bb03024 100644 --- a/client/src-tauri/src/state/recording_types.rs +++ b/client/src-tauri/src/state/recording_types.rs @@ -1,5 +1,7 @@ //! Recording session types. +pub use crate::grpc::types::enums::AudioSource; + /// Audio samples with metadata for streaming. #[derive(Debug, Clone)] pub struct AudioSamplesChunk { @@ -7,6 +9,7 @@ pub struct AudioSamplesChunk { pub timestamp: f64, pub sample_rate: u32, pub channels: u16, + pub audio_source: AudioSource, } /// Active recording session state diff --git a/client/src/App.tsx b/client/src/App.tsx index fcd486a..85f3c05 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -14,6 +14,7 @@ import { ProjectProvider } from '@/contexts/project-context'; import { useProjects } from '@/contexts/project-state'; import { WorkspaceProvider } from '@/contexts/workspace-context'; import { useWorkspace } from '@/contexts/workspace-state'; +import { THIRTY_SECONDS_MS } from '@/lib/constants/timing'; import AnalyticsPage from '@/pages/Analytics'; import HomePage from '@/pages/Home'; import MeetingDetailPage from '@/pages/MeetingDetail'; @@ -26,7 +27,15 @@ import SettingsPage from '@/pages/Settings'; import TasksPage from '@/pages/Tasks'; import NotFound from './pages/NotFound'; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: THIRTY_SECONDS_MS, + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}); function NavigateExposer() { const navigate = useNavigate(); diff --git a/client/src/api/adapters/cached/templates.ts b/client/src/api/adapters/cached/templates.ts index 22b2323..95bc875 100644 --- a/client/src/api/adapters/cached/templates.ts +++ b/client/src/api/adapters/cached/templates.ts @@ -2,6 +2,8 @@ import { emptyResponses } from '../../core/helpers'; import type { NoteFlowAPI } from '../../interface'; import type { ArchiveSummarizationTemplateRequest, + CloudConsentFeature, + CloudConsentStatus, CreateSummarizationTemplateRequest, GetSummarizationTemplateRequest, GetSummarizationTemplateResponse, @@ -27,6 +29,8 @@ export const cachedTemplatesAPI: Pick< | 'restoreSummarizationTemplateVersion' | 'grantCloudConsent' | 'revokeCloudConsent' + | 'grantCloudConsentFeature' + | 'revokeCloudConsentFeature' | 'getCloudConsentStatus' > = { async listSummarizationTemplates( @@ -79,7 +83,20 @@ export const cachedTemplatesAPI: Pick< return rejectReadOnly(); }, - async getCloudConsentStatus(): Promise<{ consentGranted: boolean }> { - return { consentGranted: false }; + async grantCloudConsentFeature(_feature: CloudConsentFeature): Promise { + return rejectReadOnly(); + }, + + async revokeCloudConsentFeature(_feature: CloudConsentFeature): Promise { + return rejectReadOnly(); + }, + + async getCloudConsentStatus(): Promise { + return { + consentGranted: false, + transcriptionConsent: false, + summaryConsent: false, + embeddingConsent: false, + }; }, }; diff --git a/client/src/api/adapters/mock/index.test.ts b/client/src/api/adapters/mock/index.test.ts index fa0ccaf..2048494 100644 --- a/client/src/api/adapters/mock/index.test.ts +++ b/client/src/api/adapters/mock/index.test.ts @@ -719,7 +719,11 @@ describe('mockAPI', () => { }; const created = await run(mockAPI.createSummarizationTemplate(createRequest)); expect(created.template.name).toBe('Custom Template'); - expect(created.version.version_number).toBe(1); + if (!created.version) { + throw new Error('Expected template version'); + } + const createdVersion = created.version; + expect(createdVersion.version_number).toBe(1); const fullTemplate = await run( mockAPI.getSummarizationTemplate({ @@ -727,7 +731,7 @@ describe('mockAPI', () => { include_current_version: true, }) ); - expect(fullTemplate.current_version?.id).toBe(created.version.id); + expect(fullTemplate.current_version?.id).toBe(createdVersion.id); const withoutVersion = await run( mockAPI.getSummarizationTemplate({ @@ -754,10 +758,10 @@ describe('mockAPI', () => { const restoreRequest: RestoreSummarizationTemplateVersionRequest = { template_id: created.template.id, - version_id: created.version.id, + version_id: createdVersion.id, }; const restored = await run(mockAPI.restoreSummarizationTemplateVersion(restoreRequest)); - expect(restored.current_version_id).toBe(created.version.id); + expect(restored.current_version_id).toBe(createdVersion.id); const archiveRequest: ArchiveSummarizationTemplateRequest = { template_id: created.template.id, @@ -933,9 +937,14 @@ describe('mockAPI', () => { const tokenValidation = await run(mockAPI.validateHuggingFaceToken()); expect(tokenValidation.valid).toBe(false); - const tasks = await run(mockAPI.listTasks()); + const tasks = await run(mockAPI.listTasks({})); expect(tasks.total_count).toBe(0); + const createdTask = await run( + mockAPI.createTask({ text: 'New task', meeting_id: 'meeting-1' }) + ); + expect(createdTask.text).toBe('New task'); + const updateTaskRequest: UpdateTaskRequest = { task_id: 'task-1', text: 'Follow up', @@ -944,13 +953,13 @@ describe('mockAPI', () => { const updatedTask = await run(mockAPI.updateTask(updateTaskRequest)); expect(updatedTask.status).toBe('done'); - const overview = await run(mockAPI.getAnalyticsOverview()); + const overview = await run(mockAPI.getAnalyticsOverview({ start_time: 0, end_time: 1 })); expect(overview.total_meetings).toBe(0); - const speakerStats = await run(mockAPI.listSpeakerStats()); + const speakerStats = await run(mockAPI.listSpeakerStats({ start_time: 0, end_time: 1 })); expect(speakerStats.speakers).toHaveLength(0); - const entityAnalytics = await run(mockAPI.getEntityAnalytics()); + const entityAnalytics = await run(mockAPI.getEntityAnalytics({ start_time: 0, end_time: 1 })); expect(entityAnalytics.total_entities).toBe(0); }); @@ -1019,7 +1028,14 @@ describe('mockAPI', () => { const originalListAudioDevices = mockAPI.listAudioDevices; mockAPI.listAudioDevices = async () => [ - { id: 'input-1', name: 'Mic', is_input: true, is_output: false }, + { + id: 'input-1', + name: 'Mic', + is_input: true, + is_output: false, + is_default: true, + sample_rates: [48000], + }, ]; const env = await run(mockAPI.checkTestEnvironment()); expect(env.hasInputDevices).toBe(true); @@ -1081,7 +1097,11 @@ describe('mockAPI', () => { await flushTimers(); await missingOidcExpectation; - const updateMissingOidc = mockAPI.updateOidcProvider({ provider_id: 'missing' }); + const updateMissingOidc = mockAPI.updateOidcProvider({ + provider_id: 'missing', + scopes: [], + allowed_groups: [], + }); const updateMissingOidcExpectation = expect(updateMissingOidc).rejects.toThrow('OIDC provider not found'); await flushTimers(); await updateMissingOidcExpectation; diff --git a/client/src/api/adapters/mock/index.ts b/client/src/api/adapters/mock/index.ts index 9e165d4..db3ee3d 100644 --- a/client/src/api/adapters/mock/index.ts +++ b/client/src/api/adapters/mock/index.ts @@ -5,10 +5,11 @@ import { preferences } from '@/lib/preferences'; import { formatDateTime, formatTime, formatTimestamp } from '@/lib/utils/format'; import { IdentityDefaults, OidcDocsUrls, Placeholders, Timing } from '../../core/constants'; import { delay, emptyResponses, paginate } from '../../core/helpers'; -import type { NoteFlowAPI } from '../../interface'; +import type { NoteFlowAPI, TranscriptionStream } from '../../interface'; import type { AddAnnotationRequest, AddProjectMemberRequest, + AnalyticsOverviewRequest, AnalyticsOverview, Annotation, ArchiveSummarizationTemplateRequest, @@ -18,17 +19,22 @@ import type { AskAssistantResponse, AudioDeviceInfo, CancelDiarizationResult, + CloudConsentFeature, + CloudConsentStatus, CompleteAuthLoginResponse, CompleteCalendarAuthResponse, ConnectionDiagnostics, CreateMeetingRequest, CreateProjectRequest, + CreateTaskRequest, CreateSummarizationTemplateRequest, DeleteOidcProviderResponse, DeleteWebhookResponse, DiarizationJobStatus, DisconnectOAuthResponse, + DualCaptureConfigInfo, EffectiveServerUrl, + EntityAnalyticsRequest, EntityAnalytics, ExportFormat, ExportResult, @@ -67,12 +73,14 @@ import type { ListProjectMembersResponse, ListProjectsRequest, ListProjectsResponse, + ListSpeakerStatsRequest, ListSpeakerStatsResponse, ListSummarizationTemplatesRequest, ListSummarizationTemplatesResponse, ListSummarizationTemplateVersionsRequest, ListSummarizationTemplateVersionsResponse, ListSyncHistoryResponse, + ListTasksRequest, ListTasksResponse, ListWebhooksResponse, ListWorkspacesResponse, @@ -81,6 +89,7 @@ import type { LogoutResponse, LogSource, Meeting, + NerEntityCategory, OidcProviderApi, PerformanceMetricsPoint, PlaybackInfo, @@ -149,7 +158,11 @@ const summarizationTemplates: Map = new Map(); const summarizationTemplateVersions: Map = new Map(); const workspaceSettingsById: Map = new Map(); let isInitialized = false; -let cloudConsentGranted = false; +const cloudConsentFeatures = { + transcription: false, + summary: false, + embedding: false, +}; const MEMORY_VARIANCE_MB = 2 * 1000; const mockPlayback: PlaybackInfo = { meeting_id: undefined, @@ -401,6 +414,13 @@ const mergeWorkspaceSettings = ( return next; }; +const dualCaptureConfig: DualCaptureConfigInfo = { + system_device_id: null, + dual_capture_enabled: false, + mic_gain: 1, + system_gain: 1, +}; + export const mockAPI: NoteFlowAPI = { async getServerInfo(): Promise { await delay(100); @@ -415,6 +435,8 @@ export const mockAPI: NoteFlowAPI = { const prefs = preferences.get(); return { url: `${prefs.server_host}:${prefs.server_port}`, + host: prefs.server_host, + port: String(prefs.server_port), source: 'default', }; }, @@ -1033,17 +1055,32 @@ export const mockAPI: NoteFlowAPI = { async grantCloudConsent(): Promise { await delay(100); - cloudConsentGranted = true; + cloudConsentFeatures.summary = true; }, async revokeCloudConsent(): Promise { await delay(100); - cloudConsentGranted = false; + cloudConsentFeatures.summary = false; }, - async getCloudConsentStatus(): Promise<{ consentGranted: boolean }> { + async grantCloudConsentFeature(feature: CloudConsentFeature): Promise { + await delay(100); + cloudConsentFeatures[feature] = true; + }, + + async revokeCloudConsentFeature(feature: CloudConsentFeature): Promise { + await delay(100); + cloudConsentFeatures[feature] = false; + }, + + async getCloudConsentStatus(): Promise { await delay(50); - return { consentGranted: cloudConsentGranted }; + return { + consentGranted: cloudConsentFeatures.summary, + transcriptionConsent: cloudConsentFeatures.transcription, + summaryConsent: cloudConsentFeatures.summary, + embeddingConsent: cloudConsentFeatures.embedding, + }; }, async listAnnotations( @@ -1228,12 +1265,28 @@ export const mockAPI: NoteFlowAPI = { async listAudioDevices(): Promise { return []; }, + async listLoopbackDevices(): Promise { + return []; + }, async getDefaultAudioDevice(_isInput: boolean): Promise { return null; }, async selectAudioDevice(deviceId: string, isInput: boolean): Promise { preferences.setAudioDevice(isInput ? 'input' : 'output', deviceId); }, + async setSystemAudioDevice(deviceId: string | null): Promise { + dualCaptureConfig.system_device_id = deviceId; + }, + async setDualCaptureEnabled(enabled: boolean): Promise { + dualCaptureConfig.dual_capture_enabled = enabled; + }, + async setAudioMixLevels(micGain: number, systemGain: number): Promise { + dualCaptureConfig.mic_gain = micGain; + dualCaptureConfig.system_gain = systemGain; + }, + async getDualCaptureConfig(): Promise { + return { ...dualCaptureConfig }; + }, async checkTestEnvironment(): Promise { const devices = await mockAPI.listAudioDevices(); const inputDevices = devices.filter((device) => device.is_input).map((device) => device.name); @@ -1437,7 +1490,7 @@ export const mockAPI: NoteFlowAPI = { _meetingId: string, entityId: string, text?: string, - category?: string + category?: NerEntityCategory ): Promise { await delay(100); return { @@ -1472,7 +1525,6 @@ export const mockAPI: NoteFlowAPI = { status: 'success', items_synced: Math.floor(Math.random() * 50) + 10, items_total: 0, - error_message: '', duration_ms: Math.floor(Math.random() * Timing.TWO_SECONDS_MS) + 500, }; }, @@ -1543,7 +1595,7 @@ export const mockAPI: NoteFlowAPI = { level, source, message: messages[Math.floor(Math.random() * messages.length)], - details: i % 3 === 0 ? { request_id: `req-${i}` } : undefined, + details: i % 3 === 0 ? { request_id: `req-${i}` } : {}, trace_id: traceId, span_id: spanId, }; @@ -2028,11 +2080,26 @@ export const mockAPI: NoteFlowAPI = { }; }, - async listTasks(): Promise { + async listTasks(_request: ListTasksRequest): Promise { await delay(100); return { tasks: [], total_count: 0 }; }, + async createTask(request: CreateTaskRequest): Promise { + await delay(100); + return { + id: `task-${Math.random().toString(36).slice(2, 10)}`, + meeting_id: request.meeting_id ?? null, + action_item_id: request.action_item_id ?? null, + text: request.text, + status: request.status ?? 'open', + assignee_person_id: request.assignee_person_id ?? null, + due_date: request.due_date ?? null, + priority: request.priority ?? 0, + completed_at: null, + }; + }, + async updateTask(request: UpdateTaskRequest): Promise { await delay(100); return { @@ -2048,7 +2115,7 @@ export const mockAPI: NoteFlowAPI = { }; }, - async getAnalyticsOverview(): Promise { + async getAnalyticsOverview(_request: AnalyticsOverviewRequest): Promise { await delay(100); return { daily: [], @@ -2057,15 +2124,18 @@ export const mockAPI: NoteFlowAPI = { total_words: 0, total_segments: 0, speaker_count: 0, + user_speaking_time: 0, + attendee_speaking_time: 0, + unknown_speaking_time: 0, }; }, - async listSpeakerStats(): Promise { + async listSpeakerStats(_request: ListSpeakerStatsRequest): Promise { await delay(100); return { speakers: [] }; }, - async getEntityAnalytics(): Promise { + async getEntityAnalytics(_request: EntityAnalyticsRequest): Promise { await delay(100); return { by_category: [], diff --git a/client/src/api/adapters/tauri/constants.ts b/client/src/api/adapters/tauri/constants.ts index d2cbe57..64412af 100644 --- a/client/src/api/adapters/tauri/constants.ts +++ b/client/src/api/adapters/tauri/constants.ts @@ -51,6 +51,8 @@ export const TauriCommands = { RESTORE_SUMMARIZATION_TEMPLATE_VERSION: 'restore_summarization_template_version', GRANT_CLOUD_CONSENT: 'grant_cloud_consent', REVOKE_CLOUD_CONSENT: 'revoke_cloud_consent', + GRANT_CLOUD_CONSENT_FEATURE: 'grant_cloud_consent_feature', + REVOKE_CLOUD_CONSENT_FEATURE: 'revoke_cloud_consent_feature', GET_CLOUD_CONSENT_STATUS: 'get_cloud_consent_status', LIST_ANNOTATIONS: 'list_annotations', GET_ANNOTATION: 'get_annotation', @@ -146,6 +148,7 @@ export const TauriCommands = { INJECT_TEST_TONE: 'inject_test_tone', // Tasks (Bugfinder Sprint) LIST_TASKS: 'list_tasks', + CREATE_TASK: 'create_task', UPDATE_TASK: 'update_task', // Analytics (Bugfinder Sprint) GET_ANALYTICS_OVERVIEW: 'get_analytics_overview', diff --git a/client/src/api/adapters/tauri/sections/meetings.test.ts b/client/src/api/adapters/tauri/sections/meetings.test.ts index b5feb93..a94afa5 100644 --- a/client/src/api/adapters/tauri/sections/meetings.test.ts +++ b/client/src/api/adapters/tauri/sections/meetings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { TauriCommands } from '../constants'; +import type { TauriInvoke } from '../types'; const meetingCacheState = vi.hoisted(() => ({ cacheMeeting: vi.fn(), @@ -39,8 +40,13 @@ const errorState = vi.hoisted(() => ({ const streamState = vi.hoisted(() => { const createStream = vi.fn<(...args: unknown[]) => unknown>(); - function MockStream(...args: unknown[]) { - return createStream(...args); + class MockStream { + constructor(...args: unknown[]) { + const stream = createStream(...args); + if (stream && typeof stream === 'object') { + Object.assign(this, stream); + } + } } return { createStream, @@ -129,7 +135,7 @@ describe('createMeetingApi', () => { states: ['completed'], limit: 1, offset: 0, - sort_order: 'desc', + sort_order: 'newest', project_ids: ['p1'], include_segments: true, }); @@ -139,7 +145,7 @@ describe('createMeetingApi', () => { states: ['completed'], limit: 1, offset: 0, - sort_order: 'desc', + sort_order: 'newest', project_id: null, project_ids: ['p1'], include_segments: true, @@ -205,7 +211,7 @@ describe('createMeetingApi', () => { const api = createMeetingApi(invoke, listen); const result = await api.startTranscription('m1'); - expect(result).toBe(stream); + expect(result).toMatchObject(stream); expect(streamState.createStream).toHaveBeenCalledWith('m1', invoke, listen); }); @@ -260,24 +266,38 @@ describe('createMeetingApi', () => { }); it('generates summaries using preference templates', async () => { - const invoke = vi.fn((command: string) => { + const invoke = vi.fn(); + const invokeImpl: TauriInvoke = async ( + command: string, + args?: Record + ) => { + invoke(command, args); if (command === TauriCommands.GET_PREFERENCES) { - return Promise.resolve({ + const preferences = { ai_template: { tone: 'formal', format: 'bullet', verbosity: 'short' }, - }); + }; + return Promise.resolve(preferences) as Promise; } if (command === TauriCommands.GENERATE_SUMMARY) { - return Promise.resolve({ id: 's1', model_version: 'v1' }); + const summary = { + meeting_id: 'm1', + model_version: 'v1', + executive_summary: '', + key_points: [], + action_items: [], + generated_at: 0, + }; + return Promise.resolve(summary) as Promise; } - return Promise.resolve(null); - }); + return Promise.resolve(null as T); + }; const listen = vi.fn(); const { createMeetingApi } = await loadMeetingApi(); - const api = createMeetingApi(invoke, listen); + const api = createMeetingApi(invokeImpl, listen); const summary = await api.generateSummary('m1', true); - expect(summary.id).toBe('s1'); + expect(summary.meeting_id).toBe('m1'); expect(clientLogState.summarizing).toHaveBeenCalledWith('m1'); expect(clientLogState.summaryGenerated).toHaveBeenCalledWith('m1', 'v1'); expect(invoke).toHaveBeenCalledWith(TauriCommands.GENERATE_SUMMARY, { @@ -288,16 +308,21 @@ describe('createMeetingApi', () => { }); it('logs summary failures', async () => { - const invoke = vi.fn((command: string) => { + const invoke = vi.fn(); + const invokeImpl: TauriInvoke = async ( + command: string, + args?: Record + ) => { + invoke(command, args); if (command === TauriCommands.GET_PREFERENCES) { - return Promise.reject(new Error('prefs down')); + return Promise.reject(new Error('prefs down')) as Promise; } - return Promise.reject(new Error('fail')); - }); + return Promise.reject(new Error('fail')) as Promise; + }; const listen = vi.fn(); const { createMeetingApi } = await loadMeetingApi(); - const api = createMeetingApi(invoke, listen); + const api = createMeetingApi(invokeImpl, listen); await expect(api.generateSummary('m1')).rejects.toThrow('fail'); expect(clientLogState.summaryFailed).toHaveBeenCalledWith('m1', 'summary failed'); diff --git a/client/src/api/adapters/tauri/sections/meetings.ts b/client/src/api/adapters/tauri/sections/meetings.ts index 8a444bf..6cf56e3 100644 --- a/client/src/api/adapters/tauri/sections/meetings.ts +++ b/client/src/api/adapters/tauri/sections/meetings.ts @@ -29,6 +29,15 @@ import { recordingBlockedDetails, RECORDING_BLOCKED_PREFIX, } from '../utils'; +import { getSecureValue, isSecureStorageAvailable } from '@/lib/storage/crypto'; + +async function getTranscriptionKey(): Promise { + if (!isSecureStorageAvailable()) { + return undefined; + } + const key = await getSecureValue('transcription_api_key'); + return key || undefined; +} export function createMeetingApi( invoke: TauriInvoke, @@ -103,7 +112,11 @@ export function createMeetingApi( async startTranscription(meetingId: string): Promise { try { - await invoke(TauriCommands.START_RECORDING, { meeting_id: meetingId }); + const transcriptionKey = await getTranscriptionKey(); + await invoke(TauriCommands.START_RECORDING, { + meeting_id: meetingId, + transcription_api_key: transcriptionKey, + }); return new TauriTranscriptionStream(meetingId, invoke, listen); } catch (error) { const details = extractErrorDetails(error, 'Failed to start recording'); diff --git a/client/src/api/adapters/tauri/sections/sections.test.ts b/client/src/api/adapters/tauri/sections/sections.test.ts index 511545d..de5a4f1 100644 --- a/client/src/api/adapters/tauri/sections/sections.test.ts +++ b/client/src/api/adapters/tauri/sections/sections.test.ts @@ -62,12 +62,15 @@ describe('tauri section APIs', () => { it('maps task commands', async () => { const invoke = vi.fn(); invoke.mockResolvedValueOnce({ tasks: [], total_count: 0 }); + invoke.mockResolvedValueOnce({ task: { id: 't2' } }); invoke.mockResolvedValueOnce({ task: { id: 't1' } }); const api = createTaskApi(invoke); await api.listTasks({ statuses: ['open'], limit: 10, offset: 0 }); + const created = await api.createTask({ text: 'New task', meeting_id: 'm1' }); const updated = await api.updateTask({ task_id: 't1', text: 'hi', status: 'open' }); + expect(created).toEqual({ id: 't2' }); expect(updated).toEqual({ id: 't1' }); expect(invoke).toHaveBeenCalledWith(TauriCommands.LIST_TASKS, { statuses: ['open'], @@ -77,6 +80,17 @@ describe('tauri section APIs', () => { project_ids: undefined, meeting_id: undefined, }); + expect(invoke).toHaveBeenCalledWith(TauriCommands.CREATE_TASK, { + request: { + text: 'New task', + meeting_id: 'm1', + action_item_id: undefined, + status: undefined, + assignee_person_id: undefined, + due_date: undefined, + priority: undefined, + }, + }); expect(invoke).toHaveBeenCalledWith(TauriCommands.UPDATE_TASK, { task_id: 't1', text: 'hi', diff --git a/client/src/api/adapters/tauri/sections/summarization.ts b/client/src/api/adapters/tauri/sections/summarization.ts index 8fe4164..d08c957 100644 --- a/client/src/api/adapters/tauri/sections/summarization.ts +++ b/client/src/api/adapters/tauri/sections/summarization.ts @@ -2,6 +2,8 @@ import type { ArchiveSummarizationTemplateRequest, AskAssistantRequest, AskAssistantResponse, + CloudConsentFeature, + CloudConsentStatus, CreateSummarizationTemplateRequest, GetSummarizationTemplateRequest, GetSummarizationTemplateResponse, @@ -18,6 +20,21 @@ import type { NoteFlowAPI } from '../../../interface'; import { TauriCommands } from '../constants'; import { clientLog } from '@/lib/observability/events'; import type { TauriInvoke } from '../types'; +import { getSecureValue, isSecureStorageAvailable } from '@/lib/storage/crypto'; + +async function getAIKeys(): Promise<{ summaryKey?: string; embeddingKey?: string }> { + if (!isSecureStorageAvailable()) { + return {}; + } + const [summaryKey, embeddingKey] = await Promise.all([ + getSecureValue('summary_api_key'), + getSecureValue('embedding_api_key'), + ]); + return { + summaryKey: summaryKey || undefined, + embeddingKey: embeddingKey || undefined, + }; +} export function createSummarizationApi(invoke: TauriInvoke): Pick< NoteFlowAPI, @@ -30,6 +47,8 @@ export function createSummarizationApi(invoke: TauriInvoke): Pick< | 'restoreSummarizationTemplateVersion' | 'grantCloudConsent' | 'revokeCloudConsent' + | 'grantCloudConsentFeature' + | 'revokeCloudConsentFeature' | 'getCloudConsentStatus' | 'askAssistant' | 'streamAssistant' @@ -127,29 +146,57 @@ export function createSummarizationApi(invoke: TauriInvoke): Pick< await invoke(TauriCommands.REVOKE_CLOUD_CONSENT); clientLog.cloudConsentRevoked(); }, - async getCloudConsentStatus(): Promise<{ consentGranted: boolean }> { - return invoke<{ consent_granted: boolean }>(TauriCommands.GET_CLOUD_CONSENT_STATUS).then( - (r) => ({ consentGranted: r.consent_granted }) - ); + async grantCloudConsentFeature(feature: CloudConsentFeature): Promise { + await invoke(TauriCommands.GRANT_CLOUD_CONSENT_FEATURE, { feature }); + }, + async revokeCloudConsentFeature(feature: CloudConsentFeature): Promise { + await invoke(TauriCommands.REVOKE_CLOUD_CONSENT_FEATURE, { feature }); + }, + async getCloudConsentStatus(): Promise { + interface RawConsentStatus { + consent_granted: boolean; + transcription_consent?: boolean; + summary_consent?: boolean; + embedding_consent?: boolean; + } + return invoke(TauriCommands.GET_CLOUD_CONSENT_STATUS).then((r) => { + const consentGranted = r.consent_granted ?? false; + return { + consentGranted, + transcriptionConsent: r.transcription_consent ?? consentGranted, + summaryConsent: r.summary_consent ?? consentGranted, + embeddingConsent: r.embedding_consent ?? consentGranted, + }; + }); }, async askAssistant(request: AskAssistantRequest): Promise { + const { summaryKey, embeddingKey } = await getAIKeys(); return invoke(TauriCommands.ASK_ASSISTANT, { question: request.question, - meeting_id: request.meeting_id, - thread_id: request.thread_id, - allow_web: request.allow_web ?? false, - top_k: request.top_k ?? 8, + config: { + meeting_id: request.meeting_id, + thread_id: request.thread_id, + allow_web: request.allow_web ?? false, + top_k: request.top_k ?? 8, + summary_api_key: summaryKey, + embedding_api_key: embeddingKey, + }, }); }, async streamAssistant(request: AskAssistantRequest): Promise { + const { summaryKey, embeddingKey } = await getAIKeys(); return invoke(TauriCommands.STREAM_ASSISTANT, { question: request.question, - meeting_id: request.meeting_id, - thread_id: request.thread_id, - allow_web: request.allow_web ?? false, - top_k: request.top_k ?? 8, + config: { + meeting_id: request.meeting_id, + thread_id: request.thread_id, + allow_web: request.allow_web ?? false, + top_k: request.top_k ?? 8, + summary_api_key: summaryKey, + embedding_api_key: embeddingKey, + }, }); }, }; diff --git a/client/src/api/adapters/tauri/sections/tasks.ts b/client/src/api/adapters/tauri/sections/tasks.ts index 3e0d19c..f92d39e 100644 --- a/client/src/api/adapters/tauri/sections/tasks.ts +++ b/client/src/api/adapters/tauri/sections/tasks.ts @@ -1,11 +1,17 @@ -import type { ListTasksRequest, ListTasksResponse, Task, UpdateTaskRequest } from '../../../types'; +import type { + CreateTaskRequest, + ListTasksRequest, + ListTasksResponse, + Task, + UpdateTaskRequest, +} from '../../../types'; import type { NoteFlowAPI } from '../../../interface'; import { TauriCommands } from '../constants'; import type { TauriInvoke } from '../types'; export function createTaskApi( invoke: TauriInvoke -): Pick { +): Pick { return { async listTasks(request: ListTasksRequest): Promise { return invoke(TauriCommands.LIST_TASKS, { @@ -17,6 +23,20 @@ export function createTaskApi( meeting_id: request.meeting_id, }); }, + async createTask(request: CreateTaskRequest): Promise { + const response = await invoke<{ task: Task }>(TauriCommands.CREATE_TASK, { + request: { + text: request.text, + meeting_id: request.meeting_id, + action_item_id: request.action_item_id ?? undefined, + status: request.status, + assignee_person_id: request.assignee_person_id, + due_date: request.due_date, + priority: request.priority, + }, + }); + return response.task; + }, async updateTask(request: UpdateTaskRequest): Promise { const response = await invoke<{ task: Task }>(TauriCommands.UPDATE_TASK, { task_id: request.task_id, diff --git a/client/src/api/interface.ts b/client/src/api/interface.ts index 0dfb5a2..8541726 100644 --- a/client/src/api/interface.ts +++ b/client/src/api/interface.ts @@ -24,6 +24,8 @@ import type { Annotation, ASRConfiguration, ASRConfigurationJobStatus, + CloudConsentFeature, + CloudConsentStatus, StreamingConfiguration, AudioDeviceInfo, DualCaptureConfigInfo, @@ -33,6 +35,7 @@ import type { StreamStateInfo, CreateMeetingRequest, CreateProjectRequest, + CreateTaskRequest, CreateSummarizationTemplateRequest, DeleteOidcProviderResponse, DeleteWebhookResponse, @@ -398,23 +401,11 @@ export interface NoteFlowAPI { // --- Cloud Consent --- - /** - * Grant consent for cloud-based summarization - * @see gRPC endpoint: GrantCloudConsent (unary) - */ grantCloudConsent(): Promise; - - /** - * Revoke consent for cloud-based summarization - * @see gRPC endpoint: RevokeCloudConsent (unary) - */ revokeCloudConsent(): Promise; - - /** - * Get current cloud consent status - * @see gRPC endpoint: GetCloudConsentStatus (unary) - */ - getCloudConsentStatus(): Promise<{ consentGranted: boolean }>; + grantCloudConsentFeature(feature: CloudConsentFeature): Promise; + revokeCloudConsentFeature(feature: CloudConsentFeature): Promise; + getCloudConsentStatus(): Promise; /** * Ask the AI assistant a question about meetings @@ -967,6 +958,8 @@ export interface NoteFlowAPI { listTasks(request: ListTasksRequest): Promise; + createTask(request: CreateTaskRequest): Promise; + updateTask(request: UpdateTaskRequest): Promise; // --- Analytics (Strategy B) --- diff --git a/client/src/api/interfaces/domains.ts b/client/src/api/interfaces/domains.ts index b7b6107..f9cdc77 100644 --- a/client/src/api/interfaces/domains.ts +++ b/client/src/api/interfaces/domains.ts @@ -50,6 +50,7 @@ import type { Summary, AskAssistantRequest, AskAssistantResponse, + CloudConsentStatus, ASRConfiguration, UpdateASRConfigurationRequest, ASRConfigurationJobStatus, @@ -206,7 +207,7 @@ export interface SummaryAPI { streamAssistant(request: AskAssistantRequest): Promise; grantCloudConsent(): Promise; revokeCloudConsent(): Promise; - getCloudConsentStatus(): Promise<{ consentGranted: boolean }>; + getCloudConsentStatus(): Promise; } /** @@ -369,4 +370,4 @@ export interface OIDCAP { refreshOidcDiscovery(providerId: string): Promise; testOidcConnection(providerId: string): Promise; listOidcPresets(): Promise; -} \ No newline at end of file +} diff --git a/client/src/api/types/core.ts b/client/src/api/types/core.ts index eff3a38..3231e70 100644 --- a/client/src/api/types/core.ts +++ b/client/src/api/types/core.ts @@ -9,6 +9,7 @@ import type { JobStatus, MeetingState, Priority, + SpeakerRole, ProcessingStepStatus, UpdateType, } from './enums'; @@ -56,6 +57,8 @@ export interface FinalSegment { speaker_id: string; /** Speaker assignment confidence (0.0-1.0) */ speaker_confidence: number; + /** Speaker role classification (user vs attendee) */ + speaker_role?: SpeakerRole; } /** @@ -312,3 +315,24 @@ export interface ExportResult { /** Suggested file extension (.md or .html) */ file_extension: string; } + +/** + * Cloud consent feature types for per-feature consent management. + * Each feature can be independently enabled/disabled for cloud processing. + */ +export type CloudConsentFeature = 'transcription' | 'summary' | 'embedding'; + +/** + * Status of cloud consent for each feature. + * Returned by getCloudConsentStatus(). + */ +export interface CloudConsentStatus { + /** @deprecated Use per-feature consent fields instead */ + consentGranted: boolean; + /** Whether cloud transcription (ASR) is consented */ + transcriptionConsent: boolean; + /** Whether cloud summarization (LLM) is consented */ + summaryConsent: boolean; + /** Whether cloud embedding is consented */ + embeddingConsent: boolean; +} diff --git a/client/src/api/types/enums.ts b/client/src/api/types/enums.ts index 0a43aae..a9efb3f 100644 --- a/client/src/api/types/enums.ts +++ b/client/src/api/types/enums.ts @@ -129,3 +129,13 @@ export type ProjectRole = 'viewer' | 'editor' | 'admin'; * - TASK_STATUS_DISMISSED = 3 */ export type TaskStatus = 'open' | 'done' | 'dismissed'; + +/** + * Speaker role classification for transcript segments + * + * gRPC enum values: + * - SPEAKER_ROLE_UNSPECIFIED = 0 + * - SPEAKER_ROLE_USER = 1 + * - SPEAKER_ROLE_ATTENDEE = 2 + */ +export type SpeakerRole = 'unspecified' | 'user' | 'attendee'; diff --git a/client/src/api/types/features/analytics.ts b/client/src/api/types/features/analytics.ts index 937ee61..ee51b5e 100644 --- a/client/src/api/types/features/analytics.ts +++ b/client/src/api/types/features/analytics.ts @@ -19,6 +19,9 @@ export interface AnalyticsOverview { total_words: number; total_segments: number; speaker_count: number; + user_speaking_time: number; + attendee_speaking_time: number; + unknown_speaking_time: number; } export interface AnalyticsOverviewRequest { diff --git a/client/src/api/types/features/tasks.ts b/client/src/api/types/features/tasks.ts index 156d7b2..51f078e 100644 --- a/client/src/api/types/features/tasks.ts +++ b/client/src/api/types/features/tasks.ts @@ -46,6 +46,22 @@ export interface UpdateTaskRequest { priority?: number; } +/** Request to create a task */ +export interface CreateTaskRequest { + text: string; + meeting_id?: string; + action_item_id?: number | null; + status?: TaskStatus; + assignee_person_id?: string | null; + due_date?: number | null; + priority?: number; +} + +/** Response from creating a task */ +export interface CreateTaskResponse { + task: Task; +} + /** Response from updating a task */ export interface UpdateTaskResponse { task: Task; diff --git a/client/src/components/common/error-boundary.test.tsx b/client/src/components/common/error-boundary.test.tsx index bc1739d..17789ae 100644 --- a/client/src/components/common/error-boundary.test.tsx +++ b/client/src/components/common/error-boundary.test.tsx @@ -1,13 +1,17 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ErrorBoundary } from '@/components/common/error-boundary'; -function Thrower() { +const Thrower = (): JSX.Element => { throw new Error('Kaboom'); -} +}; describe('ErrorBoundary', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); @@ -26,7 +30,6 @@ describe('ErrorBoundary', () => { it('renders fallback UI on error and reloads', async () => { const reloadSpy = vi.fn(); vi.stubGlobal('location', { ...window.location, reload: reloadSpy }); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); render( @@ -39,7 +42,5 @@ describe('ErrorBoundary', () => { fireEvent.click(screen.getByRole('button', { name: /reload app/i })); expect(reloadSpy).toHaveBeenCalled(); - - consoleSpy.mockRestore(); }); }); diff --git a/client/src/components/common/nav-link.test.tsx b/client/src/components/common/nav-link.test.tsx index aa2495b..cf15c0a 100644 --- a/client/src/components/common/nav-link.test.tsx +++ b/client/src/components/common/nav-link.test.tsx @@ -6,7 +6,10 @@ import { NavLink } from './nav-link'; describe('NavLink', () => { it('applies active class when route matches', () => { render( - + Active @@ -18,7 +21,10 @@ describe('NavLink', () => { it('does not apply active class when route does not match', () => { render( - + Active diff --git a/client/src/components/features/analytics/analytics-utils.test.ts b/client/src/components/features/analytics/analytics-utils.test.ts index 81c6ef2..69a6bdd 100644 --- a/client/src/components/features/analytics/analytics-utils.test.ts +++ b/client/src/components/features/analytics/analytics-utils.test.ts @@ -1,12 +1,20 @@ -import { describe, expect, it } from 'vitest'; - +import { describe, expect, it, vi } from 'vitest'; import { mapSpeakerStats, speakerLabel, wordCountTickLabel } from '@/components/features/analytics/analytics-utils'; +vi.mock('@/lib/preferences', () => ({ + preferences: { + getGlobalSpeakerName: () => undefined, + }, +})); + describe('analytics-utils', () => { it('formats speaker labels safely', () => { expect(speakerLabel(null)).toBe(''); expect(speakerLabel({})).toBe(''); expect(speakerLabel({ speakerId: 'spk', percentage: 12.345 })).toBe('spk: 12.3%'); + expect( + speakerLabel({ speakerId: 'spk', displayName: 'Speaker', percentage: 12.345 }) + ).toBe('Speaker: 12.3%'); }); it('formats word count ticks', () => { @@ -23,6 +31,7 @@ describe('analytics-utils', () => { total_time: 50, segment_count: 2, meeting_count: 1, + avg_confidence: 0.9, }, { speaker_id: 'b', @@ -30,6 +39,7 @@ describe('analytics-utils', () => { total_time: 50, segment_count: 1, meeting_count: 1, + avg_confidence: 0.8, }, ]); expect(mapped[0]?.percentage).toBe(50); diff --git a/client/src/components/features/analytics/analytics-utils.ts b/client/src/components/features/analytics/analytics-utils.ts index 69c2f3c..6f3c8c5 100644 --- a/client/src/components/features/analytics/analytics-utils.ts +++ b/client/src/components/features/analytics/analytics-utils.ts @@ -1,4 +1,5 @@ import type { SpeakerStat } from '@/api/types'; +import { preferences } from '@/lib/preferences'; export const SPEAKER_COLORS = [ 'hsl(var(--chart-1))', @@ -31,11 +32,13 @@ export function speakerLabel(entry: unknown): string { } const record = entry as Record; const speakerId = typeof record.speakerId === 'string' ? record.speakerId : null; + const displayName = typeof record.displayName === 'string' ? record.displayName : null; const percentage = typeof record.percentage === 'number' ? record.percentage : null; if (!speakerId || percentage === null) { return ''; } - return `${speakerId}: ${percentage.toFixed(1)}%`; + const label = displayName || speakerId; + return `${label}: ${percentage.toFixed(1)}%`; } export function wordCountTickLabel(value: unknown): string { @@ -50,7 +53,8 @@ export function mapSpeakerStats(speakers: SpeakerStat[]): SpeakerStats[] { const totalTime = speakers.reduce((sum, s) => sum + s.total_time, 0); return speakers.map((s) => ({ speakerId: s.speaker_id, - displayName: s.display_name, + displayName: + preferences.getGlobalSpeakerName(s.speaker_id) ?? s.display_name ?? s.speaker_id, totalTime: s.total_time, percentage: totalTime > 0 ? (s.total_time / totalTime) * 100 : 0, segmentCount: s.segment_count, diff --git a/client/src/components/features/analytics/entities-tab.tsx b/client/src/components/features/analytics/entities-tab.tsx index 34d5881..827229c 100644 --- a/client/src/components/features/analytics/entities-tab.tsx +++ b/client/src/components/features/analytics/entities-tab.tsx @@ -7,6 +7,8 @@ import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ import { chartAxis, chartStrokes, flexLayout, overflow } from '@/lib/ui/styles'; const titleRowClass = flexLayout.itemsGap2; +const RADIAN = Math.PI / 180; +const MIN_LABEL_PERCENT = 0.05; const CATEGORY_COLORS = [ 'hsl(var(--chart-1))', @@ -106,9 +108,27 @@ export function EntitiesTab({ entityAnalytics }: EntitiesTabProps) { cy="50%" outerRadius={100} innerRadius={60} - label={({ category, percent }) => - `${category} (${(percent * 100).toFixed(0)}%)` - } + paddingAngle={2} + label={({ cx, cy, midAngle, innerRadius, outerRadius, percent }) => { + const radius = innerRadius + (outerRadius - innerRadius) * 0.5; + const x = cx + radius * Math.cos(-midAngle * RADIAN); + const y = cy + radius * Math.sin(-midAngle * RADIAN); + + if (percent < MIN_LABEL_PERCENT) return null; + + return ( + + {`${(percent * 100).toFixed(0)}%`} + + ); + }} labelLine={false} > {categoryData.map((entry) => ( @@ -121,7 +141,9 @@ export function EntitiesTab({ entityAnalytics }: EntitiesTabProps) { categoryLabel(name), ]} /> - + {categoryLabel(value)}} + /> ) : ( @@ -144,12 +166,12 @@ export function EntitiesTab({ entityAnalytics }: EntitiesTabProps) { - + categoryLabel(v)} /> diff --git a/client/src/components/features/analytics/meetings-tab.tsx b/client/src/components/features/analytics/meetings-tab.tsx index 439a3b2..ddc8692 100644 --- a/client/src/components/features/analytics/meetings-tab.tsx +++ b/client/src/components/features/analytics/meetings-tab.tsx @@ -1,6 +1,6 @@ // Meetings analytics tab content -import { useId } from 'react'; +import { useId, useMemo } from 'react'; import { format } from 'date-fns'; import { Area, @@ -17,13 +17,12 @@ import { XAxis, YAxis, } from 'recharts'; -import { Calendar, Clock, FileText, Mic, TrendingUp, Users } from 'lucide-react'; +import { Calendar, Clock, FileText, HelpCircle, Mic, TrendingUp, User, Users } from 'lucide-react'; import type { AnalyticsOverview } from '@/api/types'; import { SPEAKER_COLORS, SPEAKER_COLOR_CLASSES, type SpeakerStats, - speakerLabel, wordCountTickLabel, } from './analytics-utils'; import { StatsCard } from '@/components/common'; @@ -35,6 +34,9 @@ import { cn } from '@/lib/utils'; const titleRowClass = flexLayout.itemsGap2; const ANALYTICS_DAYS = 14; +const MAX_CHART_SPEAKERS = 5; +const RADIAN = Math.PI / 180; +const MIN_LABEL_PERCENT = 0.05; interface DailyStats { date: string; @@ -62,6 +64,33 @@ interface MeetingsTabProps { export function MeetingsTab({ overview, speakerStats, chartConfig }: MeetingsTabProps) { const chartId = useId(); + + // Aggregate speaker stats for the pie chart to avoid clutter + const chartData = useMemo(() => { + // Sort speakers by percentage + const sorted = [...speakerStats].sort((a, b) => b.percentage - a.percentage); + + // If we have (MAX + 1) or fewer speakers, show all of them + if (sorted.length <= MAX_CHART_SPEAKERS + 1) { + return sorted; + } + + // Otherwise show top MAX and aggregate the rest as "Others" + const topN = sorted.slice(0, MAX_CHART_SPEAKERS); + const others = sorted.slice(MAX_CHART_SPEAKERS); + + const othersStat: SpeakerStats = { + speakerId: 'others', + displayName: 'Others', + totalTime: others.reduce((acc, s) => acc + s.totalTime, 0), + percentage: others.reduce((acc, s) => acc + s.percentage, 0), + segmentCount: others.reduce((acc, s) => acc + s.segmentCount, 0), + meetingCount: Math.max(...others.map(s => s.meetingCount)), + }; + + return [...topN, othersStat]; + }, [speakerStats]); + const gridProps = { strokeDasharray: '3 3', className: chartStrokes.muted }; const durationTooltip = ( formatDuration(Number(value))} /> @@ -77,6 +106,9 @@ export function MeetingsTab({ overview, speakerStats, chartConfig }: MeetingsTab const avgWordsPerMeeting = overview.total_meetings > 0 ? overview.total_words / overview.total_meetings : 0; + const totalSpeakingTime = + overview.user_speaking_time + overview.attendee_speaking_time + overview.unknown_speaking_time; + return (
@@ -106,6 +138,92 @@ export function MeetingsTab({ overview, speakerStats, chartConfig }: MeetingsTab />
+ {totalSpeakingTime > 0 && ( + + + + + Speaking Time by Role + + Comparison of your speaking time vs attendees + + +
+
+
+
+ + You +
+ + {formatDuration(overview.user_speaking_time)} + +
+
+
+
+

+ {Math.round((overview.user_speaking_time / totalSpeakingTime) * 100)}% +

+
+ +
+
+
+ + Attendees +
+ + {formatDuration(overview.attendee_speaking_time)} + +
+
+
+
+

+ {Math.round((overview.attendee_speaking_time / totalSpeakingTime) * 100)}% +

+
+ + {overview.unknown_speaking_time > 0 && ( +
+
+
+ + Unknown +
+ + {formatDuration(overview.unknown_speaking_time)} + +
+
+
+
+

+ {Math.round((overview.unknown_speaking_time / totalSpeakingTime) * 100)}% +

+
+ )} +
+ + + )} +
@@ -198,31 +316,61 @@ export function MeetingsTab({ overview, speakerStats, chartConfig }: MeetingsTab
- {speakerStats.length > 0 ? ( + {chartData.length > 0 ? ( { + const radius = innerRadius + (outerRadius - innerRadius) * 0.5; + const x = cx + radius * Math.cos(-midAngle * RADIAN); + const y = cy + radius * Math.sin(-midAngle * RADIAN); + + // Only show label if slice is big enough + if (percent < MIN_LABEL_PERCENT) return null; + + return ( + + {`${(percent * 100).toFixed(0)}%`} + + ); + }} labelLine={false} > - {speakerStats.map((stat, idx) => ( + {chartData.map((entry, idx) => ( ))} [`${value.toFixed(1)}%`, name]} + contentStyle={{ borderRadius: '8px' }} + /> + {value}} /> - ) : ( diff --git a/client/src/components/features/analytics/speech-analysis-tab.tsx b/client/src/components/features/analytics/speech-analysis-tab.tsx index f462544..f5d9ceb 100644 --- a/client/src/components/features/analytics/speech-analysis-tab.tsx +++ b/client/src/components/features/analytics/speech-analysis-tab.tsx @@ -1,325 +1,33 @@ -import { AlertCircle, Brain, Hash, Lightbulb, MessageSquare, TrendingUp } from 'lucide-react'; import { useMemo } from 'react'; +import { + AlertCircle, + Brain, + Hash, + Lightbulb, + MessageSquare, + TrendingUp, + User, + Users, +} from 'lucide-react'; import type { Meeting } from '@/api/types'; import { AnalyticsCardTitle } from '@/components/features/analytics/analytics-card-title'; +import { + analyzeRoleStats, + analyzeSpeechPatterns, + extractEntities, + type EntityData, +} from '@/components/features/analytics/speech-analysis-utils'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; import { typography } from '@/lib/ui/styles'; -interface EntityData { - text: string; - type: 'topic' | 'action' | 'question' | 'keyword'; - count: number; - weight: number; -} - -interface SpeechPattern { - name: string; - description: string; - score: number; - feedback: string; - type: 'positive' | 'neutral' | 'improvement'; -} - -const WORDS_PER_MINUTE_BASE = 60; -const OPTIMAL_WPM_MIN = WORDS_PER_MINUTE_BASE * 2; -const OPTIMAL_WPM_MAX = WORDS_PER_MINUTE_BASE * 3; -const OPTIMAL_WPM_TARGET = (WORDS_PER_MINUTE_BASE * 5) / 2; - -function extractEntities(meetings: Meeting[]): EntityData[] { - const entityMap = new Map(); - - // Common filler words to exclude - const stopWords = new Set([ - 'the', - 'a', - 'an', - 'is', - 'are', - 'was', - 'were', - 'be', - 'been', - 'being', - 'have', - 'has', - 'had', - 'do', - 'does', - 'did', - 'will', - 'would', - 'could', - 'should', - 'may', - 'might', - 'must', - 'shall', - 'can', - 'need', - 'dare', - 'ought', - 'used', - 'to', - 'of', - 'in', - 'for', - 'on', - 'with', - 'at', - 'by', - 'from', - 'as', - 'into', - 'through', - 'during', - 'before', - 'after', - 'above', - 'below', - 'between', - 'under', - 'again', - 'further', - 'then', - 'once', - 'here', - 'there', - 'when', - 'where', - 'why', - 'how', - 'all', - 'each', - 'few', - 'more', - 'most', - 'other', - 'some', - 'such', - 'no', - 'nor', - 'not', - 'only', - 'own', - 'same', - 'so', - 'than', - 'too', - 'very', - 'just', - 'and', - 'but', - 'if', - 'or', - 'because', - 'until', - 'while', - 'although', - 'though', - 'after', - 'that', - 'this', - 'these', - 'those', - 'i', - 'you', - 'he', - 'she', - 'it', - 'we', - 'they', - 'what', - 'which', - 'who', - 'whom', - 'me', - 'him', - 'her', - 'us', - 'them', - 'my', - 'your', - 'his', - 'its', - 'our', - 'their', - 'mine', - 'yours', - 'hers', - 'ours', - 'theirs', - 'um', - 'uh', - 'like', - 'yeah', - 'okay', - 'ok', - 'right', - 'well', - 'so', - 'actually', - 'basically', - 'literally', - 'really', - 'very', - 'just', - ]); - - for (const meeting of meetings) { - for (const segment of meeting.segments) { - for (const wordTiming of segment.words) { - const text = wordTiming.word.toLowerCase().replace(/[^a-z0-9]/g, ''); - if (text.length < 3 || stopWords.has(text)) { - continue; - } - - const existing = entityMap.get(text); - if (existing) { - existing.count++; - } else { - // Determine type based on heuristics - let type: EntityData['type'] = 'keyword'; - if (text.endsWith('ing') || text.endsWith('tion')) { - type = 'action'; - } else if (text.length > 8) { - type = 'topic'; - } - - entityMap.set(text, { count: 1, type }); - } - } - } - } - - // Convert to array and calculate weights - const maxCount = Math.max(...Array.from(entityMap.values()).map((e) => e.count), 1); - - return Array.from(entityMap.entries()) - .map(([text, { count, type }]) => ({ - text, - type, - count, - weight: count / maxCount, - })) - .sort((a, b) => b.count - a.count) - .slice(0, 50); -} - -function analyzeSpeechPatterns(meetings: Meeting[]): SpeechPattern[] { - if (meetings.length === 0) { - return []; - } - - // Calculate various metrics - let totalWords = 0; - let totalDuration = 0; - let questionCount = 0; - let fillerWords = 0; - const fillerWordSet = new Set([ - 'um', - 'uh', - 'like', - 'you know', - 'basically', - 'actually', - 'literally', - 'right', - ]); - - const speakerWordCounts = new Map(); - const sentenceLengths: number[] = []; - - for (const meeting of meetings) { - totalDuration += meeting.duration_seconds; - - for (const segment of meeting.segments) { - const wordCount = segment.words.length; - totalWords += wordCount; - sentenceLengths.push(wordCount); - - speakerWordCounts.set( - segment.speaker_id, - (speakerWordCounts.get(segment.speaker_id) || 0) + wordCount - ); - - for (const wordTiming of segment.words) { - const text = wordTiming.word.toLowerCase(); - if (text.includes('?')) { - questionCount++; - } - if (fillerWordSet.has(text.replace(/[^a-z\s]/g, ''))) { - fillerWords++; - } - } - } - } - - const avgWordsPerMinute = totalDuration > 0 ? totalWords / (totalDuration / 60) : 0; - const avgSentenceLength = - sentenceLengths.length > 0 - ? sentenceLengths.reduce((a, b) => a + b, 0) / sentenceLengths.length - : 0; - const fillerRatio = totalWords > 0 ? (fillerWords / totalWords) * 100 : 0; - const questionRatio = totalWords > 0 ? (questionCount / totalWords) * 1000 : 0; // per 1000 words - - const patterns: SpeechPattern[] = [ - { - name: 'Speaking Pace', - description: `${Math.round(avgWordsPerMinute)} words per minute`, - score: Math.min(100, Math.max(0, 100 - Math.abs(avgWordsPerMinute - OPTIMAL_WPM_TARGET) / 2)), - feedback: - avgWordsPerMinute < OPTIMAL_WPM_MIN - ? 'Consider speaking slightly faster for better engagement' - : avgWordsPerMinute > OPTIMAL_WPM_MAX - ? 'Try slowing down to improve clarity' - : 'Your pace is in the optimal range', - type: - avgWordsPerMinute >= OPTIMAL_WPM_MIN && avgWordsPerMinute <= OPTIMAL_WPM_MAX - ? 'positive' - : 'improvement', - }, - { - name: 'Clarity Score', - description: `Avg ${avgSentenceLength.toFixed(1)} words per segment`, - score: Math.min(100, Math.max(0, 100 - Math.abs(avgSentenceLength - 15) * 3)), - feedback: - avgSentenceLength > 25 - ? 'Breaking up longer segments can improve clarity' - : avgSentenceLength < 8 - ? 'Consider expanding on points for better context' - : 'Your segment lengths support good comprehension', - type: avgSentenceLength >= 8 && avgSentenceLength <= 25 ? 'positive' : 'neutral', - }, - { - name: 'Filler Word Usage', - description: `${fillerRatio.toFixed(2)}% of words are fillers`, - score: Math.max(0, 100 - fillerRatio * 20), - feedback: - fillerRatio > 3 - ? 'Practice pausing instead of using filler words' - : fillerRatio > 1 - ? 'Moderate filler usage - room for improvement' - : 'Excellent - minimal filler word usage', - type: fillerRatio <= 1 ? 'positive' : fillerRatio <= 3 ? 'neutral' : 'improvement', - }, - { - name: 'Engagement (Questions)', - description: `${questionRatio.toFixed(1)} questions per 1000 words`, - score: Math.min(100, questionRatio * 10), - feedback: - questionRatio < 2 - ? 'Try asking more questions to boost engagement' - : questionRatio > 10 - ? 'Good question frequency for interactive discussions' - : 'Balanced use of questions', - type: questionRatio >= 2 ? 'positive' : 'neutral', - }, - ]; - - return patterns; -} +const FILLER_WORD_SCALE = 500; +const QUESTION_RATE_SCALE = 10000; +const PERCENTAGE_SCALE = 100; +const PER_1K_WORDS = 1000; +const MAX_PROGRESS_VALUE = 100; +const TOP_ENTITIES_LIMIT = 30; interface SpeechAnalysisTabProps { meetings: Meeting[]; @@ -328,8 +36,54 @@ interface SpeechAnalysisTabProps { export function SpeechAnalysisTab({ meetings }: SpeechAnalysisTabProps) { const entities = useMemo(() => extractEntities(meetings), [meetings]); const patterns = useMemo(() => analyzeSpeechPatterns(meetings), [meetings]); + const roleStats = useMemo(() => analyzeRoleStats(meetings), [meetings]); - const topEntities = entities.slice(0, 30); + const userFillerPercent = + roleStats.user.wordCount > 0 + ? (roleStats.user.fillerCount / roleStats.user.wordCount) * PERCENTAGE_SCALE + : 0; + const userFillerProgress = + roleStats.user.wordCount > 0 + ? Math.min( + MAX_PROGRESS_VALUE, + (roleStats.user.fillerCount / roleStats.user.wordCount) * FILLER_WORD_SCALE + ) + : 0; + const userQuestionRate = + roleStats.user.wordCount > 0 + ? (roleStats.user.questionCount / roleStats.user.wordCount) * PER_1K_WORDS + : 0; + const userQuestionProgress = + roleStats.user.wordCount > 0 + ? Math.min( + MAX_PROGRESS_VALUE, + (roleStats.user.questionCount / roleStats.user.wordCount) * QUESTION_RATE_SCALE + ) + : 0; + const othersFillerPercent = + roleStats.others.wordCount > 0 + ? (roleStats.others.fillerCount / roleStats.others.wordCount) * PERCENTAGE_SCALE + : 0; + const othersFillerProgress = + roleStats.others.wordCount > 0 + ? Math.min( + MAX_PROGRESS_VALUE, + (roleStats.others.fillerCount / roleStats.others.wordCount) * FILLER_WORD_SCALE + ) + : 0; + const othersQuestionRate = + roleStats.others.wordCount > 0 + ? (roleStats.others.questionCount / roleStats.others.wordCount) * PER_1K_WORDS + : 0; + const othersQuestionProgress = + roleStats.others.wordCount > 0 + ? Math.min( + MAX_PROGRESS_VALUE, + (roleStats.others.questionCount / roleStats.others.wordCount) * QUESTION_RATE_SCALE + ) + : 0; + + const topEntities = entities.slice(0, TOP_ENTITIES_LIMIT); const entityTypeColors: Record = { topic: 'bg-chart-1/20 text-chart-1 border-chart-1/30', action: 'bg-chart-2/20 text-chart-2 border-chart-2/30', @@ -339,6 +93,78 @@ export function SpeechAnalysisTab({ meetings }: SpeechAnalysisTabProps) { return (
+ {/* Role Comparison Card */} + {(roleStats.user.segments > 0 || roleStats.others.segments > 0) && ( + + + + + Speaking Dynamics: You vs Others + + Comparison of communication styles + + +
+ {/* User column */} +
+
+ + You +
+ + {/* Stats items */} +
+
+ Filler Words + {userFillerPercent.toFixed(1)}% +
+ +
+ +
+
+ Questions + {userQuestionRate.toFixed(1)} / 1k words +
+ +
+
+ + {/* Others column */} +
+
+ + Others +
+ + {/* Stats items */} +
+
+ Filler Words + {othersFillerPercent.toFixed(1)}% +
+ +
+ +
+
+ Questions + {othersQuestionRate.toFixed(1)} / 1k words +
+ +
+
+
+
+
+ )} + {/* Word Cloud / Entity Map */} diff --git a/client/src/components/features/analytics/speech-analysis-utils.ts b/client/src/components/features/analytics/speech-analysis-utils.ts new file mode 100644 index 0000000..24af5e1 --- /dev/null +++ b/client/src/components/features/analytics/speech-analysis-utils.ts @@ -0,0 +1,375 @@ +import type { Meeting } from '@/api/types'; + +export interface EntityData { + text: string; + type: 'topic' | 'action' | 'question' | 'keyword'; + count: number; + weight: number; +} + +export interface SpeechPattern { + name: string; + description: string; + score: number; + feedback: string; + type: 'positive' | 'neutral' | 'improvement'; +} + +export interface RoleStats { + user: { + duration: number; + wordCount: number; + questionCount: number; + fillerCount: number; + segments: number; + }; + others: { + duration: number; + wordCount: number; + questionCount: number; + fillerCount: number; + segments: number; + }; +} + +const WORDS_PER_MINUTE_BASE = 60; +const OPTIMAL_WPM_MIN = WORDS_PER_MINUTE_BASE * 2; +const OPTIMAL_WPM_MAX = WORDS_PER_MINUTE_BASE * 3; +const OPTIMAL_WPM_TARGET = (WORDS_PER_MINUTE_BASE * 5) / 2; +const PER_1K_WORDS = 1000; + +export function extractEntities(meetings: Meeting[]): EntityData[] { + const entityMap = new Map(); + + const stopWords = new Set([ + 'the', + 'a', + 'an', + 'is', + 'are', + 'was', + 'were', + 'be', + 'been', + 'being', + 'have', + 'has', + 'had', + 'do', + 'does', + 'did', + 'will', + 'would', + 'could', + 'should', + 'may', + 'might', + 'must', + 'shall', + 'can', + 'need', + 'dare', + 'ought', + 'used', + 'to', + 'of', + 'in', + 'for', + 'on', + 'with', + 'at', + 'by', + 'from', + 'as', + 'into', + 'through', + 'during', + 'before', + 'after', + 'above', + 'below', + 'between', + 'under', + 'again', + 'further', + 'then', + 'once', + 'here', + 'there', + 'when', + 'where', + 'why', + 'how', + 'all', + 'each', + 'few', + 'more', + 'most', + 'other', + 'some', + 'such', + 'no', + 'nor', + 'not', + 'only', + 'own', + 'same', + 'so', + 'than', + 'too', + 'very', + 'just', + 'and', + 'but', + 'if', + 'or', + 'because', + 'until', + 'while', + 'although', + 'though', + 'after', + 'that', + 'this', + 'these', + 'those', + 'i', + 'you', + 'he', + 'she', + 'it', + 'we', + 'they', + 'what', + 'which', + 'who', + 'whom', + 'me', + 'him', + 'her', + 'us', + 'them', + 'my', + 'your', + 'his', + 'its', + 'our', + 'their', + 'mine', + 'yours', + 'hers', + 'ours', + 'theirs', + 'um', + 'uh', + 'like', + 'yeah', + 'okay', + 'ok', + 'right', + 'well', + 'so', + 'actually', + 'basically', + 'literally', + 'really', + 'very', + 'just', + ]); + + for (const meeting of meetings) { + for (const segment of meeting.segments) { + for (const wordTiming of segment.words) { + const text = wordTiming.word.toLowerCase().replace(/[^a-z0-9]/g, ''); + if (text.length < 3 || stopWords.has(text)) { + continue; + } + + const existing = entityMap.get(text); + if (existing) { + existing.count++; + } else { + let type: EntityData['type'] = 'keyword'; + if (text.endsWith('ing') || text.endsWith('tion')) { + type = 'action'; + } else if (text.length > 8) { + type = 'topic'; + } + + entityMap.set(text, { count: 1, type }); + } + } + } + } + + const maxCount = Math.max(...Array.from(entityMap.values()).map((e) => e.count), 1); + + return Array.from(entityMap.entries()) + .map(([text, { count, type }]) => ({ + text, + type, + count, + weight: count / maxCount, + })) + .sort((a, b) => b.count - a.count) + .slice(0, 50); +} + +export function analyzeSpeechPatterns(meetings: Meeting[]): SpeechPattern[] { + if (meetings.length === 0) { + return []; + } + + let totalWords = 0; + let totalDuration = 0; + let questionCount = 0; + let fillerWords = 0; + const fillerWordSet = new Set([ + 'um', + 'uh', + 'like', + 'you know', + 'basically', + 'actually', + 'literally', + 'right', + ]); + + const speakerWordCounts = new Map(); + const sentenceLengths: number[] = []; + + for (const meeting of meetings) { + totalDuration += meeting.duration_seconds; + + for (const segment of meeting.segments) { + const wordCount = segment.words.length; + totalWords += wordCount; + sentenceLengths.push(wordCount); + + speakerWordCounts.set( + segment.speaker_id, + (speakerWordCounts.get(segment.speaker_id) || 0) + wordCount + ); + + for (const wordTiming of segment.words) { + const text = wordTiming.word.toLowerCase(); + if (text.includes('?')) { + questionCount++; + } + if (fillerWordSet.has(text.replace(/[^a-z\s]/g, ''))) { + fillerWords++; + } + } + } + } + + const avgWordsPerMinute = totalDuration > 0 ? totalWords / (totalDuration / 60) : 0; + const avgSentenceLength = + sentenceLengths.length > 0 + ? sentenceLengths.reduce((a, b) => a + b, 0) / sentenceLengths.length + : 0; + const fillerRatio = totalWords > 0 ? (fillerWords / totalWords) * 100 : 0; + const questionRatio = + totalWords > 0 ? (questionCount / totalWords) * PER_1K_WORDS : 0; + + return [ + { + name: 'Speaking Pace', + description: `${Math.round(avgWordsPerMinute)} words per minute`, + score: Math.min( + 100, + Math.max(0, 100 - Math.abs(avgWordsPerMinute - OPTIMAL_WPM_TARGET) / 2) + ), + feedback: + avgWordsPerMinute < OPTIMAL_WPM_MIN + ? 'Consider speaking slightly faster for better engagement' + : avgWordsPerMinute > OPTIMAL_WPM_MAX + ? 'Try slowing down to improve clarity' + : 'Your pace is in the optimal range', + type: + avgWordsPerMinute >= OPTIMAL_WPM_MIN && avgWordsPerMinute <= OPTIMAL_WPM_MAX + ? 'positive' + : 'improvement', + }, + { + name: 'Clarity Score', + description: `Avg ${avgSentenceLength.toFixed(1)} words per segment`, + score: Math.min(100, Math.max(0, 100 - Math.abs(avgSentenceLength - 15) * 3)), + feedback: + avgSentenceLength > 25 + ? 'Breaking up longer segments can improve clarity' + : avgSentenceLength < 8 + ? 'Consider expanding on points for better context' + : 'Your segment lengths support good comprehension', + type: avgSentenceLength >= 8 && avgSentenceLength <= 25 ? 'positive' : 'neutral', + }, + { + name: 'Filler Word Usage', + description: `${fillerRatio.toFixed(2)}% of words are fillers`, + score: Math.max(0, 100 - fillerRatio * 20), + feedback: + fillerRatio > 3 + ? 'Practice pausing instead of using filler words' + : fillerRatio > 1 + ? 'Moderate filler usage - room for improvement' + : 'Excellent - minimal filler word usage', + type: fillerRatio <= 1 ? 'positive' : fillerRatio <= 3 ? 'neutral' : 'improvement', + }, + { + name: 'Engagement (Questions)', + description: `${questionRatio.toFixed(1)} questions per 1000 words`, + score: Math.min(100, questionRatio * 10), + feedback: + questionRatio < 2 + ? 'Try asking more questions to boost engagement' + : questionRatio > 10 + ? 'Good question frequency for interactive discussions' + : 'Balanced use of questions', + type: questionRatio >= 2 ? 'positive' : 'neutral', + }, + ]; +} + +export function analyzeRoleStats(meetings: Meeting[]): RoleStats { + const stats: RoleStats = { + user: { duration: 0, wordCount: 0, questionCount: 0, fillerCount: 0, segments: 0 }, + others: { duration: 0, wordCount: 0, questionCount: 0, fillerCount: 0, segments: 0 }, + }; + + const fillerWordSet = new Set([ + 'um', + 'uh', + 'like', + 'you know', + 'basically', + 'actually', + 'literally', + 'right', + ]); + + for (const meeting of meetings) { + for (const segment of meeting.segments) { + const role = segment.speaker_role === 'user' ? 'user' : 'others'; + const target = stats[role]; + + const duration = segment.end_time - segment.start_time; + target.duration += duration; + target.segments++; + + const wordCount = segment.words.length; + target.wordCount += wordCount; + + for (const word of segment.words) { + const text = word.word.toLowerCase(); + if (text.includes('?')) { + target.questionCount++; + } + if (fillerWordSet.has(text.replace(/[^a-z\s]/g, ''))) { + target.fillerCount++; + } + } + } + } + + return stats; +} diff --git a/client/src/components/features/assistant/assistant-dialog.tsx b/client/src/components/features/assistant/assistant-dialog.tsx new file mode 100644 index 0000000..5f498f8 --- /dev/null +++ b/client/src/components/features/assistant/assistant-dialog.tsx @@ -0,0 +1,276 @@ +import { Bot, Eraser, Loader2, MessageSquare, Send, Sparkles, User } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +import type { SegmentCitation } from '@/api/types'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useAssistant } from '@/hooks/processing/use-assistant'; +import { cn } from '@/lib/utils'; +import { formatTime } from '@/lib/utils/format'; +import { generateUuid } from '@/lib/utils/id'; + +interface AssistantDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + meetingId?: string; +} + +interface Message { + id: string; + role: 'user' | 'assistant'; + content: string; + citations?: SegmentCitation[]; + isError?: boolean; +} + +export function AssistantDialog({ open, onOpenChange, meetingId }: AssistantDialogProps) { + const { ask, clearConversation: clearAssistantState, isLoading } = useAssistant({ + meetingId, + }); + + const [input, setInput] = useState(''); + const [messages, setMessages] = useState([]); + const scrollAreaRef = useRef(null); + const bottomRef = useRef(null); + + const messageCount = messages.length; + // biome-ignore lint/correctness/useExhaustiveDependencies: deps are triggers, not values used inside + useEffect(() => { + if (bottomRef.current) { + bottomRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [messageCount, isLoading]); + + const handleSend = async () => { + if (!input.trim() || isLoading) { + return; + } + + const question = input.trim(); + setInput(''); + + // Add user message + const userMsg: Message = { + id: generateUuid(), + role: 'user', + content: question, + }; + setMessages((prev) => [...prev, userMsg]); + + const result = await ask(question); + + if (result === null) { + return; + } + + if (result.success) { + const assistantMsg: Message = { + id: generateUuid(), + role: 'assistant', + content: result.response.answer, + citations: result.response.citations, + }; + setMessages((prev) => [...prev, assistantMsg]); + } else { + const errorMsg: Message = { + id: generateUuid(), + role: 'assistant', + content: result.error, + isError: true, + }; + setMessages((prev) => [...prev, errorMsg]); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const handleClear = () => { + clearAssistantState(); + setMessages([]); + }; + + return ( + + + +
+
+ + AI Assistant +
+ {messages.length > 0 && ( + + )} +
+ + Ask questions about your meeting transcript + +
+ + +
+ {messages.length === 0 ? ( +
+
+ +
+

How can I help you?

+

+ Ask me questions about this meeting, or ask for a summary of specific topics. +

+
+ {[ + 'Summarize the key decisions', + 'What are the next steps?', + 'Who was mentioned?', + 'Did we discuss the budget?', + ].map((suggestion) => ( + + ))} +
+
+ ) : ( + messages.map((msg) => ( +
+
+ {msg.role === 'user' ? ( + + ) : ( + + )} +
+ +
+
+ {msg.content} +
+ + {/* Citations */} + {msg.citations && msg.citations.length > 0 && ( +
+ + Sources + +
+ {msg.citations.map((citation, idx) => ( +
+
+ + {formatTime(citation.start_time)} - {formatTime(citation.end_time)} + + + Segment {citation.segment_id} + +
+

+ "{citation.text}" +

+
+ ))} +
+
+ )} +
+
+ )) + )} + {isLoading && ( +
+
+ +
+
+ + Thinking... +
+
+ )} +
+
+ + + +
+ setInput(e.target.value)} + placeholder="Ask a question..." + onKeyDown={handleKeyDown} + disabled={isLoading} + className="flex-1" + autoFocus + /> + +
+
+ +
+ ); +} diff --git a/client/src/components/features/assistant/index.ts b/client/src/components/features/assistant/index.ts new file mode 100644 index 0000000..5986b60 --- /dev/null +++ b/client/src/components/features/assistant/index.ts @@ -0,0 +1 @@ +export * from './assistant-dialog'; diff --git a/client/src/components/features/calendar/upcoming-meetings.test.tsx b/client/src/components/features/calendar/upcoming-meetings.test.tsx index 4b23771..a3bf81e 100644 --- a/client/src/components/features/calendar/upcoming-meetings.test.tsx +++ b/client/src/components/features/calendar/upcoming-meetings.test.tsx @@ -123,7 +123,11 @@ vi.mock('@/components/ui/tooltip', () => ({ })); const renderWithRouter = (ui: React.ReactElement) => - render({ui}); + render( + + {ui} + + ); const createEvent = (id: string, overrides: Partial = {}): CalendarEvent => ({ id, diff --git a/client/src/components/features/connectivity/api-mode-indicator.tsx b/client/src/components/features/connectivity/api-mode-indicator.tsx index 0c40f33..d75e3b4 100644 --- a/client/src/components/features/connectivity/api-mode-indicator.tsx +++ b/client/src/components/features/connectivity/api-mode-indicator.tsx @@ -104,7 +104,8 @@ export function ApiModeIndicator({ className, }: ApiModeIndicatorProps) { // Simulation takes precedence over connection mode - const config = isSimulating ? SIMULATION_CONFIG : MODE_CONFIGS[mode]; + const modeConfig = MODE_CONFIGS[mode] ?? MODE_CONFIGS.disconnected; + const config = isSimulating ? SIMULATION_CONFIG : modeConfig; const Icon = config.icon; // Don't show indicator when connected and not simulating (normal state) diff --git a/client/src/components/features/connectivity/connection-status.test.tsx b/client/src/components/features/connectivity/connection-status.test.tsx index 383ea44..bdb99a2 100644 --- a/client/src/components/features/connectivity/connection-status.test.tsx +++ b/client/src/components/features/connectivity/connection-status.test.tsx @@ -28,7 +28,7 @@ describe('ConnectionStatus', () => { await waitFor(() => { expect(screen.getByText('Connected')).toBeInTheDocument(); }); - expect(screen.getByText('v1.2.3')).toBeInTheDocument(); + expect(await screen.findByText('v1.2.3')).toBeInTheDocument(); }); it('shows disconnected state', () => { diff --git a/client/src/components/features/settings/ai-config-section.tsx b/client/src/components/features/settings/ai-config-section.tsx index a4cdb7d..1939c90 100644 --- a/client/src/components/features/settings/ai-config-section.tsx +++ b/client/src/components/features/settings/ai-config-section.tsx @@ -1,399 +1,56 @@ -// AI Configuration Settings Section - import { motion } from 'framer-motion'; -import { AlertTriangle, Brain } from 'lucide-react'; -import { useEffect, useState } from 'react'; -import type { - AIProviderConfig, - AIProviderType, - TranscriptionProviderConfig, - TranscriptionProviderType, -} from '@/api/types'; +import { AlertTriangle, Brain, Cloud, Loader2 } from 'lucide-react'; +import type { CloudConsentFeature } from '@/api/types'; import { Accordion } from '@/components/ui/accordion'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { addClientLog } from '@/lib/observability/client'; -import { toast } from '@/hooks'; -import { toastError } from '@/lib/observability/errors'; +import { useCloudConsent } from '@/hooks'; +import { cn } from '@/lib/utils'; +import { iconSize } from '@/lib/ui/styles'; import { AI_PROVIDERS, - fetchModels as fetchProviderModels, TRANSCRIPTION_PROVIDERS, - testEndpoint as testProviderEndpoint, } from '@/lib/ai-providers'; -import { getSecureValue, isSecureStorageAvailable, setSecureValue } from '@/lib/storage/crypto'; -import { preferences } from '@/lib/preferences'; import { - buildResetModels, - ensureSelectedModel, getManualModelHint, shouldShowManualModelEntry, } from './ai-config-models'; -import { useCachedModelCatalog } from './ai-config-hooks'; import { ProviderConfigCard } from './provider-config-card'; - -type ConfigType = 'transcription' | 'summary' | 'embedding'; +import { useAIConfigState } from './use-ai-config-state'; export function AIConfigSection() { - const [transcriptionConfig, setTranscriptionConfig] = useState( - preferences.get().ai_config.transcription - ); - const [summaryConfig, setSummaryConfig] = useState( - preferences.get().ai_config.summary - ); - const [embeddingConfig, setEmbeddingConfig] = useState( - preferences.get().ai_config.embedding - ); + const { + transcriptionConfig, + summaryConfig, + embeddingConfig, + fetchingTranscriptionModels, + fetchingSummaryModels, + fetchingEmbeddingModels, + testingTranscription, + testingSummary, + testingEmbedding, + encryptionAvailable, + createProviderChangeHandler, + createBaseUrlChangeHandler, + createApiKeyChangeHandler, + createModelChangeHandler, + createFetchModelsHandler, + createTestHandler, + } = useAIConfigState(); - const [fetchingTranscriptionModels, setFetchingTranscriptionModels] = useState(false); - const [fetchingSummaryModels, setFetchingSummaryModels] = useState(false); - const [fetchingEmbeddingModels, setFetchingEmbeddingModels] = useState(false); - const [testingTranscription, setTestingTranscription] = useState(false); - const [testingSummary, setTestingSummary] = useState(false); - const [testingEmbedding, setTestingEmbedding] = useState(false); + const { + transcription: cloudTranscription, + summary: cloudSummary, + embedding: cloudEmbedding, + isLoading: cloudConsentLoading, + toggleFeature: toggleCloudFeature, + error: cloudConsentError, + } = useCloudConsent(); - const encryptionAvailable = isSecureStorageAvailable(); + const anyCloudEnabled = cloudTranscription || cloudSummary || cloudEmbedding; - const getConfigForType = (configType: ConfigType) => - configType === 'transcription' - ? transcriptionConfig - : configType === 'summary' - ? summaryConfig - : embeddingConfig; - - // Load encrypted API keys on mount - useEffect(() => { - const loadKeys = async () => { - if (!encryptionAvailable) { - return; - } - try { - const [transcriptionKey, summaryKey, embeddingKey] = await Promise.all([ - getSecureValue('transcription_api_key'), - getSecureValue('summary_api_key'), - getSecureValue('embedding_api_key'), - ]); - if (transcriptionKey) { - setTranscriptionConfig((prev) => ({ ...prev, api_key: transcriptionKey })); - } - if (summaryKey) { - setSummaryConfig((prev) => ({ ...prev, api_key: summaryKey })); - } - if (embeddingKey) { - setEmbeddingConfig((prev) => ({ ...prev, api_key: embeddingKey })); - } - } catch (error) { - addClientLog({ - level: 'warning', - source: 'app', - message: 'Failed to decrypt AI config API keys', - details: error instanceof Error ? error.message : String(error), - metadata: { context: 'ai_config_key_load' }, - }); - } - }; - loadKeys(); - }, [encryptionAvailable]); - - // Generic handlers factory - use explicit config updates to avoid type inference issues - const createProviderChangeHandler = - (configType: ConfigType) => (provider: string, baseUrl: string) => { - const currentConfig = getConfigForType(configType); - const resetModels = buildResetModels(currentConfig); - const providerType = - configType === 'transcription' - ? (provider as TranscriptionProviderType) - : (provider as AIProviderType); - if (configType === 'transcription') { - setTranscriptionConfig((prev) => ({ - ...prev, - provider: providerType as TranscriptionProviderType, - base_url: baseUrl, - available_models: resetModels, - models_last_updated: null, - models_source: null, - test_status: 'untested' as const, - })); - } else if (configType === 'summary') { - setSummaryConfig((prev) => ({ - ...prev, - provider: providerType as AIProviderType, - base_url: baseUrl, - available_models: resetModels, - models_last_updated: null, - models_source: null, - test_status: 'untested' as const, - })); - } else { - setEmbeddingConfig((prev) => ({ - ...prev, - provider: providerType as AIProviderType, - base_url: baseUrl, - available_models: resetModels, - models_last_updated: null, - models_source: null, - test_status: 'untested' as const, - })); - } - preferences.updateAIConfig( - configType, - { - provider: providerType, - base_url: baseUrl, - available_models: resetModels, - models_last_updated: null, - models_source: null, - }, - { resetTestStatus: true } - ); - }; - - const createBaseUrlChangeHandler = (configType: ConfigType) => (url: string) => { - const currentConfig = getConfigForType(configType); - const resetModels = buildResetModels(currentConfig); - if (configType === 'transcription') { - setTranscriptionConfig((prev) => ({ - ...prev, - base_url: url, - available_models: resetModels, - models_last_updated: null, - models_source: null, - })); - } else if (configType === 'summary') { - setSummaryConfig((prev) => ({ - ...prev, - base_url: url, - available_models: resetModels, - models_last_updated: null, - models_source: null, - })); - } else { - setEmbeddingConfig((prev) => ({ - ...prev, - base_url: url, - available_models: resetModels, - models_last_updated: null, - models_source: null, - })); - } - preferences.updateAIConfig(configType, { - base_url: url, - available_models: resetModels, - models_last_updated: null, - models_source: null, - }); - }; - - const createApiKeyChangeHandler = (configType: ConfigType) => async (key: string) => { - if (configType === 'transcription') { - setTranscriptionConfig((prev) => ({ - ...prev, - api_key: key, - test_status: 'untested' as const, - })); - } else if (configType === 'summary') { - setSummaryConfig((prev) => ({ ...prev, api_key: key, test_status: 'untested' as const })); - } else { - setEmbeddingConfig((prev) => ({ ...prev, api_key: key, test_status: 'untested' as const })); - } - if (encryptionAvailable) { - try { - await setSecureValue(`${configType}_api_key`, key); - } catch (error) { - // Secure storage write failure is non-critical but should be visible for diagnostics - addClientLog({ - level: 'warning', - source: 'app', - message: `Secure storage write failed for ${configType} API key`, - details: error instanceof Error ? error.message : String(error), - metadata: { context: 'ai_config_secure_storage', config_type: configType }, - }); - } - } - }; - - const createModelChangeHandler = (configType: ConfigType) => (model: string) => { - const currentConfig = getConfigForType(configType); - const nextModels = ensureSelectedModel(currentConfig.available_models, model); - if (configType === 'transcription') { - setTranscriptionConfig((prev) => ({ - ...prev, - selected_model: model, - available_models: ensureSelectedModel(prev.available_models, model), - })); - } else if (configType === 'summary') { - setSummaryConfig((prev) => ({ - ...prev, - selected_model: model, - available_models: ensureSelectedModel(prev.available_models, model), - })); - } else { - setEmbeddingConfig((prev) => ({ - ...prev, - selected_model: model, - available_models: ensureSelectedModel(prev.available_models, model), - })); - } - preferences.updateAIConfig(configType, { selected_model: model, available_models: nextModels }); - }; - - const createFetchModelsHandler = - (configType: ConfigType) => - async (forceRefresh: boolean = false) => { - const config = - configType === 'transcription' - ? transcriptionConfig - : configType === 'summary' - ? summaryConfig - : embeddingConfig; - const setFetching = - configType === 'transcription' - ? setFetchingTranscriptionModels - : configType === 'summary' - ? setFetchingSummaryModels - : setFetchingEmbeddingModels; - - setFetching(true); - try { - const result = await fetchProviderModels( - config.provider, - config.base_url, - config.api_key, - configType, - { forceRefresh } - ); - if (result.success) { - const nextModels = ensureSelectedModel(result.models, config.selected_model); - const modelsLastUpdated = result.updatedAt ?? config.models_last_updated ?? null; - const modelsSource = result.source ?? config.models_source ?? null; - if (configType === 'transcription') { - setTranscriptionConfig((prev) => ({ - ...prev, - available_models: nextModels, - models_last_updated: modelsLastUpdated, - models_source: modelsSource, - })); - } else if (configType === 'summary') { - setSummaryConfig((prev) => ({ - ...prev, - available_models: nextModels, - models_last_updated: modelsLastUpdated, - models_source: modelsSource, - })); - } else { - setEmbeddingConfig((prev) => ({ - ...prev, - available_models: nextModels, - models_last_updated: modelsLastUpdated, - models_source: modelsSource, - })); - } - preferences.updateAIConfig(configType, { - available_models: nextModels, - models_last_updated: modelsLastUpdated, - models_source: modelsSource, - }); - const sourceLabel = result.source === 'cache' ? 'Loaded from cache' : 'Loaded from API'; - const forceLabel = forceRefresh ? ' (forced refresh)' : ''; - const description = - result.error || `${sourceLabel}${forceLabel} • ${nextModels.length} models`; - toast({ - title: result.stale ? 'Models loaded (stale cache)' : 'Models loaded', - description, - variant: result.stale ? 'destructive' : 'default', - }); - } else { - toast({ - title: 'Failed to fetch models', - description: result.error, - variant: 'destructive', - }); - } - } catch (error) { - toastError({ - title: 'Failed to fetch models', - error, - fallback: 'Unknown error', - }); - } finally { - setFetching(false); - } - }; - - useCachedModelCatalog('transcription', transcriptionConfig, setTranscriptionConfig); - useCachedModelCatalog('summary', summaryConfig, setSummaryConfig); - useCachedModelCatalog('embedding', embeddingConfig, setEmbeddingConfig); - - const createTestHandler = (configType: ConfigType) => async () => { - const config = - configType === 'transcription' - ? transcriptionConfig - : configType === 'summary' - ? summaryConfig - : embeddingConfig; - const setTesting = - configType === 'transcription' - ? setTestingTranscription - : configType === 'summary' - ? setTestingSummary - : setTestingEmbedding; - - setTesting(true); - try { - const result = await testProviderEndpoint( - config.provider, - config.base_url, - config.api_key, - config.selected_model, - configType - ); - const status: 'success' | 'error' = result.success ? 'success' : 'error'; - if (configType === 'transcription') { - setTranscriptionConfig((prev) => ({ - ...prev, - test_status: status, - last_tested: Date.now(), - })); - } else if (configType === 'summary') { - setSummaryConfig((prev) => ({ ...prev, test_status: status, last_tested: Date.now() })); - } else { - setEmbeddingConfig((prev) => ({ ...prev, test_status: status, last_tested: Date.now() })); - } - preferences.setAIConfigTestStatus(configType, status); - toast({ - title: result.success ? 'Connection successful' : 'Connection failed', - description: result.message + (result.latency ? ` (${result.latency}ms)` : ''), - variant: result.success ? 'default' : 'destructive', - }); - } catch (error) { - preferences.setAIConfigTestStatus(configType, 'error'); - if (configType === 'transcription') { - setTranscriptionConfig((prev) => ({ - ...prev, - test_status: 'error' as const, - last_tested: Date.now(), - })); - } else if (configType === 'summary') { - setSummaryConfig((prev) => ({ - ...prev, - test_status: 'error' as const, - last_tested: Date.now(), - })); - } else { - setEmbeddingConfig((prev) => ({ - ...prev, - test_status: 'error' as const, - last_tested: Date.now(), - })); - } - toastError({ - title: 'Connection failed', - error, - fallback: 'Unknown error', - }); - } finally { - setTesting(false); - } + const handleCloudToggle = (feature: CloudConsentFeature) => { + void toggleCloudFeature(feature); }; return ( @@ -408,14 +65,28 @@ export function AIConfigSection() {
-
- AI Configuration +
+
+ AI Configuration + {cloudConsentLoading && ( + + )} + {anyCloudEnabled && ( + + )} +
- Configure AI providers, API keys, and models for transcription, summarization, and - embeddings + Configure AI providers and enable cloud processing per feature
+ {cloudConsentError && ( + + + Cloud consent error + {cloudConsentError} + + )}
{!encryptionAvailable && ( @@ -447,6 +118,9 @@ export function AIConfigSection() { transcriptionConfig )} manualModelHint={getManualModelHint('transcription', transcriptionConfig.provider)} + cloudEnabled={cloudTranscription} + onCloudToggle={() => handleCloudToggle('transcription')} + cloudToggleLoading={cloudConsentLoading} /> handleCloudToggle('summary')} + cloudToggleLoading={cloudConsentLoading} /> handleCloudToggle('embedding')} + cloudToggleLoading={cloudConsentLoading} /> diff --git a/client/src/components/features/settings/cloud-ai-toggle.tsx b/client/src/components/features/settings/cloud-ai-toggle.tsx index 4eb3f96..32cebc9 100644 --- a/client/src/components/features/settings/cloud-ai-toggle.tsx +++ b/client/src/components/features/settings/cloud-ai-toggle.tsx @@ -1,64 +1,104 @@ -/** - * Standalone Cloud AI Processing toggle component. - * - * Displays a prominent toggle for enabling/disabling cloud AI processing. - * When enabled, meeting transcripts may be sent to cloud providers (Anthropic, OpenAI). - * When disabled, only local models are used and data stays on device. - */ - -import { Cloud, Loader2, Shield } from 'lucide-react'; +import { Cloud, Loader2, Mic, FileText, Database } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Switch } from '@/components/ui/switch'; import { useCloudConsent } from '@/hooks'; import { iconSize } from '@/lib/ui/styles'; import { cn } from '@/lib/utils'; +import type { CloudConsentFeature } from '@/api/types'; + +interface FeatureToggleProps { + feature: CloudConsentFeature; + label: string; + description: string; + icon: React.ReactNode; + isEnabled: boolean; + isLoading: boolean; + onToggle: (feature: CloudConsentFeature) => void; +} + +function FeatureToggle({ + feature, + label, + description, + icon, + isEnabled, + isLoading, + onToggle, +}: FeatureToggleProps) { + return ( +
+
+
{icon}
+
+

{label}

+

{description}

+
+
+ onToggle(feature)} + disabled={isLoading} + aria-label={`Toggle cloud ${label.toLowerCase()}`} + /> +
+ ); +} -/** - * Cloud AI Processing toggle with prominent Card styling. - * Designed for placement at the top of the AI settings tab. - */ export function CloudAIToggle() { - const { isGranted, isLoading, toggle, error } = useCloudConsent(); + const { transcription, summary, embedding, isLoading, toggleFeature, error } = useCloudConsent(); + + const anyEnabled = transcription || summary || embedding; + + const handleToggle = (feature: CloudConsentFeature) => { + void toggleFeature(feature); + }; return ( - {isGranted ? ( - - ) : ( - - )} + Cloud AI Processing + {isLoading && } - -
-
-

- {isGranted - ? 'Using cloud providers for AI summarization' - : 'Using local-only AI processing'} -

-

- {isGranted - ? 'Transcripts may be sent to Anthropic or OpenAI for higher-quality summaries.' - : 'Your data never leaves your device.'} -

-
-
- {isLoading && ( - - )} - void toggle()} - disabled={isLoading} - aria-label="Toggle cloud AI processing" - /> -
-
- {error &&

{error}

} + +

+ Control which AI features can use cloud providers. Disabled features use local-only + processing. +

+ + } + isEnabled={transcription} + isLoading={isLoading} + onToggle={handleToggle} + /> + + } + isEnabled={summary} + isLoading={isLoading} + onToggle={handleToggle} + /> + + } + isEnabled={embedding} + isLoading={isLoading} + onToggle={handleToggle} + /> + + {error &&

{error}

}
); diff --git a/client/src/components/features/settings/provider-config-card.tsx b/client/src/components/features/settings/provider-config-card.tsx index 4d2e27f..76139ae 100644 --- a/client/src/components/features/settings/provider-config-card.tsx +++ b/client/src/components/features/settings/provider-config-card.tsx @@ -1,5 +1,6 @@ import { CheckCircle2, + Cloud, Download, Eye, EyeOff, @@ -19,6 +20,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { InlineLabel } from '@/components/ui/inline-label'; import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; import { Select, SelectContent, @@ -45,6 +47,12 @@ interface ProviderConfigCardProps { isTestingEndpoint: boolean; showManualModelEntry?: boolean; manualModelHint?: string; + /** Whether cloud processing is enabled for this feature */ + cloudEnabled?: boolean; + /** Callback when cloud toggle is changed */ + onCloudToggle?: () => void; + /** Whether cloud toggle is in loading state */ + cloudToggleLoading?: boolean; } export function ProviderConfigCard({ @@ -62,6 +70,9 @@ export function ProviderConfigCard({ isTestingEndpoint, showManualModelEntry = false, manualModelHint, + cloudEnabled, + onCloudToggle, + cloudToggleLoading = false, }: ProviderConfigCardProps) { const [showApiKey, setShowApiKey] = useState(false); const destructive = ButtonVariant.DESTRUCTIVE; @@ -72,6 +83,8 @@ export function ProviderConfigCard({ onProviderChange(value, provider?.defaultUrl || ''); }; + const showCloudToggle = cloudEnabled !== undefined && onCloudToggle !== undefined; + return (
-
-

{title}

-

{description}

+
+ {showCloudToggle && ( +
e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + } + }} + > + + +
+ )} +
+

{title}

+

{description}

+
{config.test_status === 'success' && ( diff --git a/client/src/components/features/settings/use-ai-config-state.ts b/client/src/components/features/settings/use-ai-config-state.ts new file mode 100644 index 0000000..d303b0b --- /dev/null +++ b/client/src/components/features/settings/use-ai-config-state.ts @@ -0,0 +1,393 @@ +import { useEffect, useState } from 'react'; +import type { + AIProviderConfig, + AIProviderType, + TranscriptionProviderConfig, + TranscriptionProviderType, +} from '@/api/types'; +import { addClientLog } from '@/lib/observability/client'; +import { toast } from '@/hooks'; +import { toastError } from '@/lib/observability/errors'; +import { + fetchModels as fetchProviderModels, + testEndpoint as testProviderEndpoint, +} from '@/lib/ai-providers'; +import { getSecureValue, isSecureStorageAvailable, setSecureValue } from '@/lib/storage/crypto'; +import { preferences } from '@/lib/preferences'; +import { + buildResetModels, + ensureSelectedModel, +} from './ai-config-models'; +import { useCachedModelCatalog } from './ai-config-hooks'; + +export type ConfigType = 'transcription' | 'summary' | 'embedding'; + +export function useAIConfigState() { + const [transcriptionConfig, setTranscriptionConfig] = useState( + preferences.get().ai_config.transcription + ); + const [summaryConfig, setSummaryConfig] = useState( + preferences.get().ai_config.summary + ); + const [embeddingConfig, setEmbeddingConfig] = useState( + preferences.get().ai_config.embedding + ); + + const [fetchingTranscriptionModels, setFetchingTranscriptionModels] = useState(false); + const [fetchingSummaryModels, setFetchingSummaryModels] = useState(false); + const [fetchingEmbeddingModels, setFetchingEmbeddingModels] = useState(false); + const [testingTranscription, setTestingTranscription] = useState(false); + const [testingSummary, setTestingSummary] = useState(false); + const [testingEmbedding, setTestingEmbedding] = useState(false); + + const encryptionAvailable = isSecureStorageAvailable(); + + const getConfigForType = (configType: ConfigType) => + configType === 'transcription' + ? transcriptionConfig + : configType === 'summary' + ? summaryConfig + : embeddingConfig; + + useEffect(() => { + const loadKeys = async () => { + if (!encryptionAvailable) { + return; + } + try { + const [transcriptionKey, summaryKey, embeddingKey] = await Promise.all([ + getSecureValue('transcription_api_key'), + getSecureValue('summary_api_key'), + getSecureValue('embedding_api_key'), + ]); + if (transcriptionKey) { + setTranscriptionConfig((prev) => ({ ...prev, api_key: transcriptionKey })); + } + if (summaryKey) { + setSummaryConfig((prev) => ({ ...prev, api_key: summaryKey })); + } + if (embeddingKey) { + setEmbeddingConfig((prev) => ({ ...prev, api_key: embeddingKey })); + } + } catch (error) { + addClientLog({ + level: 'warning', + source: 'app', + message: 'Failed to decrypt AI config API keys', + details: error instanceof Error ? error.message : String(error), + metadata: { context: 'ai_config_key_load' }, + }); + } + }; + loadKeys(); + }, [encryptionAvailable]); + + const createProviderChangeHandler = + (configType: ConfigType) => (provider: string, baseUrl: string) => { + const currentConfig = getConfigForType(configType); + const resetModels = buildResetModels(currentConfig); + const providerType = + configType === 'transcription' + ? (provider as TranscriptionProviderType) + : (provider as AIProviderType); + + if (configType === 'transcription') { + setTranscriptionConfig((prev) => ({ + ...prev, + provider: providerType as TranscriptionProviderType, + base_url: baseUrl, + available_models: resetModels, + models_last_updated: null, + models_source: null, + test_status: 'untested' as const, + })); + } else if (configType === 'summary') { + setSummaryConfig((prev) => ({ + ...prev, + provider: providerType as AIProviderType, + base_url: baseUrl, + available_models: resetModels, + models_last_updated: null, + models_source: null, + test_status: 'untested' as const, + })); + } else { + setEmbeddingConfig((prev) => ({ + ...prev, + provider: providerType as AIProviderType, + base_url: baseUrl, + available_models: resetModels, + models_last_updated: null, + models_source: null, + test_status: 'untested' as const, + })); + } + preferences.updateAIConfig( + configType, + { + provider: providerType, + base_url: baseUrl, + available_models: resetModels, + models_last_updated: null, + models_source: null, + }, + { resetTestStatus: true } + ); + }; + + const createBaseUrlChangeHandler = (configType: ConfigType) => (url: string) => { + const currentConfig = getConfigForType(configType); + const resetModels = buildResetModels(currentConfig); + if (configType === 'transcription') { + setTranscriptionConfig((prev) => ({ + ...prev, + base_url: url, + available_models: resetModels, + models_last_updated: null, + models_source: null, + })); + } else if (configType === 'summary') { + setSummaryConfig((prev) => ({ + ...prev, + base_url: url, + available_models: resetModels, + models_last_updated: null, + models_source: null, + })); + } else { + setEmbeddingConfig((prev) => ({ + ...prev, + base_url: url, + available_models: resetModels, + models_last_updated: null, + models_source: null, + })); + } + preferences.updateAIConfig(configType, { + base_url: url, + available_models: resetModels, + models_last_updated: null, + models_source: null, + }); + }; + + const createApiKeyChangeHandler = (configType: ConfigType) => async (key: string) => { + if (configType === 'transcription') { + setTranscriptionConfig((prev) => ({ + ...prev, + api_key: key, + test_status: 'untested' as const, + })); + } else if (configType === 'summary') { + setSummaryConfig((prev) => ({ ...prev, api_key: key, test_status: 'untested' as const })); + } else { + setEmbeddingConfig((prev) => ({ ...prev, api_key: key, test_status: 'untested' as const })); + } + if (encryptionAvailable) { + try { + await setSecureValue(`${configType}_api_key`, key); + } catch (error) { + addClientLog({ + level: 'warning', + source: 'app', + message: `Secure storage write failed for ${configType} API key`, + details: error instanceof Error ? error.message : String(error), + metadata: { context: 'ai_config_secure_storage', config_type: configType }, + }); + } + } + }; + + const createModelChangeHandler = (configType: ConfigType) => (model: string) => { + const currentConfig = getConfigForType(configType); + const nextModels = ensureSelectedModel(currentConfig.available_models, model); + if (configType === 'transcription') { + setTranscriptionConfig((prev) => ({ + ...prev, + selected_model: model, + available_models: ensureSelectedModel(prev.available_models, model), + })); + } else if (configType === 'summary') { + setSummaryConfig((prev) => ({ + ...prev, + selected_model: model, + available_models: ensureSelectedModel(prev.available_models, model), + })); + } else { + setEmbeddingConfig((prev) => ({ + ...prev, + selected_model: model, + available_models: ensureSelectedModel(prev.available_models, model), + })); + } + preferences.updateAIConfig(configType, { selected_model: model, available_models: nextModels }); + }; + + const createFetchModelsHandler = + (configType: ConfigType) => + async (forceRefresh: boolean = false) => { + const config = getConfigForType(configType); + const setFetching = + configType === 'transcription' + ? setFetchingTranscriptionModels + : configType === 'summary' + ? setFetchingSummaryModels + : setFetchingEmbeddingModels; + + setFetching(true); + try { + const result = await fetchProviderModels( + config.provider, + config.base_url, + config.api_key, + configType, + { forceRefresh } + ); + if (result.success) { + const nextModels = ensureSelectedModel(result.models, config.selected_model); + const modelsLastUpdated = result.updatedAt ?? config.models_last_updated ?? null; + const modelsSource = result.source ?? config.models_source ?? null; + if (configType === 'transcription') { + setTranscriptionConfig((prev) => ({ + ...prev, + available_models: nextModels, + models_last_updated: modelsLastUpdated, + models_source: modelsSource, + })); + } else if (configType === 'summary') { + setSummaryConfig((prev) => ({ + ...prev, + available_models: nextModels, + models_last_updated: modelsLastUpdated, + models_source: modelsSource, + })); + } else { + setEmbeddingConfig((prev) => ({ + ...prev, + available_models: nextModels, + models_last_updated: modelsLastUpdated, + models_source: modelsSource, + })); + } + preferences.updateAIConfig(configType, { + available_models: nextModels, + models_last_updated: modelsLastUpdated, + models_source: modelsSource, + }); + const sourceLabel = result.source === 'cache' ? 'Loaded from cache' : 'Loaded from API'; + const forceLabel = forceRefresh ? ' (forced refresh)' : ''; + const description = + result.error || `${sourceLabel}${forceLabel} • ${nextModels.length} models`; + toast({ + title: result.stale ? 'Models loaded (stale cache)' : 'Models loaded', + description, + variant: result.stale ? 'destructive' : 'default', + }); + } else { + toast({ + title: 'Failed to fetch models', + description: result.error, + variant: 'destructive', + }); + } + } catch (error) { + toastError({ + title: 'Failed to fetch models', + error, + fallback: 'Unknown error', + }); + } finally { + setFetching(false); + } + }; + + useCachedModelCatalog('transcription', transcriptionConfig, setTranscriptionConfig); + useCachedModelCatalog('summary', summaryConfig, setSummaryConfig); + useCachedModelCatalog('embedding', embeddingConfig, setEmbeddingConfig); + + const createTestHandler = (configType: ConfigType) => async () => { + const config = getConfigForType(configType); + const setTesting = + configType === 'transcription' + ? setTestingTranscription + : configType === 'summary' + ? setTestingSummary + : setTestingEmbedding; + + setTesting(true); + try { + const result = await testProviderEndpoint( + config.provider, + config.base_url, + config.api_key, + config.selected_model, + configType + ); + const status: 'success' | 'error' = result.success ? 'success' : 'error'; + if (configType === 'transcription') { + setTranscriptionConfig((prev) => ({ + ...prev, + test_status: status, + last_tested: Date.now(), + })); + } else if (configType === 'summary') { + setSummaryConfig((prev) => ({ ...prev, test_status: status, last_tested: Date.now() })); + } else { + setEmbeddingConfig((prev) => ({ ...prev, test_status: status, last_tested: Date.now() })); + } + preferences.setAIConfigTestStatus(configType, status); + toast({ + title: result.success ? 'Connection successful' : 'Connection failed', + description: result.message + (result.latency ? ` (${result.latency}ms)` : ''), + variant: result.success ? 'default' : 'destructive', + }); + } catch (error) { + preferences.setAIConfigTestStatus(configType, 'error'); + if (configType === 'transcription') { + setTranscriptionConfig((prev) => ({ + ...prev, + test_status: 'error' as const, + last_tested: Date.now(), + })); + } else if (configType === 'summary') { + setSummaryConfig((prev) => ({ + ...prev, + test_status: 'error' as const, + last_tested: Date.now(), + })); + } else { + setEmbeddingConfig((prev) => ({ + ...prev, + test_status: 'error' as const, + last_tested: Date.now(), + })); + } + toastError({ + title: 'Connection failed', + error, + fallback: 'Unknown error', + }); + } finally { + setTesting(false); + } + }; + + return { + transcriptionConfig, + summaryConfig, + embeddingConfig, + fetchingTranscriptionModels, + fetchingSummaryModels, + fetchingEmbeddingModels, + testingTranscription, + testingSummary, + testingEmbedding, + encryptionAvailable, + createProviderChangeHandler, + createBaseUrlChangeHandler, + createApiKeyChangeHandler, + createModelChangeHandler, + createFetchModelsHandler, + createTestHandler, + }; +} diff --git a/client/src/components/layout/app-sidebar.tsx b/client/src/components/layout/app-sidebar.tsx index d29becc..00bf296 100644 --- a/client/src/components/layout/app-sidebar.tsx +++ b/client/src/components/layout/app-sidebar.tsx @@ -18,6 +18,7 @@ import { } from 'lucide-react'; import { useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; +import { AssistantDialog } from '@/components/features/assistant'; import { Button } from '@/components/ui/button'; import { ProjectSidebar } from '@/components/features/projects/ProjectSidebar'; import { useProjects } from '@/contexts/project-state'; @@ -31,6 +32,7 @@ interface AppSidebarProps { export function AppSidebar({ onStartRecording, isRecording }: AppSidebarProps) { const [collapsed, setCollapsed] = useState(false); + const [assistantOpen, setAssistantOpen] = useState(false); const location = useLocation(); const { activeProject } = useProjects(); const tags = preferences.getTags(); @@ -157,11 +159,14 @@ export function AppSidebar({ onStartRecording, isRecording }: AppSidebarProps) { variant="subtle" size={collapsed ? 'icon' : 'default'} className={cn('w-full', collapsed && 'px-0')} + onClick={() => setAssistantOpen(true)} > {!collapsed && 'Ask AI'}
+ + ); } diff --git a/client/src/components/ui/markdown-editor.test.tsx b/client/src/components/ui/markdown-editor.test.tsx index 91fabad..f1df2b8 100644 --- a/client/src/components/ui/markdown-editor.test.tsx +++ b/client/src/components/ui/markdown-editor.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react'; +import type { Editor } from '@tiptap/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MarkdownEditor } from '@/components/ui/markdown-editor'; @@ -23,6 +24,12 @@ type EditorStub = { destroy: () => void; }; +type ToggleStubProps = React.ButtonHTMLAttributes & { + children: React.ReactNode; + onPressedChange: () => void; + pressed?: boolean; +}; + const mocks = vi.hoisted(() => ({ useMarkdownEditor: vi.fn(), })); @@ -64,11 +71,9 @@ vi.mock('@/components/ui/toggle', () => ({ Toggle: ({ children, onPressedChange, + pressed: _pressed, ...props - }: { - children: React.ReactNode; - onPressedChange: () => void; - }) => ( + }: ToggleStubProps) => ( @@ -107,6 +112,8 @@ const createEditor = (id: string): { editor: EditorStub; chain: Chain } => { return { editor, chain }; }; +const asEditor = (editor: EditorStub): Editor => editor as unknown as Editor; + describe('MarkdownEditor', () => { beforeEach(() => { mocks.useMarkdownEditor.mockReset(); @@ -118,7 +125,7 @@ describe('MarkdownEditor', () => { it('renders toolbar and executes formatting commands', () => { const { editor, chain } = createEditor('internal'); - mocks.useMarkdownEditor.mockReturnValue(editor); + mocks.useMarkdownEditor.mockReturnValue(asEditor(editor)); render(); @@ -131,7 +138,7 @@ describe('MarkdownEditor', () => { it('hides toolbar when disabled', () => { const { editor } = createEditor('internal'); - mocks.useMarkdownEditor.mockReturnValue(editor); + mocks.useMarkdownEditor.mockReturnValue(asEditor(editor)); render(); @@ -150,16 +157,16 @@ describe('MarkdownEditor', () => { it('uses external editor when provided', () => { const { editor } = createEditor('external'); - mocks.useMarkdownEditor.mockReturnValue(createEditor('internal').editor); + mocks.useMarkdownEditor.mockReturnValue(asEditor(createEditor('internal').editor)); - render(); + render(); expect(screen.getByTestId('editor-content')).toHaveAttribute('data-editor-id', 'external'); }); it('destroys internal editor on unmount', () => { const { editor } = createEditor('internal'); - mocks.useMarkdownEditor.mockReturnValue(editor); + mocks.useMarkdownEditor.mockReturnValue(asEditor(editor)); const { unmount } = render(); unmount(); @@ -169,9 +176,11 @@ describe('MarkdownEditor', () => { it('does not destroy external editor on unmount', () => { const { editor } = createEditor('external'); - mocks.useMarkdownEditor.mockReturnValue(createEditor('internal').editor); + mocks.useMarkdownEditor.mockReturnValue(asEditor(createEditor('internal').editor)); - const { unmount } = render(); + const { unmount } = render( + + ); unmount(); expect(editor.destroy).not.toHaveBeenCalled(); @@ -179,7 +188,7 @@ describe('MarkdownEditor', () => { it('passes editable false when disabled', () => { const { editor } = createEditor('internal'); - mocks.useMarkdownEditor.mockReturnValue(editor); + mocks.useMarkdownEditor.mockReturnValue(asEditor(editor)); render(); diff --git a/client/src/contexts/project-context.test.tsx b/client/src/contexts/project-context.test.tsx index 8e9e6d5..bbed44f 100644 --- a/client/src/contexts/project-context.test.tsx +++ b/client/src/contexts/project-context.test.tsx @@ -6,7 +6,7 @@ import { IdentityDefaults } from '@/api'; import type { Project } from '@/api/types'; import { ProjectProvider } from '@/contexts/project-context'; import { useProjects } from '@/contexts/project-state'; -import { useWorkspace } from '@/contexts/workspace-state'; +import { useWorkspace, type WorkspaceContextValue } from '@/contexts/workspace-state'; import { readStorageRaw, writeStorageRaw } from '@/lib/storage/utils'; const apiState = vi.hoisted(() => ({ @@ -125,6 +125,22 @@ function ProjectConsumer() { return
consumer
; } +const buildWorkspaceContext = ( + overrides: Partial = {} +): WorkspaceContextValue => ({ + currentWorkspace: { + id: 'workspace-1', + name: 'Workspace One', + role: 'owner', + }, + workspaces: [], + currentUser: null, + switchWorkspace: vi.fn().mockResolvedValue(undefined), + isLoading: false, + error: null, + ...overrides, +}); + describe('ProjectProvider', () => { beforeEach(() => { apiState.listProjects.mockReset(); @@ -137,13 +153,12 @@ describe('ProjectProvider', () => { apiState.deleteProject.mockReset(); vi.mocked(readStorageRaw).mockReset(); vi.mocked(writeStorageRaw).mockReset(); - vi.mocked(useWorkspace).mockReturnValue({ - currentWorkspace: { id: 'workspace-1', role: 'owner' }, - }); + vi.mocked(useWorkspace).mockReturnValue(buildWorkspaceContext()); apiState.setActiveProject.mockResolvedValue({}); }); - it('throws when useProjects is used outside provider', () => { + it('throws when useProjects is used outside provider', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); const onError = vi.fn(); render( @@ -154,6 +169,8 @@ describe('ProjectProvider', () => { new Error('useProjects must be used within ProjectProvider') ); expect(screen.getByTestId('error-boundary')).toBeInTheDocument(); + await new Promise((resolve) => queueMicrotask(() => resolve())); + consoleSpy.mockRestore(); }); it('loads projects and prefers active project from API', async () => { @@ -301,7 +318,11 @@ describe('ProjectProvider', () => { }); it('throws when creating a project without a workspace', async () => { - vi.mocked(useWorkspace).mockReturnValue({ currentWorkspace: null }); + vi.mocked(useWorkspace).mockReturnValue( + buildWorkspaceContext({ + currentWorkspace: null, + }) + ); const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); @@ -336,7 +357,7 @@ describe('ProjectProvider', () => { screen.getByRole('button', { name: 'Archive' }).click(); }); - await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(() => resolve())); expect(apiState.setActiveProject).toHaveBeenCalledWith({ workspace_id: 'workspace-1', @@ -366,7 +387,7 @@ describe('ProjectProvider', () => { screen.getByRole('button', { name: 'Delete' }).click(); }); - await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(() => resolve())); expect(apiState.setActiveProject).toHaveBeenCalledWith({ workspace_id: 'workspace-1', diff --git a/client/src/hooks/audio/use-asr-config.test.ts b/client/src/hooks/audio/use-asr-config.test.ts index 570c3f2..20551d6 100644 --- a/client/src/hooks/audio/use-asr-config.test.ts +++ b/client/src/hooks/audio/use-asr-config.test.ts @@ -175,7 +175,6 @@ describe('useAsrConfig', () => { }); it('updates config when job completes with new configuration', async () => { - vi.useFakeTimers(); mockAPI.getAsrConfiguration.mockResolvedValue(mockConfig); mockAPI.updateAsrConfiguration.mockResolvedValue({ jobId: 'job-123', @@ -195,7 +194,7 @@ describe('useAsrConfig', () => { const { result } = renderHook(() => useAsrConfig()); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.isLoading).toBe(false); }); @@ -203,11 +202,7 @@ describe('useAsrConfig', () => { await result.current.updateConfig({ modelSize: 'small' }); }); - await act(async () => { - await vi.advanceTimersByTimeAsync(500); - }); - - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.isReconfiguring).toBe(false); }); @@ -215,7 +210,6 @@ describe('useAsrConfig', () => { }); it('refreshes config when job completes without new configuration', async () => { - vi.useFakeTimers(); const updatedConfig = { ...mockConfig, modelSize: 'small' as const }; mockAPI.getAsrConfiguration .mockResolvedValueOnce(mockConfig) @@ -237,7 +231,7 @@ describe('useAsrConfig', () => { const { result } = renderHook(() => useAsrConfig()); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.isLoading).toBe(false); }); @@ -245,11 +239,7 @@ describe('useAsrConfig', () => { await result.current.updateConfig({ modelSize: 'small' }); }); - await act(async () => { - await vi.advanceTimersByTimeAsync(500); - }); - - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.isReconfiguring).toBe(false); }); @@ -258,7 +248,6 @@ describe('useAsrConfig', () => { }); it('stops reconfiguring and reports failure status', async () => { - vi.useFakeTimers(); mockAPI.getAsrConfiguration.mockResolvedValue(mockConfig); mockAPI.updateAsrConfiguration.mockResolvedValue({ jobId: 'job-789', @@ -277,7 +266,7 @@ describe('useAsrConfig', () => { const { result } = renderHook(() => useAsrConfig()); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.isLoading).toBe(false); }); @@ -285,11 +274,7 @@ describe('useAsrConfig', () => { await result.current.updateConfig({ modelSize: 'small' }); }); - await act(async () => { - await vi.advanceTimersByTimeAsync(500); - }); - - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.isReconfiguring).toBe(false); }); @@ -297,7 +282,6 @@ describe('useAsrConfig', () => { }); it('stops reconfiguring and reports cancelled status', async () => { - vi.useFakeTimers(); mockAPI.getAsrConfiguration.mockResolvedValue(mockConfig); mockAPI.updateAsrConfiguration.mockResolvedValue({ jobId: 'job-999', @@ -316,7 +300,7 @@ describe('useAsrConfig', () => { const { result } = renderHook(() => useAsrConfig()); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.isLoading).toBe(false); }); @@ -324,11 +308,7 @@ describe('useAsrConfig', () => { await result.current.updateConfig({ modelSize: 'small' }); }); - await act(async () => { - await vi.advanceTimersByTimeAsync(500); - }); - - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.isReconfiguring).toBe(false); }); diff --git a/client/src/hooks/audio/use-audio-devices.test.ts b/client/src/hooks/audio/use-audio-devices.test.ts index ac3af61..b9268fa 100644 --- a/client/src/hooks/audio/use-audio-devices.test.ts +++ b/client/src/hooks/audio/use-audio-devices.test.ts @@ -8,8 +8,10 @@ import { toastError } from '@/lib/observability/errors'; import { clientLog } from '@/lib/observability/events'; import { addClientLog } from '@/lib/observability/client'; import { preferences } from '@/lib/preferences'; +import { defaultPreferences } from '@/lib/preferences/constants'; import { TauriEvents, useTauriEvent } from '@/lib/system/events'; import { useAudioDevices } from './use-audio-devices'; +import type { UserPreferences } from '@/api/types'; const tauriHandlers = new Map void>(); @@ -103,6 +105,38 @@ class MockAudioContext { close = vi.fn(); } +const captureAsyncError = async (action: () => Promise) => { + let caught: unknown; + await act(async () => { + try { + await action(); + } catch (error) { + caught = error; + } + }); + return caught; +}; + +type PreferencesOverrides = Omit, 'audio_devices'> & { + audio_devices?: Partial; +}; + +const buildPreferences = (overrides: PreferencesOverrides = {}): UserPreferences => ({ + ...defaultPreferences, + ...overrides, + audio_devices: { + ...defaultPreferences.audio_devices, + ...overrides.audio_devices, + }, +}); + +const buildAudioDevices = ( + overrides: Partial = {} +): UserPreferences['audio_devices'] => ({ + ...defaultPreferences.audio_devices, + ...overrides, +}); + describe('useAudioDevices', () => { beforeEach(() => { vi.useFakeTimers(); @@ -118,9 +152,11 @@ describe('useAudioDevices', () => { enumerateDevices: vi.fn(), }, }); - vi.mocked(preferences.get).mockReturnValue({ - audio_devices: { input_device_id: '', output_device_id: '' }, - }); + vi.mocked(preferences.get).mockReturnValue( + buildPreferences({ + audio_devices: { input_device_id: '', output_device_id: '' }, + }) + ); tauriHandlers.clear(); }); @@ -133,7 +169,7 @@ describe('useAudioDevices', () => { it('loads devices in browser mode and selects defaults', async () => { vi.mocked(isTauriEnvironment).mockReturnValue(false); const mockStream = { getTracks: () => [] }; - const mediaDevices = navigator.mediaDevices as { + const mediaDevices = navigator.mediaDevices as unknown as { getUserMedia: ReturnType; enumerateDevices: ReturnType; }; @@ -158,7 +194,7 @@ describe('useAudioDevices', () => { it('handles browser permission errors', async () => { vi.mocked(isTauriEnvironment).mockReturnValue(false); - const mediaDevices = navigator.mediaDevices as { + const mediaDevices = navigator.mediaDevices as unknown as { getUserMedia: ReturnType; enumerateDevices: ReturnType; }; @@ -176,7 +212,7 @@ describe('useAudioDevices', () => { it('suppresses toasts on permission errors when disabled', async () => { vi.mocked(isTauriEnvironment).mockReturnValue(false); - const mediaDevices = navigator.mediaDevices as { + const mediaDevices = navigator.mediaDevices as unknown as { getUserMedia: ReturnType; enumerateDevices: ReturnType; }; @@ -236,22 +272,26 @@ describe('useAudioDevices', () => { it('syncs tauri preferences when they differ', async () => { vi.mocked(isTauriEnvironment).mockReturnValue(true); - vi.mocked(preferences.get).mockReturnValue({ - audio_devices: { - input_device_id: 'local-input', - output_device_id: 'local-output', - system_device_id: 'local-system', - }, - }); + vi.mocked(preferences.get).mockReturnValue( + buildPreferences({ + audio_devices: { + input_device_id: 'local-input', + output_device_id: 'local-output', + system_device_id: 'local-system', + }, + }) + ); vi.mocked(getAPI).mockReturnValue({ - getPreferences: vi.fn().mockResolvedValue({ - audio_devices: { - input_device_id: 'tauri-input', - output_device_id: 'tauri-output', - system_device_id: 'tauri-system', - }, - }), + getPreferences: vi.fn().mockResolvedValue( + buildPreferences({ + audio_devices: { + input_device_id: 'tauri-input', + output_device_id: 'tauri-output', + system_device_id: 'tauri-system', + }, + }) + ), listAudioDevices: vi.fn().mockResolvedValue([]), listLoopbackDevices: vi.fn().mockResolvedValue([]), getDualCaptureConfig: vi.fn().mockResolvedValue({ @@ -376,14 +416,24 @@ describe('useAudioDevices', () => { it('reconciles legacy tauri device IDs to stable IDs', async () => { vi.mocked(isTauriEnvironment).mockReturnValue(true); - vi.mocked(preferences.get).mockReturnValue({ - audio_devices: { input_device_id: 'input:0:Mic', output_device_id: 'output:1:Speakers' }, - }); + vi.mocked(preferences.get).mockReturnValue( + buildPreferences({ + audio_devices: buildAudioDevices({ + input_device_id: 'input:0:Mic', + output_device_id: 'output:1:Speakers', + }), + }) + ); const selectAudioDevice = vi.fn(); vi.mocked(getAPI).mockReturnValue({ - getPreferences: vi.fn().mockResolvedValue({ - audio_devices: { input_device_id: 'input:0:Mic', output_device_id: 'output:1:Speakers' }, - }), + getPreferences: vi.fn().mockResolvedValue( + buildPreferences({ + audio_devices: buildAudioDevices({ + input_device_id: 'input:0:Mic', + output_device_id: 'output:1:Speakers', + }), + }) + ), listAudioDevices: vi.fn().mockResolvedValue([ { id: 'input:Mic', name: 'Mic', is_input: true }, { id: 'output:Speakers', name: 'Speakers', is_input: false }, @@ -457,7 +507,10 @@ describe('useAudioDevices', () => { await result.current.loadDevices(); }); - await expect(result.current.setInputDevice('mic-1')).rejects.toThrow('input fail'); + const error = await captureAsyncError(() => result.current.setInputDevice('mic-1')); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('input fail'); expect(clientLog.deviceSelectionFailed).toHaveBeenCalledWith('input', 'mic-1', 'input fail'); expect(toastError).toHaveBeenCalledWith( expect.objectContaining({ title: 'Device selection issue' }) @@ -500,7 +553,10 @@ describe('useAudioDevices', () => { await result.current.loadDevices(); }); - await expect(result.current.setOutputDevice('spk-1')).rejects.toThrow('output fail'); + const error = await captureAsyncError(() => result.current.setOutputDevice('spk-1')); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('output fail'); expect(clientLog.deviceSelectionFailed).toHaveBeenCalledWith('output', 'spk-1', 'output fail'); expect(toastError).toHaveBeenCalledWith( expect.objectContaining({ title: 'Device selection issue' }) @@ -515,7 +571,10 @@ describe('useAudioDevices', () => { const { result } = renderHook(() => useAudioDevices()); - await expect(result.current.setSystemDevice('system-1')).rejects.toThrow('system fail'); + const error = await captureAsyncError(() => result.current.setSystemDevice('system-1')); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('system fail'); expect(clientLog.deviceSelectionFailed).toHaveBeenCalledWith('system', 'system-1', 'system fail'); expect(toastError).toHaveBeenCalledWith( expect.objectContaining({ title: 'Device selection issue' }) @@ -570,7 +629,10 @@ describe('useAudioDevices', () => { const { result } = renderHook(() => useAudioDevices()); - await expect(result.current.setDualCaptureEnabled(true)).rejects.toThrow('dual fail'); + const error = await captureAsyncError(() => result.current.setDualCaptureEnabled(true)); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('dual fail'); expect(toastError).toHaveBeenCalledWith( expect.objectContaining({ title: 'Dual capture error' }) ); @@ -584,7 +646,10 @@ describe('useAudioDevices', () => { const { result } = renderHook(() => useAudioDevices()); - await expect(result.current.setMixLevels(0.5, 0.2)).rejects.toThrow('mix fail'); + const error = await captureAsyncError(() => result.current.setMixLevels(0.5, 0.2)); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('mix fail'); expect(toastError).toHaveBeenCalledWith( expect.objectContaining({ title: 'Audio mix error' }) ); @@ -593,7 +658,7 @@ describe('useAudioDevices', () => { it('starts and stops input testing in browser mode', async () => { vi.mocked(isTauriEnvironment).mockReturnValue(false); const stopTrack = vi.fn(); - const mediaDevices = navigator.mediaDevices as { + const mediaDevices = navigator.mediaDevices as unknown as { getUserMedia: ReturnType; enumerateDevices: ReturnType; }; @@ -619,7 +684,7 @@ describe('useAudioDevices', () => { it('shows toast when starting input test in browser mode', async () => { vi.mocked(isTauriEnvironment).mockReturnValue(false); - const mediaDevices = navigator.mediaDevices as { + const mediaDevices = navigator.mediaDevices as unknown as { getUserMedia: ReturnType; enumerateDevices: ReturnType; }; @@ -636,7 +701,7 @@ describe('useAudioDevices', () => { it('handles browser input test errors with toast', async () => { vi.mocked(isTauriEnvironment).mockReturnValue(false); - const mediaDevices = navigator.mediaDevices as { + const mediaDevices = navigator.mediaDevices as unknown as { getUserMedia: ReturnType; enumerateDevices: ReturnType; }; @@ -695,16 +760,16 @@ describe('useAudioDevices', () => { it('persists selected devices across hook remounts', async () => { vi.mocked(isTauriEnvironment).mockReturnValue(true); - const prefsState = { - audio_devices: { + const prefsState = buildPreferences({ + audio_devices: buildAudioDevices({ input_device_id: '', output_device_id: '', system_device_id: '', dual_capture_enabled: false, mic_gain: 1, system_gain: 1, - }, - }; + }), + }); vi.mocked(preferences.get).mockImplementation(() => prefsState); const deviceSetters = { @@ -723,7 +788,9 @@ describe('useAudioDevices', () => { vi.mocked(getAPI).mockReturnValue({ getPreferences: vi .fn() - .mockImplementation(() => Promise.resolve({ audio_devices: prefsState.audio_devices })), + .mockImplementation(() => + Promise.resolve(buildPreferences({ audio_devices: prefsState.audio_devices })) + ), listAudioDevices: vi.fn().mockResolvedValue([ { id: 'input:Mic', name: 'Mic', is_input: true }, { id: 'output:Speakers', name: 'Speakers', is_input: false }, @@ -907,7 +974,7 @@ describe('useAudioDevices', () => { expect(toast).not.toHaveBeenCalled(); }); - it('ignores tauri audio test events when not testing input', () => { + it('ignores tauri audio test events when not testing input', async () => { const { result } = renderHook(() => useAudioDevices({ showToasts: false })); const lastCall = vi.mocked(useTauriEvent).mock.calls.at(-1); const handler = lastCall?.[1] as ((event: { level: number }) => void) | undefined; @@ -915,10 +982,15 @@ describe('useAudioDevices', () => { return; } + await act(async () => { + await vi.runAllTimersAsync(); + }); + const initialLevel = result.current.inputLevel; - act(() => { + await act(async () => { handler({ level: 0.9 }); + await vi.runAllTimersAsync(); }); expect(result.current.inputLevel).toBe(initialLevel); @@ -926,11 +998,16 @@ describe('useAudioDevices', () => { it('does not auto-select devices when preferences already set', async () => { vi.mocked(isTauriEnvironment).mockReturnValue(false); - vi.mocked(preferences.get).mockReturnValue({ - audio_devices: { input_device_id: 'mic-1', output_device_id: 'spk-1' }, - }); + vi.mocked(preferences.get).mockReturnValue( + buildPreferences({ + audio_devices: buildAudioDevices({ + input_device_id: 'mic-1', + output_device_id: 'spk-1', + }), + }) + ); - const mediaDevices = navigator.mediaDevices as { + const mediaDevices = navigator.mediaDevices as unknown as { getUserMedia: ReturnType; enumerateDevices: ReturnType; }; diff --git a/client/src/hooks/auth/use-cloud-consent.test.ts b/client/src/hooks/auth/use-cloud-consent.test.ts index 8783862..5ff0b97 100644 --- a/client/src/hooks/auth/use-cloud-consent.test.ts +++ b/client/src/hooks/auth/use-cloud-consent.test.ts @@ -33,12 +33,12 @@ describe('useCloudConsent', () => { const { result } = renderHook(() => useCloudConsent()); expect(result.current.isLoading).toBe(true); - expect(result.current.isGranted).toBe(false); + expect(result.current.summary).toBe(false); expect(result.current.error).toBeNull(); }); it('loads initial consent status as true', async () => { - mockAPI.getCloudConsentStatus.mockResolvedValue({ consentGranted: true }); + mockAPI.getCloudConsentStatus.mockResolvedValue({ summaryConsent: true }); const { result } = renderHook(() => useCloudConsent()); @@ -46,12 +46,12 @@ describe('useCloudConsent', () => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.isGranted).toBe(true); + expect(result.current.summary).toBe(true); expect(result.current.error).toBeNull(); }); it('loads initial consent status as false', async () => { - mockAPI.getCloudConsentStatus.mockResolvedValue({ consentGranted: false }); + mockAPI.getCloudConsentStatus.mockResolvedValue({ summaryConsent: false }); const { result } = renderHook(() => useCloudConsent()); @@ -59,7 +59,7 @@ describe('useCloudConsent', () => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.isGranted).toBe(false); + expect(result.current.summary).toBe(false); expect(result.current.error).toBeNull(); }); @@ -72,7 +72,7 @@ describe('useCloudConsent', () => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.isGranted).toBe(false); + expect(result.current.summary).toBe(false); expect(result.current.error).toBe('Network error'); }); @@ -85,14 +85,14 @@ describe('useCloudConsent', () => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.isGranted).toBe(false); + expect(result.current.summary).toBe(false); expect(result.current.error).toBe('Failed to load consent status'); }); }); describe('grant', () => { it('grants consent successfully', async () => { - mockAPI.getCloudConsentStatus.mockResolvedValue({ consentGranted: false }); + mockAPI.getCloudConsentStatus.mockResolvedValue({ summaryConsent: false }); mockAPI.grantCloudConsent.mockResolvedValue(undefined); const { result } = renderHook(() => useCloudConsent()); @@ -101,19 +101,19 @@ describe('useCloudConsent', () => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.isGranted).toBe(false); + expect(result.current.summary).toBe(false); await act(async () => { - await result.current.grant(); + await result.current.grantFeature('summary'); }); - expect(result.current.isGranted).toBe(true); + expect(result.current.summary).toBe(true); expect(result.current.error).toBeNull(); expect(mockAPI.grantCloudConsent).toHaveBeenCalledOnce(); }); it('handles grant error', async () => { - mockAPI.getCloudConsentStatus.mockResolvedValue({ consentGranted: false }); + mockAPI.getCloudConsentStatus.mockResolvedValue({ summaryConsent: false }); mockAPI.grantCloudConsent.mockRejectedValue(new Error('Permission denied')); const { result } = renderHook(() => useCloudConsent()); @@ -125,7 +125,7 @@ describe('useCloudConsent', () => { // The grant call will throw, but we need to catch it to check state await act(async () => { try { - await result.current.grant(); + await result.current.grantFeature('summary'); } catch { // Expected to throw } @@ -134,11 +134,11 @@ describe('useCloudConsent', () => { await waitFor(() => { expect(result.current.error).toBe('Permission denied'); }); - expect(result.current.isGranted).toBe(false); + expect(result.current.summary).toBe(false); }); it('handles grant error with non-Error values', async () => { - mockAPI.getCloudConsentStatus.mockResolvedValue({ consentGranted: false }); + mockAPI.getCloudConsentStatus.mockResolvedValue({ summaryConsent: false }); mockAPI.grantCloudConsent.mockRejectedValue('denied'); const { result } = renderHook(() => useCloudConsent()); @@ -149,7 +149,7 @@ describe('useCloudConsent', () => { await act(async () => { try { - await result.current.grant(); + await result.current.grantFeature('summary'); } catch { // Expected to throw } @@ -158,13 +158,13 @@ describe('useCloudConsent', () => { await waitFor(() => { expect(result.current.error).toBe('Failed to grant consent'); }); - expect(result.current.isGranted).toBe(false); + expect(result.current.summary).toBe(false); }); }); describe('revoke', () => { it('revokes consent successfully', async () => { - mockAPI.getCloudConsentStatus.mockResolvedValue({ consentGranted: true }); + mockAPI.getCloudConsentStatus.mockResolvedValue({ summaryConsent: true }); mockAPI.revokeCloudConsent.mockResolvedValue(undefined); const { result } = renderHook(() => useCloudConsent()); @@ -173,19 +173,19 @@ describe('useCloudConsent', () => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.isGranted).toBe(true); + expect(result.current.summary).toBe(true); await act(async () => { - await result.current.revoke(); + await result.current.revokeFeature('summary'); }); - expect(result.current.isGranted).toBe(false); + expect(result.current.summary).toBe(false); expect(result.current.error).toBeNull(); expect(mockAPI.revokeCloudConsent).toHaveBeenCalledOnce(); }); it('handles revoke error', async () => { - mockAPI.getCloudConsentStatus.mockResolvedValue({ consentGranted: true }); + mockAPI.getCloudConsentStatus.mockResolvedValue({ summaryConsent: true }); mockAPI.revokeCloudConsent.mockRejectedValue(new Error('Server error')); const { result } = renderHook(() => useCloudConsent()); @@ -197,7 +197,7 @@ describe('useCloudConsent', () => { // The revoke call will throw, but we need to catch it to check state await act(async () => { try { - await result.current.revoke(); + await result.current.revokeFeature('summary'); } catch { // Expected to throw } @@ -206,11 +206,11 @@ describe('useCloudConsent', () => { await waitFor(() => { expect(result.current.error).toBe('Server error'); }); - expect(result.current.isGranted).toBe(true); // State unchanged on error + expect(result.current.summary).toBe(true); // State unchanged on error }); it('handles revoke error with non-Error values', async () => { - mockAPI.getCloudConsentStatus.mockResolvedValue({ consentGranted: true }); + mockAPI.getCloudConsentStatus.mockResolvedValue({ summaryConsent: true }); mockAPI.revokeCloudConsent.mockRejectedValue(123); const { result } = renderHook(() => useCloudConsent()); @@ -221,7 +221,7 @@ describe('useCloudConsent', () => { await act(async () => { try { - await result.current.revoke(); + await result.current.revokeFeature('summary'); } catch { // Expected to throw } @@ -230,13 +230,13 @@ describe('useCloudConsent', () => { await waitFor(() => { expect(result.current.error).toBe('Failed to revoke consent'); }); - expect(result.current.isGranted).toBe(true); + expect(result.current.summary).toBe(true); }); }); describe('toggle', () => { it('toggles from false to true', async () => { - mockAPI.getCloudConsentStatus.mockResolvedValue({ consentGranted: false }); + mockAPI.getCloudConsentStatus.mockResolvedValue({ summaryConsent: false }); mockAPI.grantCloudConsent.mockResolvedValue(undefined); const { result } = renderHook(() => useCloudConsent()); @@ -246,16 +246,16 @@ describe('useCloudConsent', () => { }); await act(async () => { - await result.current.toggle(); + await result.current.toggleFeature('summary'); }); - expect(result.current.isGranted).toBe(true); + expect(result.current.summary).toBe(true); expect(mockAPI.grantCloudConsent).toHaveBeenCalledOnce(); expect(mockAPI.revokeCloudConsent).not.toHaveBeenCalled(); }); it('toggles from true to false', async () => { - mockAPI.getCloudConsentStatus.mockResolvedValue({ consentGranted: true }); + mockAPI.getCloudConsentStatus.mockResolvedValue({ summaryConsent: true }); mockAPI.revokeCloudConsent.mockResolvedValue(undefined); const { result } = renderHook(() => useCloudConsent()); @@ -265,10 +265,10 @@ describe('useCloudConsent', () => { }); await act(async () => { - await result.current.toggle(); + await result.current.toggleFeature('summary'); }); - expect(result.current.isGranted).toBe(false); + expect(result.current.summary).toBe(false); expect(mockAPI.revokeCloudConsent).toHaveBeenCalledOnce(); expect(mockAPI.grantCloudConsent).not.toHaveBeenCalled(); }); @@ -276,7 +276,7 @@ describe('useCloudConsent', () => { describe('loading states during operations', () => { it('sets isLoading during grant', async () => { - mockAPI.getCloudConsentStatus.mockResolvedValue({ consentGranted: false }); + mockAPI.getCloudConsentStatus.mockResolvedValue({ summaryConsent: false }); let resolveGrant: (() => void) | null = null; mockAPI.grantCloudConsent.mockImplementation( () => @@ -294,7 +294,7 @@ describe('useCloudConsent', () => { // Start grant operation let grantPromise: Promise | null = null; act(() => { - grantPromise = result.current.grant(); + grantPromise = result.current.grantFeature('summary'); }); // Should be loading during operation diff --git a/client/src/hooks/auth/use-cloud-consent.ts b/client/src/hooks/auth/use-cloud-consent.ts index 292e1ea..48a3af8 100644 --- a/client/src/hooks/auth/use-cloud-consent.ts +++ b/client/src/hooks/auth/use-cloud-consent.ts @@ -1,41 +1,42 @@ -/** - * Hook for managing cloud consent state. - * - * Cloud consent controls whether AI summarization can use cloud-based - * providers (like Anthropic, OpenAI) or must use local-only providers. - */ - import { useCallback, useEffect, useState } from 'react'; import { getAPI } from '@/api/interface'; import { extractErrorMessage } from '@/api'; +import type { CloudConsentFeature } from '@/api/types'; interface UseCloudConsentReturn { - /** Whether cloud consent is currently granted. */ - isGranted: boolean; - /** Whether the consent status is being loaded or changed. */ + transcription: boolean; + summary: boolean; + embedding: boolean; isLoading: boolean; - /** Toggle consent state (grant if not granted, revoke if granted). */ - toggle: () => Promise; - /** Grant consent for cloud processing. */ - grant: () => Promise; - /** Revoke consent for cloud processing. */ - revoke: () => Promise; - /** Error message if toggle failed. */ error: string | null; + grantFeature: (feature: CloudConsentFeature) => Promise; + revokeFeature: (feature: CloudConsentFeature) => Promise; + toggleFeature: (feature: CloudConsentFeature) => Promise; + /** @deprecated Use transcription/summary/embedding instead */ + isGranted: boolean; + /** @deprecated Use toggleFeature('summary') instead */ + toggle: () => Promise; + /** @deprecated Use grantFeature('summary') instead */ + grant: () => Promise; + /** @deprecated Use revokeFeature('summary') instead */ + revoke: () => Promise; } export function useCloudConsent(): UseCloudConsentReturn { - const [isGranted, setIsGranted] = useState(false); + const [transcription, setTranscription] = useState(false); + const [summary, setSummary] = useState(false); + const [embedding, setEmbedding] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - // Load initial consent status useEffect(() => { const loadStatus = async () => { try { const api = getAPI(); const status = await api.getCloudConsentStatus(); - setIsGranted(status.consentGranted); + setTranscription(status.transcriptionConsent ?? false); + setSummary(status.summaryConsent ?? false); + setEmbedding(status.embeddingConsent ?? false); setError(null); } catch (err) { setError(extractErrorMessage(err, 'Failed to load consent status')); @@ -46,50 +47,93 @@ export function useCloudConsent(): UseCloudConsentReturn { void loadStatus(); }, []); - const grant = useCallback(async () => { + const grantFeature = useCallback(async (feature: CloudConsentFeature) => { setIsLoading(true); setError(null); + const failureMessage = + feature === 'summary' ? 'Failed to grant consent' : `Failed to grant ${feature} consent`; try { const api = getAPI(); - await api.grantCloudConsent(); - setIsGranted(true); + if (typeof api.grantCloudConsentFeature === 'function') { + await api.grantCloudConsentFeature(feature); + } else { + await api.grantCloudConsent(); + } + if (feature === 'transcription') { + setTranscription(true); + } else if (feature === 'summary') { + setSummary(true); + } else if (feature === 'embedding') { + setEmbedding(true); + } } catch (err) { - setError(extractErrorMessage(err, 'Failed to grant consent')); + setError(extractErrorMessage(err, failureMessage)); throw err; } finally { setIsLoading(false); } }, []); - const revoke = useCallback(async () => { + const revokeFeature = useCallback(async (feature: CloudConsentFeature) => { setIsLoading(true); setError(null); + const failureMessage = + feature === 'summary' ? 'Failed to revoke consent' : `Failed to revoke ${feature} consent`; try { const api = getAPI(); - await api.revokeCloudConsent(); - setIsGranted(false); + if (typeof api.revokeCloudConsentFeature === 'function') { + await api.revokeCloudConsentFeature(feature); + } else { + await api.revokeCloudConsent(); + } + if (feature === 'transcription') { + setTranscription(false); + } else if (feature === 'summary') { + setSummary(false); + } else if (feature === 'embedding') { + setEmbedding(false); + } } catch (err) { - setError(extractErrorMessage(err, 'Failed to revoke consent')); + setError(extractErrorMessage(err, failureMessage)); throw err; } finally { setIsLoading(false); } }, []); - const toggle = useCallback(async () => { - if (isGranted) { - await revoke(); - } else { - await grant(); - } - }, [isGranted, grant, revoke]); + const toggleFeature = useCallback( + async (feature: CloudConsentFeature) => { + const isCurrentlyGranted = + feature === 'transcription' + ? transcription + : feature === 'summary' + ? summary + : embedding; + if (isCurrentlyGranted) { + await revokeFeature(feature); + } else { + await grantFeature(feature); + } + }, + [transcription, summary, embedding, grantFeature, revokeFeature] + ); + + const grant = useCallback(() => grantFeature('summary'), [grantFeature]); + const revoke = useCallback(() => revokeFeature('summary'), [revokeFeature]); + const toggle = useCallback(() => toggleFeature('summary'), [toggleFeature]); return { - isGranted, + transcription, + summary, + embedding, isLoading, + error, + grantFeature, + revokeFeature, + toggleFeature, + isGranted: summary, toggle, grant, revoke, - error, }; } diff --git a/client/src/hooks/data/use-async-data.test.tsx b/client/src/hooks/data/use-async-data.test.tsx index e854752..260f4a0 100644 --- a/client/src/hooks/data/use-async-data.test.tsx +++ b/client/src/hooks/data/use-async-data.test.tsx @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { useAsyncData, useMutation } from './use-async-data'; import { extractErrorMessage } from '@/api'; @@ -49,14 +49,16 @@ describe('useAsyncData', () => { expect(result.current.isLoading).toBe(false); expect(fetcher).not.toHaveBeenCalled(); - result.current.refetch(); + await act(async () => { + result.current.refetch(); + }); expect(fetcher).not.toHaveBeenCalled(); }); it('supports refetch and reset', async () => { const fetcher = vi.fn().mockResolvedValue('first'); const { result, rerender } = renderHook( - ({ initialData }) => useAsyncData(fetcher, [], { initialData }), + ({ initialData }: { initialData: string }) => useAsyncData(fetcher, [], { initialData }), { initialProps: { initialData: 'initial' } } ); @@ -66,7 +68,9 @@ describe('useAsyncData', () => { expect(result.current.data).toBe('first'); - result.current.reset(); + await act(async () => { + result.current.reset(); + }); await waitFor(() => { expect(result.current.data).toBe('initial'); @@ -74,7 +78,9 @@ describe('useAsyncData', () => { }); rerender({ initialData: 'next' }); - result.current.refetch(); + await act(async () => { + result.current.refetch(); + }); await waitFor(() => { expect(fetcher).toHaveBeenCalledTimes(2); @@ -93,12 +99,18 @@ describe('useAsyncData', () => { useMutation(mutationFn, { onSuccess, onError }) ); - const success = await result.current.mutate('payload'); + let success: MutationResult | undefined; + await act(async () => { + success = await result.current.mutate('payload'); + }); expect(success).toEqual({ ok: true }); expect(onSuccess).toHaveBeenCalledWith({ ok: true }); mutationFn.mockRejectedValueOnce(new Error('boom')); - const failure = await result.current.mutate('payload'); + let failure: MutationResult | undefined; + await act(async () => { + failure = await result.current.mutate('payload'); + }); expect(failure).toBeUndefined(); expect(onError).toHaveBeenCalledWith('Boom'); }); @@ -107,12 +119,16 @@ describe('useAsyncData', () => { const mutationFn = vi.fn().mockResolvedValue('ok'); const { result } = renderHook(() => useMutation(mutationFn)); - await result.current.mutate('payload'); + await act(async () => { + await result.current.mutate('payload'); + }); await waitFor(() => { expect(result.current.data).toBe('ok'); }); - result.current.reset(); + await act(async () => { + result.current.reset(); + }); await waitFor(() => { expect(result.current.data).toBeUndefined(); expect(result.current.error).toBeNull(); diff --git a/client/src/hooks/data/use-async-data.ts b/client/src/hooks/data/use-async-data.ts index f2cce77..97016c8 100644 --- a/client/src/hooks/data/use-async-data.ts +++ b/client/src/hooks/data/use-async-data.ts @@ -93,25 +93,20 @@ export function useAsyncData( const [isLoading, setIsLoading] = useState(!skip); const [error, setError] = useState(null); - // Track if component is mounted to avoid state updates after unmount - const isMountedRef = useRef(true); - // Track current fetch to handle race conditions + // Track the current fetch to handle race conditions (stale responses) const fetchIdRef = useRef(0); - const depsSnapshotRef = useRef<{ - fetcher: () => Promise; - deps: readonly unknown[]; - } | null>(null); - // Use refs for options that shouldn't trigger refetches when they change - // This prevents oscillation loops when skip/callbacks change during loading transitions + // Store callbacks and fetcher in refs to avoid dependency churn const skipRef = useRef(skip); skipRef.current = skip; const onSuccessRef = useRef(onSuccess); onSuccessRef.current = onSuccess; const onErrorRef = useRef(onError); onErrorRef.current = onError; + const fetcherRef = useRef(fetcher); + fetcherRef.current = fetcher; - const doFetch = useCallback(async () => { + const doFetch = useCallback(async (mountedRef: { current: boolean }) => { if (skipRef.current) { return; } @@ -122,26 +117,29 @@ export function useAsyncData( setError(null); try { - const result = await fetcher(); + const result = await fetcherRef.current(); - // Only update state if this is still the most recent fetch and component is mounted - if (isMountedRef.current && currentFetchId === fetchIdRef.current) { + // Only update state if this is still the latest fetch AND component is mounted + if (mountedRef.current && currentFetchId === fetchIdRef.current) { setData(result); setIsLoading(false); onSuccessRef.current?.(result); } } catch (err) { - if (isMountedRef.current && currentFetchId === fetchIdRef.current) { - const errorMessage = extractErrorMessage(err); + if (mountedRef.current && currentFetchId === fetchIdRef.current) { + const errorMessage = extractErrorMessage(err, 'Request failed'); setError(errorMessage); setIsLoading(false); onErrorRef.current?.(errorMessage); } } - }, [fetcher]); + }, []); + + // Create a stable mounted ref that's scoped to the effect lifecycle + const mountedRef = useRef(false); const refetch = useCallback(() => { - void doFetch(); + void doFetch(mountedRef); }, [doFetch]); const reset = useCallback(() => { @@ -151,26 +149,15 @@ export function useAsyncData( setError(null); }, [initialData]); - // Fetch on mount and when deps change (manual compare avoids spread deps linting) useEffect(() => { - const previous = depsSnapshotRef.current; - const hasChanged = - previous?.fetcher !== fetcher || - previous?.deps.length !== deps.length || - (previous?.deps.some((value, index) => !Object.is(value, deps[index])) ?? true); - if (hasChanged) { - depsSnapshotRef.current = { fetcher, deps }; - void doFetch(); - } - }); - - // Track mounted state - useEffect(() => { - isMountedRef.current = true; + // Set mounted at the start of this effect instance + mountedRef.current = true; + void doFetch(mountedRef); return () => { - isMountedRef.current = false; + // Clear mounted on cleanup - this specific effect instance is done + mountedRef.current = false; }; - }, []); + }, [doFetch, ...deps]); return { data, isLoading, error, refetch, reset }; } @@ -250,7 +237,7 @@ export function useMutation( return result; } catch (err) { if (isMountedRef.current) { - const errorMessage = extractErrorMessage(err); + const errorMessage = extractErrorMessage(err, 'Mutation failed'); setError(errorMessage); setIsLoading(false); onError?.(errorMessage); diff --git a/client/src/hooks/processing/use-assistant.test.ts b/client/src/hooks/processing/use-assistant.test.ts index 4722743..5c26725 100644 --- a/client/src/hooks/processing/use-assistant.test.ts +++ b/client/src/hooks/processing/use-assistant.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { AskAssistantResponse } from '@/api/types'; import { getAPI } from '@/api/interface'; import { toastError } from '@/lib/observability/errors'; -import { useAssistant } from '@/hooks/processing/use-assistant'; +import { type AskResult, useAssistant } from '@/hooks/processing/use-assistant'; const apiState = vi.hoisted(() => ({ askAssistant: vi.fn(), @@ -50,7 +50,7 @@ describe('useAssistant', () => { useAssistant({ meetingId: 'meeting-1', allowWeb: true, topK: 3 }) ); - let promise: Promise | null = null; + let promise: Promise | null = null; await act(async () => { promise = result.current.ask('Question'); }); @@ -87,7 +87,8 @@ describe('useAssistant', () => { const { result } = renderHook(() => useAssistant({ meetingId: 'meeting-2' })); await act(async () => { - await expect(result.current.ask('Question')).resolves.toBeNull(); + const askResult = await result.current.ask('Question'); + expect(askResult).toEqual({ success: false, error: 'Toast error' }); }); await waitFor(() => { @@ -131,7 +132,7 @@ describe('useAssistant', () => { const { result, unmount } = renderHook(() => useAssistant()); - let promise: Promise | null = null; + let promise: Promise | null = null; await act(async () => { promise = result.current.ask('Question'); }); diff --git a/client/src/hooks/processing/use-assistant.ts b/client/src/hooks/processing/use-assistant.ts index 28c4019..8822ee1 100644 --- a/client/src/hooks/processing/use-assistant.ts +++ b/client/src/hooks/processing/use-assistant.ts @@ -20,9 +20,13 @@ interface UseAssistantOptions { topK?: number; } +export type AskResult = + | { success: true; response: AskAssistantResponse } + | { success: false; error: string }; + interface UseAssistantReturn { state: AssistantState; - ask: (question: string) => Promise; + ask: (question: string) => Promise; clearConversation: () => void; isLoading: boolean; } @@ -49,7 +53,7 @@ export function useAssistant(options: UseAssistantOptions = {}): UseAssistantRet }, []); const ask = useCallback( - async (question: string): Promise => { + async (question: string): Promise => { if (!question.trim()) { return null; } @@ -78,7 +82,7 @@ export function useAssistant(options: UseAssistantOptions = {}): UseAssistantRet error: null, }); - return response; + return { success: true, response }; } catch (error) { if (!mountedRef.current) { return null; @@ -96,7 +100,7 @@ export function useAssistant(options: UseAssistantOptions = {}): UseAssistantRet error: message, })); - return null; + return { success: false, error: message }; } }, [meetingId, allowWeb, topK, state.threadId] diff --git a/client/src/hooks/recording/use-recording-session.test.tsx b/client/src/hooks/recording/use-recording-session.test.tsx index 2db89c6..f82b14b 100644 --- a/client/src/hooks/recording/use-recording-session.test.tsx +++ b/client/src/hooks/recording/use-recording-session.test.tsx @@ -19,7 +19,14 @@ type PreferencesSnapshot = Pick, 'simula const mockGetAPI = vi.hoisted(() => vi.fn<() => NoteFlowAPI>()); const mockIsTauriEnvironment = vi.hoisted(() => vi.fn<() => boolean>()); const mockUseConnectionState = vi.hoisted(() => vi.fn<() => ConnectionHelpers>()); -const mockGuard = vi.hoisted(() => vi.fn()); +const mockGuard = vi.hoisted((): GuardFn => { + return async function guard( + action: () => Promise, + _options?: GuardOptions + ): Promise { + return action(); + }; +}); const mockToast = vi.hoisted(() => vi.fn<(args: ToastArgs) => ReturnType>()); const mockAddClientLog = vi.hoisted(() => vi.fn<(args: ClientLogArgs) => void>()); const mockReportError = vi.hoisted( @@ -60,7 +67,9 @@ vi.mock('@/contexts/connection-state', () => ({ })); vi.mock('@/hooks/data/use-guarded-mutation', () => ({ - useGuardedMutation: (): { guard: GuardFn } => ({ guard: mockGuard }), + useGuardedMutation: (): { guard: GuardFn } => ({ + guard: (action, options) => mockGuard(action, options), + }), })); vi.mock('@/hooks/ui/use-toast', () => ({ @@ -103,6 +112,10 @@ const baseMeeting: Meeting = { id: 'meeting-1', title: 'Test Meeting', state: 'created', + created_at: 0, + duration_seconds: 0, + segments: [], + metadata: {}, }; const baseConnection: ConnectionHelpers = { state: { @@ -161,7 +174,6 @@ describe('useRecordingSession', () => { mockGetAPI.mockReturnValue(mockAPIInstance as unknown as NoteFlowAPI); mockIsTauriEnvironment.mockReturnValue(false); mockUseConnectionState.mockReturnValue(withConnection({ isConnected: true })); - mockGuard.mockImplementation(async (fn: () => Promise) => fn()); mockPreferencesGet.mockReturnValue({ simulate_transcription: false }); tauriHandlers.clear(); }); @@ -351,7 +363,11 @@ describe('useRecordingSession', () => { if (!updateHandler) { throw new Error('missing update handler'); } - const vadStartUpdate: TranscriptUpdate = { update_type: 'vad_start' }; + const vadStartUpdate: TranscriptUpdate = { + update_type: 'vad_start', + meeting_id: baseMeeting.id, + server_timestamp: 0, + }; await act(async () => { updateHandler(vadStartUpdate); }); @@ -578,7 +594,10 @@ describe('useRecordingSession', () => { const { result, unmount } = renderHook(() => useRecordingSession({})); - const startTask = result.current.startRecording(); + let startTask: Promise; + await act(async () => { + startTask = result.current.startRecording(); + }); await waitFor(() => { expect(mockAPIInstance.startTranscription).toHaveBeenCalled(); @@ -587,8 +606,8 @@ describe('useRecordingSession', () => { await act(async () => { unmount(); }); - resolveStream?.(stream); await act(async () => { + resolveStream?.(stream); await startTask; }); @@ -604,10 +623,13 @@ describe('useRecordingSession', () => { const { result, unmount } = renderHook(() => useRecordingSession({})); - const startTask = result.current.startRecording(); - unmount(); - rejectCreate?.(new Error('late fail')); + let startTask: Promise; await act(async () => { + startTask = result.current.startRecording(); + }); + await act(async () => { + unmount(); + rejectCreate?.(new Error('late fail')); await startTask; }); @@ -629,10 +651,13 @@ describe('useRecordingSession', () => { await result.current.startRecording(); }); - const stopTask = result.current.stopRecording(); - unmount(); - resolveStop?.({ ...baseMeeting, state: 'stopped' }); + let stopTask: Promise; await act(async () => { + stopTask = result.current.stopRecording(); + }); + await act(async () => { + unmount(); + resolveStop?.({ ...baseMeeting, state: 'stopped' }); await stopTask; }); @@ -656,10 +681,13 @@ describe('useRecordingSession', () => { await result.current.startRecording(); }); - const stopTask = result.current.stopRecording(); - unmount(); - rejectStop?.(new Error('stop fail')); + let stopTask: Promise; await act(async () => { + stopTask = result.current.stopRecording(); + }); + await act(async () => { + unmount(); + rejectStop?.(new Error('stop fail')); await stopTask; }); diff --git a/client/src/hooks/ui/use-mobile.test.tsx b/client/src/hooks/ui/use-mobile.test.tsx index 28634c8..b8e89d7 100644 --- a/client/src/hooks/ui/use-mobile.test.tsx +++ b/client/src/hooks/ui/use-mobile.test.tsx @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Breakpoints } from '@/lib/config'; @@ -39,8 +39,10 @@ describe('useIsMobile', () => { }); window.innerWidth = Breakpoints.MOBILE - 10; - listeners.forEach((cb) => { - cb(new Event('change') as MediaQueryListEvent); + await act(async () => { + listeners.forEach((cb) => { + cb(new Event('change') as MediaQueryListEvent); + }); }); await waitFor(() => { expect(result.current).toBe(true); diff --git a/client/src/lib/constants/timing.ts b/client/src/lib/constants/timing.ts index 5eb60ea..ed997d7 100644 --- a/client/src/lib/constants/timing.ts +++ b/client/src/lib/constants/timing.ts @@ -48,6 +48,9 @@ export const PKM_SYNC_INTERVAL_MINUTES = 30; /** Refresh interval for performance metrics (ms) */ export const METRICS_REFRESH_INTERVAL_MS = FIVE_SECONDS_MS; +/** Stale time for React Query analytics queries (ms) */ +export const ANALYTICS_STALE_TIME_MS = ONE_MINUTE_MS; + // ============================================================================= // Diarization // ============================================================================= diff --git a/client/src/lib/observability/client.ts b/client/src/lib/observability/client.ts index 7545c55..7e684bf 100644 --- a/client/src/lib/observability/client.ts +++ b/client/src/lib/observability/client.ts @@ -81,7 +81,8 @@ function flushLogs(): void { const existing = loadLogs(); // Prepend pending logs (newest first) and trim to max - const merged = [...pendingLogs.reverse(), ...existing].slice(0, MAX_ENTRIES); + const pending = [...pendingLogs].reverse(); + const merged = [...pending, ...existing].slice(0, MAX_ENTRIES); pendingLogs = []; saveLogs(merged); } @@ -90,7 +91,8 @@ export function getClientLogs(): ClientLogEntry[] { // Include pending logs in the result for consistency if (pendingLogs.length > 0) { const existing = loadLogs(); - return [...pendingLogs.reverse(), ...existing].slice(0, MAX_ENTRIES); + const pending = [...pendingLogs].reverse(); + return [...pending, ...existing].slice(0, MAX_ENTRIES); } return loadLogs(); } diff --git a/client/src/lib/observability/messages.test.ts b/client/src/lib/observability/messages.test.ts index 936795d..4c08e6a 100644 --- a/client/src/lib/observability/messages.test.ts +++ b/client/src/lib/observability/messages.test.ts @@ -186,6 +186,51 @@ describe('toFriendlyMessage', () => { const result = toFriendlyMessage('Already Capitalized Message', {}); expect(result).toBe('Already Capitalized Message'); }); + + describe('database and infrastructure messages', () => { + it('translates database migration start', () => { + const result = toFriendlyMessage('Running database migrations...', {}); + expect(result).toBe('Updating database...'); + }); + + it('translates project root discovery', () => { + const result = toFriendlyMessage('Project Root Discovered', {}); + expect(result).toBe('Found project configuration'); + }); + + it('translates alembic version check', () => { + const result = toFriendlyMessage('Alembic version table exists, checking if migrations needed...', {}); + expect(result).toBe('Checking database for updates'); + }); + + it('translates schema check with table count', () => { + const result = toFriendlyMessage('Schema check: Table Count=33, Alembic Version Exists=True', { + 'table count': '33', + 'alembic version exists': 'True', + }); + expect(result).toBe('Database ready (33 tables)'); + }); + + it('translates database connection ready', () => { + const result = toFriendlyMessage('Database connection pool ready', {}); + expect(result).toBe('Database connected'); + }); + + it('translates migration complete', () => { + const result = toFriendlyMessage('Migration complete', {}); + expect(result).toBe('Database updated successfully'); + }); + + it('translates server initialization', () => { + const result = toFriendlyMessage('Server initializing', {}); + expect(result).toBe('Starting up...'); + }); + + it('translates server ready', () => { + const result = toFriendlyMessage('Server ready', {}); + expect(result).toBe('Server is ready'); + }); + }); }); describe('case insensitivity', () => { diff --git a/client/src/lib/observability/messages.ts b/client/src/lib/observability/messages.ts index 8d5d968..c5babdb 100644 --- a/client/src/lib/observability/messages.ts +++ b/client/src/lib/observability/messages.ts @@ -31,7 +31,7 @@ const MESSAGE_TEMPLATES: Record = { return title ? `Created new meeting: ${title}` : 'Created new meeting'; }, 'meeting deleted': () => 'Meeting deleted successfully', - stopmeetingrpc: () => 'Meeting recording stopped', + stopmeetingrpc: () => 'Meeting recording has been stopped', // Cloud consent 'cloud consent granted': () => 'Cloud AI features enabled', @@ -216,8 +216,7 @@ const MESSAGE_TEMPLATES: Record = { // Security and encryption 'decryption failed': () => 'Could not decrypt saved data - may need to re-enter API keys', 'failed to retrieve secure value': () => 'Could not load secure setting', - 'secure storage migration failed': () => - 'Secure storage needs recovery - some settings may be lost', + 'secure storage migration failed': () => 'Secure storage needs recovery - some settings may be lost', 'secure storage migrated': () => 'Secure storage upgraded successfully', 'secure storage key mismatch': () => 'Secure storage needs recovery', 'failed to decrypt api keys': () => 'Could not load saved API keys - please re-enter them', @@ -251,7 +250,7 @@ const MESSAGE_TEMPLATES: Record = { 'meeting cache write failed': () => 'Could not cache meeting data', // Sync operations - 'failed to persist preferences sync': () => 'Could not save sync status', + 'failed to persist preferences sync': () => 'Could not save settings to disk', 'failed to decode preference key': () => 'Settings key format error', 'validation event listener error': () => 'Settings validation error', @@ -260,6 +259,48 @@ const MESSAGE_TEMPLATES: Record = { // App policy 'recording blocked': () => 'Recording paused - blocked by app policy', + + // Additional backend messages + 'meeting_created': () => 'New meeting has been created in the database', + 'meeting_updated': () => 'Meeting information has been updated', + 'meeting_deleted': () => 'Meeting has been removed from the database', + 'preference_created': () => 'New user preference has been saved', + 'preference_updated': () => 'User preference has been updated', + 'preference_deleted': () => 'User preference has been removed', + 'webhook_delivery_failed': () => 'Webhook notification delivery failed', + 'calendar_list_events_failed': () => 'Unable to retrieve calendar events', + 'oauth_disconnect_success': () => 'Successfully disconnected from external service', + 'oauth_disconnect_failed': () => 'Failed to disconnect from external service', + 'diarization_offline_model_loaded': () => 'Speaker identification model has been loaded', + 'streaming_turns_cleared': () => 'Speaker turn data has been cleared', + 'asr_config_requested_but_no_service': () => 'Audio configuration requested but service is unavailable', + + // Database and infrastructure + 'running database migrations': () => 'Updating database...', + 'project root discovered': () => 'Found project configuration', + 'alembic version table exists': () => 'Checking database for updates', + 'schema check': (d) => { + const tableCount = d['table count']; + const versionExists = d['alembic version exists']; + if (tableCount && versionExists !== undefined) { + return `Database ready (${tableCount} tables)`; + } + return 'Database schema check complete'; + }, + 'database connection pool ready': () => 'Database connected', + 'alembic version table exists, checking if migrations needed': () => 'Checking database for updates', + 'running migrations': () => 'Updating database...', + 'applying migrations': () => 'Applying database changes...', + 'migration complete': () => 'Database updated successfully', + 'no migrations needed': () => 'Database is up to date', + 'database initialization complete': () => 'Database setup complete', + 'connection established': () => 'Connected to database', + 'connection closed': () => 'Database connection closed', + 'session started': () => 'Session started', + 'session closed': () => 'Session ended', + 'server initializing': () => 'Starting up...', + 'server ready': () => 'Server is ready', + 'server shutting down': () => 'Server is shutting down', }; /** diff --git a/client/src/lib/storage/crypto.test.ts b/client/src/lib/storage/crypto.test.ts index c79c1c8..29b07be 100644 --- a/client/src/lib/storage/crypto.test.ts +++ b/client/src/lib/storage/crypto.test.ts @@ -11,18 +11,19 @@ import { checkSecureStorageHealth, setSecureValue, } from '@/lib/storage/crypto'; -import { addClientLog } from '@/lib/observability/client'; +import * as clientLogs from '@/lib/observability/client'; import { DEVICE_ID_KEY, SECURE_DATA_KEY } from '@/lib/storage/keys'; -vi.mock('@/lib/observability/client', () => ({ - addClientLog: vi.fn(), -})); - const toArrayBuffer = (input: ArrayBuffer | ArrayBufferView): ArrayBuffer => { if (input instanceof ArrayBuffer) { return input; } - return input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); + const { buffer, byteOffset, byteLength } = input; + if (buffer instanceof ArrayBuffer) { + return buffer.slice(byteOffset, byteOffset + byteLength); + } + const view = new Uint8Array(buffer, byteOffset, byteLength); + return view.slice().buffer; }; const mockCrypto = { @@ -36,6 +37,7 @@ const mockCrypto = { toArrayBuffer(data) ), }, + randomUUID: () => 'mock-uuid', getRandomValues: (array: Uint8Array) => { for (let i = 0; i < array.length; i++) { array[i] = (i * 31) % 256; @@ -46,12 +48,14 @@ const mockCrypto = { describe('crypto utilities', () => { beforeEach(() => { - vi.stubGlobal('crypto', mockCrypto as Crypto); + vi.stubGlobal('crypto', mockCrypto); localStorage.clear(); + vi.spyOn(clientLogs, 'addClientLog').mockImplementation(() => {}); }); afterEach(() => { vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); it('encrypts and decrypts data', async () => { @@ -103,9 +107,9 @@ describe('crypto utilities', () => { it('detects secure storage availability', () => { expect(isSecureStorageAvailable()).toBe(true); const emptyCrypto: unknown = {}; - vi.stubGlobal('crypto', emptyCrypto as Crypto); + vi.stubGlobal('crypto', emptyCrypto); expect(isSecureStorageAvailable()).toBe(false); - vi.stubGlobal('crypto', mockCrypto as Crypto); + vi.stubGlobal('crypto', mockCrypto); expect(isSecureStorageAvailable()).toBe(true); }); @@ -124,7 +128,7 @@ describe('crypto utilities', () => { await importCredentialsBackup(backup, 'passphrase'); expect(await getSecureValue('token')).toBe('abc123'); - expect(addClientLog).toHaveBeenCalledWith( + expect(clientLogs.addClientLog).toHaveBeenCalledWith( expect.objectContaining({ message: 'Credentials imported from backup successfully' }) ); }); @@ -161,9 +165,9 @@ describe('crypto utilities', () => { it('reports secure storage health states', async () => { const unavailableCrypto: Partial = {}; - vi.stubGlobal('crypto', unavailableCrypto as Crypto); + vi.stubGlobal('crypto', unavailableCrypto); expect(await checkSecureStorageHealth()).toBe('unavailable'); - vi.stubGlobal('crypto', mockCrypto as Crypto); + vi.stubGlobal('crypto', mockCrypto); localStorage.removeItem(SECURE_DATA_KEY); expect(await checkSecureStorageHealth()).toBe('empty'); diff --git a/client/src/lib/storage/crypto.ts b/client/src/lib/storage/crypto.ts index daeb510..39e1e72 100644 --- a/client/src/lib/storage/crypto.ts +++ b/client/src/lib/storage/crypto.ts @@ -325,9 +325,40 @@ export async function checkSecureStorageHealth(): Promise { } } -// Check if Web Crypto API is available export function isSecureStorageAvailable(): boolean { - return Boolean(window.crypto?.subtle && window.crypto.getRandomValues); + try { + const hasWindow = typeof window !== 'undefined'; + const hasCrypto = hasWindow && typeof window.crypto !== 'undefined'; + const hasSubtle = hasCrypto && typeof window.crypto.subtle !== 'undefined'; + const hasGetRandomValues = hasCrypto && typeof window.crypto.getRandomValues === 'function'; + + const available = hasWindow && hasCrypto && hasSubtle && hasGetRandomValues; + + if (!available && hasWindow) { + addClientLog({ + level: 'warning', + source: 'app', + message: 'Secure storage availability check failed', + metadata: { + hasWindow: String(hasWindow), + hasCrypto: String(hasCrypto), + hasSubtle: String(hasSubtle), + hasGetRandomValues: String(hasGetRandomValues), + isSecureContext: String(window.isSecureContext), + }, + }); + } + + return available; + } catch (error) { + addClientLog({ + level: 'warning', + source: 'app', + message: 'Secure storage availability check threw', + details: error instanceof Error ? error.message : String(error), + }); + return false; + } } /** diff --git a/client/src/lib/ui/styles.ts b/client/src/lib/ui/styles.ts index 797b618..2792984 100644 --- a/client/src/lib/ui/styles.ts +++ b/client/src/lib/ui/styles.ts @@ -83,6 +83,7 @@ export const searchIcon = { // Chart axis styles export const chartAxis = { xAxis: 'text-xs fill-muted-foreground', + tick: { fontSize: 12, fontFamily: 'Inter, system-ui, sans-serif' }, } as const; // Connection status values (to reduce string literal duplication) diff --git a/client/src/pages/Analytics.test.tsx b/client/src/pages/Analytics.test.tsx index 8cabd9c..f4c4657 100644 --- a/client/src/pages/Analytics.test.tsx +++ b/client/src/pages/Analytics.test.tsx @@ -29,6 +29,9 @@ const MOCK_OVERVIEW: AnalyticsOverview = { total_words: MOCK_TOTAL_WORDS, total_segments: MOCK_TOTAL_SEGMENTS, speaker_count: MOCK_SPEAKER_COUNT, + user_speaking_time: 0, + attendee_speaking_time: 0, + unknown_speaking_time: 0, }; const MOCK_SPEAKER_RESPONSE: ListSpeakerStatsResponse = { diff --git a/client/src/pages/Analytics.tsx b/client/src/pages/Analytics.tsx index 62a4521..63deefb 100644 --- a/client/src/pages/Analytics.tsx +++ b/client/src/pages/Analytics.tsx @@ -4,6 +4,7 @@ import { Activity, BarChart3, MessageSquare, ScrollText, Tag } from 'lucide-reac import { useMemo, useState } from 'react'; import { getAPI } from '@/api/interface'; import type { ListMeetingsResponse } from '@/api/types'; +import { ANALYTICS_STALE_TIME_MS } from '@/lib/constants/timing'; import { EntitiesTab } from '@/components/features/analytics/entities-tab'; import { LogsTab } from '@/components/features/analytics/logs-tab'; import { MeetingsTab } from '@/components/features/analytics/meetings-tab'; @@ -17,8 +18,12 @@ const ANALYTICS_DAYS = 14; export default function AnalyticsPage() { const [activeTab, setActiveTab] = useState('meetings'); - const endTime = useMemo(() => Math.floor(Date.now() / 1000), []); - const startTime = useMemo(() => Math.floor(subDays(new Date(), ANALYTICS_DAYS - 1).getTime() / 1000), []); + const [timeRange] = useState(() => { + const endTime = Math.floor(Date.now() / 1000); + const startTime = Math.floor(subDays(new Date(), ANALYTICS_DAYS - 1).getTime() / 1000); + return { startTime, endTime }; + }); + const { startTime, endTime } = timeRange; const { data: overview, isLoading: overviewLoading } = useQuery({ queryKey: ['analytics-overview', startTime, endTime], @@ -27,6 +32,7 @@ export default function AnalyticsPage() { start_time: startTime, end_time: endTime, }), + staleTime: ANALYTICS_STALE_TIME_MS, }); const { data: speakerResponse, isLoading: speakersLoading } = useQuery({ @@ -36,8 +42,11 @@ export default function AnalyticsPage() { start_time: startTime, end_time: endTime, }), + staleTime: ANALYTICS_STALE_TIME_MS, }); + + const speakerStats = useMemo( () => (speakerResponse?.speakers ? mapSpeakerStats(speakerResponse.speakers) : []), [speakerResponse] @@ -47,6 +56,7 @@ export default function AnalyticsPage() { queryKey: ['meetings', 'speech-analysis'], queryFn: () => getAPI().listMeetings({ limit: 100, include_segments: true }), enabled: activeTab === 'speech', + staleTime: ANALYTICS_STALE_TIME_MS, }); const { data: entityAnalytics, isLoading: entitiesLoading } = useQuery({ @@ -57,6 +67,7 @@ export default function AnalyticsPage() { end_time: endTime, }), enabled: activeTab === 'entities', + staleTime: ANALYTICS_STALE_TIME_MS, }); const chartConfig = { diff --git a/client/src/pages/Home.behavior.test.tsx b/client/src/pages/Home.behavior.test.tsx index 2f3d040..0138dc7 100644 --- a/client/src/pages/Home.behavior.test.tsx +++ b/client/src/pages/Home.behavior.test.tsx @@ -1,14 +1,26 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createContext } from 'react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { FinalSegment, Meeting } from '@/api/types'; import HomePage from '@/pages/Home'; +import { ConnectionProvider } from '@/contexts/connection-context'; +import { WorkspaceProvider } from '@/contexts/workspace-context'; +import { ProjectProvider } from '@/contexts/project-context'; + +const mockListMeetings = vi.fn(); + +vi.mock('@/api/interface', () => ({ + getAPI: () => ({ + listMeetings: mockListMeetings, + }), +})); const homeState = vi.hoisted(() => ({ meetings: [] as Meeting[], loading: false, - error: null as unknown, })); const projectsState: { activeProject: { id: string } | null } = { @@ -19,9 +31,16 @@ const tasksState = vi.hoisted(() => ({ tasks: [] as Array<{ meetingId: string; text: string; meetingTitle: string; priority: 'low' | 'medium' | 'high' }>, })); -const logState = vi.hoisted(() => ({ - addClientLog: vi.fn(), -})); +const mockUseQuery = vi.hoisted(() => + vi.fn( + (options: { queryFn?: () => Promise; enabled?: boolean }) => { + if (options.enabled !== false && options.queryFn) { + void options.queryFn(); + } + return { data: homeState.meetings, isLoading: homeState.loading }; + } + ) +); const preferencesState = vi.hoisted(() => ({ toggleTaskCompletion: vi.fn(), @@ -32,27 +51,35 @@ const meetingCardState = vi.hoisted(() => ({ calls: [] as Meeting[], })); -vi.mock('@/hooks', () => ({ - useAsyncData: ( - _fetcher: () => Promise, - _deps: unknown[], - options?: { onError?: (error: unknown) => void } - ) => { - if (homeState.error && options?.onError) { - options.onError(homeState.error); - } - return { data: homeState.meetings, isLoading: homeState.loading }; - }, -})); +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useQuery: mockUseQuery, + }; +}); vi.mock('@/contexts/project-state', () => ({ + ProjectContext: createContext(null), useProjects: () => ({ activeProject: projectsState.activeProject, }), })); -vi.mock('@/lib/observability/client', () => ({ - addClientLog: logState.addClientLog, +vi.mock('@/contexts/workspace-state', () => ({ + WorkspaceContext: createContext(null), + useWorkspace: () => ({ + currentWorkspace: { id: 'workspace-1', name: 'Default Workspace', role: 'owner', is_default: true }, + workspaces: [{ id: 'workspace-1', name: 'Default Workspace', role: 'owner', is_default: true }], + currentUser: { user_id: 'user-1', display_name: 'Test User' }, + switchWorkspace: vi.fn(), + isLoading: false, + error: null, + }), +})); + +vi.mock('@/contexts/connection-state', () => ({ + ConnectionContext: createContext(null), })); vi.mock('@/lib/utils/format', () => ({ @@ -61,6 +88,8 @@ vi.mock('@/lib/utils/format', () => ({ vi.mock('@/lib/preferences', () => ({ preferences: { + get: () => ({ simulate_transcription: false }), + subscribe: () => () => {}, isTaskCompleted: preferencesState.isTaskCompleted, toggleTaskCompletion: preferencesState.toggleTaskCompletion, }, @@ -108,12 +137,31 @@ const createMeeting = (overrides: Partial = {}): Meeting => { }; }; -const renderHome = () => - render( - - - +const renderHome = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + return render( + + + + + + + } /> + + + + + + ); +}; const findTaskToggleButton = (taskText: string): HTMLButtonElement => { const node = screen.getByText(taskText); @@ -133,36 +181,32 @@ describe('HomePage behavior', () => { vi.clearAllMocks(); homeState.meetings = []; homeState.loading = false; - homeState.error = null; projectsState.activeProject = { id: 'project-1' }; tasksState.tasks = []; meetingCardState.calls = []; - }); - it('logs when meeting load fails', () => { - homeState.error = new Error('boom'); - - renderHome(); - - expect(logState.addClientLog).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Failed to load recent meetings', + mockListMeetings.mockImplementation(() => + Promise.resolve({ + meetings: homeState.meetings, + total: homeState.meetings.length, }) ); }); + it('renders empty state when no meetings are returned', () => { + renderHome(); + + expect(screen.getByText('No meetings yet')).toBeInTheDocument(); + }); + it('renders active recording and uses project meeting path', () => { - homeState.meetings = [ - createMeeting({ id: 'meeting-1', title: 'Live call', state: 'recording' }), - createMeeting({ id: 'meeting-2', title: 'Postmortem', state: 'completed' }), - ]; + homeState.meetings = [createMeeting({ state: 'recording' })]; renderHome(); expect(screen.getByText('Recording in progress')).toBeInTheDocument(); const meetingLink = screen.getAllByRole('link', { name: /view all/i })[0]; expect(meetingLink.getAttribute('href')).toBe('/projects/project-1/meetings'); - expect(meetingCardState.calls.map((m) => m.id)).toEqual(['meeting-2']); }); it('renders tasks list and toggles completion', () => { diff --git a/client/src/pages/Home.test.tsx b/client/src/pages/Home.test.tsx index 3dd8b29..f67ae36 100644 --- a/client/src/pages/Home.test.tsx +++ b/client/src/pages/Home.test.tsx @@ -82,7 +82,10 @@ function createWrapper() { - + @@ -133,7 +136,9 @@ describe('HomePage', () => { const Wrapper = createWrapper(); render(, { wrapper: Wrapper }); - expect(screen.getByText('Recently Recorded')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('Recently Recorded')).toBeInTheDocument(); + }); }); it('renders action items section header', async () => { @@ -142,7 +147,9 @@ describe('HomePage', () => { const Wrapper = createWrapper(); render(, { wrapper: Wrapper }); - expect(screen.getByText('Action Items')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('Action Items')).toBeInTheDocument(); + }); }); it('shows empty tasks state when no tasks', async () => { @@ -195,7 +202,9 @@ describe('HomePage', () => { const Wrapper = createWrapper(); render(, { wrapper: Wrapper }); - const viewAllButtons = screen.getAllByRole('link', { name: /view all/i }); - expect(viewAllButtons.length).toBeGreaterThanOrEqual(1); + await waitFor(() => { + const viewAllButtons = screen.getAllByRole('link', { name: /view all/i }); + expect(viewAllButtons.length).toBeGreaterThanOrEqual(1); + }); }); }); diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 62b084c..59f77a8 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -1,48 +1,36 @@ // Home Dashboard Page +import { useQuery } from '@tanstack/react-query'; import { motion } from 'framer-motion'; import { ArrowRight, Calendar, CheckSquare, Circle } from 'lucide-react'; import { Link } from 'react-router-dom'; import { getAPI } from '@/api/interface'; -import { EmptyState } from '@/components/common'; +import { EmptyState, PriorityBadge } from '@/components/common'; import { MeetingCard } from '@/components/features/meetings'; -import { PriorityBadge } from '@/components/common'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { SkeletonMeetingCard } from '@/components/ui/skeleton'; import { useProjects } from '@/contexts/project-state'; -import { useAsyncData } from '@/hooks'; -import { addClientLog } from '@/lib/observability/client'; import { getGreeting } from '@/lib/utils/format'; import { preferences } from '@/lib/preferences'; import { HOME_RECENT_MEETINGS_LIMIT, HOME_SKELETON_CARDS_COUNT, HOME_TASKS_LIMIT, + THIRTY_SECONDS_MS, } from '@/lib/constants/timing'; import { aggregateTasksFromMeetings } from '@/types/task'; export default function HomePage() { const { activeProject } = useProjects(); - const { data: meetings = [], isLoading: loading } = useAsyncData( - () => + const { data: meetings = [], isLoading: loading } = useQuery({ + queryKey: ['home', 'meetings'], + queryFn: () => getAPI() .listMeetings({ limit: HOME_RECENT_MEETINGS_LIMIT, sort_order: 'newest' }) .then((r) => r.meetings), - [], - { - initialData: [], - onError: (error) => { - addClientLog({ - level: 'warning', - source: 'app', - message: 'Failed to load recent meetings', - details: error, - metadata: { context: 'home_page_load' }, - }); - }, - } - ); + staleTime: THIRTY_SECONDS_MS, + }); const allTasks = aggregateTasksFromMeetings(meetings, (meetingId, taskText) => preferences.isTaskCompleted(meetingId, taskText) diff --git a/client/src/pages/MeetingDetail.test.tsx b/client/src/pages/MeetingDetail.test.tsx index 28d7cb7..c43d727 100644 --- a/client/src/pages/MeetingDetail.test.tsx +++ b/client/src/pages/MeetingDetail.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -169,7 +169,10 @@ function createWrapper(meetingId: string = MOCK_MEETING_ID) { - + @@ -208,9 +211,9 @@ describe('MeetingDetailPage', () => { expect(document.querySelector('.skeleton-shimmer')).toBeInTheDocument(); }); - if (resolvePromise) { - resolvePromise(createMockMeeting()); - } + await act(async () => { + resolvePromise?.(createMockMeeting()); + }); }); it('shows not found message when meeting does not exist', async () => { @@ -242,7 +245,7 @@ describe('MeetingDetailPage', () => { render(, { wrapper: Wrapper }); await waitFor(() => { - expect(screen.getByText('Transcript')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /full transcript/i })).toBeInTheDocument(); }); }); @@ -260,14 +263,14 @@ describe('MeetingDetailPage', () => { expect(summaryButtons.length).toBeGreaterThanOrEqual(1); }); - it('renders entities tab', async () => { + it('renders action items panel', async () => { mockGetMeeting.mockResolvedValue(createMockMeeting()); const Wrapper = createWrapper(); render(, { wrapper: Wrapper }); await waitFor(() => { - expect(screen.getByRole('tab', { name: /entities/i })).toBeInTheDocument(); + expect(screen.getByText('Action Items')).toBeInTheDocument(); }); }); @@ -278,7 +281,7 @@ describe('MeetingDetailPage', () => { render(, { wrapper: Wrapper }); await waitFor(() => { - expect(screen.getByRole('tab', { name: /notes/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /my notes/i })).toBeInTheDocument(); }); }); }); diff --git a/client/src/pages/Meetings.test.tsx b/client/src/pages/Meetings.test.tsx index 317fd71..b2ece4b 100644 --- a/client/src/pages/Meetings.test.tsx +++ b/client/src/pages/Meetings.test.tsx @@ -1,40 +1,31 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; import type { MouseEvent } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Project } from '@/api/types'; +import type { Meeting, MeetingState, Project } from '@/api/types'; import type { ListMeetingsRequest, ListMeetingsResponse } from '@/api/types/requests'; import MeetingsPage from '@/pages/Meetings'; import { MEETINGS_PAGE_LIMIT } from '@/lib/constants/timing'; -const MOCK_SEGMENT_COUNT = 5; -const MOCK_WORD_COUNT = 100; const MOCK_TOTAL_COUNT_SINGLE = 1; const MOCK_TOTAL_COUNT_MULTIPLE = 2; const MOCK_TOTAL_COUNT_PAGINATION = 25; const MOCK_DURATION_SECONDS = 3600; const MOCK_CREATED_AT_EPOCH = Math.floor(Date.now() / 1000); -function createMockMeeting(overrides: Partial<{ - id: string; - title: string; - state: string; - created_at: number; - segment_count: number; - word_count: number; -}>) { +function createMockMeeting( + overrides: Partial> = {} +): Meeting { return { id: overrides.id ?? 'meeting-1', title: overrides.title ?? 'Untitled Meeting', - state: overrides.state ?? 'completed', + state: overrides.state ?? ('completed' satisfies MeetingState), created_at: overrides.created_at ?? MOCK_CREATED_AT_EPOCH, duration_seconds: MOCK_DURATION_SECONDS, - segments: [{ id: 'seg-1', text: 'Sample transcript text' }], + segments: [], metadata: {}, - segment_count: overrides.segment_count ?? MOCK_SEGMENT_COUNT, - word_count: overrides.word_count ?? MOCK_WORD_COUNT, }; } @@ -147,7 +138,10 @@ function createWrapper() { return function Wrapper({ children }: { children: React.ReactNode }) { return ( - + @@ -178,12 +172,14 @@ describe('MeetingsPage', () => { const Wrapper = createWrapper(); render(, { wrapper: Wrapper }); - expect(screen.getByText('Meetings')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('Meetings')).toBeInTheDocument(); + }); }); it('shows loading skeletons while fetching', async () => { - let resolvePromise: ((value: unknown) => void) | undefined; - const promise = new Promise((resolve) => { + let resolvePromise: ((value: ListMeetingsResponse) => void) | undefined; + const promise = new Promise((resolve) => { resolvePromise = resolve; }); mockListMeetings.mockReturnValue(promise); @@ -191,11 +187,13 @@ describe('MeetingsPage', () => { const Wrapper = createWrapper(); render(, { wrapper: Wrapper }); - expect(screen.getByText('Past Recordings')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('Past Recordings')).toBeInTheDocument(); + }); - if (resolvePromise) { - resolvePromise({ meetings: [], total_count: 0 }); - } + await act(async () => { + resolvePromise?.({ meetings: [], total_count: 0 }); + }); }); it('shows empty state when no meetings', async () => { @@ -216,8 +214,6 @@ describe('MeetingsPage', () => { id: 'meeting-1', title: 'Team Standup', state: 'completed', - segment_count: MOCK_SEGMENT_COUNT, - word_count: MOCK_WORD_COUNT, }), ], total_count: MOCK_TOTAL_COUNT_SINGLE, diff --git a/client/src/pages/Meetings.tsx b/client/src/pages/Meetings.tsx index e01dac4..d6d719b 100644 --- a/client/src/pages/Meetings.tsx +++ b/client/src/pages/Meetings.tsx @@ -1,7 +1,7 @@ // Meetings list page import { Calendar, Loader2 } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; import { getAPI } from '@/api/interface'; import type { Meeting, MeetingState } from '@/api/types'; @@ -36,6 +36,9 @@ export default function MeetingsPage() { }); const { guard } = useGuardedMutation(); const resolvedProjectId = projectId ?? activeProject?.id; + + const selectedIdsRef = useRef(selectedProjectIds); + selectedIdsRef.current = selectedProjectIds; const activeProjects = useMemo( () => projects.filter((project) => !project.is_archived), [projects] @@ -71,7 +74,7 @@ export default function MeetingsPage() { offset, states: stateFilter === 'all' ? undefined : [stateFilter as MeetingState], project_id: projectScope === 'active' ? resolvedProjectId : undefined, - project_ids: projectScope === 'selected' ? selectedProjectIds : undefined, + project_ids: projectScope === 'selected' ? selectedIdsRef.current : undefined, }); if (append) { setMeetings((prev) => [...prev, ...response.meetings]); @@ -92,7 +95,7 @@ export default function MeetingsPage() { setLoadingMore(false); } }, - [shouldSkipFetch, stateFilter, projectScope, resolvedProjectId, selectedProjectIds] + [shouldSkipFetch, stateFilter, projectScope, resolvedProjectId] ); useEffect(() => { diff --git a/client/src/pages/People.tsx b/client/src/pages/People.tsx index ce174bd..b514b73 100644 --- a/client/src/pages/People.tsx +++ b/client/src/pages/People.tsx @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { AnimatePresence, motion } from 'framer-motion'; import { Calendar, Clock, Edit2, Mic, TrendingUp, Users, X } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { forwardRef, useEffect, useMemo, useState } from 'react'; import { getAPI } from '@/api/interface'; import type { SpeakerStat } from '@/api/types'; import { SuccessIcon } from '@/components/icons/status-icons'; @@ -18,47 +18,48 @@ import { formatDuration } from '@/lib/utils/format'; import { preferences } from '@/lib/preferences'; import { getSpeakerColorIndex } from '@/lib/audio/speaker'; import { cardPadding, typography } from '@/lib/ui/styles'; +import { ANALYTICS_STALE_TIME_MS, FIVE_SECONDS_MS } from '@/lib/constants/timing'; /** Speaker stats with display name from preferences applied */ interface DisplaySpeakerStat extends SpeakerStat { displayName: string; } -function SpeakerCard({ - speaker, - maxSpeakingTime, - onRename, -}: { +interface SpeakerCardProps { speaker: DisplaySpeakerStat; maxSpeakingTime: number; onRename: (speakerId: string, newName: string) => void; -}) { - const [isEditing, setIsEditing] = useState(false); - const [editName, setEditName] = useState(speaker.displayName); - const colorIndex = getSpeakerColorIndex(speaker.speaker_id); +} - const handleSave = () => { - if (editName.trim()) { - onRename(speaker.speaker_id, editName.trim()); +const SpeakerCard = forwardRef( + function SpeakerCard({ speaker, maxSpeakingTime, onRename }, ref) { + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(speaker.displayName); + const colorIndex = getSpeakerColorIndex(speaker.speaker_id); + + const handleSave = () => { + if (editName.trim()) { + onRename(speaker.speaker_id, editName.trim()); + setIsEditing(false); + } + }; + + const handleCancel = () => { + setEditName(speaker.displayName); setIsEditing(false); - } - }; + }; - const handleCancel = () => { - setEditName(speaker.displayName); - setIsEditing(false); - }; + const statValueClass = 'font-semibold'; + const speakingPercentage = (speaker.total_time / maxSpeakingTime) * 100; - const statValueClass = 'font-semibold'; - const speakingPercentage = (speaker.total_time / maxSpeakingTime) * 100; - - return ( - + return ( +
@@ -164,30 +165,26 @@ function SpeakerCard({ ); -} - -const ANALYTICS_DAYS = 90; + } +); export default function PeoplePage() { const [searchQuery, setSearchQuery] = useState(''); const [prefsVersion, setPrefsVersion] = useState(0); - const timeRange = useMemo(() => { - const now = Date.now(); - const startTime = now - ANALYTICS_DAYS * 24 * 60 * 60 * 1000; - return { start_time: startTime, end_time: now }; - }, []); - const { data: speakerStatsResponse, isLoading, + isFetching, } = useQuery({ - queryKey: ['speakerStats', timeRange.start_time, timeRange.end_time], + queryKey: ['speakerStats', 'all-time'], queryFn: () => getAPI().listSpeakerStats({ - start_time: timeRange.start_time, - end_time: timeRange.end_time, + start_time: 0, + end_time: 0, }), + staleTime: ANALYTICS_STALE_TIME_MS, + gcTime: FIVE_SECONDS_MS * 60, // Keep in cache for 5 minutes }); useEffect(() => { @@ -257,6 +254,11 @@ export default function PeoplePage() {

People + {isFetching && !isLoading && ( + + Updating... + + )}

View and manage speakers across all your meetings diff --git a/client/src/pages/Recording.behavior.test.tsx b/client/src/pages/Recording.behavior.test.tsx index 6aa92a6..51abf68 100644 --- a/client/src/pages/Recording.behavior.test.tsx +++ b/client/src/pages/Recording.behavior.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -282,7 +282,7 @@ const basePanelState = (): PanelPreferencesState => ({ const renderRecording = () => render( - + ); @@ -389,7 +389,9 @@ describe('RecordingPage behavior', () => { throw new Error('Missing transcript card props'); } - cardProps.onTogglePin('entity-1'); + await act(async () => { + cardProps.onTogglePin('entity-1'); + }); await waitFor(() => { const hasPinned = transcriptCardState.calls.some((call) => @@ -398,7 +400,9 @@ describe('RecordingPage behavior', () => { expect(hasPinned).toBe(true); }); - cardProps.onTogglePin('entity-1'); + await act(async () => { + cardProps.onTogglePin('entity-1'); + }); await waitFor(() => { const lastCall = transcriptCardState.calls[transcriptCardState.calls.length - 1]; @@ -424,13 +428,17 @@ describe('RecordingPage behavior', () => { scrollElement.scrollTop = 0; scrollElement.scrollTo = vi.fn(); - scrollElement.dispatchEvent(new Event('scroll')); + await act(async () => { + scrollElement.dispatchEvent(new Event('scroll')); + }); await waitFor(() => { expect(screen.getByTestId('jump-to-live')).toBeInTheDocument(); }); - fireEvent.click(screen.getByTestId('jump-to-live')); + await act(async () => { + fireEvent.click(screen.getByTestId('jump-to-live')); + }); expect(scrollElement.scrollTo).toHaveBeenCalledWith({ top: 1000, diff --git a/client/src/pages/Recording.test.tsx b/client/src/pages/Recording.test.tsx index 5427f42..67acf4d 100644 --- a/client/src/pages/Recording.test.tsx +++ b/client/src/pages/Recording.test.tsx @@ -39,7 +39,7 @@ vi.mock('@/api/interface', () => ({ })); // Mock toast -const mockToast = vi.fn(); +const mockToast = vi.fn((..._args: unknown[]) => undefined); vi.mock('@/hooks/ui/use-toast', () => ({ toast: (...args: unknown[]) => mockToast(...args), })); @@ -96,53 +96,49 @@ describe('RecordingPage', () => { vi.clearAllMocks(); }); - it('shows desktop-only message when not running in Tauri', () => { + it('shows desktop-only message when not running in Tauri', async () => { mockIsTauriEnvironment.mockReturnValue(false); localStorage.setItem('noteflow_preferences', JSON.stringify({ simulate_transcription: false })); const router = createMemoryRouter([{ path: '/recording/:id', element: }], { initialEntries: ['/recording/new'], future: { - v7_startTransition: true, v7_relativeSplatPath: true, }, }); render( - + ); - expect(screen.getByText('Desktop recording only')).toBeInTheDocument(); - expect( - screen.getByText(/Recording and live transcription are available in the desktop app/i) - ).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('Desktop recording only')).toBeInTheDocument(); + expect( + screen.getByText(/Recording and live transcription are available in the desktop app/i) + ).toBeInTheDocument(); + }); }); - it('allows simulated recording when enabled in preferences', () => { + it('allows simulated recording when enabled in preferences', async () => { mockIsTauriEnvironment.mockReturnValue(false); localStorage.setItem('noteflow_preferences', JSON.stringify({ simulate_transcription: true })); const router = createMemoryRouter([{ path: '/recording/:id', element: }], { initialEntries: ['/recording/new'], future: { - v7_startTransition: true, v7_relativeSplatPath: true, }, }); render( - + ); - expect(screen.getByRole('button', { name: /Start Recording/i })).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /Start Recording/i })).toBeInTheDocument(); + }); }); }); @@ -171,15 +167,12 @@ describe('RecordingPage - GAP-006 Connection Bootstrapping', () => { localStorage.setItem('noteflow_preferences', JSON.stringify({ simulate_transcription: false })); const router = createMemoryRouter([{ path: '/recording/:id', element: }], { initialEntries: ['/recording/new'], - future: { v7_startTransition: true, v7_relativeSplatPath: true }, + future: { v7_relativeSplatPath: true }, }); render( - + ); @@ -203,15 +196,12 @@ describe('RecordingPage - GAP-006 Connection Bootstrapping', () => { localStorage.setItem('noteflow_preferences', JSON.stringify({ simulate_transcription: false })); const router = createMemoryRouter([{ path: '/recording/:id', element: }], { initialEntries: ['/recording/new'], - future: { v7_startTransition: true, v7_relativeSplatPath: true }, + future: { v7_relativeSplatPath: true }, }); render( - + ); @@ -246,15 +236,12 @@ describe('RecordingPage - GAP-006 Connection Bootstrapping', () => { localStorage.setItem('noteflow_preferences', JSON.stringify({ simulate_transcription: false })); const router = createMemoryRouter([{ path: '/recording/:id', element: }], { initialEntries: ['/recording/new'], - future: { v7_startTransition: true, v7_relativeSplatPath: true }, + future: { v7_relativeSplatPath: true }, }); render( - + ); diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index 49e2f15..e5704a6 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -1,7 +1,7 @@ // Settings Page import { motion } from 'framer-motion'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { getAPI } from '@/api/interface'; import { initializeTauriAPI, isTauriEnvironment } from '@/api'; @@ -53,9 +53,9 @@ export default function SettingsPage() { const [serverInfo, setServerInfo] = useState(null); const [effectiveServerUrl, setEffectiveServerUrl] = useState(null); - // Audio devices - use shared hook + const shouldLoadAudio = activeTab === 'audio' && (isTauriEnvironment() || DevModeConfig.isDevMode()); const audioDevices = useAudioDevices({ - autoLoad: isTauriEnvironment() || DevModeConfig.isDevMode(), + autoLoad: shouldLoadAudio, showToasts: true, }); @@ -184,36 +184,21 @@ export default function SettingsPage() { } }, []); - const hydrateServerPreferences = useCallback(async () => { - try { - const api = await initializeTauriAPI(); - const prefs = await api.getPreferences(); - setServerHost(prefs.server_host); - setServerPort(prefs.server_port); - const current = preferences.get(); - - // CRITICAL: audio_devices are LOCAL-ONLY - never use Tauri values - // User selections in localStorage are authoritative; Tauri disk values are ignored. - // This prevents stale auto-selected devices from overwriting user choices. - const mergedAudioDevices = { ...current.audio_devices }; - - preferences.replace({ ...current, ...prefs, audio_devices: mergedAudioDevices }); + const mountedRef = useRef(false); + useEffect(() => { + if (mountedRef.current) { return; - } catch { - // Not running in Tauri; fall back to local preferences. } + mountedRef.current = true; + const prefs = preferences.get(); setServerHost(prefs.server_host); setServerPort(prefs.server_port); - }, []); - - // Load server info and API keys on mount - useEffect(() => { + checkConnection(); - void hydrateServerPreferences(); loadEncryptedApiKeys(); void fetchProviders(); - }, [checkConnection, hydrateServerPreferences, loadEncryptedApiKeys, fetchProviders]); + }, [checkConnection, loadEncryptedApiKeys, fetchProviders]); useEffect(() => { return preferences.subscribe((prefs) => { @@ -227,9 +212,14 @@ export default function SettingsPage() { }); }, []); - // Start sync scheduler when integrations are loaded + // Start sync scheduler when integrations are loaded (one-time after initial load) + const schedulerStartedRef = useRef(false); useEffect(() => { + if (schedulerStartedRef.current) { + return; + } if (!loadingApiKeys && integrations.some((i) => i.status === 'connected')) { + schedulerStartedRef.current = true; startScheduler(integrations); } }, [loadingApiKeys, integrations, startScheduler]); @@ -414,7 +404,7 @@ export default function SettingsPage() { - + - + - + - + - + + render( + + + + ); + const apiMocks = vi.hoisted(() => ({ mockListTasks: vi.fn<(request: ListTasksRequest) => Promise>(), mockUpdateTask: vi.fn(), @@ -312,25 +322,19 @@ describe('TasksPage', () => { kanbanState.lastProps = null; }); - it('shows loading skeletons when fetching tasks', () => { + it('shows loading skeletons when fetching tasks', async () => { queryState.tasksLoading = true; queryState.tasksResponse = undefined; - render( - - - - ); + renderTasksPage(); - expect(document.querySelector('.skeleton-shimmer')).toBeInTheDocument(); + await waitFor(() => { + expect(document.querySelector('.skeleton-shimmer')).toBeInTheDocument(); + }); }); it('shows empty state when there are no tasks', () => { - render( - - - - ); + renderTasksPage(); expect(screen.getByText('No open tasks')).toBeInTheDocument(); expect(screen.getByText('All caught up! Great job.')).toBeInTheDocument(); @@ -339,11 +343,7 @@ describe('TasksPage', () => { it('renders task list when tasks are available', () => { queryState.tasksResponse = { tasks: [MOCK_TASK_WITH_MEETING], total_count: MOCK_TASK_TOTAL }; - render( - - - - ); + renderTasksPage(); expect(screen.getByText('Follow up with client')).toBeInTheDocument(); expect(screen.getByText('Sprint Planning')).toBeInTheDocument(); @@ -365,11 +365,7 @@ describe('TasksPage', () => { total_count: 2, }; - render( - - - - ); + renderTasksPage(); fireEvent.click(screen.getByRole('button', { name: /high/i })); expect(screen.getByText('Send invoice')).toBeInTheDocument(); @@ -396,11 +392,7 @@ describe('TasksPage', () => { total_count: 2, }; - render( - - - - ); + renderTasksPage(); fireEvent.click(findTaskRowButton('Open task')); @@ -427,18 +419,18 @@ describe('TasksPage', () => { preferencesState.tasks_view_mode = 'board'; queryState.tasksResponse = { tasks: [MOCK_TASK_WITH_MEETING], total_count: 1 }; - render( - - - - ); + renderTasksPage(); - expect(screen.getByText('TasksKanbanView')).toBeInTheDocument(); - expect(projectScopeState.lastProps).not.toBeNull(); - expect(kanbanState.lastProps).not.toBeNull(); + await waitFor(() => { + expect(screen.getByText('TasksKanbanView')).toBeInTheDocument(); + expect(projectScopeState.lastProps).not.toBeNull(); + expect(kanbanState.lastProps).not.toBeNull(); + }); - kanbanState.lastProps?.onStatusChange(MOCK_TASK_ID, 'done'); - kanbanState.lastProps?.onTextChange(MOCK_TASK_ID, 'Updated task'); + await act(async () => { + kanbanState.lastProps?.onStatusChange(MOCK_TASK_ID, 'done'); + kanbanState.lastProps?.onTextChange(MOCK_TASK_ID, 'Updated task'); + }); expect(tasksMutationState.mutate).toHaveBeenCalledWith({ taskId: MOCK_TASK_ID, @@ -450,8 +442,10 @@ describe('TasksPage', () => { }); preferencesState.setTasksProjectFilter.mockClear(); - projectScopeState.lastProps?.onProjectScopeChange('selected'); - projectScopeState.lastProps?.onSelectedProjectIdsChange(['project-2']); + await act(async () => { + projectScopeState.lastProps?.onProjectScopeChange('selected'); + projectScopeState.lastProps?.onSelectedProjectIdsChange(['project-2']); + }); await waitFor(() => { expect(preferencesState.setTasksProjectFilter).toHaveBeenCalledWith('selected', ['project-2']); @@ -465,11 +459,7 @@ describe('TasksPage', () => { projectsState.projects = []; projectsState.isLoading = false; - render( - - - - ); + renderTasksPage(); const calls = mockUseQuery.mock.calls as unknown as Array<[{ enabled?: boolean }]>; const lastCall = calls[calls.length - 1]; @@ -490,33 +480,21 @@ describe('TasksPage', () => { total_count: 1, }; - render( - - - - ); + renderTasksPage(); const link = screen.getByRole('link', { name: /unscoped meeting/i }); expect(link.getAttribute('href')).toBe('/projects'); }); it('persists view mode changes', () => { - render( - - - - ); + renderTasksPage(); fireEvent.click(screen.getByTitle('Board View')); expect(preferencesState.setTasksViewMode).toHaveBeenCalledWith('board'); }); it('requests all statuses when filter is set to all', async () => { - render( - - - - ); + renderTasksPage(); const allTab = screen.getByRole('tab', { name: /all/i }); fireEvent.pointerDown(allTab); @@ -531,11 +509,7 @@ describe('TasksPage', () => { }); it('invokes mutation success and error handlers', async () => { - render( - - - - ); + renderTasksPage(); const options = mutationState.lastOptions; if (!options) { @@ -558,11 +532,7 @@ describe('TasksPage', () => { it('switches back to list view when clicking list view button', () => { preferencesState.tasks_view_mode = 'board'; - render( - - - - ); + renderTasksPage(); fireEvent.click(screen.getByTitle('List View')); expect(preferencesState.setTasksViewMode).toHaveBeenCalledWith('list'); diff --git a/client/src/pages/meeting-detail/ask-panel.tsx b/client/src/pages/meeting-detail/ask-panel.tsx index 3e6a811..97651b6 100644 --- a/client/src/pages/meeting-detail/ask-panel.tsx +++ b/client/src/pages/meeting-detail/ask-panel.tsx @@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; +import { generateUuid } from '@/lib/utils/id'; import { useAssistant } from '@/hooks/processing'; import { formatTime } from '@/lib/utils/format'; @@ -50,7 +51,7 @@ export function AskPanel({ meetingId, onCitationClick }: AskPanelProps) { // Add user message const userMsg: Message = { - id: crypto.randomUUID(), + id: generateUuid(), role: 'user', text: question, }; @@ -60,7 +61,7 @@ export function AskPanel({ meetingId, onCitationClick }: AskPanelProps) { if (response) { const assistantMsg: Message = { - id: crypto.randomUUID(), + id: generateUuid(), role: 'assistant', text: response.answer, citations: response.citations, diff --git a/client/src/pages/meeting-detail/index.test.tsx b/client/src/pages/meeting-detail/index.test.tsx index da13fe7..cdf0129 100644 --- a/client/src/pages/meeting-detail/index.test.tsx +++ b/client/src/pages/meeting-detail/index.test.tsx @@ -141,8 +141,23 @@ vi.mock('@/components/ui/tabs', () => ({ TabsContent: ({ children }: { children: React.ReactNode }) =>

{children}
, })); +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, + PopoverTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + vi.mock('./summary-panel', () => ({ - SummaryPanel: () =>
, + SummaryPanel: ({ summary, onGenerateSummary }: { summary?: unknown; onGenerateSummary?: () => void }) => ( +
+
+ {!summary && ( + + )} +
+ ), })); vi.mock('./entities-panel', () => ({ @@ -421,6 +436,7 @@ describe('MeetingDetailPage', () => { }); it('handles citation clicks with virtualized transcript', () => { + vi.useFakeTimers(); const segments = Array.from({ length: 101 }, (_, index) => createSegment(index)); const meeting = createMeeting({ segments }); const handleSegmentSelect = vi.fn(); @@ -432,26 +448,28 @@ describe('MeetingDetailPage', () => { { index: 1, start: 96 }, ]; virtualizerState.totalSize = 192; - askPanelState.citation = { + const citation: SegmentCitation = { + meeting_id: meeting.id, segment_id: segments[1]?.segment_id ?? 2, start_time: segments[1]?.start_time ?? 5, end_time: segments[1]?.end_time ?? 10, text: 'Segment 2', score: 0.8, }; + askPanelState.citation = citation; render(); fireEvent.click(screen.getByText('Trigger citation')); + vi.runAllTimers(); - expect(handleSegmentSelect).toHaveBeenCalledWith( - askPanelState.citation.segment_id, - askPanelState.citation.start_time - ); + expect(handleSegmentSelect).toHaveBeenCalledWith(citation.segment_id, citation.start_time); expect(virtualizerState.scrollToIndex).toHaveBeenCalledWith(1, { align: 'center' }); + vi.useRealTimers(); }); it('scrolls to transcript rows when not virtualized', () => { + vi.useFakeTimers(); const segments = Array.from({ length: 2 }, (_, index) => createSegment(index)); const meeting = createMeeting({ segments }); const handleSegmentSelect = vi.fn(); @@ -464,22 +482,22 @@ describe('MeetingDetailPage', () => { meetingDetailState.data = createMeetingDetailState({ meeting }); playbackState.data = createPlaybackState({ handleSegmentSelect }); - askPanelState.citation = { + const citation: SegmentCitation = { + meeting_id: meeting.id, segment_id: segments[0]?.segment_id ?? 1, start_time: segments[0]?.start_time ?? 0, end_time: segments[0]?.end_time ?? 5, text: 'Segment 1', score: 0.5, }; + askPanelState.citation = citation; render(); fireEvent.click(screen.getByText('Trigger citation')); + vi.runAllTimers(); - expect(handleSegmentSelect).toHaveBeenCalledWith( - askPanelState.citation.segment_id, - askPanelState.citation.start_time - ); + expect(handleSegmentSelect).toHaveBeenCalledWith(citation.segment_id, citation.start_time); expect(scrollSpy).toHaveBeenCalled(); if (originalScroll) { @@ -490,9 +508,11 @@ describe('MeetingDetailPage', () => { } else { delete (HTMLElement.prototype as { scrollIntoView?: unknown }).scrollIntoView; } + vi.useRealTimers(); }); it('ignores citations that do not match any segment', () => { + vi.useFakeTimers(); const segments = Array.from({ length: 2 }, (_, index) => createSegment(index)); const handleSegmentSelect = vi.fn(); @@ -500,19 +520,23 @@ describe('MeetingDetailPage', () => { meeting: createMeeting({ segments }), }); playbackState.data = createPlaybackState({ handleSegmentSelect }); - askPanelState.citation = { + const citation: SegmentCitation = { + meeting_id: 'meeting-1', segment_id: 999, start_time: 0, end_time: 1, text: 'Missing segment', score: 0.1, }; + askPanelState.citation = citation; render(); fireEvent.click(screen.getByText('Trigger citation')); + vi.runAllTimers(); expect(handleSegmentSelect).not.toHaveBeenCalled(); expect(virtualizerState.scrollToIndex).not.toHaveBeenCalled(); + vi.useRealTimers(); }); }); diff --git a/client/src/pages/meeting-detail/index.tsx b/client/src/pages/meeting-detail/index.tsx index a05b170..34ba347 100644 --- a/client/src/pages/meeting-detail/index.tsx +++ b/client/src/pages/meeting-detail/index.tsx @@ -7,21 +7,21 @@ import { useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { ChevronDown, ChevronRight, MessageSquare, Sparkles } from 'lucide-react'; +import { List, MessageSquare, NotebookPen, Sparkles, Plus, CheckSquare } from 'lucide-react'; import { getAPI } from '@/api/interface'; import { isTauriEnvironment } from '@/api'; -import type { ExportFormat } from '@/api/types'; +import type { ActionItem, ExportFormat, Priority, SegmentCitation, TaskStatus } from '@/api/types'; import { ProcessingStatus } from '@/components/features/meetings'; import { SkeletonTranscript } from '@/components/ui/skeleton'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { PriorityBadge } from '@/components/common'; import { useGuardedMutation } from '@/hooks'; +import { toast } from '@/hooks/ui/use-toast'; +import { toastError } from '@/lib/observability/errors'; import { buildExportBlob, downloadBlob } from '@/lib/utils/download'; import { @@ -30,20 +30,30 @@ import { TRANSCRIPT_VIRTUALIZE_THRESHOLD, } from './constants'; import { AskPanel } from './ask-panel'; -import { EntitiesPanel } from './entities-panel'; import { Header } from './header'; import { SummaryPanel } from './summary-panel'; import { MeetingTranscriptRow } from './transcript-row'; import { useMeetingDetail } from './use-meeting-detail'; import { usePlayback } from './use-playback'; +const TASK_STATUSES: TaskStatus[] = ['open', 'done', 'dismissed']; +const TASKS_LIMIT = 500; +const PRIORITY_VALUES: Record = { + high: 3, + medium: 2, + low: 1, +}; + +const normalizeActionItemText = (text: string) => text.trim().toLowerCase(); + export default function MeetingDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { guard } = useGuardedMutation(); const transcriptScrollRef = useRef(null); const isTauri = isTauriEnvironment(); - const [isSummaryOpen, setIsSummaryOpen] = useState(true); + const [activeTab, setActiveTab] = useState('summary'); + const [addingActionItemKey, setAddingActionItemKey] = useState(null); const { meeting, @@ -144,6 +154,105 @@ export default function MeetingDetailPage() { }); }; + const handleCitationClick = (citation: SegmentCitation) => { + const segmentIndex = meeting?.segments.findIndex( + (s) => s.segment_id === citation.segment_id + ) ?? -1; + + if (segmentIndex === -1) { + return; + } + + setActiveTab('transcript'); + handleSegmentSelect(citation.segment_id, citation.start_time); + setTimeout(() => { + if (shouldVirtualizeTranscript) { + transcriptVirtualizer.scrollToIndex(segmentIndex, { align: 'center' }); + } else if (transcriptScrollRef.current) { + const segmentElement = transcriptScrollRef.current.querySelector( + `[data-segment-id="${citation.segment_id}"]` + ); + segmentElement?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 100); + }; + + const handleAddToTasks = async (item: ActionItem) => { + if (!meeting) { + return; + } + + const actionKey = normalizeActionItemText(item.text); + setAddingActionItemKey(actionKey); + + try { + const result = await guard( + async () => { + const listResponse = await getAPI().listTasks({ + meeting_id: meeting.id, + limit: TASKS_LIMIT, + statuses: TASK_STATUSES, + }); + + const match = listResponse.tasks.find( + (taskWithMeeting) => + normalizeActionItemText(taskWithMeeting.task.text) === actionKey + ); + + if (!match) { + const created = await getAPI().createTask({ + text: item.text, + meeting_id: meeting.id, + priority: PRIORITY_VALUES[item.priority], + due_date: item.due_date, + status: 'open', + }); + + toast({ + title: 'Added to tasks', + description: 'Action item is now in your open tasks.', + }); + + return created; + } + + if (match.task.status === 'open') { + toast({ + title: 'Already in tasks', + description: 'This action item is already in your open tasks.', + }); + return match; + } + + await getAPI().updateTask({ task_id: match.task.id, status: 'open' }); + + toast({ + title: 'Added to tasks', + description: 'Action item is now in your open tasks.', + }); + + return match; + }, + { + title: 'Offline mode', + message: 'Updating tasks requires an active server connection.', + } + ); + + if (!result) { + return; + } + } catch (error) { + toastError({ + title: 'Failed to add task', + error, + fallback: 'Unable to update tasks right now.', + }); + } finally { + setAddingActionItemKey(null); + } + }; + if (loading) { return (
@@ -161,7 +270,7 @@ export default function MeetingDetailPage() { } return ( -
+
{processingState.isActive && ( -
+
)} -
- {/* Main Content Area: Summary + Transcript */} -
- {/* Summary Section */} -
- -
- - - - - {!meeting.summary && !loading && ( - - )} -
- - -
- -
-
-
-
- - {/* Transcript Section */} -
-

Transcript

- {shouldVirtualizeTranscript ? ( -
- {transcriptVirtualizer.getVirtualItems().map((virtualRow) => { - const segment = meeting.segments[virtualRow.index]; - return ( -
- -
- ); - })} +
+ {/* Left Column: Combined Content Tabs */} +
+ +
+ + + + Executive Summary + + + + Full Transcript + + + + My Notes + +
- ) : ( -
- {meeting.segments.map((segment) => ( - + + - ))} -
- )} -
-
+ - {/* Right Panel: Ask, Entities & Notes */} -
- - - - - Ask - - - Entities - - - Notes - - - - { - const segmentIndex = meeting.segments.findIndex( - (s) => s.segment_id === citation.segment_id - ); - if (segmentIndex !== -1) { - handleSegmentSelect(citation.segment_id, citation.start_time); - if (shouldVirtualizeTranscript) { - transcriptVirtualizer.scrollToIndex(segmentIndex, { align: 'center' }); - } else if (transcriptScrollRef.current) { - const segmentElement = transcriptScrollRef.current.querySelector( - `[data-segment-id="${citation.segment_id}"]` - ); - segmentElement?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - } - }} - /> - - - - - -

No annotations yet

-
+ +
+ {shouldVirtualizeTranscript ? ( +
+ {transcriptVirtualizer.getVirtualItems().map((virtualRow) => { + const segment = meeting.segments[virtualRow.index]; + return ( +
+ +
+ ); + })} +
+ ) : ( +
+ {meeting.segments.map((segment) => ( + + ))} +
+ )} +
+
+ + +