feat: introduce unified status row, quick actions for notes and transcripts, jump-to-live indicator, and in-transcript search to the recording page.

This commit is contained in:
2026-01-19 01:43:07 +00:00
parent 3aacef9d68
commit 0f92ef8053
21 changed files with 894 additions and 149 deletions

View File

@@ -116,6 +116,7 @@ export function ApiModeIndicator({
<Badge
variant={compact ? 'secondary' : config.variant}
className={cn('gap-1 text-xs uppercase tracking-wide', config.colorClass, className)}
title={config.label}
>
<Icon className="h-3.5 w-3.5" />
{!compact && config.label}

View File

@@ -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<string | null>(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 (
<div className="flex h-screen w-full overflow-hidden bg-background">
<AppSidebar onStartRecording={handleStartRecording} isRecording={isRecording} />
<AppSidebar onStartRecording={handleStartRecording} isRecording={!!activeMeetingId} />
<div className="flex-1 flex flex-col overflow-hidden">
<OfflineBanner />
<TopBar />
<main className="flex-1 overflow-auto">
<Outlet context={{ isRecording, setIsRecording }} />
<Outlet context={{ activeMeetingId, setActiveMeetingId } satisfies AppOutletContext} />
</main>
</div>
</div>

View File

@@ -95,7 +95,7 @@ export function AppSidebar({ onStartRecording, isRecording }: AppSidebarProps) {
className={cn('w-full', collapsed && 'px-0')}
>
<Mic className={cn('h-5 w-5', !collapsed && 'mr-1')} />
{!collapsed && (isRecording ? 'Recording...' : 'Start Recording')}
{!collapsed && (isRecording ? 'Go to Recording' : 'Start Recording')}
</Button>
</div>

View File

@@ -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 (
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Search transcript..."
className="pl-8 pr-8 h-9 text-sm"
/>
{value && (
<Button
variant="ghost"
size="icon-sm"
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7"
onClick={() => onChange('')}
title="Clear search"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
);
}

View File

@@ -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 (
<Button
variant="secondary"
size="sm"
className="absolute bottom-6 right-6 z-10 shadow-lg gap-2 animate-in fade-in slide-in-from-bottom-2 border border-border"
onClick={onClick}
>
<ArrowDown className="h-4 w-4 animate-bounce" />
Jump to Live
</Button>
);
}

View File

@@ -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(
<NotesQuickActions
onInsertTimestamp={vi.fn()}
onAddActionItem={vi.fn()}
onAddDecision={vi.fn()}
elapsedTime={42}
/>
);
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(
<NotesQuickActions
onInsertTimestamp={onInsertTimestamp}
onAddActionItem={onAddActionItem}
onAddDecision={onAddDecision}
elapsedTime={0}
/>
);
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();
});
});

View File

@@ -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 (
<div className="flex gap-1 mb-2">
<Button
variant="outline"
size="sm"
onClick={onInsertTimestamp}
title="Insert current timestamp"
className="h-6 px-2 text-xs"
>
<Clock className="h-3 w-3 mr-1" />
{formatElapsedTime(elapsedTime)}
</Button>
<Button
variant="outline"
size="sm"
onClick={onAddActionItem}
title="Add action item checklist"
className="h-6 px-2 text-xs"
>
<CheckSquare className="h-3 w-3 mr-1" />
Action
</Button>
<Button
variant="outline"
size="sm"
onClick={onAddDecision}
title="Add decision point"
className="h-6 px-2 text-xs"
>
<Gavel className="h-3 w-3 mr-1" />
Decision
</Button>
</div>
);
}

View File

@@ -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'}
</h1>
<div className="flex items-center gap-3 mt-1">
<Badge variant="recording" className="gap-1.5">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-destructive opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-destructive" />
</span>
Recording
</Badge>
{/* Sprint GAP-007: Show API mode indicator */}
<ApiModeIndicator mode={connectionMode} isSimulating={simulateTranscription} />
<span className="text-sm text-muted-foreground font-mono">
{formatElapsedTime(elapsedTime)}
</span>
<UnifiedStatusRow
recordingState={recordingState}
elapsedTime={elapsedTime}
connectionMode={connectionMode}
isSimulating={simulateTranscription}
/>
</div>
</div>
)}

