Compare commits

..

2 Commits

Author SHA1 Message Date
e442347b6e feat: play the recording on the Live monitor too
The operator watches the Live monitor and never sees My calls, so the recording
was reachable only by the colleague who took the call. Now both audiences get it,
by the route each already uses:

  operator  -> call_recording_url on the lead, via the record view
  colleague -> GET /api/agent-sessions/:id/call, authorised by owning the session

Two paths because they are looking at two different things — a lead record and a
conversation — not because the data differs.

Placed ABOVE the summary in the drawer: the summary is a claim about the call and
this is the call, so on a demo about whether the AI captured things fairly the
evidence should not sit below the fold.

preload="none" on both. The drawer opens on every row click, and a WAV of a whole
phone call is not something to fetch speculatively.

tsc -b and vite build clean.
2026-08-27 12:31:11 +05:30
0612626491 feat: play the call recording on My calls
Same treatment as the BA operator console: the audio sits with the conversation
it belongs to, so "did the AI capture what the customer actually said" is
answerable by pressing play rather than by trusting the summary above it.

Reads the new /api/agent-sessions/:id/call, which is scoped to the human who
took the call — the recording lives on the voice session, owned by the telephony
leg, and is unreachable through the ordinary session API.

preload="none" so opening the screen doesn't pull a WAV of a whole phone call
before anyone asked to hear one. A 404 is a normal answer and renders nothing:
recordings are written at call END, so a live call shows its duration and end
reason with a note instead of a dead player.

tsc -b and vite build clean.
2026-08-27 11:28:04 +05:30
4 changed files with 119 additions and 3 deletions

View File

@ -171,3 +171,36 @@ export async function fetchCallMessages(
if (Array.isArray(res)) return res if (Array.isArray(res)) return res
return res?.messages ?? [] return res?.messages ?? []
} }
export interface CallFacts {
voice_session_id: number
recording_url: string
end_reason: string
duration_seconds: number
answered: boolean
started_at?: string
ended_at?: string | null
}
/**
* The call behind a transferred session recording included.
*
* The audio lives on the VOICE session, which is owned by the telephony leg and
* not by any person, so it is unreachable through the ordinary session API. This
* endpoint bridges that: owning the co-pilot session is the statement "you were
* the colleague on this call", and it returns only that call's own facts.
*
* 404 is normal a session that isn't a handover has no call behind it, and a
* recording is written at call END, so a live call has facts but no audio yet.
*/
export async function fetchCallFacts(
client: ZinoClient,
sessionId: number,
): Promise<CallFacts | null> {
try {
return await client.request<CallFacts>('GET', `/api/agent-sessions/${sessionId}/call`)
} catch (err: any) {
if (err?.status === 404) return null
throw err
}
}

View File

@ -39,6 +39,8 @@ export const FIELDS = {
CALL_DURATION: 'call_duration_seconds', CALL_DURATION: 'call_duration_seconds',
CALL_SUMMARY: 'call_summary', CALL_SUMMARY: 'call_summary',
CONTACT_NOTES: 'contact_notes', CONTACT_NOTES: 'contact_notes',
/** Dual-channel WAV of the call, written when it ends. */
CALL_RECORDING: 'call_recording_url',
} as const } as const
/** /**

View File

@ -169,6 +169,7 @@ function DetailDrawer({ row, onClose }: { row: LeadRow; onClose: () => void }) {
const summary = str(row[FIELDS.CALL_SUMMARY]) const summary = str(row[FIELDS.CALL_SUMMARY])
const notes = str(row[FIELDS.CONTACT_NOTES]) const notes = str(row[FIELDS.CONTACT_NOTES])
const transferred = str(row[FIELDS.CALL_END_REASON]) === END_REASON_HANDOVER const transferred = str(row[FIELDS.CALL_END_REASON]) === END_REASON_HANDOVER
const recording = str(row[FIELDS.CALL_RECORDING])
return ( return (
<div className="fixed inset-0 z-20 flex justify-end bg-slate-900/20" onClick={onClose}> <div className="fixed inset-0 z-20 flex justify-end bg-slate-900/20" onClick={onClose}>
@ -205,6 +206,25 @@ function DetailDrawer({ row, onClose }: { row: LeadRow; onClose: () => void }) {
</div> </div>
)} )}
{/* The recording sits ABOVE the summary on purpose: the summary is a
claim about the conversation, and this is the conversation. On a demo
about whether the AI captured things fairly, the evidence should not
be below the fold. */}
<div className="mt-5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Recording</h3>
{recording ? (
// preload="none": a WAV of a whole call, and the drawer opens on
// every row click. Fetch it when someone actually presses play.
<audio controls preload="none" src={recording} className="mt-2 h-9 w-full" />
) : (
<p className="mt-1.5 text-sm text-slate-400">
{transferred || str(row[FIELDS.CALL_END_REASON])
? 'No recording for this call.'
: 'Available once the call ends.'}
</p>
)}
</div>
<Section title="Call summary" body={summary} empty="No summary yet — it is written when the call ends." /> <Section title="Call summary" body={summary} empty="No summary yet — it is written when the call ends." />
<Section title="Notes" body={notes} /> <Section title="Notes" body={notes} />

