diff --git a/client/src/components/api-mode-indicator.tsx b/client/src/components/api-mode-indicator.tsx index bd98021..3223dfd 100644 --- a/client/src/components/api-mode-indicator.tsx +++ b/client/src/components/api-mode-indicator.tsx @@ -116,6 +116,7 @@ export function ApiModeIndicator({ {!compact && config.label} diff --git a/client/src/components/app-layout.tsx b/client/src/components/app-layout.tsx index cedb153..83abdb2 100644 --- a/client/src/components/app-layout.tsx +++ b/client/src/components/app-layout.tsx @@ -6,14 +6,19 @@ import { AppSidebar } from '@/components/app-sidebar'; import { OfflineBanner } from '@/components/offline-banner'; import { TopBar } from '@/components/top-bar'; +export interface AppOutletContext { + activeMeetingId: string | null; + setActiveMeetingId: (id: string | null) => void; +} + export function AppLayout() { - const [isRecording, setIsRecording] = useState(false); + const [activeMeetingId, setActiveMeetingId] = useState(null); const navigate = useNavigate(); const handleStartRecording = () => { - if (isRecording) { + if (activeMeetingId) { // Navigate to current recording - navigate('/recording'); + navigate(`/recording/${activeMeetingId}`); } else { // Start new recording navigate('/recording/new'); @@ -22,12 +27,12 @@ export function AppLayout() { return (
- +
- +
diff --git a/client/src/components/app-sidebar.tsx b/client/src/components/app-sidebar.tsx index 28233a6..f6b925a 100644 --- a/client/src/components/app-sidebar.tsx +++ b/client/src/components/app-sidebar.tsx @@ -95,7 +95,7 @@ export function AppSidebar({ onStartRecording, isRecording }: AppSidebarProps) { className={cn('w-full', collapsed && 'px-0')} > - {!collapsed && (isRecording ? 'Recording...' : 'Start Recording')} + {!collapsed && (isRecording ? 'Go to Recording' : 'Start Recording')} diff --git a/client/src/components/recording/in-transcript-search.tsx b/client/src/components/recording/in-transcript-search.tsx new file mode 100644 index 0000000..15846e8 --- /dev/null +++ b/client/src/components/recording/in-transcript-search.tsx @@ -0,0 +1,33 @@ +import { Search, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +export interface InTranscriptSearchProps { + value: string; + onChange: (value: string) => void; +} + +export function InTranscriptSearch({ value, onChange }: InTranscriptSearchProps) { + return ( +
+ + onChange(e.target.value)} + placeholder="Search transcript..." + className="pl-8 pr-8 h-9 text-sm" + /> + {value && ( + + )} +
+ ); +} diff --git a/client/src/components/recording/jump-to-live-indicator.tsx b/client/src/components/recording/jump-to-live-indicator.tsx new file mode 100644 index 0000000..cffe9f9 --- /dev/null +++ b/client/src/components/recording/jump-to-live-indicator.tsx @@ -0,0 +1,20 @@ +import { ArrowDown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface JumpToLiveIndicatorProps { + onClick: () => void; +} + +export function JumpToLiveIndicator({ onClick }: JumpToLiveIndicatorProps) { + return ( + + ); +} diff --git a/client/src/components/recording/notes-quick-actions.test.tsx b/client/src/components/recording/notes-quick-actions.test.tsx new file mode 100644 index 0000000..4c70c31 --- /dev/null +++ b/client/src/components/recording/notes-quick-actions.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { NotesQuickActions } from './notes-quick-actions'; + +vi.mock('@/lib/format', () => ({ + formatElapsedTime: vi.fn(() => '00:42'), +})); + +describe('NotesQuickActions', () => { + it('renders buttons with formatted time', () => { + render( + + ); + + expect(screen.getByText('00:42')).toBeDefined(); + expect(screen.getByText('Action')).toBeDefined(); + expect(screen.getByText('Decision')).toBeDefined(); + }); + + it('calls handlers on click', () => { + const onInsertTimestamp = vi.fn(); + const onAddActionItem = vi.fn(); + const onAddDecision = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByText('00:42')); // Timestamp button + expect(onInsertTimestamp).toHaveBeenCalled(); + + fireEvent.click(screen.getByText('Action')); + expect(onAddActionItem).toHaveBeenCalled(); + + fireEvent.click(screen.getByText('Decision')); + expect(onAddDecision).toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/recording/notes-quick-actions.tsx b/client/src/components/recording/notes-quick-actions.tsx new file mode 100644 index 0000000..a8a3504 --- /dev/null +++ b/client/src/components/recording/notes-quick-actions.tsx @@ -0,0 +1,52 @@ +import { CheckSquare, Clock, Gavel } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { formatElapsedTime } from '@/lib/format'; + +export interface NotesQuickActionsProps { + onInsertTimestamp: () => void; + onAddActionItem: () => void; + onAddDecision: () => void; + elapsedTime: number; +} + +export function NotesQuickActions({ + onInsertTimestamp, + onAddActionItem, + onAddDecision, + elapsedTime, +}: NotesQuickActionsProps) { + return ( +
+ + + +
+ ); +} diff --git a/client/src/components/recording/recording-header.tsx b/client/src/components/recording/recording-header.tsx index 046927c..f10e906 100644 --- a/client/src/components/recording/recording-header.tsx +++ b/client/src/components/recording/recording-header.tsx @@ -5,12 +5,10 @@ import { Loader2, Mic, Square } from 'lucide-react'; import type { ConnectionMode } from '@/api/connection-state'; import type { Meeting } from '@/api/types'; import { EntityManagementPanel } from '@/components/entity-management-panel'; -import { ApiModeIndicator } from '@/components/api-mode-indicator'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { formatElapsedTime } from '@/lib/format'; import { ButtonVariant, iconWithMargin } from '@/lib/styles'; +import { UnifiedStatusRow } from './unified-status-row'; import { AudioDeviceSelector } from './audio-device-selector'; type RecordingState = 'idle' | 'starting' | 'recording' | 'paused' | 'stopping'; @@ -55,18 +53,12 @@ export function RecordingHeader({ {meeting?.title || meetingTitle || 'New Recording'}
- - - - - - Recording - - {/* Sprint GAP-007: Show API mode indicator */} - - - {formatElapsedTime(elapsedTime)} - +
)} diff --git a/client/src/components/recording/stats-content.tsx b/client/src/components/recording/stats-content.tsx index 0412873..3b8a4b2 100644 --- a/client/src/components/recording/stats-content.tsx +++ b/client/src/components/recording/stats-content.tsx @@ -31,13 +31,18 @@ export function StatsContent({ () => segments.reduce((acc, s) => acc + s.text.split(' ').length, 0), [segments] ); + const wpm = useMemo( + () => (elapsedTime > 0 ? Math.round(wordCount / (elapsedTime / 60000)) : 0), + [wordCount, elapsedTime] + ); return ( <> -
+
+

Speakers

diff --git a/client/src/components/recording/transcript-segment-actions.test.tsx b/client/src/components/recording/transcript-segment-actions.test.tsx new file mode 100644 index 0000000..75b413f --- /dev/null +++ b/client/src/components/recording/transcript-segment-actions.test.tsx @@ -0,0 +1,42 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { TranscriptSegmentActions } from './transcript-segment-actions'; + +// Mock clipboard +Object.assign(navigator, { + clipboard: { + writeText: vi.fn(), + }, +}); + +vi.mock('@/hooks/use-toast', () => ({ + toast: vi.fn(), +})); + +describe('TranscriptSegmentActions', () => { + it('renders copy button and handles click', () => { + render(); + + const copyButton = screen.getByTitle('Copy text'); + expect(copyButton).toBeDefined(); + + 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(); + }); + + it('does not render play button when onPlay is missing', () => { + render(); + expect(screen.queryByTitle('Play audio')).toBeNull(); + }); +}); diff --git a/client/src/components/recording/transcript-segment-actions.tsx b/client/src/components/recording/transcript-segment-actions.tsx new file mode 100644 index 0000000..6041371 --- /dev/null +++ b/client/src/components/recording/transcript-segment-actions.tsx @@ -0,0 +1,32 @@ +import { Copy, Play } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { toast } from '@/hooks/use-toast'; + +export interface TranscriptSegmentActionsProps { + segmentText: string; + onPlay?: () => void; +} + +export function TranscriptSegmentActions({ segmentText, onPlay }: TranscriptSegmentActionsProps) { + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(segmentText); + toast({ description: 'Text copied to clipboard' }); + } catch { + toast({ description: 'Failed to copy text', variant: 'destructive' }); + } + }; + + return ( +
+ {onPlay && ( + + )} + +
+ ); +} diff --git a/client/src/components/recording/transcript-segment-card.tsx b/client/src/components/recording/transcript-segment-card.tsx index 95194ea..1a48ad2 100644 --- a/client/src/components/recording/transcript-segment-card.tsx +++ b/client/src/components/recording/transcript-segment-card.tsx @@ -6,6 +6,7 @@ import type { FinalSegment } from '@/api/types'; import { EntityHighlightText } from '@/components/entity-highlight'; import { SpeakerBadge } from '@/components/speaker-badge'; import { ConfidenceIndicator } from './confidence-indicator'; +import { TranscriptSegmentActions } from './transcript-segment-actions'; import { formatTime } from '@/lib/format'; export interface TranscriptSegmentCardProps { @@ -29,20 +30,23 @@ export const TranscriptSegmentCard = memo(function TranscriptSegmentCard({ {formatTime(segment.start_time)}
-
- - +
+
+ + +
+

{ + const defaultProps = { + recordingState: 'idle' as RecordingState, + elapsedTime: 0, + connectionMode: 'connected' as const, + isSimulating: false, + }; + + it('renders nothing relevant when idle', () => { + // When idle, only ApiModeIndicator might show (if not connected/simulating logic applies) + // But specific to this component's active parts: + render(); + expect(screen.queryByText('Recording')).toBeNull(); + }); + + it('renders recording badge and time when recording', () => { + render( + + ); + + expect(screen.getByText('Recording')).toBeDefined(); + expect(screen.getByText('01:05')).toBeDefined(); + }); + + it('renders simulation indicator when simulating', () => { + render(); + expect(screen.getByTitle('Simulated')).toBeDefined(); + }); + + it('renders connection mode when disconnected', () => { + render(); + expect(screen.getByTitle('Offline')).toBeDefined(); + }); +}); diff --git a/client/src/components/recording/unified-status-row.tsx b/client/src/components/recording/unified-status-row.tsx new file mode 100644 index 0000000..d3e1823 --- /dev/null +++ b/client/src/components/recording/unified-status-row.tsx @@ -0,0 +1,41 @@ +import { ApiModeIndicator } from '@/components/api-mode-indicator'; +import { Badge } from '@/components/ui/badge'; +import { formatElapsedTime } from '@/lib/format'; +import type { ConnectionMode } from '@/api/connection-state'; + +export type RecordingState = 'idle' | 'starting' | 'recording' | 'paused' | 'stopping'; + +export interface UnifiedStatusRowProps { + recordingState: RecordingState; + elapsedTime: number; + connectionMode: ConnectionMode; + isSimulating: boolean; +} + +export function UnifiedStatusRow({ + recordingState, + elapsedTime, + connectionMode, + isSimulating, +}: UnifiedStatusRowProps) { + return ( +

+ {recordingState === 'recording' && ( + <> + + + + + + Recording + + + + {formatElapsedTime(elapsedTime)} + + + )} + +
+ ); +} diff --git a/client/src/components/speaker-badge.test.tsx b/client/src/components/speaker-badge.test.tsx index 9264d85..07d6129 100644 --- a/client/src/components/speaker-badge.test.tsx +++ b/client/src/components/speaker-badge.test.tsx @@ -1,14 +1,20 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { SpeakerBadge } from './speaker-badge'; +import { preferences } from '@/lib/preferences'; vi.mock('@/lib/preferences', () => ({ preferences: { - getSpeakerName: vi.fn(() => 'Custom Name'), + getSpeakerName: vi.fn((_, id) => (id === 'SPEAKER_00' ? 'Custom Name' : undefined)), + setSpeakerName: vi.fn(), }, })); describe('SpeakerBadge', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('uses custom speaker name when meetingId provided', () => { render(); expect(screen.getByText('Custom Name')).toBeInTheDocument(); @@ -22,6 +28,40 @@ describe('SpeakerBadge', () => { it('includes confidence in title when provided', () => { render(); const badge = screen.getByText('SPEAKER_02'); - expect(badge).toHaveAttribute('title', 'Confidence: 82%'); + expect(badge).toHaveAttribute('title', expect.stringContaining('Confidence: 82%')); + }); + + it('allows inline renaming on double click', () => { + render(); + const badge = screen.getByText('SPEAKER_03'); + + // Double click to edit + fireEvent.doubleClick(badge); + + const input = screen.getByDisplayValue('SPEAKER_03'); + expect(input).toBeInTheDocument(); + + // Type new name + fireEvent.change(input, { target: { value: 'Alicia' } }); + + // Press Enter to save + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); + + expect(preferences.setSpeakerName).toHaveBeenCalledWith('meeting-1', 'SPEAKER_03', 'Alicia'); + expect(screen.queryByDisplayValue('Alicia')).not.toBeInTheDocument(); + }); + + it('cancels renaming on Escape', () => { + render(); + const badge = screen.getByText('SPEAKER_04'); + + fireEvent.doubleClick(badge); + const input = screen.getByDisplayValue('SPEAKER_04'); + + fireEvent.change(input, { target: { value: 'Wrong Name' } }); + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' }); + + expect(preferences.setSpeakerName).not.toHaveBeenCalled(); + expect(screen.getByText('SPEAKER_04')).toBeInTheDocument(); }); }); diff --git a/client/src/components/speaker-badge.tsx b/client/src/components/speaker-badge.tsx index 5ce553e..f7d70b0 100644 --- a/client/src/components/speaker-badge.tsx +++ b/client/src/components/speaker-badge.tsx @@ -1,9 +1,9 @@ -// Speaker badge with color coding - import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; import { preferences } from '@/lib/preferences'; import { getSpeakerColorIndex } from '@/lib/speaker-utils'; import { cn } from '@/lib/utils'; +import { useCallback, useEffect, useState } from 'react'; interface SpeakerBadgeProps { speakerId: string; @@ -27,11 +27,55 @@ export function SpeakerBadge({ displayName ?? (meetingId ? preferences.getSpeakerName(meetingId, speakerId) || speakerId : speakerId); + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(resolvedName); + + useEffect(() => { + setEditName(resolvedName); + }, [resolvedName]); + + const handleSave = useCallback(() => { + if (editName.trim() && meetingId) { + preferences.setSpeakerName(meetingId, speakerId, editName.trim()); + } + setIsEditing(false); + }, [editName, meetingId, speakerId]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSave(); + } else if (e.key === 'Escape') { + setEditName(resolvedName); + setIsEditing(false); + } + }; + + if (isEditing) { + return ( + setEditName(e.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + className="h-5 w-32 px-1 py-0 text-xs bg-muted border-input" + autoFocus + /> + ); + } + return ( setIsEditing(true)} > {resolvedName} diff --git a/client/src/components/timestamped-notes-editor.tsx b/client/src/components/timestamped-notes-editor.tsx index 6a54719..dc73b34 100644 --- a/client/src/components/timestamped-notes-editor.tsx +++ b/client/src/components/timestamped-notes-editor.tsx @@ -1,5 +1,5 @@ import { AnimatePresence, motion } from 'framer-motion'; -import { Clock, History, PenLine, Save, Trash2 } from 'lucide-react'; +import { History, PenLine, Save, Trash2 } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -10,6 +10,7 @@ import { Textarea } from '@/components/ui/textarea'; import { formatElapsedTime } from '@/lib/format'; import { generateUuid } from '@/lib/id-utils'; import { cn } from '@/lib/utils'; +import { NotesQuickActions } from '@/components/recording/notes-quick-actions'; export interface NoteEdit { id: string; @@ -109,29 +110,42 @@ export function TimestampedNotesEditor({ saveNote(false); }; + // Manual save // Manual save const handleManualSave = () => { saveNote(false); }; - // Add quick timestamp marker - const insertTimestamp = useCallback(() => { - const marker = `\n\n---\n**[${formatElapsedTime(elapsedTime)}]** `; + // Generic text insertion + const insertText = useCallback((text: string, cursorOffset: number = 0) => { const textarea = textareaRef.current; if (textarea) { const start = textarea.selectionStart; const end = textarea.selectionEnd; - const newContent = currentNote.substring(0, start) + marker + currentNote.substring(end); + const newContent = currentNote.substring(0, start) + text + currentNote.substring(end); setCurrentNote(newContent); - // Focus and move cursor after marker + // Focus and move cursor setTimeout(() => { textarea.focus(); - textarea.setSelectionRange(start + marker.length, start + marker.length); + const newCursor = start + text.length + cursorOffset; + textarea.setSelectionRange(newCursor, newCursor); }, 0); } else { - setCurrentNote((prev) => prev + marker); + setCurrentNote((prev) => prev + text); } - }, [elapsedTime, currentNote]); + }, [currentNote]); + + const handleInsertTimestamp = useCallback(() => { + insertText(`\n\n---\n**[${formatElapsedTime(elapsedTime)}]** `); + }, [insertText, elapsedTime]); + + const handleAddActionItem = useCallback(() => { + insertText('\n- [ ] '); + }, [insertText]); + + const handleAddDecision = useCallback(() => { + insertText('\n> **DECISION:** '); + }, [insertText]); // Clear all notes const clearNotes = () => { @@ -179,29 +193,17 @@ export function TimestampedNotesEditor({
{isRecording && ( - <> - - - + )} {notes.length > 0 && (