From 708cf4bc40199a76c9a85afa381d82b80df21d52 Mon Sep 17 00:00:00 2001 From: Yashas Date: Thu, 10 Sep 2026 13:17:51 +0530 Subject: [PATCH] console: the WhatsApp exchange is one entry, and it opens the whole thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/api/thread.js | 216 +++++++++++++++++++++ src/components/Conversation.css | 47 +++++ src/components/Conversation.jsx | 158 +++++++++------- src/components/Timeline.css | 55 +++++- src/components/Timeline.jsx | 325 +++++++++++++++++++++----------- src/screens/Lead.jsx | 34 ++-- 6 files changed, 650 insertions(+), 185 deletions(-) create mode 100644 src/api/thread.js diff --git a/src/api/thread.js b/src/api/thread.js new file mode 100644 index 0000000..0ac01f8 --- /dev/null +++ b/src/api/thread.js @@ -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` +} diff --git a/src/components/Conversation.css b/src/components/Conversation.css index 37ac8de..1c757cc 100644 --- a/src/components/Conversation.css +++ b/src/components/Conversation.css @@ -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; } diff --git a/src/components/Conversation.jsx b/src/components/Conversation.jsx index d2aa0ec..7f771c7 100644 --- a/src/components/Conversation.jsx +++ b/src/components/Conversation.jsx @@ -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(
e.stopPropagation()} >
-

WhatsApp with {name || 'the customer'}

+
+

WhatsApp with {name || 'the customer'}

+ {turns.length ? ( +

+ {turns.length} message{turns.length === 1 ? '' : 's'} + {episodes.length > 1 ? ` · ${episodes.length} exchanges` : ''} +

+ ) : null} +
-
- {turns.length ? turns.map((t) => ( -
-

{t.text}

- {at(t.at)} -
+
+ {episodes.length ? episodes.map((ep, i) => ( + + {i > 0 ? ( +
+ {gapOf(episodes[i - 1], ep)} +
+ ) : null} +
+ {ep.turns.map((t) => ( +
+

{t.text}

+ + {/* 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)} + +
+ ))} +
+
)) : (

Nothing has been exchanged on WhatsApp yet.

)} @@ -123,9 +155,9 @@ export default function Conversation({ rows, name, onClose }) {

Shows the customer'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.

diff --git a/src/components/Timeline.css b/src/components/Timeline.css index ad7626d..8d687da 100644 --- a/src/components/Timeline.css +++ b/src/components/Timeline.css @@ -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; } diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 29462f1..e55dadd 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -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 ( + + ) +} + +/** 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

Loading the timeline…

if (!rows.length) return

Nothing has happened yet.

+ // 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 })
    {visible.map((it) => ( + it.kind === 'wa' ? ( +
  1. + +
    +
    +

    + + WhatsApp +

    + {/* A resumed exchange says so on its own line, so nobody + reads it as a second conversation out of nowhere. */} + {it.ep.after.length ? continued : null} + + {it.ep.count} message{it.ep.count === 1 ? '' : 's'} + + {spanAt(it.ep.from, it.ep.to)} +
    +
    + {voicesOf(it.ep, customerName)} +
    + + {it.ep.after.length ? ( +

    + The same thread, picked up after {listOf(it.ep.after)}. +

    + ) : 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. */} + +
    +
  2. + ) : (
  3. {/* The worker, as a face in the gutter. Recognition down a column beats reading five names to notice it was one agent throughout. */}
    - {it.isChat ? ( -

    - - WhatsApp -

    - ) : ( -

    {it.what}

    - )} - {it.count > 1 ? {it.count} messages : null} - {it.stage && !it.isChat ? {it.stage.name} : null} +

    {it.what}

    + {it.stage ? {it.stage.name} : null} {it.when}
    {it.kind === 'sys' ? System - : it.isChat && !it.agentKey - ? the customer - : it.agentKey - ? <>{it.actor}AI - : {it.actor}} + : it.agentKey + ? <>{it.actor}AI + : {it.actor}}
    {/* 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]) => (
    {label} @@ -500,8 +607,13 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall }) {it.hasCall && onOpenCall ? ( ) : null} - {it.isChat && onOpenChat ? ( - + {/* 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 ? ( + ) : 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 })
  4. + ) ))}
diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx index 655ff00..f96e1e1 100644 --- a/src/screens/Lead.jsx +++ b/src/screens/Lead.jsx @@ -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() {

{chatTurns ? ( -
@@ -888,8 +891,13 @@ export default function Lead() { - {showChat ? ( - setShowChat(false)} /> + {chat ? ( + setChat(null)} + /> ) : null} {openAgent ? ( setOpenAgent(null)} />