refactor: reorganize component exports, remove deprecated integration config panels, and add new UI hooks and icons.

This commit is contained in:
2026-01-19 22:21:04 +00:00
parent 1835dcae92
commit 217a867a8d
83 changed files with 623 additions and 1244 deletions

View File

@@ -1,6 +1,6 @@
---
active: true
iteration: 1
iteration: 6
max_iterations: 0
completion_promise: null
started_at: "2026-01-19T15:47:26Z"

View File

@@ -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<f32>→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) "Lets meet at Navi around 3ish tomorrow."
- gold: Navi=location, 3ish=time, tomorrow=time_relative
3) "Paris is on the agenda, but its 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) "Lets 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) "Lets revisit this in two weeks."
- gold: two weeks=duration
14) "The patch for Safari is done."
- gold: Safari=product, patch=task
15) "Well 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: "Lets 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: "Lets 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.

View File

@@ -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';

View File

@@ -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';

View File

@@ -0,0 +1,3 @@
// Dev components barrel export
export { DevProfiler } from './dev-profiler';
export { SimulationConfirmationDialog } from './simulation-confirmation-dialog';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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', () => {

View File

@@ -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';

View File

@@ -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', () => {

View File

@@ -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 */

View File

@@ -83,17 +83,24 @@ function EntityTooltip({ entity, isPinned, onPin, onClose, position }: EntityToo
);
}
interface HighlightedTermProps {
export interface HighlightedTermProps {
text: string;
entity: Entity;
pinnedEntities: Set<string>;
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<HTMLSpanElement>(null);
const termRef = useRef<HTMLButtonElement>(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)}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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';

View File

@@ -0,0 +1,2 @@
// Notes feature components barrel export
export { TimestampedNotesEditor, type NoteEdit } from './timestamped-notes-editor';

View File

@@ -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;

View File

@@ -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(() => {

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

@@ -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';

View File

@@ -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({
<span className="text-xs text-primary font-mono shrink-0 pt-1">LIVE</span>
<div className="flex-1">
<p className="text-sm text-primary/80 leading-relaxed italic">
<EntityHighlightText
<AnimatedTranscription
text={text}
blockId="partial-live"
staggerDelay={80}
pinnedEntities={pinnedEntities}
onTogglePin={onTogglePin}
showCursor={true}
/>
<span className="inline-block w-0.5 h-4 bg-primary ml-0.5 animate-pulse" />
</p>
</div>
</motion.div>

View File

@@ -14,8 +14,8 @@ vi.mock('framer-motion', () => ({
AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock('@/components/entity-highlight', () => ({
EntityHighlightText: ({ text }: { text: string }) => <span>{text}</span>,
vi.mock('@/components/features/entities/animated-transcription', () => ({
AnimatedTranscription: ({ text }: { text: string }) => <span>{text}</span>,
}));
vi.mock('./speaker-distribution', () => ({

View File

@@ -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: () => <div data-testid="entity-panel" />,
}));
vi.mock('@/components/api-mode-indicator', () => ({
vi.mock('@/components/features/connectivity/api-mode-indicator', () => ({
ApiModeIndicator: ({ isSimulating }: { mode: string; isSimulating: boolean }) =>
isSimulating ? <div>Simulated</div> : null,
}));

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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', () => {

View File

@@ -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';

View File

@@ -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({
<TranscriptSegmentActions segmentText={segment.text} />
</div>
<p className="text-sm text-foreground leading-relaxed">
<EntityHighlightText
<AnimatedTranscription
text={segment.text}
blockId={`segment-${segment.segment_id}`}
staggerDelay={80}
pinnedEntities={pinnedEntities}
onTogglePin={onTogglePin}
showCursor={false}
/>
</p>
</div>

View File

@@ -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';

View File

@@ -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', () => {

View File

@@ -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';

View File

@@ -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';

View File

@@ -0,0 +1,2 @@
// Workspace feature components barrel export
export { WorkspaceSwitcher } from './workspace-switcher';

View File

@@ -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', () => ({

View File

@@ -0,0 +1,6 @@
import { CheckCircle2 } from 'lucide-react';
import type { SVGProps } from 'react';
export function SuccessIcon(props: SVGProps<SVGSVGElement>) {
return <CheckCircle2 className="h-4 w-4 text-green-500" {...props} />;
}

View File

@@ -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<Integration>) => void;
showSecrets: Record<string, boolean>;
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 (
<div className={configPanelContentStyles}>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Client ID" icon={<Key className="h-4 w-4" />}>
<Input
value={config.client_id}
onChange={(e) => onUpdate({ oauth_config: { ...config, client_id: e.target.value } })}
placeholder="Enter client ID"
/>
</Field>
<SecretInput
label="Client Secret"
value={config.client_secret}
onChange={(value) => onUpdate({ oauth_config: { ...config, client_secret: value } })}
placeholder="Enter client secret"
showSecret={showSecrets.client_secret ?? false}
onToggleSecret={() => toggleSecret('client_secret')}
icon={<Lock className="h-4 w-4" />}
/>
</div>
<Field label="Redirect URI" icon={<Globe className="h-4 w-4" />}>
<Input
value={config.redirect_uri}
onChange={(e) => onUpdate({ oauth_config: { ...config, redirect_uri: e.target.value } })}
placeholder="https://your-app.com/auth/callback"
/>
<p className="text-xs text-muted-foreground">
Configure this URL in your OAuth provider's settings
</p>
</Field>
<div className="space-y-2">
<Label className="text-sm">Scopes</Label>
<Input
value={config.scopes.join(', ')}
onChange={(e) =>
onUpdate({
oauth_config: {
...config,
scopes: e.target.value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
},
})
}
placeholder="openid, email, profile"
/>
<p className="text-xs text-muted-foreground">Comma-separated list of OAuth scopes</p>
</div>
</div>
);
}

View File

@@ -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<Integration>) => void;
showSecrets: Record<string, boolean>;
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 (
<div className={configPanelContentStyles}>
<div className="flex items-center gap-2">
<Badge variant="secondary">OAuth 2.0</Badge>
<span className="text-xs text-muted-foreground">Requires OAuth authentication</span>
</div>
<div className="flex items-center justify-between rounded-md border border-border bg-background/50 p-3">
<div className="space-y-1">
<Label className="text-sm">Use custom OAuth credentials</Label>
<p className="text-xs text-muted-foreground">
{overrideEnabled
? 'Using custom credentials for this workspace'
: 'Using server-provided credentials'}
</p>
{!canOverride ? (
<p className="text-xs text-muted-foreground">Admin access required</p>
) : null}
</div>
<Switch
checked={overrideEnabled}
onCheckedChange={(value) => onUpdate({ oauth_override_enabled: value })}
disabled={!canOverride}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Client ID" icon={<Key className="h-4 w-4" />}>
<Input
value={oauthConfig.client_id}
onChange={(e) =>
onUpdate({
oauth_config: { ...oauthConfig, client_id: e.target.value },
})
}
placeholder="Enter client ID"
disabled={oauthFieldsDisabled}
/>
</Field>
<SecretInput
label="Client Secret"
value={oauthConfig.client_secret}
onChange={(value) => 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={<Lock className="h-4 w-4" />}
disabled={oauthFieldsDisabled}
/>
</div>
<Field label="Redirect URI" icon={<Globe className="h-4 w-4" />}>
<Input
value={oauthConfig.redirect_uri}
onChange={(e) =>
onUpdate({
oauth_config: { ...oauthConfig, redirect_uri: e.target.value },
})
}
placeholder="https://your-app.com/calendar/callback"
disabled={oauthFieldsDisabled}
/>
</Field>
<Separator />
<div className="space-y-2">
<Label className="text-sm">Sync Interval (minutes)</Label>
<Select
value={String(calConfig.sync_interval_minutes || 15)}
onValueChange={(value) =>
onUpdate({
calendar_config: { ...calConfig, sync_interval_minutes: parseInt(value, 10) },
})
}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5 minutes</SelectItem>
<SelectItem value="15">15 minutes</SelectItem>
<SelectItem value="30">30 minutes</SelectItem>
<SelectItem value="60">1 hour</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">Calendar IDs (optional)</Label>
<Input
value={calConfig.calendar_ids?.join(', ') || ''}
onChange={(e) =>
onUpdate({
calendar_config: {
...calConfig,
calendar_ids: e.target.value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
},
})
}
placeholder="primary, work@example.com"
/>
<p className="text-xs text-muted-foreground">
Leave empty to sync all calendars, or specify calendar IDs
</p>
</div>
<div className="space-y-2">
<Label className="text-sm">Webhook URL (optional)</Label>
<Input
value={calConfig.webhook_url || ''}
onChange={(e) =>
onUpdate({
calendar_config: { ...calConfig, webhook_url: e.target.value },
})
}
placeholder="https://your-app.com/webhooks/calendar"
/>
<p className="text-xs text-muted-foreground">Receive real-time calendar updates</p>
</div>
</div>
);
}

View File

@@ -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<Integration>) => void;
onTest?: () => void;
isTesting: boolean;
showSecrets: Record<string, boolean>;
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 (
<div className={configPanelContentStyles}>
<div className="space-y-2">
<Label className="text-sm">Provider Type</Label>
<Select
value={config.provider_type}
onValueChange={(value: 'smtp' | 'api') =>
onUpdate({
email_config: { ...config, provider_type: value },
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="api">API (SendGrid, Resend, etc.)</SelectItem>
<SelectItem value="smtp">SMTP Server</SelectItem>
</SelectContent>
</Select>
</div>
{config.provider_type === 'api' ? (
<SecretInput
label="API Key"
value={config.api_key || ''}
onChange={(value) => onUpdate({ email_config: { ...config, api_key: value } })}
placeholder="Enter your API key"
showSecret={showSecrets.email_api_key ?? false}
onToggleSecret={() => toggleSecret('email_api_key')}
icon={<Key className="h-4 w-4" />}
/>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="SMTP Host" icon={<Server className="h-4 w-4" />}>
<Input
value={config.smtp_host || ''}
onChange={(e) =>
onUpdate({
email_config: { ...config, smtp_host: e.target.value },
})
}
placeholder="smtp.example.com"
/>
</Field>
<div className="space-y-2">
<Label className="text-sm">Port</Label>
<Input
type="number"
value={config.smtp_port || IntegrationDefaults.SMTP_PORT}
onChange={(e) =>
onUpdate({
email_config: {
...config,
smtp_port: parseInt(e.target.value, 10) || IntegrationDefaults.SMTP_PORT,
},
})
}
placeholder={String(IntegrationDefaults.SMTP_PORT)}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-sm">Username</Label>
<Input
value={config.smtp_username || ''}
onChange={(e) =>
onUpdate({
email_config: { ...config, smtp_username: e.target.value },
})
}
placeholder="username@example.com"
/>
</div>
<SecretInput
label="Password"
value={config.smtp_password || ''}
onChange={(value) => onUpdate({ email_config: { ...config, smtp_password: value } })}
placeholder="SMTP password"
showSecret={showSecrets.smtp_password ?? false}
onToggleSecret={() => toggleSecret('smtp_password')}
icon={<Lock className="h-4 w-4" />}
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={config.smtp_secure ?? true}
onCheckedChange={(checked) =>
onUpdate({
email_config: { ...config, smtp_secure: checked },
})
}
/>
<Label className="text-sm">Use TLS/SSL</Label>
</div>
</>
)}
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<Field label="From Email" icon={<Mail className="h-4 w-4" />}>
<Input
type="email"
value={config.from_email || ''}
onChange={(e) =>
onUpdate({
email_config: { ...config, from_email: e.target.value },
})
}
placeholder="noreply@example.com"
/>
</Field>
<div className="space-y-2">
<Label className="text-sm">From Name</Label>
<Input
value={config.from_name || ''}
onChange={(e) =>
onUpdate({
email_config: { ...config, from_name: e.target.value },
})
}
placeholder="NoteFlow"
/>
</div>
</div>
<TestButton onTest={onTest} isTesting={isTesting} label="Send Test Email" Icon={Mail} />
</div>
);
}

View File

@@ -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<Integration>) => void;
onTest?: () => void;
isTesting?: boolean;
}
export function IntegrationConfigPanel({
integration,
onUpdate,
onTest,
isTesting = false,
}: IntegrationConfigPanelProps) {
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const toggleSecret = (key: string) => setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
// OAuth/SSO Configuration
if (integration.type === 'auth') {
return (
<AuthConfig
integration={integration}
onUpdate={onUpdate}
showSecrets={showSecrets}
toggleSecret={toggleSecret}
/>
);
}
// Email Configuration
if (integration.type === 'email') {
return (
<EmailConfig
integration={integration}
onUpdate={onUpdate}
onTest={onTest}
isTesting={isTesting}
showSecrets={showSecrets}
toggleSecret={toggleSecret}
/>
);
}
// Calendar Configuration
if (integration.type === 'calendar') {
return (
<CalendarConfig
integration={integration}
onUpdate={onUpdate}
showSecrets={showSecrets}
toggleSecret={toggleSecret}
/>
);
}
// PKM Configuration
if (integration.type === 'pkm') {
return (
<PKMConfig
integration={integration}
onUpdate={onUpdate}
showSecrets={showSecrets}
toggleSecret={toggleSecret}
/>
);
}
// Custom/Webhook Configuration
if (integration.type === 'custom') {
return (
<WebhookConfig
integration={integration}
onUpdate={onUpdate}
onTest={onTest}
isTesting={isTesting}
showSecrets={showSecrets}
toggleSecret={toggleSecret}
/>
);
}
// OIDC Provider Configuration
if (integration.type === 'oidc') {
return (
<OIDCConfig
integration={integration}
onUpdate={onUpdate}
onTest={onTest}
isTesting={isTesting}
showSecrets={showSecrets}
toggleSecret={toggleSecret}
/>
);
}
return (
<div className="py-4 text-sm text-muted-foreground">
No configuration options available for this integration.
</div>
);
}

View File

@@ -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<Integration>) => void;
onTest?: () => void;
isTesting: boolean;
showSecrets: Record<string, boolean>;
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 (
<div className="space-y-4 pt-2">
<div className="flex items-center gap-2">
<Badge variant="secondary">OIDC</Badge>
<span className="text-xs text-muted-foreground">OpenID Connect Provider</span>
</div>
<div className="space-y-2">
<Label className="text-sm">Provider Preset</Label>
<Select
value={config.preset}
onValueChange={(value) =>
onUpdate({
oidc_config: { ...config, preset: value as typeof config.preset },
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="authentik">Authentik</SelectItem>
<SelectItem value="authelia">Authelia</SelectItem>
<SelectItem value="keycloak">Keycloak</SelectItem>
<SelectItem value="auth0">Auth0</SelectItem>
<SelectItem value="okta">Okta</SelectItem>
<SelectItem value="azure_ad">Azure AD / Entra ID</SelectItem>
<SelectItem value="custom">Custom OIDC</SelectItem>
</SelectContent>
</Select>
</div>
<Field label="Issuer URL" icon={<Globe className="h-4 w-4" />}>
<Input
value={config.issuer_url}
onChange={(e) => onUpdate({ oidc_config: { ...config, issuer_url: e.target.value } })}
placeholder="https://auth.example.com"
/>
<p className="text-xs text-muted-foreground">
Base URL for OIDC discovery (/.well-known/openid-configuration)
</p>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Client ID" icon={<Key className="h-4 w-4" />}>
<Input
value={config.client_id}
onChange={(e) => onUpdate({ oidc_config: { ...config, client_id: e.target.value } })}
placeholder="noteflow-client"
/>
</Field>
<SecretInput
label="Client Secret"
value={config.client_secret || ''}
onChange={(value) => onUpdate({ oidc_config: { ...config, client_secret: value } })}
placeholder="Enter client secret"
showSecret={showSecrets.oidc_client_secret ?? false}
onToggleSecret={() => toggleSecret('oidc_client_secret')}
icon={<Lock className="h-4 w-4" />}
/>
</div>
<div className="space-y-2">
<Label className="text-sm">Scopes</Label>
<Input
value={config.scopes.join(', ')}
onChange={(e) =>
onUpdate({
oidc_config: {
...config,
scopes: e.target.value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
},
})
}
placeholder="openid, profile, email, groups"
/>
<p className="text-xs text-muted-foreground">Comma-separated list of OAuth scopes</p>
</div>
<div className="flex items-center gap-2">
<Switch
checked={config.require_email_verified}
onCheckedChange={(checked) =>
onUpdate({
oidc_config: { ...config, require_email_verified: checked },
})
}
/>
<Label className="text-sm">Require verified email</Label>
</div>
{config.discovery && (
<div className="rounded-md bg-muted/50 p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground">Discovery Endpoints</p>
<div className="text-xs space-y-1">
<p>
<span className="text-muted-foreground">Authorization:</span>{' '}
{config.discovery.authorization_endpoint}
</p>
<p>
<span className="text-muted-foreground">Token:</span>{' '}
{config.discovery.token_endpoint}
</p>
{config.discovery.userinfo_endpoint && (
<p>
<span className="text-muted-foreground">UserInfo:</span>{' '}
{config.discovery.userinfo_endpoint}
</p>
)}
</div>
{config.discovery_refreshed_at && (
<p className="text-xs text-muted-foreground">
Last refreshed: {formatTimestamp(config.discovery_refreshed_at)}
</p>
)}
</div>
)}
<TestButton onTest={onTest} isTesting={isTesting} label="Test OIDC Connection" />
</div>
);
}

View File

@@ -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<Integration>) => void;
showSecrets: Record<string, boolean>;
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 (
<div className="space-y-4 pt-2">
{isNotion && (
<>
<SecretInput
label="Integration Token"
value={config.api_key || ''}
onChange={(value) => onUpdate({ pkm_config: { ...config, api_key: value } })}
placeholder="secret_xxxxxxxxxxxxxxxx"
showSecret={showSecrets.notion_token ?? false}
onToggleSecret={() => toggleSecret('notion_token')}
icon={<Key className="h-4 w-4" />}
/>
<p className="text-xs text-muted-foreground">
Create an integration at{' '}
<a
href="https://www.notion.so/my-integrations"
target="_blank"
rel={EXTERNAL_LINK_REL}
className="text-primary hover:underline"
>
notion.so/my-integrations
</a>
</p>
<Field label="Database ID" icon={<Database className="h-4 w-4" />}>
<Input
value={config.database_id || ''}
onChange={(e) =>
onUpdate({
pkm_config: { ...config, database_id: e.target.value },
})
}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
/>
<p className="text-xs text-muted-foreground">The ID from your Notion database URL</p>
</Field>
</>
)}
{isObsidian && (
<Field label="Vault Path" icon={<FolderOpen className="h-4 w-4" />}>
<div className="flex gap-2">
<Input
value={config.vault_path || ''}
onChange={(e) =>
onUpdate({
pkm_config: { ...config, vault_path: e.target.value },
})
}
placeholder="/path/to/obsidian/vault"
className="flex-1"
/>
<Button variant="outline" size="icon">
<FolderOpen className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">Path to your Obsidian vault folder</p>
</Field>
)}
{!isNotion && !isObsidian && (
<>
<SecretInput
label="API Key"
value={config.api_key || ''}
onChange={(value) => onUpdate({ pkm_config: { ...config, api_key: value } })}
placeholder="Enter API key"
showSecret={showSecrets.pkm_api_key ?? false}
onToggleSecret={() => toggleSecret('pkm_api_key')}
icon={<Key className="h-4 w-4" />}
/>
<div className="space-y-2">
<Label className="text-sm">Workspace ID</Label>
<Input
value={config.workspace_id || ''}
onChange={(e) =>
onUpdate({
pkm_config: { ...config, workspace_id: e.target.value },
})
}
placeholder="Enter workspace ID"
/>
</div>
</>
)}
<div className="flex items-center gap-2">
<Switch
checked={config.sync_enabled ?? false}
onCheckedChange={(checked) =>
onUpdate({
pkm_config: { ...config, sync_enabled: checked },
})
}
/>
<Label className="text-sm">Enable auto-sync</Label>
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-2">
<Label className={labelStyles.withIcon}>
{icon}
{label}
</Label>
{children}
</div>
);
}
/**
* 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 (
<Field label={label} icon={icon}>
<div className="relative">
<Input
type={showSecret ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="pr-10"
disabled={disabled}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={onToggleSecret}
disabled={disabled}
>
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</Field>
);
}
/**
* 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 (
<Button variant="outline" onClick={onTest} disabled={isTesting}>
{isTesting ? (
<Loader2 className={`${iconWithMargin.md} animate-spin`} />
) : (
<Icon className={iconWithMargin.md} />
)}
{label}
</Button>
);
}

View File

@@ -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<Integration>) => void;
onTest?: () => void;
isTesting: boolean;
showSecrets: Record<string, boolean>;
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 (
<div className="space-y-4 pt-2">
<Field label="Webhook URL" icon={<Globe className="h-4 w-4" />}>
<Input
value={config.url}
onChange={(e) =>
onUpdate({
webhook_config: { ...config, url: e.target.value },
})
}
placeholder="https://api.example.com/webhook"
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-sm">HTTP Method</Label>
<Select
value={config.method}
onValueChange={(value: 'GET' | 'POST' | 'PUT') =>
onUpdate({
webhook_config: { ...config, method: value },
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">Authentication</Label>
<Select
value={config.auth_type || 'none'}
onValueChange={(value: 'none' | 'bearer' | 'basic' | 'api_key') =>
onUpdate({
webhook_config: { ...config, auth_type: value },
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="bearer">Bearer Token</SelectItem>
<SelectItem value="basic">Basic Auth</SelectItem>
<SelectItem value="api_key">API Key Header</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{config.auth_type && config.auth_type !== 'none' && (
<SecretInput
label={
config.auth_type === 'bearer'
? 'Bearer Token'
: config.auth_type === 'basic'
? 'Credentials (user:pass)'
: 'API Key'
}
value={config.auth_value || ''}
onChange={(value) => 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={<Key className="h-4 w-4" />}
/>
)}
<TestButton onTest={onTest} isTesting={isTesting} label="Test Webhook" />
</div>
);
}

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -0,0 +1,9 @@
import type { ComponentPropsWithoutRef } from 'react';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
type MediumLabelProps = ComponentPropsWithoutRef<typeof Label>;
export function MediumLabel({ className, ...props }: MediumLabelProps) {
return <Label className={cn('text-sm font-medium', className)} {...props} />;
}

View File

@@ -0,0 +1,3 @@
// System components barrel export
export { TauriEventListener } from './tauri-event-listener';
export { SecureStorageRecoveryDialog } from './secure-storage-recovery-dialog';

View File

@@ -1,6 +1,6 @@
// Secure storage recovery dialog - shown when encrypted credentials cannot be decrypted
import { ConfirmationDialog } from '@/components/confirmation-dialog';
import { ConfirmationDialog } from '@/components/common';
export interface SecureStorageRecoveryDialogProps {
open: boolean;

View File

@@ -1,7 +1,7 @@
import { render } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { ApiModeIndicator } from '@/components/api-mode-indicator';
import { ApiModeIndicator } from '@/components/features/connectivity';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ScrollBar } from '@/components/ui/scroll-area';

View File

@@ -1,3 +1,4 @@
export * from './use-animated-words';
export * from './use-mobile';
export * from './use-panel-preferences';
export * from './use-recording-panels';

View File

@@ -0,0 +1,105 @@
// Hook for tracking which words have been animated in streaming transcription
// Ensures only new words animate while existing words remain static
import { useEffect, useRef, useMemo } from 'react';
export interface UseAnimatedWordsOptions {
/** Stagger delay between words in milliseconds. Default: 80 */
staggerDelay?: number;
/** Unique ID for animation tracking. Reset when this changes. */
blockId: string;
}
export interface AnimatedWordState {
/** The word text */
word: string;
/** Index of the word in the text */
index: number;
/** Whether this word should animate (true for new words) */
shouldAnimate: boolean;
/** Animation delay in milliseconds */
delay: number;
}
/**
* Hook for tracking animated words in streaming transcription.
*
* Ensures incremental animation behavior:
* - New words: animate with staggered delay
* - Previously seen words: display without animation
* - Reset tracking when blockId changes
*
* @param text - The text to split into animated words
* @param options - Animation options including stagger delay and block ID
* @returns Array of word states with animation information
*/
export function useAnimatedWords(
text: string,
options: UseAnimatedWordsOptions
): AnimatedWordState[] {
const { staggerDelay = 80, blockId } = options;
// Track which word indices have been seen (animated)
const seenIndicesRef = useRef<Set<number>>(new Set());
const previousBlockIdRef = useRef<string>(blockId);
// Reset tracking when blockId changes
useEffect(() => {
if (previousBlockIdRef.current !== blockId) {
seenIndicesRef.current = new Set();
previousBlockIdRef.current = blockId;
}
}, [blockId]);
// Split text into words and compute animation states
const wordStates = useMemo(() => {
if (!text) {
return [];
}
// Split on whitespace while preserving structure
const words = text.split(/(\s+)/);
return words
.map((word, index) => {
// Skip empty strings and pure whitespace
if (!word || /^\s+$/.test(word)) {
return null;
}
const shouldAnimate = !seenIndicesRef.current.has(index);
// Calculate delay only for words that should animate
// Use the count of words before this one that also need animation
const animatingWordsBefore = words
.slice(0, index)
.filter((w, i) => w && !/^\s+$/.test(w) && !seenIndicesRef.current.has(i)).length;
const delay = shouldAnimate ? animatingWordsBefore * staggerDelay : 0;
return {
word,
index,
shouldAnimate,
delay,
};
})
.filter((state): state is AnimatedWordState => state !== null);
}, [text, staggerDelay]);
// Mark current words as seen after render
useEffect(() => {
if (!text) {
return;
}
const words = text.split(/(\s+)/);
words.forEach((word, index) => {
if (word && !/^\s+$/.test(word)) {
seenIndicesRef.current.add(index);
}
});
}, [text]);
return wordStates;
}

View File

@@ -324,6 +324,51 @@
box-shadow: 0 0 0 8px hsl(142 71% 45% / 0);
}
}
/* Word materialization animation for streaming transcription */
@keyframes reveal-shimmer-heavy {
0% {
opacity: 0;
transform: translateY(12px);
filter: brightness(0.2) blur(12px);
}
50% {
opacity: 1;
filter: brightness(2) blur(2px);
}
100% {
opacity: 1;
transform: translateY(0);
filter: brightness(1) blur(0px);
}
}
.animate-reveal-chunk {
animation: reveal-shimmer-heavy 1.1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
will-change: transform, opacity, filter;
}
.animate-reveal-complete {
opacity: 1;
transform: translateY(0);
filter: none;
}
/* Entity highlight fade-in for two-phase animation */
@keyframes entity-highlight-reveal {
from {
background-color: transparent;
border-color: transparent;
}
to {
background-color: hsl(var(--primary) / 0.15);
border-color: hsl(var(--primary) / 0.4);
}
}
.animate-entity-highlight {
animation: entity-highlight-reveal 0.3s ease-out forwards;
}
}
/* Page layout system - reusable across pages */

View File

@@ -3,7 +3,7 @@
*/
import type { LogEntry as ApiLogEntry } from '@/api/types';
import type { LogEntryData } from '@/components/analytics/log-entry';
import type { LogEntryData } from '@/components/features/analytics/log-entry';
/**
* Convert API log entry to component log entry data.

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import type { LogEntryData } from '@/components/analytics/log-entry';
import type { LogEntryData } from '@/components/features/analytics/log-entry';
import {
getKnownEventDomains,
isErrorGroup,

View File

@@ -7,7 +7,7 @@
* - "Recording completed (5m 23s)"
*/
import type { LogEntryData } from '@/components/analytics/log-entry';
import type { LogEntryData } from '@/components/features/analytics/log-entry';
/** Summary of a log group with contextual information */
export interface GroupSummary {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import type { LogEntryData } from '@/components/analytics/log-entry';
import type { LogEntryData } from '@/components/features/analytics/log-entry';
import { formatGap, getGroupGap, groupLogs, type LogGroup } from './groups';
function createLog(overrides: Partial<LogEntryData> = {}): LogEntryData {

View File

@@ -5,7 +5,7 @@
* and navigation in the timeline view.
*/
import type { LogEntryData } from '@/components/analytics/log-entry';
import type { LogEntryData } from '@/components/features/analytics/log-entry';
import { MS_PER_MINUTE } from '@/lib/utils/time';
import { summarizeLogGroup, type GroupSummary } from './group-summarizer';

View File

@@ -37,11 +37,11 @@ import {
SPEAKER_COLOR_CLASSES,
speakerLabel,
wordCountTickLabel,
} from '@/components/analytics/analytics-utils';
import { LogsTab } from '@/components/analytics/logs-tab';
import { PerformanceTab } from '@/components/analytics/performance-tab';
import { SpeechAnalysisTab } from '@/components/analytics/speech-analysis-tab';
import { StatsCard } from '@/components/stats-card';
} from '@/components/features/analytics/analytics-utils';
import { LogsTab } from '@/components/features/analytics/logs-tab';
import { PerformanceTab } from '@/components/features/analytics/performance-tab';
import { SpeechAnalysisTab } from '@/components/features/analytics/speech-analysis-tab';
import { StatsCard } from '@/components/common';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';

View File

@@ -4,9 +4,9 @@ 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/empty-state';
import { MeetingCard } from '@/components/meeting-card';
import { PriorityBadge } from '@/components/priority-badge';
import { EmptyState } 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';

View File

@@ -6,14 +6,14 @@ import { useParams } from 'react-router-dom';
import { getAPI } from '@/api/interface';
import type { MeetingState } from '@/api/types';
import type { ProjectScope } from '@/api/types/requests';
import { EmptyState } from '@/components/empty-state';
import { MeetingCard } from '@/components/meeting-card';
import { ProjectScopeFilter } from '@/components/projects/ProjectScopeFilter';
import { EmptyState } from '@/components/common';
import { MeetingCard } from '@/components/features/meetings';
import { ProjectScopeFilter } from '@/components/features/projects/ProjectScopeFilter';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SearchIcon } from '@/components/ui/search-icon';
import { SkeletonMeetingCard } from '@/components/ui/skeleton';
import { UpcomingMeetings } from '@/components/upcoming-meetings';
import { UpcomingMeetings } from '@/components/features/calendar';
import { useProjects } from '@/contexts/project-state';
import { useAsyncData } from '@/hooks';
import { useGuardedMutation } from '@/hooks';

View File

@@ -5,8 +5,8 @@ import { Link, useParams } from 'react-router-dom';
import { useProjects } from '@/contexts/project-state';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ProjectMembersPanel } from '@/components/projects/ProjectMembersPanel';
import { ProjectSettingsPanel } from '@/components/projects/ProjectSettingsPanel';
import { ProjectMembersPanel } from '@/components/features/projects/ProjectMembersPanel';
import { ProjectSettingsPanel } from '@/components/features/projects/ProjectSettingsPanel';
export default function ProjectSettingsPage() {
const { projectId } = useParams();

View File

@@ -1,6 +1,6 @@
// Projects page
import { ProjectList } from '@/components/projects/ProjectList';
import { ProjectList } from '@/components/features/projects/ProjectList';
export default function ProjectsPage() {
return (

View File

@@ -180,7 +180,7 @@ vi.mock('framer-motion', () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/components/recording', () => ({
vi.mock('@/components/features/recording', () => ({
RecordingHeader: ({
recordingState,
meetingTitle,
@@ -273,7 +273,7 @@ vi.mock('@/components/recording', () => ({
),
}));
vi.mock('@/components/timestamped-notes-editor', () => ({
vi.mock('@/components/features/notes/timestamped-notes-editor', () => ({
TimestampedNotesEditor: () => <div data-testid="notes-editor" />,
}));

View File

@@ -13,8 +13,8 @@ import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
import { isTauriEnvironment } from '@/api';
import type { AppOutletContext } from '@/components/app-layout';
import type { NoteEdit } from '@/components/timestamped-notes-editor';
import type { AppOutletContext } from '@/components/layout';
import type { NoteEdit } from '@/components/features/notes';
import {
IdleState,
ListeningState,
@@ -24,7 +24,7 @@ import {
StatsPanel,
TranscriptSegmentCard,
VADIndicator,
} from '@/components/recording';
} from '@/components/features/recording';
import { Card, CardContent } from '@/components/ui/card';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
import { useConnectionState } from '@/contexts/connection-state';
@@ -42,8 +42,8 @@ import { useRecordingSession } from '@/hooks';
import { preferences } from '@/lib/preferences';
import { buildSpeakerNameMap } from '@/lib/audio/speaker';
import { JumpToLiveIndicator } from '@/components/recording/jump-to-live-indicator';
import { InTranscriptSearch } from '@/components/recording/in-transcript-search';
import { JumpToLiveIndicator } from '@/components/features/recording/jump-to-live-indicator';
import { InTranscriptSearch } from '@/components/features/recording/in-transcript-search';
const TRANSCRIPT_VIRTUALIZE_THRESHOLD = 100;

View File

@@ -7,10 +7,10 @@ import { Link } from 'react-router-dom';
import { getAPI } from '@/api/interface';
import type { Priority } from '@/api/types';
import type { ProjectScope } from '@/api/types/requests';
import { EmptyState } from '@/components/empty-state';
import { PriorityBadge } from '@/components/priority-badge';
import { ProjectScopeFilter } from '@/components/projects/ProjectScopeFilter';
import { CenteredStatsCard } from '@/components/stats-card';
import { EmptyState } from '@/components/common';
import { PriorityBadge } from '@/components/common';
import { ProjectScopeFilter } from '@/components/features/projects/ProjectScopeFilter';
import { CenteredStatsCard } from '@/components/common';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';

View File

@@ -18,7 +18,7 @@ import {
import { isTauriEnvironment } from '@/api';
import type { ExportFormat, Meeting, PlaybackInfo } from '@/api/types';
import { MeetingStateBadge } from '@/components/meeting-state-badge';
import { MeetingStateBadge } from '@/components/features/meetings';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,

View File

@@ -11,7 +11,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { getAPI } from '@/api/interface';
import { isTauriEnvironment } from '@/api';
import type { ExportFormat } from '@/api/types';
import { ProcessingStatus } from '@/components/processing-status';
import { ProcessingStatus } from '@/components/features/meetings';
import { SkeletonTranscript } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useGuardedMutation } from '@/hooks';

View File

@@ -5,7 +5,7 @@
import { Sparkles } from 'lucide-react';
import type { Summary } from '@/api/types';
import { PriorityBadge } from '@/components/priority-badge';
import { PriorityBadge } from '@/components/common';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';

View File

@@ -5,7 +5,7 @@
import { motion } from 'framer-motion';
import { memo } from 'react';
import { SpeakerBadge } from '@/components/speaker-badge';
import { SpeakerBadge } from '@/components/common/badges/speaker-badge';
import { formatTime } from '@/lib/utils/format';
import type { MeetingTranscriptRowProps } from './types';

View File

@@ -5,7 +5,7 @@ import {
CloudAIToggle,
OllamaStatusCard,
SummarizationSettingsPanel,
} from '@/components/settings';
} from '@/components/features/settings';
import { PROVIDER_ENDPOINTS } from '@/lib/config/provider-endpoints';
import { preferences } from '@/lib/preferences';

View File

@@ -1,4 +1,4 @@
import { AudioDevicesSection, RecordingAppPolicySection } from '@/components/settings';
import { AudioDevicesSection, RecordingAppPolicySection } from '@/components/features/settings';
import type { UseAudioDevicesReturn } from '@/hooks';
interface AudioTabProps {

View File

@@ -6,7 +6,7 @@ import {
ConnectionDiagnosticsPanel,
DeveloperOptionsSection,
QuickActionsSection,
} from '@/components/settings';
} from '@/components/features/settings';
import { toast } from '@/hooks';
import { toastError } from '@/lib/observability/errors';
import { meetingCache } from '@/lib/cache/meeting-cache';

View File

@@ -1,9 +1,9 @@
import { motion } from 'framer-motion';
import type { Integration } from '@/api/types';
import { IntegrationsSection } from '@/components/settings';
import { SyncControlPanel } from '@/components/sync-control-panel';
import { SyncHistoryLog } from '@/components/sync-history-log';
import { WebhookSettingsPanel } from '@/components/webhook-settings-panel';
import { IntegrationsSection } from '@/components/features/settings';
import { SyncControlPanel } from '@/components/features/sync';
import { SyncHistoryLog } from '@/components/features/sync';
import { WebhookSettingsPanel } from '@/components/features/integrations';
import type { IntegrationSyncState } from '@/hooks';
interface IntegrationsTabProps {

View File

@@ -1,7 +1,7 @@
import type { EffectiveServerUrl } from '@/api/types';
import { SecureStorageRecoveryDialog } from '@/components/secure-storage-recovery-dialog';
import { ServerSwitchConfirmationDialog } from '@/components/server-switch-confirmation-dialog';
import { SimulationConfirmationDialog } from '@/components/simulation-confirmation-dialog';
import { SecureStorageRecoveryDialog } from '@/components/system';
import { ServerSwitchConfirmationDialog } from '@/components/features/connectivity';
import { SimulationConfirmationDialog } from '@/components/dev';
import { buildServerUrl } from '@/lib/config/server';
interface PendingServerChange {

View File

@@ -1,7 +1,7 @@
import { motion } from 'framer-motion';
import type { EffectiveServerUrl, ServerInfo } from '@/api/types';
import { PreferencesSyncStatus } from '@/components/preferences-sync-status';
import { ServerConnectionSection } from '@/components/settings';
import { PreferencesSyncStatus } from '@/components/features/sync';
import { ServerConnectionSection } from '@/components/features/settings';
interface StatusTabProps {
serverHost: string;