From f4c67310cbbfff276f6ca47df38d3d7ff8038e85 Mon Sep 17 00:00:00 2001 From: Yashas Date: Tue, 8 Sep 2026 15:41:19 +0530 Subject: [PATCH] console: the agents explain themselves, and the chat reads as a chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/api/agents.js | 65 ++++++++++++++++++--- src/components/AgentCard.css | 88 ++++++++++++++++++++++++++++ src/components/AgentCard.jsx | 109 +++++++++++++++++++++++++++++++++++ src/components/AgentChip.css | 23 ++++++++ src/components/AgentChip.jsx | 31 +++++++++- src/components/Timeline.css | 44 ++++++++++++++ src/components/Timeline.jsx | 76 +++++++++++++++++++++--- src/screens/Lead.jsx | 13 ++++- 8 files changed, 429 insertions(+), 20 deletions(-) create mode 100644 src/components/AgentCard.css create mode 100644 src/components/AgentCard.jsx 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 }) {