diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md index 10a3c42..96bca82 100644 --- a/.claude/ralph-loop.local.md +++ b/.claude/ralph-loop.local.md @@ -1,6 +1,6 @@ --- active: true -iteration: 1 +iteration: 6 max_iterations: 0 completion_promise: null started_at: "2026-01-19T15:47:26Z" diff --git a/.claudectx/codefixes.md b/.claudectx/codefixes.md index 83482c3..f3f8864 100644 --- a/.claudectx/codefixes.md +++ b/.claudectx/codefixes.md @@ -1,55 +1,287 @@ -# Codefixes Tracking (Jan 19, 2026) +# Entity extraction robustness notes -## Completion Status +## Library options that can do heavy lifting +- GLiNER (Apache-2.0): generalist NER model that supports arbitrary labels without retraining; API via `GLiNER.from_pretrained(...).predict_entities(text, labels, threshold=0.5)`. + - Source: https://github.com/urchade/GLiNER +- Presidio (MIT): pipeline framework for detection + redaction focused on PII; includes NLP + regex recognizers, and extensible recognizer registry; useful for sensitive entity categories rather than general “meeting entities”. + - Source: https://github.com/microsoft/presidio +- Stanza (Apache-2.0): full NLP pipeline with NER component; supports many languages; requires model download and can add CoreNLP features. + - Source: https://stanfordnlp.github.io/stanza/ +- spaCy (open-source): production-oriented NLP toolkit with NER + entity ruler; offers fast pipelines and rule-based patterns; easy to combine ML + rules for domain entities. + - Source: https://spacy.io/usage/spacy-101 -| Item | Description | Status | -|------|-------------|--------| -| 1 | Audio chunk serialization optimization | ✅ COMPLETED | -| 2 | Client backpressure throttling | ✅ COMPLETED | -| 3 | Whisper VAD disabled for streaming | ✅ COMPLETED | -| 4 | Per-segment DB overhead reduction | ✅ COMPLETED | -| 5 | Auto-trigger offline diarization | ✅ COMPLETED | -| 6 | Diarization turn pruning tuning | ✅ COMPLETED | -| 7 | Audio format conversion tests | ✅ COMPLETED | -| 8 | Stream state consolidation | ✅ COMPLETED | -| 9 | Adaptive partial cadence | ✅ COMPLETED | -| 10 | TS object spread (no issue) | ✅ N/A | -| 11 | Pre-existing test fixes (sprint 15 + sync) | ✅ COMPLETED | +## Recommendation for NoteFlow +- Best “heavy lifting” candidate: GLiNER for flexible, domain-specific entity types without retraining. It can be fed label sets like [person, company, product, app, location, time, duration, event] and will return per-label spans. +- If PII detection is a requirement: layer Presidio on top of GLiNER outputs (or run Presidio first for sensitive categories). Presidio is not optimized for general entity relevance outside PII. +- If you prefer classic pipelines or need dependency parsing: spaCy or Stanza; spaCy is easier to extend with rule-based entity ruler and custom merge policies. ---- +## Quick wins for robustness (model-agnostic) +- Normalize text: capitalization restoration, punctuation cleanup, ASR filler removal before NER. +- Post-process: merge adjacent time phrases ("last night" + "20 minutes"), dedupe casing, drop single-token low-signal entities. +- Context scoring: down-rank profanity/generic nouns unless in quoted/explicit phrases. +- Speaker-aware aggregation: extract per speaker, then merge and rank by frequency and context windows. -## Summary of Items +## Meeting entity schema (proposed) +- person +- org +- product +- app +- location +- time +- time_relative +- duration +- event +- task +- decision +- topic -1. **Audio serialization**: Optimize JS→Rust→gRPC path (Float32Array→Vec→bytes) ✅ - - Added `bytemuck` crate for zero-copy byte casting - - Replaced per-element `flat_map` with `bytemuck::cast_slice` (O(1) cast + single memcpy) - - Added compile-time endianness check for safety - - Added 3 unit tests verifying identical output - - Fixed Rust quality warning: extracted `TEST_SAMPLE_COUNT` constant -2. **Backpressure**: Add real throttling when server reports congestion ✅ - - Added `THROTTLE_THRESHOLD_MS` (3 seconds) and `THROTTLE_RESUME_DELAY_MS` (500ms) - - `send()` returns false when throttled, drops chunks - - 10 new throttle behavior tests added -3. **Whisper VAD**: Pass vad_filter=False for streaming segments ✅ -4. **DB overhead**: Cache meeting info in stream state, reduce per-segment commits ✅ - - Added `meeting_db_id` caching in `MeetingStreamState` - - `_ensure_meeting_db_id()` fetches on first segment only - - Fixed type errors with proper `UnitOfWork` and `AsrResult` types -5. **Auto diarization**: Trigger offline refinement after recording stop ✅ - - Added `diarization_auto_refine` setting (default False) - - Config flows through `ServicesConfig` to servicer - - `auto_trigger_diarization_refinement()` in `_jobs.py` handles job creation - - Triggered from `start_post_processing()` after meeting stops -6. **Pruning tuning**: Reduce streaming diarization window (preview mode) ✅ - - Reduced `_MAX_TURN_AGE_MINUTES` from 15 to 5 - - Reduced `_MAX_TURN_COUNT` from 5000 to 1000 -7. **Audio tests**: Add missing tests for resample/format validation ✅ - - Created 34 tests in `tests/grpc/test_audio_processing.py` - - TestResampleAudio (8), TestDecodeAudioChunk (4), TestConvertAudioFormat (8), TestValidateStreamFormat (14) -8. **Stream state**: Migrate to MeetingStreamState as single source of truth ✅ -9. **Adaptive cadence**: Apply multiplier to partial cadence under congestion ✅ -10. **TS spread**: No issue found in current codebase ✅ -11. **Pre-existing test fixes**: Fixed failing tests discovered during validation ✅ - - `test_sprint_15_1_critical_bugs.py`: Fixed path prefixes (`_mixins` → `mixins`) - - `test_sync_orchestration.py`: Fixed `error_message` → `error_code` in protocol/assertion +## spaCy label mapping (to meeting schema) +- PERSON -> person +- ORG -> org +- GPE/LOC/FAC -> location +- PRODUCT -> product +- EVENT -> event +- DATE/TIME -> time +- CARDINAL/QUANTITY -> duration (only if units present: min/hour/day/week/month) +- WORK_OF_ART/LAW -> topic (fallback) +- NORP -> topic (fallback) +## GLiNER label set (meeting tuned) +- person, org, product, app, location, time, time_relative, duration, event, task, decision, topic + +## Eval content (meeting-like snippets) +1) "We should ping Pixel about the shipment, maybe next week." + - gold: Pixel=product, next week=time_relative +2) "Let’s meet at Navi around 3ish tomorrow." + - gold: Navi=location, 3ish=time, tomorrow=time_relative +3) "Paris is on the agenda, but it’s just a placeholder." + - gold: Paris=location, agenda=topic +4) "The demo for Figma went well last night." + - gold: demo=event, Figma=product, last night=time_relative +5) "I spent 20 minutes fixing the auth issue." + - gold: 20 minutes=duration, auth issue=topic +6) "Can you file a Jira for the login bug?" + - gold: Jira=product, login bug=task +7) "Sam and Priya agreed to ship v2 by Friday." + - gold: Sam=person, Priya=person, Friday=time +8) "We decided to drop the old onboarding flow." + - gold: decision=decision, onboarding flow=topic +9) "Let’s sync after the standup." + - gold: sync=event, standup=event +10) "I heard Maya mention Notion for notes." + - gold: Maya=person, Notion=product, notes=topic +11) "Zoom audio was bad during the call." + - gold: Zoom=product, call=event +12) "We should ask Alex about the budget." + - gold: Alex=person, budget=topic +13) "Let’s revisit this in two weeks." + - gold: two weeks=duration +14) "The patch for Safari is done." + - gold: Safari=product, patch=task +15) "We’ll meet at the cafe near Union Station." + - gold: cafe=location, Union Station=location + +## Data-backed comparison (local eval) +- Setup: spaCy `en_core_web_sm` with mapping rules above vs GLiNER `urchade/gliner_medium-v2.1` with label set above. +- Dataset: 15 meeting-like snippets (see eval content section). +- Results: + - spaCy: precision 0.34, recall 0.19 (tp=7, fp=10, fn=26) + - GLiNER: precision 0.36, recall 0.36 (tp=11, fp=17, fn=22) + +## Recommendation based on results +- GLiNER outperformed spaCy on recall (0.36 vs 0.19) while maintaining slightly higher precision, which is more aligned with meeting transcripts where relevance is tied to custom labels and informal mentions. +- Use GLiNER as the primary extractor, then apply a post-processing filter for low-signal entities and a normalization layer for time/duration to improve precision. + +## Implementation plan: GLiNER + post-processing with swap-friendly architecture (detailed + references) + +### Overview +Goal: Add GLiNER while keeping a clean boundary so switching back to spaCy or another backend is a config-only change. This uses three layers: +1) Backend (GLiNER/spaCy) returns `RawEntity` only. +2) Post-processor normalizes and filters `RawEntity`. +3) Mapper converts `RawEntity` -> domain `NamedEntity`. + +### Step 0: Identify the existing entry points +- Current NER engine: `src/noteflow/infrastructure/ner/engine.py`. +- Domain entity type: `src/noteflow/domain/entities/named_entity.py` (for `EntityCategory`, `NamedEntity`). +- Service creation: `src/noteflow/grpc/startup/services.py` (selects NER engine). +- Feature flag: `src/noteflow/config/settings/_features.py` (`ner_enabled`). + +### Step 1: Add backend-agnostic models +Create new file: `src/noteflow/infrastructure/ner/backends/types.py` +- Add `RawEntity` dataclass: + - `text: str`, `label: str`, `start: int`, `end: int`, `confidence: float | None` + - `label` must be lowercase (e.g., `person`, `time_relative`). +- Add `NerBackend` protocol: + - `def extract(self, text: str) -> list[RawEntity]: ...` + +### Step 2: Implement GLiNER backend +Create `src/noteflow/infrastructure/ner/backends/gliner_backend.py` +- Lazy-load model to avoid startup delay (pattern copied from `src/noteflow/infrastructure/ner/engine.py`). +- Import: `from gliner import GLiNER`. +- Model: `GLiNER.from_pretrained("urchade/gliner_medium-v2.1")`. +- Labels come from settings; if missing, default to the meeting label list in this doc. +- Convert GLiNER entity dicts into `RawEntity`: + - `text`: `entity["text"]` + - `label`: `entity["label"].lower()` + - `start`: `entity["start"]` + - `end`: `entity["end"]` + - `confidence`: `entity.get("score")` +- Keep thresholds configurable (`NOTEFLOW_NER_GLINER_THRESHOLD`), default `0.5`. + +### Step 3: Move spaCy logic into a backend +Create `src/noteflow/infrastructure/ner/backends/spacy_backend.py` +- Move current spaCy loading logic from `src/noteflow/infrastructure/ner/engine.py`. +- Keep the model constants in `src/noteflow/config/constants.py`. +- Use the existing spaCy mapping and skip rules; move them into this backend or a shared module. +- Convert `Doc.ents` into `RawEntity` with lowercase labels. + +### Step 4: Create shared post-processing pipeline +Create `src/noteflow/infrastructure/ner/post_processing.py` (pure functions only, no class): +- `normalize_text(text: str) -> str` + - `lower()`, collapse whitespace, trim. +- `dedupe_entities(entities: list[RawEntity]) -> list[RawEntity]` + - key: normalized text; keep highest confidence if both present. +- `drop_low_signal_entities(entities: list[RawEntity], text: str) -> list[RawEntity]` + - drop profanity, short tokens (`len <= 2`), numeric-only entities. + - list should live in `src/noteflow/domain/constants/` or `src/noteflow/config/constants.py`. +- `merge_time_phrases(entities: list[RawEntity], text: str) -> list[RawEntity]` + - If two time-like entities are adjacent in the original text, merge into one span. +- `infer_duration(entities: list[RawEntity]) -> list[RawEntity]` + - If entity contains duration units (`minute`, `hour`, `day`, `week`, `month`), set label to `duration`. + +### Step 5: Mapping to domain entities +Create `src/noteflow/infrastructure/ner/mapper.py` +- `map_raw_to_named(entities: list[RawEntity]) -> list[NamedEntity]`. +- Convert labels to `EntityCategory` from `src/noteflow/domain/entities/named_entity.py`. +- Confidence rules: + - If `RawEntity.confidence` exists, use it. + - Else use default 0.8 (same as current spaCy fallback in `engine.py`). + +### Step 6: Update NerEngine to use composition +Edit `src/noteflow/infrastructure/ner/engine.py`: +- Remove direct spaCy dependency from `NerEngine`. +- Add constructor params: + - `backend: NerBackend`, `post_processor: Callable`, `mapper: NerMapper`. +- Flow in `extract`: + 1) `raw = backend.extract(text)` + 2) `raw = post_process(raw, text)` + 3) `entities = mapper.map_raw_to_named(raw)` + 4) Deduplicate by normalized text (use current logic, or move to post-processing). + +### Step 7: Configurable backend selection +Edit `src/noteflow/grpc/startup/services.py`: +- Add config: `NOTEFLOW_NER_BACKEND` (default `spacy`). +- If value is `gliner`, instantiate `GLiNERBackend`; else `SpacyBackend`. +- Continue to respect `get_feature_flags().ner_enabled` in `create_ner_service`. + +### Step 8: Tests (behavior + user flow + response quality) +Create the following tests and use global fixtures from `tests/conftest.py` (do not redefine them): +- Use `mock_uow`, `sample_meeting`, `meeting_id`, `sample_datetime`, `approx_sequence` where helpful. +- Avoid loops/conditionals in tests; use `@pytest.mark.parametrize`. + +**Unit tests** +- `tests/ner/test_post_processing.py` + - Validate normalization, dedupe, time merge, duration inference, and profanity drop. + - Use small, deterministic inputs and assert exact `RawEntity` outputs. +- `tests/ner/test_mapper.py` + - Map every label to `EntityCategory` explicitly. + - Assert confidence fallback when `RawEntity.confidence=None`. + +**Behavior tests** +- `tests/ner/test_engine_behavior.py` + - Use a stub backend to simulate GLiNER outputs with overlapping spans. + - Verify `NerEngine.extract` returns stable, deduped, correctly categorized entities. + +**User-flow tests** +- `tests/ner/test_service_flow.py` + - Use `mock_uow` and `sample_meeting` to simulate service flow that calls NER after a meeting completes. + - Assert entities are persisted with expected categories and segment IDs. + +**Response quality tests** +- `tests/ner/test_quality_contract.py` + - Feed short, meeting-like snippets and assert: + - No profanity entities survive. + - Time phrases are merged. + - Product vs person is not mis-typed for known examples (Pixel, Notion, Zoom). + +### Test matrix (inputs -> expected outputs) +Use these exact cases in `tests/ner/test_quality_contract.py` with `@pytest.mark.parametrize`: +1) Text: "We should ping Pixel about the shipment, maybe next week." + - Expect: `Pixel=product`, `next week=time_relative` +2) Text: "Let’s meet at Navi around 3ish tomorrow." + - Expect: `Navi=location`, `3ish=time`, `tomorrow=time_relative` +3) Text: "The demo for Figma went well last night." + - Expect: `demo=event`, `Figma=product`, `last night=time_relative` +4) Text: "I spent 20 minutes fixing the auth issue." + - Expect: `20 minutes=duration`, `auth issue=topic` +5) Text: "Can you file a Jira for the login bug?" + - Expect: `Jira=product`, `login bug=task` +6) Text: "Zoom audio was bad during the call." + - Expect: `Zoom=product`, `call=event` +7) Text: "We should ask Alex about the budget." + - Expect: `Alex=person`, `budget=topic` +8) Text: "Let’s revisit this in two weeks." + - Expect: `two weeks=duration` +9) Text: "We decided to drop the old onboarding flow." + - Expect: `decision=decision`, `onboarding flow=topic` +10) Text: "This is shit, totally broken." + - Expect: no entity with text "shit" +11) Text: "Last night we met in Paris." + - Expect: `last night=time_relative`, `Paris=location` +12) Text: "We can sync after the standup next week." + - Expect: `sync=event`, `standup=event`, `next week=time_relative` +13) Text: "PIXEL and pixel are the same product." + - Expect: one `pixel=product` after dedupe +14) Text: "Met with Navi, NAVI, and navi." + - Expect: one `navi=location` after dedupe +15) Text: "It happened 2 days ago." + - Expect: `2 days=duration` or `2 days=time_relative` (pick one, but be consistent) +16) Text: "We should ask 'Sam' and Sam about the release." + - Expect: one `sam=person` +17) Text: "Notion and Figma docs are ready." + - Expect: `Notion=product`, `Figma=product` +18) Text: "Meet at Union Station, then Union station again." + - Expect: one `union station=location` +19) Text: "We need a patch for Safari and safari iOS." + - Expect: one `safari=product` and `patch=task` +20) Text: "App 'Navi' crashed during the call." + - Expect: `Navi=product` (overrides location when app context exists) + +### Test assertions guidance +- Always assert exact normalized text + label pairs. +- Assert counts (expected length) to avoid hidden extras. +- For time merge: verify only one entity span for "last night" or "next week". +- For profanity: assert it does not appear in any entity text. +- For casing/duplicates: assert one normalized entity only. +- For override rules: assert app/product context wins over location if explicit. + +### Step 9: Strict exit criteria +Implementation is only done if ALL are true: +- `make quality` passes. +- No compatibility wrappers or adapter shims added. +- No legacy code paths left in place (spaCy backend must be an actual backend, not dead code). +- No temp artifacts or experimental files left behind. + +### Example backend usage (pseudo) +``` +backend = GLiNERBackend(labels=settings.ner_labels, threshold=settings.ner_threshold) +raw = backend.extract(text) +raw = post_process(raw, text) +entities = mapper.map_raw_to_named(raw) +``` + +### Outcome +- Switching NER backends is now a 1-line config change. +- Post-processing is shared and consistent across backends. +- Domain entities remain unchanged for callers. + +## Simple eval scoring checklist +- Precision per label +- Recall per label +- Actionable rate (task/decision/event/topic) +- Type confusion counts (person<->product, org<->product, location<->org, time<->duration) + +## Next steps to validate +- Run GLiNER on sample transcript and compare entity yield vs spaCy pipeline. +- Apply the spaCy label mapping above to ensure apples-to-apples scoring. +- Add a lightweight rule layer for time_relative/duration normalization. diff --git a/client/src/App.tsx b/client/src/App.tsx index 02fd3e8..bf6095b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,10 +1,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { BrowserRouter, Navigate, Route, Routes, useParams } from 'react-router-dom'; -import { AppLayout } from '@/components/app-layout'; -import { DevProfiler } from '@/components/dev-profiler'; -import { ErrorBoundary } from '@/components/error-boundary'; -import { PreferencesSyncBridge } from '@/components/preferences-sync-bridge'; -import { TauriEventListener } from '@/components/tauri-event-listener'; +import { AppLayout } from '@/components/layout'; +import { ErrorBoundary } from '@/components/common'; +import { DevProfiler } from '@/components/dev'; +import { PreferencesSyncBridge } from '@/components/features/sync'; +import { TauriEventListener } from '@/components/system'; import { Toaster as Sonner } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/toaster'; import { TooltipProvider } from '@/components/ui/tooltip'; diff --git a/client/src/components/common/index.ts b/client/src/components/common/index.ts new file mode 100644 index 0000000..607d61c --- /dev/null +++ b/client/src/components/common/index.ts @@ -0,0 +1,13 @@ +// Common/shared UI components barrel export +export { EmptyState } from './empty-state'; +export { StatsCard, CenteredStatsCard } from './stats-card'; +export { ErrorBoundary } from './error-boundary'; +export { NavLink } from './nav-link'; + +// Badges +export { AnnotationTypeBadge } from './badges/annotation-type-badge'; +export { PriorityBadge } from './badges/priority-badge'; +export { SpeakerBadge } from './badges/speaker-badge'; + +// Dialogs +export { ConfirmationDialog } from './dialogs/confirmation-dialog'; diff --git a/client/src/components/dev/index.ts b/client/src/components/dev/index.ts new file mode 100644 index 0000000..a40199f --- /dev/null +++ b/client/src/components/dev/index.ts @@ -0,0 +1,3 @@ +// Dev components barrel export +export { DevProfiler } from './dev-profiler'; +export { SimulationConfirmationDialog } from './simulation-confirmation-dialog'; diff --git a/client/src/components/dev/simulation-confirmation-dialog.tsx b/client/src/components/dev/simulation-confirmation-dialog.tsx index baeb1ef..fb9c1b8 100644 --- a/client/src/components/dev/simulation-confirmation-dialog.tsx +++ b/client/src/components/dev/simulation-confirmation-dialog.tsx @@ -5,7 +5,7 @@ // Includes "Don't ask again" checkbox for persistent skip preference. import { useState } from 'react'; -import { ConfirmationDialog } from '@/components/confirmation-dialog'; +import { ConfirmationDialog } from '@/components/common'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; diff --git a/client/src/components/features/analytics/logs-tab-list.tsx b/client/src/components/features/analytics/logs-tab-list.tsx index 62881a4..ad2f1d4 100644 --- a/client/src/components/features/analytics/logs-tab-list.tsx +++ b/client/src/components/features/analytics/logs-tab-list.tsx @@ -2,8 +2,8 @@ import { FileText, RefreshCw } from 'lucide-react'; import type { RefObject } from 'react'; import type { LogLevel, LogSource } from '@/api/types'; -import { LogEntry as LogEntryComponent, type LogEntryData } from '@/components/analytics/log-entry'; -import { LogTimeline } from '@/components/analytics/log-timeline'; +import { LogEntry as LogEntryComponent, type LogEntryData } from './log-entry'; +import { LogTimeline } from './log-timeline'; import { ScrollArea } from '@/components/ui/scroll-area'; import type { LogGroup, GroupMode } from '@/lib/observability/groups'; import type { SummarizedLog } from '@/lib/observability/summarizer'; diff --git a/client/src/components/features/analytics/logs-tab.tsx b/client/src/components/features/analytics/logs-tab.tsx index fab587a..730c6b9 100644 --- a/client/src/components/features/analytics/logs-tab.tsx +++ b/client/src/components/features/analytics/logs-tab.tsx @@ -18,10 +18,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Timing } from '@/api'; import { getAPI } from '@/api/interface'; import type { LogLevel as ApiLogLevel, LogSource as ApiLogSource } from '@/api/types'; -import type { LogEntryData } from '@/components/analytics/log-entry'; -import { levelConfig } from '@/components/analytics/log-entry-config'; -import { AnalyticsCardTitle } from '@/components/analytics/analytics-card-title'; -import { LogsTabList } from '@/components/analytics/logs-tab-list'; +import type { LogEntryData } from './log-entry'; +import { levelConfig } from './log-entry-config'; +import { AnalyticsCardTitle } from './analytics-card-title'; +import { LogsTabList } from './logs-tab-list'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; diff --git a/client/src/components/features/analytics/speech-analysis-tab.tsx b/client/src/components/features/analytics/speech-analysis-tab.tsx index 4fe8646..f462544 100644 --- a/client/src/components/features/analytics/speech-analysis-tab.tsx +++ b/client/src/components/features/analytics/speech-analysis-tab.tsx @@ -1,7 +1,7 @@ import { AlertCircle, Brain, Hash, Lightbulb, MessageSquare, TrendingUp } from 'lucide-react'; import { useMemo } from 'react'; import type { Meeting } from '@/api/types'; -import { AnalyticsCardTitle } from '@/components/analytics/analytics-card-title'; +import { AnalyticsCardTitle } from '@/components/features/analytics/analytics-card-title'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; diff --git a/client/src/components/features/calendar/index.ts b/client/src/components/features/calendar/index.ts new file mode 100644 index 0000000..6f44476 --- /dev/null +++ b/client/src/components/features/calendar/index.ts @@ -0,0 +1,4 @@ +// Calendar feature components barrel export +export { CalendarConnectionPanel } from './calendar-connection-panel'; +export { CalendarEventsPanel } from './calendar-events-panel'; +export { UpcomingMeetings } from './upcoming-meetings'; diff --git a/client/src/components/features/connectivity/api-mode-indicator.test.tsx b/client/src/components/features/connectivity/api-mode-indicator.test.tsx index 0203f31..4c9b7e5 100644 --- a/client/src/components/features/connectivity/api-mode-indicator.test.tsx +++ b/client/src/components/features/connectivity/api-mode-indicator.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { ApiModeIndicator } from '@/components/api-mode-indicator'; +import { ApiModeIndicator } from '@/components/features/connectivity'; describe('ApiModeIndicator', () => { it('renders simulated badge when isSimulating is true', () => { diff --git a/client/src/components/features/connectivity/index.ts b/client/src/components/features/connectivity/index.ts new file mode 100644 index 0000000..e8c8106 --- /dev/null +++ b/client/src/components/features/connectivity/index.ts @@ -0,0 +1,5 @@ +// Connectivity feature components barrel export +export { ConnectionStatus } from './connection-status'; +export { OfflineBanner } from './offline-banner'; +export { ApiModeIndicator } from './api-mode-indicator'; +export { ServerSwitchConfirmationDialog } from './server-switch-confirmation-dialog'; diff --git a/client/src/components/features/connectivity/offline-banner.test.tsx b/client/src/components/features/connectivity/offline-banner.test.tsx index e7d447d..01303e1 100644 --- a/client/src/components/features/connectivity/offline-banner.test.tsx +++ b/client/src/components/features/connectivity/offline-banner.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { ConnectionProvider } from '@/contexts/connection-context'; -import { OfflineBanner } from '@/components/offline-banner'; +import { OfflineBanner } from '@/components/features/connectivity'; import { incrementReconnectAttempts, setConnectionMode } from '@/api'; describe('OfflineBanner', () => { diff --git a/client/src/components/features/connectivity/server-switch-confirmation-dialog.tsx b/client/src/components/features/connectivity/server-switch-confirmation-dialog.tsx index 6af1fb0..3322668 100644 --- a/client/src/components/features/connectivity/server-switch-confirmation-dialog.tsx +++ b/client/src/components/features/connectivity/server-switch-confirmation-dialog.tsx @@ -4,7 +4,7 @@ // Informs users that integrations will be disconnected and need re-authentication. import { Server } from 'lucide-react'; -import { ConfirmationDialog } from '@/components/confirmation-dialog'; +import { ConfirmationDialog } from '@/components/common'; export interface ServerSwitchConfirmationDialogProps { /** Whether the dialog is open */ diff --git a/client/src/components/features/entities/entity-highlight.tsx b/client/src/components/features/entities/entity-highlight.tsx index c2a49c5..7b338a6 100644 --- a/client/src/components/features/entities/entity-highlight.tsx +++ b/client/src/components/features/entities/entity-highlight.tsx @@ -83,17 +83,24 @@ function EntityTooltip({ entity, isPinned, onPin, onClose, position }: EntityToo ); } -interface HighlightedTermProps { +export interface HighlightedTermProps { text: string; entity: Entity; pinnedEntities: Set; onTogglePin: (entityId: string) => void; + className?: string; } -function HighlightedTerm({ text, entity, pinnedEntities, onTogglePin }: HighlightedTermProps) { +export function HighlightedTerm({ + text, + entity, + pinnedEntities, + onTogglePin, + className, +}: HighlightedTermProps) { const [isHovered, setIsHovered] = useState(false); const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 }); - const termRef = useRef(null); + const termRef = useRef(null); const suppressClickRef = useRef(false); const isPinned = pinnedEntities.has(entity.id); const showTooltip = isHovered || isPinned; @@ -137,7 +144,8 @@ function HighlightedTerm({ text, entity, pinnedEntities, onTogglePin }: Highligh 'cursor-pointer rounded px-0.5 -mx-0.5 transition-colors border-0 bg-transparent', 'bg-primary/15 text-primary border-b border-primary/40 border-dashed', 'hover:bg-primary/25 hover:border-solid', - isPinned && 'bg-primary/25 border-solid' + isPinned && 'bg-primary/25 border-solid', + className )} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} diff --git a/client/src/components/features/entities/index.ts b/client/src/components/features/entities/index.ts new file mode 100644 index 0000000..274cbfa --- /dev/null +++ b/client/src/components/features/entities/index.ts @@ -0,0 +1,8 @@ +// Entities feature components barrel export +export { + EntityHighlightText, + HighlightedTerm, + type HighlightedTermProps, +} from './entity-highlight'; +export { EntityManagementPanel } from './entity-management-panel'; +export { AnimatedTranscription, type AnimatedTranscriptionProps } from './animated-transcription'; diff --git a/client/src/components/features/integrations/index.ts b/client/src/components/features/integrations/index.ts new file mode 100644 index 0000000..13b0f0f --- /dev/null +++ b/client/src/components/features/integrations/index.ts @@ -0,0 +1,6 @@ +// Integrations feature components barrel export +export { IntegrationConfigPanel } from './integration-config-panel'; +export { WebhookSettingsPanel } from './webhook-settings-panel'; + +// Re-export from integration-config-panel folder +export * from './integration-config-panel/index'; diff --git a/client/src/components/features/integrations/integration-config-panel.tsx b/client/src/components/features/integrations/integration-config-panel.tsx index 7a976c2..91d5563 100644 --- a/client/src/components/features/integrations/integration-config-panel.tsx +++ b/client/src/components/features/integrations/integration-config-panel.tsx @@ -2,7 +2,7 @@ * Integration Configuration Panel Component. * * Re-exports from the modular integration-config-panel package. - * For new code, import directly from '@/components/integration-config-panel'. + * For new code, import directly from '@/components/features/integrations'. */ export { diff --git a/client/src/components/features/meetings/index.ts b/client/src/components/features/meetings/index.ts new file mode 100644 index 0000000..78499fa --- /dev/null +++ b/client/src/components/features/meetings/index.ts @@ -0,0 +1,4 @@ +// Meetings feature components barrel export +export { MeetingCard } from './meeting-card'; +export { MeetingStateBadge } from './meeting-state-badge'; +export { ProcessingStatus } from './processing-status'; diff --git a/client/src/components/features/meetings/meeting-card.tsx b/client/src/components/features/meetings/meeting-card.tsx index 9d56b9c..caca528 100644 --- a/client/src/components/features/meetings/meeting-card.tsx +++ b/client/src/components/features/meetings/meeting-card.tsx @@ -5,7 +5,7 @@ import { Calendar, Clock, Trash2 } from 'lucide-react'; import { forwardRef } from 'react'; import { Link } from 'react-router-dom'; import type { Meeting } from '@/api/types'; -import { MeetingStateBadge } from '@/components/meeting-state-badge'; +import { MeetingStateBadge } from '@/components/features/meetings'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { useProjects } from '@/contexts/project-state'; diff --git a/client/src/components/features/notes/index.ts b/client/src/components/features/notes/index.ts new file mode 100644 index 0000000..17d66f9 --- /dev/null +++ b/client/src/components/features/notes/index.ts @@ -0,0 +1,2 @@ +// Notes feature components barrel export +export { TimestampedNotesEditor, type NoteEdit } from './timestamped-notes-editor'; diff --git a/client/src/components/features/notes/timestamped-notes-editor.tsx b/client/src/components/features/notes/timestamped-notes-editor.tsx index 1d27236..468b3a8 100644 --- a/client/src/components/features/notes/timestamped-notes-editor.tsx +++ b/client/src/components/features/notes/timestamped-notes-editor.tsx @@ -13,7 +13,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { formatElapsedTime } from '@/lib/utils/format'; import { generateUuid } from '@/lib/utils/id'; import { cn } from '@/lib/utils'; -import { NotesQuickActions } from '@/components/recording/notes-quick-actions'; +import { NotesQuickActions } from '@/components/features/recording/notes-quick-actions'; export interface NoteEdit { id: string; diff --git a/client/src/components/features/recording/audio-level-meter.test.tsx b/client/src/components/features/recording/audio-level-meter.test.tsx index 366db54..1369708 100644 --- a/client/src/components/features/recording/audio-level-meter.test.tsx +++ b/client/src/components/features/recording/audio-level-meter.test.tsx @@ -2,7 +2,7 @@ import { act, render } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { AudioLevelMeter } from '@/components/recording/audio-level-meter'; +import { AudioLevelMeter } from './audio-level-meter'; describe('AudioLevelMeter', () => { beforeEach(() => { diff --git a/client/src/components/features/recording/confidence-indicator.test.tsx b/client/src/components/features/recording/confidence-indicator.test.tsx index 205e364..50334b6 100644 --- a/client/src/components/features/recording/confidence-indicator.test.tsx +++ b/client/src/components/features/recording/confidence-indicator.test.tsx @@ -2,7 +2,7 @@ import { render } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { ConfidenceIndicator } from '@/components/recording/confidence-indicator'; +import { ConfidenceIndicator } from './confidence-indicator'; describe('ConfidenceIndicator', () => { it('renders percentage correctly', () => { diff --git a/client/src/components/features/recording/idle-state.test.tsx b/client/src/components/features/recording/idle-state.test.tsx index a68a72f..2d92924 100644 --- a/client/src/components/features/recording/idle-state.test.tsx +++ b/client/src/components/features/recording/idle-state.test.tsx @@ -2,7 +2,7 @@ import { render } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { IdleState } from '@/components/recording/idle-state'; +import { IdleState } from './idle-state'; describe('IdleState', () => { it('renders the ready to record heading', () => { diff --git a/client/src/components/features/recording/notes-panel.tsx b/client/src/components/features/recording/notes-panel.tsx index 5784c92..174cec6 100644 --- a/client/src/components/features/recording/notes-panel.tsx +++ b/client/src/components/features/recording/notes-panel.tsx @@ -5,7 +5,7 @@ import { PanelRightClose, PanelRightOpen } from 'lucide-react'; -import { type NoteEdit, TimestampedNotesEditor } from '@/components/timestamped-notes-editor'; +import { type NoteEdit, TimestampedNotesEditor } from '@/components/features/notes'; import { Button } from '@/components/ui/button'; import { buttonSize } from '@/lib/ui/styles'; diff --git a/client/src/components/features/recording/partial-text-display.tsx b/client/src/components/features/recording/partial-text-display.tsx index 71daae5..a9630d8 100644 --- a/client/src/components/features/recording/partial-text-display.tsx +++ b/client/src/components/features/recording/partial-text-display.tsx @@ -2,7 +2,7 @@ import { motion } from 'framer-motion'; import { memo } from 'react'; -import { EntityHighlightText } from '@/components/entity-highlight'; +import { AnimatedTranscription } from '@/components/features/entities'; export interface PartialTextDisplayProps { text: string; @@ -28,12 +28,14 @@ export const PartialTextDisplay = memo(function PartialTextDisplay({ LIVE

