Ask the desk, on the phone

The support desk the console got today, brought across: full screen (the
only sensible shape on a phone), the conversation list behind a button like
the queue drawer, and the same charts drawn from the tables the desk
already answers with.

Ops only, and for the same reason as the console: the desk reads the whole
book — every partner, every premium, every commission — so a partner agent
must not open it. The platform enforces that per user; the button is simply
not drawn for anyone else.

Reachable from the app bar and from the drawer, since a question about the
book is as likely to start from a queue as from a lead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-10 21:20:14 +05:30
parent 5b30cfd647
commit 6d6c4931cc
5 changed files with 528 additions and 242 deletions

View File

@ -1,121 +1,167 @@
/* Ask the desk a panel over the console, not a page of its own. Whoever /* Ask the desk, on a phone.
opens it is in the middle of something; it should be dismissible in one
press and leave the work behind it visible. */
.desk__scrim { Full screen is the only sensible shape here there is no "beside" on a
position: fixed; inset: 0; z-index: 60; phone, and the desk's answers are long. Same structure as the console so the
background: rgba(11, 42, 91, .32); two stay one product: a bar, the conversation list behind a button, one
display: flex; justify-content: flex-end; column of prose, the question box pinned to the bottom above the keyboard. */
}
.desk { .desk {
width: min(560px, 100%); height: 100%; position: fixed; inset: 0; z-index: 70;
display: flex; flex-direction: column; display: flex; flex-direction: column;
background: var(--zk-card); background: var(--zk-tint);
border-left: 1px solid var(--zk-line); padding-top: env(safe-area-inset-top);
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__bar {
.desk__empty p { margin: 0 0 12px; font-size: var(--fs-sm); color: var(--zk-muted); } flex: none; display: flex; align-items: center; gap: 12px;
.desk__sugg { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; } height: 56px; padding: 0 14px; background: var(--zk-navy); color: #fff;
.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__id { flex: 1; min-width: 0; }
.desk__sugg button:disabled { opacity: .5; cursor: default; } .desk__id h2 { margin: 0; font-size: var(--fs-md); font-weight: 700; }
.desk__id p { display: none; }
.desk__x, .desk__rails, .desk__new {
flex: none; width: 38px; height: 38px; display: grid; place-items: center;
border: 0; border-radius: 10px; background: transparent; color: #fff; cursor: pointer;
}
.desk__x svg, .desk__rails svg { width: 19px; height: 19px; }
.desk__rails { margin-left: -6px; }
.desk__x:active, .desk__rails:active, .desk__new:active { background: rgba(255,255,255,.16); }
.desk__new { font-size: 0; }
.desk__new::before { content: '+'; font-size: 22px; font-weight: 400; line-height: 1; }
.desk__row { display: flex; } .desk__body { flex: 1; min-height: 0; display: flex; position: relative; }
.desk__row--user { justify-content: flex-end; }
/* The question, as the operator wrote it. */ /* The conversation list slides over, like the queue drawer. */
.desk__rail {
position: absolute; inset: 0 auto 0 0; z-index: 2;
width: min(300px, 84vw); background: #fff; border-right: 1px solid var(--zk-line);
overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 4px;
transform: translateX(-101%); transition: transform .2s var(--ease);
box-shadow: 10px 0 34px rgba(11,42,91,.18);
}
.desk__rail.is-open { transform: none; }
@media (prefers-reduced-motion: reduce) { .desk__rail { transition: none; } }
.desk__railh {
font-size: var(--fs-3xs); font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
color: var(--zk-grey); padding: 4px 8px 8px;
}
.desk__railnone { margin: 4px 8px; font-size: var(--fs-2xs); color: var(--zk-grey); line-height: 1.5; }
.desk__sess {
display: block; width: 100%; text-align: left; cursor: pointer;
padding: 11px 12px; border-radius: 10px; border: 1px solid transparent; background: transparent;
font: inherit; color: var(--zk-ink);
}
.desk__sess.is-on { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
.desk__sesst {
display: -webkit-box; font-size: var(--fs-xs); font-weight: 600; line-height: 1.35;
overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
.desk__sessw { display: block; font-size: var(--fs-3xs); color: var(--zk-grey); margin-top: 3px; }
.desk__main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.desk__feed { flex: 1; min-height: 0; overflow-y: auto; padding: 18px 16px 6px; overscroll-behavior: contain; }
.desk__col { max-width: 720px; margin: 0 auto; }
.desk__turn { margin-bottom: 22px; }
.desk__turn--user { display: flex; justify-content: flex-end; }
.desk__q { .desk__q {
margin: 0; max-width: 82%; margin: 0; max-width: 86%; padding: 11px 15px;
padding: 10px 14px; border-radius: var(--r-md) var(--r-md) var(--r-xs) var(--r-md); border-radius: 14px 14px 4px 14px;
background: var(--zk-navy); color: #fff; font-size: var(--fs-sm); line-height: 1.5; background: var(--zk-navy); color: #fff; font-size: var(--fs-sm); line-height: 1.5;
} }
.desk__a { font-size: var(--fs-sm); color: var(--zk-ink); }
/* The answer. Left-aligned and unboxed, because it is prose to be read, not a .desk__a p { margin: 0 0 11px; line-height: 1.6; }
message in a chat to be scanned. */ .desk__a h4 { margin: 18px 0 7px; font-size: var(--fs-md); font-weight: 700; }
.desk__a { max-width: 100%; font-size: var(--fs-sm); color: var(--zk-ink); } .desk__a ul, .desk__a ol { margin: 0 0 12px; padding-left: 20px; }
.desk__a p { margin: 0 0 10px; line-height: 1.55; } .desk__a li { margin-bottom: 5px; line-height: 1.55; }
.desk__a h4 { margin: 14px 0 6px; font-size: var(--fs-md); font-weight: 700; } .desk__a blockquote {
.desk__a ul, .desk__a ol { margin: 0 0 10px; padding-left: 20px; } margin: 0 0 12px; padding: 9px 14px; border-left: 3px solid var(--zk-amber-bar);
.desk__a li { margin-bottom: 5px; line-height: 1.5; } background: var(--zk-amber-tint); border-radius: 0 10px 10px 0;
}
.desk__a code { .desk__a code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em;
background: var(--zk-tint); padding: 1px 5px; border-radius: 4px; background: var(--zk-tint); padding: 1px 5px; border-radius: 4px;
} }
.desk__a strong { font-weight: 700; } .desk__tablewrap { overflow-x: auto; margin: 0 0 14px; }
.desk__a table { border-collapse: collapse; width: 100%; font-size: var(--fs-2xs); }
.desk__a th {
text-align: left; font-weight: 600; color: var(--zk-grey); white-space: nowrap;
padding: 8px 12px 8px 0; border-bottom: 1px solid var(--zk-line);
}
.desk__a td { padding: 8px 12px 8px 0; border-bottom: 1px solid var(--zk-line-soft); white-space: nowrap; }
.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; }
/* What it actually ran to answer — its working, not just its conclusion. */ .desk__tools { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.desk__tools { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.desk__tools span { .desk__tools span {
font-size: var(--fs-3xs); color: var(--zk-muted); font-size: var(--fs-3xs); color: var(--zk-muted);
background: var(--zk-line-soft); padding: 2px 8px; border-radius: var(--r-pill); background: var(--zk-line-soft); padding: 2px 9px; border-radius: 999px;
} }
.desk__empty { padding: 18px 0 8px; }
.desk__empty h3 { margin: 0 0 6px; font-size: var(--fs-xl); font-weight: 700; color: var(--zk-ink); }
.desk__empty > p { margin: 0 0 18px; font-size: var(--fs-xs); color: var(--zk-muted); line-height: 1.5; }
.desk__sugg { display: flex; flex-direction: column; gap: 9px; }
.desk__sugg button {
font: inherit; font-size: var(--fs-xs); text-align: left; cursor: pointer; min-height: 44px;
padding: 11px 15px; border-radius: 12px;
border: 1px solid var(--zk-line); background: #fff; color: var(--zk-ink);
}
.desk__sugg button:disabled { opacity: .5; }
.desk__wait { display: flex; align-items: center; gap: 10px; font-size: var(--fs-xs); color: var(--zk-muted); } .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 { 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 { 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(2) { animation-delay: .2s; }
.desk__dots i:nth-child(3) { animation-delay: .4s; } .desk__dots i:nth-child(3) { animation-delay: .4s; }
@keyframes desk-blink { 0%, 60%, 100% { opacity: .25; } 30% { opacity: 1; } } @keyframes desk-blink { 0%,60%,100% { opacity: .25; } 30% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .desk__dots i { animation: none; opacity: .6; } } @media (prefers-reduced-motion: reduce) { .desk__dots i { animation: none; opacity: .6; } }
.desk__err { .desk__err {
border: 1px solid var(--zk-danger-line); background: var(--zk-danger-tint); 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; border-radius: 12px; padding: 12px 15px; display: flex; flex-direction: column; gap: 3px;
} }
.desk__err strong { font-size: var(--fs-sm); color: var(--zk-danger-ink); } .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__err span { font-size: var(--fs-2xs); color: var(--zk-muted); }
.desk__ask { .desk__ask {
flex: none; display: flex; gap: 10px; align-items: flex-end; flex: none; border-top: 1px solid var(--zk-line); background: #fff;
padding: 14px 22px 18px; border-top: 1px solid var(--zk-line-soft); background: #fff; padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
} }
.desk__askcol { display: flex; gap: 10px; align-items: flex-end; }
.desk__ask textarea { .desk__ask textarea {
flex: 1; resize: none; font: inherit; font-size: var(--fs-sm); color: var(--zk-ink); 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; padding: 12px 14px; border: 1px solid var(--zk-line); border-radius: 12px;
background: #fff; min-height: 46px; max-height: 140px;
} }
.desk__ask textarea:focus { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); } .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); } .desk__ask textarea:disabled { background: var(--zk-tint); color: var(--zk-grey); }
@media (max-width: 640px) { /* ── Chart drawn from the answer's own table ─────────────────────────────── */
.desk { width: 100%; border-left: 0; } .dchart { margin: 0 0 14px; padding: 14px 15px; border: 1px solid var(--zk-line); border-radius: 14px; background: #fff; }
.desk__head, .desk__feed, .desk__ask { padding-left: 16px; padding-right: 16px; } .dchart__head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: var(--fs-3xs); color: var(--zk-grey); margin-bottom: 13px; }
.dchart__toggle { margin-left: auto; display: inline-flex; border: 1px solid var(--zk-line); border-radius: 999px; overflow: hidden; }
.dchart__toggle button {
font: inherit; font-size: var(--fs-3xs); font-weight: 600; cursor: pointer;
padding: 6px 13px; border: 0; background: #fff; color: var(--zk-muted);
} }
.dchart__toggle button.is-on { background: var(--zk-navy); color: #fff; }
/* Ask the desk for anything countable and it answers with a table. */ .dchart__bars { display: flex; flex-direction: column; gap: 9px; }
.desk__tablewrap { overflow-x: auto; margin: 0 0 12px; } .dchart__row { display: grid; grid-template-columns: minmax(80px, 38%) 1fr auto; gap: 10px; align-items: center; }
.desk__a table { border-collapse: collapse; width: 100%; font-size: var(--fs-xs); } .dchart__lab { font-size: var(--fs-3xs); color: var(--zk-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.desk__a th { .dchart__track { height: 15px; background: var(--zk-line-soft); border-radius: 3px; overflow: hidden; }
text-align: left; font-weight: 600; color: var(--zk-grey); white-space: nowrap; .dchart__fill { display: block; height: 100%; border-radius: 3px; }
padding: 7px 12px 7px 0; border-bottom: 1px solid var(--zk-line); .dchart__val { font-size: var(--fs-3xs); font-variant-numeric: tabular-nums; color: var(--zk-muted); white-space: nowrap; }
} .dchart__cap { margin: 10px 0 0; font-size: var(--fs-3xs); color: var(--zk-grey); }
.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; } .dchart__donut { display: flex; flex-direction: column; gap: 16px; align-items: center; }
.desk__a tr:last-child td { border-bottom: 0; } .dchart__donut svg { width: 150px; height: 150px; flex: none; }
.dchart__total { font-size: 21px; font-weight: 700; fill: var(--zk-ink); font-variant-numeric: tabular-nums; }
.dchart__totlab { font-size: 10px; fill: var(--zk-grey); letter-spacing: .06em; text-transform: uppercase; }
.dchart__key { list-style: none; margin: 0; padding: 0; width: 100%; display: flex; flex-direction: column; gap: 7px; }
.dchart__key li { display: grid; grid-template-columns: 10px 1fr auto auto; gap: 10px; align-items: center; font-size: var(--fs-3xs); }
.dchart__sw { width: 10px; height: 10px; border-radius: 2px; }
.dchart__keylab { color: var(--zk-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dchart__keyval { color: var(--zk-muted); font-variant-numeric: tabular-nums; }
.dchart__keypct { color: var(--zk-grey); font-variant-numeric: tabular-nums; min-width: 32px; text-align: right; }

View File

@ -1,261 +1,344 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { SUPPORT_AGENT_ID } from '../api/config.js' import { SUPPORT_AGENT_ID } from '../api/config.js'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import { describeError } from '../api/errors.js' import { describeError } from '../api/errors.js'
import DeskChart, { chartableFrom } from './DeskChart.jsx'
import './AskDesk.css' import './AskDesk.css'
/** /**
* Ask the desk. * Ask the desk the support desk, full screen.
* *
* A support desk on the platform, bound to this app, that reads the deployed * 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. * 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 * A different kind of thing from the five AI employees: they DO the work and
* and write to the workflow; this one only reads, and only to answer a person's * write to the workflow; this one only reads, to answer a person's question.
* question. Nothing it says changes a lead. * Nothing it says changes a lead.
* *
* The panel is deliberately thin. The desk is an ordinary interactive agent on * Full screen rather than a side panel, because the answers are long a
* the platform, so a conversation is a session and a question is a message on * timeline, a table of every stalled lead and reading them in a 560px gutter
* it; the console's own token is accepted on those routes as-is. * meant scrolling a column while the page behind it sat unused. The
* * conversation list comes with it: an investigation is worth returning to, and
* Answers take twenty to forty seconds because it runs real queries first, so * every conversation opens a ticket on the platform whether or not anyone
* the waiting state says what it is doing rather than showing a spinner. * reads it back.
*/ */
const SUGGESTIONS = [ const SUGGESTIONS = [
'Which leads have not moved in over 12 hours?',
'How many leads are in each stage right now?', 'How many leads are in each stage right now?',
'Which leads have not moved in over 12 hours?',
'What reason did the AI give for each underwriting referral?', 'What reason did the AI give for each underwriting referral?',
'Total written premium, and how many policies were issued?', 'Total written premium, and how many policies were issued?',
] ]
/* The desk answers in markdown. Rather than carry a parser for five features, /* The desk answers in markdown. This renders the five things it uses:
this handles exactly what it uses: headings, bullets, numbered steps, tables, headings, lists, tables, bold and inline code. Anything else falls through
bold and inline code. Anything else falls through as text, which is the right as text, which is the right failure an unrendered asterisk is legible; a
failure an unrendered asterisk is legible; a crashed panel is not. 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) { function inline(text, key) {
const parts = String(text).split(/(\*\*[^*]+\*\*|`[^`]+`)/g) return String(text).split(/(\*\*[^*]+\*\*|`[^`]+`)/g).map((p, i) => {
return parts.map((p, i) => {
if (p.startsWith('**') && p.endsWith('**')) return <strong key={`${key}-${i}`}>{p.slice(2, -2)}</strong> if (p.startsWith('**') && p.endsWith('**')) return <strong key={`${key}-${i}`}>{p.slice(2, -2)}</strong>
if (p.startsWith('`') && p.endsWith('`')) return <code key={`${key}-${i}`}>{p.slice(1, -1)}</code> if (p.startsWith('`') && p.endsWith('`')) return <code key={`${key}-${i}`}>{p.slice(1, -1)}</code>
return p 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 cells = (line) => line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim())
const isRow = (line) => /^\s*\|.*\|\s*$/.test(line) const isRow = (line) => /^\s*\|.*\|\s*$/.test(line)
const isRule = (line) => /^\s*\|[\s:|-]+\|\s*$/.test(line) const isRule = (line) => /^\s*\|[\s:|-]+\|\s*$/.test(line)
function Rich({ text }) { /** Parses one answer into blocks, so a table can be drawn as well as printed. */
function parse(text) {
const lines = String(text || '').split('\n') const lines = String(text || '').split('\n')
const out = [] const out = []
let list = null let list = null
let rows = null let rows = null
const flush = () => { const flushList = () => {
if (rows) {
const [head, ...body] = rows
out.push(
<div className="desk__tablewrap" key={`t${out.length}`}>
<table>
<thead><tr>{head.map((c, i) => <th key={i}>{inline(c, `h${i}`)}</th>)}</tr></thead>
<tbody>
{body.map((r, ri) => (
<tr key={ri}>{r.map((c, ci) => <td key={ci}>{inline(c, `${ri}-${ci}`)}</td>)}</tr>
))}
</tbody>
</table>
</div>
)
rows = null
}
if (!list) return if (!list) return
out.push(list.ordered out.push({ t: list.ordered ? 'ol' : 'ul', items: list.items })
? <ol key={`l${out.length}`}>{list.items.map((t, i) => <li key={i}>{inline(t, `${out.length}-${i}`)}</li>)}</ol>
: <ul key={`l${out.length}`}>{list.items.map((t, i) => <li key={i}>{inline(t, `${out.length}-${i}`)}</li>)}</ul>)
list = null list = null
} }
const flushTable = () => {
if (!rows) return
out.push({ t: 'table', head: rows[0], body: rows.slice(1) })
rows = null
}
const flush = () => { flushTable(); flushList() }
lines.forEach((raw, i) => { lines.forEach((raw) => {
const line = raw.trimEnd() const line = raw.trimEnd()
const bullet = line.match(/^\s*[-*]\s+(.*)$/) const bullet = line.match(/^\s*[-*]\s+(.*)$/)
const numbered = line.match(/^\s*\d+[.)]\s+(.*)$/) const numbered = line.match(/^\s*\d+[.)]\s+(.*)$/)
const heading = line.match(/^#{1,4}\s+(.*)$/) const heading = line.match(/^#{1,4}\s+(.*)$/)
const quote = line.match(/^\s*>\s?(.*)$/)
if (isRow(line)) { if (isRow(line)) {
// A rule under the header is markdown's alignment row, not data. The if (!isRule(line)) { flushList(); if (!rows) rows = []; rows.push(cells(line)) }
// 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() === '') { } else if (rows && line.trim() === '') {
/* hold the table open across a blank line */ /* hold the table open across a blank line */
} else if (bullet) { } else if (bullet) {
if (!list || list.ordered) { flush(); list = { ordered: false, items: [] } } flushTable()
if (!list || list.ordered) { flushList(); list = { ordered: false, items: [] } }
list.items.push(bullet[1]) list.items.push(bullet[1])
} else if (numbered) { } else if (numbered) {
if (!list || !list.ordered) { flush(); list = { ordered: true, items: [] } } flushTable()
if (!list || !list.ordered) { flushList(); list = { ordered: true, items: [] } }
list.items.push(numbered[1]) list.items.push(numbered[1])
} else if (heading) { } else if (heading) {
flush(); out.push(<h4 key={i}>{inline(heading[1], i)}</h4>) flush(); out.push({ t: 'h', text: heading[1] })
} else if (quote) {
flush(); out.push({ t: 'quote', text: quote[1] })
} else if (line.trim() === '') { } else if (line.trim() === '') {
flush() flush()
} else { } else {
flush(); out.push(<p key={i}>{inline(line, i)}</p>) flush(); out.push({ t: 'p', text: line })
} }
}) })
flush() flush()
return <>{out}</> return out
} }
/** The tools a turn ran, as chips — the desk's working, not just its answer. */ function Answer({ text }) {
const blocks = useMemo(() => parse(text), [text])
// Chart type is per-table and remembered while the answer is on screen.
const [kinds, setKinds] = useState({})
return (
<>
{blocks.map((b, i) => {
if (b.t === 'h') return <h4 key={i}>{inline(b.text, i)}</h4>
if (b.t === 'p') return <p key={i}>{inline(b.text, i)}</p>
if (b.t === 'quote') return <blockquote key={i}>{inline(b.text, i)}</blockquote>
if (b.t === 'ul') return <ul key={i}>{b.items.map((t, j) => <li key={j}>{inline(t, `${i}-${j}`)}</li>)}</ul>
if (b.t === 'ol') return <ol key={i}>{b.items.map((t, j) => <li key={j}>{inline(t, `${i}-${j}`)}</li>)}</ol>
if (b.t === 'table') {
const chart = chartableFrom(b.head, b.body)
return (
<div key={i}>
{chart ? (
<DeskChart
data={chart}
kind={kinds[i] || 'bar'}
onKind={(k) => setKinds((p) => ({ ...p, [i]: k }))}
/>
) : null}
<div className="desk__tablewrap">
<table>
<thead><tr>{b.head.map((c, j) => <th key={j}>{inline(c, `h${j}`)}</th>)}</tr></thead>
<tbody>
{b.body.map((r, ri) => (
<tr key={ri}>{r.map((c, ci) => <td key={ci}>{inline(c, `${ri}-${ci}`)}</td>)}</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
return null
})}
</>
)
}
/** The tools a turn ran — the desk's working, not just its answer. */
function toolsOf(row) { function toolsOf(row) {
if (!row) return [] const raw = row?.metadata?.tool_calls ?? row?.metadata?.api_calls ?? row?.metadata?.tools
const raw = row.metadata?.tool_calls ?? row.metadata?.api_calls ?? row.metadata?.tools return (Array.isArray(raw) ? raw : [])
const list = Array.isArray(raw) ? raw : [] .map((t) => (typeof t === 'string' ? t : t?.name || t?.tool_name)).filter(Boolean)
return list.map((t) => (typeof t === 'string' ? t : t?.name || t?.tool_name)).filter(Boolean) }
const when = (iso) => {
const d = new Date(iso)
if (isNaN(d)) return ''
const mins = Math.round((Date.now() - d.getTime()) / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return mins + 'm ago'
if (mins < 1440) return Math.round(mins / 60) + 'h ago'
return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })
} }
export default function AskDesk({ onClose }) { export default function AskDesk({ onClose }) {
const { client } = useZino() const { client } = useZino()
const [sessions, setSessions] = useState(null)
const [sessionId, setSessionId] = useState(null) const [sessionId, setSessionId] = useState(null)
const [rows, setRows] = useState([]) const [rows, setRows] = useState([])
const [text, setText] = useState('') const [text, setText] = useState('')
const [asking, setAsking] = useState(false) const [asking, setAsking] = useState(false)
const [err, setErr] = useState(null) const [err, setErr] = useState(null)
const [railOpen, setRailOpen] = useState(false)
const feedRef = useRef(null) const feedRef = useRef(null)
const inputRef = useRef(null) const inputRef = useRef(null)
// One conversation per opening of the panel. Reusing the last one would make const loadSessions = useCallback(() => {
// the desk answer this week's question against last week's context, and the client.deskSessions(SUPPORT_AGENT_ID)
// transcript stays on the platform either way. .then((r) => setSessions(Array.isArray(r) ? r : (r?.data ?? [])))
useEffect(() => { .catch(() => setSessions([]))
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]) }, [client])
useEffect(() => { loadSessions() }, [loadSessions])
// Escape closes, because a full-screen panel with no way out by keyboard is
// a trap for anyone who opened it by accident.
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape' && !asking) onClose?.() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose, asking])
useEffect(() => { useEffect(() => {
const el = feedRef.current const el = feedRef.current
if (el) el.scrollTop = el.scrollHeight if (el) el.scrollTop = el.scrollHeight
}, [rows, asking]) }, [rows, asking])
useEffect(() => { inputRef.current?.focus() }, [sessionId]) const openSession = useCallback((id) => {
setSessionId(id); setRows([]); setErr(null); setRailOpen(false)
client.deskMessages(id)
.then((r) => {
const msgs = Array.isArray(r) ? r : (r?.data ?? [])
setRows(msgs
.filter((m) => m.role === 'user' || m.role === 'agent')
.map((m) => ({ key: 'm' + m.id, role: m.role === 'user' ? 'user' : 'agent', content: m.content, tools: [] })))
})
.catch(setErr)
}, [client])
const newSession = useCallback(async () => {
setErr(null); setRows([]); setSessionId(null); setRailOpen(false)
try {
const s = await client.deskStartSession(SUPPORT_AGENT_ID)
setSessionId(s?.id ?? s?.data?.id ?? null)
inputRef.current?.focus()
} catch (e) { setErr(e) }
}, [client])
// One conversation is opened on arrival, so the first question needs no setup.
useEffect(() => { newSession() }, [newSession])
const ask = useCallback(async (question) => { const ask = useCallback(async (question) => {
const q = String(question ?? '').trim() const q = String(question ?? '').trim()
if (!q || !sessionId || asking) return if (!q || !sessionId || asking) return
setErr(null) setErr(null); setText('')
setText('')
// The question shows immediately; the answer replaces the waiting line.
setRows((prev) => [...prev, { key: 'q' + Date.now(), role: 'user', content: q }]) setRows((prev) => [...prev, { key: 'q' + Date.now(), role: 'user', content: q }])
setAsking(true) setAsking(true)
try { try {
const res = await client.deskAsk(sessionId, q) const res = await client.deskAsk(sessionId, q)
const answer = res?.agent_message const answer = res?.agent_message
const called = toolsOf(res?.call_api_message)
setRows((prev) => [...prev, { setRows((prev) => [...prev, {
key: 'a' + (answer?.id ?? Date.now()), key: 'a' + (answer?.id ?? Date.now()),
role: 'agent', role: 'agent',
content: answer?.content ?? 'The desk answered with nothing at all.', content: answer?.content ?? 'The desk answered with nothing at all.',
tools: called, tools: toolsOf(res?.call_api_message),
}]) }])
} catch (e) { loadSessions()
setErr(e) } catch (e) { setErr(e) } finally {
} finally { setAsking(false); inputRef.current?.focus()
setAsking(false)
inputRef.current?.focus()
} }
}, [client, sessionId, asking]) }, [client, sessionId, asking, loadSessions])
return ( return (
<div className="desk__scrim" role="presentation" onClick={onClose}> <div className="desk" role="dialog" aria-label="Ask the desk" aria-modal="true">
<aside <header className="desk__bar">
className="desk" role="dialog" aria-label="Ask the desk" <button
onClick={(e) => e.stopPropagation()} type="button" className="desk__rails" aria-label="Conversations"
> aria-expanded={railOpen} onClick={() => setRailOpen((v) => !v)}
<header className="desk__head">
<div>
<h2>Ask the desk</h2>
<p>Reads the book and answers with evidence. It cannot change anything.</p>
</div>
<button type="button" className="desk__x" onClick={onClose} aria-label="Close">×</button>
</header>
<div className="desk__feed" ref={feedRef}>
{rows.length === 0 && !asking ? (
<div className="desk__empty">
<p>Ask about a lead, a queue, or the book as a whole. Try:</p>
<div className="desk__sugg">
{SUGGESTIONS.map((s) => (
<button key={s} type="button" disabled={!sessionId} onClick={() => ask(s)}>{s}</button>
))}
</div>
</div>
) : null}
{rows.map((r) => (
<div key={r.key} className={'desk__row desk__row--' + r.role}>
{r.role === 'user'
? <p className="desk__q">{r.content}</p>
: (
<div className="desk__a">
<Rich text={r.content} />
{r.tools?.length ? (
<div className="desk__tools">
{r.tools.map((t, i) => <span key={t + i}>{t}</span>)}
</div>
) : null}
</div>
)}
</div>
))}
{asking ? (
<div className="desk__row desk__row--agent">
<div className="desk__wait">
<span className="desk__dots" aria-hidden="true"><i /><i /><i /></span>
Reading the book this takes half a minute.
</div>
</div>
) : null}
{err ? (
<div className="desk__err" role="alert">
<strong>{describeError(err).title}</strong>
<span>{err?.message}</span>
</div>
) : null}
</div>
<form
className="desk__ask"
onSubmit={(e) => { e.preventDefault(); ask(text) }}
> >
<textarea <svg viewBox="0 0 18 18" aria-hidden="true"><path d="M3 4.5h12M3 9h12M3 13.5h8" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /></svg>
ref={inputRef} </button>
value={text} <div className="desk__id">
rows={2} <h2>Ask the desk</h2>
placeholder={sessionId ? 'Ask a question…' : 'Opening a conversation…'} <p>Reads the book and answers with evidence. It cannot change anything.</p>
disabled={!sessionId || asking} </div>
onChange={(e) => setText(e.target.value)} <button type="button" className="desk__new" onClick={newSession}>New question</button>
onKeyDown={(e) => { <button type="button" className="desk__x" onClick={onClose} aria-label="Close">
// Enter sends; Shift+Enter is a new line. A question is usually <svg viewBox="0 0 18 18" aria-hidden="true"><path d="M4.5 4.5l9 9M13.5 4.5l-9 9" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
// one line, and reaching for a button every time is friction. </button>
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); ask(text) } </header>
}}
/> <div className="desk__body">
<button type="submit" className="btn btn--primary" disabled={!sessionId || asking || !text.trim()}> <aside className={'desk__rail' + (railOpen ? ' is-open' : '')} aria-label="Past conversations">
Ask <span className="desk__railh">Conversations</span>
</button> {sessions === null ? <p className="desk__railnone">Loading</p>
</form> : sessions.length === 0 ? <p className="desk__railnone">Nothing yet. Every question you ask is kept here.</p>
</aside> : sessions.map((s) => (
<button
key={s.id} type="button"
className={'desk__sess' + (s.id === sessionId ? ' is-on' : '')}
onClick={() => openSession(s.id)}
>
<span className="desk__sesst">{s.title || 'Untitled question'}</span>
<span className="desk__sessw">{when(s.created_at)}</span>
</button>
))}
</aside>
<main className="desk__main">
<div className="desk__feed" ref={feedRef}>
<div className="desk__col">
{rows.length === 0 && !asking ? (
<div className="desk__empty">
<h3>What would you like to know?</h3>
<p>Ask about a lead, a queue, or the book as a whole. Anything countable comes back as a table and a chart.</p>
<div className="desk__sugg">
{SUGGESTIONS.map((s) => (
<button key={s} type="button" disabled={!sessionId} onClick={() => ask(s)}>{s}</button>
))}
</div>
</div>
) : null}
{rows.map((r) => (
<div key={r.key} className={'desk__turn desk__turn--' + r.role}>
{r.role === 'user'
? <p className="desk__q">{r.content}</p>
: (
<div className="desk__a">
<Answer text={r.content} />
{r.tools?.length ? (
<div className="desk__tools">
{r.tools.map((t, i) => <span key={t + i}>{t}</span>)}
</div>
) : null}
</div>
)}
</div>
))}
{asking ? (
<div className="desk__turn desk__turn--agent">
<div className="desk__wait">
<span className="desk__dots" aria-hidden="true"><i /><i /><i /></span>
Reading the book this takes half a minute.
</div>
</div>
) : null}
{err ? (
<div className="desk__err" role="alert">
<strong>{describeError(err).title}</strong>
<span>{err?.message}</span>
</div>
) : null}
</div>
</div>
<form className="desk__ask" onSubmit={(e) => { e.preventDefault(); ask(text) }}>
<div className="desk__col desk__askcol">
<textarea
ref={inputRef} value={text} rows={1}
placeholder={sessionId ? 'Ask a question…' : 'Opening a conversation…'}
disabled={!sessionId || asking}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
// Enter sends; Shift+Enter is a new line. A question is
// usually one line, and reaching for a button every time is
// friction on the thing this panel exists for.
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); ask(text) }
}}
/>
<button type="submit" className="btn btn--primary" disabled={!sessionId || asking || !text.trim()}>Ask</button>
</div>
</form>
</main>
</div>
</div> </div>
) )
} }

View File

@ -0,0 +1,136 @@
/**
* A chart drawn from the desk's own answer.
*
* The platform has no chart widget and the desk holds no widget tools its
* fourteen tools are plain API reads, so everything it says comes back as text.
* What it DOES do, reliably, when asked anything countable, is answer with a
* markdown table. So the chart here is not a second source: it is that table,
* drawn. Every bar can be checked against the row beside it.
*
* Inline SVG rather than a charting library: two shapes, one scale, and a
* library would be more bytes than the console's whole chart vocabulary.
*/
/** A cell like "₹3.96 L", "69%", "1,204" or "12" as a number, or null. */
function toNumber(raw) {
const s = String(raw ?? '').trim()
if (!s) return null
// Indian short forms carry their own multiplier and would otherwise sort as 3.96 < 12.
const cr = /([\d.,]+)\s*Cr/i.exec(s)
if (cr) return parseFloat(cr[1].replace(/,/g, '')) * 1e7
const lakh = /([\d.,]+)\s*L\b/i.exec(s)
if (lakh) return parseFloat(lakh[1].replace(/,/g, '')) * 1e5
const plain = s.replace(/[₹$,%\s]/g, '').replace(/,/g, '')
const n = Number(plain)
return Number.isFinite(n) ? n : null
}
/**
* Can this table be drawn? It needs a label column, one numeric column, and at
* least two rows one bar is a fact, not a chart.
*/
/* A totals row is the sum of the others, so charting it draws one bar as long
as all the rest put together, or a slice that swallows half the ring. The
desk adds one to most count tables. */
const isTotalRow = (label) => /^\**\s*(total|sum|overall|all)\b/i.test(String(label ?? '').trim())
export function chartableFrom(head, allRows) {
const rows = allRows.filter((r) => !isTotalRow(r[0]))
if (!head?.length || rows.length < 2) return null
// The rightmost column that parses as a number on most rows is the measure.
for (let c = head.length - 1; c >= 1; c--) {
const vals = rows.map((r) => toNumber(r[c]))
const good = vals.filter((v) => v !== null && v >= 0)
if (good.length >= Math.max(2, Math.ceil(rows.length * 0.8))) {
const points = rows
.map((r, i) => ({ label: String(r[0] ?? '').trim(), value: vals[i], raw: String(r[c] ?? '') }))
.filter((p) => p.label && p.value !== null)
if (points.length < 2) return null
if (points.every((p) => p.value === 0)) return null
return { measure: head[c], points }
}
}
return null
}
/* The console's colour rule has meaning attached to it, so a chart of arbitrary
categories cannot borrow it. This is a neutral sequence in the same family
ordered, so the biggest slice is always the deepest. */
const SERIES = ['#1F55A6', '#4E7FC4', '#7CA0D4', '#137A66', '#4FA08D', '#A86A0B', '#D99A2B', '#7A8699']
function Bars({ points, measure }) {
const max = Math.max(...points.map((p) => p.value))
return (
<div className="dchart__bars">
{points.map((p, i) => (
<div className="dchart__row" key={p.label + i}>
<span className="dchart__lab" title={p.label}>{p.label}</span>
<span className="dchart__track">
<span className="dchart__fill" style={{ width: Math.max(1.5, (p.value / max) * 100) + '%', background: SERIES[i % SERIES.length] }} />
</span>
<span className="dchart__val">{p.raw}</span>
</div>
))}
<p className="dchart__cap">{measure}</p>
</div>
)
}
function Donut({ points, measure }) {
const total = points.reduce((t, p) => t + p.value, 0)
if (!total) return <Bars points={points} measure={measure} />
const R = 62, C = 2 * Math.PI * R
let offset = 0
return (
<div className="dchart__donut">
<svg viewBox="0 0 160 160" role="img" aria-label={`${measure} by share`}>
<g transform="rotate(-90 80 80)">
{points.map((p, i) => {
const frac = p.value / total
const dash = `${frac * C} ${C - frac * C}`
const el = (
<circle
key={p.label + i} cx="80" cy="80" r={R} fill="none"
stroke={SERIES[i % SERIES.length]} strokeWidth="26"
strokeDasharray={dash} strokeDashoffset={-offset}
/>
)
offset += frac * C
return el
})}
</g>
<text x="80" y="76" textAnchor="middle" className="dchart__total">{total.toLocaleString('en-IN')}</text>
<text x="80" y="94" textAnchor="middle" className="dchart__totlab">total</text>
</svg>
<ul className="dchart__key">
{points.map((p, i) => (
<li key={p.label + i}>
<span className="dchart__sw" style={{ background: SERIES[i % SERIES.length] }} />
<span className="dchart__keylab">{p.label}</span>
<span className="dchart__keyval">{p.raw}</span>
<span className="dchart__keypct">{Math.round((p.value / total) * 100)}%</span>
</li>
))}
</ul>
</div>
)
}
export default function DeskChart({ data, kind, onKind }) {
const { points, measure } = data
// A share only means something when the parts make a whole. Counts by stage
// do; a column of average premiums does not, and a donut of averages is a
// lie told in colour so the toggle offers it, and bars stay the default.
return (
<figure className="dchart">
<figcaption className="dchart__head">
<span>{measure} · {points.length} rows</span>
<span className="dchart__toggle" role="group" aria-label="Chart type">
<button type="button" className={kind === 'bar' ? 'is-on' : ''} onClick={() => onKind('bar')}>Bars</button>
<button type="button" className={kind === 'donut' ? 'is-on' : ''} onClick={() => onKind('donut')}>Share</button>
</span>
</figcaption>
{kind === 'donut' ? <Donut points={points} measure={measure} /> : <Bars points={points} measure={measure} />}
</figure>
)
}

View File

@ -90,6 +90,13 @@ export default function Shell() {
console's masthead. Bundled from src/assets, so the URL is console's masthead. Bundled from src/assets, so the URL is
rewritten relative and resolves under the sub-path mount. */} rewritten relative and resolves under the sub-path mount. */}
<img className="appbar__logo" src={logo} alt="Zurich Kotak General Insurance" /> <img className="appbar__logo" src={logo} alt="Zurich Kotak General Insurance" />
{canAskDesk ? (
<button type="button" className="appbar__desk" aria-label="Ask the desk" onClick={() => setDesk(true)}>
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M3 4.5h14v9h-8l-3.5 3v-3H3z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
</svg>
</button>
) : null}
<button type="button" className="appbar__me" aria-label="Your account" onClick={() => setMe(true)}> <button type="button" className="appbar__me" aria-label="Your account" onClick={() => setMe(true)}>
{initials(user)} {initials(user)}
</button> </button>
@ -154,6 +161,8 @@ export default function Shell() {
</> </>
) : null} ) : null}
{desk ? <AskDesk onClose={() => setDesk(false)} /> : null}
{me ? ( {me ? (
<> <>
<div className="scrim" style={{ zIndex: 60 }} role="presentation" onClick={() => setMe(false)} /> <div className="scrim" style={{ zIndex: 60 }} role="presentation" onClick={() => setMe(false)} />
@ -178,7 +187,6 @@ export default function Shell() {
</> </>
) : null} ) : null}
{desk ? <AskDesk onClose={() => setDesk(false)} /> : null}
</div> </div>
) )
} }

View File

@ -631,3 +631,16 @@ button { font: inherit; }
The desk reads the WHOLE book, so it is an ops tool and the button is drawn The desk reads the WHOLE book, so it is an ops tool and the button is drawn
only for the role that may see one. */ only for the role that may see one. */
.nav--desk .nav__i { background: var(--zk-navy); color: #fff; } .nav--desk .nav__i { background: var(--zk-navy); color: #fff; }
/* The support desk, reachable from anywhere. Ops only the button is not
drawn for anyone else, and the platform refuses them anyway. */
.appbar__desk {
margin-left: auto; flex: none;
width: 38px; height: 38px; display: grid; place-items: center;
border: 0; border-radius: 10px; background: transparent; color: #fff; cursor: pointer;
}
.appbar__desk svg { width: 20px; height: 20px; }
.appbar__desk:active { background: rgba(255,255,255,.16); }
/* With the desk button present it owns the auto margin. */
.appbar__desk ~ .appbar__me { margin-left: 8px; }
.nav--desk .nav__i { background: var(--zk-tint-blue); color: var(--zk-blue); }