console: the WhatsApp exchange is one entry, and it opens the whole thread

A four-message exchange about a premium took seven entries in the trail —
"WhatsApp", "WhatsApp", "WhatsApp" — each with its own heading, actor, quote
box and Open thread link, so the conversation occupied more of the story than
the entire underwriting chain. The turns were being grouped per speaker, which
collapses nothing when the customer and Engage are alternating.

An exchange is now one entry: who spoke, how many messages, the last two of
them, and the whole thread behind a click on the card. It carries no single
actor — that was the trap the per-speaker rule was there to avoid, because
collapsing turns while keeping the first speaker's name printed the customer's
words under Engage's chip. Here both sides are named on their own message and
neither owns the entry.

ONE ENTRY PER BURST, not one for the whole thread. The quote objection and the
payment exchange are the same conversation resumed, but they are twenty minutes
and four workflow steps apart; folding them together would date the payment
messages to the moment of the objection and put them above the underwriting
that actually preceded them. Saying what happened in what order is the trail's
one job. So each burst sits where it happened, the later ones marked
"continued" and saying what ran in between, and every one of them opens the
same complete thread.

The thread itself is now built once, in api/thread.js, and shared:

  - It picks up the two sends that are NOT chat activities. Request Premium
    and the issuance step compose their message inside a trigger and write it
    to customer_answer beside their own work (98/99/100), so those messages
    belonged to the conversation and appeared nowhere in the trail. The step
    now says "Sent 1 message on WhatsApp" and links into the thread rather
    than quoting text the exchange below it already shows.
  - The dialog groups the bursts with a divider that says how long the thread
    was quiet and which steps ran meanwhile. Read flat, a reply about the
    premium appeared to answer the payment link that followed it.
  - Every bubble is named, both directions. It stamped only the time, so a
    thread read back months later could not say which of our replies a person
    wrote and which Engage did.
  - Opened from an exchange it opens AT that exchange, marked briefly; opened
    from the header it opens at the newest message, which is where a chat is
    read from.
  - The count on the lead header comes off the thread. Scanning the audit rows
    counted the platform's own repeats — one submission is recorded up to three
    times — so the badge claimed nine messages over a six-message conversation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-10 13:17:51 +05:30
parent 7c328d2b88
commit 708cf4bc40
6 changed files with 650 additions and 185 deletions

216
src/api/thread.js Normal file
View File

