console: ask the support desk without leaving the console

An "Ask the desk" panel over the console, for ops only. The desk is a
platform Support App bound to app 536: it 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, to answer a person's question.

No new auth. The desk is an ordinary interactive agent, a conversation is a
session and a question is a message on it, and the token this console
already holds is accepted on those routes as-is.

Ops only, deliberately: the desk reads the WHOLE book — every partner,
every premium, every commission — so a partner agent must not open it. The
platform enforces it per user; the button just isn't drawn for anyone else.

The panel renders the markdown the desk actually uses — headings, lists,
bold, code and tables. Tables earn their place: asked anything countable it
replies with one, and rendered as raw pipes a correct answer reads like a
broken one.

Verified end to end against the database: its stage counts matched all ten
states exactly, and its account of why ZK-2026-01040 sits with the
underwriter names both PAN numbers correctly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-10 16:04:32 +05:30
parent ac3dc2965a
commit 73b6ff7eb0
6 changed files with 466 additions and 1 deletions

View File

@ -68,6 +68,37 @@ export class ZinoClient {
return res.json()
}
/* The support desk
Not app-scoped: these are the platform's own agent routes, and the token
this console already holds is accepted on them as-is. A conversation is a
session; a question is a message on it. */
/** This operator's conversations with the desk, newest first. */
deskSessions(agentId) {
return this.request('GET', `/api/agent-sessions?agent_id=${agentId}`)
}
/** Open a new conversation. */
deskStartSession(agentId) {
return this.request('POST', '/api/agent-sessions', { agent_id: agentId })
}
/** The transcript of one conversation. */
deskMessages(sessionId) {
return this.request('GET', `/api/agent-sessions/${sessionId}/messages`)
}
/**
* Ask. Answers with the rows the backend persisted for the turn
* { user_message, call_api_message, agent_message } not a bare string.
*
* These take twenty to forty seconds: the desk runs real queries against the
* book before it answers. The caller has to say so, or the panel looks hung.
*/
deskAsk(sessionId, content) {
return this.request('POST', `/api/agent-sessions/${sessionId}/messages`, { content })
}
/**
* org_id must be a STRING. The gateway rejects a number with
* "cannot unmarshal number into Go struct field LoginRequest.org_id".

View File

@ -34,6 +34,21 @@ export const WORKFLOW = 'zk_wf_lead'
* error: the queue returns nothing, correctly, for a name no lead is in. That
* is the failure mode this table has, and it is silent.
*/
/**
* The support desk this console can ask questions of.
*
* A Support App on the platform: an AI desk bound to app 536 that reads the
* deployed config and the live book through read-only tools and answers with
* evidence. It is org-scoped and lives outside this app, so its id is config
* here rather than something the console can derive.
*
* Access is granted per user on the platform (usersystem.tbl_org_user_agents)
* and the console additionally gates the panel to ops see ASK_DESK_ROLES.
* Both have to agree; the platform's grant is the one that actually enforces.
*/
export const SUPPORT_AGENT_ID = 29648
export const ASK_DESK_ROLES = ['ops_admin']
export const STAGES = [
{ uid: 'zk-state-new', name: 'New Lead', kind: 'auto', doing: 'Checking the lead', by: 'Intake AI' },
{ uid: 'zk-state-qualified', name: 'Awaiting Contact', kind: 'auto', doing: 'Calling the customer', by: 'the voice agent' },

121
src/components/AskDesk.css Normal file
View File

@ -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; }

261
src/components/AskDesk.jsx Normal file
View File

@ -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 <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 }) {
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}`}>
<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
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}</>
}
/** 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 (
<div className="desk__scrim" role="presentation" onClick={onClose}>
<aside
className="desk" role="dialog" aria-label="Ask the desk"
onClick={(e) => e.stopPropagation()}
>
<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
ref={inputRef}
value={text}
rows={2}
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.
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); ask(text) }
}}
/>
<button type="submit" className="btn btn--primary" disabled={!sessionId || asking || !text.trim()}>
Ask
</button>
</form>
</aside>
</div>
)
}

View File

@ -191,3 +191,22 @@
@media (prefers-reduced-motion: reduce) {
.shell__nav { transition: none; }
}
/* The support desk, reachable from anywhere in the console. Ops only the
button is not drawn for anyone else, and the platform refuses them anyway. */
.mast__desk {
margin-left: auto;
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 14px; border-radius: var(--r-pill);
border: 1px solid rgba(255, 255, 255, .28); background: transparent; color: #fff;
font: inherit; font-size: var(--fs-xs); font-weight: 600; cursor: pointer;
transition: background var(--t-fast), border-color var(--t-fast);
}
.mast__desk svg { width: 14px; height: 14px; }
.mast__desk:hover { background: rgba(255, 255, 255, .12); border-color: rgba(255, 255, 255, .5); }
.mast__desk:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(255, 255, 255, .28); }
/* With the desk button present it owns the auto margin, so the user menu must
not also claim one or the two separate to opposite ends. */
.mast__desk ~ .mast__user { margin-left: 0; }
@media (max-width: 900px) { .mast__desk span, .mast__desk { font-size: 0; gap: 0; padding: 8px; } .mast__desk svg { font-size: initial; } }

View File

@ -1,9 +1,10 @@
import { useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { NAV_GROUPS, STAGES } from '../api/config.js'
import { ASK_DESK_ROLES, NAV_GROUPS, STAGES } from '../api/config.js'
import { rolesOf, visibleStages } from '../api/permissions.js'
import { useStageCounts } from '../api/portfolio.jsx'
import { useZino } from '../api/provider.jsx'
import AskDesk from '../components/AskDesk.jsx'
import UserMenu from './UserMenu.jsx'
import './Shell.css'
@ -35,6 +36,12 @@ export default function Shell() {
// because a menu that stays open over the page you just chose is a menu you
// have to dismiss twice. Desktop never reads this the rail is always there.
const [navOpen, setNavOpen] = useState(false)
// The support desk reads the WHOLE book every partner, every premium so
// it is an ops tool, not something a partner agent may open. The platform
// enforces this too (the desk is assigned per user); this only decides
// whether the button is drawn.
const [deskOpen, setDeskOpen] = useState(false)
const canAskDesk = ASK_DESK_ROLES.some((r) => roles.includes(r))
// Closing on the link rather than on the route: a menu that stays open over
// the page you just chose is a menu you have to dismiss twice. Keyed off the
// anchor so opening the Closed fold which is not navigation leaves it up.
@ -87,6 +94,15 @@ export default function Shell() {
</div>
<span className="mast__sep" aria-hidden="true" />
<div className="mast__app">Lead Desk <span>Lead to policy</span></div>
{canAskDesk ? (
<button type="button" className="mast__desk" onClick={() => setDeskOpen(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z" fill="none" stroke="currentColor"
strokeWidth="1.3" strokeLinejoin="round" />
</svg>
Ask the desk
</button>
) : null}
<div className="mast__user"><UserMenu /></div>
</header>
@ -136,6 +152,8 @@ export default function Shell() {
<Outlet />
</main>
</div>
{deskOpen ? <AskDesk onClose={() => setDeskOpen(false)} /> : null}
</div>
)
}