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.
This commit is contained in:
Yashas 2026-08-27 10:51:59 +05:30
parent 80a85e6764
commit 94e5e9520b
5 changed files with 306 additions and 2 deletions

View File

@ -4,6 +4,7 @@ import Layout from './components/Layout'
import Login from './pages/Login' import Login from './pages/Login'
import PlaceCall from './pages/PlaceCall' import PlaceCall from './pages/PlaceCall'
import CallMonitor from './pages/CallMonitor' import CallMonitor from './pages/CallMonitor'
import MyCalls from './pages/MyCalls'
export default function App() { export default function App() {
const { client } = useZino() const { client } = useZino()
@ -25,6 +26,7 @@ export default function App() {
<Route path="/" element={<Navigate to="/place-call" replace />} /> <Route path="/" element={<Navigate to="/place-call" replace />} />
<Route path="/place-call" element={<PlaceCall />} /> <Route path="/place-call" element={<PlaceCall />} />
<Route path="/monitor" element={<CallMonitor />} /> <Route path="/monitor" element={<CallMonitor />} />
<Route path="/my-calls" element={<MyCalls />} />
<Route path="*" element={<Navigate to="/place-call" replace />} /> <Route path="*" element={<Navigate to="/place-call" replace />} />
</Routes> </Routes>
</Layout> </Layout>

View File

@ -1,5 +1,5 @@
import type { ZinoClient } from './zino-sdk' import type { ZinoClient } from './zino-sdk'
import { APP_ID, RECORD_VIEW_ID, WORKFLOW_UUID, ACTIVITY_IDS } from './config' import { APP_ID, RECORD_VIEW_ID, WORKFLOW_UUID, ACTIVITY_IDS, COPILOT_AGENT_ID } from './config'
// Why this file exists rather than using the SDK's WorkflowService/ViewService: // Why this file exists rather than using the SDK's WorkflowService/ViewService:
// //
@ -110,3 +110,64 @@ export function isPlausiblePhone(raw: string): boolean {
const e = toE164(raw) const e = toE164(raw)
return /^\+\d{10,15}$/.test(e) return /^\+\d{10,15}$/.test(e)
} }
// --- the human agent's own transferred calls --------------------------------
//
// These hit agents-backend (dev.getzino.in/api/agent-sessions), NOT the app's
// core/view services — but with the SAME app-user token. agents-backend
// validates it against the shared JWT secret and falls back to a DB lookup for
// user-service tokens, which omit org_user_id.
//
// Authorization is `session.user_id == token.sub`, and a renderer token carries
// the platform user id in `sub`. So a human sees exactly the calls that were
// transferred to them: no filtering happens here, and none is possible from the
// browser — someone else's call is a 403 from the server.
export interface CopilotSession {
id: number
agent_id: number
status?: string
title?: string
summary?: string
created_at?: string
updated_at?: string
related_session_id?: number | null
collected_data?: Record<string, unknown> | null
}
export interface CopilotMessage {
id: number
session_id: number
role: string
content: string
created_at?: string
}
/**
* Calls transferred to the signed-in human, newest first.
*
* Note the server only lists sessions that have at least one `user` message. A
* co-pilot session's `user` messages are the overheard "[live call]" lines, so a
* transfer where the silent listener never attached will NOT appear here even
* though the bridge itself succeeded.
*/
export async function fetchMyCalls(client: ZinoClient): Promise<CopilotSession[]> {
const res = await client.request<CopilotSession[] | null>(
'GET',
`/api/agent-sessions?agent_id=${COPILOT_AGENT_ID}`,
)
return Array.isArray(res) ? res : []
}
export async function fetchCallMessages(
client: ZinoClient,
sessionId: number,
): Promise<CopilotMessage[]> {
const res = await client.request<CopilotMessage[] | { messages?: CopilotMessage[] } | null>(
'GET',
`/api/agent-sessions/${sessionId}/messages`,
)
if (Array.isArray(res)) return res
return res?.messages ?? []
}

View File

@ -1,6 +1,6 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { LogOut, PhoneOutgoing, Radio, Zap } from 'lucide-react' import { Headphones, LogOut, PhoneOutgoing, Radio, Zap } from 'lucide-react'
import { useZino } from '../zino-sdk' import { useZino } from '../zino-sdk'
import { BRAND } from '../config' import { BRAND } from '../config'
@ -55,6 +55,19 @@ export default function Layout({ children }: { children: ReactNode }) {
<PhoneOutgoing size={16} /> <PhoneOutgoing size={16} />
Place a call Place a call
</NavLink> </NavLink>
<NavLink
to="/my-calls"
className={({ isActive }) =>
`${navItem} ${
isActive
? 'bg-red-50 text-red-700'
: 'text-slate-600 hover:bg-slate-100'
}`
}
>
<Headphones size={16} />
My calls
</NavLink>
<NavLink <NavLink
to="/monitor" to="/monitor"
className={({ isActive }) => className={({ isActive }) =>

View File

@ -16,6 +16,16 @@ export const ACTIVITY_IDS = {
export const RECORD_VIEW_ID = 'rv-verta-leads' export const RECORD_VIEW_ID = 'rv-verta-leads'
/**
* The interactive agent that acts as the human's co-pilot after a transfer.
* Each transferred call becomes a session on this agent, OWNED BY the human it
* was routed to which is what scopes "My calls" to the signed-in user.
*/
export const COPILOT_AGENT_ID = 29420
/** Prefix the listener leg puts on speech it overheard during the bridged call. */
export const LIVE_CALL_PREFIX = '[live call]'
/** Field keys as the record view returns them. */ /** Field keys as the record view returns them. */
export const FIELDS = { export const FIELDS = {
LEAD_NAME: 'lead_name', LEAD_NAME: 'lead_name',

218
src/pages/MyCalls.tsx Normal file
View File

@ -0,0 +1,218 @@
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',
})
}