@ -0,0 +1,216 @@
import { AGENTS } from './agents.js'
import { baseFieldId } from './config.js'
/**
* THE WHATSAPP THREAD, BUILT ONCE.
*
* The conversation was being reconstructed twice the timeline read it per
* audit row, the dialog read it per field and the two disagreed. The trail
* printed one entry per turn, so a four-message exchange took more of the page
* than the entire underwriting chain, and a message the workflow recorded from
* inside a trigger (the payment request, the issuance note) appeared in the
* dialog and nowhere in the trail. One builder, two readers, one answer.
*
* WHY A THREAD IS NOT A LIST OF ROWS. The audit trail records activities; the
* conversation is a thing that persists ACROSS them. Three facts make it awkward
* and they are all handled here rather than in each screen's own guess:
*
* 1. The customer's turns and ours are separate activities `customer-reply`
* is performed by the channel, `answer-customer` by Engage so a turn is
* a FIELD on a row, not a row.
* 2. Two of our sends are not chat activities at all. Request Premium and the
* issuance step compose their message inside a trigger and write it to
* `customer_answer` alongside their own work (see 98/99/100 in the seed
* SQL). They belong in the thread and stay on their own step in the trail.
* 3. One submission is recorded up to three times the trigger commit, its
* repeat, and the settle so the same sentence arrives three times.
*
* `episodes` is what makes this renderable as a timeline entry. The thread is
* one conversation, but it happens in bursts: the customer argues about the
* quote, then KYC and underwriting run for twenty minutes, then the premium
* request reopens the same thread. An episode is one burst. Collapsing the
* whole thread into a single entry would date the payment exchange to the
* moment of the quote objection and destroy the chronology the trail exists
* for; leaving every turn as its own entry is what it did before. A burst is
* the unit that is both honest and short.
*/
/** The two activities that ARE the thread — one turn each, either direction. */
export const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer'])
const INBOUND = 'customer_reply'
const OUTBOUND = 'customer_answer'
/**
* The fields the thread OWNS. A step that writes one is not asked to print it
* as well the message belongs to the conversation, and quoting it on the step
* too is how the payment request came to say the same sentence twice.
*/
export const THREAD_FIELDS = new Set([INBOUND, OUTBOUND])
/** Bookkeeping rows. They interrupt nothing: the platform writing a field is
* not a step that broke off a conversation. */
const SYSTEM_ACT = 'DATA_UPDATE'
/**
* Last resort for a step whose activity_name did not come back without it the
* trail prints a raw slug like "zk-act-doc-reminder" in the middle of an
* otherwise readable story. A guess, but one that reads as English.
*/
export function prettyUid(uid) {
if (!uid) return 'Step'
return String(uid)
.replace(/^zk-act-/, '')
.split('-')
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
/** What to call a step. The server's own name first; the slug, tidied, after. */
export function stepName(row) {
return row?.activity_name || prettyUid(row?.activity_id)
}
/**
* `fields[]` is the platform's typed rendering of a submission; `data` is the
* raw fallback for a row the workflow could not resolve.
*/
function fieldsOf(row) {
return Array.isArray(row.fields) && row.fields.length
? row.fields
: Object.entries(row.data || {}).map(([k, v]) => ({ field_id: k, value: v }))
}
/** Who sent an outbound turn. Usually Engage; sometimes a person answering by
* hand, which must not be attributed to the machine. */
function senderOf(row) {
const key = (row.user_roles || []).find((r) => AGENTS[r]) || null
return { agentKey: key, by: key ? AGENTS[key].short : (row.user_name || 'Zurich Kotak') }
}
/** The turns carried by one audit row, in field order. */
function turnsInRow(row) {
const out = []
for (const f of fieldsOf(row)) {
const base = baseFieldId(f.field_id)
if (!THREAD_FIELDS.has(base)) continue
const text = typeof f.value === 'string' ? f.value.trim() : ''
if (!text) continue
out.push({ side: base === INBOUND ? 'them' : 'us', text })
}
return out
}
/**
* The thread, its bursts, and which audit row carries what.
*
* Returns:
* turns every message, oldest first
* episodes contiguous bursts, each with the steps that preceded it
* byRow row id -> { episodeId, sent, received }, so a step that sent a
* message can link into the thread instead of quoting it
*/
export function buildThread(rows) {
const empty = { turns: [], episodes: [], byRow: new Map() }
if (!Array.isArray(rows) || !rows.length) return empty
// Sorted on (timestamp, id). One submission writes three rows in the same
// second, so a timestamp alone leaves their order to the sort's stability and
// puts a reply before the message it answers whenever the two land together.
const ordered = [...rows].sort((a, b) => {
const t = String(a.created_at).localeCompare(String(b.created_at))
return t !== 0 ? t : (Number(a.id) || 0) - (Number(b.id) || 0)
})
const turns = []
const episodes = []
const byRow = new Map()
let current = null
// The steps seen since the last message, and whether any of them broke off
// the conversation. `pending` accumulates even before the first message so
// that the first episode can drop it: there is nothing to bridge yet.
let pending = []
let broke = false
for (const row of ordered) {
const mine = turnsInRow(row)
if (!mine.length) {
// A chat activity that recorded no text is not a step that interrupted
// anything — it is an empty row. Bookkeeping is not a step either.
if (row.activity_id === SYSTEM_ACT || CHAT_ACTS.has(row.activity_id)) continue
if (current) broke = true
const name = stepName(row)
if (name && !pending.includes(name)) pending.push(name)
continue
}
const who = senderOf(row)
for (const t of mine) {
const prev = turns[turns.length - 1]
// De-duped against the PREVIOUS turn only. A customer who asks the same
// thing twice because the first went unanswered has said it twice, and a
// thread that showed it once would hide exactly the impatience an
// operator needs to see. Only an immediate repeat is the platform
// recording one submission three times.
if (prev && prev.side === t.side && prev.text === t.text) continue
if (!current || broke) {
current = {
id: `ep${episodes.length + 1}`,
turns: [],
// What ran between this burst and the one before it. Empty on the
// first, which begins the conversation rather than resuming it.
after: episodes.length ? pending : [],
anchorRowId: row.id,
anchorIsChat: CHAT_ACTS.has(row.activity_id),
}
episodes.push(current)
broke = false
}
pending = []
const turn = {
key: `${row.id}-${t.side}-${turns.length}`,
side: t.side,
text: t.text,
at: row.created_at,
rowId: row.id,
episodeId: current.id,
agentKey: t.side === 'us' ? who.agentKey : null,
by: t.side === 'us' ? who.by : 'the customer',
}
current.turns.push(turn)
turns.push(turn)
const tally = byRow.get(row.id) || { episodeId: current.id, sent: 0, received: 0 }
if (t.side === 'us') tally.sent += 1
else tally.received += 1
byRow.set(row.id, tally)
}
}
for (const ep of episodes) {
ep.count = ep.turns.length
ep.from = ep.turns[0].at
ep.to = ep.turns[ep.turns.length - 1].at
// The last exchange, which is where the conversation GOT to — what an
// operator scanning the trail is actually looking for.
ep.preview = ep.turns.slice(-2)
}
return { turns, episodes, byRow }
}
/**
* "Verify KYC and Underwriting Screen", "A, B +2 more" a bridge, not a list.
* Used for the steps that interrupted a conversation and for the voices in it.
*/
export function listOf(names, max = 2) {
if (!names || !names.length) return ''
if (names.length <= max) {
return names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
}
return `${names.slice(0, max).join(', ')} +${names.length - max} more`
}

View File

@ -1,6 +1,8 @@
.conv { max-height: min(80vh, 760px); }
.conv__body {
/* The offset parent for the scroll-to-an-exchange in Conversation.jsx. */
position: relative;
overflow-y: auto;
padding: 18px 22px 8px;
display: flex;
@ -86,3 +88,48 @@
width: 100%;
height: 38px;
}
/* "7 messages · 2 exchanges" — the size of the thread, on the header. */
.conv__sub {
margin: 2px 0 0;
font-size: var(--fs-2xs);
color: var(--zk-grey);
}
/* One burst of messages. The bubbles take their left/right alignment from the
flex column they sit in, so an episode has to be one too. */
.conv__ep {
display: flex;
flex-direction: column;
gap: 10px;
border-radius: var(--r-md);
}
/* Opened from the trail, the exchange you arrived from is marked and then
the mark fades, because a permanent tint on one part of a thread reads as a
state something is in rather than as "you came from here". */
.conv__ep.is-anchor { animation: conv-found 2.4s var(--ease) forwards; }
@keyframes conv-found {
0%, 45% { box-shadow: 0 0 0 6px var(--zk-teal-tint); background: var(--zk-teal-tint); }
100% { box-shadow: 0 0 0 6px transparent; background: transparent; }
}
/* THE PAUSE, ACCOUNTED FOR. Two bursts twenty minutes and four workflow steps
apart read as one continuous exchange without this so a reply about the
premium appears to answer the payment link that followed it. */
.conv__gap {
display: flex;
align-items: center;
gap: 10px;
margin: 6px 2px;
font-size: var(--fs-3xs);
color: var(--zk-grey);
}
.conv__gap::before,
.conv__gap::after {
content: '';
flex: 1;
height: 1px;
background: var(--zk-line-soft);
}
.conv__gap span { flex: none; }

View File

@ -1,68 +1,39 @@
import { useEffect, useRef } from 'react'
import { Fragment, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { baseFieldId } from '../api/config.js'
import { buildThread, listOf } from '../api/thread.js'
import './Conversation.css'
/**
* The WhatsApp thread, read out of the audit trail.
* The WhatsApp thread, whole, scrollable, in the order it was said.
*
* The trail shows each turn as its own entry among twenty others, so reading a
* conversation meant scrolling a log and holding the order in your head. Here
* it is what it is: a thread, oldest first, theirs on the left and ours on the
* right.
* The trail's job is what happened in what order; this is the other question
* what was actually said and it is one conversation even though the workflow
* touches it from several places. Theirs on the left, ours on the right, oldest
* first, newest in view when it opens.
*
* THE THREAD IS BUILT ONCE, in api/thread.js, and shared with the timeline
* entry that opens it. It used to be reassembled here from the raw fields,
* which is how the two came to disagree about how many messages there were.
*
* WHAT THE GAPS ARE. The conversation runs in bursts: the customer argues about
* the premium, then KYC and the underwriting screen run, then the payment
* request reopens the same thread twenty minutes later. Read as one flat list
* those bursts run together and the reply to a quote appears to answer the
* payment link. So the workflow steps that happened in between are named on the
* divider, and the thread reads as what it is one conversation with pauses in
* it that somebody can account for.
*
* WHAT IT CAN AND CANNOT SHOW, said plainly at the foot of the panel rather
* than left for someone to discover. The console can only show what the
* workflow RECORDS, so anything sent from inside a trigger node and never
* written to a field is invisible here however certainly the customer received
* it. The acceptance confirmation used to be exactly that the panel showed
* nothing after "I confirm", which read as though we had ignored somebody
* agreeing to buy so 90 writes it into customer_answer and it appears.
*
* One send remains absent: the quote, which is a registered WhatsApp template
* rather than text we compose. The "I have your message" receipt used to be a
* second it is gone from the workflow now (97), because it put a line saying
* only "an answer is coming" between every question and its answer.
* it. Both payment-path messages used to be exactly that (98, 99); 100 writes
* them to `customer_answer` and they appear. One send remains absent: the
* quote, which is a registered WhatsApp template rather than text we compose.
*
* A thread that quietly omitted any of this would be worse than one that says
* which parts it holds.
*/
function turnsFrom(rows) {
if (!Array.isArray(rows)) return []
const out = []
// Sorted on (timestamp, id). One submission writes three rows in the same
// second the trigger commit, its repeat, and the settle so a timestamp
// alone leaves their order to the sort's stability and puts a reply before
// the message it answers whenever the two land in the same second.
const ordered = [...rows].sort((a, b) => {
const t = String(a.created_at).localeCompare(String(b.created_at))
return t !== 0 ? t : (Number(a.id) || 0) - (Number(b.id) || 0)
})
for (const r of ordered) {
const fields = Array.isArray(r.fields) && r.fields.length
? r.fields
: Object.entries(r.data || {}).map(([k, v]) => ({ field_id: k, value: v }))
for (const f of fields) {
const base = baseFieldId(f.field_id)
const v = typeof f.value === 'string' ? f.value.trim() : ''
if (!v) continue
if (base === 'customer_reply') out.push({ side: 'them', text: v, at: r.created_at, key: r.id + '-in' })
if (base === 'customer_answer') out.push({ side: 'us', text: v, at: r.created_at, key: r.id + '-out' })
}
}
// One submission is recorded three times the trigger commit, its repeat,
// and the settle so the same sentence would print three times.
//
// De-duped against the PREVIOUS turn only, not against the whole thread. A
// customer who asks the same thing twice because the first went unanswered
// has said it twice, and a thread that silently showed it once would hide
// exactly the impatience an operator needs to see. Only an immediate repeat
// of the same side and the same words is the platform talking to itself.
return out.filter((t, i) => {
const prev = out[i - 1]
return !(prev && prev.side === t.side && prev.text === t.text)
})
}
function at(ts) {
const d = new Date(ts)
@ -70,10 +41,26 @@ function at(ts) {
return d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
}
export default function Conversation({ rows, name, onClose }) {
/** How long the thread was quiet, and what the workflow did meanwhile. */
function gapOf(prev, ep) {
const a = Date.parse(prev.to)
const b = Date.parse(ep.from)
const mins = isNaN(a) || isNaN(b) ? null : Math.round((b - a) / 60000)
const howLong = mins == null || mins < 1 ? 'Moments later'
: mins === 1 ? 'A minute later'
: mins < 60 ? `${mins} minutes later`
: mins < 120 ? 'An hour later'
: mins < 1440 ? `${Math.round(mins / 60)} hours later`
: mins < 2880 ? 'The next day'
: `${Math.round(mins / 1440)} days later`
return ep.after.length ? `${howLong} — after ${listOf(ep.after)}` : howLong
}
export default function Conversation({ rows, name, anchor, onClose }) {
const ref = useRef(null)
const bodyRef = useRef(null)
const restore = useRef(null)
const turns = turnsFrom(rows)
const { turns, episodes } = buildThread(rows)
useEffect(() => {
restore.current = document.activeElement
@ -89,6 +76,23 @@ export default function Conversation({ rows, name, onClose }) {
}
}, [onClose])
/**
* Opened from an exchange in the trail, it opens AT that exchange; opened
* from the lead header, it opens at the newest message, which is where a
* chat is read from.
*
* scrollTop rather than scrollIntoView: this element is inside a dialog over
* a frozen page, and scrollIntoView walks every scrollable ancestor to get
* there. `.conv__body` is the offset parent (see the CSS), so the sum is
* exactly the distance wanted.
*/
useEffect(() => {
const body = bodyRef.current
if (!body) return
const target = anchor ? body.querySelector(`[data-ep="${anchor}"]`) : null
body.scrollTop = target ? Math.max(0, target.offsetTop - 12) : body.scrollHeight
}, [anchor])
return createPortal(
<div className="prose__scrim" onClick={onClose} role="presentation">
<div
@ -101,7 +105,15 @@ export default function Conversation({ rows, name, onClose }) {
onClick={(e) => e.stopPropagation()}
>
<div className="prose__head">
<h3>WhatsApp with {name || 'the customer'}</h3>
<div>
<h3>WhatsApp with {name || 'the customer'}</h3>
{turns.length ? (
<p className="conv__sub">
{turns.length} message{turns.length === 1 ? '' : 's'}
{episodes.length > 1 ? ` · ${episodes.length} exchanges` : ''}
</p>
) : null}
</div>
<button type="button" onClick={onClose} aria-label="Close">
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
@ -110,12 +122,32 @@ export default function Conversation({ rows, name, onClose }) {
</button>
</div>
<div className="conv__body">
{turns.length ? turns.map((t) => (
<div key={t.key} className={'bub bub--' + t.side}>
<p>{t.text}</p>
<span>{at(t.at)}</span>
</div>
<div className="conv__body" ref={bodyRef}>
{episodes.length ? episodes.map((ep, i) => (
<Fragment key={ep.id}>
{i > 0 ? (
<div className="conv__gap">
<span>{gapOf(episodes[i - 1], ep)}</span>
</div>
) : null}
<div
className={'conv__ep' + (anchor === ep.id ? ' is-anchor' : '')}
data-ep={ep.id}
>
{ep.turns.map((t) => (
<div key={t.key} className={'bub bub--' + t.side}>
<p>{t.text}</p>
<span>
{/* Named on every message, both directions. The previous
version stamped only the time, so a thread read back
months later could not say which of our replies a
person wrote and which Engage did. */}
{t.side === 'them' ? (name || 'the customer') : t.by} · {at(t.at)}
</span>
</div>
))}
</div>
</Fragment>
)) : (
<p className="conv__empty">Nothing has been exchanged on WhatsApp yet.</p>
)}
@ -123,9 +155,9 @@ export default function Conversation({ rows, name, onClose }) {
<p className="conv__note">
Shows the customer&apos;s messages and everything written back to them,
including the acceptance confirmation. One automatic send is not here:
the quote, which goes out as a registered WhatsApp template rather than
text we compose.
including the acceptance confirmation, the payment request and the
issuance note. One automatic send is not here: the quote, which goes
out as a registered WhatsApp template rather than text we compose.
</p>
</div>
</div>

View File

@ -18,8 +18,6 @@
background: var(--zk-line-soft); color: var(--zk-muted);
}
.tl__disc--sys { font-size: 18px; color: var(--zk-grey); }
.tl__disc--cust { background: var(--zk-teal-tint); color: var(--zk-teal); }
.tl__disc--cust svg { width: 16px; height: 16px; }
.tl__body { min-width: 0; display: flex; flex-direction: column; }
.tl__line1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; min-height: 36px; }
@ -34,7 +32,6 @@
.tl__by { font-size: 14.5px; color: var(--zk-muted); margin-top: -2px; display: flex; align-items: center; gap: 6px; }
.tl__byname { font-weight: 600; color: var(--zk-ink); }
.tl__byname--cust { color: var(--zk-teal); }
.tl__tag { font-size: 12px; font-weight: 700; letter-spacing: .04em; color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 6px; border-radius: 4px; }
/* What was said — a quote block. */
@ -78,3 +75,55 @@
padding: 6px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-pill); background: #fff; cursor: pointer;
}
.tl__toggle:hover { background: var(--zk-tint); color: var(--zk-ink); }
/* THE WHATSAPP EXCHANGE, COLLAPSED
One entry for a burst of messages: who spoke, how many there were, the last
two of them, and the whole thread behind a click. The card IS the control
clicking anywhere in it opens the conversation, which is how every timeline
of this shape behaves and what stops the entry needing its own link row. */
.tl__disc--wa { background: var(--zk-teal-tint); color: var(--zk-teal); }
.tl__disc--wa svg { width: 17px; height: 17px; }
/* "The same thread, picked up after Verify KYC and Underwriting Screen." */
.tl__wacont { margin: 4px 0 0; font-size: var(--fs-2xs); color: var(--zk-grey); }
.tl__wa {
display: block; width: 100%; max-width: 72ch; margin-top: 10px;
padding: 12px 14px 10px; text-align: left; cursor: pointer;
font: inherit; color: inherit; background: #fff;
border: 1px solid var(--zk-line); border-radius: var(--r-md);
transition: border-color var(--t-fast);
}
.tl__wa:hover { border-color: var(--zk-blue-light); }
.tl__wa:hover .tl__wafoot { text-decoration: underline; }
.tl__wapre { display: flex; flex-direction: column; gap: 6px; }
.tl__wamsg {
max-width: 88%; padding: 7px 11px 6px;
border-radius: 12px; background: var(--zk-line-soft);
}
.tl__wamsg em {
display: block; font-style: normal; margin-bottom: 2px;
font-size: var(--fs-3xs); font-weight: 600; color: var(--zk-grey);
}
/* Two lines of each message, no more. A preview that grows with whatever
somebody typed has stopped being a preview. */
.tl__wamsg > span {
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
overflow: hidden;
font-size: 14.5px; line-height: 1.45; color: var(--zk-ink);
}
/* Theirs left, ours right the same arrangement as the thread itself, so the
preview is recognisably a piece of the conversation it opens. */
.tl__wamsg--them { align-self: flex-start; border-bottom-left-radius: 4px; }
.tl__wamsg--us {
align-self: flex-end; background: var(--zk-tint-blue); border-bottom-right-radius: 4px;
}
.tl__wamsg--us em { color: var(--zk-blue); }
.tl__wafoot {
display: flex; align-items: center; gap: 4px; margin-top: 9px;
font-size: var(--fs-xs); font-weight: 600; color: var(--zk-blue);
}
.tl__wafoot svg { width: 11px; height: 11px; }

View File

@ -3,9 +3,9 @@ import { useZino } from '../api/provider.jsx'
import AgentChip from './AgentChip.jsx'
import ClampText from './ClampText.jsx'
import ChainOfThought from './ChainOfThought.jsx'
import { AGENTS } from '../api/agents.js'
import { AGENTS, initialsOf } from '../api/agents.js'
import { APP_ID, STAGES, baseFieldId } from '../api/config.js'
import { initialsOf } from '../api/agents.js'
import { CHAT_ACTS, THREAD_FIELDS, buildThread, listOf, stepName } from '../api/thread.js'
import './Timeline.css'
/* The roster lives in api/agents.js now one short name, one colour and one
@ -54,11 +54,16 @@ const REASONING = [
['resume_note', 'Why now'],
]
/* CONVERSATION lines stay as plain quotes they are what was said to and by
the customer, not the machine's reasoning, and belong beside the thread. */
/* WHAT WAS SAID IS NO LONGER QUOTED ON THE STEP.
*
* `customer_reply` and `customer_answer` used to be rendered here, which is
* what made every WhatsApp turn its own entry in the trail: five headings, five
* actors and five quote boxes for one exchange about a premium. They belong to
* the THREAD now (api/thread.js), which owns them for both the collapsed entry
* below and the dialog. Leaving them here as well would print the payment
* request's own message on the Request Premium step and again in the exchange
* it opened. */
const CONVERSATION = [
['customer_reply', 'The customer said'],
['customer_answer', 'We replied'],
['dedupe_match_ref', 'Duplicate of'],
]
@ -69,9 +74,12 @@ const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk
/**
* Fields already shown elsewhere in the entry, so the "what it wrote" list
* does not repeat them: the narrative prose above it, the money figures below
* it, the document chips, and the platform's own bookkeeping.
* it, the thread that owns the messages, the document chips, and the platform's
* own bookkeeping.
*/
const SHOWN_ELSEWHERE = new Set([...NARRATIVE.map(([k]) => k), ...MONEY, '_system'])
const SHOWN_ELSEWHERE = new Set([
...NARRATIVE.map(([k]) => k), ...MONEY, ...THREAD_FIELDS, '_system',
])
/** A value as one short line. Long prose is already in the narrative block, so
* anything here is a field value, not a paragraph. */
@ -112,32 +120,50 @@ const DOC_LABELS = {
const FILE_TYPES = new Set(['file', 'ocr'])
function when(ts) {
if (!ts) return ''
/** The absolute stamp and the relative one, kept apart: a collapsed exchange
* spans two moments and has only one "ago". */
function absAt(ts) {
const d = new Date(ts)
if (isNaN(d)) return ''
return d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
}
function agoAt(ts) {
const d = new Date(ts)
if (isNaN(d)) return ''
const mins = Math.round((Date.now() - d.getTime()) / 60000)
const rel = mins < 1 ? 'just now'
return mins < 1 ? 'just now'
: mins < 60 ? `${mins}m ago`
: mins < 1440 ? `${Math.round(mins / 60)}h ago`
: `${Math.round(mins / 1440)}d ago`
return `${d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} · ${rel}`
}
function when(ts) {
if (!ts) return ''
const abs = absAt(ts)
return abs ? `${abs} · ${agoAt(ts)}` : ''
}
/**
* Last resort for a step whose activity_name did not come back. Without this
* the timeline the screen the whole product is demonstrated on prints a
* raw slug like "zk-act-doc-reminder" in the middle of an otherwise readable
* story. Turning it into "Doc Reminder" is a guess, but it is a guess that
* reads as English.
* An exchange happens over a stretch of time rather than at an instant, and the
* stamp has to say so dated only at its first message, an entry reads as
* though the reply visible inside it arrived before it was written.
*
* The "ago" is measured from the LAST message: how stale the conversation is,
* which is the operationally useful half.
*/
function prettyUid(uid) {
if (!uid) return 'Step'
return String(uid)
.replace(/^zk-act-/, '')
.split('-')
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
function spanAt(from, to) {
if (!from) return ''
if (!to || to === from) return when(from)
const a = new Date(from)
const b = new Date(to)
if (isNaN(a) || isNaN(b)) return when(from)
const hm = (d) => d.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })
const day = (d) => d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })
const head = a.toDateString() === b.toDateString()
? `${day(a)}, ${hm(a)} ${hm(b)}`
: `${day(a)} ${day(b)}`
return `${head} · ${agoAt(to)}`
}
/** An uploaded file, as the platform stores it. */
@ -146,15 +172,35 @@ function filesIn(value) {
return value.filter((f) => f && typeof f === 'object' && f.uuid)
}
/** The WhatsApp mark, on the disc and in the heading. */
function WaGlyph() {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 1.6a6.3 6.3 0 0 0-5.4 9.5L1.7 14.4l3.4-.9A6.3 6.3 0 1 0 8 1.6z"
fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
<path d="M5.7 5.6c.5-.1.7.1.9.5l.3.7c.1.2 0 .4-.1.5l-.3.3c-.1.1-.2.3-.1.4a3.4 3.4 0 0 0 1.6 1.6c.2.1.3 0 .4-.1l.3-.3c.2-.2.3-.2.5-.1l.8.4c.4.2.5.4.4.8-.1.5-.6.9-1.1.9-1.6 0-4-2.4-4-4 0-.5.3-1 .8-1.1z"
fill="currentColor" />
</svg>
)
}
/** Who spoke in an exchange: "Priya and Engage". Named rather than counted
* "2 participants" is a fact nobody needed. */
function voicesOf(ep, customerName) {
const who = []
if (ep.turns.some((t) => t.side === 'them')) who.push(customerName || 'the customer')
for (const t of ep.turns) {
if (t.side === 'us' && !who.includes(t.by)) who.push(t.by)
}
return listOf(who, 3)
}
/**
* `rows` is owned by the lead page and refreshed on its poll, so the trail
* keeps up with a lead that five agents are working through in three minutes.
* This component fetched them itself once on mount and never again.
*/
/** 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, onOpenCall }) {
export default function Timeline({ rows, customerName, 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
@ -164,6 +210,10 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
if (!rows) return <p className="tl__loading">Loading the timeline</p>
if (!rows.length) return <p className="tl__loading">Nothing has happened yet.</p>
// The conversation, assembled once and shared with the dialog. `episodes` are
// its bursts; `byRow` says which steps put a message on WhatsApp themselves.
const { episodes, byRow } = buildThread(rows)
// Oldest first: a timeline reads forwards.
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
@ -212,6 +262,10 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
user_name: prev.user_name || r.user_name,
user_roles: (prev.user_roles && prev.user_roles.length) ? prev.user_roles : r.user_roles,
created_at: prev.created_at,
// Every raw row folded in here. The thread is keyed on RAW ids a
// message may have been recorded on the second row of a submission
// whose first row won the merge so a lookup has to try all of them.
mergedIds: [...(prev.mergedIds || [prev.id]), r.id],
}
continue
}
@ -219,7 +273,9 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
}
let lastStage = null
const items = merged.map((r, i) => {
/** One step: what it was, who took it, what it wrote. */
const stepEntry = (r, ids) => {
const roles = r.user_roles || []
// SYSTEM FIRST. A DATA_UPDATE row inherits the roles of whoever caused it,
// so an AI's own field write matched the agent roster, was classed as its work,
@ -260,10 +316,6 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
}
}
// The channel performs these as the system, but the meaningful actor is
// 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)
@ -274,14 +326,20 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
const moved = stage && stage.uid !== lastStage
if (stage) lastStage = stage.uid
// A step that composed a WhatsApp message inside its own trigger Request
// Premium, the issuance note. The text lives in the thread; the step says
// it sent one and links there rather than quoting what is already shown.
const chat = ids.map((id) => byRow.get(id)).find(Boolean)
return {
key: r.id ?? i,
kind: isChat ? 'chat' : kind,
isChat,
key: r.id ?? `${r.activity_id}-${r.created_at}`,
kind,
hasCall,
sent: chat?.sent ?? 0,
chatEpisode: chat?.episodeId ?? null,
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)),
what: isSystem ? 'Data updated' : stepName(r),
stage: moved ? stage : null,
when: when(r.created_at),
docs,
@ -325,49 +383,59 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
.map(([base, f]) => [f.label || base.replace(/_/g, ' '), short(f)])
.filter(([, v]) => v !== null),
}
})
/**
* THE CONVERSATION IS ONE THING, NOT NINE.
*
* Every WhatsApp turn was its own entry "Customer Reply", "Customer
* Reply", "Customer Reply", "Answer the Customer" each with a heading, an
* actor and a quote box, so a four-message exchange occupied more of the
* trail than the entire underwriting chain. A resend made it worse: the same
* sentence printed four times because the customer sent it four times.
*
* A contiguous run of chat turns now collapses to one entry that says how
* many messages, shows the last of them, and opens the thread. The trail goes
* back to being a list of decisions, and the conversation goes back to being
* a conversation.
*
* Contiguous, not global: a second exchange after underwriting is a separate
* episode in this lead's story and should read as one.
*/
const grouped = []
for (const it of items) {
const last = grouped[grouped.length - 1]
// SAME SPEAKER ONLY. Collapsing every contiguous chat turn folded the
// customer's question and the AI's answer into one entry that kept the
// FIRST turn's name and the LAST turn's words so the customer's message
// printed under Engage's chip, attributing to the machine something a
// person had typed. Grouping per speaker still collapses the case this
// exists for (the same sentence resent four times) and can never
// misattribute, because every row in a run is the same voice.
if (it.isChat && last && last.isChat && last.agentKey === it.agentKey) {
last.count += 1
last.when = it.when
// Keep the newest line as the preview an operator scanning the trail
// wants where the conversation GOT to, not where it started.
if (it.conversation.length) last.conversation = it.conversation
last.docs = last.docs.concat(it.docs)
continue
}
grouped.push({ ...it, count: 1 })
}
const sysCount = grouped.filter((it) => it.kind === 'sys').length
const visible = showSys ? grouped : grouped.filter((it) => it.kind !== 'sys')
/**
* THE CONVERSATION IS ONE ENTRY, NOT NINE.
*
* Every WhatsApp turn used to be its own step in the trail "WhatsApp",
* "WhatsApp", "WhatsApp" each with a heading, an actor, a quote box and an
* Open thread link, so a four-message exchange about a premium took more of
* the story than the entire underwriting chain.
*
* An exchange is now one entry: who spoke, how many messages, the last two of
* them, and the whole thread one click away. It carries NO single actor, which
* is the trap the previous attempt fell into collapsing turns while keeping
* the first speaker's name printed the customer's words under Engage's chip.
* Here both sides are named on their own message and neither owns the entry.
*
* ONE ENTRY PER BURST, not one for the whole thread. The quote objection and
* the payment exchange are the same conversation resumed, but they are twenty
* minutes and four workflow steps apart. Folding them into a single entry
* would date the payment messages to the moment of the objection and place
* them above the underwriting that actually preceded them and saying what
* happened in what order is the trail's one job. So each burst sits where it
* happened, the later ones marked as a continuation, and every one of them
* opens the same complete thread.
*/
const entries = []
const placed = new Set()
const waEntry = (ep) => ({ key: 'wa-' + ep.id, kind: 'wa', ep })
for (const r of merged) {
const ids = r.mergedIds || [r.id]
const opens = episodes.filter((e) => !placed.has(e.id) && ids.includes(e.anchorRowId))
if (CHAT_ACTS.has(r.activity_id)) {
// The row IS a turn, not a step, so it never appears as one. Its exchange
// takes its place once, where the exchange began.
for (const e of opens) { entries.push(waEntry(e)); placed.add(e.id) }
continue
}
entries.push(stepEntry(r, ids))
// A step whose own message reopened the thread sits directly above the
// exchange it started.
for (const e of opens) { entries.push(waEntry(e)); placed.add(e.id) }
}
// A guard, not a case: an exchange whose anchor row did not survive the merge
// would otherwise vanish from the trail entirely.
for (const e of episodes) if (!placed.has(e.id)) entries.push(waEntry(e))
const sysCount = entries.filter((it) => it.kind === 'sys').length
const visible = showSys ? entries : entries.filter((it) => it.kind !== 'sys')
return (
<>
@ -379,16 +447,71 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
<ol className="tl">
{visible.map((it) => (
it.kind === 'wa' ? (
<li key={it.key} className="tl__ev tl__ev--wa">
<span className="tl__gutter" aria-hidden="true">
<span className="tl__disc tl__disc--wa"><WaGlyph /></span>
</span>
<div className="tl__body">
<div className="tl__line1">
<h4 className="tl__what tl__what--wa">
<WaGlyph />
WhatsApp
</h4>
{/* A resumed exchange says so on its own line, so nobody
reads it as a second conversation out of nowhere. */}
{it.ep.after.length ? <span className="tl__n">continued</span> : null}
<span className="tl__n">
{it.ep.count} message{it.ep.count === 1 ? '' : 's'}
</span>
<span className="tl__when">{spanAt(it.ep.from, it.ep.to)}</span>
</div>
<div className="tl__by">
<span className="tl__byname">{voicesOf(it.ep, customerName)}</span>
</div>
{it.ep.after.length ? (
<p className="tl__wacont">
The same thread, picked up after {listOf(it.ep.after)}.
</p>
) : null}
{/* The last exchange, as a thread rather than a quote block
and the whole card opens the conversation, which is how
every timeline of this shape behaves. */}
<button
type="button"
className="tl__wa"
onClick={() => onOpenChat?.(it.ep.id)}
aria-label={`Open the WhatsApp conversation — ${it.ep.count} messages`}
>
<span className="tl__wapre">
{it.ep.preview.map((t) => (
<span key={t.key} className={'tl__wamsg tl__wamsg--' + t.side}>
<em>{t.side === 'them' ? (customerName || 'the customer') : t.by}</em>
<span>{t.text}</span>
</span>
))}
</span>
<span className="tl__wafoot">
{it.ep.count > it.ep.preview.length
? `Open the conversation — all ${it.ep.count} messages`
: 'Open the conversation'}
<svg viewBox="0 0 12 12" aria-hidden="true">
<path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.6"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</button>
</div>
</li>
) : (
<li key={it.key} className={'tl__ev tl__ev--' + it.kind}>
{/* The worker, as a face in the gutter. Recognition down a column
beats reading five names to notice it was one agent throughout. */}
<span className="tl__gutter" aria-hidden="true">
{it.kind === 'sys' ? (
<span className="tl__disc tl__disc--sys">·</span>
) : it.isChat && !it.agentKey ? (
<span className="tl__disc tl__disc--cust">
<svg viewBox="0 0 16 16"><path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" /></svg>
</span>
) : it.agentKey ? (
<AgentChip agentKey={it.agentKey} size="md" showName={false} onOpen={onOpenAgent} />
) : (
@ -397,31 +520,16 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
</span>
<div className="tl__body">
<div className="tl__line1">
{it.isChat ? (
<h4 className="tl__what tl__what--wa">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 1.6a6.3 6.3 0 0 0-5.4 9.5L1.7 14.4l3.4-.9A6.3 6.3 0 1 0 8 1.6z"
fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
<path d="M5.7 5.6c.5-.1.7.1.9.5l.3.7c.1.2 0 .4-.1.5l-.3.3c-.1.1-.2.3-.1.4a3.4 3.4 0 0 0 1.6 1.6c.2.1.3 0 .4-.1l.3-.3c.2-.2.3-.2.5-.1l.8.4c.4.2.5.4.4.8-.1.5-.6.9-1.1.9-1.6 0-4-2.4-4-4 0-.5.3-1 .8-1.1z"
fill="currentColor" />
</svg>
WhatsApp
</h4>
) : (
<h4 className="tl__what">{it.what}</h4>
)}
{it.count > 1 ? <span className="tl__n">{it.count} messages</span> : null}
{it.stage && !it.isChat ? <span className="tl__to">{it.stage.name}</span> : null}
<h4 className="tl__what">{it.what}</h4>
{it.stage ? <span className="tl__to">{it.stage.name}</span> : null}
<span className="tl__when">{it.when}</span>
</div>
<div className="tl__by">
{it.kind === 'sys'
? <span className="tl__byname">System</span>
: it.isChat && !it.agentKey
? <span className="tl__byname tl__byname--cust">the customer</span>
: it.agentKey
? <><span className="tl__byname">{it.actor}</span><small className="tl__tag">AI</small></>
: <span className="tl__byname">{it.actor}</span>}
: it.agentKey
? <><span className="tl__byname">{it.actor}</span><small className="tl__tag">AI</small></>
: <span className="tl__byname">{it.actor}</span>}
</div>
{/* What was received, named and openable. The count is stated
@ -480,7 +588,6 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
))
: null}
{/* What was said to and by the customer — always a plain quote. */}
{it.conversation.map(([label, v]) => (
<div className="tl__say" key={label}>
<span className="tl__saylabel">{label}</span>
@ -500,8 +607,13 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
{it.hasCall && onOpenCall ? (
<button type="button" className="tl__link" onClick={onOpenCall}>Hear the call</button>
) : null}
{it.isChat && onOpenChat ? (
<button type="button" className="tl__link" onClick={onOpenChat}>Open thread</button>
{/* This step put something on WhatsApp itself. The message is in
the thread with the rest of the conversation, not quoted
here a second time. */}
{it.sent && onOpenChat ? (
<button type="button" className="tl__link" onClick={() => onOpenChat(it.chatEpisode)}>
{it.sent === 1 ? 'Sent 1 message on WhatsApp' : `Sent ${it.sent} messages on WhatsApp`}
</button>
) : null}
{/* Folded, because most steps write a lot and the trail has to
stay readable as a story. Open it and the step becomes an
@ -519,6 +631,7 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
</div>
</div>
</li>
)
))}
</ol>
</>

View File

@ -12,6 +12,7 @@ import StageRail from '../components/StageRail.jsx'
import Timeline from '../components/Timeline.jsx'
import { APP_ID, DOC_SLOTS, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js'
import { AGENTS } from '../api/agents.js'
import { buildThread } from '../api/thread.js'
import { actionsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
import './screens.css'
@ -208,7 +209,10 @@ export default function Lead() {
// page and the conversation view needs the same rows. One fetch, one
// poll, two readers that cannot disagree.
const [audit, setAudit] = useState(null)
const [showChat, setShowChat] = useState(false)
// The conversation dialog: null when closed, else the exchange to open at.
// `anchor` is an episode id from the trail opened from the header there is
// no particular exchange to land on, so it opens at the newest message.
const [chat, setChat] = useState(null)
const [openAgent, setOpenAgent] = useState(null)
const [showCall, setShowCall] = useState(false)
// The full record, on demand see the panel comment below.
@ -379,14 +383,12 @@ export default function Lead() {
// Derived from the same audit rows the trail renders, so they cannot drift.
const worked = [...new Set((audit || [])
.flatMap((r) => (r.user_roles || []).filter((x) => AGENTS[x])))]
const chatTurns = (audit || []).reduce((n, r) => {
const fs = Array.isArray(r.fields) && r.fields.length ? r.fields : []
return n + fs.filter((f) => {
const b = String(f.field_id || '').replace(/_\d+$/, '')
return (b === 'customer_reply' || b === 'customer_answer')
&& typeof f.value === 'string' && f.value.trim()
}).length
}, 0)
// Counted off the THREAD, not the rows. Scanning the audit rows counted the
// platform's own repeats one submission is recorded up to three times so
// the badge claimed nine messages over a six-message conversation, and it
// also missed the two sends recorded from inside a trigger. buildThread
// answers both, and is the same answer the dialog renders.
const chatTurns = buildThread(audit).turns.length
// An automated stage that has stopped for a stated reason. Checked before
// the "in progress" strip below, which would otherwise keep promising that
@ -759,7 +761,7 @@ export default function Lead() {
</p>
</div>
{chatTurns ? (
<button type="button" className="panel__act" onClick={() => setShowChat(true)}>
<button type="button" className="panel__act" onClick={() => setChat({ anchor: null })}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z" fill="none" stroke="currentColor"
strokeWidth="1.3" strokeLinejoin="round" />
@ -772,8 +774,9 @@ export default function Lead() {
<Timeline
rows={audit}
customerName={row.customer_name}
onOpenAgent={setOpenAgent}
onOpenChat={() => setShowChat(true)}
onOpenChat={(episode) => setChat({ anchor: episode ?? null })}
onOpenCall={() => setShowCall(true)}
/>
</div>
@ -888,8 +891,13 @@ export default function Lead() {
</aside>
</div>
{showChat ? (
<Conversation rows={audit} name={row.customer_name} onClose={() => setShowChat(false)} />
{chat ? (
<Conversation
rows={audit}
name={row.customer_name}
anchor={chat.anchor}
onClose={() => setChat(null)}
/>
) : null}
{openAgent ? (
<AgentCard agentKey={openAgent} onClose={() => setOpenAgent(null)} />