diff --git a/README.md b/README.md index 48b325d..caf701c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,28 @@ divergence here would be two apps disagreeing about the same lead: submission writes three rows) is hard-won, and reimplementing it here would mean two different accounts of the same history. +**The dialogs are copied verbatim too** — for the same reason, and because the +functionality has to be identical rather than merely similar: + + src/api/thread.js the WhatsApp thread, built once + src/components/Conversation.jsx the thread, with its gaps accounted for + src/components/CallTranscript.jsx + src/components/AgentCard.jsx what an agent is and what it can reach + src/components/LeadFileDialog.jsx the whole record, with a filter + src/components/NewLeadDialog.jsx filing a lead + src/components/AskDesk.jsx the support desk (ops only) + +They are NOT restyled per file. They all render the console's `prose__scrim` / +`prose__dlg` chrome, and `styles/app.css` re-points that chrome at phone width +into a bottom sheet — full width, rounded top, a grab handle via `::before`, +`env(safe-area-inset-bottom)` under the last row. One block, and every dialog +in the app is phone-native at once, including the two ClampText already opened. +Above 700px the console's centred dialog is kept, which is the right answer for +a tablet. + +Add a dialog to the console and it can be copied here as-is. Restyle one of +them here and the next copy silently reverts it. + **Written fresh for the phone:** every screen, the shell, and the stylesheet. ## The colour rule diff --git a/src/components/AgentCard.css b/src/components/AgentCard.css new file mode 100644 index 0000000..8eebdf3 --- /dev/null +++ b/src/components/AgentCard.css @@ -0,0 +1,91 @@ +/* The agent dialog. Namespaced `agc`, not `card`: the screens use `.card` + for every panel, and a bare `.card { max-height }` here was silently + capping the lead tables at 720px while their rows kept rendering. */ +.agc { max-height: min(82vh, 720px); } + +.agc__head { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 20px 20px 16px 22px; + border-bottom: 1px solid var(--zk-line-soft); +} + +.agc__disc { width: 38px !important; height: 38px !important; font-size: var(--fs-xs) !important; flex: none; } + +.agc__head > div { flex: 1; min-width: 0; } + +.agc__head h3 { margin: 0 0 3px; font-size: var(--fs-md); font-weight: 500; color: var(--zk-ink); } +.agc__head p { margin: 0; font-size: var(--fs-2xs); line-height: 1.45; color: var(--zk-muted); } + +.agc__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); +} +.agc__head button svg { width: 14px; height: 14px; } +.agc__head button:hover { background: var(--zk-tint); color: var(--zk-ink); } +.agc__head button:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; } + +.agc__body { overflow-y: auto; padding: 18px 22px 20px; } + +.agc__body section + section { margin-top: 20px; } + +.agc__body h4 { + margin: 0 0 8px; + font-size: var(--fs-3xs); + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--zk-grey); +} + +.agc__body p { margin: 0; font-size: var(--fs-xs); line-height: 1.55; color: var(--zk-muted); } + +.agc__tools { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; } +.agc__tools li { + padding: 11px 13px; + border: 1px solid var(--zk-line); + border-radius: var(--r-md); + background: var(--zk-white); +} +.agc__tools code { + display: block; + margin-bottom: 5px; + font-size: var(--fs-2xs); + font-weight: 600; + color: var(--zk-blue-dark); +} +.agc__tools span { font-size: var(--fs-2xs); 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. */ +.agc__none { + padding: 11px 13px; + border-radius: var(--r-md); + background: var(--zk-tint); + border: 1px solid var(--zk-line-soft); +} + +.agc__kb { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; } +.agc__kb li { + font-size: var(--fs-2xs); + padding: 4px 11px; + border-radius: var(--r-pill); + background: var(--zk-tint-blue); + color: var(--zk-blue-dark); +} + +.agc__foot { + margin-top: 20px !important; + padding-top: 14px; + border-top: 1px solid var(--zk-line-soft); + font-size: var(--fs-2xs) !important; + color: var(--zk-grey) !important; +} diff --git a/src/components/AgentCard.jsx b/src/components/AgentCard.jsx new file mode 100644 index 0000000..01c1951 --- /dev/null +++ b/src/components/AgentCard.jsx @@ -0,0 +1,112 @@ +import { useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +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 createPortal( +
+
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. +

