diff --git a/client/src-tauri/src/commands/connection.rs b/client/src-tauri/src/commands/connection.rs index 9625897..9898e76 100644 --- a/client/src-tauri/src/commands/connection.rs +++ b/client/src-tauri/src/commands/connection.rs @@ -121,16 +121,43 @@ pub fn get_effective_server_url(state: State<'_, Arc>) -> EffectiveSer // Otherwise, use config (which tracks env vs default) let default_url = cfg.server.default_address.clone(); - let (h, p) = if let Some(pos) = default_url.find(':') { - (&default_url[..pos], &default_url[pos + 1..]) - } else { - (&default_url[..], "") - }; + let (host, port) = parse_host_port(&default_url); EffectiveServerUrl { url: default_url.clone(), - host: h.to_string(), - port: p.to_string(), + host, + port, source: cfg.server.address_source, } } + +fn parse_host_port(address: &str) -> (String, String) { + let trimmed = address.trim(); + if trimmed.is_empty() { + return (String::new(), String::new()); + } + + if let Some(rest) = trimmed.strip_prefix('[') { + if let Some(end) = rest.find(']') { + let host = &rest[..end]; + let port = rest[end + 1..].strip_prefix(':').unwrap_or(""); + return (host.to_string(), sanitize_port(port)); + } + } + + if let Some((host, port)) = trimmed.rsplit_once(':') { + if !host.is_empty() && !port.is_empty() && !host.contains(':') { + return (host.to_string(), sanitize_port(port)); + } + } + + (trimmed.to_string(), String::new()) +} + +fn sanitize_port(port: &str) -> String { + if port.parse::().is_ok() { + port.to_string() + } else { + String::new() + } +} diff --git a/client/src/components/recording/transcript-segment-actions.test.tsx b/client/src/components/recording/transcript-segment-actions.test.tsx index 75b413f..0b2da9f 100644 --- a/client/src/components/recording/transcript-segment-actions.test.tsx +++ b/client/src/components/recording/transcript-segment-actions.test.tsx @@ -14,23 +14,23 @@ vi.mock('@/hooks/use-toast', () => ({ })); describe('TranscriptSegmentActions', () => { - it('renders copy button and handles click', () => { + it('renders copy button and handles click', async () => { render(); - + const copyButton = screen.getByTitle('Copy text'); expect(copyButton).toBeDefined(); - - fireEvent.click(copyButton); + + await fireEvent.click(copyButton); expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Hello world'); }); it('renders play button when onPlay is provided', () => { const onPlay = vi.fn(); render(); - + const playButton = screen.getByTitle('Play audio'); expect(playButton).toBeDefined(); - + fireEvent.click(playButton); expect(onPlay).toHaveBeenCalled(); }); diff --git a/client/src/components/recording/transcript-segment-actions.tsx b/client/src/components/recording/transcript-segment-actions.tsx index 6041371..b9f2fc0 100644 --- a/client/src/components/recording/transcript-segment-actions.tsx +++ b/client/src/components/recording/transcript-segment-actions.tsx @@ -9,6 +9,10 @@ export interface TranscriptSegmentActionsProps { export function TranscriptSegmentActions({ segmentText, onPlay }: TranscriptSegmentActionsProps) { const handleCopy = async () => { + if (!navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') { + toast({ description: 'Clipboard not supported', variant: 'destructive' }); + return; + } try { await navigator.clipboard.writeText(segmentText); toast({ description: 'Text copied to clipboard' }); diff --git a/client/src/hooks/use-panel-preferences.ts b/client/src/hooks/use-panel-preferences.ts index 6db01ea..437b411 100644 --- a/client/src/hooks/use-panel-preferences.ts +++ b/client/src/hooks/use-panel-preferences.ts @@ -76,11 +76,22 @@ function normalizeSizes(prefs: PanelPreferences): PanelPreferences { const visibleSidePanels = (showNotesPanel ? 1 : 0) + (showStatsPanel ? 1 : 0); if (visibleSidePanels === SIDE_PANEL_COUNT) { - const sideSpace = (remaining - SIDE_PANEL_COUNT * COLLAPSED_SIZE_PERCENT) / SIDE_PANEL_COUNT; + const totalMinSide = MIN_NOTES_SIZE_PERCENT + MIN_STATS_SIZE_PERCENT; + let newNotesSize = MIN_NOTES_SIZE_PERCENT; + let newStatsSize = MIN_STATS_SIZE_PERCENT; + + if (remaining > totalMinSide) { + const excess = remaining - totalMinSide; + const totalPref = clampedNotesSize + clampedStatsSize; + const notesRatio = totalPref > 0 ? clampedNotesSize / totalPref : 0.5; + newNotesSize += excess * notesRatio; + newStatsSize += excess * (1 - notesRatio); + } + return { ...prefs, - notesPanelSize: Math.max(sideSpace, MIN_NOTES_SIZE_PERCENT), - statsPanelSize: Math.max(sideSpace, MIN_STATS_SIZE_PERCENT), + notesPanelSize: clamp(newNotesSize, MIN_NOTES_SIZE_PERCENT, MAX_NOTES_SIZE_PERCENT), + statsPanelSize: clamp(newStatsSize, MIN_STATS_SIZE_PERCENT, MAX_STATS_SIZE_PERCENT), transcriptPanelSize: transcriptSize, }; } diff --git a/client/src/pages/Recording.tsx b/client/src/pages/Recording.tsx index 13ee1d1..6d4c88b 100644 --- a/client/src/pages/Recording.tsx +++ b/client/src/pages/Recording.tsx @@ -158,7 +158,7 @@ export default function RecordingPage() { const handleJumpToLive = useCallback(() => { const scrollElement = transcriptScrollRef.current; if (scrollElement) { - scrollElement.scrollTo({ top: scrollElement.scrollHeight, behavior: 'smooth' }); + scrollElement.scrollTo({ top: scrollElement.scrollHeight, behavior: 'auto' }); } }, []); diff --git a/repomix.config.json b/repomix.config.json index 82eb6d3..0f831cb 100644 --- a/repomix.config.json +++ b/repomix.config.json @@ -29,7 +29,7 @@ "include": [ "client/src", "src/", - "clinet/src-tauri" + "client/src-tauri" ], "ignore": { @@ -110,4 +110,4 @@ "tokenCount": { "encoding": "o200k_base" } -} \ No newline at end of file +} diff --git a/scripts/dev_watch_server.py b/scripts/dev_watch_server.py index 00b45db..7e876e5 100644 --- a/scripts/dev_watch_server.py +++ b/scripts/dev_watch_server.py @@ -73,7 +73,11 @@ def run_server() -> None: nested subprocess spawning that causes zombie processes when watchfiles restarts on file changes. """ - os.execvp(sys.executable, [sys.executable, "-m", "noteflow.grpc.server"]) + try: + os.execvp(sys.executable, [sys.executable, "-m", "noteflow.grpc.server"]) + except OSError as exc: + print(f"Failed to exec gRPC server: {exc}", file=sys.stderr) + raise SystemExit(1) from exc def main() -> None: diff --git a/src/noteflow/grpc/mixins/diarization/_execution.py b/src/noteflow/grpc/mixins/diarization/_execution.py index 72cdc0b..974a9a3 100644 --- a/src/noteflow/grpc/mixins/diarization/_execution.py +++ b/src/noteflow/grpc/mixins/diarization/_execution.py @@ -30,6 +30,21 @@ def _set_diarization_span_attributes(span: Span, ctx: DiarizationJobContext) -> span.set_attribute("diarization.num_speakers", ctx.num_speakers) +async def _load_meeting_creator_id(ctx: DiarizationJobContext) -> str | None: + """Best-effort lookup of meeting creator for audit context.""" + from ..converters import parse_meeting_id_or_none + + meeting_id = parse_meeting_id_or_none(ctx.meeting_id) + if meeting_id is None: + return None + + async with ctx.host.create_repository_provider() as repo: + meeting = await repo.meetings.get(meeting_id) + if meeting is None or meeting.created_by_id is None: + return None + return str(meeting.created_by_id) + + async def _run_diarization_refinement( ctx: DiarizationJobContext, span: Span, @@ -79,19 +94,24 @@ async def _execute_diarization_with_span( span.set_attribute("diarization.cancelled", True) await ctx.host.handle_job_cancelled(ctx.job_id, ctx.job, ctx.meeting_id) raise # Re-raise to propagate cancellation - except NoDiarizationAudioError as exc: + except NoDiarizationAudioError: # Expected for simulated transcripts or meetings without audio capture. # Mark as completed with 0 updates rather than failed. + skip_reason = "no_audio" span.set_attribute("diarization.skipped", True) - span.set_attribute("diarization.skip_reason", str(exc)) + span.set_attribute("diarization.skip_reason", skip_reason) + created_by_id = await _load_meeting_creator_id(ctx) logger.info( - "Diarization skipped for meeting %s: %s", - ctx.meeting_id, - exc, + "diarization_skipped", + job_id=ctx.job_id, + meeting_id=ctx.meeting_id, + reason=skip_reason, + created_by_id=created_by_id, ) # Complete the job with 0 updates - this is a valid "nothing to do" state await ctx.host.update_job_completed(ctx.job_id, ctx.job, 0, []) span.add_event("job_completed_no_audio") + return # INTENTIONAL BROAD HANDLER: Job error boundary # - Diarization can fail in many ways (model errors, audio issues, etc.) # - Must capture any failure and update job status