diff --git a/src/components/CallTranscript.jsx b/src/components/CallTranscript.jsx
new file mode 100644
index 0000000..d15b74f
--- /dev/null
+++ b/src/components/CallTranscript.jsx
@@ -0,0 +1,105 @@
+import { useEffect, useRef } from 'react'
+import './Conversation.css'
+
+/**
+ * The call, turn by turn.
+ *
+ * The voice agent hands the workflow a full transcript when a call ends, and
+ * until now Engage read it, wrote three sentences of summary, and the words
+ * themselves were dropped. So a customer said "I think it's 12,000 rupees" and
+ * "lower premium", and the file held one AI's paraphrase of that.
+ *
+ * It matters more here than it would elsewhere: `consent_artefact =
+ * verbal_call` is recorded from what the agent HEARD, and it is the artefact
+ * justifying every later contact. A colleague reviewing that months later
+ * should see the customer's own words.
+ *
+ * Rendered like the WhatsApp thread on purpose — agent right, caller left —
+ * because it is the same kind of thing and should not need learning twice.
+ */
+function turnsFrom(text) {
+ if (!text || typeof text !== 'string') return []
+ const out = []
+ // "Agent: ..." / "Caller: ..." with a turn continuing until the next label.
+ // Split on the labels rather than on newlines: a single turn wraps, and
+ // splitting per line would shatter one sentence into four bubbles.
+ const re = /^(Agent|Caller|Customer|User|Assistant):\s*/i
+ for (const raw of text.split('\n')) {
+ const line = raw.replace(/\s+$/, '')
+ if (!line.trim()) continue
+ const m = line.match(re)
+ if (m) {
+ const who = m[1].toLowerCase()
+ out.push({
+ side: (who === 'agent' || who === 'assistant') ? 'us' : 'them',
+ text: line.slice(m[0].length).trim(),
+ })
+ } else if (out.length) {
+ out[out.length - 1].text += (out[out.length - 1].text ? '\n' : '') + line.trim()
+ }
+ }
+ return out.filter((t) => t.text)
+}
+
+export default function CallTranscript({ text, name, onClose }) {
+ const ref = useRef(null)
+ const restore = useRef(null)
+ const turns = turnsFrom(text)
+
+ useEffect(() => {
+ restore.current = document.activeElement
+ ref.current?.focus()
+ const onKey = (e) => { if (e.key === 'Escape') onClose() }
+ document.addEventListener('keydown', onKey)
+ const prev = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ return () => {
+ document.removeEventListener('keydown', onKey)
+ document.body.style.overflow = prev
+ if (restore.current instanceof HTMLElement) restore.current.focus()
+ }
+ }, [onClose])
+
+ return (
+
+ )) : (
+ /* A transcript that will not parse is still evidence, so it is
+ shown raw rather than replaced with "nothing to display". */
+
{text}
+ )}
+
+
+
+ Recorded by the voice agent as the call happened. This is what was said —
+ the call notes in the trail are Engage's reading of it, which is a
+ different thing.
+
+
+
+ )
+}
diff --git a/src/components/Conversation.css b/src/components/Conversation.css
index f5f2cf5..d9e55b8 100644
--- a/src/components/Conversation.css
+++ b/src/components/Conversation.css
@@ -61,3 +61,18 @@
line-height: 1.5;
color: var(--zk-grey);
}
+
+/* A transcript the parser could not split into turns is still evidence.
+ Shown raw rather than replaced with "nothing to display". */
+.conv__raw {
+ margin: 0;
+ padding: 14px 16px;
+ font: inherit;
+ font-size: 0.82rem;
+ line-height: 1.6;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ color: var(--zk-muted);
+ background: var(--zk-tint);
+ border-radius: var(--r-md);
+}
diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx
index f2ce670..4a42bd4 100644
--- a/src/components/Timeline.jsx
+++ b/src/components/Timeline.jsx
@@ -130,7 +130,7 @@ function filesIn(value) {
/** The two activities that ARE the WhatsApp thread. */
const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer'])
-export default function Timeline({ rows, onOpenAgent, onOpenChat }) {
+export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall }) {
const { client } = useZino()
// DATA_UPDATE entries are the platform writing fields, not anyone deciding
// anything. They are the bulk of a busy trail and they are hidden until asked
@@ -240,6 +240,9 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat }) {
// the person who typed. "System · Customer Reply" told the reader the
// opposite of what happened.
const isChat = CHAT_ACTS.has(r.activity_id)
+ // Log Contact is where the call lands. Offer the words themselves beside
+ // the summary, because they are not the same thing.
+ const hasCall = r.activity_id === 'zk-act-contact' && Boolean(byBase.get('call_transcript')?.value)
const stage = STAGES.find((s) => s.uid === r.execution_state)
// A self-loop — Capture Motor Risk runs inside Document Pending and settles
// back into it — is not a move, and printing "→ Document Pending" against
@@ -251,6 +254,7 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat }) {
key: r.id ?? i,
kind: isChat ? 'chat' : kind,
isChat,
+ hasCall,
agentKey: aiRole || null,
actor: aiRole ? AGENTS[aiRole].full : (r.user_name || 'System'),
what: isChat ? 'WhatsApp' : isSystem ? 'Data updated' : (r.activity_name || prettyUid(r.activity_id)),
@@ -365,6 +369,11 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat }) {
Open thread
) : null}
+ {it.hasCall && onOpenCall ? (
+
+ ) : null}
{/* What was received, named and openable. The count is stated
diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx
index 30fa34c..8c9e5ea 100644
--- a/src/screens/Lead.jsx
+++ b/src/screens/Lead.jsx
@@ -4,6 +4,7 @@ import { useZino } from '../api/provider.jsx'
import ActivityForm from '../components/ActivityForm.jsx'
import AgentCard from '../components/AgentCard.jsx'
import AgentChip from '../components/AgentChip.jsx'
+import CallTranscript from '../components/CallTranscript.jsx'
import ClampText from '../components/ClampText.jsx'
import Conversation from '../components/Conversation.jsx'
import Timeline from '../components/Timeline.jsx'
@@ -61,7 +62,7 @@ const GROUPS = [
['Source', ['lead_ref','product_line','source_channel','partner_code','partner_branch','rm_or_agent_id','consent_artefact']],
['Customer', ['customer_name','entity_name','mobile','email','pan','gstin','udyam_no']],
['Intake', ['lead_score','attribution_status','attribution_reason','dedupe_match_ref','eligibility_outcome','eligibility_reason']],
- ['Contact', ['contact_outcome','outreach_window','contact_notes']],
+ ['Contact', ['contact_outcome','outreach_window','contact_notes','call_transcript']],
// The five OCR slots led this list and were absent from it, which is how a
// lead with an RC, an expiring policy and a PAN attached showed "Documents 2"
// — the status and the notes. They were also absent from the view itself
@@ -152,6 +153,7 @@ export default function Lead() {
const [audit, setAudit] = useState(null)
const [showChat, setShowChat] = useState(false)
const [openAgent, setOpenAgent] = useState(null)
+ const [showCall, setShowCall] = useState(false)
// A clock in state rather than Date.now() in the render body. Reading the
// wall clock while rendering is impure — React may render twice and get two
// answers — and it also means the "nothing for 6 minutes" counter would only
@@ -704,6 +706,7 @@ export default function Lead() {
rows={audit}
onOpenAgent={setOpenAgent}
onOpenChat={() => setShowChat(true)}
+ onOpenCall={() => setShowCall(true)}
/>
@@ -716,6 +719,9 @@ export default function Lead() {
{openAgent ? (
setOpenAgent(null)} />
) : null}
+ {showCall ? (
+ setShowCall(false)} />
+ ) : null}
)
}