From 0612626491da1f1d28795cada596c2cad273f773 Mon Sep 17 00:00:00 2001 From: Yashas Date: Thu, 27 Aug 2026 11:28:04 +0530 Subject: [PATCH] feat: play the call recording on My calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/api.ts | 33 +++++++++++++++++++++ src/pages/MyCalls.tsx | 67 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/api.ts b/src/api.ts index a69133b..9093e4e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -171,3 +171,36 @@ export async function fetchCallMessages( if (Array.isArray(res)) return res 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 { + try { + return await client.request('GET', `/api/agent-sessions/${sessionId}/call`) + } catch (err: any) { + if (err?.status === 404) return null + throw err + } +} diff --git a/src/pages/MyCalls.tsx b/src/pages/MyCalls.tsx index 3cf93ea..b07af15 100644 --- a/src/pages/MyCalls.tsx +++ b/src/pages/MyCalls.tsx @@ -1,7 +1,7 @@ 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 { fetchCallMessages, fetchMyCalls, type CopilotSession } from '../api' +import { fetchCallFacts, fetchCallMessages, fetchMyCalls, type CopilotSession } from '../api' import { LIVE_CALL_PREFIX } from '../config' const POLL_MS = 3000 @@ -73,7 +73,12 @@ export default function MyCalls() { /> ))} - {activeId && } + {activeId && ( +
+ + +
+ )} )} @@ -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 ( +
+
+

+ Call recording +

+ + + {dur(data.duration_seconds)} + + {data.end_reason && ( + + {data.end_reason.replace(/-/g, ' ')} + + )} +
+ + {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. +
+ ) +} + +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 }) { const { client } = useZino() const { data: messages = [], isLoading, error } = useQuery({