updated the ai bot to the next level agent

This commit is contained in:
2026-08-20 18:23:37 +05:30
parent 1bf89adb6a
commit 505d50cc0a
7 changed files with 774 additions and 122 deletions

View File

@@ -88,14 +88,39 @@ const AppSideNav = ({ isCollapsed, onCollapsedChange }) => {
.doormile-side-nav .astryx-side-nav-item[aria-label] > * {
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 {
width: 18px !important;
height: 18px !important;
font-size: 20px !important;
width: 20px !important;
height: 20px !important;
margin: 0 !important;
display: flex;
align-items: 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 {
background-color: ${DT.brand}14 !important;
}

View File

@@ -12,6 +12,38 @@
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 {
/* 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
@@ -20,6 +52,8 @@
change here rather than a hunt through the components. */
--dai-accent: var(--color-accent, #0f172a);
--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
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;
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 {
display: none;
position: fixed;
inset: 0;
z-index: 1300;
@@ -62,31 +103,88 @@
/* --------------------------------------------------------------------------
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 {
position: fixed;
top: var(--dai-inset);
right: var(--dai-inset);
bottom: var(--dai-inset);
z-index: 1301;
width: var(--dai-panel-width);
max-width: calc(100vw - (var(--dai-inset) * 2));
/* Set from JS by AIPanel — Astryx does not publish a header-height token,
despite --appshell-header-height looking like one. */
top: var(--dai-dock-top, 57px);
right: 0;
bottom: 0;
z-index: 1200;
width: var(--dai-dock-width);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: var(--dai-surface);
border: 1px solid var(--dai-border);
border-radius: 5px;
box-shadow: var(--dai-shadow);
transform: translateX(calc(100% + var(--dai-inset) * 2));
opacity: 0;
border-left: 1px solid var(--dai-border);
border-radius: 0;
box-shadow: -6px 0 20px rgba(15, 23, 42, 0.05);
transform: translateX(100%);
visibility: hidden;
transition:
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'] {
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 {
outline: none;
@@ -221,64 +319,102 @@
/* --------------------------------------------------------------------------
Suggestion cards
-------------------------------------------------------------------------- */
.dai-suggestion {
display: flex;
align-items: center;
gap: 10px;
/* The suggestions strip sits between the conversation and the composer, so it
needs the composer's horizontal rhythm and a hairline to separate it from
the thread scrolling above it. */
.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%;
padding: 10px 11px;
}
.dai-suggestion {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 5px 10px;
text-align: left;
font: inherit;
font-size: 12px;
line-height: 1.35;
font-weight: 500;
color: var(--dai-text);
background: var(--dai-surface);
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;
transition:
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease,
transform 140ms ease;
}
.dai-suggestion:hover {
background: var(--dai-surface-alt);
border-color: var(--dai-border-strong);
transform: translateY(-1px);
}
.dai-suggestion:active {
transform: scale(0.99);
transform: translateY(0);
}
.dai-suggestion:focus-visible {
outline: 2px solid var(--dai-accent);
outline-offset: 2px;
}
.dai-root .dai-suggestion-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: 5px;
background: var(--dai-surface-hover);
color: var(--dai-text-muted);
transition: color 140ms ease;
}
.dai-suggestion:hover .dai-suggestion-icon {
color: var(--dai-text-secondary);
}
.dai-root .dai-suggestion-text {
flex: 1 1 auto;
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);
min-width: 0;
}
/* Plain text link-button ("View more", "Sources") */
.dai-link {
@@ -565,17 +701,83 @@
}
.dai-root .dai-composer {
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);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
padding: 8px 8px 6px 12px;
padding: 10px 10px 8px 14px;
transition:
border-color 140ms ease,
box-shadow 140ms ease;
}
.dai-root .dai-composer[data-focused='true'] {
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 {
display: block;
@@ -597,8 +799,10 @@
color: var(--dai-text-muted);
}
.dai-root .dai-hint {
font-size: 11px;
font-size: 10.5px;
line-height: 1.3;
color: var(--dai-text-muted);
padding-inline: 2px;
}
/* --------------------------------------------------------------------------
Trigger (lives in the app TopNav)

View File

@@ -1,4 +1,4 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { LuArrowUp } from 'react-icons/lu';
@@ -17,11 +17,73 @@ import { ChatDictationButton, useChatDictation } from '@astryxdesign/core/Chat';
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 textareaRef = useRef(null);
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
// 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}
aria-label="Ask Doormile AI about operations"
/>
<HStack gap={1} padding={0} vAlign="center" justify="between">
<ChatDictationButton dictation={dictation} size="sm" />
{/* Both controls group on the RIGHT. `justify="between"` pushed the mic
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
type="button"
className="dai-send"
@@ -74,7 +140,9 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
</button>
</HStack>
</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>
);
};

View File

@@ -1,7 +1,7 @@
import { memo, useState } from 'react';
import PropTypes from 'prop-types';
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 { VStack } from '@astryxdesign/core/VStack';
@@ -34,7 +34,7 @@ const UserMessage = ({ text }) => (
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 sourceCount = message.sourceCalls?.length || 0;
@@ -194,16 +194,15 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
</VStack>
)}
{message.followUps?.length > 0 && (
<VStack gap={1} padding={0}>
{message.followUps.map((q) => (
<button key={q} type="button" className="dai-suggestion" onClick={() => onAsk(q)}>
<Text className="dai-suggestion-text">{q}</Text>
<LuArrowRight className="dai-suggestion-arrow" size={14} aria-hidden="true" />
</button>
))}
</VStack>
)}
{/* Follow-ups are NOT rendered here.
They used to appear twice: once under each answer and again in the
strip above the composer. Two sets of chips offering overlapping
questions in one panel is the assistant asking the same thing twice,
and the in-message copy scrolled away the moment the next answer
arrived — so the one that was always reachable is the one that was
kept. `message.followUps` still carries the intent's suggestions;
AIPanel feeds them into that single strip. */}
</VStack>
);
};
@@ -211,21 +210,19 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
AssistantMessage.propTypes = {
message: PropTypes.object.isRequired,
onCopy: PropTypes.func.isRequired,
onAsk: PropTypes.func.isRequired,
onSubmitForm: PropTypes.func.isRequired,
onCancelAction: PropTypes.func.isRequired,
onChooseStep: 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' ? (
<UserMessage text={message.text} />
) : (
<AssistantMessage
message={message}
onCopy={onCopy}
onAsk={onAsk}
onSubmitForm={onSubmitForm}
onCancelAction={onCancelAction}
onChooseStep={onChooseStep}
@@ -236,7 +233,6 @@ const AIMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction, onCho
AIMessage.propTypes = {
message: PropTypes.object.isRequired,
onCopy: PropTypes.func.isRequired,
onAsk: PropTypes.func.isRequired,
onSubmitForm: PropTypes.func.isRequired,
onCancelAction: PropTypes.func.isRequired,
onChooseStep: PropTypes.func.isRequired,

View File

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { useLocation } from 'react-router-dom';
import dayjs from 'dayjs';
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 { VStack } from '@astryxdesign/core/VStack';
@@ -25,9 +25,9 @@ import { startAssignFlow, answerAssignStep } from '../assignFlow';
import { executeAssign, executeBatchAssign, describeRider } from '../assignActions';
import { startRepeatFlow, answerRepeatStep } from '../repeatFlow';
import { buildRepeatRun, describeDay } from '../repeatRuns';
import { getPageContext } from './pageContext';
import { getPageContext, getFollowUps, toChips } from './pageContext';
import { Spark, LiveIndicator, TypingIndicator } from './AIParts';
import AIWelcome from './AIWelcome';
import AIWelcome, { SuggestionChips } from './AIWelcome';
import AIMessage from './AIMessage';
import AIComposer from './AIComposer';
import '../DoormileAI.css';
@@ -48,6 +48,16 @@ const HISTORY_KEY = 'doormileBotHistory';
const HISTORY_LIMIT = 50;
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;
const makeId = () => nextId++;
const now = () => dayjs().format('hh:mm A');
@@ -121,6 +131,74 @@ const AIPanel = ({ isOpen, onClose }) => {
return () => clearTimeout(timer);
}, [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 ----
useEffect(() => {
if (!isOpen) return undefined;
@@ -1031,6 +1109,42 @@ const AIPanel = ({ isOpen, onClose }) => {
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(
<>
<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}
gap={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"
tabIndex={-1}
>
@@ -1058,6 +1177,28 @@ const AIPanel = ({ isOpen, onClose }) => {
</HStack>
<HStack gap={1} padding={0} vAlign="center">
<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
hasChevron={false}
button={{ label: 'Conversation options', icon: <MoreOutlined />, isIconOnly: true, variant: 'ghost', size: 'sm' }}
@@ -1091,7 +1232,6 @@ const AIPanel = ({ isOpen, onClose }) => {
key={m.id}
message={m}
onCopy={copyMessage}
onAsk={ask}
onSubmitForm={submitForm}
onCancelAction={cancelAction}
onChooseStep={chooseStep}
@@ -1109,7 +1249,7 @@ const AIPanel = ({ isOpen, onClose }) => {
)}
</VStack>
) : (
<AIWelcome context={pageContext} onAsk={ask} />
<AIWelcome />
)}
</VStack>
@@ -1121,6 +1261,24 @@ const AIPanel = ({ isOpen, onClose }) => {
)}
</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 ---- */}
<AIComposer value={value} onChange={setValue} onSubmit={ask} isBusy={isSending} placeholder="Ask about orders, riders, hubs…" />
</VStack>

View File

@@ -1,12 +1,12 @@
import PropTypes from 'prop-types';
import dayjs from 'dayjs';
import { LuArrowRight } from 'react-icons/lu';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Text } from '@astryxdesign/core/Text';
import { Spark } from './AIParts';
import { CHIP_LABELS } from './pageContext';
// ==============================|| Doormile AI — welcome state ||============================== //
//
@@ -21,61 +21,72 @@ const greeting = () => {
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;
// 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 (
<button type="button" className="dai-suggestion" onClick={() => onAsk(item.text)}>
<HStack className="dai-suggestion-icon" padding={0} gap={0} aria-hidden="true">
<Icon size={14} strokeWidth={2} />
</HStack>
<Text className="dai-suggestion-text">{item.text}</Text>
<LuArrowRight className="dai-suggestion-arrow" size={14} aria-hidden="true" />
<button type="button" className="dai-suggestion" onClick={() => onAsk(item.text)} aria-label={item.text} title={item.text}>
<Icon className="dai-suggestion-icon" size={13} strokeWidth={2} aria-hidden="true" />
<span className="dai-suggestion-text">{label}</span>
</button>
);
};
SuggestionCard.propTypes = {
SuggestionChip.propTypes = {
item: PropTypes.shape({ icon: PropTypes.elementType.isRequired, text: PropTypes.string.isRequired }).isRequired,
onAsk: PropTypes.func.isRequired
};
const AIWelcome = ({ context, onAsk }) => {
// Every suggestion for this page, always. `context.more` used to sit behind a
// "View N more" / "Show fewer" toggle; hiding half the things the assistant
// can answer behind a click made it look narrower than it is.
const shown = [...context.suggestions, ...(context.more || [])];
// Exported so AIPanel can render it directly above the composer rather than
// inside the scrolling body. The chips are a way to START a message, so they
// belong with the input, not buried in the welcome copy where they scrolled
// out of reach the moment a conversation began.
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 (
<VStack className="dai-welcome" gap={3} padding={0}>
<VStack gap={1.5} padding={0}>
<Spark size="lg" />
<VStack gap={0.5} padding={0}>
<Text className="dai-welcome-greeting">{greeting()}</Text>
<Text className="dai-welcome-lead">How can I help with today&rsquo;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 className="dai-welcome" gap={1.5} padding={0}>
<Spark size="lg" />
<VStack gap={0.5} padding={0}>
<Text className="dai-welcome-greeting">{greeting()}</Text>
<Text className="dai-welcome-lead">How can I help with today&rsquo;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>
);
};
AIWelcome.propTypes = {
context: PropTypes.shape({
suggestions: PropTypes.array.isRequired,
more: PropTypes.array
}).isRequired,
onAsk: PropTypes.func.isRequired
onAsk: PropTypes.func
};
export default AIWelcome;

View File

@@ -11,7 +11,8 @@ import {
LuTimerOff,
LuUserPlus,
LuPackagePlus,
LuListPlus
LuListPlus,
LuRepeat
} from 'react-icons/lu';
// ==============================|| Doormile AI — page context ||============================== //
@@ -26,13 +27,68 @@ import {
// answer that yet" is worse than no chip. When adding one, check it against
// 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 = {
label: 'Orders',
suggestions: [
{ icon: LuClock3, text: "Give me today's operations summary" },
{ icon: LuPackage, text: 'How many orders today?' },
{ 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: [
{ 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 delivered orders today?' },
{ icon: LuLayers, text: 'Morning Batch orders today' },
{ icon: LuClock3, text: 'Orders today vs yesterday' },
{ icon: LuUserPlus, text: 'Create a customer' }
{ icon: LuClock3, text: 'Orders today vs yesterday' }
]
};
@@ -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'
// with the wrong label.
const ROUTES = [
@@ -173,6 +299,10 @@ const ROUTES = [
// 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)];
// 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 seen = new Set();
const out = [];
@@ -186,10 +316,45 @@ const EVERY_QUESTION = (() => {
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 suggestions = [];
[...(context.suggestions || []), ...(context.more || []), ...EVERY_QUESTION].forEach((q) => {
[...(context.suggestions || []), ...(context.more || []), ...GLOBAL_SUGGESTIONS].forEach((q) => {
if (seen.has(q.text)) return;
seen.add(q.text);
suggestions.push(q);
@@ -199,5 +364,30 @@ const withEveryQuestion = (context) => {
export const getPageContext = (pathname = '') => {
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 });