import { useEffect, useRef, useState, useCallback, useMemo } from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { buildCustoms, waitForIceGathering, PARTICIPANTS, RECORDING_CONFIG, WEBHOOK_URL, VX_SERVER } from '../lib/webrtc' import { decodeSessionConfig } from '../lib/sessionConfig' import { COMPANIES } from '../lib/companies' const DEFAULT_KEY = import.meta.env.VITE_FLOW_API_KEY || '' export default function Room({ sessionId, state }) { const navigate = useNavigate() const location = useLocation() // Config resolution: state (dev shortcut) OR URL ?cfg= param (normal interviewee flow) const config = useMemo(() => { if (state?.name || state?.candidateName) return state const encoded = new URLSearchParams(location.search).get('cfg') return encoded ? decodeSessionConfig(encoded) : null }, [state, location.search]) const name = config?.candidateName || config?.name || '' const companyId = typeof config?.company === 'string' ? config.company : config?.company?.id const companyObj = companyId ? (COMPANIES[companyId] || config?.company) : config?.company const apiKey = config?.apiKey || DEFAULT_KEY const server = config?.server || VX_SERVER const voiceSettings = config?.voiceSettings || null // Interviewee opened via URL → show join screen before connecting const isUrlFlow = !state?.name && !state?.candidateName const [started, setStarted] = useState(!isUrlFlow) const watchUrl = `${window.location.origin}${window.location.pathname}#/watch/${sessionId}` const pcRef = useRef(null) const streamRef = useRef(null) const audioCtxRef = useRef(null) const userVideoRef = useRef(null) const aiVideoRef = useRef(null) const aiAudioRef = useRef(null) const chatEndRef = useRef(null) const [connState, setConnState] = useState('connecting') const [aiState, setAiState] = useState('waiting') const [userSpeaking, setUserSpeaking] = useState(false) const [aiSpeaking, setAiSpeaking] = useState(false) const [micMuted, setMicMuted] = useState(false) const [hasUserVid, setHasUserVid] = useState(false) const [hasAiVid, setHasAiVid] = useState(false) const [elapsed, setElapsed] = useState(0) const [chatMessages, setChatMessages] = useState([]) const [copied, setCopied] = useState(false) // Timer — only runs once started useEffect(() => { if (!started) return const t = setInterval(() => setElapsed(s => s + 1), 1000) return () => clearInterval(t) }, [started]) useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [chatMessages]) // SSE — subscribe once we have a session useEffect(() => { if (!started) return const es = new EventSource(`${WEBHOOK_URL}/events?session=${sessionId}`) es.onmessage = e => { try { const data = JSON.parse(e.data) const responses = data.responses || [] const msgs = [] // User transcript — non-streaming entry with out.user_input const userEntry = responses.find(r => !r.streaming && r.out?.user_input) if (userEntry) { const text = userEntry.out.user_input .replace(/[\s\S]*?<\/turn-visual-context>/g, '') .replace(/[\s\S]*?<\/non-verbal-elements>/g, '') .trim() if (text) msgs.push({ role: 'user', text }) } // AI text — main LLM only (prev_llm is audio filler, not transcript) const aiRaw = responses .filter(r => r.streaming && r.chunk && r.node === 'llm') .map(r => r.chunk) .join('') const aiText = aiRaw .split(/<\|speak\|>/) .map(seg => seg.replace(/<\|hangup\|>\S*/g, '').trim()) .filter(Boolean) .join(' ') .trim() if (aiText) msgs.push({ role: 'ai', text: aiText }) if (msgs.length) setChatMessages(prev => [...prev, ...msgs]) } catch {} } return () => es.close() }, [sessionId, started]) // VAD const startVAD = useCallback((analyser, threshold, onSpeak, onSilence) => { const buf = new Uint8Array(analyser.fftSize) let speakFrames = 0, silentFrames = 0 const tick = () => { analyser.getByteTimeDomainData(buf) let sum = 0 for (let i = 0; i < buf.length; i++) { const v = (buf[i] - 128) / 128; sum += v * v } const rms = Math.sqrt(sum / buf.length) * 100 if (rms > threshold) { speakFrames++; silentFrames = 0 if (speakFrames > 3) onSpeak() } else { silentFrames++; speakFrames = 0 if (silentFrames > 8) onSilence() } requestAnimationFrame(tick) } tick() }, []) const setupUserVAD = useCallback((stream) => { const ctx = audioCtxRef.current const src = ctx.createMediaStreamSource(stream) const an = ctx.createAnalyser(); an.fftSize = 512; an.smoothingTimeConstant = 0.7 src.connect(an) startVAD(an, 12, () => setUserSpeaking(true), () => setUserSpeaking(false)) }, [startVAD]) const setupAIVAD = useCallback((stream) => { if (!audioCtxRef.current) audioCtxRef.current = new (window.AudioContext || window.webkitAudioContext)() const ctx = audioCtxRef.current const src = ctx.createMediaStreamSource(stream) const an = ctx.createAnalyser(); an.fftSize = 512; an.smoothingTimeConstant = 0.7 src.connect(an) startVAD(an, 10, () => { setAiSpeaking(true); setAiState('speaking') }, () => { setAiSpeaking(false); setAiState('listening') } ) }, [startVAD]) // WebRTC bootstrap — only runs when started useEffect(() => { if (!started || !name || !companyObj) return let cancelled = false let reconnectAttempts = 0 let reconnectTimer = null const MAX_RECONNECTS = 5 async function connect(stream) { if (cancelled) return let iceServers = [] try { const r = await fetch(`${server}/rtc/ice-servers`, { signal: AbortSignal.timeout(3000) }) if (r.ok) { const d = await r.json(); iceServers = d.ice_servers || [] } } catch {} const pc = new RTCPeerConnection({ iceServers }) pcRef.current = pc stream.getTracks().forEach(t => pc.addTrack(t, stream)) pc.ontrack = e => { if (e.track.kind === 'audio') { aiAudioRef.current.srcObject = e.streams[0] setupAIVAD(e.streams[0]) } else if (e.track.kind === 'video') { aiVideoRef.current.srcObject = e.streams[0] setHasAiVid(true) } } pc.onconnectionstatechange = () => { const st = pc.connectionState if (st === 'connected') { reconnectAttempts = 0; setConnState('connected') } if (st === 'failed' || st === 'disconnected') { pc.close() if (!cancelled && reconnectAttempts < MAX_RECONNECTS) { reconnectAttempts++ setConnState('connecting') reconnectTimer = setTimeout(() => connect(stream), Math.min(1000 * reconnectAttempts, 5000)) } else if (!cancelled) { setConnState('failed') } } } const offer = await pc.createOffer() await pc.setLocalDescription(offer) await waitForIceGathering(pc) const customs = buildCustoms(companyId, name, voiceSettings) let resp try { resp = await fetch(`${server}/rtc/offer/audio`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'api_key': apiKey }, body: JSON.stringify({ sdp: pc.localDescription.sdp, type: pc.localDescription.type, participants: PARTICIPANTS, customs, session_id: sessionId, ...RECORDING_CONFIG, }), }) } catch { if (!cancelled && reconnectAttempts < MAX_RECONNECTS) { reconnectAttempts++ setConnState('connecting') reconnectTimer = setTimeout(() => connect(stream), Math.min(1000 * reconnectAttempts, 5000)) } else { setConnState('failed') } return } if (!resp.ok) { setConnState('failed'); return } const answer = await resp.json() await pc.setRemoteDescription(new RTCSessionDescription(answer)) } async function start() { let stream try { stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true }) } catch { try { stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }) } catch { setConnState('failed'); return } } if (cancelled) { stream.getTracks().forEach(t => t.stop()); return } streamRef.current = stream if (stream.getVideoTracks().length) { userVideoRef.current.srcObject = stream setHasUserVid(true) } audioCtxRef.current = new (window.AudioContext || window.webkitAudioContext)() setupUserVAD(stream) connect(stream) } start() return () => { cancelled = true clearTimeout(reconnectTimer) cleanup() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [started]) function cleanup() { streamRef.current?.getTracks().forEach(t => t.stop()) pcRef.current?.close() pcRef.current = null audioCtxRef.current?.close() audioCtxRef.current = null } function toggleMic() { if (!streamRef.current) return const next = !micMuted setMicMuted(next) streamRef.current.getAudioTracks().forEach(t => { t.enabled = !next }) if (next) setUserSpeaking(false) } function leave() { cleanup() navigate('/') } function copyLink() { navigator.clipboard.writeText(watchUrl).then(() => { setCopied(true) setTimeout(() => setCopied(false), 2000) }) } const fmtTime = s => { const m = Math.floor(s / 60).toString().padStart(2, '0') const ss = (s % 60).toString().padStart(2, '0') return `${m}:${ss}` } // ── Invalid link ───────────────────────────────────────────────────────────── if (!config || !companyObj || !name) { return (

Invalid interview link.

) } // ── Interviewee join screen ─────────────────────────────────────────────────── if (!started) { return (
{companyObj.icon}

