From 94e5e9520ba830ef01bdb414d11fd4e47c8076f9 Mon Sep 17 00:00:00 2001 From: Yashas Date: Thu, 27 Aug 2026 10:51:59 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20My=20calls=20=E2=80=94=20the=20human=20?= =?UTF-8?q?sees=20the=20transcript=20of=20the=20call=20they=20took?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/App.tsx | 2 + src/api.ts | 63 ++++++++++- src/components/Layout.tsx | 15 ++- src/config.ts | 10 ++ src/pages/MyCalls.tsx | 218 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 src/pages/MyCalls.tsx diff --git a/src/App.tsx b/src/App.tsx index 6b12c60..e0a8393 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/src/api.ts b/src/api.ts index dc5823c..a69133b 100644 --- a/src/api.ts +++ b/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 | 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 { + const res = await client.request( + 'GET', + `/api/agent-sessions?agent_id=${COPILOT_AGENT_ID}`, + ) + return Array.isArray(res) ? res : [] +} + +export async function fetchCallMessages( + client: ZinoClient, + sessionId: number, +): Promise { + const res = await client.request( + 'GET', + `/api/agent-sessions/${sessionId}/messages`, + ) + if (Array.isArray(res)) return res + return res?.messages ?? [] +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index fd31769..c583755 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -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 }) { Place a call + + `${navItem} ${ + isActive + ? 'bg-red-50 text-red-700' + : 'text-slate-600 hover:bg-slate-100' + }` + } + > + + My calls + diff --git a/src/config.ts b/src/config.ts index 9797566..d0c4dda 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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', diff --git a/src/pages/MyCalls.tsx b/src/pages/MyCalls.tsx new file mode 100644 index 0000000..3cf93ea --- /dev/null +++ b/src/pages/MyCalls.tsx @@ -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(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 ( +
+
+
+

My calls

+

+ Calls Meera transferred to you. The conversation streams in live while you talk, + with quiet suggestions you can relay. +

+
+ +
+ + {error && ( +

+ {(error as any)?.message || 'Could not load your calls'} +

+ )} + + {!isLoading && calls.length === 0 && ( +
+ +

No calls transferred to you yet

+

+ When Meera hands a call to your desk, it appears here — with the brief, the live + conversation, and the AI's suggestions. +

+
+ )} + + {calls.length > 0 && ( +
+
    + {calls.map((c) => ( + setSelectedId(c.id)} + /> + ))} +
+ {activeId && } +
+ )} +
+ ) +} + +function CallRow({ + call, + active, + onClick, +}: { + call: CopilotSession + active: boolean + onClick: () => void +}) { + const cd = (call.collected_data ?? {}) as Record + const who = String(cd.name ?? cd.lead_name ?? 'Caller') + const reason = String(cd.handover_reason ?? '') + + return ( +
  • + +
  • + ) +} + +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 ( +
    + {(error as any)?.status === 403 + ? 'This call was transferred to someone else.' + : (error as any)?.message || 'Could not load the conversation'} +
    + ) + } + + return ( +
    + {isLoading &&

    Loading…

    } + {messages.map((m) => { + const overheard = m.content.startsWith(LIVE_CALL_PREFIX) + if (overheard) { + return ( + } + label="Overheard on the call" + tone="slate" + text={m.content.slice(LIVE_CALL_PREFIX.length).trim()} + /> + ) + } + if (m.content.startsWith('HANDOVER BRIEF')) { + return ( + } + 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 ( + } + label="Suggested — yours to relay" + tone="indigo" + text={m.content} + /> + ) + })} +
    + ) +} + +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 ( +
    +
    + {icon} + {label} +
    +

    {text}

    +
    + ) +} + +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', + }) +}