+
+
+
+ , + document.body, + ) +} diff --git a/src/components/AskDesk.css b/src/components/AskDesk.css new file mode 100644 index 0000000..bb9f616 --- /dev/null +++ b/src/components/AskDesk.css @@ -0,0 +1,121 @@ +/* Ask the desk — a panel over the console, not a page of its own. Whoever + opens it is in the middle of something; it should be dismissible in one + press and leave the work behind it visible. */ + +.desk__scrim { + position: fixed; inset: 0; z-index: 60; + background: rgba(11, 42, 91, .32); + display: flex; justify-content: flex-end; +} + +.desk { + width: min(560px, 100%); height: 100%; + display: flex; flex-direction: column; + background: var(--zk-card); + border-left: 1px solid var(--zk-line); + box-shadow: -18px 0 48px rgba(11, 42, 91, .18); + animation: desk-in .2s var(--ease); +} +@keyframes desk-in { from { transform: translateX(24px); opacity: .6; } to { transform: none; opacity: 1; } } +@media (prefers-reduced-motion: reduce) { .desk { animation: none; } } + +.desk__head { + display: flex; align-items: flex-start; gap: 16px; + padding: 18px 22px 14px; border-bottom: 1px solid var(--zk-line-soft); flex: none; +} +.desk__head h2 { margin: 0; font-size: var(--fs-xl); font-weight: 700; color: var(--zk-ink); } +.desk__head p { margin: 3px 0 0; font-size: var(--fs-xs); color: var(--zk-muted); max-width: 46ch; } +.desk__x { + margin-left: auto; flex: none; width: 32px; height: 32px; border-radius: 50%; + border: 1px solid var(--zk-line); background: #fff; color: var(--zk-muted); + font-size: 20px; line-height: 1; cursor: pointer; +} +.desk__x:hover { background: var(--zk-tint); color: var(--zk-ink); } + +.desk__feed { + flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; + padding: 18px 22px; display: flex; flex-direction: column; gap: 16px; +} + +/* Nothing asked yet: say what it is for, and offer four real questions. */ +.desk__empty p { margin: 0 0 12px; font-size: var(--fs-sm); color: var(--zk-muted); } +.desk__sugg { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; } +.desk__sugg button { + font: inherit; font-size: var(--fs-xs); text-align: left; cursor: pointer; + padding: 9px 14px; border-radius: var(--r-sm); + border: 1px solid var(--zk-line); background: #fff; color: var(--zk-ink); +} +.desk__sugg button:hover:not(:disabled) { border-color: var(--zk-blue-light); background: var(--zk-tint-blue); color: var(--zk-navy); } +.desk__sugg button:disabled { opacity: .5; cursor: default; } + +.desk__row { display: flex; } +.desk__row--user { justify-content: flex-end; } + +/* The question, as the operator wrote it. */ +.desk__q { + margin: 0; max-width: 82%; + padding: 10px 14px; border-radius: var(--r-md) var(--r-md) var(--r-xs) var(--r-md); + background: var(--zk-navy); color: #fff; font-size: var(--fs-sm); line-height: 1.5; +} + +/* The answer. Left-aligned and unboxed, because it is prose to be read, not a + message in a chat to be scanned. */ +.desk__a { max-width: 100%; font-size: var(--fs-sm); color: var(--zk-ink); } +.desk__a p { margin: 0 0 10px; line-height: 1.55; } +.desk__a h4 { margin: 14px 0 6px; font-size: var(--fs-md); font-weight: 700; } +.desk__a ul, .desk__a ol { margin: 0 0 10px; padding-left: 20px; } +.desk__a li { margin-bottom: 5px; line-height: 1.5; } +.desk__a code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; + background: var(--zk-tint); padding: 1px 5px; border-radius: 4px; +} +.desk__a strong { font-weight: 700; } + +/* What it actually ran to answer — its working, not just its conclusion. */ +.desk__tools { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; } +.desk__tools span { + font-size: var(--fs-3xs); color: var(--zk-muted); + background: var(--zk-line-soft); padding: 2px 8px; border-radius: var(--r-pill); +} + +.desk__wait { display: flex; align-items: center; gap: 10px; font-size: var(--fs-xs); color: var(--zk-muted); } +.desk__dots { display: inline-flex; gap: 4px; } +.desk__dots i { width: 6px; height: 6px; border-radius: 50%; background: var(--zk-blue); animation: desk-blink 1.2s infinite; } +.desk__dots i:nth-child(2) { animation-delay: .2s; } +.desk__dots i:nth-child(3) { animation-delay: .4s; } +@keyframes desk-blink { 0%, 60%, 100% { opacity: .25; } 30% { opacity: 1; } } +@media (prefers-reduced-motion: reduce) { .desk__dots i { animation: none; opacity: .6; } } + +.desk__err { + border: 1px solid var(--zk-danger-line); background: var(--zk-danger-tint); + border-radius: var(--r-md); padding: 12px 14px; display: flex; flex-direction: column; gap: 3px; +} +.desk__err strong { font-size: var(--fs-sm); color: var(--zk-danger-ink); } +.desk__err span { font-size: var(--fs-2xs); color: var(--zk-muted); } + +.desk__ask { + flex: none; display: flex; gap: 10px; align-items: flex-end; + padding: 14px 22px 18px; border-top: 1px solid var(--zk-line-soft); background: #fff; +} +.desk__ask textarea { + flex: 1; resize: none; font: inherit; font-size: var(--fs-sm); color: var(--zk-ink); + padding: 10px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-sm); background: #fff; +} +.desk__ask textarea:focus { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); } +.desk__ask textarea:disabled { background: var(--zk-tint); color: var(--zk-grey); } + +@media (max-width: 640px) { + .desk { width: 100%; border-left: 0; } + .desk__head, .desk__feed, .desk__ask { padding-left: 16px; padding-right: 16px; } +} + +/* Ask the desk for anything countable and it answers with a table. */ +.desk__tablewrap { overflow-x: auto; margin: 0 0 12px; } +.desk__a table { border-collapse: collapse; width: 100%; font-size: var(--fs-xs); } +.desk__a th { + text-align: left; font-weight: 600; color: var(--zk-grey); white-space: nowrap; + padding: 7px 12px 7px 0; border-bottom: 1px solid var(--zk-line); +} +.desk__a td { padding: 7px 12px 7px 0; border-bottom: 1px solid var(--zk-line-soft); } +.desk__a th:last-child, .desk__a td:last-child { padding-right: 0; text-align: right; font-variant-numeric: tabular-nums; } +.desk__a tr:last-child td { border-bottom: 0; } diff --git a/src/components/AskDesk.jsx b/src/components/AskDesk.jsx new file mode 100644 index 0000000..aa72756 --- /dev/null +++ b/src/components/AskDesk.jsx @@ -0,0 +1,261 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { SUPPORT_AGENT_ID } from '../api/config.js' +import { useZino } from '../api/provider.jsx' +import { describeError } from '../api/errors.js' +import './AskDesk.css' + +/** + * Ask the desk. + * + * A support desk on the platform, bound to this app, that reads the deployed + * config and the live book through read-only tools and answers with evidence. + * It is a different kind of thing from the five AI employees: they DO the work + * and write to the workflow; this one only reads, and only to answer a person's + * question. Nothing it says changes a lead. + * + * The panel is deliberately thin. The desk is an ordinary interactive agent on + * the platform, so a conversation is a session and a question is a message on + * it; the console's own token is accepted on those routes as-is. + * + * Answers take twenty to forty seconds because it runs real queries first, so + * the waiting state says what it is doing rather than showing a spinner. + */ + +const SUGGESTIONS = [ + 'Which leads have not moved in over 12 hours?', + 'How many leads are in each stage right now?', + 'What reason did the AI give for each underwriting referral?', + 'Total written premium, and how many policies were issued?', +] + +/* The desk answers in markdown. Rather than carry a parser for five features, + this handles exactly what it uses: headings, bullets, numbered steps, tables, + bold and inline code. Anything else falls through as text, which is the right + failure — an unrendered asterisk is legible; a crashed panel is not. + + Tables earn their place: asked for anything countable the desk replies with + one, and rendered as raw pipes a correct answer reads like a broken one. */ +function inline(text, key) { + const parts = String(text).split(/(\*\*[^*]+\*\*|`[^`]+`)/g) + return parts.map((p, i) => { + if (p.startsWith('**') && p.endsWith('**')) return {p.slice(2, -2)} + if (p.startsWith('`') && p.endsWith('`')) return {p.slice(1, -1)} + return p + }) +} + +/** `| a | b |` -> ['a','b']; a separator row (`|---|`) has no content. */ +const cells = (line) => line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim()) +const isRow = (line) => /^\s*\|.*\|\s*$/.test(line) +const isRule = (line) => /^\s*\|[\s:|-]+\|\s*$/.test(line) + +function Rich({ text }) { + const lines = String(text || '').split('\n') + const out = [] + let list = null + let rows = null + + const flush = () => { + if (rows) { + const [head, ...body] = rows + out.push( +
+ + {head.map((c, i) => )} + + {body.map((r, ri) => ( + {r.map((c, ci) => )} + ))} + +
{inline(c, `h${i}`)}
{inline(c, `${ri}-${ci}`)}
+
+ ) + rows = null + } + if (!list) return + out.push(list.ordered + ?
    {list.items.map((t, i) =>
  1. {inline(t, `${out.length}-${i}`)}
  2. )}
+ : ) + list = null + } + + lines.forEach((raw, i) => { + const line = raw.trimEnd() + const bullet = line.match(/^\s*[-*]\s+(.*)$/) + const numbered = line.match(/^\s*\d+[.)]\s+(.*)$/) + const heading = line.match(/^#{1,4}\s+(.*)$/) + if (isRow(line)) { + // A rule under the header is markdown's alignment row, not data. The + // desk sometimes omits it and sometimes leaves a blank line between + // rows, so neither is treated as the end of the table. + if (!isRule(line)) { + if (!rows) { flush(); rows = [] } + rows.push(cells(line)) + } + } else if (rows && line.trim() === '') { + /* hold the table open across a blank line */ + } else if (bullet) { + if (!list || list.ordered) { flush(); list = { ordered: false, items: [] } } + list.items.push(bullet[1]) + } else if (numbered) { + if (!list || !list.ordered) { flush(); list = { ordered: true, items: [] } } + list.items.push(numbered[1]) + } else if (heading) { + flush(); out.push(

{inline(heading[1], i)}

) + } else if (line.trim() === '') { + flush() + } else { + flush(); out.push(

{inline(line, i)}

) + } + }) + flush() + return <>{out} +} + +/** The tools a turn ran, as chips — the desk's working, not just its answer. */ +function toolsOf(row) { + if (!row) return [] + const raw = row.metadata?.tool_calls ?? row.metadata?.api_calls ?? row.metadata?.tools + const list = Array.isArray(raw) ? raw : [] + return list.map((t) => (typeof t === 'string' ? t : t?.name || t?.tool_name)).filter(Boolean) +} + +export default function AskDesk({ onClose }) { + const { client } = useZino() + const [sessionId, setSessionId] = useState(null) + const [rows, setRows] = useState([]) + const [text, setText] = useState('') + const [asking, setAsking] = useState(false) + const [err, setErr] = useState(null) + const feedRef = useRef(null) + const inputRef = useRef(null) + + // One conversation per opening of the panel. Reusing the last one would make + // the desk answer this week's question against last week's context, and the + // transcript stays on the platform either way. + useEffect(() => { + let dead = false + client.deskStartSession(SUPPORT_AGENT_ID) + .then((s) => { if (!dead) setSessionId(s?.id ?? s?.data?.id ?? null) }) + .catch((e) => { if (!dead) setErr(e) }) + return () => { dead = true } + }, [client]) + + useEffect(() => { + const el = feedRef.current + if (el) el.scrollTop = el.scrollHeight + }, [rows, asking]) + + useEffect(() => { inputRef.current?.focus() }, [sessionId]) + + const ask = useCallback(async (question) => { + const q = String(question ?? '').trim() + if (!q || !sessionId || asking) return + setErr(null) + setText('') + // The question shows immediately; the answer replaces the waiting line. + setRows((prev) => [...prev, { key: 'q' + Date.now(), role: 'user', content: q }]) + setAsking(true) + try { + const res = await client.deskAsk(sessionId, q) + const answer = res?.agent_message + const called = toolsOf(res?.call_api_message) + setRows((prev) => [...prev, { + key: 'a' + (answer?.id ?? Date.now()), + role: 'agent', + content: answer?.content ?? 'The desk answered with nothing at all.', + tools: called, + }]) + } catch (e) { + setErr(e) + } finally { + setAsking(false) + inputRef.current?.focus() + } + }, [client, sessionId, asking]) + + return ( +
+