- -

diff --git a/client/src/components/features/recording/recording-components.test.tsx b/client/src/components/features/recording/recording-components.test.tsx index 31bb227..6a3ea42 100644 --- a/client/src/components/features/recording/recording-components.test.tsx +++ b/client/src/components/features/recording/recording-components.test.tsx @@ -14,8 +14,8 @@ vi.mock('framer-motion', () => ({ AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}, })); -vi.mock('@/components/entity-highlight', () => ({ - EntityHighlightText: ({ text }: { text: string }) => {text}, +vi.mock('@/components/features/entities/animated-transcription', () => ({ + AnimatedTranscription: ({ text }: { text: string }) => {text}, })); vi.mock('./speaker-distribution', () => ({ diff --git a/client/src/components/features/recording/recording-header.test.tsx b/client/src/components/features/recording/recording-header.test.tsx index 5af126a..d0ab01c 100644 --- a/client/src/components/features/recording/recording-header.test.tsx +++ b/client/src/components/features/recording/recording-header.test.tsx @@ -2,11 +2,11 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { RecordingHeader } from './recording-header'; -vi.mock('@/components/entity-management-panel', () => ({ +vi.mock('@/components/features/entities/entity-management-panel', () => ({ EntityManagementPanel: () =>
, })); -vi.mock('@/components/api-mode-indicator', () => ({ +vi.mock('@/components/features/connectivity/api-mode-indicator', () => ({ ApiModeIndicator: ({ isSimulating }: { mode: string; isSimulating: boolean }) => isSimulating ?
Simulated
: null, })); diff --git a/client/src/components/features/recording/recording-header.tsx b/client/src/components/features/recording/recording-header.tsx index b5a1184..570aa61 100644 --- a/client/src/components/features/recording/recording-header.tsx +++ b/client/src/components/features/recording/recording-header.tsx @@ -4,7 +4,7 @@ import { Loader2, Mic, Square } from 'lucide-react'; import type { ConnectionMode } from '@/api'; import type { Meeting } from '@/api/types'; -import { EntityManagementPanel } from '@/components/entity-management-panel'; +import { EntityManagementPanel } from '@/components/features/entities'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ButtonVariant, iconWithMargin } from '@/lib/ui/styles'; diff --git a/client/src/components/features/recording/speaker-distribution.tsx b/client/src/components/features/recording/speaker-distribution.tsx index eba2547..2d171a2 100644 --- a/client/src/components/features/recording/speaker-distribution.tsx +++ b/client/src/components/features/recording/speaker-distribution.tsx @@ -2,7 +2,7 @@ import { memo, useMemo } from 'react'; import type { FinalSegment } from '@/api/types'; -import { SpeakerBadge } from '@/components/speaker-badge'; +import { SpeakerBadge } from '@/components/common'; import { getSpeakerColorIndex } from '@/lib/audio/speaker'; interface SpeakerDistributionProps { diff --git a/client/src/components/features/recording/stat-card.test.tsx b/client/src/components/features/recording/stat-card.test.tsx index 1c1f241..1e541f5 100644 --- a/client/src/components/features/recording/stat-card.test.tsx +++ b/client/src/components/features/recording/stat-card.test.tsx @@ -3,7 +3,7 @@ import { render } from '@testing-library/react'; import { Clock, Mic, Users } from 'lucide-react'; import { describe, expect, it } from 'vitest'; -import { StatCard } from '@/components/recording/stat-card'; +import { StatCard } from './stat-card'; describe('StatCard', () => { it('renders label and value', () => { diff --git a/client/src/components/features/recording/stats-panel.tsx b/client/src/components/features/recording/stats-panel.tsx index 4fa0c29..7aa5c0c 100644 --- a/client/src/components/features/recording/stats-panel.tsx +++ b/client/src/components/features/recording/stats-panel.tsx @@ -6,7 +6,7 @@ import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'; import type { FinalSegment } from '@/api/types'; -import { StatsContent } from '@/components/recording/stats-content'; +import { StatsContent } from './stats-content'; import { Button } from '@/components/ui/button'; import { buttonSize } from '@/lib/ui/styles'; diff --git a/client/src/components/features/recording/transcript-segment-card.tsx b/client/src/components/features/recording/transcript-segment-card.tsx index e371ce4..0a16080 100644 --- a/client/src/components/features/recording/transcript-segment-card.tsx +++ b/client/src/components/features/recording/transcript-segment-card.tsx @@ -3,8 +3,8 @@ import { motion } from 'framer-motion'; import { memo } from 'react'; import type { FinalSegment } from '@/api/types'; -import { EntityHighlightText } from '@/components/entity-highlight'; -import { SpeakerBadge } from '@/components/speaker-badge'; +import { AnimatedTranscription } from '@/components/features/entities'; +import { SpeakerBadge } from '@/components/common'; import { ConfidenceIndicator } from './confidence-indicator'; import { TranscriptSegmentActions } from './transcript-segment-actions'; import { formatTime } from '@/lib/utils/format'; @@ -49,10 +49,13 @@ export const TranscriptSegmentCard = memo(function TranscriptSegmentCard({

-

diff --git a/client/src/components/features/recording/unified-status-row.tsx b/client/src/components/features/recording/unified-status-row.tsx index f94a5fa..0137dfb 100644 --- a/client/src/components/features/recording/unified-status-row.tsx +++ b/client/src/components/features/recording/unified-status-row.tsx @@ -1,4 +1,4 @@ -import { ApiModeIndicator } from '@/components/api-mode-indicator'; +import { ApiModeIndicator } from '@/components/features/connectivity'; import { Badge } from '@/components/ui/badge'; import { formatElapsedTime } from '@/lib/utils/format'; import type { ConnectionMode } from '@/api'; diff --git a/client/src/components/features/recording/vad-indicator.test.tsx b/client/src/components/features/recording/vad-indicator.test.tsx index 96a3eb8..c1dfaa2 100644 --- a/client/src/components/features/recording/vad-indicator.test.tsx +++ b/client/src/components/features/recording/vad-indicator.test.tsx @@ -2,7 +2,7 @@ import { render } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { VADIndicator } from '@/components/recording/vad-indicator'; +import { VADIndicator } from './vad-indicator'; describe('VADIndicator', () => { it('returns null when not recording', () => { diff --git a/client/src/components/features/settings/integrations-section/integration-item.tsx b/client/src/components/features/settings/integrations-section/integration-item.tsx index 64e6d7c..d0afdef 100644 --- a/client/src/components/features/settings/integrations-section/integration-item.tsx +++ b/client/src/components/features/settings/integrations-section/integration-item.tsx @@ -5,8 +5,8 @@ import { AlertTriangle, Clock, Loader2, X } from 'lucide-react'; import type { Integration } from '@/api/types'; -import { IntegrationConfigPanel } from '@/components/integration-config-panel'; -import { SyncStatusIndicator } from '@/components/sync-status-indicator'; +import { IntegrationConfigPanel } from '@/components/features/integrations'; +import { SyncStatusIndicator } from '@/components/features/sync'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; diff --git a/client/src/components/features/sync/index.ts b/client/src/components/features/sync/index.ts new file mode 100644 index 0000000..bc0a6a9 --- /dev/null +++ b/client/src/components/features/sync/index.ts @@ -0,0 +1,6 @@ +// Sync feature components barrel export +export { SyncControlPanel } from './sync-control-panel'; +export { SyncHistoryLog } from './sync-history-log'; +export { SyncStatusIndicator } from './sync-status-indicator'; +export { PreferencesSyncStatus } from './preferences-sync-status'; +export { PreferencesSyncBridge } from './preferences-sync-bridge'; diff --git a/client/src/components/features/workspace/index.ts b/client/src/components/features/workspace/index.ts new file mode 100644 index 0000000..bc6f741 --- /dev/null +++ b/client/src/components/features/workspace/index.ts @@ -0,0 +1,2 @@ +// Workspace feature components barrel export +export { WorkspaceSwitcher } from './workspace-switcher'; diff --git a/client/src/components/features/workspace/workspace-switcher.test.tsx b/client/src/components/features/workspace/workspace-switcher.test.tsx index 11e203c..8f01edc 100644 --- a/client/src/components/features/workspace/workspace-switcher.test.tsx +++ b/client/src/components/features/workspace/workspace-switcher.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { WorkspaceSwitcher } from '@/components/workspace-switcher'; +import { WorkspaceSwitcher } from '@/components/features/workspace'; import { useWorkspace } from '@/contexts/workspace-state'; vi.mock('@/contexts/workspace-state', () => ({ diff --git a/client/src/components/icons/status-icons.tsx b/client/src/components/icons/status-icons.tsx new file mode 100644 index 0000000..e5b2a29 --- /dev/null +++ b/client/src/components/icons/status-icons.tsx @@ -0,0 +1,6 @@ +import { CheckCircle2 } from 'lucide-react'; +import type { SVGProps } from 'react'; + +export function SuccessIcon(props: SVGProps) { + return ; +} diff --git a/client/src/components/integration-config-panel/auth-config.tsx b/client/src/components/integration-config-panel/auth-config.tsx deleted file mode 100644 index 2ff68f6..0000000 --- a/client/src/components/integration-config-panel/auth-config.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/** - * OAuth/SSO authentication configuration. - */ - -import { Globe, Key, Lock } from 'lucide-react'; - -import type { Integration } from '@/api/types'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; - -import { configPanelContentStyles, Field, SecretInput } from './shared'; - -interface AuthConfigProps { - integration: Integration; - onUpdate: (config: Partial) => void; - showSecrets: Record; - toggleSecret: (key: string) => void; -} - -export function AuthConfig({ integration, onUpdate, showSecrets, toggleSecret }: AuthConfigProps) { - const config = integration.oauth_config || { - client_id: '', - client_secret: '', - redirect_uri: '', - scopes: [], - }; - - return ( -
-
- }> - onUpdate({ oauth_config: { ...config, client_id: e.target.value } })} - placeholder="Enter client ID" - /> - - onUpdate({ oauth_config: { ...config, client_secret: value } })} - placeholder="Enter client secret" - showSecret={showSecrets.client_secret ?? false} - onToggleSecret={() => toggleSecret('client_secret')} - icon={} - /> -
- }> - onUpdate({ oauth_config: { ...config, redirect_uri: e.target.value } })} - placeholder="https://your-app.com/auth/callback" - /> -

- Configure this URL in your OAuth provider's settings -

-
-
- - - onUpdate({ - oauth_config: { - ...config, - scopes: e.target.value - .split(',') - .map((s) => s.trim()) - .filter(Boolean), - }, - }) - } - placeholder="openid, email, profile" - /> -

Comma-separated list of OAuth scopes

-
-
- ); -} diff --git a/client/src/components/integration-config-panel/calendar-config.tsx b/client/src/components/integration-config-panel/calendar-config.tsx deleted file mode 100644 index 2396a31..0000000 --- a/client/src/components/integration-config-panel/calendar-config.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Calendar integration configuration. - */ - -import { Globe, Key, Lock } from 'lucide-react'; - -import type { Integration } from '@/api/types'; -import { Badge } from '@/components/ui/badge'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Separator } from '@/components/ui/separator'; -import { Switch } from '@/components/ui/switch'; -import { useWorkspace } from '@/contexts/workspace-state'; -import { configPanelContentStyles, Field, SecretInput } from './shared'; - -interface CalendarConfigProps { - integration: Integration; - onUpdate: (config: Partial) => void; - showSecrets: Record; - toggleSecret: (key: string) => void; -} - -export function CalendarConfig({ - integration, - onUpdate, - showSecrets, - toggleSecret, -}: CalendarConfigProps) { - const { currentWorkspace } = useWorkspace(); - const calConfig = integration.calendar_config || { - sync_interval_minutes: 15, - calendar_ids: [], - }; - const oauthConfig = integration.oauth_config || { - client_id: '', - client_secret: '', - redirect_uri: '', - scopes: [], - }; - const overrideEnabled = integration.oauth_override_enabled ?? false; - const overrideHasSecret = integration.oauth_override_has_secret ?? false; - const canOverride = - currentWorkspace?.role === 'owner' || currentWorkspace?.role === 'admin'; - const oauthFieldsDisabled = !overrideEnabled || !canOverride; - - return ( -
-
- OAuth 2.0 - Requires OAuth authentication -
-
-
- -

- {overrideEnabled - ? 'Using custom credentials for this workspace' - : 'Using server-provided credentials'} -

- {!canOverride ? ( -

Admin access required

- ) : null} -
- onUpdate({ oauth_override_enabled: value })} - disabled={!canOverride} - /> -
-
- }> - - onUpdate({ - oauth_config: { ...oauthConfig, client_id: e.target.value }, - }) - } - placeholder="Enter client ID" - disabled={oauthFieldsDisabled} - /> - - onUpdate({ oauth_config: { ...oauthConfig, client_secret: value } })} - placeholder={overrideHasSecret ? 'Stored on server' : 'Enter client secret'} - showSecret={showSecrets.calendar_client_secret ?? false} - onToggleSecret={() => toggleSecret('calendar_client_secret')} - icon={} - disabled={oauthFieldsDisabled} - /> -
- }> - - onUpdate({ - oauth_config: { ...oauthConfig, redirect_uri: e.target.value }, - }) - } - placeholder="https://your-app.com/calendar/callback" - disabled={oauthFieldsDisabled} - /> - - - -
- - -
-
- - - onUpdate({ - calendar_config: { - ...calConfig, - calendar_ids: e.target.value - .split(',') - .map((s) => s.trim()) - .filter(Boolean), - }, - }) - } - placeholder="primary, work@example.com" - /> -

- Leave empty to sync all calendars, or specify calendar IDs -

-
-
- - - onUpdate({ - calendar_config: { ...calConfig, webhook_url: e.target.value }, - }) - } - placeholder="https://your-app.com/webhooks/calendar" - /> -

Receive real-time calendar updates

-
-
- ); -} diff --git a/client/src/components/integration-config-panel/email-config.tsx b/client/src/components/integration-config-panel/email-config.tsx deleted file mode 100644 index bf8bbb5..0000000 --- a/client/src/components/integration-config-panel/email-config.tsx +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Email provider configuration. - */ - -import { Key, Lock, Mail, Server } from 'lucide-react'; - -import type { Integration } from '@/api/types'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Separator } from '@/components/ui/separator'; -import { Switch } from '@/components/ui/switch'; -import { IntegrationDefaults } from '@/lib/config'; -import { configPanelContentStyles, Field, SecretInput, TestButton } from './shared'; - -interface EmailConfigProps { - integration: Integration; - onUpdate: (config: Partial) => void; - onTest?: () => void; - isTesting: boolean; - showSecrets: Record; - toggleSecret: (key: string) => void; -} - -export function EmailConfig({ - integration, - onUpdate, - onTest, - isTesting, - showSecrets, - toggleSecret, -}: EmailConfigProps) { - const config = integration.email_config || { - provider_type: 'api' as const, - api_key: '', - from_email: '', - from_name: '', - }; - - return ( -
-
- - -
- - {config.provider_type === 'api' ? ( - onUpdate({ email_config: { ...config, api_key: value } })} - placeholder="Enter your API key" - showSecret={showSecrets.email_api_key ?? false} - onToggleSecret={() => toggleSecret('email_api_key')} - icon={} - /> - ) : ( - <> -
- }> - - onUpdate({ - email_config: { ...config, smtp_host: e.target.value }, - }) - } - placeholder="smtp.example.com" - /> - -
- - - onUpdate({ - email_config: { - ...config, - smtp_port: parseInt(e.target.value, 10) || IntegrationDefaults.SMTP_PORT, - }, - }) - } - placeholder={String(IntegrationDefaults.SMTP_PORT)} - /> -
-
-
-
- - - onUpdate({ - email_config: { ...config, smtp_username: e.target.value }, - }) - } - placeholder="username@example.com" - /> -
- onUpdate({ email_config: { ...config, smtp_password: value } })} - placeholder="SMTP password" - showSecret={showSecrets.smtp_password ?? false} - onToggleSecret={() => toggleSecret('smtp_password')} - icon={} - /> -
-
- - onUpdate({ - email_config: { ...config, smtp_secure: checked }, - }) - } - /> - -
- - )} - - - -
- }> - - onUpdate({ - email_config: { ...config, from_email: e.target.value }, - }) - } - placeholder="noreply@example.com" - /> - -
- - - onUpdate({ - email_config: { ...config, from_name: e.target.value }, - }) - } - placeholder="NoteFlow" - /> -
-
- - -
- ); -} diff --git a/client/src/components/integration-config-panel/index.tsx b/client/src/components/integration-config-panel/index.tsx deleted file mode 100644 index fa87251..0000000 --- a/client/src/components/integration-config-panel/index.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Integration Configuration Panel Component. - * - * Renders configuration forms based on integration type. - * Split into separate components for maintainability. - */ - -import { useState } from 'react'; - -import type { Integration } from '@/api/types'; - -import { AuthConfig } from './auth-config'; -import { CalendarConfig } from './calendar-config'; -import { EmailConfig } from './email-config'; -import { OIDCConfig } from './oidc-config'; -import { PKMConfig } from './pkm-config'; -import { WebhookConfig } from './webhook-config'; - -export interface IntegrationConfigPanelProps { - integration: Integration; - onUpdate: (config: Partial) => void; - onTest?: () => void; - isTesting?: boolean; -} - -export function IntegrationConfigPanel({ - integration, - onUpdate, - onTest, - isTesting = false, -}: IntegrationConfigPanelProps) { - const [showSecrets, setShowSecrets] = useState>({}); - const toggleSecret = (key: string) => setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] })); - - // OAuth/SSO Configuration - if (integration.type === 'auth') { - return ( - - ); - } - - // Email Configuration - if (integration.type === 'email') { - return ( - - ); - } - - // Calendar Configuration - if (integration.type === 'calendar') { - return ( - - ); - } - - // PKM Configuration - if (integration.type === 'pkm') { - return ( - - ); - } - - // Custom/Webhook Configuration - if (integration.type === 'custom') { - return ( - - ); - } - - // OIDC Provider Configuration - if (integration.type === 'oidc') { - return ( - - ); - } - - return ( -
- No configuration options available for this integration. -
- ); -} diff --git a/client/src/components/integration-config-panel/oidc-config.tsx b/client/src/components/integration-config-panel/oidc-config.tsx deleted file mode 100644 index bffd36f..0000000 --- a/client/src/components/integration-config-panel/oidc-config.tsx +++ /dev/null @@ -1,183 +0,0 @@ -/** - * OIDC provider configuration. - */ - -import { Globe, Key, Lock } from 'lucide-react'; - -import type { Integration } from '@/api/types'; -import { Badge } from '@/components/ui/badge'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Switch } from '@/components/ui/switch'; -import { formatTimestamp } from '@/lib/utils/format'; - -import { Field, SecretInput, TestButton } from './shared'; - -interface OIDCConfigProps { - integration: Integration; - onUpdate: (config: Partial) => void; - onTest?: () => void; - isTesting: boolean; - showSecrets: Record; - toggleSecret: (key: string) => void; -} - -export function OIDCConfig({ - integration, - onUpdate, - onTest, - isTesting, - showSecrets, - toggleSecret, -}: OIDCConfigProps) { - const config = integration.oidc_config || { - preset: 'custom' as const, - issuer_url: '', - client_id: '', - client_secret: '', - scopes: ['openid', 'profile', 'email'], - claim_mapping: { - subject_claim: 'sub', - email_claim: 'email', - email_verified_claim: 'email_verified', - name_claim: 'name', - preferred_username_claim: 'preferred_username', - groups_claim: 'groups', - picture_claim: 'picture', - }, - require_email_verified: true, - allowed_groups: [], - }; - - return ( -
-
- OIDC - OpenID Connect Provider -
- -
- - -
- - }> - onUpdate({ oidc_config: { ...config, issuer_url: e.target.value } })} - placeholder="https://auth.example.com" - /> -

