frontendt / src /components /WatchRoom.jsx
ayushsinghal1510's picture
Fixed
d867e05
Raw
History Blame Contribute Delete
8.87 kB
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { WEBHOOK_URL } from '../lib/webrtc'
export default function WatchRoom({ sessionId }) {
const navigate = useNavigate()
const chatEndRef = useRef(null)
const [messages, setMessages] = useState([])
const [connected, setConnected] = useState(false)
const [copied, setCopied] = useState(false)
const watchUrl = `${window.location.origin}${window.location.pathname}#/watch/${sessionId}`
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
useEffect(() => {
const es = new EventSource(`${WEBHOOK_URL}/events?session=${sessionId}`)
es.onopen = () => setConnected(true)
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(/<turn-visual-context>[\s\S]*?<\/turn-visual-context>/g, '')
.replace(/<non-verbal-elements>[\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) setMessages(prev => [...prev, ...msgs])
} catch {}
}
es.onerror = () => setConnected(false)
return () => es.close()
}, [sessionId])
function copyLink() {
navigator.clipboard.writeText(watchUrl).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<div style={s.root}>
{/* sidebar */}
<aside style={s.sidebar}>
<div style={s.sideTop}>
<button style={s.back} onClick={() => navigate('/')}>← home</button>
<p style={s.sideLabel}>watching session</p>
<p style={s.sessionId}>{sessionId}</p>
<div style={{ ...s.dot, background: connected ? 'var(--accent)' : 'var(--muted)', animation: connected ? 'blink .8s step-end infinite' : 'none' }} />
<p style={s.connLabel}>{connected ? 'live' : 'waiting…'}</p>
</div>
<div style={s.sideDivider} />
<div style={s.sideBottom}>
<p style={s.shareSectionLabel}>Share this view</p>
<p style={s.shareUrl}>{watchUrl}</p>
<button
style={{ ...s.copyBtn, ...(copied ? s.copyBtnDone : {}) }}
onClick={copyLink}
>
{copied ? 'Copied!' : 'Copy link'}
</button>
</div>
</aside>
{/* main chat area */}
<main style={s.main}>
<header style={s.header}>
<span style={s.headerLabel}>Live Transcript</span>
{messages.length > 0 && (
<span style={s.msgCount}>{messages.length} messages</span>
)}
</header>
<div style={s.chatScroll}>
{messages.length === 0 ? (
<div style={s.emptyState}>
<span style={s.emptyIcon}></span>
<p style={s.emptyTitle}>Waiting for the interview to begin</p>
<p style={s.emptyHint}>Messages will appear here in real time as the conversation unfolds.</p>
</div>
) : (
messages.map((msg, i) => (
<div key={i} style={{ ...s.msgRow, ...(msg.role === 'user' ? s.msgRowUser : s.msgRowAi) }}>
<span style={{ ...s.role, color: msg.role === 'user' ? 'var(--user-ring)' : 'var(--ai-ring)' }}>
{msg.role === 'user' ? 'Candidate' : 'Interviewer'}
</span>
<div style={{ ...s.bubble, ...(msg.role === 'user' ? s.bubbleUser : s.bubbleAi) }}>
{msg.text}
</div>
</div>
))
)}
<div ref={chatEndRef} />
</div>
</main>
</div>
)
}
const s = {
root: {
height: '100vh',
display: 'flex',
flexDirection: 'row',
overflow: 'hidden',
},
sidebar: {
width: 220,
flexShrink: 0,
borderRight: '1px solid var(--border)',
background: 'var(--surface)',
display: 'flex',
flexDirection: 'column',
padding: '24px 18px',
gap: 0,
},
sideTop: {
display: 'flex',
flexDirection: 'column',
gap: 6,
},
back: {
alignSelf: 'flex-start',
fontFamily: 'var(--mono)',
fontSize: 10,
color: 'var(--muted)',
background: 'none',
border: 'none',
cursor: 'pointer',
letterSpacing: '.08em',
padding: 0,
marginBottom: 20,
},
sideLabel: {
fontFamily: 'var(--mono)',
fontSize: 8,
letterSpacing: '.22em',
textTransform: 'uppercase',
color: 'var(--muted)',
},
sessionId: {
fontFamily: 'var(--mono)',
fontSize: 10,
color: 'var(--text)',
wordBreak: 'break-all',
lineHeight: 1.5,
marginBottom: 8,
},
dot: {
width: 6,
height: 6,
borderRadius: '50%',
flexShrink: 0,
},
connLabel: {
fontFamily: 'var(--mono)',
fontSize: 9,
letterSpacing: '.18em',
textTransform: 'uppercase',
color: 'var(--muted)',
marginTop: -4,
},
sideDivider: {
height: 1,
background: 'var(--border)',
margin: '20px 0',
},
sideBottom: {
display: 'flex',
flexDirection: 'column',
gap: 8,
},
shareSectionLabel: {
fontFamily: 'var(--mono)',
fontSize: 8,
letterSpacing: '.22em',
textTransform: 'uppercase',
color: 'var(--muted)',
marginBottom: 2,
},
shareUrl: {
fontFamily: 'var(--mono)',
fontSize: 9,
color: 'var(--muted)',
wordBreak: 'break-all',
lineHeight: 1.5,
},
copyBtn: {
fontFamily: 'var(--mono)',
fontSize: 9,
letterSpacing: '.12em',
padding: '7px 0',
borderRadius: 6,
border: '1px solid var(--border2)',
background: 'transparent',
color: 'var(--text)',
cursor: 'pointer',
width: '100%',
textAlign: 'center',
transition: 'border-color .15s, color .15s',
marginTop: 4,
},
copyBtnDone: {
color: 'var(--accent)',
borderColor: 'var(--accent)',
},
main: {
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
},
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 28px',
height: 56,
borderBottom: '1px solid var(--border)',
flexShrink: 0,
},
headerLabel: {
fontFamily: 'var(--mono)',
fontSize: 11,
letterSpacing: '.18em',
textTransform: 'uppercase',
color: 'var(--muted)',
},
msgCount: {
fontFamily: 'var(--mono)',
fontSize: 10,
color: 'var(--muted)',
background: 'var(--border2)',
padding: '2px 8px',
borderRadius: 10,
},
chatScroll: {
flex: 1,
overflowY: 'auto',
padding: '24px 28px',
display: 'flex',
flexDirection: 'column',
gap: 16,
},
emptyState: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
flex: 1,
paddingTop: 80,
},
emptyIcon: {
fontSize: 32,
color: 'var(--muted)',
opacity: .4,
},
emptyTitle: {
fontFamily: 'var(--sans)',
fontSize: 15,
fontWeight: 600,
color: 'var(--text)',
opacity: .6,
},
emptyHint: {
fontFamily: 'var(--mono)',
fontSize: 11,
color: 'var(--muted)',
textAlign: 'center',
maxWidth: 360,
lineHeight: 1.6,
letterSpacing: '.06em',
},
msgRow: {
display: 'flex',
flexDirection: 'column',
gap: 5,
maxWidth: 640,
},
msgRowUser: { alignSelf: 'flex-end', alignItems: 'flex-end' },
msgRowAi: { alignSelf: 'flex-start', alignItems: 'flex-start' },
role: {
fontFamily: 'var(--mono)',
fontSize: 8,
letterSpacing: '.2em',
textTransform: 'uppercase',
},
bubble: {
fontSize: 13,
lineHeight: 1.6,
padding: '10px 14px',
borderRadius: 12,
wordBreak: 'break-word',
},
bubbleUser: {
background: 'rgba(200,240,96,.08)',
border: '1px solid rgba(200,240,96,.18)',
color: 'var(--text)',
borderBottomRightRadius: 3,
},
bubbleAi: {
background: 'var(--surface)',
border: '1px solid var(--border2)',
color: 'var(--text)',
borderBottomLeftRadius: 3,
},
}