View File

@@ -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 (
<>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<StatCard icon={Clock} label="Duration" value={formatElapsedTime(elapsedTime)} />
<StatCard icon={Mic} label="Segments" value={segments.length.toString()} />
<StatCard icon={Waves} label="Words" value={wordCount.toString()} />
<StatCard icon={Waves} label="Speed" value={`${wpm} wpm`} />
</div>
<div className="pt-4 border-t border-border">
<h4 className="text-sm font-medium text-muted-foreground mb-3">Speakers</h4>

View File

@@ -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(<TranscriptSegmentActions segmentText="Hello world" />);
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(<TranscriptSegmentActions segmentText="Hello world" onPlay={onPlay} />);
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(<TranscriptSegmentActions segmentText="Hello world" />);
expect(screen.queryByTitle('Play audio')).toBeNull();
});
});

View File

@@ -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 (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onPlay && (
<Button variant="ghost" size="icon-sm" onClick={onPlay} title="Play audio">
<Play className="h-3.5 w-3.5" />
</Button>
)}
<Button variant="ghost" size="icon-sm" onClick={handleCopy} title="Copy text">
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
);
}

View File

@@ -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({
<motion.div
initial={animate ? { opacity: 0, y: 20 } : false}
animate={animate ? { opacity: 1, y: 0 } : undefined}
className="flex gap-3 p-3 rounded-lg bg-card border border-border"
className="group flex gap-3 p-3 rounded-lg bg-card border border-border"
>
<span className="text-xs text-muted-foreground font-mono shrink-0 pt-1">
{formatTime(segment.start_time)}
</span>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<SpeakerBadge
speakerId={segment.speaker_id}
meetingId={meetingId}
displayName={speakerName}
confidence={segment.speaker_confidence}
/>
<ConfidenceIndicator confidence={segment.speaker_confidence} />
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<SpeakerBadge
speakerId={segment.speaker_id}
meetingId={meetingId}
displayName={speakerName}
confidence={segment.speaker_confidence}
/>
<ConfidenceIndicator confidence={segment.speaker_confidence} />
</div>
<TranscriptSegmentActions segmentText={segment.text} />
</div>
<p className="text-sm text-foreground leading-relaxed">
<EntityHighlightText

View File

@@ -0,0 +1,42 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { UnifiedStatusRow, type RecordingState } from './unified-status-row';
describe('UnifiedStatusRow', () => {
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(<UnifiedStatusRow {...defaultProps} />);
expect(screen.queryByText('Recording')).toBeNull();
});
it('renders recording badge and time when recording', () => {
render(
<UnifiedStatusRow
{...defaultProps}
recordingState="recording"
elapsedTime={65} // 1m 5s
/>
);
expect(screen.getByText('Recording')).toBeDefined();
expect(screen.getByText('01:05')).toBeDefined();
});
it('renders simulation indicator when simulating', () => {
render(<UnifiedStatusRow {...defaultProps} isSimulating={true} />);
expect(screen.getByTitle('Simulated')).toBeDefined();
});
it('renders connection mode when disconnected', () => {
render(<UnifiedStatusRow {...defaultProps} connectionMode="disconnected" />);
expect(screen.getByTitle('Offline')).toBeDefined();
});
});

View File

@@ -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 (
<div className="flex items-center gap-2 text-sm">
{recordingState === 'recording' && (
<>
<Badge variant="recording" className="gap-1.5">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-destructive opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-destructive" />
</span>
Recording
</Badge>
<span className="text-muted-foreground"></span>
<span className="font-mono text-muted-foreground">
{formatElapsedTime(elapsedTime)}
</span>
</>
)}
<ApiModeIndicator mode={connectionMode} isSimulating={isSimulating} compact />
</div>
);
}

