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:
parent
80a85e6764
commit
94e5e9520b
@ -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>
|
||||||
|
|||||||
63
src/api.ts
63
src/api.ts
@ -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 ?? []
|
||||||
|
}
|
||||||
|
|||||||
@ -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 }) =>
|
||||||
|
|||||||
@ -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
218
src/pages/MyCalls.tsx
Normal 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',
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user