View File

@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Headphones, Inbox, PhoneForwarded, RefreshCw, Sparkles, User } from 'lucide-react' import { Clock, Headphones, Inbox, PhoneForwarded, RefreshCw, Sparkles, User } from 'lucide-react'
import { useQuery, useZino } from '../zino-sdk' import { useQuery, useZino } from '../zino-sdk'
import { fetchCallMessages, fetchMyCalls, type CopilotSession } from '../api' import { fetchCallFacts, fetchCallMessages, fetchMyCalls, type CopilotSession } from '../api'
import { LIVE_CALL_PREFIX } from '../config' import { LIVE_CALL_PREFIX } from '../config'
const POLL_MS = 3000 const POLL_MS = 3000
@ -73,7 +73,12 @@ export default function MyCalls() {
/> />
))} ))}
</ul> </ul>
{activeId && <Thread sessionId={activeId} />} {activeId && (
<div className="space-y-4">
<Recording sessionId={activeId} />
<Thread sessionId={activeId} />
</div>
)}
</div> </div>
)} )}
</div> </div>
@ -114,6 +119,62 @@ function CallRow({
) )
} }
/**
* The recording, and the call's own facts.
*
* This is the evidence the transcript above is a summary OF. On a demo whose
* pitch is that the AI captured the conversation faithfully, "did it?" should be
* answerable by pressing play rather than by trusting the text.
*/
function Recording({ sessionId }: { sessionId: number }) {
const { client } = useZino()
const { data, isLoading } = useQuery({
queryKey: ['call-facts', sessionId],
queryFn: () => fetchCallFacts(client, sessionId),
// Written at call end, so it arrives late on a live call. Keep asking, but
// stop once there is audio.
refetchInterval: (q: any) => (q?.state?.data?.recording_url ? false : POLL_MS * 3),
})
if (isLoading || !data) return null
return (
<div className="rounded-2xl border border-slate-200 bg-white p-5">
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Call recording
</h3>
<span className="inline-flex items-center gap-1.5 text-xs text-slate-500">
<Clock size={12} />
<span className="tabular-nums">{dur(data.duration_seconds)}</span>
</span>
{data.end_reason && (
<span className="font-mono text-[11px] text-slate-400">
{data.end_reason.replace(/-/g, ' ')}
</span>
)}
</div>
{data.recording_url ? (
// preload="none": the audio is a WAV of a whole phone call. Fetching it
// for every call in the list, before anyone asks to hear one, would pull
// megabytes nobody wanted.
<audio controls preload="none" src={data.recording_url} className="mt-3 h-9 w-full" />
) : (
<p className="mt-2 text-xs italic text-slate-400">
No recording yet it is written when the call ends.
</p>
)}
</div>
)
}
function dur(s?: number): string {
const n = Number(s ?? 0)
if (!n) return '—'
return n >= 60 ? `${Math.floor(n / 60)}m ${n % 60}s` : `${n}s`
}
function Thread({ sessionId }: { sessionId: number }) { function Thread({ sessionId }: { sessionId: number }) {
const { client } = useZino() const { client } = useZino()
const { data: messages = [], isLoading, error } = useQuery({ const { data: messages = [], isLoading, error } = useQuery({