View File

@@ -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(<SpeakerBadge speakerId="SPEAKER_00" meetingId="meeting-1" />);
expect(screen.getByText('Custom Name')).toBeInTheDocument();
@@ -22,6 +28,40 @@ describe('SpeakerBadge', () => {
it('includes confidence in title when provided', () => {
render(<SpeakerBadge speakerId="SPEAKER_02" confidence={0.82} />);
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(<SpeakerBadge speakerId="SPEAKER_03" meetingId="meeting-1" />);
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(<SpeakerBadge speakerId="SPEAKER_04" meetingId="meeting-1" />);
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();
});
});

View File

@@ -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 (
<Input
value={editName}
onChange={(e) => 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 (
<Badge
variant="speaker"
className={cn(`bg-speaker-${colorIndex} speaker-${colorIndex}`, className)}
title={confidence ? `Confidence: ${Math.round(confidence * 100)}%` : undefined}
className={cn(
`bg-speaker-${colorIndex} speaker-${colorIndex} cursor-pointer hover:opacity-80 transition-opacity select-none`,
className
)}
title={
confidence
? `Speaker: ${resolvedName} (Confidence: ${Math.round(confidence * 100)}%)`
: undefined
}
onDoubleClick={() => setIsEditing(true)}
>
{resolvedName}
</Badge>

View File

@@ -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({
</div>
<div className="flex items-center gap-1">
{isRecording && (
<>
<Button
variant="ghost"
size="sm"
onClick={insertTimestamp}
className="h-7 text-xs gap-1"
title="Insert timestamp marker"
>
<Clock className="h-3 w-3" />
Insert Time
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleManualSave}
className="h-7 text-xs gap-1"
title="Save now"
disabled={lastSavedContent.current === currentNote.trim()}
>
<Save className="h-3 w-3" />
Save
</Button>
</>
<Button
variant="ghost"
size="sm"
onClick={handleManualSave}
className="h-7 text-xs gap-1"
title="Save now"
disabled={lastSavedContent.current === currentNote.trim()}
>
<Save className="h-3 w-3" />
Save
</Button>
)}
{notes.length > 0 && (
<Button
@@ -290,8 +292,20 @@ export function TimestampedNotesEditor({
)}
</AnimatePresence>
{/* Quick Actions Toolbar */}
{isRecording && (
<div className="px-3 pt-2">
<NotesQuickActions
elapsedTime={elapsedTime}
onInsertTimestamp={handleInsertTimestamp}
onAddActionItem={handleAddActionItem}
onAddDecision={handleAddDecision}
/>
</div>
)}
{/* Editor */}
<div className="flex-1 pt-3 flex flex-col min-h-0">
<div className="flex-1 px-3 pt-1 flex flex-col min-h-0">
<div className="flex-1 relative">
<Textarea
ref={textareaRef}

View File

@@ -21,6 +21,9 @@ const MAX_NOTES_SIZE_PERCENT = 40;
/** Maximum size for stats panel */
const MAX_STATS_SIZE_PERCENT = 35;
/** Minimum size for notes panel when expanded */
const MIN_NOTES_SIZE_PERCENT = 25;
interface PanelPreferences {
showNotesPanel: boolean;
showStatsPanel: boolean;
@@ -31,7 +34,7 @@ interface PanelPreferences {
const DEFAULT_PREFERENCES: PanelPreferences = {
showNotesPanel: true,
showStatsPanel: true,
showStatsPanel: false,
notesPanelSize: DEFAULT_NOTES_SIZE_PERCENT,
statsPanelSize: DEFAULT_STATS_SIZE_PERCENT,
transcriptPanelSize: DEFAULT_TRANSCRIPT_SIZE_PERCENT,
@@ -99,7 +102,12 @@ function isPanelPreferences(value: unknown): value is Partial<PanelPreferences>
}
/** Export panel size constants for use in panel components */
export { COLLAPSED_SIZE_PERCENT, MAX_NOTES_SIZE_PERCENT, MAX_STATS_SIZE_PERCENT };
export {
COLLAPSED_SIZE_PERCENT,
MAX_NOTES_SIZE_PERCENT,
MAX_STATS_SIZE_PERCENT,
MIN_NOTES_SIZE_PERCENT,
};
export function usePanelPreferences() {
const [preferences, setPreferences] = useState<PanelPreferences>(() => {

View File

@@ -0,0 +1,267 @@
/**
* Recording Session Integration Tests
*
* This suite validates the backend connectivity and streaming data flow by preventing
* the full E2E overhead but mocking the Tauri IPC boundary.
*/
import { cleanup, renderHook, act, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest';
import { useRecordingSession } from '@/hooks/use-recording-session';
import { TauriEvents, TauriCommands } from '@/api/tauri-constants';
import type { TranscriptUpdate, Meeting, FinalSegment } from '@/api/types';
import type { TranscriptionStream } from '@/api/interface';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type React from 'react';
// Mock values matching backend enums
const UPDATE_TYPE_PARTIAL = 'partial';
const UPDATE_TYPE_FINAL = 'final';
const UPDATE_TYPE_VAD_START = 'vad_start';
const UPDATE_TYPE_VAD_END = 'vad_end';
// Setup backend mocks with explicit typing to avoid 'any'
const mockInvoke = vi.fn<(cmd: string, args?: unknown) => Promise<unknown>>();
const mockListen = vi.fn<(event: string, handler: (event: { payload: TranscriptUpdate }) => void) => Promise<() => void>>();
// Mock the window.__TAURI__ object
Object.defineProperty(window, '__TAURI__', {
writable: true,
value: {
tauri: { invoke: mockInvoke },
event: { listen: mockListen },
},
});
// Create a mock API that mirrors the structure we expect
const mockTauriAPI = {
createMeeting: (args: unknown): Promise<Meeting> => mockInvoke(TauriCommands.CREATE_MEETING, args) as Promise<Meeting>,
startTranscription: (meetingId: string): Promise<TranscriptionStream> => {
const promise = mockInvoke(TauriCommands.START_RECORDING, { meeting_id: meetingId }) as Promise<void>;
return promise.then((): TranscriptionStream => ({
send: vi.fn(),
close: (): Promise<void> => mockInvoke(TauriCommands.STOP_RECORDING, { meeting_id: meetingId }) as Promise<void>,
onUpdate: (cb: (update: TranscriptUpdate) => void): Promise<void> => {
return mockListen(TauriEvents.TRANSCRIPT_UPDATE, (event: { payload: TranscriptUpdate }) => cb(event.payload)).then(() => {});
},
onError: vi.fn(),
}));
},
stopMeeting: (id: string): Promise<Meeting> => mockInvoke(TauriCommands.STOP_MEETING, { meeting_id: id }) as Promise<Meeting>,
getStreamState: (): Promise<{ state: 'idle' }> => Promise.resolve({ state: 'idle' } as const),
isE2EMode: (): boolean => false,
isConnected: (): Promise<boolean> => Promise.resolve(true),
getPreferences: (): Promise<{ simulate_transcription: boolean }> => Promise.resolve({ simulate_transcription: false }),
};
// Mock dependencies
vi.mock('@/contexts/connection-state', () => ({
useConnectionState: () => ({
isConnected: true,
isReconnecting: false,
isReadOnly: false,
mode: 'connected',
state: { mode: 'connected' },
}),
}));
vi.mock('@/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api')>();
return {
...actual,
isTauriEnvironment: () => true,
getAPI: () => mockTauriAPI,
};
});
vi.mock('@/api/interface', () => ({
getAPI: () => mockTauriAPI,
}));
// Wrapper for React Query
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
describe('Recording Session Integration', () => {
beforeEach(() => {
vi.clearAllMocks();
queryClient.clear();
mockListen.mockImplementation(async (_event: string, _handler: (event: { payload: TranscriptUpdate }) => void) => {
const unlisten: () => void = () => { };
return unlisten;
});
mockInvoke.mockImplementation(async (cmd: string, args?: unknown) => {
const argsObj = args as Record<string, unknown>;
switch (cmd) {
case TauriCommands.CREATE_MEETING: {
const meeting: Meeting = {
id: 'meeting-integration-test',
title: (argsObj?.title as string) || 'New Meeting',
state: 'created',
created_at: Date.now() / 1000,
duration_seconds: 0,
segments: [],
metadata: {},
};
return meeting;
}
case TauriCommands.START_RECORDING:
return Promise.resolve();
case TauriCommands.STOP_RECORDING:
return {
id: 'meeting-integration-test',
state: 'stopped',
segments: [],
};
case TauriCommands.GET_PREFERENCES:
return { simulate_transcription: false };
default:
return undefined;
}
});
});
afterEach(() => {
cleanup();
});
const emitTranscriptUpdate = (update: Partial<TranscriptUpdate>) => {
const call = mockListen.mock.calls.find((args) => args[0] === TauriEvents.TRANSCRIPT_UPDATE);
if (!call) throw new Error(`No listener registered for ${TauriEvents.TRANSCRIPT_UPDATE}`);
const handler = call[1];
act(() => {
const payload: TranscriptUpdate = {
meeting_id: 'meeting-integration-test',
server_timestamp: Date.now() / 1000,
update_type: 'partial',
...update,
};
handler({ payload });
});
};
it('Happy Path: Full Recording Lifecycle with Streaming', async () => {
const { result } = renderHook(() => useRecordingSession({ initialTitle: 'Integration Test' }), { wrapper });
expect(result.current.recordingState).toBe('idle');
await act(async () => {
await result.current.startRecording();
});
expect(mockInvoke).toHaveBeenCalledWith(TauriCommands.CREATE_MEETING, expect.anything());
expect(mockInvoke).toHaveBeenCalledWith(TauriCommands.START_RECORDING, expect.objectContaining({
meeting_id: 'meeting-integration-test'
}));
expect(result.current.recordingState).toBe('recording');
emitTranscriptUpdate({
update_type: UPDATE_TYPE_PARTIAL,
partial_text: 'Hello world',
});
await waitFor(() => {
expect(result.current.partialText).toBe('Hello world');
});
const segment: FinalSegment = {
segment_id: 1,
text: 'Hello world.',
start_time: 0,
end_time: 1.5,
words: [],
language: 'en',
speaker_id: 'spk_1',
language_confidence: 1,
avg_logprob: 0,
no_speech_prob: 0,
speaker_confidence: 1,
};
emitTranscriptUpdate({
update_type: UPDATE_TYPE_FINAL,
segment,
});
await waitFor(() => {
expect(result.current.segments).toHaveLength(1);
expect(result.current.segments[0].text).toBe('Hello world.');
expect(result.current.partialText).toBe('');
});
await act(async () => {
await result.current.stopRecording();
});
expect(mockInvoke).toHaveBeenCalledWith(TauriCommands.STOP_RECORDING, expect.anything());
expect(result.current.recordingState).toBe('idle');
});
it('VAD: Simulates Voice Activity Toggling', async () => {
const { result } = renderHook(() => useRecordingSession({}), { wrapper });
await act(async () => { await result.current.startRecording(); });
emitTranscriptUpdate({ update_type: UPDATE_TYPE_VAD_START });
await waitFor(() => { expect(result.current.isVadActive).toBe(true); });
emitTranscriptUpdate({ update_type: UPDATE_TYPE_VAD_END });
await waitFor(() => { expect(result.current.isVadActive).toBe(false); });
});
it('Data Flow: Handles Rapid/Out-of-Order Updates (Jitter Simulation)', async () => {
const { result } = renderHook(() => useRecordingSession({}), { wrapper });
await act(async () => { await result.current.startRecording(); });
emitTranscriptUpdate({ update_type: UPDATE_TYPE_PARTIAL, partial_text: 'T' });
emitTranscriptUpdate({ update_type: UPDATE_TYPE_PARTIAL, partial_text: 'Te' });
emitTranscriptUpdate({ update_type: UPDATE_TYPE_PARTIAL, partial_text: 'Tes' });
emitTranscriptUpdate({ update_type: UPDATE_TYPE_PARTIAL, partial_text: 'Testing Jitter' });
await waitFor(() => { expect(result.current.partialText).toBe('Testing Jitter'); });
});
it('Error Handling: Handles Backend Start Failure', async () => {
mockInvoke.mockImplementation(async (cmd) => {
if (cmd === TauriCommands.START_RECORDING) throw new Error('Microphone busy');
if (cmd === TauriCommands.CREATE_MEETING) return { id: 'fail-meeting' } as unknown as Meeting;
return undefined;
});
const { result } = renderHook(() => useRecordingSession({}), { wrapper });
await act(async () => {
try { await result.current.startRecording(); } catch (_e) { /* Expected */ }
});
expect(result.current.recordingState).not.toBe('recording');
});
it('Speaker Identification: Correctly Attributes Speakers', async () => {
const { result } = renderHook(() => useRecordingSession({}), { wrapper });
await act(async () => { await result.current.startRecording(); });
const segmentA: FinalSegment = {
segment_id: 10, text: 'Hi', speaker_id: 'Alice', start_time: 0, end_time: 1, words: [], language: 'en',
language_confidence: 1, avg_logprob: 0, no_speech_prob: 0, speaker_confidence: 1
};
const segmentB: FinalSegment = {
segment_id: 11, text: 'Hello', speaker_id: 'Bob', start_time: 1, end_time: 2, words: [], language: 'en',
language_confidence: 1, avg_logprob: 0, no_speech_prob: 0, speaker_confidence: 1
};
emitTranscriptUpdate({ update_type: UPDATE_TYPE_FINAL, segment: segmentA });
emitTranscriptUpdate({ update_type: UPDATE_TYPE_FINAL, segment: segmentB });
await waitFor(() => {
expect(result.current.segments).toHaveLength(2);
expect(result.current.segments[0].speaker_id).toBe('Alice');
expect(result.current.segments[1].speaker_id).toBe('Bob');
});
});
});

View File

@@ -8,12 +8,13 @@
*/
import { AnimatePresence } from 'framer-motion';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ImperativePanelHandle } from 'react-resizable-panels';
import { useNavigate, useParams } from 'react-router-dom';
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 {
IdleState,
@@ -33,12 +34,17 @@ import {
COLLAPSED_SIZE_PERCENT,
MAX_NOTES_SIZE_PERCENT,
MAX_STATS_SIZE_PERCENT,
MIN_NOTES_SIZE_PERCENT,
usePanelPreferences,
} from '@/hooks/use-panel-preferences';
import { useRecordingSession } from '@/hooks/use-recording-session';
import { preferences } from '@/lib/preferences';
import { buildSpeakerNameMap } from '@/lib/speaker-utils';
import { JumpToLiveIndicator } from '@/components/recording/jump-to-live-indicator';
import { InTranscriptSearch } from '@/components/recording/in-transcript-search';
const TRANSCRIPT_VIRTUALIZE_THRESHOLD = 100;
const TRANSCRIPT_ESTIMATED_ROW_HEIGHT = 104;
const TRANSCRIPT_OVERSCAN = 8;
@@ -72,6 +78,17 @@ export default function RecordingPage() {
isTauri,
} = session;
const { setActiveMeetingId } = useOutletContext<AppOutletContext>();
// Sync active meeting ID to AppLayout
useEffect(() => {
if (recordingState === 'recording' && meeting?.id) {
setActiveMeetingId(meeting.id);
} else {
setActiveMeetingId(null);
}
}, [recordingState, meeting?.id, setActiveMeetingId]);
// Notes state
const [notes, setNotes] = useState<NoteEdit[]>([]);
@@ -106,6 +123,21 @@ export default function RecordingPage() {
const [speakerNameMap, setSpeakerNameMap] = useState<Map<string, string>>(new Map());
// Search state
const [searchQuery, setSearchQuery] = useState('');
const filteredSegments = useMemo(() => {
if (!searchQuery) {
return segments;
}
const lowerQuery = searchQuery.toLowerCase();
return segments.filter(
(s) =>
s.text.toLowerCase().includes(lowerQuery) ||
speakerNameMap.get(s.speaker_id)?.toLowerCase().includes(lowerQuery)
);
}, [segments, searchQuery, speakerNameMap]);
useEffect(() => {
if (!meeting?.id || segments.length === 0) {
setSpeakerNameMap(new Map());
@@ -120,14 +152,22 @@ export default function RecordingPage() {
// Scroll refs
const transcriptScrollRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true);
const [showJumpToLive, setShowJumpToLive] = useState(false);
const handleJumpToLive = useCallback(() => {
const scrollElement = transcriptScrollRef.current;
if (scrollElement) {
scrollElement.scrollTo({ top: scrollElement.scrollHeight, behavior: 'smooth' });
}
}, []);
// Panel refs for imperative collapse/expand
const notesPanelRef = useRef<ImperativePanelHandle>(null);
const statsPanelRef = useRef<ImperativePanelHandle>(null);
const shouldVirtualizeTranscript = segments.length > TRANSCRIPT_VIRTUALIZE_THRESHOLD;
const shouldVirtualizeTranscript = filteredSegments.length > TRANSCRIPT_VIRTUALIZE_THRESHOLD;
const transcriptVirtualizer = useVirtualizer({
count: segments.length,
count: filteredSegments.length,
getScrollElement: () => transcriptScrollRef.current,
estimateSize: () => TRANSCRIPT_ESTIMATED_ROW_HEIGHT,
overscan: TRANSCRIPT_OVERSCAN,
@@ -143,7 +183,13 @@ export default function RecordingPage() {
const handleScroll = () => {
const distanceFromBottom =
scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight;
isNearBottomRef.current = distanceFromBottom < AUTO_SCROLL_THRESHOLD_PX;
const isNear = distanceFromBottom < AUTO_SCROLL_THRESHOLD_PX;
isNearBottomRef.current = isNear;
const shouldShow = !isNear && recordingState === 'recording';
if (showJumpToLive !== shouldShow) {
setShowJumpToLive(shouldShow);
}
};
handleScroll();
@@ -151,7 +197,7 @@ export default function RecordingPage() {
return () => {
scrollElement.removeEventListener('scroll', handleScroll);
};
}, []);
}, [recordingState, showJumpToLive]);
// Auto-scroll to bottom for live updates
useEffect(() => {
@@ -245,78 +291,86 @@ export default function RecordingPage() {
minSize={30}
onResize={setTranscriptPanelSize}
>
<div ref={transcriptScrollRef} className="h-full overflow-auto p-6">
{recordingState === 'idle' ? (
<IdleState />
) : (
<div className="max-w-3xl mx-auto space-y-4">
{/* VAD Indicator */}
<VADIndicator isActive={isVadActive} isRecording={recordingState === 'recording'} />
<div className="relative h-full w-full">
{showJumpToLive && <JumpToLiveIndicator onClick={handleJumpToLive} />}
<div ref={transcriptScrollRef} className="h-full overflow-auto p-6">
{recordingState === 'idle' ? (
<IdleState />
) : (
<div className="max-w-3xl mx-auto space-y-4">
{/* VAD Indicator */}
<VADIndicator isActive={isVadActive} isRecording={recordingState === 'recording'} />
{/* Transcript */}
<div className="space-y-3">
{shouldVirtualizeTranscript ? (
<div
style={{
height: transcriptVirtualizer.getTotalSize(),
position: 'relative',
}}
>
{transcriptVirtualizer.getVirtualItems().map((virtualRow) => {
const segment = segments[virtualRow.index];
return (
<div
key={segment.segment_id}
ref={transcriptVirtualizer.measureElement}
className="pb-3"
data-index={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<TranscriptSegmentCard
segment={segment}
meetingId={meeting?.id}
speakerName={speakerNameMap.get(segment.speaker_id)}
pinnedEntities={pinnedEntities}
onTogglePin={handleTogglePinEntity}
animate={false}
/>
</div>
);
})}
</div>
) : (
<AnimatePresence mode="popLayout">
{segments.map((segment) => (
<TranscriptSegmentCard
key={segment.segment_id}
segment={segment}
meetingId={meeting?.id}
speakerName={speakerNameMap.get(segment.speaker_id)}
pinnedEntities={pinnedEntities}
onTogglePin={handleTogglePinEntity}
/>
))}
</AnimatePresence>
{/* Search */}
{segments.length > 0 && (
<InTranscriptSearch value={searchQuery} onChange={setSearchQuery} />
)}
<PartialTextDisplay
text={partialText}
pinnedEntities={pinnedEntities}
onTogglePin={handleTogglePinEntity}
/>
</div>
{/* Empty State */}
{segments.length === 0 && !partialText && recordingState === 'recording' && (
<ListeningState />
)}
</div>
)}
{/* Transcript */}
<div className="space-y-3">
{shouldVirtualizeTranscript ? (
<div
style={{
height: transcriptVirtualizer.getTotalSize(),
position: 'relative',
}}
>
{transcriptVirtualizer.getVirtualItems().map((virtualRow) => {
const segment = filteredSegments[virtualRow.index];
return (
<div
key={segment.segment_id}
ref={transcriptVirtualizer.measureElement}
className="pb-3"
data-index={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<TranscriptSegmentCard
segment={segment}
meetingId={meeting?.id}
speakerName={speakerNameMap.get(segment.speaker_id)}
pinnedEntities={pinnedEntities}
onTogglePin={handleTogglePinEntity}
animate={false}
/>
</div>
);
})}
</div>
) : (
<AnimatePresence mode="popLayout">
{filteredSegments.map((segment) => (
<TranscriptSegmentCard
key={segment.segment_id}
segment={segment}
meetingId={meeting?.id}
speakerName={speakerNameMap.get(segment.speaker_id)}
pinnedEntities={pinnedEntities}
onTogglePin={handleTogglePinEntity}
/>
))}
</AnimatePresence>
)}
<PartialTextDisplay
text={partialText}
pinnedEntities={pinnedEntities}
onTogglePin={handleTogglePinEntity}
/>
</div>
{/* Empty State */}
{segments.length === 0 && !partialText && recordingState === 'recording' && (
<ListeningState />
)}
</div>
)}
</div>
</div>
</ResizablePanel>
@@ -329,7 +383,7 @@ export default function RecordingPage() {
id="notes"
order={2}
defaultSize={showNotesPanel ? notesPanelSize : COLLAPSED_SIZE_PERCENT}
minSize={COLLAPSED_SIZE_PERCENT}
minSize={MIN_NOTES_SIZE_PERCENT}
maxSize={MAX_NOTES_SIZE_PERCENT}
collapsible
collapsedSize={COLLAPSED_SIZE_PERCENT}

View File

@@ -10,6 +10,7 @@ interface ImportMetaEnv {
* Set to 'false' in production builds to hide simulation toggle.
*/
readonly VITE_DEV_MODE: string;
readonly VITE_E2E_MODE?: string;
}
interface ImportMeta {