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.
219 lines
6.9 KiB
TypeScript
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',
|
|
})
|
|
}
|