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 PlaceCall from './pages/PlaceCall'
|
||||
import CallMonitor from './pages/CallMonitor'
|
||||
import MyCalls from './pages/MyCalls'
|
||||
|
||||
export default function App() {
|
||||
const { client } = useZino()
|
||||
@ -25,6 +26,7 @@ export default function App() {
|
||||
<Route path="/" element={<Navigate to="/place-call" replace />} />
|
||||
<Route path="/place-call" element={<PlaceCall />} />
|
||||
<Route path="/monitor" element={<CallMonitor />} />
|
||||
<Route path="/my-calls" element={<MyCalls />} />
|
||||
<Route path="*" element={<Navigate to="/place-call" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
|
||||
63
src/api.ts
63
src/api.ts
@ -1,5 +1,5 @@
|
||||
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:
|
||||
//
|
||||
@ -110,3 +110,64 @@ export function isPlausiblePhone(raw: string): boolean {
|
||||
const e = toE164(raw)
|
||||
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 { 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 { BRAND } from '../config'
|
||||
|
||||
@ -55,6 +55,19 @@ export default function Layout({ children }: { children: ReactNode }) {
|
||||
<PhoneOutgoing size={16} />
|
||||
Place a call
|
||||
</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
|
||||
to="/monitor"
|
||||
className={({ isActive }) =>
|
||||
|
||||
@ -16,6 +16,16 @@ export const ACTIVITY_IDS = {
|
||||
|
||||
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. */
|
||||
export const FIELDS = {
|
||||
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