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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
33
client/src/components/recording/in-transcript-search.tsx
Normal file
33
client/src/components/recording/in-transcript-search.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
client/src/components/recording/jump-to-live-indicator.tsx
Normal file
20
client/src/components/recording/jump-to-live-indicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
client/src/components/recording/notes-quick-actions.test.tsx
Normal file
48
client/src/components/recording/notes-quick-actions.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
52
client/src/components/recording/notes-quick-actions.tsx
Normal file
52
client/src/components/recording/notes-quick-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
42
client/src/components/recording/unified-status-row.test.tsx
Normal file
42
client/src/components/recording/unified-status-row.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
41
client/src/components/recording/unified-status-row.tsx
Normal file
41
client/src/components/recording/unified-status-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>(() => {
|
||||
|
||||
267
client/src/integration/recording-session.integration.test.tsx
Normal file
267
client/src/integration/recording-session.integration.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
1
client/src/vite-env.d.ts
vendored
1
client/src/vite-env.d.ts
vendored
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user