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(
+ {turns.length} message{turns.length === 1 ? '' : 's'} + {episodes.length > 1 ? ` · ${episodes.length} exchanges` : ''} +
+ ) : null} +{t.text}
- {at(t.at)} -{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.
Loading the timeline…
if (!rows.length) returnNothing 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 })+ 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. */} + +