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:
parent
7c328d2b88
commit
708cf4bc40
216
src/api/thread.js
Normal file
216
src/api/thread.js
Normal 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`
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
.conv { max-height: min(80vh, 760px); }
|
.conv { max-height: min(80vh, 760px); }
|
||||||
|
|
||||||
.conv__body {
|
.conv__body {
|
||||||
|
/* The offset parent for the scroll-to-an-exchange in Conversation.jsx. */
|
||||||
|
position: relative;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 18px 22px 8px;
|
padding: 18px 22px 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -86,3 +88,48 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 38px;
|
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; }
|
||||||
|
|||||||
@ -1,68 +1,39 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { Fragment, useEffect, useRef } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { baseFieldId } from '../api/config.js'
|
import { buildThread, listOf } from '../api/thread.js'
|
||||||
import './Conversation.css'
|
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
|
* The trail's job is what happened in what order; this is the other question —
|
||||||
* conversation meant scrolling a log and holding the order in your head. Here
|
* what was actually said — and it is one conversation even though the workflow
|
||||||
* it is what it is: a thread, oldest first, theirs on the left and ours on the
|
* touches it from several places. Theirs on the left, ours on the right, oldest
|
||||||
* right.
|
* 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
|
* 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
|
* 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
|
* workflow RECORDS, so anything sent from inside a trigger node and never
|
||||||
* written to a field is invisible here however certainly the customer received
|
* written to a field is invisible here however certainly the customer received
|
||||||
* it. The acceptance confirmation used to be exactly that — the panel showed
|
* it. Both payment-path messages used to be exactly that (98, 99); 100 writes
|
||||||
* nothing after "I confirm", which read as though we had ignored somebody
|
* them to `customer_answer` and they appear. One send remains absent: the
|
||||||
* agreeing to buy — so 90 writes it into customer_answer and it appears.
|
* quote, which is a registered WhatsApp template rather than text we compose.
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* A thread that quietly omitted any of this would be worse than one that says
|
* A thread that quietly omitted any of this would be worse than one that says
|
||||||
* which parts it holds.
|
* 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) {
|
function at(ts) {
|
||||||
const d = new Date(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' })
|
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 ref = useRef(null)
|
||||||
|
const bodyRef = useRef(null)
|
||||||
const restore = useRef(null)
|
const restore = useRef(null)
|
||||||
const turns = turnsFrom(rows)
|
const { turns, episodes } = buildThread(rows)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
restore.current = document.activeElement
|
restore.current = document.activeElement
|
||||||
@ -89,6 +76,23 @@ export default function Conversation({ rows, name, onClose }) {
|
|||||||
}
|
}
|
||||||
}, [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(
|
return createPortal(
|
||||||
<div className="prose__scrim" onClick={onClose} role="presentation">
|
<div className="prose__scrim" onClick={onClose} role="presentation">
|
||||||
<div
|
<div
|
||||||
@ -101,7 +105,15 @@ export default function Conversation({ rows, name, onClose }) {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="prose__head">
|
<div className="prose__head">
|
||||||
|
<div>
|
||||||
<h3>WhatsApp with {name || 'the customer'}</h3>
|
<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">
|
<button type="button" onClick={onClose} aria-label="Close">
|
||||||
<svg viewBox="0 0 14 14" aria-hidden="true">
|
<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"
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="conv__body">
|
<div className="conv__body" ref={bodyRef}>
|
||||||
{turns.length ? turns.map((t) => (
|
{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}>
|
<div key={t.key} className={'bub bub--' + t.side}>
|
||||||
<p>{t.text}</p>
|
<p>{t.text}</p>
|
||||||
<span>{at(t.at)}</span>
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
)) : (
|
)) : (
|
||||||
<p className="conv__empty">Nothing has been exchanged on WhatsApp yet.</p>
|
<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">
|
<p className="conv__note">
|
||||||
Shows the customer's messages and everything written back to them,
|
Shows the customer's messages and everything written back to them,
|
||||||
including the acceptance confirmation. One automatic send is not here:
|
including the acceptance confirmation, the payment request and the
|
||||||
the quote, which goes out as a registered WhatsApp template rather than
|
issuance note. One automatic send is not here: the quote, which goes
|
||||||
text we compose.
|
out as a registered WhatsApp template rather than text we compose.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -18,8 +18,6 @@
|
|||||||
background: var(--zk-line-soft); color: var(--zk-muted);
|
background: var(--zk-line-soft); color: var(--zk-muted);
|
||||||
}
|
}
|
||||||
.tl__disc--sys { font-size: 18px; color: var(--zk-grey); }
|
.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__body { min-width: 0; display: flex; flex-direction: column; }
|
||||||
.tl__line1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; min-height: 36px; }
|
.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__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 { 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; }
|
.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. */
|
/* 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;
|
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); }
|
.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; }
|
||||||
|
|||||||
@ -3,9 +3,9 @@ import { useZino } from '../api/provider.jsx'
|
|||||||
import AgentChip from './AgentChip.jsx'
|
import AgentChip from './AgentChip.jsx'
|
||||||
import ClampText from './ClampText.jsx'
|
import ClampText from './ClampText.jsx'
|
||||||
import ChainOfThought from './ChainOfThought.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 { 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'
|
import './Timeline.css'
|
||||||
|
|
||||||
/* The roster lives in api/agents.js now — one short name, one colour and one
|
/* 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'],
|
['resume_note', 'Why now'],
|
||||||
]
|
]
|
||||||
|
|
||||||
/* CONVERSATION lines stay as plain quotes — they are what was said to and by
|
/* WHAT WAS SAID IS NO LONGER QUOTED ON THE STEP.
|
||||||
the customer, not the machine's reasoning, and belong beside the thread. */
|
*
|
||||||
|
* `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 = [
|
const CONVERSATION = [
|
||||||
['customer_reply', 'The customer said'],
|
|
||||||
['customer_answer', 'We replied'],
|
|
||||||
['dedupe_match_ref', 'Duplicate of'],
|
['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
|
* 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
|
* 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
|
/** A value as one short line. Long prose is already in the narrative block, so
|
||||||
* anything here is a field value, not a paragraph. */
|
* anything here is a field value, not a paragraph. */
|
||||||
@ -112,32 +120,50 @@ const DOC_LABELS = {
|
|||||||
|
|
||||||
const FILE_TYPES = new Set(['file', 'ocr'])
|
const FILE_TYPES = new Set(['file', 'ocr'])
|
||||||
|
|
||||||
function when(ts) {
|
/** The absolute stamp and the relative one, kept apart: a collapsed exchange
|
||||||
if (!ts) return ''
|
* spans two moments and has only one "ago". */
|
||||||
|
function absAt(ts) {
|
||||||
const d = new Date(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 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 < 60 ? `${mins}m ago`
|
||||||
: mins < 1440 ? `${Math.round(mins / 60)}h ago`
|
: mins < 1440 ? `${Math.round(mins / 60)}h ago`
|
||||||
: `${Math.round(mins / 1440)}d 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
|
* An exchange happens over a stretch of time rather than at an instant, and the
|
||||||
* the timeline — the screen the whole product is demonstrated on — prints a
|
* stamp has to say so — dated only at its first message, an entry reads as
|
||||||
* raw slug like "zk-act-doc-reminder" in the middle of an otherwise readable
|
* though the reply visible inside it arrived before it was written.
|
||||||
* story. Turning it into "Doc Reminder" is a guess, but it is a guess that
|
*
|
||||||
* reads as English.
|
* The "ago" is measured from the LAST message: how stale the conversation is,
|
||||||
|
* which is the operationally useful half.
|
||||||
*/
|
*/
|
||||||
function prettyUid(uid) {
|
function spanAt(from, to) {
|
||||||
if (!uid) return 'Step'
|
if (!from) return ''
|
||||||
return String(uid)
|
if (!to || to === from) return when(from)
|
||||||
.replace(/^zk-act-/, '')
|
const a = new Date(from)
|
||||||
.split('-')
|
const b = new Date(to)
|
||||||
.filter(Boolean)
|
if (isNaN(a) || isNaN(b)) return when(from)
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
const hm = (d) => d.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })
|
||||||
.join(' ')
|
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. */
|
/** 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)
|
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
|
* `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.
|
* 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.
|
* This component fetched them itself once on mount and never again.
|
||||||
*/
|
*/
|
||||||
/** The two activities that ARE the WhatsApp thread. */
|
export default function Timeline({ rows, customerName, onOpenAgent, onOpenChat, onOpenCall }) {
|
||||||
const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer'])
|
|
||||||
|
|
||||||
export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall }) {
|
|
||||||
const { client } = useZino()
|
const { client } = useZino()
|
||||||
// DATA_UPDATE entries are the platform writing fields, not anyone deciding
|
// 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
|
// 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) return <p className="tl__loading">Loading the timeline…</p>
|
||||||
if (!rows.length) return <p className="tl__loading">Nothing has happened yet.</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.
|
// Oldest first: a timeline reads forwards.
|
||||||
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
|
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_name: prev.user_name || r.user_name,
|
||||||
user_roles: (prev.user_roles && prev.user_roles.length) ? prev.user_roles : r.user_roles,
|
user_roles: (prev.user_roles && prev.user_roles.length) ? prev.user_roles : r.user_roles,
|
||||||
created_at: prev.created_at,
|
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
|
continue
|
||||||
}
|
}
|
||||||
@ -219,7 +273,9 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
}
|
}
|
||||||
|
|
||||||
let lastStage = null
|
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 || []
|
const roles = r.user_roles || []
|
||||||
// SYSTEM FIRST. A DATA_UPDATE row inherits the roles of whoever caused it,
|
// 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,
|
// 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
|
// Log Contact is where the call lands. Offer the words themselves beside
|
||||||
// the summary, because they are not the same thing.
|
// the summary, because they are not the same thing.
|
||||||
const hasCall = r.activity_id === 'zk-act-contact' && Boolean(byBase.get('call_transcript')?.value)
|
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
|
const moved = stage && stage.uid !== lastStage
|
||||||
if (stage) lastStage = stage.uid
|
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 {
|
return {
|
||||||
key: r.id ?? i,
|
key: r.id ?? `${r.activity_id}-${r.created_at}`,
|
||||||
kind: isChat ? 'chat' : kind,
|
kind,
|
||||||
isChat,
|
|
||||||
hasCall,
|
hasCall,
|
||||||
|
sent: chat?.sent ?? 0,
|
||||||
|
chatEpisode: chat?.episodeId ?? null,
|
||||||
agentKey: aiRole || null,
|
agentKey: aiRole || null,
|
||||||
actor: aiRole ? AGENTS[aiRole].full : (r.user_name || 'System'),
|
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,
|
stage: moved ? stage : null,
|
||||||
when: when(r.created_at),
|
when: when(r.created_at),
|
||||||
docs,
|
docs,
|
||||||
@ -325,49 +383,59 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
.map(([base, f]) => [f.label || base.replace(/_/g, ' '), short(f)])
|
.map(([base, f]) => [f.label || base.replace(/_/g, ' '), short(f)])
|
||||||
.filter(([, v]) => v !== null),
|
.filter(([, v]) => v !== null),
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE CONVERSATION IS ONE THING, NOT NINE.
|
* THE CONVERSATION IS ONE ENTRY, NOT NINE.
|
||||||
*
|
*
|
||||||
* Every WhatsApp turn was its own entry — "Customer Reply", "Customer
|
* Every WhatsApp turn used to be its own step in the trail — "WhatsApp",
|
||||||
* Reply", "Customer Reply", "Answer the Customer" — each with a heading, an
|
* "WhatsApp", "WhatsApp" — each with a heading, an actor, a quote box and an
|
||||||
* actor and a quote box, so a four-message exchange occupied more of the
|
* Open thread link, so a four-message exchange about a premium took more of
|
||||||
* trail than the entire underwriting chain. A resend made it worse: the same
|
* the story than the entire underwriting chain.
|
||||||
* 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
|
* An exchange is now one entry: who spoke, how many messages, the last two of
|
||||||
* many messages, shows the last of them, and opens the thread. The trail goes
|
* them, and the whole thread one click away. It carries NO single actor, which
|
||||||
* back to being a list of decisions, and the conversation goes back to being
|
* is the trap the previous attempt fell into — collapsing turns while keeping
|
||||||
* a conversation.
|
* 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.
|
||||||
*
|
*
|
||||||
* Contiguous, not global: a second exchange after underwriting is a separate
|
* ONE ENTRY PER BURST, not one for the whole thread. The quote objection and
|
||||||
* episode in this lead's story and should read as one.
|
* 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 grouped = []
|
const entries = []
|
||||||
for (const it of items) {
|
const placed = new Set()
|
||||||
const last = grouped[grouped.length - 1]
|
|
||||||
// SAME SPEAKER ONLY. Collapsing every contiguous chat turn folded the
|
const waEntry = (ep) => ({ key: 'wa-' + ep.id, kind: 'wa', ep })
|
||||||
// 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
|
for (const r of merged) {
|
||||||
// printed under Engage's chip, attributing to the machine something a
|
const ids = r.mergedIds || [r.id]
|
||||||
// person had typed. Grouping per speaker still collapses the case this
|
const opens = episodes.filter((e) => !placed.has(e.id) && ids.includes(e.anchorRowId))
|
||||||
// exists for (the same sentence resent four times) and can never
|
|
||||||
// misattribute, because every row in a run is the same voice.
|
if (CHAT_ACTS.has(r.activity_id)) {
|
||||||
if (it.isChat && last && last.isChat && last.agentKey === it.agentKey) {
|
// The row IS a turn, not a step, so it never appears as one. Its exchange
|
||||||
last.count += 1
|
// takes its place — once, where the exchange began.
|
||||||
last.when = it.when
|
for (const e of opens) { entries.push(waEntry(e)); placed.add(e.id) }
|
||||||
// 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
|
continue
|
||||||
}
|
}
|
||||||
grouped.push({ ...it, count: 1 })
|
|
||||||
|
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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const sysCount = grouped.filter((it) => it.kind === 'sys').length
|
// A guard, not a case: an exchange whose anchor row did not survive the merge
|
||||||
const visible = showSys ? grouped : grouped.filter((it) => it.kind !== 'sys')
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -379,16 +447,71 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
|
|
||||||
<ol className="tl">
|
<ol className="tl">
|
||||||
{visible.map((it) => (
|
{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}>
|
<li key={it.key} className={'tl__ev tl__ev--' + it.kind}>
|
||||||
{/* The worker, as a face in the gutter. Recognition down a column
|
{/* The worker, as a face in the gutter. Recognition down a column
|
||||||
beats reading five names to notice it was one agent throughout. */}
|
beats reading five names to notice it was one agent throughout. */}
|
||||||
<span className="tl__gutter" aria-hidden="true">
|
<span className="tl__gutter" aria-hidden="true">
|
||||||
{it.kind === 'sys' ? (
|
{it.kind === 'sys' ? (
|
||||||
<span className="tl__disc tl__disc--sys">·</span>
|
<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 ? (
|
) : it.agentKey ? (
|
||||||
<AgentChip agentKey={it.agentKey} size="md" showName={false} onOpen={onOpenAgent} />
|
<AgentChip agentKey={it.agentKey} size="md" showName={false} onOpen={onOpenAgent} />
|
||||||
) : (
|
) : (
|
||||||
@ -397,28 +520,13 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
</span>
|
</span>
|
||||||
<div className="tl__body">
|
<div className="tl__body">
|
||||||
<div className="tl__line1">
|
<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>
|
<h4 className="tl__what">{it.what}</h4>
|
||||||
)}
|
{it.stage ? <span className="tl__to">{it.stage.name}</span> : null}
|
||||||
{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}
|
|
||||||
<span className="tl__when">{it.when}</span>
|
<span className="tl__when">{it.when}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="tl__by">
|
<div className="tl__by">
|
||||||
{it.kind === 'sys'
|
{it.kind === 'sys'
|
||||||
? <span className="tl__byname">System</span>
|
? <span className="tl__byname">System</span>
|
||||||
: it.isChat && !it.agentKey
|
|
||||||
? <span className="tl__byname tl__byname--cust">the customer</span>
|
|
||||||
: it.agentKey
|
: it.agentKey
|
||||||
? <><span className="tl__byname">{it.actor}</span><small className="tl__tag">AI</small></>
|
? <><span className="tl__byname">{it.actor}</span><small className="tl__tag">AI</small></>
|
||||||
: <span className="tl__byname">{it.actor}</span>}
|
: <span className="tl__byname">{it.actor}</span>}
|
||||||
@ -480,7 +588,6 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
|
|
||||||
{/* What was said to and by the customer — always a plain quote. */}
|
|
||||||
{it.conversation.map(([label, v]) => (
|
{it.conversation.map(([label, v]) => (
|
||||||
<div className="tl__say" key={label}>
|
<div className="tl__say" key={label}>
|
||||||
<span className="tl__saylabel">{label}</span>
|
<span className="tl__saylabel">{label}</span>
|
||||||
@ -500,8 +607,13 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
{it.hasCall && onOpenCall ? (
|
{it.hasCall && onOpenCall ? (
|
||||||
<button type="button" className="tl__link" onClick={onOpenCall}>Hear the call</button>
|
<button type="button" className="tl__link" onClick={onOpenCall}>Hear the call</button>
|
||||||
) : null}
|
) : null}
|
||||||
{it.isChat && onOpenChat ? (
|
{/* This step put something on WhatsApp itself. The message is in
|
||||||
<button type="button" className="tl__link" onClick={onOpenChat}>Open thread</button>
|
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}
|
) : null}
|
||||||
{/* Folded, because most steps write a lot and the trail has to
|
{/* Folded, because most steps write a lot and the trail has to
|
||||||
stay readable as a story. Open it and the step becomes an
|
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>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import StageRail from '../components/StageRail.jsx'
|
|||||||
import Timeline from '../components/Timeline.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 { 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 { AGENTS } from '../api/agents.js'
|
||||||
|
import { buildThread } from '../api/thread.js'
|
||||||
import { actionsFor, rolesOf } from '../api/permissions.js'
|
import { actionsFor, rolesOf } from '../api/permissions.js'
|
||||||
import { describeError } from '../api/errors.js'
|
import { describeError } from '../api/errors.js'
|
||||||
import './screens.css'
|
import './screens.css'
|
||||||
@ -208,7 +209,10 @@ export default function Lead() {
|
|||||||
// page — and the conversation view needs the same rows. One fetch, one
|
// page — and the conversation view needs the same rows. One fetch, one
|
||||||
// poll, two readers that cannot disagree.
|
// poll, two readers that cannot disagree.
|
||||||
const [audit, setAudit] = useState(null)
|
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 [openAgent, setOpenAgent] = useState(null)
|
||||||
const [showCall, setShowCall] = useState(false)
|
const [showCall, setShowCall] = useState(false)
|
||||||
// The full record, on demand — see the panel comment below.
|
// 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.
|
// Derived from the same audit rows the trail renders, so they cannot drift.
|
||||||
const worked = [...new Set((audit || [])
|
const worked = [...new Set((audit || [])
|
||||||
.flatMap((r) => (r.user_roles || []).filter((x) => AGENTS[x])))]
|
.flatMap((r) => (r.user_roles || []).filter((x) => AGENTS[x])))]
|
||||||
const chatTurns = (audit || []).reduce((n, r) => {
|
// Counted off the THREAD, not the rows. Scanning the audit rows counted the
|
||||||
const fs = Array.isArray(r.fields) && r.fields.length ? r.fields : []
|
// platform's own repeats — one submission is recorded up to three times — so
|
||||||
return n + fs.filter((f) => {
|
// the badge claimed nine messages over a six-message conversation, and it
|
||||||
const b = String(f.field_id || '').replace(/_\d+$/, '')
|
// also missed the two sends recorded from inside a trigger. buildThread
|
||||||
return (b === 'customer_reply' || b === 'customer_answer')
|
// answers both, and is the same answer the dialog renders.
|
||||||
&& typeof f.value === 'string' && f.value.trim()
|
const chatTurns = buildThread(audit).turns.length
|
||||||
}).length
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
// An automated stage that has stopped for a stated reason. Checked before
|
// An automated stage that has stopped for a stated reason. Checked before
|
||||||
// the "in progress" strip below, which would otherwise keep promising that
|
// the "in progress" strip below, which would otherwise keep promising that
|
||||||
@ -759,7 +761,7 @@ export default function Lead() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{chatTurns ? (
|
{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">
|
<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"
|
<path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z" fill="none" stroke="currentColor"
|
||||||
strokeWidth="1.3" strokeLinejoin="round" />
|
strokeWidth="1.3" strokeLinejoin="round" />
|
||||||
@ -772,8 +774,9 @@ export default function Lead() {
|
|||||||
|
|
||||||
<Timeline
|
<Timeline
|
||||||
rows={audit}
|
rows={audit}
|
||||||
|
customerName={row.customer_name}
|
||||||
onOpenAgent={setOpenAgent}
|
onOpenAgent={setOpenAgent}
|
||||||
onOpenChat={() => setShowChat(true)}
|
onOpenChat={(episode) => setChat({ anchor: episode ?? null })}
|
||||||
onOpenCall={() => setShowCall(true)}
|
onOpenCall={() => setShowCall(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -888,8 +891,13 @@ export default function Lead() {
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showChat ? (
|
{chat ? (
|
||||||
<Conversation rows={audit} name={row.customer_name} onClose={() => setShowChat(false)} />
|
<Conversation
|
||||||
|
rows={audit}
|
||||||
|
name={row.customer_name}
|
||||||
|
anchor={chat.anchor}
|
||||||
|
onClose={() => setChat(null)}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{openAgent ? (
|
{openAgent ? (
|
||||||
<AgentCard agentKey={openAgent} onClose={() => setOpenAgent(null)} />
|
<AgentCard agentKey={openAgent} onClose={() => setOpenAgent(null)} />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user