diff --git a/src/layout/MainLayout/AppSideNav.js b/src/layout/MainLayout/AppSideNav.js index 898f58a..bc1ad51 100644 --- a/src/layout/MainLayout/AppSideNav.js +++ b/src/layout/MainLayout/AppSideNav.js @@ -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; } diff --git a/src/pages/nearle/assistant/DoormileAI.css b/src/pages/nearle/assistant/DoormileAI.css index 722e2f4..1df56f5 100644 --- a/src/pages/nearle/assistant/DoormileAI.css +++ b/src/pages/nearle/assistant/DoormileAI.css @@ -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 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) diff --git a/src/pages/nearle/assistant/DoormileAI/AIComposer.js b/src/pages/nearle/assistant/DoormileAI/AIComposer.js index 0d82528..31f91bf 100644 --- a/src/pages/nearle/assistant/DoormileAI/AIComposer.js +++ b/src/pages/nearle/assistant/DoormileAI/AIComposer.js @@ -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" /> - - + {/* 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. */} + + - Enter to send · Shift + Enter for a new line + + {dictation.isListening ? 'Listening — sends automatically after a short pause' : 'Enter to send · Shift + Enter for a new line'} + ); }; diff --git a/src/pages/nearle/assistant/DoormileAI/AIMessage.js b/src/pages/nearle/assistant/DoormileAI/AIMessage.js index 798b4b2..40238f0 100644 --- a/src/pages/nearle/assistant/DoormileAI/AIMessage.js +++ b/src/pages/nearle/assistant/DoormileAI/AIMessage.js @@ -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 )} - {message.followUps?.length > 0 && ( - - {message.followUps.map((q) => ( - - ))} - - )} + {/* 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. */} ); }; @@ -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' ? ( ) : ( 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 , 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( <> + {/* 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. */} + : } + onClick={() => setIsWide((v) => !v)} + /> , 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 }) => { )} ) : ( - + )} @@ -1121,6 +1261,24 @@ const AIPanel = ({ isOpen, onClose }) => { )} + {/* ---- 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 && ( + + + {hasThread ? (nextSteps.length > 0 ? 'Next steps' : 'Ask something else') : 'Suggested questions'} + + + + )} + {/* ---- composer ---- */} diff --git a/src/pages/nearle/assistant/DoormileAI/AIWelcome.js b/src/pages/nearle/assistant/DoormileAI/AIWelcome.js index 7b65001..edb21ce 100644 --- a/src/pages/nearle/assistant/DoormileAI/AIWelcome.js +++ b/src/pages/nearle/assistant/DoormileAI/AIWelcome.js @@ -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 ( - ); }; -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 }) => ( + + {items.map((item) => ( + + ))} + +); +SuggestionChips.propTypes = { + items: PropTypes.array.isRequired, + onAsk: PropTypes.func.isRequired +}; + +const AIWelcome = () => { return ( - - - - - {greeting()} - How can I help with today’s operations? - - - I read live orders, riders, vehicles and hubs. Every number comes from a real call — I never estimate one. - - - - - Suggested questions - - {shown.map((item) => ( - - ))} - + + + + {greeting()} + How can I help with today’s operations? + + I read live orders, riders, vehicles and hubs. Every number comes from a real call — I never estimate one. + ); }; AIWelcome.propTypes = { - context: PropTypes.shape({ - suggestions: PropTypes.array.isRequired, - more: PropTypes.array - }).isRequired, - onAsk: PropTypes.func.isRequired + onAsk: PropTypes.func }; export default AIWelcome; diff --git a/src/pages/nearle/assistant/DoormileAI/pageContext.js b/src/pages/nearle/assistant/DoormileAI/pageContext.js index d546d50..61cbce8 100644 --- a/src/pages/nearle/assistant/DoormileAI/pageContext.js +++ b/src/pages/nearle/assistant/DoormileAI/pageContext.js @@ -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 });