Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/desktop/src/main/ipc/meetings.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CallAnalysisSettings } from '@pairux/shared-types';
import { ipcMain } from 'electron';
import { apiFetch } from '../lib/apiFetch';
import { getValidAuth } from '../auth/secure-storage';
Expand Down Expand Up @@ -73,15 +74,19 @@ export function registerMeetingHandlers(): void {
'meetings:start',
async (
_event,
args: { scheduledSessionId: string }
args: { scheduledSessionId: string; analysis?: CallAnalysisSettings }
): Promise<StartMeetingResult | { success: false; error: string }> => {
try {
const headers = await getAuthHeaders();
if (!headers) return { success: false, error: 'Not authenticated' };

const response = await apiFetch(
`${API_BASE_URL}/api/scheduled-sessions/${args.scheduledSessionId}/start`,
{ method: 'POST', headers }
{
method: 'POST',
headers,
body: JSON.stringify(args.analysis?.enabled ? { analysis: args.analysis } : {}),
}
);

const data = (await response.json()) as ApiResponse<{
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/ipc/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export function registerSessionHandlers(): void {
allowGuestControl: settings?.allowGuestControl ?? false,
maxParticipants: settings?.maxParticipants ?? 5,
mode: settings?.mode ?? 'p2p',
...(settings?.analysis?.enabled ? { analysis: settings.analysis } : {}),
}),
});

Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/preload/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CallAnalysisSettings } from '@pairux/shared-types';
import type {
CaptureSource,
Plan,
Expand Down Expand Up @@ -72,6 +73,8 @@ export interface CreateSessionSettings {
allowGuestControl?: boolean;
maxParticipants?: number;
mode?: SessionMode;
/** AI call analysis, chosen before the call starts. */
analysis?: CallAnalysisSettings;
}

// Request/response channels (invoke pattern)
Expand Down Expand Up @@ -233,7 +236,7 @@ export interface IPCChannels {
};

'meetings:start': {
args: { scheduledSessionId: string };
args: { scheduledSessionId: string; analysis?: CallAnalysisSettings };
return: StartMeetingResult | { success: false; error: string };
};

Expand Down
108 changes: 108 additions & 0 deletions apps/desktop/src/renderer/components/CallAnalysisPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { Sparkles } from 'lucide-react';
import type { CallAnalysisKind } from '@pairux/shared-types';
import { useAnalysisPreference } from '@/lib/callAnalysisPreference';
import { getElectronAPI } from '@/lib/ipc';

const KIND_LABELS: Record<CallAnalysisKind, string> = {
general: 'General call',
interview: 'Interview',
'team-sync': 'Team sync',
presentation: 'Presentation',
};

/**
* Home-screen switch for AI call analysis: decided before a call starts and
* applied to the next session this app creates. Turning it on turns
* "Record the call" on with it.
*/
export function CallAnalysisPanel({ disabled = false }: { disabled?: boolean }) {
const [value, setValue] = useAnalysisPreference();

return (
<div
className="mb-6 rounded-lg border border-violet-500/30 bg-violet-500/10 p-4"
data-testid="call-analysis-panel"
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Sparkles className="h-4 w-4 text-violet-400" />
AI call analysis
<span className="rounded-full bg-violet-600 px-1.5 py-0.5 text-[10px] font-bold uppercase text-white">
Pro
</span>
</p>
<p className="mt-1 text-xs text-muted-foreground">
Feedback after the call on talk time, pace, filler words and what to practice. Applies
to the next call you start; it cannot be switched on mid-call.{' '}
<button
type="button"
className="text-violet-300 underline"
onClick={() => void getElectronAPI().invoke('auth:openExternal', '/analyses')}
>
Your reports
</button>
</p>
</div>
<button
type="button"
role="switch"
aria-checked={value.enabled}
aria-label="Analyze the next call"
disabled={disabled}
onClick={() => {
setValue(
value.enabled
? { ...value, enabled: false }
: { ...value, enabled: true, keepRecording: true }
);
}}
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors disabled:opacity-50 ${
value.enabled ? 'bg-violet-600' : 'bg-muted'
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
value.enabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{value.enabled && (
<div className="mt-3 flex flex-wrap items-center gap-4 text-sm">
<label className="flex items-center gap-2">
Call type
<select
value={value.kind}
disabled={disabled}
onChange={(e) => {
setValue({ ...value, kind: e.target.value as CallAnalysisKind });
}}
className="rounded-md border border-border bg-background px-2 py-1"
>
{(Object.keys(KIND_LABELS) as CallAnalysisKind[]).map((kind) => (
<option key={kind} value={kind}>
{KIND_LABELS[kind]}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={value.keepRecording}
disabled={disabled}
onChange={(e) => {
setValue({ ...value, keepRecording: e.target.checked });
}}
/>
Record the call and keep the recording
</label>
<span className="text-xs text-muted-foreground">
Everyone who joins is told the call is recorded for AI analysis.
</span>
</div>
)}
</div>
);
}
3 changes: 3 additions & 0 deletions apps/desktop/src/renderer/components/CreateLinkModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getElectronAPI } from '@/lib/ipc';
import type { Session, SessionMode } from '@pairux/shared-types';
import { APP_URL } from '../../shared/config';
import { getDefaultAllowGuestControl, getDefaultSessionMode } from '@/lib/sessionDefaults';
import { analysisForNewCall } from '@/lib/callAnalysisPreference';

interface CreateLinkModalProps {
isOpen: boolean;
Expand All @@ -27,10 +28,12 @@ export function CreateLinkModal({ isOpen, onClose, onStartSharing }: CreateLinkM

try {
const api = getElectronAPI();
const analysis = analysisForNewCall();
const result = await api.invoke('session:create', {
allowGuestControl: getDefaultAllowGuestControl(),
maxParticipants: mode === 'sfu' ? 10 : 5,
mode,
...(analysis ? { analysis } : {}),
});

if (!result.success) {
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/renderer/components/RecordingNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Sparkles } from 'lucide-react';
import type { SessionSettings } from '@pairux/shared-types';

/**
* Told to everyone in a call whose host turned on AI call analysis. Stays up
* for the whole call: consent to being recorded has to remain visible.
* (Web twin: apps/web/src/components/session/RecordingNotice.tsx.)
*/
export function RecordingNotice({ settings }: { settings: SessionSettings | null | undefined }) {
const analysis = settings?.analysis;
if (analysis?.enabled !== true) return null;
return (
<div
role="status"
data-testid="recording-notice"
className="flex items-center gap-2 rounded-lg bg-violet-600/20 px-3 py-2 text-xs text-violet-200"
>
<Sparkles className="h-3.5 w-3.5 flex-shrink-0" />
This call is being recorded for AI analysis by the host
{analysis.keepRecording ? ', who will keep the recording' : ''}. Leave the call if you do not
want to take part.
</div>
);
}
3 changes: 3 additions & 0 deletions apps/desktop/src/renderer/components/StartMeetingModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '@/lib/meetingTiming';
import type { ScheduledMeeting } from '../../preload/api';
import type { Session } from '@pairux/shared-types';
import { analysisForNewCall } from '@/lib/callAnalysisPreference';

interface Props {
isOpen: boolean;
Expand Down Expand Up @@ -75,8 +76,10 @@ export function StartMeetingModal({ isOpen, onClose, onStarted }: Props) {
setStartingId(meeting.id);
setError(null);
try {
const analysis = analysisForNewCall();
const result = await getElectronAPI().invoke('meetings:start', {
scheduledSessionId: meeting.id,
...(analysis ? { analysis } : {}),
});
if (!result.success) {
setError(result.error);
Expand Down
61 changes: 61 additions & 0 deletions apps/desktop/src/renderer/components/capture/CapturePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
clampAudioGain,
} from '@pairux/shared-types';
import { APP_URL, API_BASE_URL } from '../../../shared/config';
import { CallAnalysisCapture, type CaptureStatus } from '@/lib/callAnalysisCapture';
import { getElectronAPI } from '@/lib/ipc';
import { Button } from '@/components/ui/button';
import { ChatPanel } from '@/components/chat';
Expand Down Expand Up @@ -1240,6 +1241,52 @@ export function CapturePreview({
presentationVideoTrack,
]);

// AI call analysis, chosen on the home screen before this call started:
// upload the call's mixed audio (host + every viewer) and screen stills for
// the report. isHosting flaps during reconnects, so the capture only ends
// when this screen goes away; the server also closes it when the session
// ends or its uploads stop.
const analysisSettings = session?.settings.analysis;
const analysisCaptureRef = useRef<CallAnalysisCapture | null>(null);
const [analysisStatus, setAnalysisStatus] = useState<CaptureStatus | 'off'>('off');
useEffect(() => {
if (!analysisSettings?.enabled || !session || !isHosting || analysisCaptureRef.current) return;
const audio = mixedStream ?? hostMicStream;
if (!audio) return;
const capture = new CallAnalysisCapture({
apiBase: API_BASE_URL,
sessionId: session.id,
source: 'desktop',
headers: (): Record<string, string> => {
const auth = getAuthHeaders().Authorization;
return auth ? { Authorization: auth } : {};
},
onStatus: setAnalysisStatus,
});
analysisCaptureRef.current = capture;
void capture.start(audio);
}, [analysisSettings?.enabled, session, isHosting, mixedStream, hostMicStream, getAuthHeaders]);
useEffect(() => {
analysisCaptureRef.current?.setVideo(stream);
}, [stream, analysisStatus]);
useEffect(() => {
return () => {
void analysisCaptureRef.current?.finish();
};
}, []);

// "Record the call" is on by default with analysis: start the full local
// recording when sharing starts. Once per shared stream, so stopping it by
// hand sticks.
const autoRecordedStreamRef = useRef<MediaStream | null>(null);
useEffect(() => {
if (!analysisSettings?.enabled || !analysisSettings.keepRecording) return;
if (!stream || !source || isRecording) return;
if (autoRecordedStreamRef.current === stream) return;
autoRecordedStreamRef.current = stream;
void handleStartRecording();
}, [analysisSettings, stream, source, isRecording, handleStartRecording]);

const handleStopRecording = useCallback(async () => {
await stopRecording();
setSpaceWarning(null);
Expand Down Expand Up @@ -1831,6 +1878,20 @@ export function CapturePreview({
)}
</div>

{/* AI call analysis indicator */}
{analysisStatus !== 'off' && (
<div
className="absolute left-4 top-4 rounded-full bg-violet-600/90 px-3 py-1 text-xs font-medium text-white"
data-testid="analysis-badge"
>
{analysisStatus === 'error'
? 'AI analysis: not recording'
: analysisStatus === 'capturing'
? 'AI analysis: recording'
: 'AI analysis on'}
</div>
)}

{/* Recording indicator */}
{isRecording && (
<div className="absolute right-4 top-4">
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/renderer/hooks/useSession.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import type { Session, SessionParticipant, SessionMode } from '@pairux/shared-types';
import { getElectronAPI } from '@/lib/ipc';
import { analysisForNewCall } from '@/lib/callAnalysisPreference';

interface SessionState {
session: Session | null;
Expand Down Expand Up @@ -40,7 +41,12 @@ export function useSession(): UseSessionReturn {

try {
const api = getElectronAPI();
const result = await api.invoke('session:create', settings);
// The home-screen AI analysis choice rides along with every new session.
const analysis = analysisForNewCall();
const result = await api.invoke('session:create', {
...settings,
...(analysis ? { analysis } : {}),
});

if (!result.success) {
throw new Error(result.error);
Expand Down
Loading
Loading