/* global React, Crest, AppShell, Tag, Eyebrow, Hairline, useFoyerData, foyerApi, foyerLoad, fmtRelative, fmtClock */
const SessionDetail = () => {
const { user, summaries, sessionsById, loading, error } = useFoyerData();
const [search, setSearch] = React.useState('');
const [filter, setFilter] = React.useState('all');
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState('');
// We can't change the AI's tag — keep filter local, drop the tag-swap UI.
const [activeVisitorKey, setActiveVisitorKey] = React.useState(null);
const [sending, setSending] = React.useState(false);
const [savedDraftKey, setSavedDraftKey] = React.useState(null);
// Visitor-swap crossfade. Mirrors the route-frame transition in index.html
// so clicking a different lead in the rail doesn't snap-cut the detail
// pane. Local because session-detail handles its own intra-page nav.
const [visitorPhase, setVisitorPhase] = React.useState('idle');
const [shownVisitorKey, setShownVisitorKey] = React.useState(null);
// Audio playback
const audioRef = React.useRef(null);
const [isPlaying, setIsPlaying] = React.useState(false);
const [audioTime, setAudioTime] = React.useState(0);
const [audioDuration, setAudioDuration] = React.useState(0);
// Pick which session to show. Set by the Sessions list / Dashboard via
// window.foyerActiveSessionId before navigating here. If nothing's set
// (e.g. agent typed /#/session directly), bounce to the list page so
// they can pick rather than getting dropped into something arbitrary.
const targetId = window.foyerActiveSessionId;
React.useEffect(() => {
if (!targetId) {
window.foyerGo('#/sessions');
}
}, [targetId]);
const session = targetId ? sessionsById[targetId] : null;
const result = session?.result;
const allVisitors = result?.visitors || [];
// Initial active visitor: name passed from dashboard, else first by score.
React.useEffect(() => {
if (!result || allVisitors.length === 0) return;
if (activeVisitorKey && allVisitors.some(v => keyOf(v) === activeVisitorKey)) return;
const target = window.foyerActiveVisitorName
? allVisitors.find(v => v.visitor.name === window.foyerActiveVisitorName)
: null;
const initial = target || allVisitors.slice().sort((a, b) =>
(b.analysis?.score || 0) - (a.analysis?.score || 0)
)[0];
setActiveVisitorKey(keyOf(initial));
setShownVisitorKey(keyOf(initial));
setDraft(initial?.analysis?.followUpDraft || initial?.analysis?.follow_up_draft || '');
}, [result]);
// Crossfade when activeVisitorKey changes: fade out the current detail
// pane, swap to the new visitor, fade back in. ~380ms total.
React.useEffect(() => {
if (!activeVisitorKey) return;
if (activeVisitorKey === shownVisitorKey) return;
setVisitorPhase('out');
const t1 = setTimeout(() => {
setShownVisitorKey(activeVisitorKey);
requestAnimationFrame(() => setVisitorPhase('in'));
}, 180);
return () => clearTimeout(t1);
}, [activeVisitorKey]);
React.useEffect(() => {
if (visitorPhase !== 'in') return;
const t = setTimeout(() => setVisitorPhase('idle'), 220);
return () => clearTimeout(t);
}, [visitorPhase]);
// Search / tag-filter narrows the left rail.
const filteredVisitors = allVisitors.filter(v => {
const t = (v.analysis?.tag || '').toLowerCase();
if (filter !== 'all' && t !== filter) return false;
if (search && !v.visitor.name.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
// For the left rail we want immediate feedback (highlight tracks the
// clicked row right away). For the detail pane we render `shown` so the
// fade-out shows the previous visitor's content until the swap.
const v = allVisitors.find(x => keyOf(x) === shownVisitorKey)
|| allVisitors.find(x => keyOf(x) === activeVisitorKey);
if (loading) {
return LOADING SESSION…;
}
if (error) {
return Couldn't load: {error};
}
if (!targetId) {
// Effect above is bouncing to /#/sessions — render an empty placeholder
// so we don't flash the previous content while the route changes.
return OPENING SESSIONS…;
}
if (!session || !result) {
return This session is still processing — pull up the list and try again in a moment.;
}
if (!v) {
return This session has no guests detected.;
}
const tag = (v.analysis?.tag || '').toLowerCase();
const followUpDraft = v.analysis?.followUpDraft || v.analysis?.follow_up_draft || '';
const utterances = result.utterances || [];
const visitorSpeaker = v.visitor.speaker;
const agentSpeaker = result.agent_speaker;
const visitorTurns = utterances.filter(u =>
u.speaker === visitorSpeaker || u.speaker === agentSpeaker
);
return (
{/* "Back to all sessions" + lead list (left rail of the inner pane) */}
{/* LEAD LIST */}
Session
{session.address || 'Untitled session'}
{new Date(session.created_at).toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })}
{allVisitors.length} GUEST{allVisitors.length === 1 ? '' : 'S'} · {(session.kind || 'recorded').toUpperCase()}
{/* search */}
setSearch(e.target.value)}
placeholder="Search guests…"
style={{
width: '100%', background: 'transparent', border: 0, borderBottom: '1px solid var(--hairline)',
padding: '10px 0', color: 'var(--cream)', fontSize: 13, fontFamily: 'var(--sans)',
outline: 'none',
}}
/>
{/* filter chips */}
{['all', 'buyer', 'seller', 'browser'].map(t => (
))}
{filteredVisitors.map(vis => {
const isActive = keyOf(vis) === activeVisitorKey;
const visTag = (vis.analysis?.tag || '').toLowerCase();
const visDraft = vis.analysis?.followUpDraft || vis.analysis?.follow_up_draft || '';
return (
{ setActiveVisitorKey(keyOf(vis)); setDraft(visDraft); setEditing(false); }}
style={{
padding: '16px 28px',
borderLeft: isActive ? '2px solid var(--gold)' : '2px solid transparent',
background: isActive ? 'var(--gold-soft)' : 'transparent',
borderBottom: '1px solid var(--hairline)',
borderRadius: 0,
margin: 0,
}}>
{vis.visitor.name}
{vis.analysis?.score ?? '—'}
{(vis.analysis?.tag || '').toUpperCase()} · SPOKE {vis.analysis?.wordsSpoken ?? vis.analysis?.words_spoken ?? 0}W
{vis.analysis?.summary}
);
})}
{/* DETAIL PANE */}
{session.kind !== 'manual' && (
)}
{/* header */}
{(v.analysis?.tag || '').toUpperCase()} · {leadStateLabel(v.lead_state)}
{v.visitor.name}
{(v.visitor.email || '—').toUpperCase()}{v.visitor.phone ? ` · ${v.visitor.phone}` : ''}
{v.analysis?.score ?? '—'}/100
{/* summary */}
The read
{v.analysis?.summary}
{v.analysis?.tagReason || v.analysis?.tag_reason ? (
Why {v.analysis?.tag}: {v.analysis?.tagReason || v.analysis?.tag_reason}
) : null}
{/* signals */}
{Array.isArray(v.analysis?.signals) && v.analysis.signals.length > 0 && (
{v.analysis.signals.map((s, i) => (
Signal {String(i + 1).padStart(2, '0')}
{s}
))}
)}
{/* transcript */}
{visitorTurns.length > 0 && (
Conversation
YOU + {v.visitor.name.toUpperCase()} · {visitorTurns.length} TURNS
{visitorTurns.map((line, i) => {
const isAgent = line.speaker === agentSpeaker;
const playheadMs = audioTime * 1000;
const isActive = isPlaying
&& line.start_ms != null && line.end_ms != null
&& playheadMs >= line.start_ms && playheadMs <= line.end_ms;
return (
{
const a = audioRef.current;
if (a) {
a.currentTime = Math.max(0, (line.start_ms || 0) / 1000);
if (!isPlaying) a.play().catch(() => {});
}
}}
style={{ display: 'grid', gridTemplateColumns: '60px 1fr', gap: 16, padding: '12px 0', cursor: 'pointer' }}
>
{fmtTimestamp(line.start_ms || 0)}
{isAgent ? `${(user?.name || 'You').split(' ')[0]} (you)` : v.visitor.name}
{line.text}
);
})}
)}
{/* follow-up */}
Drafted follow-up
{leadStateLabel(v.lead_state)}
To: {v.visitor.email || '—'}
Subject: Great meeting you{session.address ? ` at ${session.address}` : ''}
{editing ? (
{/* footer actions */}
Captured {fmtRelative(session.created_at)}{session.completed_at ? ` · processed ${fmtRelative(session.completed_at)}` : ''}.
);
};
function keyOf(v) {
return (v?.visitor?.name || '') + ':' + (v?.visitor?.speaker || '');
}
function leadStateLabel(s) {
if (!s) return 'NEEDS DRAFT';
if (s.snoozed_until) {
const t = Date.parse(s.snoozed_until);
if (!Number.isNaN(t) && t > Date.now()) return `SNOOZED · ${new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }).toUpperCase()}`;
}
switch (s.status) {
case 'drafted': return 'DRAFT';
case 'sent': return s.sent_at ? `SENT · ${fmtRelative(s.sent_at)}` : 'SENT';
case 'replied': return 'REPLIED';
case 'archived': return 'ARCHIVED';
default: return (s.status || '').toUpperCase();
}
}
function fmtTimestamp(ms) {
const total = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
async function markState(v, status, sessionId) {
try {
await foyerApi.post(`/sessions/${sessionId}/visitors/state`, {
name: v.visitor.name,
speaker: v.visitor.speaker || '',
status,
});
await foyerLoad({ force: true });
} catch (e) {
alert('Could not update state: ' + (e.message || e));
}
}
async function snooze(v, days, sessionId) {
const until = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
try {
await foyerApi.post(`/sessions/${sessionId}/visitors/state`, {
name: v.visitor.name,
speaker: v.visitor.speaker || '',
status: v.lead_state?.status || 'sent',
snoozed_until: until,
});
await foyerLoad({ force: true });
} catch (e) {
alert('Could not snooze: ' + (e.message || e));
}
}
function Centered({ children }) {
return (
{children}
);
}
// Sticky audio bar pinned to the top of the detail pane. Streams from
// /sessions/{id}/audio — the same-origin