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(
+
| {inline(c, `h${i}`)} | )}
|---|
| {inline(c, `${ri}-${ci}`)} | )}
{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 ( +