From 3bc7d4c35575865b1192faa2a89af60ebaeef24d Mon Sep 17 00:00:00 2001 From: Yashas Date: Thu, 10 Sep 2026 17:27:46 +0530 Subject: [PATCH] Everything the console can do, on the phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone app shipped with the API layer and the timeline copied from the console but none of its dialogs, so several things it appeared to offer did nothing at all: - Tapping a WhatsApp exchange in the trail called an onOpenChat that was never passed. The entry rendered, said "Open the conversation — all 7 messages", and there was no conversation to open. - No screen had a New lead button. The entry activity, its permissions and its form were all present and unreachable. - An agent chip opened nothing; the call transcript and the recording were unreachable; `call_transcript` was rendered as a field value, which is two thousand characters cut at a hundred and fifty. - The record showed its first three groups and truncated every long value mid-sentence. There was no way to see the other sixty-odd fields. - The action bar rendered only the stage's `do` actions, so Mark Lost, the re-quote, the reminders and every recovery lever were unreachable — four stages offered a partner agent one button and no way to drop a lead the system was working. - `nudgeFor` was called as nudgeFor(stage.uid, roles) rather than nudgeFor(stage, lead), so `stage.uid` was undefined, the switch fell through, and the way out of a stalled lead was never offered. - The journey rail marked every step left of the current one as done, which claims steps that never happened: a lead whose documents arrived from Qualified skips Contacted, and a callback sends it backwards. The dialogs are COPIED from the console rather than rewritten — the conversation, the call, the agent card, the lead file, the new-lead form, and the support desk — for the same reason api/ and Timeline.jsx already were: the functionality has to be identical, and a second implementation is a second account of the same lead. What makes that work on a phone is one CSS block rather than six components. They all render the console's prose__scrim / prose__dlg chrome, so app.css re-points that chrome at phone width into a bottom sheet: full width, rounded top, a grab handle via ::before so the copied markup is untouched, and env(safe-area-inset-bottom) under the last row. Every dialog in the app becomes phone-native at once, including the two ClampText was already opening. Above 700px the console's centred dialog is kept, which is right for a tablet. Also here: the secondary actions get a sheet of their own, grouped by what they ARE (optional, "lead not moving?", the way out) rather than listed as equals; the record's long values fold instead of being cut, and the whole file opens with a filter; the rail reads the audit for which steps this lead actually entered; the overview grows the sign-off band for the role that owns it; and the drawer grows Ask the desk for ops. Verified: build clean, lint unchanged at the app's own baseline of 15, every class the copied dialogs use resolves in the bundled CSS, and every dialog's prop contract matches its call site. Not verified in a browser — no browser tooling in this session. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 22 +++ src/components/AgentCard.css | 91 ++++++++++ src/components/AgentCard.jsx | 112 ++++++++++++ src/components/AskDesk.css | 121 +++++++++++++ src/components/AskDesk.jsx | 261 +++++++++++++++++++++++++++ src/components/CallTranscript.jsx | 118 ++++++++++++ src/components/Conversation.css | 152 ++++++++++++++++ src/components/Conversation.jsx | 173 ++++++++++++++++++ src/components/LeadFileDialog.css | 65 +++++++ src/components/LeadFileDialog.jsx | 166 +++++++++++++++++ src/components/NewLeadDialog.css | 17 ++ src/components/NewLeadDialog.jsx | 112 ++++++++++++ src/layout/Shell.jsx | 26 ++- src/screens/Lead.jsx | 291 +++++++++++++++++++++++++++--- src/screens/Leads.jsx | 22 ++- src/screens/Overview.jsx | 42 ++++- src/styles/app.css | 174 ++++++++++++++++++ 17 files changed, 1929 insertions(+), 36 deletions(-) create mode 100644 src/components/AgentCard.css create mode 100644 src/components/AgentCard.jsx create mode 100644 src/components/AskDesk.css create mode 100644 src/components/AskDesk.jsx create mode 100644 src/components/CallTranscript.jsx create mode 100644 src/components/Conversation.css create mode 100644 src/components/Conversation.jsx create mode 100644 src/components/LeadFileDialog.css create mode 100644 src/components/LeadFileDialog.jsx create mode 100644 src/components/NewLeadDialog.css create mode 100644 src/components/NewLeadDialog.jsx 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 ( +
+