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 (
+
+ ) : (
+ /* 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 = (
+ <>
{initials}
{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 }) {