| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import React, { useState } from 'react' |
| import { Phone, RotateCcw } from 'lucide-react' |
|
|
| export interface CallEventTranscriptLine { |
| who: 'user' | 'assistant' |
| text: string |
| } |
|
|
| export interface CallEventRowProps { |
| |
| durationSec: number |
| |
| |
| endedAt?: number |
| |
| |
| personaName?: string |
| |
| |
| transcript?: CallEventTranscriptLine[] |
| |
| onResume?: () => void |
| |
| |
| |
| defaultExpanded?: boolean |
| |
| |
| className?: string |
| } |
|
|
| function formatDuration(s: number): string { |
| const clamped = Math.max(0, Math.floor(s)) |
| if (clamped < 60) return `${clamped}s` |
| const m = Math.floor(clamped / 60) |
| const rest = clamped % 60 |
| return rest === 0 ? `${m}m` : `${m}m ${rest}s` |
| } |
|
|
| function formatTime(ms: number): string { |
| try { |
| return new Date(ms).toLocaleTimeString([], { |
| hour: 'numeric', |
| minute: '2-digit', |
| }) |
| } catch { |
| return '' |
| } |
| } |
|
|
| |
| |
| |
| function stripTranscriptMarkup(text: string): string { |
| return text |
| .replace(/!\[[^\]]*\]\([^)]+\)/g, '') |
| .replace(/\s+/g, ' ') |
| .trim() |
| } |
|
|
| const CallEventRow: React.FC<CallEventRowProps> = ({ |
| durationSec, |
| endedAt, |
| personaName, |
| transcript, |
| onResume, |
| defaultExpanded = false, |
| className, |
| }) => { |
| const [expanded, setExpanded] = useState(defaultExpanded) |
| const hasTranscript = !!(transcript && transcript.length > 0) |
| const canResume = !!onResume |
| const hasActions = hasTranscript || canResume |
|
|
| const timeLabel = endedAt ? formatTime(endedAt) : '' |
| const durLabel = formatDuration(durationSec) |
| const summary = timeLabel ? `Call · ${durLabel} · ${timeLabel}` : `Call · ${durLabel}` |
| const labelBtnLabel = expanded ? 'Collapse call row' : 'Expand call row' |
| const rowClickable = hasTranscript |
|
|
| return ( |
| <div |
| role="region" |
| aria-label={summary} |
| className={['group my-2', className ?? ''].join(' ')} |
| > |
| {/* Divider rule + centered label + hover action cluster */} |
| <div className="flex items-center gap-3 text-white/40 text-[12px] tracking-wide select-none"> |
| <div className="flex-1 h-px bg-white/10" /> |
| |
| <button |
| type="button" |
| onClick={() => { if (rowClickable) setExpanded((v) => !v) }} |
| disabled={!rowClickable} |
| aria-label={labelBtnLabel} |
| className={[ |
| 'flex items-center gap-1.5 whitespace-nowrap transition-colors', |
| rowClickable |
| ? 'cursor-pointer hover:text-white/70' |
| : 'cursor-default', |
| 'bg-transparent border-0 p-0 m-0 font-inherit', |
| ].join(' ')} |
| > |
| <Phone size={11} aria-hidden="true" className="opacity-70" /> |
| <span>{summary}</span> |
| </button> |
| |
| {hasActions && ( |
| <div |
| className={[ |
| 'flex items-center gap-2 text-[11px] text-white/45 whitespace-nowrap', |
| // Hidden at rest, reveals on hover of the region. |
| // Always visible when the row is expanded so the |
| // Collapse link is reachable without re-hovering. |
| expanded ? 'opacity-100' : 'opacity-0 group-hover:opacity-100', |
| 'transition-opacity duration-150', |
| ].join(' ')} |
| > |
| {hasTranscript && ( |
| <button |
| type="button" |
| onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v) }} |
| className="bg-transparent border-0 p-0 m-0 cursor-pointer hover:text-white/85 transition-colors" |
| > |
| {expanded ? 'Collapse' : 'Transcript'} |
| </button> |
| )} |
| {hasTranscript && canResume && ( |
| <span className="text-white/20" aria-hidden="true">·</span> |
| )} |
| {canResume && ( |
| <button |
| type="button" |
| onClick={(e) => { e.stopPropagation(); onResume!() }} |
| className="inline-flex items-center gap-1 bg-transparent border-0 p-0 m-0 cursor-pointer hover:text-white/85 transition-colors" |
| aria-label="Resume call" |
| > |
| <RotateCcw size={10} aria-hidden="true" className="opacity-80" /> |
| <span>Resume</span> |
| </button> |
| )} |
| </div> |
| )} |
| |
| <div className="flex-1 h-px bg-white/10" /> |
| </div> |
| |
| {/* Expanded transcript — inline, unframed */} |
| {expanded && hasTranscript && ( |
| <div className="mt-3 mb-1 space-y-1.5"> |
| {transcript!.map((line, i) => { |
| const label = line.who === 'user' ? 'You' : (personaName || 'Assistant') |
| return ( |
| <div key={i} className="flex gap-4 text-[13.5px] leading-relaxed"> |
| <div className="w-28 flex-shrink-0 text-white/35 text-[11px] uppercase tracking-wider pt-0.5"> |
| {label} |
| </div> |
| <div className="flex-1 text-white/75"> |
| {stripTranscriptMarkup(line.text)} |
| </div> |
| </div> |
| ) |
| })} |
| </div> |
| )} |
| </div> |
| ) |
| } |
|
|
| export default CallEventRow |
|
|