Mock interview

Ready when
you are.

Company {companyObj.name}
Candidate {name}

The AI interviewer will open the session. This is a practice run — speak naturally and take your time. Your microphone and camera will be requested.

Everything stays within this session.

) } // ── Interview room ──────────────────────────────────────────────────────────── const statusLabel = { connecting: 'Connecting…', connected: 'Connected', failed: 'Connection lost' } const aiStateLabel = { waiting: 'Waiting', listening: 'Listening', thinking: 'Thinking…', speaking: 'Speaking' } return (
{/* topbar */}
Mock Interview {companyObj.name}
{fmtTime(elapsed)} {connState}
{/* body: stage + chat */}
{/* stage */}
{/* user card */}
{userSpeaking && !micMuted && !hasUserVid && ( <>
)}
{name} candidate
{/* AI card */}
{aiSpeaking && !hasAiVid && ( <>
)}
{companyObj.name} Interviewer AI
{/* live chat panel */}
{/* status row */}
{statusLabel[connState]}
{/* controls */}
{aiStateLabel[aiState]}
) } // ── Sub-components ──────────────────────────────────────────────────────────── function WaveBars({ color }) { const heights = [14, 28, 20, 34, 18] return (
{heights.map((h, i) => (
))}
) } function MicBars({ color }) { const heights = [12, 26, 18, 30, 14] return (
{heights.map((h, i) => (
))}
) } function MicIcon() { return ( ) } function MicOffIcon() { return ( ) } function LeaveIcon() { return ( ) } // ── Styles ──────────────────────────────────────────────────────────────────── // Join screen styles const j = { page: { minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '48px 20px', position: 'relative', overflow: 'hidden', }, glow: { position: 'fixed', inset: 0, pointerEvents: 'none' }, inner: { width: '100%', maxWidth: 420, display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 1, }, iconBox: { width: 56, height: 56, borderRadius: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24, marginBottom: 20, }, pill: { display: 'inline-block', alignSelf: 'flex-start', fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '.2em', textTransform: 'uppercase', border: '1px solid', padding: '4px 10px', borderRadius: 20, marginBottom: 20, }, title: { fontSize: 'clamp(32px, 6vw, 50px)', fontWeight: 800, lineHeight: .95, letterSpacing: '-.03em', marginBottom: 28, }, metaRow: { display: 'flex', alignItems: 'stretch', gap: 0, background: 'var(--surface)', border: '1px solid var(--border2)', borderRadius: 10, overflow: 'hidden', marginBottom: 28, }, metaItem: { display: 'flex', flexDirection: 'column', gap: 4, padding: '14px 18px', flex: 1, }, metaDivider: { width: 1, background: 'var(--border2)', flexShrink: 0, }, metaLabel: { fontFamily: 'var(--mono)', fontSize: 8, letterSpacing: '.2em', textTransform: 'uppercase', color: 'var(--muted)', }, metaValue: { fontFamily: 'var(--sans)', fontSize: 14, fontWeight: 700, color: 'var(--text)', }, hint: { fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--muted)', lineHeight: 1.7, letterSpacing: '.04em', marginBottom: 32, }, beginBtn: { color: '#0a0a0a', border: 'none', fontFamily: 'var(--sans)', fontSize: 15, fontWeight: 700, letterSpacing: '.06em', padding: '18px 28px', borderRadius: 8, cursor: 'pointer', marginBottom: 16, }, fine: { fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--muted)', letterSpacing: '.1em', textAlign: 'center', }, errorTitle: { fontFamily: 'var(--mono)', fontSize: 13, color: 'var(--muted)', marginBottom: 16, }, backBtn: { fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--muted)', background: 'none', border: 'none', cursor: 'pointer', letterSpacing: '.08em', padding: 0, }, } // Room styles const s = { root: { height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' }, topbar: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 28px', height: 56, borderBottom: '1px solid var(--border)', flexShrink: 0, }, topLeft: { display: 'flex', alignItems: 'center', gap: 12 }, topRight: { display: 'flex', alignItems: 'center', gap: 12 }, sessionLabel: { fontFamily: 'var(--mono)', fontSize: 11, letterSpacing: '.18em', color: 'var(--muted)', textTransform: 'uppercase' }, companyChip: { fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '.14em', textTransform: 'uppercase', padding: '3px 9px', borderRadius: 20, border: '1px solid' }, timer: { fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--muted)', letterSpacing: '.1em' }, connBadge: { fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '.12em', textTransform: 'uppercase', padding: '3px 9px', borderRadius: 20, border: '1px solid var(--border2)', color: 'var(--muted)' }, connGreen: { color: 'var(--accent)', borderColor: 'var(--accent)' }, connRed: { color: 'var(--danger)', borderColor: 'var(--danger)' }, connYellow:{ color: '#f0c060', borderColor: '#f0c06040' }, body: { flex: 1, display: 'flex', overflow: 'hidden' }, stage: { flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 48, position: 'relative', overflow: 'hidden', }, stageGlow1: { position: 'absolute', inset: 0, background: 'radial-gradient(ellipse 50% 40% at 30% 60%, rgba(200,240,96,.04) 0%, transparent 70%)', pointerEvents: 'none' }, stageGlow2: { position: 'absolute', inset: 0, background: 'radial-gradient(ellipse 40% 40% at 70% 40%, rgba(96,160,240,.03) 0%, transparent 70%)', pointerEvents: 'none' }, participantCol: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, position: 'relative', zIndex: 1 }, avatarWrap: { position: 'relative', width: 120, height: 120 }, videoWrap: { width: 160, height: 120 }, ring: { position: 'absolute', inset: -8, borderRadius: '50%', border: '2px solid', opacity: .6 }, ringOuter: { position: 'absolute', inset: -16, borderRadius: '50%', border: '1px solid' }, avatar: { width: '100%', height: '100%', borderRadius: '50%', border: '2px solid', background: 'var(--surface)', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', transition: 'border-color .2s', }, videoAvatar: { borderRadius: 12 }, avatarIcon: { fontSize: 32 }, selfVideo: { width: '100%', height: '100%', objectFit: 'cover' }, aiVideo: { width: '100%', height: '100%', objectFit: 'cover' }, participantName: { fontFamily: 'var(--sans)', fontSize: 13, fontWeight: 600, color: 'var(--text)' }, participantRole: { fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '.2em', textTransform: 'uppercase', color: 'var(--muted)' }, divider: { width: 1, height: 80, background: 'var(--border)' }, chatPanel: { width: 300, flexShrink: 0, borderLeft: '1px solid var(--border)', display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--surface)', }, observerBar: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', borderBottom: '1px solid var(--border)', gap: 8, flexShrink: 0, }, observerBarLeft: { display: 'flex', flexDirection: 'column', gap: 2, flex: 1, minWidth: 0 }, observerLabel: { fontFamily: 'var(--mono)', fontSize: 8, letterSpacing: '.18em', textTransform: 'uppercase', color: 'var(--muted)' }, observerUrl: { fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, copyBtn: { fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '.1em', padding: '4px 10px', borderRadius: 5, border: '1px solid var(--border2)', background: 'transparent', color: 'var(--text)', cursor: 'pointer', flexShrink: 0, transition: 'border-color .15s, color .15s' }, copyBtnDone: { color: 'var(--accent)', borderColor: 'var(--accent)' }, chatHeader: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', borderBottom: '1px solid var(--border)', flexShrink: 0 }, chatHeaderLabel: { fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '.18em', textTransform: 'uppercase', color: 'var(--muted)' }, chatCount: { fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--muted)', background: 'var(--border2)', padding: '1px 7px', borderRadius: 10 }, chatScroll: { flex: 1, overflowY: 'auto', padding: '14px', display: 'flex', flexDirection: 'column', gap: 12 }, chatEmpty: { fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--muted)', letterSpacing: '.08em', textAlign: 'center', paddingTop: 24 }, chatMsg: { display: 'flex', flexDirection: 'column', gap: 4 }, chatMsgUser: { alignItems: 'flex-end' }, chatMsgAi: { alignItems: 'flex-start' }, chatRole: { fontFamily: 'var(--mono)', fontSize: 8, letterSpacing: '.18em', textTransform: 'uppercase' }, chatBubble: { fontSize: 12, lineHeight: 1.6, padding: '8px 11px', borderRadius: 10, wordBreak: 'break-word', maxWidth: '90%' }, chatBubbleUser: { background: 'rgba(200,240,96,.07)', border: '1px solid rgba(200,240,96,.15)', color: 'var(--text)', borderBottomRightRadius: 3 }, chatBubbleAi: { background: 'var(--bg)', border: '1px solid var(--border2)', color: 'var(--text)', borderBottomLeftRadius: 3 }, statusRow: { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 28px', borderTop: '1px solid var(--border)', flexShrink: 0, background: 'var(--surface)' }, dot: { width: 6, height: 6, borderRadius: '50%', flexShrink: 0 }, statusText: { fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '.1em', color: 'var(--muted)' }, controls: { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 20, padding: '14px 28px', borderTop: '1px solid var(--border)', flexShrink: 0, }, ctrlBtn: { width: 44, height: 44, borderRadius: '50%', border: '1px solid var(--border2)', background: 'var(--surface)', color: 'var(--text)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'border-color .15s, background .15s' }, ctrlMuted: { background: 'var(--danger)', borderColor: 'var(--danger)', color: '#fff' }, ctrlLeave: { background: 'rgba(255,80,80,.1)', borderColor: 'rgba(255,80,80,.3)', color: 'var(--danger)' }, aiChip: { display: 'flex', alignItems: 'center', gap: 7, fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '.12em', textTransform: 'uppercase', color: 'var(--muted)', padding: '8px 14px', borderRadius: 20, border: '1px solid var(--border2)', background: 'var(--surface)', transition: 'border-color .2s, color .2s' }, aiChipDot: { width: 6, height: 6, borderRadius: '50%', flexShrink: 0, transition: 'background .2s' }, aiChipSpeak: { color: 'var(--ai-ring)', borderColor: 'var(--ai-ring)' }, aiChipThink: { color: '#f0c060', borderColor: '#f0c06040' }, aiChipListen: { color: 'var(--accent)', borderColor: 'rgba(200,240,96,.3)' }, }