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

{inline(heading[1], i)}

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

{inline(line, i)}

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