- Base URL for OIDC discovery (/.well-known/openid-configuration) -

-
- -
- }> - onUpdate({ oidc_config: { ...config, client_id: e.target.value } })} - placeholder="noteflow-client" - /> - - onUpdate({ oidc_config: { ...config, client_secret: value } })} - placeholder="Enter client secret" - showSecret={showSecrets.oidc_client_secret ?? false} - onToggleSecret={() => toggleSecret('oidc_client_secret')} - icon={} - /> -
- -
- - - onUpdate({ - oidc_config: { - ...config, - scopes: e.target.value - .split(',') - .map((s) => s.trim()) - .filter(Boolean), - }, - }) - } - placeholder="openid, profile, email, groups" - /> -

Comma-separated list of OAuth scopes

-
- -
- - onUpdate({ - oidc_config: { ...config, require_email_verified: checked }, - }) - } - /> - -
- - {config.discovery && ( -
-

Discovery Endpoints

-
-

- Authorization:{' '} - {config.discovery.authorization_endpoint} -

-

- Token:{' '} - {config.discovery.token_endpoint} -

- {config.discovery.userinfo_endpoint && ( -

- UserInfo:{' '} - {config.discovery.userinfo_endpoint} -

- )} -
- {config.discovery_refreshed_at && ( -

- Last refreshed: {formatTimestamp(config.discovery_refreshed_at)} -

- )} -
- )} - - -
- ); -} diff --git a/client/src/components/integration-config-panel/pkm-config.tsx b/client/src/components/integration-config-panel/pkm-config.tsx deleted file mode 100644 index ce68998..0000000 --- a/client/src/components/integration-config-panel/pkm-config.tsx +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Personal Knowledge Management (PKM) configuration. - */ - -import { Database, FolderOpen, Key } from 'lucide-react'; - -import type { Integration } from '@/api/types'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Switch } from '@/components/ui/switch'; -import { EXTERNAL_LINK_REL } from '@/lib/ui/styles'; -import { Field, SecretInput } from './shared'; - -interface PKMConfigProps { - integration: Integration; - onUpdate: (config: Partial) => void; - showSecrets: Record; - toggleSecret: (key: string) => void; -} - -export function PKMConfig({ integration, onUpdate, showSecrets, toggleSecret }: PKMConfigProps) { - const config = integration.pkm_config || { api_key: '', workspace_id: '', sync_enabled: false }; - const isNotion = integration.name.toLowerCase().includes('notion'); - const isObsidian = integration.name.toLowerCase().includes('obsidian'); - - return ( -
- {isNotion && ( - <> - onUpdate({ pkm_config: { ...config, api_key: value } })} - placeholder="secret_xxxxxxxxxxxxxxxx" - showSecret={showSecrets.notion_token ?? false} - onToggleSecret={() => toggleSecret('notion_token')} - icon={} - /> -

- Create an integration at{' '} - - notion.so/my-integrations - -

- }> - - onUpdate({ - pkm_config: { ...config, database_id: e.target.value }, - }) - } - placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - /> -

The ID from your Notion database URL

-
- - )} - - {isObsidian && ( - }> -
- - onUpdate({ - pkm_config: { ...config, vault_path: e.target.value }, - }) - } - placeholder="/path/to/obsidian/vault" - className="flex-1" - /> - -
-

