updated the ai bot to the next level agent
This commit is contained in:
@@ -88,14 +88,39 @@ const AppSideNav = ({ isCollapsed, onCollapsedChange }) => {
|
|||||||
.doormile-side-nav .astryx-side-nav-item[aria-label] > * {
|
.doormile-side-nav .astryx-side-nav-item[aria-label] > * {
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
}
|
}
|
||||||
|
/* COLLAPSED rail — the icon is the only thing left, so it carries the
|
||||||
|
whole item and has to be legible on its own.
|
||||||
|
|
||||||
|
font-size is the load-bearing property here, not width/height.
|
||||||
|
Astryx renders the glyph as an svg sized in em units, so the width and
|
||||||
|
height below only ever sized the WRAPPER: the box was 18px while the
|
||||||
|
glyph inside it stayed at the inherited 14px, leaving 2px of dead
|
||||||
|
space on every side and an icon that read small in a 40px target.
|
||||||
|
Setting the font-size is what actually scales the mark. */
|
||||||
.doormile-side-nav .astryx-side-nav-item[aria-label] .astryx-icon {
|
.doormile-side-nav .astryx-side-nav-item[aria-label] .astryx-icon {
|
||||||
width: 18px !important;
|
font-size: 20px !important;
|
||||||
height: 18px !important;
|
width: 20px !important;
|
||||||
|
height: 20px !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
.doormile-side-nav .astryx-side-nav-item[aria-label] .astryx-icon svg {
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* EXPANDED rail — the label does the identifying, so the icon only has
|
||||||
|
to sit comfortably beside it. Still bumped off the inherited 14px,
|
||||||
|
which was undersized against a 14px label. */
|
||||||
|
.doormile-side-nav .astryx-side-nav-item:not([aria-label]) .astryx-icon {
|
||||||
|
font-size: 17px !important;
|
||||||
|
}
|
||||||
|
.doormile-side-nav .astryx-side-nav-item:not([aria-label]) .astryx-icon svg {
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
.doormile-side-nav .astryx-side-nav-item[aria-label]:hover {
|
.doormile-side-nav .astryx-side-nav-item[aria-label]:hover {
|
||||||
background-color: ${DT.brand}14 !important;
|
background-color: ${DT.brand}14 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,38 @@
|
|||||||
doesn't carry (the AI accent, the scrim, the panel elevation).
|
doesn't carry (the AI accent, the scrim, the panel elevation).
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Dock geometry.
|
||||||
|
|
||||||
|
These three live on :root, not on .dai-root, because the LAYOUT has to read
|
||||||
|
them: the assistant is docked into the page rather than floated over it, so
|
||||||
|
the app's content container shifts by exactly the panel's width and animates
|
||||||
|
on the same curve. A token only .dai-root can see cannot do that.
|
||||||
|
|
||||||
|
All three are --dai- prefixed. :root is shared with Dispatch.css and
|
||||||
|
globalPolish.css, and an unprefixed name there is decided by bundle order.
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
:root {
|
||||||
|
/* 30vw, NOT 30%.
|
||||||
|
|
||||||
|
The two consumers resolve a percentage against different boxes: the panel
|
||||||
|
is position:fixed, so `width: 30%` is 30% of the VIEWPORT, while
|
||||||
|
`padding-right: 30%` on the content container is 30% of ITS containing
|
||||||
|
block — which is narrower by the side nav. At 1440px that is 432px of
|
||||||
|
panel against 410px of reserved strip, and the page slides 22px underneath
|
||||||
|
the panel. A viewport unit resolves the same for both.
|
||||||
|
|
||||||
|
max() rather than a separate min-width for the same reason: a min-width
|
||||||
|
that only the panel knows about would reintroduce the mismatch on narrow
|
||||||
|
screens. */
|
||||||
|
/* Default = the NORMAL step. AIPanel overwrites this property on the
|
||||||
|
document element when the operator toggles the width, so the panel and
|
||||||
|
the page's reserved strip always read the same value. */
|
||||||
|
--dai-dock-width: max(300px, 25vw);
|
||||||
|
--dai-duration: 240ms;
|
||||||
|
--dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
.dai-root {
|
.dai-root {
|
||||||
/* Brand accent — resolves to the app's accent token, which is black
|
/* Brand accent — resolves to the app's accent token, which is black
|
||||||
(root CLAUDE.md §6.2: "the brand is black", one accent only). Send
|
(root CLAUDE.md §6.2: "the brand is black", one accent only). Send
|
||||||
@@ -20,6 +52,8 @@
|
|||||||
change here rather than a hunt through the components. */
|
change here rather than a hunt through the components. */
|
||||||
--dai-accent: var(--color-accent, #0f172a);
|
--dai-accent: var(--color-accent, #0f172a);
|
||||||
--dai-accent-contrast: #ffffff;
|
--dai-accent-contrast: #ffffff;
|
||||||
|
/* Focus ring, tinted with the accent rather than a flat grey wash. */
|
||||||
|
--dai-accent-ring: color-mix(in srgb, var(--dai-accent) 14%, transparent);
|
||||||
|
|
||||||
/* AI identity accent — used ONLY on the spark/orb marks so the assistant
|
/* AI identity accent — used ONLY on the spark/orb marks so the assistant
|
||||||
reads as an AI surface without turning the panel into a purple product. */
|
reads as an AI surface without turning the panel into a purple product. */
|
||||||
@@ -48,7 +82,14 @@
|
|||||||
Scrim — deliberately light. The dashboard underneath must stay readable;
|
Scrim — deliberately light. The dashboard underneath must stay readable;
|
||||||
this is a layering cue, not a modal blackout.
|
this is a layering cue, not a modal blackout.
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
|
/* The scrim exists for the MOBILE presentation only (see the media query at
|
||||||
|
the end of this block). On desktop the panel is docked into the layout and
|
||||||
|
dimming the page behind it would be a lie — the page is still live, still
|
||||||
|
scrollable, and still the thing the operator is working on. Dimming is also
|
||||||
|
the single strongest cue that something is modal, which is exactly the
|
||||||
|
impression this panel should not give. */
|
||||||
.dai-scrim {
|
.dai-scrim {
|
||||||
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1300;
|
z-index: 1300;
|
||||||
@@ -62,31 +103,88 @@
|
|||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Panel
|
Panel
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
|
/* DOCKED, not floating.
|
||||||
|
|
||||||
|
It used to be an inset card: 12px off every edge, fully rounded, drop
|
||||||
|
shadowed, over a scrim — every cue of a popup. It now sits flush in the
|
||||||
|
right 30% of the workspace, starting under the app header and running to the
|
||||||
|
bottom of the window, with a single hairline on its left edge. The page does
|
||||||
|
not move underneath it; it makes room for it.
|
||||||
|
|
||||||
|
`top` reads Astryx's own measured header height so the dock lines up with
|
||||||
|
the bottom of the top nav rather than guessing a number.
|
||||||
|
|
||||||
|
No radius and no drop shadow: both are what make a surface read as floating
|
||||||
|
ABOVE the page. A soft shadow is kept only as a left-edge falloff so the
|
||||||
|
seam has depth without the panel detaching. */
|
||||||
.dai-panel {
|
.dai-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: var(--dai-inset);
|
/* Set from JS by AIPanel — Astryx does not publish a header-height token,
|
||||||
right: var(--dai-inset);
|
despite --appshell-header-height looking like one. */
|
||||||
bottom: var(--dai-inset);
|
top: var(--dai-dock-top, 57px);
|
||||||
z-index: 1301;
|
right: 0;
|
||||||
width: var(--dai-panel-width);
|
bottom: 0;
|
||||||
max-width: calc(100vw - (var(--dai-inset) * 2));
|
z-index: 1200;
|
||||||
|
width: var(--dai-dock-width);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--dai-surface);
|
background: var(--dai-surface);
|
||||||
border: 1px solid var(--dai-border);
|
border-left: 1px solid var(--dai-border);
|
||||||
border-radius: 5px;
|
border-radius: 0;
|
||||||
box-shadow: var(--dai-shadow);
|
box-shadow: -6px 0 20px rgba(15, 23, 42, 0.05);
|
||||||
transform: translateX(calc(100% + var(--dai-inset) * 2));
|
transform: translateX(100%);
|
||||||
opacity: 0;
|
visibility: hidden;
|
||||||
transition:
|
transition:
|
||||||
transform var(--dai-duration) var(--dai-ease),
|
transform var(--dai-duration) var(--dai-ease),
|
||||||
opacity var(--dai-duration) var(--dai-ease);
|
visibility 0s linear var(--dai-duration);
|
||||||
}
|
}
|
||||||
.dai-panel[data-open='true'] {
|
.dai-panel[data-open='true'] {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
opacity: 1;
|
visibility: visible;
|
||||||
|
transition:
|
||||||
|
transform var(--dai-duration) var(--dai-ease),
|
||||||
|
visibility 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- The page makes room -------------------------------------------------
|
||||||
|
`.dai-docked` is set on <body> while the panel is open. Padding rather than
|
||||||
|
width/margin: the content container is a flex child inside AppShell's own
|
||||||
|
height:fill chain, and changing its width there fights that chain, whereas
|
||||||
|
padding leaves the box model alone and simply reserves the strip.
|
||||||
|
|
||||||
|
The transition sits on the container unconditionally so the page slides back
|
||||||
|
when the panel closes too — a rule that only exists while `.dai-docked` is
|
||||||
|
applied cannot animate its own removal. */
|
||||||
|
.astryx-layout-content {
|
||||||
|
transition: padding-right var(--dai-duration) var(--dai-ease);
|
||||||
|
}
|
||||||
|
body.dai-docked .astryx-layout-content {
|
||||||
|
padding-right: var(--dai-dock-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Mobile: there is no 30% worth having -------------------------------
|
||||||
|
30% of a phone is ~120px. Below the breakpoint the panel becomes what it
|
||||||
|
used to be everywhere — a full-width overlay with a scrim — and the page
|
||||||
|
stops reserving a strip it cannot afford. */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.dai-panel {
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1301;
|
||||||
|
border-left: none;
|
||||||
|
box-shadow: var(--dai-shadow);
|
||||||
|
}
|
||||||
|
.dai-scrim {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.dai-scrim[data-open='true'] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
body.dai-docked .astryx-layout-content {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.dai-panel:focus {
|
.dai-panel:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -221,64 +319,102 @@
|
|||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Suggestion cards
|
Suggestion cards
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
.dai-suggestion {
|
/* The suggestions strip sits between the conversation and the composer, so it
|
||||||
display: flex;
|
needs the composer's horizontal rhythm and a hairline to separate it from
|
||||||
align-items: center;
|
the thread scrolling above it. */
|
||||||
gap: 10px;
|
.dai-root .dai-suggestions-bar {
|
||||||
|
/* Shrinkable, with a hard cap.
|
||||||
|
|
||||||
|
At the normal 25% width the full twenty chips still stack to several rows.
|
||||||
|
As `flex: 0 0 auto` that block refuses to shrink, so on a short laptop it
|
||||||
|
pushes the composer off the bottom of the panel — the input disappears and
|
||||||
|
the assistant cannot be used at all. The cap is a safety valve, not a
|
||||||
|
layout choice: it only engages when the strip would otherwise cost more
|
||||||
|
than a third of the panel, and it never applies once a thread exists,
|
||||||
|
because the strip is four chips by then. */
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 34vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 14px 8px;
|
||||||
|
border-top: 1px solid var(--dai-border);
|
||||||
|
background: var(--dai-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Follow-up mode: a handful of chips, so the strip is FIXED — it never shrinks
|
||||||
|
and never scrolls.
|
||||||
|
|
||||||
|
The cap and `flex-shrink: 1` above exist for the cold panel's twenty chips.
|
||||||
|
Once a conversation starts the strip is three or four, and leaving it
|
||||||
|
shrinkable meant a long thread squeezed it: the flex row gave the space to
|
||||||
|
the conversation, `overflow-y: auto` quietly hid the remainder, and the
|
||||||
|
follow-ups the operator was meant to act on disappeared below a fold nobody
|
||||||
|
could see. Few chips must always be fully visible. */
|
||||||
|
.dai-root .dai-suggestions-bar[data-compact='true'] {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Suggested questions: chips ------------------------------------------
|
||||||
|
They wrap, they never scroll. A horizontal scroller can always leave a chip
|
||||||
|
half-visible at the edge, and a half-visible button is a bug no amount of
|
||||||
|
fade masking fixes. */
|
||||||
|
.dai-root .dai-suggestions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 11px;
|
}
|
||||||
|
|
||||||
|
.dai-suggestion {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 5px 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-weight: 500;
|
||||||
color: var(--dai-text);
|
color: var(--dai-text);
|
||||||
background: var(--dai-surface);
|
background: var(--dai-surface);
|
||||||
border: 1px solid var(--dai-border);
|
border: 1px solid var(--dai-border);
|
||||||
border-radius: 5px;
|
/* 14px, pinned — NOT 999px.
|
||||||
|
|
||||||
|
On a one-line chip a fully round radius already resolves to about 14px, so
|
||||||
|
these look identical. The difference shows on a chip whose question wraps
|
||||||
|
to two lines: 999px would resolve to half of ~46px and the chip stops
|
||||||
|
reading as a chip and starts reading as a card, so one long suggestion
|
||||||
|
would look like a different component from the nineteen beside it. */
|
||||||
|
border-radius: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
background-color 140ms ease,
|
background-color 140ms ease,
|
||||||
border-color 140ms ease,
|
border-color 140ms ease,
|
||||||
|
color 140ms ease,
|
||||||
transform 140ms ease;
|
transform 140ms ease;
|
||||||
}
|
}
|
||||||
.dai-suggestion:hover {
|
.dai-suggestion:hover {
|
||||||
background: var(--dai-surface-alt);
|
background: var(--dai-surface-alt);
|
||||||
border-color: var(--dai-border-strong);
|
border-color: var(--dai-border-strong);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
.dai-suggestion:active {
|
.dai-suggestion:active {
|
||||||
transform: scale(0.99);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
.dai-suggestion:focus-visible {
|
.dai-suggestion:focus-visible {
|
||||||
outline: 2px solid var(--dai-accent);
|
outline: 2px solid var(--dai-accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
.dai-root .dai-suggestion-icon {
|
.dai-root .dai-suggestion-icon {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 26px;
|
color: var(--dai-text-muted);
|
||||||
height: 26px;
|
transition: color 140ms ease;
|
||||||
border-radius: 5px;
|
}
|
||||||
background: var(--dai-surface-hover);
|
.dai-suggestion:hover .dai-suggestion-icon {
|
||||||
color: var(--dai-text-secondary);
|
color: var(--dai-text-secondary);
|
||||||
}
|
}
|
||||||
.dai-root .dai-suggestion-text {
|
.dai-root .dai-suggestion-text {
|
||||||
flex: 1 1 auto;
|
min-width: 0;
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
.dai-root .dai-suggestion-arrow {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
color: var(--dai-text-muted);
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-3px);
|
|
||||||
transition:
|
|
||||||
opacity 140ms ease,
|
|
||||||
transform 140ms ease;
|
|
||||||
}
|
|
||||||
.dai-suggestion:hover .dai-suggestion-arrow,
|
|
||||||
.dai-suggestion:focus-visible .dai-suggestion-arrow {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
}
|
||||||
/* Plain text link-button ("View more", "Sources") */
|
/* Plain text link-button ("View more", "Sources") */
|
||||||
.dai-link {
|
.dai-link {
|
||||||
@@ -565,17 +701,83 @@
|
|||||||
}
|
}
|
||||||
.dai-root .dai-composer {
|
.dai-root .dai-composer {
|
||||||
border: 1px solid var(--dai-border-strong);
|
border: 1px solid var(--dai-border-strong);
|
||||||
border-radius: 5px;
|
/* 14px, the same corner the suggestion chips use. At 5px the input was the
|
||||||
|
one sharp-cornered thing in a panel of rounded surfaces, and it sat
|
||||||
|
directly beneath the chips where the mismatch was most visible. */
|
||||||
|
border-radius: 14px;
|
||||||
background: var(--dai-surface);
|
background: var(--dai-surface);
|
||||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
padding: 8px 8px 6px 12px;
|
padding: 10px 10px 8px 14px;
|
||||||
transition:
|
transition:
|
||||||
border-color 140ms ease,
|
border-color 140ms ease,
|
||||||
box-shadow 140ms ease;
|
box-shadow 140ms ease;
|
||||||
}
|
}
|
||||||
.dai-root .dai-composer[data-focused='true'] {
|
.dai-root .dai-composer[data-focused='true'] {
|
||||||
border-color: var(--dai-accent);
|
border-color: var(--dai-accent);
|
||||||
box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.06);
|
box-shadow: 0 0 0 3px var(--dai-accent-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Microphone.
|
||||||
|
|
||||||
|
Astryx's smallest dictation button is 28px, two short of the send button it
|
||||||
|
sits beside — a visible step between two controls on the same row. Pinned to
|
||||||
|
match, with the glyph scaled through font-size because the icon inside is
|
||||||
|
sized in em units (the same reason the side nav icons needed font-size
|
||||||
|
rather than width). */
|
||||||
|
.dai-root .dai-mic {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
min-width: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.dai-root .dai-mic svg {
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Send button.
|
||||||
|
|
||||||
|
This had NO styles at all — the class was referenced once, in a
|
||||||
|
reduced-motion reset, and nowhere else. It rendered as a bare 16px arrow
|
||||||
|
glyph on a transparent background with square corners: not a button, just an
|
||||||
|
icon sitting next to the microphone. Verified in the browser before fixing.
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.dai-root .dai-send {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--dai-accent);
|
||||||
|
color: var(--dai-accent-contrast);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 140ms ease,
|
||||||
|
opacity 140ms ease,
|
||||||
|
transform 140ms ease;
|
||||||
|
}
|
||||||
|
.dai-root .dai-send:hover:not(:disabled) {
|
||||||
|
opacity: 0.86;
|
||||||
|
}
|
||||||
|
.dai-root .dai-send:active:not(:disabled) {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
.dai-root .dai-send:focus-visible {
|
||||||
|
outline: 2px solid var(--dai-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
/* Disabled is the resting state until something is typed, so it has to read as
|
||||||
|
"not yet", not as "broken" — a filled grey circle, same shape and weight. */
|
||||||
|
.dai-root .dai-send:disabled {
|
||||||
|
background: var(--dai-surface-hover);
|
||||||
|
color: var(--dai-text-muted);
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
.dai-root .dai-composer textarea {
|
.dai-root .dai-composer textarea {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -597,8 +799,10 @@
|
|||||||
color: var(--dai-text-muted);
|
color: var(--dai-text-muted);
|
||||||
}
|
}
|
||||||
.dai-root .dai-hint {
|
.dai-root .dai-hint {
|
||||||
font-size: 11px;
|
font-size: 10.5px;
|
||||||
|
line-height: 1.3;
|
||||||
color: var(--dai-text-muted);
|
color: var(--dai-text-muted);
|
||||||
|
padding-inline: 2px;
|
||||||
}
|
}
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Trigger (lives in the app TopNav)
|
Trigger (lives in the app TopNav)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useLayoutEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { LuArrowUp } from 'react-icons/lu';
|
import { LuArrowUp } from 'react-icons/lu';
|
||||||
|
|
||||||
@@ -17,11 +17,73 @@ import { ChatDictationButton, useChatDictation } from '@astryxdesign/core/Chat';
|
|||||||
|
|
||||||
const MAX_ROWS_PX = 108;
|
const MAX_ROWS_PX = 108;
|
||||||
|
|
||||||
|
// How long a dictating operator has to stay quiet before the message sends
|
||||||
|
// itself. Long enough to think mid-sentence, short enough that you are not left
|
||||||
|
// wondering whether it heard you.
|
||||||
|
const SILENCE_MS = 3000;
|
||||||
|
|
||||||
const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
||||||
const textareaRef = useRef(null);
|
const textareaRef = useRef(null);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
const dictation = useChatDictation({ onResult: (transcript) => onChange(transcript) });
|
// ---- dictation: send on silence ----
|
||||||
|
// Speech arrives as a stream of partial transcripts, so "finished speaking"
|
||||||
|
// is not an event the API gives us — it has to be inferred from the stream
|
||||||
|
// going quiet. Every transcript update re-arms a timer; whichever one is not
|
||||||
|
// followed by another within SILENCE_MS is the end of the sentence.
|
||||||
|
//
|
||||||
|
// Everything the timer touches is read through a ref. The callback is created
|
||||||
|
// once and fires up to three seconds later, so a closed-over `value` or
|
||||||
|
// `isBusy` would be whatever they were when dictation started, not when the
|
||||||
|
// operator stopped talking — it would send a stale half-sentence, or send
|
||||||
|
// while a previous answer was still streaming.
|
||||||
|
const valueRef = useRef(value);
|
||||||
|
const isBusyRef = useRef(isBusy);
|
||||||
|
const dictationRef = useRef(null);
|
||||||
|
const silenceTimer = useRef(0);
|
||||||
|
|
||||||
|
valueRef.current = value;
|
||||||
|
isBusyRef.current = isBusy;
|
||||||
|
|
||||||
|
const clearSilence = useCallback(() => {
|
||||||
|
if (silenceTimer.current) {
|
||||||
|
clearTimeout(silenceTimer.current);
|
||||||
|
silenceTimer.current = 0;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const armSilence = useCallback(() => {
|
||||||
|
clearSilence();
|
||||||
|
silenceTimer.current = setTimeout(() => {
|
||||||
|
silenceTimer.current = 0;
|
||||||
|
// Stop the mic first. Sending while it is still listening would let the
|
||||||
|
// next words land in an input the operator has already "sent".
|
||||||
|
dictationRef.current?.stop();
|
||||||
|
const text = valueRef.current.trim();
|
||||||
|
if (text && !isBusyRef.current) onSubmit(text);
|
||||||
|
}, SILENCE_MS);
|
||||||
|
}, [clearSilence, onSubmit]);
|
||||||
|
|
||||||
|
const dictation = useChatDictation({
|
||||||
|
// Fires on every partial AND final result, which is what makes this a
|
||||||
|
// silence detector rather than an end-of-recognition one.
|
||||||
|
onTranscript: (transcript) => {
|
||||||
|
onChange(transcript);
|
||||||
|
armSilence();
|
||||||
|
},
|
||||||
|
onResult: (transcript) => {
|
||||||
|
onChange(transcript);
|
||||||
|
armSilence();
|
||||||
|
},
|
||||||
|
// Stopped by hand, or the engine gave up: no pending auto-send.
|
||||||
|
onEnd: clearSilence,
|
||||||
|
onError: clearSilence
|
||||||
|
});
|
||||||
|
|
||||||
|
dictationRef.current = dictation;
|
||||||
|
|
||||||
|
// A panel closed mid-sentence must not fire a message afterwards.
|
||||||
|
useEffect(() => clearSilence, [clearSilence]);
|
||||||
|
|
||||||
// Grow with content, then let the stylesheet's max-height take over and
|
// Grow with content, then let the stylesheet's max-height take over and
|
||||||
// scroll. Runs before paint so there's no visible jump on the first line
|
// scroll. Runs before paint so there's no visible jump on the first line
|
||||||
@@ -61,8 +123,12 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
aria-label="Ask Doormile AI about operations"
|
aria-label="Ask Doormile AI about operations"
|
||||||
/>
|
/>
|
||||||
<HStack gap={1} padding={0} vAlign="center" justify="between">
|
{/* Both controls group on the RIGHT. `justify="between"` pushed the mic
|
||||||
<ChatDictationButton dictation={dictation} size="sm" />
|
to the far left and the send button to the far right, leaving a
|
||||||
|
wide dead gap between two things that do the same job — put a
|
||||||
|
message in. Together they read as one action cluster. */}
|
||||||
|
<HStack gap={1} padding={0} vAlign="center" justify="end">
|
||||||
|
<ChatDictationButton dictation={dictation} size="sm" className="dai-mic" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="dai-send"
|
className="dai-send"
|
||||||
@@ -74,7 +140,9 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
|||||||
</button>
|
</button>
|
||||||
</HStack>
|
</HStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
<Text className="dai-hint">Enter to send · Shift + Enter for a new line</Text>
|
<Text className="dai-hint">
|
||||||
|
{dictation.isListening ? 'Listening — sends automatically after a short pause' : 'Enter to send · Shift + Enter for a new line'}
|
||||||
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo, useState } from 'react';
|
import { memo, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { CopyOutlined } from '@ant-design/icons';
|
import { CopyOutlined } from '@ant-design/icons';
|
||||||
import { LuChevronDown, LuArrowRight } from 'react-icons/lu';
|
import { LuChevronDown } from 'react-icons/lu';
|
||||||
|
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
@@ -34,7 +34,7 @@ const UserMessage = ({ text }) => (
|
|||||||
|
|
||||||
UserMessage.propTypes = { text: PropTypes.string.isRequired };
|
UserMessage.propTypes = { text: PropTypes.string.isRequired };
|
||||||
|
|
||||||
const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) => {
|
const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) => {
|
||||||
const [showSources, setShowSources] = useState(false);
|
const [showSources, setShowSources] = useState(false);
|
||||||
const sourceCount = message.sourceCalls?.length || 0;
|
const sourceCount = message.sourceCalls?.length || 0;
|
||||||
|
|
||||||
@@ -194,16 +194,15 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
|
|||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{message.followUps?.length > 0 && (
|
{/* Follow-ups are NOT rendered here.
|
||||||
<VStack gap={1} padding={0}>
|
|
||||||
{message.followUps.map((q) => (
|
They used to appear twice: once under each answer and again in the
|
||||||
<button key={q} type="button" className="dai-suggestion" onClick={() => onAsk(q)}>
|
strip above the composer. Two sets of chips offering overlapping
|
||||||
<Text className="dai-suggestion-text">{q}</Text>
|
questions in one panel is the assistant asking the same thing twice,
|
||||||
<LuArrowRight className="dai-suggestion-arrow" size={14} aria-hidden="true" />
|
and the in-message copy scrolled away the moment the next answer
|
||||||
</button>
|
arrived — so the one that was always reachable is the one that was
|
||||||
))}
|
kept. `message.followUps` still carries the intent's suggestions;
|
||||||
</VStack>
|
AIPanel feeds them into that single strip. */}
|
||||||
)}
|
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -211,21 +210,19 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
|
|||||||
AssistantMessage.propTypes = {
|
AssistantMessage.propTypes = {
|
||||||
message: PropTypes.object.isRequired,
|
message: PropTypes.object.isRequired,
|
||||||
onCopy: PropTypes.func.isRequired,
|
onCopy: PropTypes.func.isRequired,
|
||||||
onAsk: PropTypes.func.isRequired,
|
|
||||||
onSubmitForm: PropTypes.func.isRequired,
|
onSubmitForm: PropTypes.func.isRequired,
|
||||||
onCancelAction: PropTypes.func.isRequired,
|
onCancelAction: PropTypes.func.isRequired,
|
||||||
onChooseStep: PropTypes.func.isRequired,
|
onChooseStep: PropTypes.func.isRequired,
|
||||||
onStopLive: PropTypes.func.isRequired
|
onStopLive: PropTypes.func.isRequired
|
||||||
};
|
};
|
||||||
|
|
||||||
const AIMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) =>
|
const AIMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) =>
|
||||||
message.sender === 'user' ? (
|
message.sender === 'user' ? (
|
||||||
<UserMessage text={message.text} />
|
<UserMessage text={message.text} />
|
||||||
) : (
|
) : (
|
||||||
<AssistantMessage
|
<AssistantMessage
|
||||||
message={message}
|
message={message}
|
||||||
onCopy={onCopy}
|
onCopy={onCopy}
|
||||||
onAsk={onAsk}
|
|
||||||
onSubmitForm={onSubmitForm}
|
onSubmitForm={onSubmitForm}
|
||||||
onCancelAction={onCancelAction}
|
onCancelAction={onCancelAction}
|
||||||
onChooseStep={onChooseStep}
|
onChooseStep={onChooseStep}
|
||||||
@@ -236,7 +233,6 @@ const AIMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction, onCho
|
|||||||
AIMessage.propTypes = {
|
AIMessage.propTypes = {
|
||||||
message: PropTypes.object.isRequired,
|
message: PropTypes.object.isRequired,
|
||||||
onCopy: PropTypes.func.isRequired,
|
onCopy: PropTypes.func.isRequired,
|
||||||
onAsk: PropTypes.func.isRequired,
|
|
||||||
onSubmitForm: PropTypes.func.isRequired,
|
onSubmitForm: PropTypes.func.isRequired,
|
||||||
onCancelAction: PropTypes.func.isRequired,
|
onCancelAction: PropTypes.func.isRequired,
|
||||||
onChooseStep: PropTypes.func.isRequired,
|
onChooseStep: PropTypes.func.isRequired,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
|||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
|
import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
|
||||||
import { LuChevronDown } from 'react-icons/lu';
|
import { LuChevronDown, LuPanelRightOpen, LuPanelRightClose } from 'react-icons/lu';
|
||||||
|
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
@@ -25,9 +25,9 @@ import { startAssignFlow, answerAssignStep } from '../assignFlow';
|
|||||||
import { executeAssign, executeBatchAssign, describeRider } from '../assignActions';
|
import { executeAssign, executeBatchAssign, describeRider } from '../assignActions';
|
||||||
import { startRepeatFlow, answerRepeatStep } from '../repeatFlow';
|
import { startRepeatFlow, answerRepeatStep } from '../repeatFlow';
|
||||||
import { buildRepeatRun, describeDay } from '../repeatRuns';
|
import { buildRepeatRun, describeDay } from '../repeatRuns';
|
||||||
import { getPageContext } from './pageContext';
|
import { getPageContext, getFollowUps, toChips } from './pageContext';
|
||||||
import { Spark, LiveIndicator, TypingIndicator } from './AIParts';
|
import { Spark, LiveIndicator, TypingIndicator } from './AIParts';
|
||||||
import AIWelcome from './AIWelcome';
|
import AIWelcome, { SuggestionChips } from './AIWelcome';
|
||||||
import AIMessage from './AIMessage';
|
import AIMessage from './AIMessage';
|
||||||
import AIComposer from './AIComposer';
|
import AIComposer from './AIComposer';
|
||||||
import '../DoormileAI.css';
|
import '../DoormileAI.css';
|
||||||
@@ -48,6 +48,16 @@ const HISTORY_KEY = 'doormileBotHistory';
|
|||||||
const HISTORY_LIMIT = 50;
|
const HISTORY_LIMIT = 50;
|
||||||
const PIN_THRESHOLD_PX = 48;
|
const PIN_THRESHOLD_PX = 48;
|
||||||
|
|
||||||
|
// Dock widths. Each is a percentage of the viewport with a pixel floor — 25vw
|
||||||
|
// is only 320px on a 1280 laptop, and the floor catches anything narrower than
|
||||||
|
// that, below which the panel stops being readable. Both values are also what
|
||||||
|
// the page reserves beside the panel, so they must stay a single expression
|
||||||
|
// (see the --dai-dock-width comment in DoormileAI.css for why a percentage
|
||||||
|
// cannot be used here).
|
||||||
|
const DOCK_NORMAL = 'max(300px, 25vw)';
|
||||||
|
const DOCK_WIDE = 'max(340px, 30vw)';
|
||||||
|
const WIDTH_KEY = 'doormileBotWidth';
|
||||||
|
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
const makeId = () => nextId++;
|
const makeId = () => nextId++;
|
||||||
const now = () => dayjs().format('hh:mm A');
|
const now = () => dayjs().format('hh:mm A');
|
||||||
@@ -121,6 +131,74 @@ const AIPanel = ({ isOpen, onClose }) => {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// ---- dock width: normal 25%, expanded 30% ----
|
||||||
|
// Written as a custom property on the document rather than held in CSS,
|
||||||
|
// because TWO things have to agree on it: the panel's own width and the
|
||||||
|
// padding the page reserves beside it (see the .dai-docked rule). One
|
||||||
|
// variable is the only way they cannot drift apart.
|
||||||
|
//
|
||||||
|
// The floors matter as much as the percentages. Below roughly a 1200px
|
||||||
|
// viewport these percentages fall under what a conversation is readable in,
|
||||||
|
// so each step has a pixel floor it will not go under.
|
||||||
|
//
|
||||||
|
// Persisted: the panel unmounts when it closes, so without this the operator
|
||||||
|
// re-expands it every single time they open the assistant.
|
||||||
|
const [isWide, setIsWide] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(WIDTH_KEY) === 'wide';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.style.setProperty('--dai-dock-width', isWide ? DOCK_WIDE : DOCK_NORMAL);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(WIDTH_KEY, isWide ? 'wide' : 'normal');
|
||||||
|
} catch {
|
||||||
|
/* private mode — the width just won't persist */
|
||||||
|
}
|
||||||
|
}, [isWide]);
|
||||||
|
|
||||||
|
// ---- anchor the dock to the real header height ----
|
||||||
|
// The dock starts where the top nav ends. `--appshell-header-height` looks
|
||||||
|
// like the right token for that and Dispatch.css already reads it, but
|
||||||
|
// Astryx does not actually publish it — verified in the browser, where it
|
||||||
|
// resolves to nothing and the fallback wins. The header is 57px, not the 64
|
||||||
|
// a fallback would guess, so trusting it left a 7px sliver of page visible
|
||||||
|
// above the panel.
|
||||||
|
//
|
||||||
|
// Measured instead, and re-measured on resize, so the seam stays closed if
|
||||||
|
// the header ever changes height.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMounted) return undefined;
|
||||||
|
const header = document.querySelector('.astryx-layout-header');
|
||||||
|
if (!header) return undefined;
|
||||||
|
const apply = () => {
|
||||||
|
const h = Math.round(header.getBoundingClientRect().height);
|
||||||
|
document.documentElement.style.setProperty('--dai-dock-top', `${h}px`);
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
const ro = new ResizeObserver(apply);
|
||||||
|
ro.observe(header);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [isMounted]);
|
||||||
|
|
||||||
|
// ---- the page makes room for the dock ----
|
||||||
|
// The panel is portaled to <body>, so it cannot be a flex sibling of the
|
||||||
|
// content area and cannot push it directly. This flag is the handshake: CSS
|
||||||
|
// (DoormileAI.css) reserves exactly --dai-dock-width of padding on the app's
|
||||||
|
// content container while it is set, and animates on the same curve, so the
|
||||||
|
// page slides open rather than being covered.
|
||||||
|
//
|
||||||
|
// Keyed on isShown, not isOpen, so the shift starts on the same frame the
|
||||||
|
// panel starts sliding in. Cleared on unmount as well as on close — leaving
|
||||||
|
// it set would strand the whole app behind a permanent empty gutter.
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.toggle('dai-docked', isShown);
|
||||||
|
return () => document.body.classList.remove('dai-docked');
|
||||||
|
}, [isShown]);
|
||||||
|
|
||||||
// ---- Escape closes ----
|
// ---- Escape closes ----
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return undefined;
|
if (!isOpen) return undefined;
|
||||||
@@ -1031,6 +1109,42 @@ const AIPanel = ({ isOpen, onClose }) => {
|
|||||||
|
|
||||||
const hasThread = messages.length > 0;
|
const hasThread = messages.length > 0;
|
||||||
|
|
||||||
|
// What the strip offers, and why it changes.
|
||||||
|
//
|
||||||
|
// Cold panel: everything, because nothing is known about intent yet.
|
||||||
|
//
|
||||||
|
// After a question: the natural NEXT steps for that question, not the same
|
||||||
|
// twenty again. Asking "Create an order" and being offered "Create an order"
|
||||||
|
// is the assistant not listening; being offered "Assign a rider" and "How
|
||||||
|
// many pending orders today?" is.
|
||||||
|
//
|
||||||
|
// The lookup is keyed on the last thing the OPERATOR said, not on the last
|
||||||
|
// answer, so it still narrows correctly when a flow is mid-conversation and
|
||||||
|
// the assistant's last message was a question back.
|
||||||
|
//
|
||||||
|
// Anything unrecognised — a typed question, or one with no obvious next step
|
||||||
|
// — falls back to this page's own suggestions rather than an empty strip.
|
||||||
|
const lastAsked = [...messages].reverse().find((m) => m.sender === 'user')?.text;
|
||||||
|
const lastAnswerFollowUps = [...messages].reverse().find((m) => m.sender === 'assistant' && m.followUps?.length)?.followUps;
|
||||||
|
|
||||||
|
// Three sources, most specific first:
|
||||||
|
// 1. the curated next steps for the exact question that was asked
|
||||||
|
// 2. the intent's own follow-ups, for a TYPED question the map doesn't
|
||||||
|
// cover — this is what the in-message chips used to render
|
||||||
|
// 3. this page's own suggestions, so the strip is never empty
|
||||||
|
const followUps = hasThread ? getFollowUps(lastAsked) : [];
|
||||||
|
const intentFollowUps = hasThread && followUps.length === 0 ? toChips(lastAnswerFollowUps) : [];
|
||||||
|
const nextSteps = followUps.length > 0 ? followUps : intentFollowUps;
|
||||||
|
const suggestionItems = hasThread
|
||||||
|
? nextSteps.length > 0
|
||||||
|
? nextSteps
|
||||||
|
: pageContext.suggestions.slice(0, 4)
|
||||||
|
: [...pageContext.suggestions, ...(pageContext.more || [])];
|
||||||
|
|
||||||
|
// Few chips => the strip is fixed and always fully visible. Twenty => it may
|
||||||
|
// shrink and scroll. See the [data-compact] rule in DoormileAI.css.
|
||||||
|
const isCompactStrip = hasThread;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<>
|
<>
|
||||||
<HStack className="dai-root dai-scrim" data-open={isShown} padding={0} gap={0} onClick={onClose} aria-hidden="true" />
|
<HStack className="dai-root dai-scrim" data-open={isShown} padding={0} gap={0} onClick={onClose} aria-hidden="true" />
|
||||||
@@ -1042,7 +1156,12 @@ const AIPanel = ({ isOpen, onClose }) => {
|
|||||||
data-open={isShown}
|
data-open={isShown}
|
||||||
gap={0}
|
gap={0}
|
||||||
padding={0}
|
padding={0}
|
||||||
role="dialog"
|
/* `complementary`, not `dialog`. A dialog promises modality — focus is
|
||||||
|
trapped, the rest of the page is inert, and screen readers announce
|
||||||
|
it as blocking. None of that is true any more: the page beside it
|
||||||
|
stays live and operable, so announcing it as a dialog would be a
|
||||||
|
lie to exactly the users who cannot see that it is docked. */
|
||||||
|
role="complementary"
|
||||||
aria-label="Doormile AI — Operations Copilot"
|
aria-label="Doormile AI — Operations Copilot"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
@@ -1058,6 +1177,28 @@ const AIPanel = ({ isOpen, onClose }) => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
<HStack gap={1} padding={0} vAlign="center">
|
<HStack gap={1} padding={0} vAlign="center">
|
||||||
<LiveIndicator />
|
<LiveIndicator />
|
||||||
|
{/* No `size` prop on the icon — deliberately.
|
||||||
|
|
||||||
|
Its two siblings here are Ant Design icons, which render at
|
||||||
|
1em and so inherit the button's 16px. Pinning this one to 15
|
||||||
|
made it a pixel smaller than the ⋯ and × beside it, which is
|
||||||
|
exactly the kind of one-pixel mismatch that reads as "off"
|
||||||
|
without being obviously wrong. Left unset, react-icons also
|
||||||
|
defaults to 1em and all three track the button together.
|
||||||
|
|
||||||
|
Panel icons rather than chevrons: a bare chevron next to a
|
||||||
|
close button is ambiguous (collapse? navigate? close?),
|
||||||
|
whereas these draw the side panel itself getting wider or
|
||||||
|
narrower, which is literally what the control does. */}
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
label={isWide ? 'Narrow the assistant' : 'Widen the assistant'}
|
||||||
|
tooltip={isWide ? 'Narrow to 25%' : 'Widen to 30%'}
|
||||||
|
aria-pressed={isWide}
|
||||||
|
icon={isWide ? <LuPanelRightClose /> : <LuPanelRightOpen />}
|
||||||
|
onClick={() => setIsWide((v) => !v)}
|
||||||
|
/>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
hasChevron={false}
|
hasChevron={false}
|
||||||
button={{ label: 'Conversation options', icon: <MoreOutlined />, isIconOnly: true, variant: 'ghost', size: 'sm' }}
|
button={{ label: 'Conversation options', icon: <MoreOutlined />, isIconOnly: true, variant: 'ghost', size: 'sm' }}
|
||||||
@@ -1091,7 +1232,6 @@ const AIPanel = ({ isOpen, onClose }) => {
|
|||||||
key={m.id}
|
key={m.id}
|
||||||
message={m}
|
message={m}
|
||||||
onCopy={copyMessage}
|
onCopy={copyMessage}
|
||||||
onAsk={ask}
|
|
||||||
onSubmitForm={submitForm}
|
onSubmitForm={submitForm}
|
||||||
onCancelAction={cancelAction}
|
onCancelAction={cancelAction}
|
||||||
onChooseStep={chooseStep}
|
onChooseStep={chooseStep}
|
||||||
@@ -1109,7 +1249,7 @@ const AIPanel = ({ isOpen, onClose }) => {
|
|||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
) : (
|
) : (
|
||||||
<AIWelcome context={pageContext} onAsk={ask} />
|
<AIWelcome />
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|
||||||
@@ -1121,6 +1261,24 @@ const AIPanel = ({ isOpen, onClose }) => {
|
|||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|
||||||
|
{/* ---- suggestions, pinned above the composer ----
|
||||||
|
They sit with the input because that is what they are: a way to
|
||||||
|
start a message without typing one. Inside the scrolling body they
|
||||||
|
were unreachable the moment a conversation began.
|
||||||
|
|
||||||
|
The full set shows only on an empty thread. Once there is a
|
||||||
|
conversation the strip drops to this page's four primary
|
||||||
|
suggestions — twenty chips permanently above the composer would
|
||||||
|
claim ~260px of a panel whose whole job is the conversation. */}
|
||||||
|
{suggestionItems.length > 0 && (
|
||||||
|
<VStack className="dai-suggestions-bar" data-compact={isCompactStrip} gap={1} padding={0}>
|
||||||
|
<Text className="dai-section-label">
|
||||||
|
{hasThread ? (nextSteps.length > 0 ? 'Next steps' : 'Ask something else') : 'Suggested questions'}
|
||||||
|
</Text>
|
||||||
|
<SuggestionChips items={suggestionItems} onAsk={ask} />
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ---- composer ---- */}
|
{/* ---- composer ---- */}
|
||||||
<AIComposer value={value} onChange={setValue} onSubmit={ask} isBusy={isSending} placeholder="Ask about orders, riders, hubs…" />
|
<AIComposer value={value} onChange={setValue} onSubmit={ask} isBusy={isSending} placeholder="Ask about orders, riders, hubs…" />
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { LuArrowRight } from 'react-icons/lu';
|
|
||||||
|
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
|
|
||||||
import { Spark } from './AIParts';
|
import { Spark } from './AIParts';
|
||||||
|
import { CHIP_LABELS } from './pageContext';
|
||||||
|
|
||||||
// ==============================|| Doormile AI — welcome state ||============================== //
|
// ==============================|| Doormile AI — welcome state ||============================== //
|
||||||
//
|
//
|
||||||
@@ -21,61 +21,72 @@ const greeting = () => {
|
|||||||
return 'Good evening';
|
return 'Good evening';
|
||||||
};
|
};
|
||||||
|
|
||||||
const SuggestionCard = ({ item, onAsk }) => {
|
// A chip, not a row.
|
||||||
|
//
|
||||||
|
// These used to be full-width rows: a 26px icon tile, the question, and a
|
||||||
|
// reveal-on-hover arrow, stacked one per line. That reads fine for four
|
||||||
|
// suggestions and falls apart at twenty — the panel opened on a wall of
|
||||||
|
// identical boxes that had to be read top to bottom, and the whole welcome
|
||||||
|
// state scrolled.
|
||||||
|
//
|
||||||
|
// As chips they flow into a block that is scanned rather than read: each one
|
||||||
|
// is only as wide as its question, so the varying widths give the eye
|
||||||
|
// something to land on, and twenty of them occupy a few rows instead of
|
||||||
|
// twenty. The icon stays as a small inline glyph for recognition; the arrow
|
||||||
|
// is gone, because on a chip it was decoration competing with the label.
|
||||||
|
const SuggestionChip = ({ item, onAsk }) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
|
// Display short, ask long. `item.text` is what intents.js matches on, so it
|
||||||
|
// is what gets asked and what assistive tech announces; the chip only shows
|
||||||
|
// a trimmed version of it so twenty of them fit in a few rows.
|
||||||
|
const label = CHIP_LABELS[item.text] || item.text;
|
||||||
return (
|
return (
|
||||||
<button type="button" className="dai-suggestion" onClick={() => onAsk(item.text)}>
|
<button type="button" className="dai-suggestion" onClick={() => onAsk(item.text)} aria-label={item.text} title={item.text}>
|
||||||
<HStack className="dai-suggestion-icon" padding={0} gap={0} aria-hidden="true">
|
<Icon className="dai-suggestion-icon" size={13} strokeWidth={2} aria-hidden="true" />
|
||||||
<Icon size={14} strokeWidth={2} />
|
<span className="dai-suggestion-text">{label}</span>
|
||||||
</HStack>
|
|
||||||
<Text className="dai-suggestion-text">{item.text}</Text>
|
|
||||||
<LuArrowRight className="dai-suggestion-arrow" size={14} aria-hidden="true" />
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
SuggestionCard.propTypes = {
|
SuggestionChip.propTypes = {
|
||||||
item: PropTypes.shape({ icon: PropTypes.elementType.isRequired, text: PropTypes.string.isRequired }).isRequired,
|
item: PropTypes.shape({ icon: PropTypes.elementType.isRequired, text: PropTypes.string.isRequired }).isRequired,
|
||||||
onAsk: PropTypes.func.isRequired
|
onAsk: PropTypes.func.isRequired
|
||||||
};
|
};
|
||||||
|
|
||||||
const AIWelcome = ({ context, onAsk }) => {
|
// Exported so AIPanel can render it directly above the composer rather than
|
||||||
// Every suggestion for this page, always. `context.more` used to sit behind a
|
// inside the scrolling body. The chips are a way to START a message, so they
|
||||||
// "View N more" / "Show fewer" toggle; hiding half the things the assistant
|
// belong with the input, not buried in the welcome copy where they scrolled
|
||||||
// can answer behind a click made it look narrower than it is.
|
// out of reach the moment a conversation began.
|
||||||
const shown = [...context.suggestions, ...(context.more || [])];
|
export const SuggestionChips = ({ items, onAsk }) => (
|
||||||
|
<HStack className="dai-suggestions" wrap="wrap" gap={1.5} padding={0} vAlign="start">
|
||||||
|
{items.map((item) => (
|
||||||
|
<SuggestionChip key={item.text} item={item} onAsk={onAsk} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
|
||||||
|
SuggestionChips.propTypes = {
|
||||||
|
items: PropTypes.array.isRequired,
|
||||||
|
onAsk: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
const AIWelcome = () => {
|
||||||
return (
|
return (
|
||||||
<VStack className="dai-welcome" gap={3} padding={0}>
|
<VStack className="dai-welcome" gap={1.5} padding={0}>
|
||||||
<VStack gap={1.5} padding={0}>
|
<Spark size="lg" />
|
||||||
<Spark size="lg" />
|
<VStack gap={0.5} padding={0}>
|
||||||
<VStack gap={0.5} padding={0}>
|
<Text className="dai-welcome-greeting">{greeting()}</Text>
|
||||||
<Text className="dai-welcome-greeting">{greeting()}</Text>
|
<Text className="dai-welcome-lead">How can I help with today’s operations?</Text>
|
||||||
<Text className="dai-welcome-lead">How can I help with today’s operations?</Text>
|
|
||||||
</VStack>
|
|
||||||
<Text className="dai-welcome-note">
|
|
||||||
I read live orders, riders, vehicles and hubs. Every number comes from a real call — I never estimate one.
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<VStack gap={1.5} padding={0}>
|
|
||||||
<Text className="dai-section-label">Suggested questions</Text>
|
|
||||||
<VStack gap={1} padding={0}>
|
|
||||||
{shown.map((item) => (
|
|
||||||
<SuggestionCard key={item.text} item={item} onAsk={onAsk} />
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
|
<Text className="dai-welcome-note">
|
||||||
|
I read live orders, riders, vehicles and hubs. Every number comes from a real call — I never estimate one.
|
||||||
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
AIWelcome.propTypes = {
|
AIWelcome.propTypes = {
|
||||||
context: PropTypes.shape({
|
onAsk: PropTypes.func
|
||||||
suggestions: PropTypes.array.isRequired,
|
|
||||||
more: PropTypes.array
|
|
||||||
}).isRequired,
|
|
||||||
onAsk: PropTypes.func.isRequired
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AIWelcome;
|
export default AIWelcome;
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
LuTimerOff,
|
LuTimerOff,
|
||||||
LuUserPlus,
|
LuUserPlus,
|
||||||
LuPackagePlus,
|
LuPackagePlus,
|
||||||
LuListPlus
|
LuListPlus,
|
||||||
|
LuRepeat
|
||||||
} from 'react-icons/lu';
|
} from 'react-icons/lu';
|
||||||
|
|
||||||
// ==============================|| Doormile AI — page context ||============================== //
|
// ==============================|| Doormile AI — page context ||============================== //
|
||||||
@@ -26,13 +27,68 @@ import {
|
|||||||
// answer that yet" is worse than no chip. When adding one, check it against
|
// answer that yet" is worse than no chip. When adding one, check it against
|
||||||
// the INTENTS catalog first.
|
// the INTENTS catalog first.
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Short chip labels.
|
||||||
|
//
|
||||||
|
// Every `text` below is the exact phrasing the deterministic matcher in
|
||||||
|
// intents.js resolves, so it CANNOT be shortened in place — that is the string
|
||||||
|
// the click actually asks. This map only changes what the chip DISPLAYS.
|
||||||
|
//
|
||||||
|
// It exists because the welcome state offers up to twenty suggestions, and as
|
||||||
|
// full sentences they are 130-255px wide in a ~393px panel: barely two fit per
|
||||||
|
// row, so the "chips" laid out as fourteen near-full-width rows and read as a
|
||||||
|
// list again. Shortened, three or four fit per row and the block becomes
|
||||||
|
// something you scan.
|
||||||
|
//
|
||||||
|
// The full question stays the accessible name and the tooltip, so nothing is
|
||||||
|
// lost for screen readers or for anyone unsure what a chip will ask. A
|
||||||
|
// suggestion with no entry here simply shows its full text.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const CHIP_LABELS = {
|
||||||
|
"Give me today's operations summary": "Today's summary",
|
||||||
|
'How many orders today?': 'Orders today',
|
||||||
|
'Which orders are delayed?': 'Delayed orders',
|
||||||
|
'Create an order': 'Create order',
|
||||||
|
'Create multiple orders': 'Bulk orders',
|
||||||
|
'Total revenue today': 'Revenue today',
|
||||||
|
'How many pending orders today?': 'Pending today',
|
||||||
|
'How many cancelled orders today?': 'Cancelled today',
|
||||||
|
'How many delivered orders today?': 'Delivered today',
|
||||||
|
'Morning Batch orders today': 'Morning batch',
|
||||||
|
'Orders today vs yesterday': 'Today vs yesterday',
|
||||||
|
'Create a customer': 'Create customer',
|
||||||
|
'How many riders are active?': 'Active riders',
|
||||||
|
'How many vehicles are available?': 'Vehicles available',
|
||||||
|
'Current hub status': 'Hub status',
|
||||||
|
'Which hubs are experiencing delays?': 'Hub delays',
|
||||||
|
'How many tenants do we have?': 'Tenants',
|
||||||
|
'How many orders this week?': 'Orders this week',
|
||||||
|
'Total revenue this week': 'Revenue this week',
|
||||||
|
'How many customers do we have?': 'Customers',
|
||||||
|
'Assign a rider': 'Assign rider',
|
||||||
|
"Repeat yesterday's orders": 'Repeat yesterday'
|
||||||
|
};
|
||||||
|
|
||||||
|
// The one follow-up phrasing that is not already a page suggestion. Verified
|
||||||
|
// against ASSIGN_TRIGGER in assignActions.js — it opens the assign-rider flow.
|
||||||
|
const ASSIGN_TEXT = 'Assign a rider';
|
||||||
|
|
||||||
|
// Recreates a previous day's run rather than building an order from scratch.
|
||||||
|
// This is the phrasing intents.js documents for it ("repeat yesterday's
|
||||||
|
// orders") and it is matched by REPEAT_TRIGGER in repeatRuns.js, which is
|
||||||
|
// ordered AHEAD of createOrder/createBulkOrders precisely so a sentence
|
||||||
|
// containing both "repeat" and "orders" recalls the run instead of opening a
|
||||||
|
// blank create form.
|
||||||
|
const REPEAT_TEXT = "Repeat yesterday's orders";
|
||||||
|
|
||||||
const ORDERS = {
|
const ORDERS = {
|
||||||
label: 'Orders',
|
label: 'Orders',
|
||||||
suggestions: [
|
suggestions: [
|
||||||
{ icon: LuClock3, text: "Give me today's operations summary" },
|
{ icon: LuClock3, text: "Give me today's operations summary" },
|
||||||
{ icon: LuPackage, text: 'How many orders today?' },
|
{ icon: LuPackage, text: 'How many orders today?' },
|
||||||
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
|
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
|
||||||
{ icon: LuPackagePlus, text: 'Create an order' }
|
{ icon: LuPackagePlus, text: 'Create an order' },
|
||||||
|
{ icon: LuRepeat, text: REPEAT_TEXT }
|
||||||
],
|
],
|
||||||
more: [
|
more: [
|
||||||
{ icon: LuListPlus, text: 'Create multiple orders' },
|
{ icon: LuListPlus, text: 'Create multiple orders' },
|
||||||
@@ -41,8 +97,7 @@ const ORDERS = {
|
|||||||
{ icon: LuPackage, text: 'How many cancelled orders today?' },
|
{ icon: LuPackage, text: 'How many cancelled orders today?' },
|
||||||
{ icon: LuPackage, text: 'How many delivered orders today?' },
|
{ icon: LuPackage, text: 'How many delivered orders today?' },
|
||||||
{ icon: LuLayers, text: 'Morning Batch orders today' },
|
{ icon: LuLayers, text: 'Morning Batch orders today' },
|
||||||
{ icon: LuClock3, text: 'Orders today vs yesterday' },
|
{ icon: LuClock3, text: 'Orders today vs yesterday' }
|
||||||
{ icon: LuUserPlus, text: 'Create a customer' }
|
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,6 +187,77 @@ const DEFAULT_CONTEXT = {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Follow-ups.
|
||||||
|
//
|
||||||
|
// What the suggestion strip offers AFTER a question has been asked. Showing
|
||||||
|
// all twenty again is noise — the operator has already told you what they are
|
||||||
|
// doing, so the next set should be the natural next steps for it. Creating an
|
||||||
|
// order leads to assigning a rider and to what is still pending; asking about
|
||||||
|
// revenue leads to the week and to the day-over-day comparison.
|
||||||
|
//
|
||||||
|
// Every string here is either one of the CHIP_LABELS questions above (all of
|
||||||
|
// which the matcher already resolves) or ASSIGN_TEXT. That constraint is the
|
||||||
|
// whole point: pageContext's rule is that a suggestion which returns "I can't
|
||||||
|
// answer that yet" is worse than no suggestion, and a follow-up is MORE likely
|
||||||
|
// to be clicked than a cold one, because it arrives exactly when it is
|
||||||
|
// relevant. Do not add a phrasing here without checking it against INTENTS.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const FOLLOW_UPS = {
|
||||||
|
// --- write actions ---
|
||||||
|
// Note what is NOT here: assigning a rider.
|
||||||
|
//
|
||||||
|
// "Create an order" only STARTS the create conversation — no order exists at
|
||||||
|
// that moment, so offering to assign one is offering to act on nothing. And
|
||||||
|
// once the order really is created, AIPanel already drops the operator
|
||||||
|
// straight into the assign flow itself (see the confirmOrder branch), because
|
||||||
|
// that hand-off is mandatory rather than optional. A chip there would either
|
||||||
|
// duplicate a step the product performs on its own or fire it too early.
|
||||||
|
//
|
||||||
|
// ASSIGN_TEXT belongs on the questions below that surface orders which
|
||||||
|
// already EXIST and are waiting for a rider.
|
||||||
|
'Create an order': [REPEAT_TEXT, 'How many pending orders today?', 'Which orders are delayed?', 'Create multiple orders'],
|
||||||
|
'Create multiple orders': ['How many pending orders today?', 'How many orders today?'],
|
||||||
|
[REPEAT_TEXT]: ['How many pending orders today?', 'How many orders today?', 'Orders today vs yesterday'],
|
||||||
|
'Create a customer': ['Create an order', 'How many customers do we have?'],
|
||||||
|
[ASSIGN_TEXT]: ['How many pending orders today?', 'Which orders are delayed?', 'How many riders are active?'],
|
||||||
|
|
||||||
|
// --- order volume ---
|
||||||
|
"Give me today's operations summary": [
|
||||||
|
'How many pending orders today?',
|
||||||
|
'Which orders are delayed?',
|
||||||
|
'Total revenue today',
|
||||||
|
'How many riders are active?'
|
||||||
|
],
|
||||||
|
'How many orders today?': [
|
||||||
|
'How many pending orders today?',
|
||||||
|
'How many delivered orders today?',
|
||||||
|
'Orders today vs yesterday',
|
||||||
|
'Total revenue today'
|
||||||
|
],
|
||||||
|
'How many pending orders today?': [ASSIGN_TEXT, 'Which orders are delayed?', 'How many delivered orders today?'],
|
||||||
|
'How many delivered orders today?': ['How many pending orders today?', 'Total revenue today', 'Orders today vs yesterday'],
|
||||||
|
'How many cancelled orders today?': ['How many delivered orders today?', 'How many orders today?', 'Which orders are delayed?'],
|
||||||
|
'Which orders are delayed?': [ASSIGN_TEXT, 'Which hubs are experiencing delays?', 'How many riders are active?'],
|
||||||
|
'Morning Batch orders today': ['Which orders are delayed?', 'How many riders are active?', 'How many orders today?'],
|
||||||
|
|
||||||
|
// --- money and trend ---
|
||||||
|
'Total revenue today': ['Total revenue this week', 'Orders today vs yesterday', 'How many orders today?'],
|
||||||
|
'Total revenue this week': ['How many orders this week?', 'Total revenue today'],
|
||||||
|
'Orders today vs yesterday': [REPEAT_TEXT, 'Total revenue today', 'How many orders this week?'],
|
||||||
|
'How many orders this week?': ['Total revenue this week', 'How many orders today?'],
|
||||||
|
|
||||||
|
// --- fleet and network ---
|
||||||
|
'How many riders are active?': ['How many vehicles are available?', 'Current hub status', ASSIGN_TEXT],
|
||||||
|
'How many vehicles are available?': ['How many riders are active?', 'Current hub status'],
|
||||||
|
'Current hub status': ['Which hubs are experiencing delays?', 'How many vehicles are available?', 'How many riders are active?'],
|
||||||
|
'Which hubs are experiencing delays?': ['Current hub status', 'Which orders are delayed?', 'How many riders are active?'],
|
||||||
|
|
||||||
|
// --- accounts ---
|
||||||
|
'How many tenants do we have?': ['How many customers do we have?', 'How many orders today?'],
|
||||||
|
'How many customers do we have?': ['Create a customer', 'How many tenants do we have?']
|
||||||
|
};
|
||||||
|
|
||||||
// Longest-prefix first so 'orders/create' doesn't fall through to 'orders'
|
// Longest-prefix first so 'orders/create' doesn't fall through to 'orders'
|
||||||
// with the wrong label.
|
// with the wrong label.
|
||||||
const ROUTES = [
|
const ROUTES = [
|
||||||
@@ -173,6 +299,10 @@ const ROUTES = [
|
|||||||
// would only be a second place for a question to hide.
|
// would only be a second place for a question to hide.
|
||||||
const ALL_CONTEXTS = [ORDERS, RIDERS, VEHICLES, HUBS, DISPATCH, TENANTS, REPORTS, DEFAULT_CONTEXT, ...ROUTES.map(([, c]) => c)];
|
const ALL_CONTEXTS = [ORDERS, RIDERS, VEHICLES, HUBS, DISPATCH, TENANTS, REPORTS, DEFAULT_CONTEXT, ...ROUTES.map(([, c]) => c)];
|
||||||
|
|
||||||
|
// Every question declared anywhere. Two jobs: it is the icon index behind
|
||||||
|
// QUESTION_BY_TEXT (so a follow-up naming a question that is no longer global
|
||||||
|
// still renders with the right icon), and it is the pool GLOBAL_SUGGESTIONS
|
||||||
|
// filters. Do not narrow it — narrow GLOBAL_TEXTS instead.
|
||||||
const EVERY_QUESTION = (() => {
|
const EVERY_QUESTION = (() => {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const out = [];
|
const out = [];
|
||||||
@@ -186,10 +316,45 @@ const EVERY_QUESTION = (() => {
|
|||||||
return out;
|
return out;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const withEveryQuestion = (context) => {
|
// ---------------------------------------------------------------------------
|
||||||
|
// The questions that ride along on EVERY page.
|
||||||
|
//
|
||||||
|
// This used to be EVERY_QUESTION — all twenty-one, everywhere — which meant the
|
||||||
|
// Orders page offered "How many tenants do we have?" and "Current hub status"
|
||||||
|
// above the composer, questions an operator working orders is not asking. The
|
||||||
|
// strip is a shortcut, and a shortcut that lists everything is a menu.
|
||||||
|
//
|
||||||
|
// What is left is the daily operating picture: what came in, what is stuck,
|
||||||
|
// what got out, what it earned, and the three ways to put work into the system.
|
||||||
|
//
|
||||||
|
// Dropping a question from THIS list does not remove it from the assistant —
|
||||||
|
// it stays typeable, it stays a follow-up, and it still leads on its own page,
|
||||||
|
// because withGlobalQuestions puts each page's own suggestions first. Hubs
|
||||||
|
// still opens on hub questions; they just no longer follow you to Orders.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const GLOBAL_TEXTS = new Set([
|
||||||
|
"Give me today's operations summary",
|
||||||
|
'How many orders today?',
|
||||||
|
'Which orders are delayed?',
|
||||||
|
'How many pending orders today?',
|
||||||
|
'How many delivered orders today?',
|
||||||
|
'How many cancelled orders today?',
|
||||||
|
'Morning Batch orders today',
|
||||||
|
'Orders today vs yesterday',
|
||||||
|
'Total revenue today',
|
||||||
|
'How many riders are active?',
|
||||||
|
'Create an order',
|
||||||
|
REPEAT_TEXT,
|
||||||
|
'Create multiple orders'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const GLOBAL_SUGGESTIONS = EVERY_QUESTION.filter((q) => GLOBAL_TEXTS.has(q.text));
|
||||||
|
|
||||||
|
// The page's own suggestions lead; the global set follows, deduplicated.
|
||||||
|
const withGlobalQuestions = (context) => {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const suggestions = [];
|
const suggestions = [];
|
||||||
[...(context.suggestions || []), ...(context.more || []), ...EVERY_QUESTION].forEach((q) => {
|
[...(context.suggestions || []), ...(context.more || []), ...GLOBAL_SUGGESTIONS].forEach((q) => {
|
||||||
if (seen.has(q.text)) return;
|
if (seen.has(q.text)) return;
|
||||||
seen.add(q.text);
|
seen.add(q.text);
|
||||||
suggestions.push(q);
|
suggestions.push(q);
|
||||||
@@ -199,5 +364,30 @@ const withEveryQuestion = (context) => {
|
|||||||
|
|
||||||
export const getPageContext = (pathname = '') => {
|
export const getPageContext = (pathname = '') => {
|
||||||
const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix));
|
const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix));
|
||||||
return withEveryQuestion(match ? match[1] : DEFAULT_CONTEXT);
|
return withGlobalQuestions(match ? match[1] : DEFAULT_CONTEXT);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Follow-ups are stored as bare strings (they have to be — they are the exact
|
||||||
|
// text that gets asked), so this turns one back into a renderable chip by
|
||||||
|
// looking its icon up among the questions already declared above. Built from
|
||||||
|
// EVERY_QUESTION rather than a second hand-written list, so a suggestion can
|
||||||
|
// never end up with an icon here that disagrees with its icon on a page.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const QUESTION_BY_TEXT = new Map(EVERY_QUESTION.map((q) => [q.text, q]));
|
||||||
|
QUESTION_BY_TEXT.set(ASSIGN_TEXT, { icon: LuBike, text: ASSIGN_TEXT });
|
||||||
|
|
||||||
|
// Returns [] for anything unrecognised — a typed question, or one with no
|
||||||
|
// natural next step. The caller falls back to the page's own suggestions, so
|
||||||
|
// the strip is never empty.
|
||||||
|
export const getFollowUps = (askedText) => {
|
||||||
|
const next = FOLLOW_UPS[askedText];
|
||||||
|
if (!next) return [];
|
||||||
|
return next.map((text) => QUESTION_BY_TEXT.get(text)).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Turns arbitrary follow-up strings into chips. Used for the intent-keyed
|
||||||
|
// FOLLOW_UP_SUGGESTIONS in intents.js, whose phrasings ("What about
|
||||||
|
// yesterday?", "This week") are conversational rather than one of the declared
|
||||||
|
// questions, so they have no icon of their own.
|
||||||
|
export const toChips = (texts = []) => texts.map((text) => QUESTION_BY_TEXT.get(text) || { icon: LuCircleDot, text });
|
||||||
|
|||||||
Reference in New Issue
Block a user