"Priya Raghavan and Engage" reads as two colleagues. Every other entry in the trail already answers this — the agent's disc carries a sparkle, its name carries an AI tag, a person's disc carries their initials — and the exchange entry was the one place printing bare names, on the screen whose whole claim is that the work was done autonomously. So the participants are chips rather than a sentence, and so is the label on every message, in the preview and in the thread: Priya Raghavan wears her initials, Engage wears the sparkle and the AI tag, and ops replying by hand wears their initials too — a person answering in the thread must never be dressed as the machine. The agent chip opens its card, as it does everywhere else in the trail; the label inside the preview card stays a span, because the card is already a button. The disc's tint and the bubble's tint are the same token, so a chip dropped into a bubble lost its disc; it is white inside one. `.bub span` narrowed to direct children other than the attribution row, so the chip's own spans are not restyled and CallTranscript's plain labels still work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
676 lines
30 KiB
JavaScript
676 lines
30 KiB
JavaScript
import { useState } from 'react'
|
||
import { useZino } from '../api/provider.jsx'
|
||
import AgentChip from './AgentChip.jsx'
|
||
import ClampText from './ClampText.jsx'
|
||
import ChainOfThought from './ChainOfThought.jsx'
|
||
import { AGENTS, initialsOf } from '../api/agents.js'
|
||
import { APP_ID, STAGES, baseFieldId } from '../api/config.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
|
||
set of initials per employee, so the same worker looks the same everywhere
|
||
it appears. This file only needs to know which roles are agents. */
|
||
|
||
/**
|
||
* The fields worth surfacing per activity — an agent's reasoning, a rule's
|
||
* output, a call's notes. Everything else stays in the file below; a timeline
|
||
* that shows every field is a table, not a story.
|
||
*
|
||
* Keyed on the BASE field id. An audit row's data is keyed by the ACTIVITY's
|
||
* field ids, which carry a per-form suffix — the call notes arrive as
|
||
* `contact_notes_2`, the document request as `documents_notes_3`. Matching the
|
||
* global ids directly, as this did, meant almost nothing ever matched: the
|
||
* timeline showed a bare list of activity names, and the one narrative line
|
||
* that did appear was an accident (a DATA_UPDATE row happens to write the
|
||
* unsuffixed key).
|
||
*/
|
||
/* AN AI STEP'S REASONING, in the order it is worth reading. These become the
|
||
"Reasoned" rung of the chain of thought — the employee's own words for why
|
||
it did what it did — rather than loose quote blocks. The first present one
|
||
is the finding shown without opening the chain. */
|
||
const REASONING = [
|
||
['attribution_reason', 'Attribution'],
|
||
['eligibility_reason', 'Eligibility'],
|
||
['ai_recommendation_rationale', 'Cover advice'],
|
||
['kyc_mismatch_notes', 'KYC mismatch'],
|
||
// A clean KYC leaves the mismatch note empty, so the step showed no chain at
|
||
// all. kyc_ref is what the KYC employee actually recorded — the document
|
||
// package it matched the identity against — so it stands in as the finding.
|
||
['kyc_ref', 'KYC check'],
|
||
['referral_analysis', 'Referral analysis'],
|
||
// Underwriting writes its reasoning into uw_referral_reason (the screen and
|
||
// prepare steps); uw_decision_notes is only the clear/decline note. The map
|
||
// knew the second and not the first, so the substantive UW decision — the
|
||
// one the reader most wants the reasoning for — showed as a bare heading.
|
||
['uw_referral_reason', 'Underwriting'],
|
||
['uw_decision_notes', 'Underwriting decision'],
|
||
['contact_notes', 'Call'],
|
||
['documents_notes', 'Documents'],
|
||
// The payment-nudge employee records why it chose to follow up now (or hold).
|
||
['nudge_notes', 'Payment follow-up'],
|
||
['quoted_breakup', 'How the premium was reached'],
|
||
['lost_reason', 'Why it was dropped'],
|
||
['resume_note', 'Why now'],
|
||
]
|
||
|
||
/* 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 = [
|
||
['dedupe_match_ref', 'Duplicate of'],
|
||
]
|
||
|
||
const NARRATIVE = [...REASONING, ...CONVERSATION]
|
||
|
||
const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk', 'motor_idv'])
|
||
|
||
/**
|
||
* 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 thread that owns the messages, the document chips, and the platform's
|
||
* own bookkeeping.
|
||
*/
|
||
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. */
|
||
function short(f) {
|
||
const v = f.value
|
||
if (v === null || v === undefined || v === '') return null
|
||
if (Array.isArray(v)) {
|
||
const named = v
|
||
.map((x) => (x && typeof x === 'object' ? (x.original_name || x.uuid || '') : x))
|
||
.filter(Boolean)
|
||
return named.length ? named.join(', ') : null
|
||
}
|
||
if (typeof v === 'object') return null
|
||
const t = String(v).trim()
|
||
if (!t) return null
|
||
// A value long enough to be prose belongs in the narrative block, not in a
|
||
// list of what changed — truncating it here would show half a sentence and
|
||
// teach the reader that this list is unreliable.
|
||
return t.length > 90 ? t.slice(0, 88) + '…' : t
|
||
}
|
||
|
||
/** Document slots, in the order they are worth reading. Labels are shorter than
|
||
* the workflow's own — "Registration Certificate (RC)" is a chip, not a form
|
||
* label — and a slot missing from here still renders under its server label. */
|
||
const DOC_LABELS = {
|
||
doc_rc: 'RC',
|
||
doc_prev_policy: 'Expiring policy',
|
||
doc_pan: 'PAN',
|
||
doc_gst_cert: 'GST certificate',
|
||
doc_udyam_cert: 'Udyam certificate',
|
||
doc_address_proof: 'Address proof',
|
||
doc_premises_proof: 'Premises proof',
|
||
doc_stock_statement: 'Stock statement',
|
||
doc_premises_photos: 'Premises photos',
|
||
doc_vehicle_photos: 'Vehicle photos',
|
||
doc_financials: 'Financials',
|
||
}
|
||
|
||
const FILE_TYPES = new Set(['file', 'ocr'])
|
||
|
||
/** 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)
|
||
return mins < 1 ? 'just now'
|
||
: mins < 60 ? `${mins}m ago`
|
||
: mins < 1440 ? `${Math.round(mins / 60)}h ago`
|
||
: `${Math.round(mins / 1440)}d ago`
|
||
}
|
||
|
||
function when(ts) {
|
||
if (!ts) return ''
|
||
const abs = absAt(ts)
|
||
return abs ? `${abs} · ${agoAt(ts)}` : ''
|
||
}
|
||
|
||
/**
|
||
* 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 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. */
|
||
function filesIn(value) {
|
||
if (!Array.isArray(value)) return []
|
||
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 — as CHIPS, not as a sentence.
|
||
*
|
||
* "Priya Raghavan and Engage" reads as two colleagues. On a screen whose whole
|
||
* claim is that the work was done autonomously, the one fact every line has to
|
||
* state is which of the two is a machine, and a bare name states the opposite.
|
||
*
|
||
* Every other entry in the trail already says it the same way — the agent's
|
||
* disc carries a sparkle, its name carries an AI tag, a person's disc carries
|
||
* their initials — so this is the existing convention reaching somewhere it was
|
||
* missing rather than a new one.
|
||
*
|
||
* The customer comes first: they are the reason the exchange exists.
|
||
*/
|
||
function voicesOf(ep, customerName) {
|
||
const who = []
|
||
if (ep.turns.some((t) => t.side === 'them')) {
|
||
who.push({ key: 'them', agentKey: null, name: customerName || 'the customer' })
|
||
}
|
||
for (const t of ep.turns) {
|
||
if (t.side !== 'us') continue
|
||
// Keyed on the agent, or on the name for a person answering by hand: ops
|
||
// replying in the thread is a person and must never wear the sparkle.
|
||
const key = t.agentKey || `person:${t.by}`
|
||
if (!who.some((w) => w.key === key)) who.push({ key, agentKey: t.agentKey, name: t.by })
|
||
}
|
||
return who
|
||
}
|
||
|
||
/** The label on a preview bubble: the same chip, small, and never a button —
|
||
* the card it sits in is already one, and a button inside a button is not. */
|
||
function TurnWho({ turn, customerName }) {
|
||
return turn.side === 'them'
|
||
? <AgentChip name={customerName || 'the customer'} size="xs" />
|
||
: <AgentChip agentKey={turn.agentKey} name={turn.by} size="xs" />
|
||
}
|
||
|
||
/**
|
||
* `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.
|
||
*/
|
||
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
|
||
// for — the story is what people and agents did.
|
||
const [showSys, setShowSys] = useState(false)
|
||
|
||
if (!rows) return <p className="tl__loading">Loading the timeline…</p>
|
||
if (!rows.length) return <p className="tl__loading">Nothing has happened yet.</p>
|
||
|
||
// The conversation, assembled once and shared with the dialog. `episodes` are
|
||
// its bursts; `byRow` says which steps put a message on WhatsApp themselves.
|
||
const { episodes, byRow } = buildThread(rows)
|
||
|
||
// Oldest first: a timeline reads forwards.
|
||
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
|
||
|
||
// ONE SUBMISSION WRITES SEVERAL ROWS. The platform records each stage of a
|
||
// submission separately, told apart by execution_state:
|
||
//
|
||
// zk-act-qualify TRIGGER_PERFORMED the trigger's commit
|
||
// zk-act-qualify TRIGGER_PERFORMED ...again, on the settle path
|
||
// zk-act-qualify zk-state-qualified the one that moved the lead
|
||
//
|
||
// Rendered literally that is "Qualify Lead" three times in a row, which
|
||
// reads as the AI having done the same thing three times. Only the row
|
||
// carrying a real state means anything to somebody reading the file.
|
||
//
|
||
// Collapsed on (same activity, within fifteen seconds) rather than on the
|
||
// execution_state alone, because a genuine repeat has to survive: Collect
|
||
// Documents really is performed twice on a lead whose first upload was
|
||
// incomplete, and those are minutes apart, not milliseconds.
|
||
//
|
||
// The kept row takes the EARLIEST timestamp — when the operator acted — and
|
||
// whichever state and payload is actually populated.
|
||
const isStage = (v) => STAGES.some((s) => s.uid === v)
|
||
const SAME_SUBMISSION_MS = 15000
|
||
|
||
const merged = []
|
||
for (const r of ordered) {
|
||
// Look BACK for a match rather than only at the previous entry: the
|
||
// platform interleaves a DATA_UPDATE row between the two halves of one
|
||
// submission, so the rows to merge are near each other in time but not
|
||
// adjacent in the list.
|
||
let at = -1
|
||
for (let i = merged.length - 1; i >= 0; i--) {
|
||
if (Date.parse(r.created_at) - Date.parse(merged[i].created_at) >= SAME_SUBMISSION_MS) break
|
||
if (merged[i].activity_id === r.activity_id) { at = i; break }
|
||
}
|
||
|
||
if (at >= 0) {
|
||
const prev = merged[at]
|
||
merged[at] = {
|
||
...prev,
|
||
// The settle row is the one that names the resulting stage.
|
||
execution_state: isStage(r.execution_state) ? r.execution_state : prev.execution_state,
|
||
// Whichever row actually carries the submission and the AI's working.
|
||
data: (r.data && Object.keys(r.data).length) ? r.data : prev.data,
|
||
fields: (r.fields && r.fields.length) ? r.fields : prev.fields,
|
||
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
|
||
}
|
||
merged.push(r)
|
||
}
|
||
|
||
let lastStage = null
|
||
|
||
/** 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,
|
||
// and appeared in the trail as an entry titled "Data updated" — twice,
|
||
// while the toggle below still offered to reveal two others. The row's
|
||
// activity id is what says it is bookkeeping; the roles say who triggered
|
||
// the bookkeeping, which is a different question.
|
||
const isSystem = r.activity_id === 'DATA_UPDATE'
|
||
const aiRole = roles.find((x) => AGENTS[x])
|
||
const kind = isSystem ? 'sys' : aiRole ? 'ai' : 'human'
|
||
|
||
/**
|
||
* The value view. `fields[]` is the platform's own typed rendering of the
|
||
* submission — one entry per configured field with its label, data type and
|
||
* value — and it is what makes a file field knowable as a file. `data` is
|
||
* the raw untyped fallback for a row the workflow could not resolve.
|
||
*/
|
||
const fields = Array.isArray(r.fields) && r.fields.length
|
||
? r.fields
|
||
: Object.entries(r.data || {}).map(([k, v]) => ({ field_id: k, label: '', data_type: '', value: v }))
|
||
|
||
const byBase = new Map()
|
||
for (const f of fields) {
|
||
const base = baseFieldId(f.field_id)
|
||
// `_system` is the platform's own marker on every submission.
|
||
if (base === '_system') continue
|
||
if (!byBase.has(base)) byBase.set(base, f)
|
||
}
|
||
|
||
// What was attached. This is the answer to "show me that the documents
|
||
// were captured": the names, from the submission that carried them, each
|
||
// one openable.
|
||
const docs = []
|
||
for (const [base, f] of byBase) {
|
||
if (!FILE_TYPES.has(f.data_type) && !base.startsWith('doc_')) continue
|
||
for (const file of filesIn(f.value)) {
|
||
docs.push({ slot: DOC_LABELS[base] || f.label || base, ...file })
|
||
}
|
||
}
|
||
|
||
// Log Contact is where the call lands. Offer the words themselves beside
|
||
// the summary, because they are not the same thing.
|
||
const hasCall = r.activity_id === 'zk-act-contact' && Boolean(byBase.get('call_transcript')?.value)
|
||
const stage = STAGES.find((s) => s.uid === r.execution_state)
|
||
// A self-loop — Capture Motor Risk runs inside Document Pending and settles
|
||
// back into it — is not a move, and printing "→ Document Pending" against
|
||
// four consecutive entries reads as the lead bouncing.
|
||
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 ?? `${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: isSystem ? 'Data updated' : stepName(r),
|
||
stage: moved ? stage : null,
|
||
when: when(r.created_at),
|
||
docs,
|
||
// The reasoning rungs of the chain of thought — the employee's own text.
|
||
reasoning: REASONING
|
||
.map(([k, label]) => [label, byBase.get(k)?.value])
|
||
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''),
|
||
// Conversation lines stay as plain quotes.
|
||
conversation: CONVERSATION
|
||
.map(([k, label]) => [label, byBase.get(k)?.value])
|
||
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''),
|
||
// The decision's confidence, when the step recorded it — ai_recommendation_confidence
|
||
// is a real field the advice step writes into the audit data, so it needs
|
||
// no backend change to reach here.
|
||
confidence: (() => {
|
||
const c = byBase.get('ai_recommendation_confidence')?.value
|
||
const n = c == null ? null : Number(c)
|
||
return Number.isFinite(n) ? n : null
|
||
})(),
|
||
figures: [...byBase]
|
||
.filter(([base, f]) => MONEY.has(base) && f.value)
|
||
.map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]),
|
||
|
||
/**
|
||
* EVERYTHING THIS STEP WROTE.
|
||
*
|
||
* Twelve fields were rendered as prose and four as money; the other
|
||
* hundred-odd were invisible. So Capture Motor Risk — which writes engine
|
||
* capacity, fuel, year, NCB, previous insurer, policy number, IDV and the
|
||
* add-ons — showed as a heading and a timestamp, and the only way to see
|
||
* what an agent had actually decided was to open the Lead file and guess
|
||
* which values came from that step.
|
||
*
|
||
* This is not the model's internal reasoning; that is recorded on the row
|
||
* (ai_reasoning, ai_tool_calls) and the audit endpoint does not return it.
|
||
* It is the next best and arguably more useful thing: the decision it
|
||
* committed, field by field, against the step that made it.
|
||
*/
|
||
wrote: [...byBase]
|
||
.filter(([base]) => !SHOWN_ELSEWHERE.has(base) && !base.startsWith('doc_'))
|
||
.map(([base, f]) => [f.label || base.replace(/_/g, ' '), short(f)])
|
||
.filter(([, v]) => v !== null),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<>
|
||
{sysCount ? (
|
||
<button type="button" className="tl__toggle" onClick={() => setShowSys((v) => !v)}>
|
||
{showSys ? 'Hide' : 'Show'} {sysCount} system update{sysCount === 1 ? '' : 's'}
|
||
</button>
|
||
) : null}
|
||
|
||
<ol className="tl">
|
||
{visible.map((it) => (
|
||
it.kind === 'wa' ? (
|
||
<li key={it.key} className="tl__ev tl__ev--wa">
|
||
<span className="tl__gutter" aria-hidden="true">
|
||
<span className="tl__disc tl__disc--wa"><WaGlyph /></span>
|
||
</span>
|
||
<div className="tl__body">
|
||
<div className="tl__line1">
|
||
<h4 className="tl__what tl__what--wa">
|
||
<WaGlyph />
|
||
WhatsApp
|
||
</h4>
|
||
{/* A resumed exchange says so on its own line, so nobody
|
||
reads it as a second conversation out of nowhere. */}
|
||
{it.ep.after.length ? <span className="tl__n">continued</span> : null}
|
||
<span className="tl__n">
|
||
{it.ep.count} message{it.ep.count === 1 ? '' : 's'}
|
||
</span>
|
||
<span className="tl__when">{spanAt(it.ep.from, it.ep.to)}</span>
|
||
</div>
|
||
<div className="tl__wavoices">
|
||
{voicesOf(it.ep, customerName).map((w) => (
|
||
<AgentChip
|
||
key={w.key}
|
||
agentKey={w.agentKey}
|
||
name={w.name}
|
||
size="sm"
|
||
onOpen={w.agentKey ? onOpenAgent : undefined}
|
||
/>
|
||
))}
|
||
</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}>
|
||
<span className="tl__wamsg-who">
|
||
<TurnWho turn={t} customerName={customerName} />
|
||
</span>
|
||
<span className="tl__wamsg-txt">{t.text}</span>
|
||
</span>
|
||
))}
|
||
</span>
|
||
<span className="tl__wafoot">
|
||
{it.ep.count > it.ep.preview.length
|
||
? `Open the conversation — all ${it.ep.count} messages`
|
||
: 'Open the conversation'}
|
||
<svg viewBox="0 0 12 12" aria-hidden="true">
|
||
<path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.6"
|
||
strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</li>
|
||
) : (
|
||
<li key={it.key} className={'tl__ev tl__ev--' + it.kind}>
|
||
{/* The worker, as a face in the gutter. Recognition down a column
|
||
beats reading five names to notice it was one agent throughout. */}
|
||
<span className="tl__gutter" aria-hidden="true">
|
||
{it.kind === 'sys' ? (
|
||
<span className="tl__disc tl__disc--sys">·</span>
|
||
) : it.agentKey ? (
|
||
<AgentChip agentKey={it.agentKey} size="md" showName={false} onOpen={onOpenAgent} />
|
||
) : (
|
||
<span className="tl__disc tl__disc--hu">{initialsOf(it.actor)}</span>
|
||
)}
|
||
</span>
|
||
<div className="tl__body">
|
||
<div className="tl__line1">
|
||
<h4 className="tl__what">{it.what}</h4>
|
||
{it.stage ? <span className="tl__to">{it.stage.name}</span> : null}
|
||
<span className="tl__when">{it.when}</span>
|
||
</div>
|
||
<div className="tl__by">
|
||
{it.kind === 'sys'
|
||
? <span className="tl__byname">System</span>
|
||
: it.agentKey
|
||
? <><span className="tl__byname">{it.actor}</span><small className="tl__tag">AI</small></>
|
||
: <span className="tl__byname">{it.actor}</span>}
|
||
</div>
|
||
|
||
{/* What was received, named and openable. The count is stated
|
||
rather than left to be inferred from a list — "3 documents
|
||
received" is the sentence the operator is looking for. */}
|
||
{it.docs.length ? (
|
||
<div className="tl__docs">
|
||
<span className="tl__docshead">
|
||
{it.docs.length} document{it.docs.length === 1 ? '' : 's'} received
|
||
</span>
|
||
<div className="tl__doclist">
|
||
{it.docs.map((d) => (
|
||
<a
|
||
key={d.uuid}
|
||
className="tl__doc"
|
||
href={`${client.baseUrl}/app/${APP_ID}/view/files/${d.uuid}/preview`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
title={d.original_name}
|
||
>
|
||
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||
<path d="M3.5 1.5h4.2L11 4.8v7.7H3.5z" fill="none" stroke="currentColor" strokeWidth="1.2"
|
||
strokeLinejoin="round" />
|
||
<path d="M7.6 1.6v3.3H11" fill="none" stroke="currentColor" strokeWidth="1.2"
|
||
strokeLinejoin="round" />
|
||
</svg>
|
||
{d.slot}
|
||
</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* THE CHAIN OF THOUGHT. For an AI step, the reasoning is not a
|
||
loose quote — it is how the employee reached the decision, so
|
||
it renders as the chain: what it checked, what it reasoned,
|
||
what it decided. This is the answer to "why did it do that",
|
||
which is the first thing a person needs before trusting or
|
||
overriding a machine. */}
|
||
{it.kind === 'ai' && it.reasoning.length ? (
|
||
<ChainOfThought
|
||
reasoning={it.reasoning}
|
||
confidence={it.confidence}
|
||
decided={{ what: it.what, stage: it.stage ? it.stage.name : null }}
|
||
/>
|
||
) : null}
|
||
|
||
{/* A human step's reason, and the AI step's reasoning when it is
|
||
not the story's actor (rare), stay as a plain quote. */}
|
||
{it.kind !== 'ai'
|
||
? it.reasoning.map(([label, v]) => (
|
||
<div className="tl__say" key={label}>
|
||
<span className="tl__saylabel">{label}</span>
|
||
<ClampText text={String(v)} title={`${label} — ${it.what}`} lines={2} threshold={120} />
|
||
</div>
|
||
))
|
||
: null}
|
||
|
||
{it.conversation.map(([label, v]) => (
|
||
<div className="tl__say" key={label}>
|
||
<span className="tl__saylabel">{label}</span>
|
||
<ClampText text={String(v)} title={`${label} — ${it.what}`} lines={2} threshold={120} />
|
||
</div>
|
||
))}
|
||
|
||
{it.figures.length ? (
|
||
<div className="tl__figs">
|
||
{it.figures.map(([k, v]) => (
|
||
<span key={k}><em>{k}</em> {v}</span>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="tl__acts">
|
||
{it.hasCall && onOpenCall ? (
|
||
<button type="button" className="tl__link" onClick={onOpenCall}>Hear the call</button>
|
||
) : null}
|
||
{/* This step put something on WhatsApp itself. The message is in
|
||
the thread with the rest of the conversation, not quoted
|
||
here a second time. */}
|
||
{it.sent && onOpenChat ? (
|
||
<button type="button" className="tl__link" onClick={() => onOpenChat(it.chatEpisode)}>
|
||
{it.sent === 1 ? 'Sent 1 message on WhatsApp' : `Sent ${it.sent} messages on WhatsApp`}
|
||
</button>
|
||
) : null}
|
||
{/* Folded, because most steps write a lot and the trail has to
|
||
stay readable as a story. Open it and the step becomes an
|
||
itemised account of what it decided. */}
|
||
{it.wrote.length ? (
|
||
<details className="tl__wrote">
|
||
<summary>{it.wrote.length} field{it.wrote.length === 1 ? '' : 's'} written</summary>
|
||
<dl>
|
||
{it.wrote.map(([k, v]) => (
|
||
<div key={k}><dt>{k}</dt><dd>{v}</dd></div>
|
||
))}
|
||
</dl>
|
||
</details>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</li>
|
||
)
|
||
))}
|
||
</ol>
|
||
</>
|
||
)
|
||
}
|