diff --git a/src/api/agents.js b/src/api/agents.js index 3321296..8652cf0 100644 --- a/src/api/agents.js +++ b/src/api/agents.js @@ -12,28 +12,79 @@ * 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. * + * `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. */ export const AGENTS = { 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', + 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: { - 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', + 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: { - short: 'Advisor', full: 'Advisor', initials: 'AD', tone: 'teal', - does: 'reads the risk and recommends the cover', + short: 'Advisor', + 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: { - 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', + 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: { - short: 'Referral', full: 'Underwriting Referral', initials: 'UW', tone: 'plum', - does: 'prepares a referred file so an underwriter can decide', + short: 'Referral', + 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)'], }, } diff --git a/src/components/AgentCard.css b/src/components/AgentCard.css new file mode 100644 index 0000000..9ee1b92 --- /dev/null +++ b/src/components/AgentCard.css @@ -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; +} diff --git a/src/components/AgentCard.jsx b/src/components/AgentCard.jsx new file mode 100644 index 0000000..cf9ab63 --- /dev/null +++ b/src/components/AgentCard.jsx @@ -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 ( +
+
e.stopPropagation()} + > +
+ +
+

{a.full}

+

{a.does.charAt(0).toUpperCase() + a.does.slice(1)}.

+
+ +
+ +
+
+

When it runs

+

{a.wakes}

+
+ +
+

What it can reach

+ {a.tools.length ? ( +
    + {a.tools.map((t) => ( +
  • + {t.name} + {t.does} +
  • + ))} +
+ ) : ( + /* 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. */ +

+ No tools. It reads the lead and the policy wordings, and writes its answer + back into the workflow — nothing else. +

+ )} +
+ +
+

What it reads

+
    + {a.knowledge.map((k) =>
  • {k}
  • )} +
+
+ +

+ 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. +

+
+
+
+ ) +} diff --git a/src/components/AgentChip.css b/src/components/AgentChip.css index 8432410..17de42e 100644 --- a/src/components/AgentChip.css +++ b/src/components/AgentChip.css @@ -33,3 +33,26 @@ .who__disc--amber { background: #a8651a; } .who__disc--plum { background: #8a3d6b; } .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; +} diff --git a/src/components/AgentChip.jsx b/src/components/AgentChip.jsx index 077b977..158157c 100644 --- a/src/components/AgentChip.jsx +++ b/src/components/AgentChip.jsx @@ -12,16 +12,41 @@ import './AgentChip.css' * 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. */ -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 label = a ? a.short : (name || 'System') const initials = a ? a.initials : initialsOf(name) const tone = a ? a.tone : 'grey' - return ( - + const body = ( + <> {showName ? {label} : 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 ( + + ) + } + + return ( + + {body} ) } diff --git a/src/components/Timeline.css b/src/components/Timeline.css index 2503d37..f4b94f4 100644 --- a/src/components/Timeline.css +++ b/src/components/Timeline.css @@ -245,3 +245,47 @@ .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: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; } diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 99fb46f..6a040cf 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -100,7 +100,10 @@ function filesIn(value) { * 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 }) { +/** 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() // 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 @@ -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) // A self-loop — Capture Motor Risk runs inside Document Pending and settles // back into it — is not a move, and printing "→ Document Pending" against @@ -215,10 +222,11 @@ export default function Timeline({ rows }) { return { key: r.id ?? i, - kind, + kind: isChat ? 'chat' : kind, + isChat, agentKey: aiRole || null, 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, when: when(r.created_at), 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 ( <> @@ -248,16 +288,36 @@ export default function Timeline({ rows }) {