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
opens it is in the middle of something; it should be dismissible in one
press and leave the work behind it visible. */
/* Ask the desk, on a phone.
.desk__scrim {
position: fixed; inset: 0; z-index: 60;
background: rgba(11, 42, 91, .32);
display: flex; justify-content: flex-end;
}
Full screen is the only sensible shape here there is no "beside" on a
phone, and the desk's answers are long. Same structure as the console so the
two stay one product: a bar, the conversation list behind a button, one
column of prose, the question box pinned to the bottom above the keyboard. */
.desk {
width: min(560px, 100%); height: 100%;
position: fixed; inset: 0; z-index: 70;
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;
background: var(--zk-tint);
padding-top: env(safe-area-inset-top);
}
/* 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__bar {
flex: none; display: flex; align-items: center; gap: 12px;
height: 56px; padding: 0 14px; background: var(--zk-navy); color: #fff;
}
.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__id { flex: 1; min-width: 0; }
.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__row--user { justify-content: flex-end; }
.desk__body { flex: 1; min-height: 0; display: flex; position: relative; }
/* 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 {
margin: 0; max-width: 82%;
padding: 10px 14px; border-radius: var(--r-md) var(--r-md) var(--r-xs) var(--r-md);
margin: 0; max-width: 86%; padding: 11px 15px;
border-radius: 14px 14px 4px 14px;
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 { font-size: var(--fs-sm); color: var(--zk-ink); }
.desk__a p { margin: 0 0 11px; line-height: 1.6; }
.desk__a h4 { margin: 18px 0 7px; font-size: var(--fs-md); font-weight: 700; }
.desk__a ul, .desk__a ol { margin: 0 0 12px; padding-left: 20px; }
.desk__a li { margin-bottom: 5px; line-height: 1.55; }
.desk__a blockquote {
margin: 0 0 12px; padding: 9px 14px; border-left: 3px solid var(--zk-amber-bar);
background: var(--zk-amber-tint); border-radius: 0 10px 10px 0;
}
.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;
}
.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: 4px; }
.desk__tools { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.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);
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__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; } }
@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;
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 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;
flex: none; border-top: 1px solid var(--zk-line); 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 {
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: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; }
/* ── Chart drawn from the answer's own table ─────────────────────────────── */
.dchart { margin: 0 0 14px; padding: 14px 15px; border: 1px solid var(--zk-line); border-radius: 14px; background: #fff; }
.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. */
.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; }
.dchart__bars { display: flex; flex-direction: column; gap: 9px; }
.dchart__row { display: grid; grid-template-columns: minmax(80px, 38%) 1fr auto; gap: 10px; align-items: center; }
.dchart__lab { font-size: var(--fs-3xs); color: var(--zk-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dchart__track { height: 15px; background: var(--zk-line-soft); border-radius: 3px; overflow: hidden; }
.dchart__fill { display: block; height: 100%; border-radius: 3px; }
.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); }
.dchart__donut { display: flex; flex-direction: column; gap: 16px; align-items: center; }
.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,197 +1,282 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, 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 DeskChart, { chartableFrom } from './DeskChart.jsx'
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
* 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.
* A different kind of thing from the five AI employees: they DO the work and
* write to the workflow; this one only reads, 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.
* Full screen rather than a side panel, because the answers are long a
* timeline, a table of every stalled lead and reading them in a 560px gutter
* meant scrolling a column while the page behind it sat unused. The
* conversation list comes with it: an investigation is worth returning to, and
* every conversation opens a ticket on the platform whether or not anyone
* reads it back.
*/
const SUGGESTIONS = [
'Which leads have not moved in over 12 hours?',
'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?',
'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. */
/* The desk answers in markdown. This renders the five things it uses:
headings, lists, 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. */
function inline(text, key) {
const parts = String(text).split(/(\*\*[^*]+\*\*|`[^`]+`)/g)
return parts.map((p, i) => {
return String(text).split(/(\*\*[^*]+\*\*|`[^`]+`)/g).map((p, i) => {
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>
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 }) {
/** 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 out = []
let list = null
let rows = null
const flush = () => {
if (rows) {
const [head, ...body] = rows
out.push(
<div className="desk__tablewrap" key={`t${out.length}`}>
const flushList = () => {
if (!list) return
out.push({ t: list.ordered ? 'ol' : 'ul', items: list.items })
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) => {
const line = raw.trimEnd()
const bullet = line.match(/^\s*[-*]\s+(.*)$/)
const numbered = line.match(/^\s*\d+[.)]\s+(.*)$/)
const heading = line.match(/^#{1,4}\s+(.*)$/)
const quote = line.match(/^\s*>\s?(.*)$/)
if (isRow(line)) {
if (!isRule(line)) { flushList(); if (!rows) rows = []; rows.push(cells(line)) }
} else if (rows && line.trim() === '') {
/* hold the table open across a blank line */
} else if (bullet) {
flushTable()
if (!list || list.ordered) { flushList(); list = { ordered: false, items: [] } }
list.items.push(bullet[1])
} else if (numbered) {
flushTable()
if (!list || !list.ordered) { flushList(); list = { ordered: true, items: [] } }
list.items.push(numbered[1])
} else if (heading) {
flush(); out.push({ t: 'h', text: heading[1] })
} else if (quote) {
flush(); out.push({ t: 'quote', text: quote[1] })
} else if (line.trim() === '') {
flush()
} else {
flush(); out.push({ t: 'p', text: line })
}
})
flush()
return out
}
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>{head.map((c, i) => <th key={i}>{inline(c, `h${i}`)}</th>)}</tr></thead>
<thead><tr>{b.head.map((c, j) => <th key={j}>{inline(c, `h${j}`)}</th>)}</tr></thead>
<tbody>
{body.map((r, ri) => (
{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>
)
rows = null
}
if (!list) return
out.push(list.ordered
? <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
}
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(<h4 key={i}>{inline(heading[1], i)}</h4>)
} else if (line.trim() === '') {
flush()
} else {
flush(); out.push(<p key={i}>{inline(line, i)}</p>)
}
})
flush()
return <>{out}</>
return null
})}
</>
)
}
/** The tools a turn ran, as chips — the desk's working, not just its answer. */
/** The tools a turn ran — 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)
const raw = row?.metadata?.tool_calls ?? row?.metadata?.api_calls ?? row?.metadata?.tools
return (Array.isArray(raw) ? raw : [])
.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 }) {
const { client } = useZino()
const [sessions, setSessions] = useState(null)
const [sessionId, setSessionId] = useState(null)
const [rows, setRows] = useState([])
const [text, setText] = useState('')
const [asking, setAsking] = useState(false)
const [err, setErr] = useState(null)
const [railOpen, setRailOpen] = useState(false)
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 }
const loadSessions = useCallback(() => {
client.deskSessions(SUPPORT_AGENT_ID)
.then((r) => setSessions(Array.isArray(r) ? r : (r?.data ?? [])))
.catch(() => setSessions([]))
}, [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(() => {
const el = feedRef.current
if (el) el.scrollTop = el.scrollHeight
}, [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 q = String(question ?? '').trim()
if (!q || !sessionId || asking) return
setErr(null)
setText('')
// The question shows immediately; the answer replaces the waiting line.
setErr(null); setText('')
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,
tools: toolsOf(res?.call_api_message),
}])
} catch (e) {
setErr(e)
} finally {
setAsking(false)
inputRef.current?.focus()
loadSessions()
} catch (e) { setErr(e) } finally {
setAsking(false); inputRef.current?.focus()
}
}, [client, sessionId, asking])
}, [client, sessionId, asking, loadSessions])
return (
<div className="desk__scrim" role="presentation" onClick={onClose}>
<aside
className="desk" role="dialog" aria-label="Ask the desk"
onClick={(e) => e.stopPropagation()}
<div className="desk" role="dialog" aria-label="Ask the desk" aria-modal="true">
<header className="desk__bar">
<button
type="button" className="desk__rails" aria-label="Conversations"
aria-expanded={railOpen} onClick={() => setRailOpen((v) => !v)}
>
<header className="desk__head">
<div>
<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>
</button>
<div className="desk__id">
<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>
<button type="button" className="desk__new" onClick={newSession}>New question</button>
<button type="button" className="desk__x" onClick={onClose} aria-label="Close">
<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>
</button>
</header>
<div className="desk__body">
<aside className={'desk__rail' + (railOpen ? ' is-open' : '')} aria-label="Past conversations">
<span className="desk__railh">Conversations</span>
{sessions === null ? <p className="desk__railnone">Loading</p>
: sessions.length === 0 ? <p className="desk__railnone">Nothing yet. Every question you ask is kept here.</p>
: 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">
<p>Ask about a lead, a queue, or the book as a whole. Try:</p>
<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>
@ -201,12 +286,12 @@ export default function AskDesk({ onClose }) {
) : null}
{rows.map((r) => (
<div key={r.key} className={'desk__row desk__row--' + r.role}>
<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">
<Rich text={r.content} />
<Answer text={r.content} />
{r.tools?.length ? (
<div className="desk__tools">
{r.tools.map((t, i) => <span key={t + i}>{t}</span>)}
@ -218,7 +303,7 @@ export default function AskDesk({ onClose }) {
))}
{asking ? (
<div className="desk__row desk__row--agent">
<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.
@ -233,29 +318,27 @@ export default function AskDesk({ onClose }) {
</div>
) : null}
</div>
</div>
<form
className="desk__ask"
onSubmit={(e) => { e.preventDefault(); ask(text) }}
>
<form className="desk__ask" onSubmit={(e) => { e.preventDefault(); ask(text) }}>
<div className="desk__col desk__askcol">
<textarea
ref={inputRef}
value={text}
rows={2}
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.
// 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>
<button type="submit" className="btn btn--primary" disabled={!sessionId || asking || !text.trim()}>Ask</button>
</div>
</form>
</aside>
</main>
</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
rewritten relative and resolves under the sub-path mount. */}
<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)}>
{initials(user)}
</button>
@ -154,6 +161,8 @@ export default function Shell() {
</>
) : null}
{desk ? <AskDesk onClose={() => setDesk(false)} /> : null}
{me ? (
<>
<div className="scrim" style={{ zIndex: 60 }} role="presentation" onClick={() => setMe(false)} />
@ -178,7 +187,6 @@ export default function Shell() {
</>
) : null}
{desk ? <AskDesk onClose={() => setDesk(false)} /> : null}
</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
only for the role that may see one. */
.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); }