console: the agents explain themselves, and the chat reads as a chat
TOOLS ARE VISIBLE NOW. Clicking any agent — in the trail or the "Worked by" strip — opens a card saying what it is, when it runs, what it can reach and what it reads. The answer is short and it is the reassuring kind: four of the five hold no tools at all and reason from the policy wordings, and the only tool in the roster reads a partner registry. That answer stopped being true once, silently. Intake held a tool that could telephone customers, its charter never mentioned it, and it used it on every lead believing it was a duplicate check (89). Nobody could see that from any screen. This is the screen that would have shown it — which is the actual argument for building it, beyond a demo looking better. Where an agent has no tools the card says so in words rather than showing an empty section. "No tools" is the single most reassuring fact about something that writes into an insurance file; a blank reads as missing data. The roster is a hand-maintained mirror of the employee config, like STAGES and ACTIONS, and carries the same hazard: a tool added there and not here is described wrongly, silently. Named in the file. THE CONVERSATION WAS EATING THE TRAIL. Every WhatsApp turn was its own entry — heading, actor, quote box — so a four-message exchange occupied more of the trail than the entire underwriting chain, and a resend printed the same sentence four times because the customer sent it four times. A contiguous run now collapses into ONE entry: a WhatsApp mark, a count, the latest line, and a link that opens the thread. A second exchange later in the lead stays separate, because that is a different episode in the story. And the actor is right. The channel performs these as the system, so the trail read "System · Customer Reply" — the opposite of what happened. It says "the customer" now, in the channel's own green, and the rail dot takes that colour so a scan shows where the conversation was without reading a word. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
171aa4c2fa
commit
f4c67310cb
@ -12,28 +12,79 @@
|
|||||||
* words, not the charter's — it appears on hover and in the roster strip, and
|
* words, not the charter's — it appears on hover and in the roster strip, and
|
||||||
* it is what makes an unfamiliar name mean something the first time.
|
* it is what makes an unfamiliar name mean something the first time.
|
||||||
*
|
*
|
||||||
|
* `tools` and `knowledge` are a MIRROR of the employee config in
|
||||||
|
* aiemployee.tbl_ai_employees. The app frontend cannot read the agents schema,
|
||||||
|
* so this is hand-maintained the way STAGES and ACTIONS are, and carries the
|
||||||
|
* same hazard: a tool added or removed there and not here is described wrongly
|
||||||
|
* in the UI, silently.
|
||||||
|
*
|
||||||
|
* Worth the trade, because "what can this thing actually do?" is the first
|
||||||
|
* question anyone watching an agent asks — and the honest answer is short and
|
||||||
|
* reassuring: four of the five hold no tools at all and work from the policy
|
||||||
|
* wordings. The one tool that exists only reads a partner registry. Nothing
|
||||||
|
* here can move money, and since 89 nothing here can telephone anybody.
|
||||||
|
*
|
||||||
* Keyed by the ROLE, because that is what an audit row carries.
|
* Keyed by the ROLE, because that is what an audit row carries.
|
||||||
*/
|
*/
|
||||||
export const AGENTS = {
|
export const AGENTS = {
|
||||||
ai_intake: {
|
ai_intake: {
|
||||||
short: 'Intake', full: 'Intake & Attribution', initials: 'IA', tone: 'violet',
|
short: 'Intake',
|
||||||
|
full: 'Intake & Attribution',
|
||||||
|
initials: 'IA',
|
||||||
|
tone: 'violet',
|
||||||
does: 'checks the lead is real, scores it, and works out whose it is',
|
does: 'checks the lead is real, scores it, and works out whose it is',
|
||||||
|
wakes: 'Once, the moment a lead is filed.',
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: 'lookup_partner',
|
||||||
|
does: 'Reads the partner registry by partner code and reports whether that POSP, broker or corporate agent is active and empanelled for this product.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)'],
|
||||||
},
|
},
|
||||||
|
|
||||||
ai_engage: {
|
ai_engage: {
|
||||||
short: 'Engage', full: 'Engage', initials: 'EN', tone: 'blue',
|
short: 'Engage',
|
||||||
|
full: 'Engage',
|
||||||
|
initials: 'EN',
|
||||||
|
tone: 'blue',
|
||||||
does: 'talks to the customer, writes up the call, and asks for what is missing',
|
does: 'talks to the customer, writes up the call, and asks for what is missing',
|
||||||
|
wakes: 'Most often of the five: after the call ends, after documents land, after the Advisor reports, when the premium falls due, and on every WhatsApp reply.',
|
||||||
|
tools: [],
|
||||||
|
knowledge: ['SME Package (BSUS / BLUS)', 'Motor (Car Secure)', 'Motor Renewal Sales Desk'],
|
||||||
},
|
},
|
||||||
|
|
||||||
ai_advisor: {
|
ai_advisor: {
|
||||||
short: 'Advisor', full: 'Advisor', initials: 'AD', tone: 'teal',
|
short: 'Advisor',
|
||||||
does: 'reads the risk and recommends the cover',
|
full: 'Advisor',
|
||||||
|
initials: 'AD',
|
||||||
|
tone: 'teal',
|
||||||
|
does: 'reads the captured risk and recommends the cover and add-ons',
|
||||||
|
wakes: 'Once, as soon as the risk has been captured.',
|
||||||
|
tools: [],
|
||||||
|
knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)', 'Motor Renewal Sales Desk'],
|
||||||
},
|
},
|
||||||
|
|
||||||
ai_kyc: {
|
ai_kyc: {
|
||||||
short: 'KYC', full: 'KYC & Evidence', initials: 'KY', tone: 'amber',
|
short: 'KYC',
|
||||||
|
full: 'KYC & Evidence',
|
||||||
|
initials: 'KY',
|
||||||
|
tone: 'amber',
|
||||||
does: 'verifies identity and screens the risk for underwriting',
|
does: 'verifies identity and screens the risk for underwriting',
|
||||||
|
wakes: 'Twice: when the customer accepts, and again to run the underwriting screen.',
|
||||||
|
tools: [],
|
||||||
|
knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)'],
|
||||||
},
|
},
|
||||||
|
|
||||||
ai_uw_referral: {
|
ai_uw_referral: {
|
||||||
short: 'Referral', full: 'Underwriting Referral', initials: 'UW', tone: 'plum',
|
short: 'Referral',
|
||||||
does: 'prepares a referred file so an underwriter can decide',
|
full: 'Underwriting Referral',
|
||||||
|
initials: 'UW',
|
||||||
|
tone: 'plum',
|
||||||
|
does: 'prepares a referred file so a human underwriter can decide',
|
||||||
|
wakes: 'Once, and only when the screen refers the risk.',
|
||||||
|
tools: [],
|
||||||
|
knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)'],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
88
src/components/AgentCard.css
Normal file
88
src/components/AgentCard.css
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
.card { max-height: min(82vh, 720px); }
|
||||||
|
|
||||||
|
.card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 20px 20px 16px 22px;
|
||||||
|
border-bottom: 1px solid var(--zk-line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__disc { width: 38px !important; height: 38px !important; font-size: 0.86rem !important; flex: none; }
|
||||||
|
|
||||||
|
.card__head > div { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.card__head h3 { margin: 0 0 3px; font-size: 1rem; font-weight: 500; color: var(--zk-ink); }
|
||||||
|
.card__head p { margin: 0; font-size: 0.82rem; line-height: 1.45; color: var(--zk-muted); }
|
||||||
|
|
||||||
|
.card__head button {
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--zk-muted);
|
||||||
|
}
|
||||||
|
.card__head button svg { width: 14px; height: 14px; }
|
||||||
|
.card__head button:hover { background: var(--zk-tint); color: var(--zk-ink); }
|
||||||
|
.card__head button:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
|
||||||
|
|
||||||
|
.card__body { overflow-y: auto; padding: 18px 22px 20px; }
|
||||||
|
|
||||||
|
.card__body section + section { margin-top: 20px; }
|
||||||
|
|
||||||
|
.card__body h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--zk-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__body p { margin: 0; font-size: 0.84rem; line-height: 1.55; color: var(--zk-muted); }
|
||||||
|
|
||||||
|
.card__tools { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.card__tools li {
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid var(--zk-line);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--zk-white);
|
||||||
|
}
|
||||||
|
.card__tools code {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--zk-blue-dark);
|
||||||
|
}
|
||||||
|
.card__tools span { font-size: 0.81rem; line-height: 1.5; color: var(--zk-muted); }
|
||||||
|
|
||||||
|
/* "No tools" is the most reassuring fact about an agent writing into an
|
||||||
|
insurance file. An empty section would read as missing data instead. */
|
||||||
|
.card__none {
|
||||||
|
padding: 11px 13px;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--zk-tint);
|
||||||
|
border: 1px solid var(--zk-line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__kb { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.card__kb li {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 4px 11px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--zk-tint-blue);
|
||||||
|
color: var(--zk-blue-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__foot {
|
||||||
|
margin-top: 20px !important;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--zk-line-soft);
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
color: var(--zk-grey) !important;
|
||||||
|
}
|
||||||
109
src/components/AgentCard.jsx
Normal file
109
src/components/AgentCard.jsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { AGENTS } from '../api/agents.js'
|
||||||
|
import './AgentCard.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this agent is, and what it can actually reach.
|
||||||
|
*
|
||||||
|
* The question anybody asks the first time they watch an AI do a step of their
|
||||||
|
* job is not "how does it work" — it is "what is it ALLOWED to touch". That
|
||||||
|
* answer is short here and it is reassuring, so it should be one click away
|
||||||
|
* rather than something a demo has to be trusted on: four of the five hold no
|
||||||
|
* tools at all and reason from the policy wordings; the only tool in the whole
|
||||||
|
* roster reads a partner registry.
|
||||||
|
*
|
||||||
|
* It is also the answer that stopped being true once, silently. Intake held a
|
||||||
|
* tool that could telephone customers, its charter never mentioned it, and it
|
||||||
|
* used it on every lead believing it was a duplicate check. Nobody could see
|
||||||
|
* that from any screen. This is the screen that would have shown it.
|
||||||
|
*/
|
||||||
|
export default function AgentCard({ agentKey, onClose }) {
|
||||||
|
const a = AGENTS[agentKey]
|
||||||
|
const ref = useRef(null)
|
||||||
|
const restore = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
restore.current = document.activeElement
|
||||||
|
ref.current?.focus()
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') onClose() }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
const prev = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
document.body.style.overflow = prev
|
||||||
|
if (restore.current instanceof HTMLElement) restore.current.focus()
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
if (!a) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="prose__scrim" onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className="prose__dlg card"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={a.full}
|
||||||
|
tabIndex={-1}
|
||||||
|
ref={ref}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="card__head">
|
||||||
|
<span className={`who__disc who__disc--${a.tone} card__disc`} aria-hidden="true">{a.initials}</span>
|
||||||
|
<div>
|
||||||
|
<h3>{a.full}</h3>
|
||||||
|
<p>{a.does.charAt(0).toUpperCase() + a.does.slice(1)}.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} aria-label="Close">
|
||||||
|
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card__body">
|
||||||
|
<section>
|
||||||
|
<h4>When it runs</h4>
|
||||||
|
<p>{a.wakes}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4>What it can reach</h4>
|
||||||
|
{a.tools.length ? (
|
||||||
|
<ul className="card__tools">
|
||||||
|
{a.tools.map((t) => (
|
||||||
|
<li key={t.name}>
|
||||||
|
<code>{t.name}</code>
|
||||||
|
<span>{t.does}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
/* Stated positively rather than left blank. "No tools" is the
|
||||||
|
single most reassuring fact about an agent that writes into an
|
||||||
|
insurance file, and an empty section reads as missing data. */
|
||||||
|
<p className="card__none">
|
||||||
|
No tools. It reads the lead and the policy wordings, and writes its answer
|
||||||
|
back into the workflow — nothing else.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4>What it reads</h4>
|
||||||
|
<ul className="card__kb">
|
||||||
|
{a.knowledge.map((k) => <li key={k}>{k}</li>)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="card__foot">
|
||||||
|
Every step it takes is recorded against the lead, with what it wrote and why.
|
||||||
|
It cannot confirm a premium, issue a policy or telephone anybody.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -33,3 +33,26 @@
|
|||||||
.who__disc--amber { background: #a8651a; }
|
.who__disc--amber { background: #a8651a; }
|
||||||
.who__disc--plum { background: #8a3d6b; }
|
.who__disc--plum { background: #8a3d6b; }
|
||||||
.who__disc--grey { background: #6b7280; }
|
.who__disc--grey { background: #6b7280; }
|
||||||
|
|
||||||
|
/* The chip becomes a control only where a card exists to open. */
|
||||||
|
.who--btn {
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 8px 2px 2px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--r-pill, 999px);
|
||||||
|
background: transparent;
|
||||||
|
transition: background .12s, border-color .12s;
|
||||||
|
}
|
||||||
|
.who--btn:hover { background: var(--zk-tint); border-color: var(--zk-line); }
|
||||||
|
.who--btn:hover .who__i { opacity: 1; }
|
||||||
|
.who--btn:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
|
||||||
|
|
||||||
|
.who__i {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
flex: none;
|
||||||
|
color: var(--zk-grey);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .12s;
|
||||||
|
}
|
||||||
|
|||||||
@ -12,16 +12,41 @@ import './AgentChip.css'
|
|||||||
* the humans are plain text reads as though the machines are the important
|
* the humans are plain text reads as though the machines are the important
|
||||||
* ones, which is the wrong way round on a screen whose job is oversight.
|
* ones, which is the wrong way round on a screen whose job is oversight.
|
||||||
*/
|
*/
|
||||||
export default function AgentChip({ agentKey, name, size = 'sm', showName = true }) {
|
export default function AgentChip({ agentKey, name, size = 'sm', showName = true, onOpen }) {
|
||||||
const a = agentKey ? AGENTS[agentKey] : null
|
const a = agentKey ? AGENTS[agentKey] : null
|
||||||
const label = a ? a.short : (name || 'System')
|
const label = a ? a.short : (name || 'System')
|
||||||
const initials = a ? a.initials : initialsOf(name)
|
const initials = a ? a.initials : initialsOf(name)
|
||||||
const tone = a ? a.tone : 'grey'
|
const tone = a ? a.tone : 'grey'
|
||||||
|
|
||||||
return (
|
const body = (
|
||||||
<span className={`who who--${size}`} title={a ? `${a.full} — ${a.does}` : name || 'System'}>
|
<>
|
||||||
<span className={`who__disc who__disc--${tone}`} aria-hidden="true">{initials}</span>
|
<span className={`who__disc who__disc--${tone}`} aria-hidden="true">{initials}</span>
|
||||||
{showName ? <span className="who__name">{label}</span> : null}
|
{showName ? <span className="who__name">{label}</span> : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only an AGENT opens a card. A person's disc is a label — there is nothing
|
||||||
|
// to explain about a human being that this app is entitled to show.
|
||||||
|
if (a && onOpen) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`who who--${size} who--btn`}
|
||||||
|
onClick={() => onOpen(agentKey)}
|
||||||
|
title={`${a.full} — what it does and what it can reach`}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
<svg className="who__i" viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<circle cx="7" cy="7" r="5.6" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
||||||
|
<path d="M7 6.2v3.4M7 4.3v.9" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`who who--${size}`} title={a ? `${a.full} — ${a.does}` : name || 'System'}>
|
||||||
|
{body}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -245,3 +245,47 @@
|
|||||||
.tl__doc svg { width: 12px; height: 12px; flex: none; opacity: .7; }
|
.tl__doc svg { width: 12px; height: 12px; flex: none; opacity: .7; }
|
||||||
.tl__doc:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
|
.tl__doc:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
|
||||||
.tl__doc:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
|
.tl__doc:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
|
||||||
|
|
||||||
|
/* ── the conversation, as one entry ───────────────────────────────────────
|
||||||
|
Every WhatsApp turn used to be its own entry, so a four-message exchange
|
||||||
|
took more of the trail than the whole underwriting chain. */
|
||||||
|
.tl__wa {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #0f7b45;
|
||||||
|
}
|
||||||
|
.tl__wa svg { width: 15px; height: 15px; }
|
||||||
|
|
||||||
|
.tl__n {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--zk-muted);
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: var(--r-pill, 999px);
|
||||||
|
background: var(--zk-line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The channel performs these as the system, but the person who typed is the
|
||||||
|
actor worth naming. "System · Customer Reply" said the opposite. */
|
||||||
|
.tl__who--cust { color: #0f7b45; font-weight: 500; }
|
||||||
|
|
||||||
|
.tl__open {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--zk-blue);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.tl__open:hover { color: var(--zk-blue-dark); text-decoration: underline; }
|
||||||
|
.tl__open:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* A chat entry's dot takes the channel's colour, so a scan down the rail shows
|
||||||
|
where the conversation happened without reading a word. */
|
||||||
|
.tl__item--chat .tl__dot { background: #0f7b45; border-color: #0f7b45; }
|
||||||
|
|||||||
@ -100,7 +100,10 @@ function filesIn(value) {
|
|||||||
* 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.
|
||||||
*/
|
*/
|
||||||
export default function Timeline({ rows }) {
|
/** The two activities that ARE the WhatsApp thread. */
|
||||||
|
const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer'])
|
||||||
|
|
||||||
|
export default function Timeline({ rows, onOpenAgent, onOpenChat }) {
|
||||||
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
|
||||||
@ -206,6 +209,10 @@ export default function Timeline({ rows }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
const stage = STAGES.find((s) => s.uid === r.execution_state)
|
const stage = STAGES.find((s) => s.uid === r.execution_state)
|
||||||
// A self-loop — Capture Motor Risk runs inside Document Pending and settles
|
// A self-loop — Capture Motor Risk runs inside Document Pending and settles
|
||||||
// back into it — is not a move, and printing "→ Document Pending" against
|
// back into it — is not a move, and printing "→ Document Pending" against
|
||||||
@ -215,10 +222,11 @@ export default function Timeline({ rows }) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
key: r.id ?? i,
|
key: r.id ?? i,
|
||||||
kind,
|
kind: isChat ? 'chat' : kind,
|
||||||
|
isChat,
|
||||||
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: isSystem ? 'Data updated' : (r.activity_name || prettyUid(r.activity_id)),
|
what: isChat ? 'WhatsApp' : isSystem ? 'Data updated' : (r.activity_name || prettyUid(r.activity_id)),
|
||||||
stage: moved ? stage : null,
|
stage: moved ? stage : null,
|
||||||
when: when(r.created_at),
|
when: when(r.created_at),
|
||||||
docs,
|
docs,
|
||||||
@ -231,8 +239,40 @@ export default function Timeline({ rows }) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const sysCount = items.filter((it) => it.kind === 'sys').length
|
/**
|
||||||
const visible = showSys ? items : items.filter((it) => it.kind !== 'sys')
|
* THE CONVERSATION IS ONE THING, NOT NINE.
|
||||||
|
*
|
||||||
|
* Every WhatsApp turn was its own entry — "Customer Reply", "Customer
|
||||||
|
* Reply", "Customer Reply", "Answer the Customer" — each with a heading, an
|
||||||
|
* actor and a quote box, so a four-message exchange occupied more of the
|
||||||
|
* trail than the entire underwriting chain. A resend made it worse: the same
|
||||||
|
* sentence printed four times because the customer sent it four times.
|
||||||
|
*
|
||||||
|
* A contiguous run of chat turns now collapses to one entry that says how
|
||||||
|
* many messages, shows the last of them, and opens the thread. The trail goes
|
||||||
|
* back to being a list of decisions, and the conversation goes back to being
|
||||||
|
* a conversation.
|
||||||
|
*
|
||||||
|
* Contiguous, not global: a second exchange after underwriting is a separate
|
||||||
|
* episode in this lead's story and should read as one.
|
||||||
|
*/
|
||||||
|
const grouped = []
|
||||||
|
for (const it of items) {
|
||||||
|
const last = grouped[grouped.length - 1]
|
||||||
|
if (it.isChat && last && last.isChat) {
|
||||||
|
last.count += 1
|
||||||
|
last.when = it.when
|
||||||
|
// Keep the newest line as the preview — an operator scanning the trail
|
||||||
|
// wants where the conversation GOT to, not where it started.
|
||||||
|
if (it.narrative.length) last.narrative = it.narrative
|
||||||
|
last.docs = last.docs.concat(it.docs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
grouped.push({ ...it, count: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const sysCount = grouped.filter((it) => it.kind === 'sys').length
|
||||||
|
const visible = showSys ? grouped : grouped.filter((it) => it.kind !== 'sys')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -248,16 +288,36 @@ export default function Timeline({ rows }) {
|
|||||||
<span className="tl__dot" aria-hidden="true" />
|
<span className="tl__dot" aria-hidden="true" />
|
||||||
<div className="tl__body">
|
<div className="tl__body">
|
||||||
<div className="tl__head">
|
<div className="tl__head">
|
||||||
<strong className="tl__what">{it.what}</strong>
|
{it.isChat ? (
|
||||||
{it.stage ? <span className="tl__stage">→ {it.stage.name}</span> : null}
|
<span className="tl__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
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<strong className="tl__what">{it.what}</strong>
|
||||||
|
)}
|
||||||
|
{it.count > 1 ? <span className="tl__n">{it.count} messages</span> : null}
|
||||||
|
{it.stage && !it.isChat ? <span className="tl__stage">→ {it.stage.name}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
{/* The worker, as a face. Recognition down a column beats
|
{/* The worker, as a face. Recognition down a column beats
|
||||||
reading five names to notice it was one agent throughout. */}
|
reading five names to notice it was one agent throughout. */}
|
||||||
<div className="tl__meta">
|
<div className="tl__meta">
|
||||||
{it.kind === 'sys'
|
{it.kind === 'sys'
|
||||||
? <span className="tl__who tl__who--sys">system</span>
|
? <span className="tl__who tl__who--sys">system</span>
|
||||||
: <AgentChip agentKey={it.agentKey} name={it.actor} />}
|
: it.isChat && !it.agentKey
|
||||||
|
? <span className="tl__who tl__who--cust">the customer</span>
|
||||||
|
: <AgentChip agentKey={it.agentKey} name={it.actor} onOpen={onOpenAgent} />}
|
||||||
<span className="tl__when">{it.when}</span>
|
<span className="tl__when">{it.when}</span>
|
||||||
|
{it.isChat && onOpenChat ? (
|
||||||
|
<button type="button" className="tl__open" onClick={onOpenChat}>
|
||||||
|
Open thread
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* What was received, named and openable. The count is stated
|
{/* What was received, named and openable. The count is stated
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useZino } from '../api/provider.jsx'
|
import { useZino } from '../api/provider.jsx'
|
||||||
import ActivityForm from '../components/ActivityForm.jsx'
|
import ActivityForm from '../components/ActivityForm.jsx'
|
||||||
|
import AgentCard from '../components/AgentCard.jsx'
|
||||||
import AgentChip from '../components/AgentChip.jsx'
|
import AgentChip from '../components/AgentChip.jsx'
|
||||||
import ClampText from '../components/ClampText.jsx'
|
import ClampText from '../components/ClampText.jsx'
|
||||||
import Conversation from '../components/Conversation.jsx'
|
import Conversation from '../components/Conversation.jsx'
|
||||||
@ -150,6 +151,7 @@ export default function Lead() {
|
|||||||
// 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)
|
const [showChat, setShowChat] = useState(false)
|
||||||
|
const [openAgent, setOpenAgent] = useState(null)
|
||||||
|
|
||||||
const load = useCallback((quiet = false) => {
|
const load = useCallback((quiet = false) => {
|
||||||
if (!quiet) setErr(null)
|
if (!quiet) setErr(null)
|
||||||
@ -647,11 +649,15 @@ export default function Lead() {
|
|||||||
{worked.length ? (
|
{worked.length ? (
|
||||||
<div className="crew">
|
<div className="crew">
|
||||||
<span className="crew__l">Worked by</span>
|
<span className="crew__l">Worked by</span>
|
||||||
{worked.map((k) => <AgentChip key={k} agentKey={k} />)}
|
{worked.map((k) => <AgentChip key={k} agentKey={k} onOpen={setOpenAgent} />)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="lead__feed">
|
<div className="lead__feed">
|
||||||
<Timeline rows={audit} />
|
<Timeline
|
||||||
|
rows={audit}
|
||||||
|
onOpenAgent={setOpenAgent}
|
||||||
|
onOpenChat={() => setShowChat(true)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -660,6 +666,9 @@ export default function Lead() {
|
|||||||
{showChat ? (
|
{showChat ? (
|
||||||
<Conversation rows={audit} name={row.customer_name} onClose={() => setShowChat(false)} />
|
<Conversation rows={audit} name={row.customer_name} onClose={() => setShowChat(false)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{openAgent ? (
|
||||||
|
<AgentCard agentKey={openAgent} onClose={() => setOpenAgent(null)} />
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user