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:
parent
5b30cfd647
commit
6d6c4931cc
@ -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; }
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
136
src/components/DeskChart.jsx
Normal file
136
src/components/DeskChart.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user