Compare commits

..

No commits in common. "e442347b6e92663e0af4a0957f604eba0cc2ae1a" and "94e5e9520ba830ef01bdb414d11fd4e47c8076f9" have entirely different histories.

4 changed files with 3 additions and 119 deletions

View File

@ -171,36 +171,3 @@ 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<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,8 +39,6 @@ export const FIELDS = {
CALL_DURATION: 'call_duration_seconds',
CALL_SUMMARY: 'call_summary',
CONTACT_NOTES: 'contact_notes',
/** Dual-channel WAV of the call, written when it ends. */
CALL_RECORDING: 'call_recording_url',
} as const
/**

View File

@ -169,7 +169,6 @@ function DetailDrawer({ row, onClose }: { row: LeadRow; onClose: () => void }) {
const summary = str(row[FIELDS.CALL_SUMMARY])
const notes = str(row[FIELDS.CONTACT_NOTES])
const transferred = str(row[FIELDS.CALL_END_REASON]) === END_REASON_HANDOVER
const recording = str(row[FIELDS.CALL_RECORDING])
return (
<div className="fixed inset-0 z-20 flex justify-end bg-slate-900/20" onClick={onClose}>
@ -206,25 +205,6 @@ function DetailDrawer({ row, onClose }: { row: LeadRow; onClose: () => void }) {
</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="Notes" body={notes} />

View File

@ -1,7 +1,7 @@
import { useState } from 'react'
import { Clock, Headphones, Inbox, PhoneForwarded, RefreshCw, Sparkles, User } from 'lucide-react'
import { Headphones, Inbox, PhoneForwarded, RefreshCw, Sparkles, User } from 'lucide-react'
import { useQuery, useZino } from '../zino-sdk'
import { fetchCallFacts, fetchCallMessages, fetchMyCalls, type CopilotSession } from '../api'
import { fetchCallMessages, fetchMyCalls, type CopilotSession } from '../api'
import { LIVE_CALL_PREFIX } from '../config'
const POLL_MS = 3000
@ -73,12 +73,7 @@ export default function MyCalls() {
/>
))}
</ul>
{activeId && (
<div className="space-y-4">
<Recording sessionId={activeId} />
<Thread sessionId={activeId} />
</div>
)}
{activeId && <Thread sessionId={activeId} />}
</div>
)}
</div>
@ -119,62 +114,6 @@ 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 }) {
const { client } = useZino()
const { data: messages = [], isLoading, error } = useQuery({