ev-sales-desk-console/src/pages/MyCalls.tsx
Yashas 94e5e9520b feat: My calls — the human sees the transcript of the call they took
Third screen, scoped to the signed-in human: the calls Meera transferred to
them, with the handover brief, the live conversation as it happens, and the
co-pilot's quiet suggestions to relay.

The scoping is the server's, not this component's. Each transferred call is a
co-pilot session OWNED BY the human it was routed to, and agents-backend
authorizes every read with `session.user_id == token.sub`. A renderer app-user
token carries the platform user id in `sub`, so someone else's call is a 403 —
no filtering happens in the browser, and none could.

Worth knowing: this talks to agents-backend (/api/agent-sessions) rather than
the app's core/view services, but with the SAME token. agents-backend validates
it against the shared secret and falls back to a DB lookup for user-service
tokens, which omit org_user_id. No new endpoint, no CORS change — it is already
on the console's base URL.

Rendering notes:
  - "[live call] …" lines are speech the AI overheard on the bridged call, from
    both sides; they render as overheard rather than as chat.
  - A lone dash is the co-pilot deliberately staying silent. Dropped, because an
    empty suggestion box reads as a bug.
  - The server only lists sessions with >= 1 user message, and a co-pilot
    session's user messages ARE the overheard lines — so a transfer where the
    listener never attached will not appear here even though the bridge worked.

tsc -b and vite build clean.
2026-08-27 10:51:59 +05:30

219 lines
6.9 KiB
TypeScript

import { useState } from 'react'
import { Headphones, Inbox, PhoneForwarded, RefreshCw, Sparkles, User } from 'lucide-react'
import { useQuery, useZino } from '../zino-sdk'
import { fetchCallMessages, fetchMyCalls, type CopilotSession } from '../api'
import { LIVE_CALL_PREFIX } from '../config'
const POLL_MS = 3000
/**
* What the human colleague sees after a call was transferred to them.
*
* Scoped to the signed-in user by the server, not by this component: each call
* is a co-pilot session OWNED BY the human it was routed to, and every read
* authorizes on `session.user_id == token.sub`. Someone else's call is a 403.
*/
export default function MyCalls() {
const { client } = useZino()
const [selectedId, setSelectedId] = useState<number | null>(null)
const { data: calls = [], isLoading, isFetching, error, refetch } = useQuery({
queryKey: ['my-calls'],
queryFn: () => fetchMyCalls(client),
refetchInterval: POLL_MS,
})
const activeId = selectedId ?? calls[0]?.id ?? null
return (
<div>
<div className="flex items-center gap-3">
<div>
<h1 className="text-xl font-semibold">My calls</h1>
<p className="mt-1 text-sm text-slate-500">
Calls Meera transferred to you. The conversation streams in live while you talk,
with quiet suggestions you can relay.
</p>
</div>
<button
onClick={() => refetch()}
className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50"
>
<RefreshCw size={13} className={isFetching ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{error && (
<p className="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
{(error as any)?.message || 'Could not load your calls'}
</p>
)}
{!isLoading && calls.length === 0 && (
<div className="mt-6 rounded-2xl border border-dashed border-slate-300 bg-white px-6 py-16 text-center">
<Inbox className="mx-auto text-slate-300" size={28} />
<p className="mt-3 text-sm font-medium text-slate-600">No calls transferred to you yet</p>
<p className="mx-auto mt-1 max-w-md text-xs text-slate-400">
When Meera hands a call to your desk, it appears here with the brief, the live
conversation, and the AI's suggestions.
</p>
</div>
)}
{calls.length > 0 && (
<div className="mt-5 grid gap-5 lg:grid-cols-[300px_1fr]">
<ul className="space-y-2">
{calls.map((c) => (
<CallRow
key={c.id}
call={c}
active={c.id === activeId}
onClick={() => setSelectedId(c.id)}
/>
))}
</ul>
{activeId && <Thread sessionId={activeId} />}
</div>
)}
</div>
)
}
function CallRow({
call,
active,
onClick,
}: {
call: CopilotSession
active: boolean
onClick: () => void
}) {
const cd = (call.collected_data ?? {}) as Record<string, unknown>
const who = String(cd.name ?? cd.lead_name ?? 'Caller')
const reason = String(cd.handover_reason ?? '')
return (
<li>
<button
onClick={onClick}
className={`w-full rounded-xl border px-3.5 py-3 text-left transition-colors ${
active
? 'border-red-200 bg-red-50'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<div className="flex items-center gap-2">
<PhoneForwarded size={13} className="shrink-0 text-red-600" />
<span className="truncate text-sm font-medium">{who}</span>
</div>
{reason && <p className="mt-1 line-clamp-2 text-xs text-slate-500">{reason}</p>}
<p className="mt-1 text-[11px] text-slate-400">{when(call.updated_at ?? call.created_at)}</p>
</button>
</li>
)
}
function Thread({ sessionId }: { sessionId: number }) {
const { client } = useZino()
const { data: messages = [], isLoading, error } = useQuery({
queryKey: ['call-thread', sessionId],
queryFn: () => fetchCallMessages(client, sessionId),
// Polls while the call is live — this is the screen the human works from.
refetchInterval: POLL_MS,
})
if (error) {
return (
<div className="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
{(error as any)?.status === 403
? 'This call was transferred to someone else.'
: (error as any)?.message || 'Could not load the conversation'}
</div>
)
}
return (
<div className="max-h-[70vh] space-y-3 overflow-y-auto rounded-2xl border border-slate-200 bg-white p-5">
{isLoading && <p className="text-sm text-slate-400">Loading…</p>}
{messages.map((m) => {
const overheard = m.content.startsWith(LIVE_CALL_PREFIX)
if (overheard) {
return (
<Bubble
key={m.id}
icon={<Headphones size={12} />}
label="Overheard on the call"
tone="slate"
text={m.content.slice(LIVE_CALL_PREFIX.length).trim()}
/>
)
}
if (m.content.startsWith('HANDOVER BRIEF')) {
return (
<Bubble
key={m.id}
icon={<User size={12} />}
label="Handover brief"
tone="amber"
text={m.content.replace(/^HANDOVER BRIEF\n?/, '')}
/>
)
}
// A lone dash is the co-pilot deliberately staying quiet — rendering it
// as an empty suggestion box would read as a bug.
if (m.content.trim() === '' || m.content.trim() === '-') return null
return (
<Bubble
key={m.id}
icon={<Sparkles size={12} />}
label="Suggested — yours to relay"
tone="indigo"
text={m.content}
/>
)
})}
</div>
)
}
function Bubble({
icon,
label,
text,
tone,
}: {
icon: React.ReactNode
label: string
text: string
tone: 'slate' | 'indigo' | 'amber'
}) {
const skin = {
slate: 'border-slate-200 bg-slate-50 text-slate-700',
indigo: 'border-indigo-200 bg-indigo-50 text-indigo-900',
amber: 'border-amber-200 bg-amber-50 text-amber-900',
}[tone]
return (
<div className={`rounded-xl border px-3.5 py-2.5 ${skin}`}>
<div className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide opacity-70">
{icon}
{label}
</div>
<p className="whitespace-pre-wrap text-sm">{text}</p>
</div>
)
}
function when(ts?: string): string {
if (!ts) return ''
const d = new Date(ts)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleString(undefined, {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})
}