From 6d6c4931cccb0ed2fd1efe5ba2f964e4301de02e Mon Sep 17 00:00:00 2001 From: Yashas Date: Thu, 10 Sep 2026 21:20:14 +0530 Subject: [PATCH] Ask the desk, on the phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/AskDesk.css | 202 ++++++++++------- src/components/AskDesk.jsx | 409 +++++++++++++++++++++-------------- src/components/DeskChart.jsx | 136 ++++++++++++ src/layout/Shell.jsx | 10 +- src/styles/app.css | 13 ++ 5 files changed, 528 insertions(+), 242 deletions(-) create mode 100644 src/components/DeskChart.jsx diff --git a/src/components/AskDesk.css b/src/components/AskDesk.css index bb9f616..8b482c8 100644 --- a/src/components/AskDesk.css +++ b/src/components/AskDesk.css @@ -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; } diff --git a/src/components/AskDesk.jsx b/src/components/AskDesk.jsx index aa72756..e934ece 100644 --- a/src/components/AskDesk.jsx +++ b/src/components/AskDesk.jsx @@ -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 { 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 {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 }) { +/** 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( -
- - {head.map((c, i) => )} - - {body.map((r, ri) => ( - {r.map((c, ci) => )} - ))} - -
{inline(c, `h${i}`)}
{inline(c, `${ri}-${ci}`)}
-
- ) - rows = null - } + const flushList = () => { if (!list) return - out.push(list.ordered - ?
    {list.items.map((t, i) =>
  1. {inline(t, `${out.length}-${i}`)}
  2. )}
- : ) + 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, i) => { + 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)) { - // 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)) - } + 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) { - if (!list || list.ordered) { flush(); list = { ordered: false, items: [] } } + flushTable() + if (!list || list.ordered) { flushList(); list = { ordered: false, items: [] } } list.items.push(bullet[1]) } 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]) } else if (heading) { - flush(); out.push(

{inline(heading[1], i)}

) + 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(

{inline(line, i)}

) + flush(); out.push({ t: 'p', text: line }) } }) 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

{inline(b.text, i)}

+ if (b.t === 'p') return

{inline(b.text, i)}

+ if (b.t === 'quote') return
{inline(b.text, i)}
+ if (b.t === 'ul') return + if (b.t === 'ol') return
    {b.items.map((t, j) =>
  1. {inline(t, `${i}-${j}`)}
  2. )}
+ if (b.t === 'table') { + const chart = chartableFrom(b.head, b.body) + return ( +
+ {chart ? ( + setKinds((p) => ({ ...p, [i]: k }))} + /> + ) : null} +
+ + {b.head.map((c, j) => )} + + {b.body.map((r, ri) => ( + {r.map((c, ci) => )} + ))} + +
{inline(c, `h${j}`)}
{inline(c, `${ri}-${ci}`)}
+
+
+ ) + } + return null + })} + + ) +} + +/** 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 ( -
-