Path to your Obsidian vault folder

-
- )} - - {!isNotion && !isObsidian && ( - <> - onUpdate({ pkm_config: { ...config, api_key: value } })} - placeholder="Enter API key" - showSecret={showSecrets.pkm_api_key ?? false} - onToggleSecret={() => toggleSecret('pkm_api_key')} - icon={} - /> -
- - - onUpdate({ - pkm_config: { ...config, workspace_id: e.target.value }, - }) - } - placeholder="Enter workspace ID" - /> -
- - )} - -
- - onUpdate({ - pkm_config: { ...config, sync_enabled: checked }, - }) - } - /> - -
-
- ); -} diff --git a/client/src/components/integration-config-panel/shared.tsx b/client/src/components/integration-config-panel/shared.tsx deleted file mode 100644 index fa8d7c8..0000000 --- a/client/src/components/integration-config-panel/shared.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Shared components for integration configuration panels. - */ - -import { Eye, EyeOff, Loader2, RefreshCw } from 'lucide-react'; -import type { ReactNode } from 'react'; - -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { iconWithMargin, labelStyles } from '@/lib/ui/styles'; - -/** Common container styles for config panel content sections. */ -export const configPanelContentStyles = 'space-y-4 pt-2'; - -/** - * Reusable form field wrapper with label and icon. - */ -export function Field({ - label, - icon, - children, -}: { - label: string; - icon?: ReactNode; - children: ReactNode; -}) { - return ( -
- - {children} -
- ); -} - -/** - * Secret input field with show/hide toggle. - */ -export function SecretInput({ - label, - value, - onChange, - placeholder, - showSecret, - onToggleSecret, - icon, - disabled = false, -}: { - label: string; - value: string; - onChange: (value: string) => void; - placeholder: string; - showSecret: boolean; - onToggleSecret: () => void; - icon?: ReactNode; - disabled?: boolean; -}) { - return ( - -
- onChange(e.target.value)} - placeholder={placeholder} - className="pr-10" - disabled={disabled} - /> - -
-
- ); -} - -/** - * Test connection button. - */ -export function TestButton({ - onTest, - isTesting, - label = 'Test Connection', - Icon = RefreshCw, -}: { - onTest?: () => void; - isTesting?: boolean; - label?: string; - Icon?: React.ElementType; -}) { - if (!onTest) { - return null; - } - return ( - - ); -} diff --git a/client/src/components/integration-config-panel/webhook-config.tsx b/client/src/components/integration-config-panel/webhook-config.tsx deleted file mode 100644 index 4ed1be7..0000000 --- a/client/src/components/integration-config-panel/webhook-config.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Custom/Webhook integration configuration. - */ - -import { Globe, Key } from 'lucide-react'; - -import type { Integration } from '@/api/types'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Field, SecretInput, TestButton } from './shared'; - -interface WebhookConfigProps { - integration: Integration; - onUpdate: (config: Partial) => void; - onTest?: () => void; - isTesting: boolean; - showSecrets: Record; - toggleSecret: (key: string) => void; -} - -export function WebhookConfig({ - integration, - onUpdate, - onTest, - isTesting, - showSecrets, - toggleSecret, -}: WebhookConfigProps) { - const config = integration.webhook_config || { - url: '', - method: 'POST' as const, - auth_type: 'none' as const, - auth_value: '', - }; - - return ( -
- }> - - onUpdate({ - webhook_config: { ...config, url: e.target.value }, - }) - } - placeholder="https://api.example.com/webhook" - /> - -
-
- - -
-
- - -
-
- - {config.auth_type && config.auth_type !== 'none' && ( - onUpdate({ webhook_config: { ...config, auth_value: value } })} - placeholder={config.auth_type === 'basic' ? 'username:password' : 'Enter value'} - showSecret={showSecrets.webhook_auth ?? false} - onToggleSecret={() => toggleSecret('webhook_auth')} - icon={} - /> - )} - - -
- ); -} diff --git a/client/src/components/layout/app-layout.tsx b/client/src/components/layout/app-layout.tsx index 83abdb2..c83fbf3 100644 --- a/client/src/components/layout/app-layout.tsx +++ b/client/src/components/layout/app-layout.tsx @@ -2,9 +2,9 @@ import { useState } from 'react'; import { Outlet, useNavigate } from 'react-router-dom'; -import { AppSidebar } from '@/components/app-sidebar'; -import { OfflineBanner } from '@/components/offline-banner'; -import { TopBar } from '@/components/top-bar'; +import { AppSidebar } from './app-sidebar'; +import { OfflineBanner } from '@/components/features/connectivity'; +import { TopBar } from './top-bar'; export interface AppOutletContext { activeMeetingId: string | null; diff --git a/client/src/components/layout/app-sidebar.tsx b/client/src/components/layout/app-sidebar.tsx index f6b925a..d29becc 100644 --- a/client/src/components/layout/app-sidebar.tsx +++ b/client/src/components/layout/app-sidebar.tsx @@ -19,7 +19,7 @@ import { import { useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { Button } from '@/components/ui/button'; -import { ProjectSidebar } from '@/components/projects/ProjectSidebar'; +import { ProjectSidebar } from '@/components/features/projects/ProjectSidebar'; import { useProjects } from '@/contexts/project-state'; import { preferences } from '@/lib/preferences'; import { cn } from '@/lib/utils'; diff --git a/client/src/components/layout/index.ts b/client/src/components/layout/index.ts new file mode 100644 index 0000000..8bc2615 --- /dev/null +++ b/client/src/components/layout/index.ts @@ -0,0 +1,4 @@ +// Layout components barrel export +export { AppLayout, type AppOutletContext } from './app-layout'; +export { AppSidebar } from './app-sidebar'; +export { TopBar } from './top-bar'; diff --git a/client/src/components/layout/top-bar.tsx b/client/src/components/layout/top-bar.tsx index 4e63b61..a3b3e1c 100644 --- a/client/src/components/layout/top-bar.tsx +++ b/client/src/components/layout/top-bar.tsx @@ -3,10 +3,9 @@ import { Download, Sparkles, Users } from 'lucide-react'; import { useState } from 'react'; -import { ConnectionStatus } from '@/components/connection-status'; -import { ProjectSwitcher } from '@/components/projects/ProjectSwitcher'; -import { ApiModeIndicator } from '@/components/api-mode-indicator'; -import { WorkspaceSwitcher } from '@/components/workspace-switcher'; +import { ConnectionStatus, ApiModeIndicator } from '@/components/features/connectivity'; +import { ProjectSwitcher } from '@/components/features/projects/ProjectSwitcher'; +import { WorkspaceSwitcher } from '@/components/features/workspace'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { SearchIcon } from '@/components/ui/search-icon'; diff --git a/client/src/components/settings/medium-label.tsx b/client/src/components/settings/medium-label.tsx new file mode 100644 index 0000000..329d72a --- /dev/null +++ b/client/src/components/settings/medium-label.tsx @@ -0,0 +1,9 @@ +import type { ComponentPropsWithoutRef } from 'react'; +import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; + +type MediumLabelProps = ComponentPropsWithoutRef; + +export function MediumLabel({ className, ...props }: MediumLabelProps) { + return