import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import PropTypes from 'prop-types'; import { useLocation } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import dayjs from 'dayjs'; import { AiOutlineMore as MoreOutlined } from 'react-icons/ai'; import { LuChevronDown, LuHistory, LuTrash2, LuMessageSquare, LuRotateCcw, LuMaximize2, LuMinimize2, LuX } from 'react-icons/lu'; import { HStack } from '@astryxdesign/core/HStack'; import { VStack } from '@astryxdesign/core/VStack'; import { Text } from '@astryxdesign/core/Text'; import { DropdownMenu } from '@astryxdesign/core/DropdownMenu'; import { IconButton } from '@/components/ds'; import { OpenToast } from 'components/third-party/OpenToast'; import { STATUS } from 'themes/dt/tokens'; import { answerQuestion, FOLLOW_UP_SUGGESTIONS } from '@/lib/assistant/intents'; import { executeCreateCustomer, buildCustomerPayload } from '@/lib/assistant/actions'; import { executeCreateOrder, buildOrderPayload, validateOrderDraft } from '@/lib/assistant/orderActions'; import { executeCreateBulk } from '@/lib/assistant/bulkOrderActions'; import { startCustomerFlow, answerStep } from '@/lib/assistant/customerFlow'; import { startOrderFlow, answerOrderStep } from '@/lib/assistant/orderFlow'; import { startBulkFlow, answerBulkStep, resolveBulkRows, lookupsNeeded, batchCount, GEOCODE_INTERVAL_MS } from '@/lib/assistant/bulkFlow'; import { startAssignFlow, answerAssignStep } from '@/lib/assistant/assignFlow'; import { executeAssign, executeRepeatAssign, describeRider } from '@/lib/assistant/assignActions'; import { startRepeatFlow, answerRepeatStep } from '@/lib/assistant/repeatFlow'; import { buildRepeatRun, describeDay } from '@/lib/assistant/repeatRuns'; import { getPageContext, getFollowUps, toChips, ORDER_CREATED, ORDER_CREATED_ASSIGNED } from './pageContext'; import { Spark, LiveIndicator, TypingIndicator } from './AIParts'; import AIWelcome, { SuggestionChips } from './AIWelcome'; import AIMessage from './AIMessage'; import AIComposer from './AIComposer'; import '../DoormileAI.css'; // ==============================|| Doormile AI — slide-over panel ||============================== // // // Right-side slide-over rendered into a portal so it layers over the page // without the dashboard reflowing underneath it. Data logic is unchanged — // every answer still comes from intents.js's answerQuestion(), which only // calls the same API functions the pages themselves use. // // The panel stays mounted through its closing transition (`isMounted` vs // `isShown`) so the exit animation actually plays instead of the element // disappearing on the first frame. const ANIMATION_MS = 260; const HISTORY_KEY = 'doormileBotHistory'; const HISTORY_LIMIT = 50; const CONVERSATIONS_KEY = 'doormileBotConversations'; const CONVERSATIONS_LIMIT = 25; const PIN_THRESHOLD_PX = 48; // Dock widths. Each is a viewport fraction with a pixel floor — 20vw is only // 256px on a 1280 laptop, and the floor catches anything narrower than that, // below which the composer and the thread stop 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). // Must stay byte-identical to the --dai-dock-width default in DoormileAI.css: // the CSS value applies before this effect runs on first paint, and any // difference between the two shows as the page jumping sideways on load. const DOCK_NORMAL = 'clamp(320px, 20vw, 460px)'; const DOCK_WIDE = 'clamp(420px, 30vw, 680px)'; const WIDTH_KEY = 'doormileBotWidth'; // Seeded from the clock, not from 1. // // Messages are keyed by this id, and a thread is restored from localStorage on // mount — but the counter restarted at 1 every page load, so the first new // message collided with the first restored one. React then logged "Encountered // two children with the same key" (seen live, dozens of times) and is free to // duplicate or drop either message; the warning says the behaviour is // unsupported. // // Restoring an ARCHIVED conversation made it worse still: that injects a whole // thread of old ids into the middle of a running count. // // A clock seed removes the class of bug rather than patching one path — every // id issued now is larger than every id issued in any earlier session, so new // and restored messages cannot occupy the same key. let nextId = Date.now(); const makeId = () => nextId++; const now = () => dayjs().format('hh:mm A'); // Reissues every id so a thread is internally unique, whatever it arrived // with. Storage written before ids were clock-seeded holds runs like // 1,2,3,4,5,6,7,1,2,3 — the counter restarted each session and wrote straight // over itself — and React keys off these, so those threads log "two children // with the same key" on every single mount until the data itself is repaired. // // Seeding the counter fixed new writes; this fixes what is already on disk. // Ids only need to be unique within a session, so reissuing is safe: nothing // persists a reference to one, and setFormStatus matches within the same // render tree. const withUniqueIds = (messages) => messages.map((m) => ({ ...m, id: makeId() })); const loadHistory = () => { try { const raw = localStorage.getItem(HISTORY_KEY); const parsed = raw ? JSON.parse(raw) : []; if (!Array.isArray(parsed)) return []; // Any write proposal that was still awaiting confirmation when the panel // last closed is retired on load. Otherwise a Create button could sit in // restored history for days and fire against data the operator can no // longer see — the proposal must be regenerated against current state. return withUniqueIds( parsed.map((m) => { const next = m.flowStep ? { ...m, flowStep: undefined } : m; return next.form?.status === 'open' ? { ...next, form: { ...next.form, status: 'stale' } } : next; }) ); } catch { return []; } }; const saveHistory = (messages) => { try { localStorage.setItem(HISTORY_KEY, JSON.stringify(messages.slice(-HISTORY_LIMIT))); } catch { // localStorage unavailable (private mode, quota) — history just won't persist } }; // --------------------------------------------------------------------------- // Past conversations. // // HISTORY_KEY holds the CURRENT thread and is overwritten on every message. // Before this existed, "New conversation" cleared `messages`, that save effect // fired, and the previous conversation was gone — permanently, with no warning // and nothing to restore. Archiving first is what makes starting a new chat a // safe thing to do rather than a destructive one. // --------------------------------------------------------------------------- const loadConversations = () => { try { const raw = localStorage.getItem(CONVERSATIONS_KEY); const parsed = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed : []; } catch { return []; } }; const saveConversations = (list) => { try { localStorage.setItem(CONVERSATIONS_KEY, JSON.stringify(list.slice(0, CONVERSATIONS_LIMIT))); } catch { /* private mode / quota — archives just won't persist */ } }; // A conversation is named after the first thing the operator asked. That is // the only line in a thread that reliably says what it was about — the // assistant's own first reply is usually a number with no subject. const conversationTitle = (messages) => { const firstAsk = messages.find((m) => m.sender === 'user' && m.text); const raw = (firstAsk?.text || 'Untitled conversation').trim(); return raw.length > 58 ? `${raw.slice(0, 57)}…` : raw; }; const AIPanel = ({ isOpen, onClose }) => { const queryClient = useQueryClient(); // The bot writes through its own calls, so the PAGE behind it keeps serving // whatever TanStack cached before the write. Assign a rider from here and the // API is correct immediately, while the Deliveries table still shows the // booking as undispatched — that page selects on `assignedmileruserid`, which // has just changed underneath it. Reported as "it assigned but Deliveries // doesn't show it"; the assignment was real, the table was stale. // // Invalidated broadly and deliberately. Naming each page's keys here would // couple the assistant to every table's cache layout and rot the first time // one changes; TanStack only refetches ACTIVE queries at once, so in practice // this refreshes the page the operator is looking at and marks the rest // stale. CLAUDE.md §7 asks for .refetch() after a mutation, but that only // reaches queries the same component owns — a cross-page write needs the // client. const refreshPageData = useCallback(() => { queryClient.invalidateQueries(); }, [queryClient]); const { pathname } = useLocation(); const pageContext = getPageContext(pathname); const [isMounted, setIsMounted] = useState(isOpen); const [isShown, setIsShown] = useState(false); const [messages, setMessages] = useState(loadHistory); const [conversations, setConversations] = useState(loadConversations); // 'chat' | 'history'. History is a STATE of the panel, not a route: the // header and composer stay exactly where they are, so it is somewhere you // look rather than somewhere you navigate to and have to find a way out of. const [view, setView] = useState('chat'); // Set when a write SUCCEEDS, so the follow-up strip can offer what comes // after an order exists rather than what comes after asking to make one. // Cleared the moment anything else is asked — it describes the last thing // that HAPPENED, not a standing preference. const [outcomeKey, setOutcomeKey] = useState(null); // The orders the LAST write produced. Held so that "Assign order" means // "assign the ones we just made" rather than "name an order for me". // { ids: string[], booking: object|null } // `booking` is only populated for a single create, where the operator picks // the rider by hand; a batch goes to the optimiser-backed endpoint instead. const [lastCreated, setLastCreated] = useState({ ids: [], booking: null }); const [value, setValue] = useState(''); const [isSending, setIsSending] = useState(false); const [context, setContext] = useState({ lastIntentId: null, lastParams: null }); const [isPinned, setIsPinned] = useState(true); // An in-progress conversational create. Held in memory only — never // persisted, so a half-finished flow can't be resurrected in a later session // and submitted against stale intent. const [flow, setFlow] = useState(null); const panelRef = useRef(null); const scrollRef = useRef(null); const lastQuestionRef = useRef(null); // Mirrors isPinned so the throttled scroll handler can compare without // re-subscribing, and cancels an in-flight frame on unmount. const pinnedRef = useRef(true); // Stop is a ref, never state: read inside the async pass, a state value is // captured at call time and never updates, so Stop did nothing. The geocode // cache is a ref so re-running after a fix doesn't re-look-up what's known. const bulkStop = useRef(false); const bulkCache = useRef(new Map()); const rafRef = useRef(0); useEffect(() => () => cancelAnimationFrame(rafRef.current), []); // ---- open / close transition ---- useEffect(() => { if (isOpen) { setIsMounted(true); const raf = requestAnimationFrame(() => setIsShown(true)); return () => cancelAnimationFrame(raf); } setIsShown(false); const timer = setTimeout(() => setIsMounted(false), ANIMATION_MS); 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('[data-app-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; const onKeyDown = (e) => { if (e.key === 'Escape') { e.stopPropagation(); onClose(); } }; document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); }, [isOpen, onClose]); // ---- move focus into the panel on open ---- useEffect(() => { if (!isShown) return; const textarea = panelRef.current?.querySelector('textarea'); (textarea || panelRef.current)?.focus(); }, [isShown]); useEffect(() => { saveHistory(messages); }, [messages]); // ---- auto-scroll, but never yank the view away from someone reading ---- useEffect(() => { if (!isPinned) return; const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [messages, isSending, isPinned]); // A scroll event fires several times per frame, and this used to call // setIsPinned on every one of them — re-rendering the entire thread mid-scroll. // The measurement is now rAF-throttled and the state is written only when the // flag actually flips, so ordinary scrolling costs no renders at all. const handleScroll = useCallback(() => { if (rafRef.current) return; rafRef.current = requestAnimationFrame(() => { rafRef.current = 0; const el = scrollRef.current; if (!el) return; const pinned = el.scrollHeight - el.scrollTop - el.clientHeight < PIN_THRESHOLD_PX; if (pinned === pinnedRef.current) return; pinnedRef.current = pinned; setIsPinned(pinned); }); }, []); const scrollToLatest = useCallback(() => { const el = scrollRef.current; if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); pinnedRef.current = true; setIsPinned(true); }, []); // Every handler passed to AIMessage is stabilised, because a new function // identity on each render defeats its memo and puts all 50 turns back on the // critical path of a scroll. const push = useCallback((message) => setMessages((prev) => [...prev, { id: makeId(), timestamp: now(), ...message }]), []); // Pushes a message and hands back its id, so a long-running pass can rewrite // that one turn rather than pushing a new one per row. const pushLive = useCallback((message) => { const id = makeId(); setMessages((prev) => [...prev, { id, timestamp: now(), ...message }]); return id; }, []); const patch = useCallback((id, fields) => setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, ...fields } : m))), []); // Renders whatever a flow step needs: a dropdown, a typed question, or the // finished quote. Kept in one place so the start of a flow and every // subsequent answer produce identical-looking turns. // Renders one turn of ANY conversational create. The question half is shared; // what happens at the end is the caller's (finishOrder / finishBulk). const renderFlowStep = useCallback( async (next, onDone) => { if (!next.done) { if (next.retry) { push({ sender: 'assistant', text: next.ask }); return; } const step = next.step; push({ sender: 'assistant', text: next.ask || step.ask, // A `select` or `rows` step carries the step definition so AIMessage // can render its control; a `text` step is answered in the composer. flowStep: step.type === 'select' || step.type === 'rows' ? step : undefined, flowDraft: next.flow.draft }); return; } await onDone(next.draft); }, [push] ); // The single order's completion: quote, gate, then offer one press. const finishOrder = useCallback( async (draft) => { // Every field gathered, price included — the flow itself quotes it and asks // for the amount when it can't (see orderFlow.js's `finalprice` step), so // there is never a NaN or an invented zero on the wire. const quote = draft.__quote || {}; // Last gate before a Create button exists. The flow validates field by // field, but this is the same check the Create Order page runs on the whole // record — if it fails, the operator gets told what's missing instead of a // button that would post a rejected booking. const { ok, errors } = validateOrderDraft(draft); if (!ok) { push({ sender: 'assistant', state: { tone: STATUS.error, iconKey: 'warning', title: "This order isn't complete", body: `${Object.values(errors).join(' · ')}. Say “create an order” to start again.` } }); setFlow(null); return; } push({ sender: 'assistant', text: `That comes to ₹${Number(draft.finalprice).toFixed(2)}.`, list: { title: 'Will be sent', numbered: false, items: [ { label: 'Customer', meta: `${draft.customer_name} · ${draft.customer_phone}` }, { label: 'Pickup', meta: draft.__pickup?.locationname || draft.__pickup?.address || '—' }, { label: 'Drop', meta: draft.deliveryaddress }, { label: 'Service', meta: draft.service_option }, { label: 'Parcels', meta: `${draft.quantity} · ${draft.itemcategory} · ${draft.itemdescription}` }, quote.km != null ? { label: 'Distance', meta: `${quote.km} km${quote.durationMin ? ` · ${quote.durationMin} min` : ''}` } : null, { label: 'Price', meta: `₹${Number(draft.finalprice).toFixed(2)}` } ].filter(Boolean) }, // Where the number came from matters: a quoted price is reproducible from // the tenant's pricing row, an entered one is the operator's own. detail: quote.total != null ? 'Priced from this tenant’s pricing row and the routed distance.' : 'Price entered manually — it couldn’t be quoted automatically.', form: { kind: 'confirmOrder', status: 'open', draft } }); }, [push] ); // Every field gathered — now the long pass. Locating is ~1 lookup/second, so // it is narrated into a single message the operator can watch and stop, // rather than freezing silently for minutes. // The assign conversation's end. Two outcomes: the operator chose to keep the // rider the backend already picked (nothing to send), or they picked someone // and get the usual one-press gate. // Everything after "which day" — resolving customers, the drift check, // re-quoting at today's tariff, the already-repeated guard. Narrated into one // message like the bulk file's locate pass, because none of it is a question. const finishRepeat = useCallback( async (draft) => { bulkStop.current = false; const liveId = pushLive({ sender: 'assistant', text: `Checking ${describeDay(draft.day)}’s orders…`, liveStop: true }); const run = await buildRepeatRun(draft.day, { shouldStop: () => bulkStop.current, onProgress: (p) => patch(liveId, { text: `Pricing at today’s tariff… ${Math.min(p.done + 1, p.total)} of ${p.total}` }) }); patch(liveId, { text: `Checked ${run.total} order${run.total === 1 ? '' : 's'} from ${describeDay(draft.day)}.`, liveStop: false }); // Nothing NEW, but everything already exists today. That is not a dead // end — running the same round twice in a day (a lunch wave and a dinner // wave to the same customers) is ordinary work. The guard's job is to make // sure it is deliberate, not to refuse it, so this warns loudly and still // offers the button. if (!run.valid.length && run.already.length) { push({ sender: 'assistant', text: `All ${run.already.length} of ${describeDay(draft.day)}’s orders have already been created today.`, list: { title: 'Already created today', numbered: false, items: run.already.slice(0, 12).map((r) => ({ label: `${r.customer_name} · ${r.customer_phone}`, meta: r.deliveryaddress })) }, detail: [ `Creating them again produces a SECOND set of orders to the same customers — a second real rider to each door. Only do it if that is what you mean.`, run.already.some((r) => r.__previousMilerUserId != null) ? 'They are also dispatched straight back to their previous riders, so those riders get the same drops twice.' : null ] .filter(Boolean) .join(' '), form: { kind: 'confirmBulk', status: 'open', draft: { rows: run.already, invalid: run.drifted, repeatOf: draft.day }, confirmLabel: `Create all ${run.already.length} again` } }); setFlow(null); return; } if (!run.valid.length) { push({ sender: 'assistant', state: { tone: STATUS.muted, iconKey: 'info', title: 'Nothing to repeat', body: run.drifted.length ? `None can be repeated — the first problem is that ${run.drifted[0].error}.` : 'That day has no repeatable orders.' }, downloadRows: run.drifted.length ? run.drifted : undefined }); setFlow(null); return; } push({ sender: 'assistant', text: `${run.valid.length} order${run.valid.length === 1 ? '' : 's'} ready to repeat.`, list: { title: 'Will be created', numbered: false, items: run.valid.slice(0, 12).map((r) => ({ label: `${r.customer_name} · ${r.customer_phone}`, meta: `₹${Number(r.finalprice).toFixed(2)}${ r.previousPrice != null && Number(r.previousPrice) !== Number(r.finalprice) ? ` (was ₹${Number(r.previousPrice).toFixed(2)})` : '' } · ${r.deliveryaddress}` })) }, // Every line here is a row NOT being created, and the reason why. detail: [ run.valid.length > 12 ? `Showing the first 12 of ${run.valid.length}.` : null, run.already.length ? `${run.already.length} already created today — left out so nobody is booked twice.` : null, run.drifted.length ? `${run.drifted.length} can’t be repeated (${run.drifted[0].error}).` : null, run.unpriced.length ? `${run.unpriced.length} couldn’t be priced at today’s tariff.` : null, run.changed.length ? `${run.changed.length} price${run.changed.length === 1 ? '' : 's'} changed since ${describeDay(draft.day)}.` : null, // The confirm button now does two things, so it has to say two // things. Creating and then silently dispatching to real riders is // more than "create" promises. (() => { const withRider = run.valid.filter((r) => r.__previousMilerUserId != null).length; if (!withRider) return null; return withRider === run.valid.length ? `All ${withRider} go straight back to the rider who ran them on ${describeDay(draft.day)} — no separate assign step.` : `${withRider} of ${run.valid.length} go straight back to ${describeDay(draft.day)}’s rider; the rest stay pending.`; })() ] .filter(Boolean) .join(' '), downloadRows: run.drifted.length ? run.drifted : undefined, form: { kind: 'confirmBulk', status: 'open', draft: { rows: run.valid, invalid: run.drifted, repeatOf: draft.day }, // Rows already created today are excluded by default — but repeating // a round twice in a day is legitimate, so there is a way to include // them without starting over. altLabel: run.already.length ? `Include the ${run.already.length} already created today (${run.valid.length + run.already.length} total)` : undefined, altDraft: run.already.length ? { rows: [...run.valid, ...run.already], invalid: run.drifted, repeatOf: draft.day } : undefined } }); }, [push, pushLive, patch] ); const finishAssign = useCallback( async (draft) => { const label = draft.booking?.bookingno || `#${draft.booking?.bookingid}`; if (draft.__keep) { push({ sender: 'assistant', text: `Left ${label} with ${describeRider(draft.__currentRider) || 'its current rider'}.`, detail: 'Nothing was sent.' }); setFlow(null); return; } push({ sender: 'assistant', text: `Assign ${label} to ${describeRider(draft.__rider)}?`, list: { title: 'Will be sent', numbered: false, items: [ { label: 'Order', meta: label }, { label: 'Rider', meta: describeRider(draft.__rider) || '—' }, draft.__currentRider ? { label: 'Replaces', meta: describeRider(draft.__currentRider) } : null, { label: 'Drop', meta: draft.booking?.deliveryaddress || '—' } ].filter(Boolean) }, // Stated because it is the difference between "the rider knows" and // "the rider will find out when they next open the app". detail: draft.__rider?.milerprofileid ? 'The rider gets a push notification when this lands.' : 'This rider has no profile id, so no notification can be sent.', form: { kind: 'confirmAssign', status: 'open', draft } }); }, [push] ); const finishBulk = useCallback( async (draft) => { const rows = draft.rows; const needed = lookupsNeeded(rows, bulkCache.current); bulkStop.current = false; const liveId = pushLive({ sender: 'assistant', text: needed ? `Locating ${needed} address${needed === 1 ? '' : 'es'} — about ${Math.ceil( (needed * GEOCODE_INTERVAL_MS) / 1000 )}s. The address service allows roughly one per second.` : 'Checking the rows…', // Rendered as a plain turn with a Stop button; see AIMessage's liveStop. liveStop: true }); const result = await resolveBulkRows(rows, { pickup: draft.__pickup, tenantid: draft.tenantid || localStorage.getItem('tenantid'), cache: bulkCache.current, shouldStop: () => bulkStop.current, onProgress: (p) => patch(liveId, { text: `${p.phase === 'price' ? 'Pricing' : 'Locating addresses'}… ${Math.min(p.done + 1, p.total)} of ${p.total}`, detail: p.current || undefined }) }); patch(liveId, { text: `Checked ${rows.length} row${rows.length === 1 ? '' : 's'}.`, detail: undefined, liveStop: false }); if (!result.valid.length) { push({ sender: 'assistant', state: { tone: STATUS.error, iconKey: 'warning', title: 'None of these rows can be sent', body: result.invalid[0]?.error ? `The first problem is: ${result.invalid[0].error}. Fix the rows and say “bulk upload orders” again.` : 'Fix the rows and try again.' } }); setFlow(null); return; } const batches = batchCount(result.valid.length); push({ sender: 'assistant', text: `${result.valid.length} order${result.valid.length === 1 ? '' : 's'} ready to create.`, list: { title: 'Will be sent', numbered: false, // Capped for readability, and the cap is STATED — a list that silently // showed 12 of 200 would read as the whole file. items: result.valid.slice(0, 12).map((r) => ({ label: `${r.customer_name} · ${r.customer_phone}`, meta: `₹${Number(r.finalprice).toFixed(2)}${r.quoted ? ' quoted' : ''} · ${r.resolvedAddress || r.deliveryaddress}` })) }, detail: [ result.valid.length > 12 ? `Showing the first 12 of ${result.valid.length}.` : null, result.invalid.length ? `${result.invalid.length} row${result.invalid.length === 1 ? '' : 's'} cannot be sent and will be left out.` : null, batches > 1 ? `Sent in ${batches} batches of at most 200.` : null, `Pickup: ${draft.__pickup?.locationname || draft.__pickup?.address || '—'} · ${draft.service_option}` ] .filter(Boolean) .join(' '), form: { kind: 'confirmBulk', status: 'open', draft: { ...draft, rows: result.valid, invalid: result.invalid } } }); }, [push, pushLive, patch] ); const ask = useCallback( async (text) => { const question = String(text || '').trim(); if (!question || isSending) return; lastQuestionRef.current = question; setValue(''); setIsPinned(true); setOutcomeKey(null); setMessages((prev) => [...prev, { id: makeId(), sender: 'user', text: question, timestamp: now() }]); setIsSending(true); try { // ---- an answer to a question we asked ---------------------------- // // Checked BEFORE the router. This is the whole fix: answerQuestion // picks an intent by matching text, so a bare reply like // "8494948494" matches nothing and used to be swallowed by the // "I can't answer that yet" fallback. Mid-flow, the text is an // answer — not a question — and is never routed. if (flow && !flow.complete) { if (/^(?:cancel|stop|nevermind|never mind|forget it)$/i.test(question)) { setFlow(null); push({ sender: 'assistant', text: 'Cancelled — nothing was created.' }); setIsSending(false); return; } // An order flow answers through this same path when the step is // free text; `select` steps come in through chooseStep instead. if (flow.kind === 'repeatRun') { const next = await answerRepeatStep(flow, question); setFlow(next.flow); await renderFlowStep(next, finishRepeat); setIsSending(false); return; } if (flow.kind === 'assignRider') { const next = await answerAssignStep(flow, question); setFlow(next.flow); await renderFlowStep(next, finishAssign); setIsSending(false); return; } if (flow.kind === 'createBulk') { const next = await answerBulkStep(flow, question); setFlow(next.flow); await renderFlowStep(next, finishBulk); setIsSending(false); return; } if (flow.kind === 'createOrder') { const next = await answerOrderStep(flow, question); setFlow(next.flow); await renderFlowStep(next, finishOrder); setIsSending(false); return; } const next = answerStep(flow, question); setFlow(next.flow); if (next.done) { // Every field gathered. Show exactly what will be sent and wait // for Submit — the same write gate every other create uses. push({ sender: 'assistant', text: 'Ready to create this customer.', list: { title: 'Will be sent', numbered: false, items: Object.entries(next.payload).map(([k, v]) => ({ label: k, meta: String(v) })) }, form: { kind: 'confirmCustomer', status: 'open', payload: next.payload } }); } else { push({ sender: 'assistant', text: next.ask }); } setIsSending(false); return; } const result = await answerQuestion(question, context); if (result) { setContext({ lastIntentId: result.intentId, lastParams: result.params }); // createCustomer no longer opens a form — it starts a conversation. // The form version was replaced on explicit request; asking one // question at a time reads better and matches how an operator would // dictate a record. if (result.form?.kind === 'repeatRun') { const started = await startRepeatFlow(); setFlow(started.flow); await renderFlowStep(started, finishRepeat); setIsSending(false); return; } if (result.form?.kind === 'assignRider') { // "Assign a rider" with no order named in the sentence used to go // straight to "Which order? Give me its number" — which, right // after creating thirteen of them, is the assistant asking the // operator to repeat something it just did itself. // // If the last write is still the most recent thing that happened, // that IS the answer to "which order". Only fall through to asking // when the question named no order AND nothing recent was created. // Every assignment goes through the same two questions: which // order, then which rider. There used to be a bulk shortcut here // for a freshly created run, but it routed through the hub console // — an endpoint this console's logins are not granted — so it read // as the fast path and then refused. One flow that works beats two // where the quicker one usually cannot run. // // A run of exactly one still skips the first question: the order is // not in doubt, so asking which one would be theatre. const target = result.form.booking || (lastCreated.ids.length === 1 ? lastCreated.booking : null); const started = await startAssignFlow(target); setFlow(started.flow); await renderFlowStep(started, finishAssign); setIsSending(false); return; } if (result.form?.kind === 'createBulkOrders') { const started = await startBulkFlow(); setFlow(started.flow); await renderFlowStep(started, finishBulk); setIsSending(false); return; } if (result.form?.kind === 'createOrder') { const started = await startOrderFlow(); setFlow(started.flow); await renderFlowStep(started, finishOrder); setIsSending(false); return; } if (result.form?.kind === 'createCustomer') { const started = startCustomerFlow(question); setFlow(started.flow); push({ sender: 'assistant', text: started.done ? 'Ready to create this customer.' : started.ask }); if (started.done) { push({ sender: 'assistant', list: { title: 'Will be sent', numbered: false, items: Object.entries(started.payload).map(([k, v]) => ({ label: k, meta: String(v) })) }, form: { kind: 'confirmCustomer', status: 'open', payload: started.payload } }); } setIsSending(false); return; } push({ form: result.form, sender: 'assistant', text: result.headline, detail: result.detail, // Documentation answers read as prose, not as a figure. isDoc: result.intentId === 'docsAnswer', metric: result.metric, stats: result.stats, sourceCalls: result.sourceCalls, followUps: FOLLOW_UP_SUGGESTIONS[result.intentId] || null }); } else { // Matched nothing. This is a coverage state, not a failure — say so // plainly rather than inventing an answer. // Genuinely off-topic questions land here. Saying only "I can't // answer that" leaves an operator with nowhere to go, so this points // at the two real places an answer might exist: rephrasing around // the data this console holds, or doormile.com for anything about // Doormile itself, which this app has no material on. push({ sender: 'assistant', state: { tone: STATUS.muted, iconKey: 'info', title: "I can't answer that one", body: 'I only read this console’s live operations — orders, riders, hubs, vehicles, batches, tenants and revenue. Try rephrasing around those, or see doormile.com for anything about Doormile itself.' } }); } } catch (err) { // Developers get the real error; the operator gets a clean state. console.error('[Doormile AI] answerQuestion failed', err); push({ sender: 'assistant', state: { tone: STATUS.error, iconKey: 'warning', title: 'Something went wrong', body: "I couldn't retrieve the latest operational data. Try that question again in a moment." } }); } finally { setIsSending(false); } }, // `lastCreated` belongs here. Without it this callback closes over the // value from the render in which it was created — the empty array — so the // batch shortcut would look at stale state and never fire, no matter how // many orders had just been made. [context, isSending, flow, renderFlowStep, finishOrder, finishBulk, finishAssign, finishRepeat, push, lastCreated] ); const copyMessage = useCallback((m) => { const text = [m.text, m.detail].filter(Boolean).join('\n'); navigator.clipboard .writeText(text) .then(() => OpenToast('Copied to clipboard', 'success', 1500)) .catch(() => OpenToast('Could not copy', 'error', 1500)); }, []); // Wipes the thread without keeping it. The menu labels this destructively // ("Delete this conversation") because that is what it does — it used to be // offered as "New conversation" too, so the safe-sounding option and the // destructive one ran the same code. const clearConversation = useCallback(() => { setMessages([]); setContext({ lastIntentId: null, lastParams: null }); setFlow(null); pinnedRef.current = true; setIsPinned(true); }, []); // Archive-then-clear. Returns the new archive so callers that also want to // load something can work from the updated list rather than stale state. const archiveCurrent = useCallback(() => { let next = conversations; if (messages.length > 0) { next = [{ id: `c${Date.now()}`, title: conversationTitle(messages), at: dayjs().toISOString(), messages }, ...conversations].slice( 0, CONVERSATIONS_LIMIT ); setConversations(next); saveConversations(next); } return next; }, [conversations, messages]); const newConversation = useCallback(() => { archiveCurrent(); clearConversation(); setView('chat'); }, [archiveCurrent, clearConversation]); // Opening a past conversation archives whatever is on screen first, then // lifts the chosen one OUT of the archive and makes it current — so a thread // is never in two places at once, and reopening one does not silently // duplicate it every time. const openConversation = useCallback( (id) => { const archived = archiveCurrent(); const found = archived.find((c) => c.id === id); if (!found) return; const remaining = archived.filter((c) => c.id !== id); setConversations(remaining); saveConversations(remaining); // Archived threads were written by the same broken counter. setMessages(withUniqueIds(found.messages)); setContext({ lastIntentId: null, lastParams: null }); setFlow(null); setView('chat'); pinnedRef.current = true; setIsPinned(true); }, [archiveCurrent] ); const deleteConversation = useCallback( (id) => { const next = conversations.filter((c) => c.id !== id); setConversations(next); saveConversations(next); }, [conversations] ); const setFormStatus = useCallback( (id, patch) => setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, form: { ...m.form, ...patch } } : m))), [] ); // The single place a write leaves the assistant. Reached only by pressing // Create on an open form. const submitForm = useCallback( async (message, values) => { if (message.form?.status !== 'open') return; setFormStatus(message.id, { status: 'running', error: undefined, initial: values }); // ---- assign one order to one rider ----------------------------------- if (message.form.kind === 'confirmAssign') { const { booking, __rider: rider } = message.form.draft; const res = await executeAssign(booking, rider); if (res.ok) { refreshPageData(); setFormStatus(message.id, { status: 'done' }); setFlow(null); push({ sender: 'assistant', text: `${res.bookingLabel} is now with ${describeRider(rider)}.`, // Three outcomes, not two. The old copy assumed a failed push could // only mean "no profile id", so a notify that returned 400 was // reported as a successful notification. Say which one it was. detail: res.notified ? 'The rider has been notified.' : rider?.milerprofileid ? 'The order is assigned, but the notification failed — tell the rider yourself. See Sources.' : 'The assignment went through, but this rider has no profile id so no notification was sent.', sourceCalls: res.sourceCalls }); } else { setFormStatus(message.id, { status: 'failed', error: res.message }); push({ sender: 'assistant', state: { tone: STATUS.error, iconKey: 'warning', title: 'Not assigned', body: res.message }, sourceCalls: res.sourceCalls }); } return; } // ---- assign a whole run ---------------------------------------------- // // batch-assign, never N single assigns: it is the only call that sequences // each rider's stops afterwards (doormile-flow.md §4). // ---- conversational order: submit what the chat gathered ------------- if (message.form.kind === 'confirmOrder') { const draft = message.form.draft; const payload = buildOrderPayload(draft, draft.__pickup); const res = await executeCreateOrder(payload); if (res.ok) { refreshPageData(); setFormStatus(message.id, { status: 'done' }); setFlow(null); // The order now EXISTS, so assigning a rider becomes the next step. // The auto-hand-off below covers the normal path; this covers the // case where it cannot run (no bookingid came back) and gives the // operator the same step as a chip instead of a dead end. setOutcomeKey(ORDER_CREATED); push({ sender: 'assistant', text: `Created order ${res.bookingno || res.id || ''}.`.trim(), detail: 'It appears on the Orders page under Pending until a rider is assigned.', sourceCalls: res.sourceCalls }); // Straight into the assign conversation. It re-reads the booking first, // so if the backend's own auto-assignment (doormile-flow.md §3) has // already landed a rider, the operator is told who and asked before // replacing them rather than silently overwriting a proximity-scored // choice. const booking = { bookingid: res.id, bookingno: res.bookingno, ...(res.created || {}) }; if (booking.bookingid) setLastCreated({ ids: [booking.bookingid], booking }); if (booking.bookingid) { const started = await startAssignFlow(booking); setFlow(started.flow); await renderFlowStep(started, finishAssign); } } else { setFormStatus(message.id, { status: 'failed', error: res.message }); push({ sender: 'assistant', state: { tone: STATUS.error, iconKey: 'warning', title: 'Order not created', body: res.message }, sourceCalls: res.sourceCalls }); } return; } // ---- conversational customer: submit what the chat gathered ---------- if (message.form.kind === 'confirmCustomer') { try { const res = await executeCreateCustomer(message.form.payload); if (res.ok) { refreshPageData(); setFormStatus(message.id, { status: 'done' }); setFlow(null); push({ sender: 'assistant', text: `Created ${[message.form.payload.firstname, message.form.payload.lastname].filter(Boolean).join(' ')}.`, // Naming the store is worth keeping: two customer resources exist, // POST /admin/customers is a confirmed 405, and the Customers page // now reads this one — so the record does show up. detail: `Customer ID ${res.id ?? '—'}. Saved to /admin/tenantcustomers — it appears on the Customers page.`, sourceCalls: res.sourceCalls }); } else { setFormStatus(message.id, { status: 'failed', error: res.message }); push({ sender: 'assistant', state: { tone: STATUS.error, iconKey: 'warning', title: 'Customer not created', body: res.message }, sourceCalls: res.sourceCalls }); } } catch (err) { console.error('[Doormile AI] confirmCustomer failed', err); setFormStatus(message.id, { status: 'failed', error: err.message || 'Nothing was saved.' }); } return; } // ---- bulk orders ---- if (message.form.kind === 'confirmBulk') { // `values` is whichever button was pressed — the default draft, or the // alternative that includes today's existing rows. const { rows, invalid, ...shared } = values?.rows ? values : message.form.draft; const res = await executeCreateBulk(rows, shared); // Unconditional: a bulk import is normally PARTIAL, so there is no // res.ok to gate on, and any row that landed has changed both tables. refreshPageData(); // A bulk import is normally a PARTIAL result, so the outcome is reported // per row rather than as a single success/failure. Without that, the only // safe response to any error is to re-submit everything — which // double-books whatever did land. setFormStatus(message.id, { status: res.failed && !res.created ? 'failed' : 'done', error: res.failed ? `${res.failed} row(s) failed` : undefined }); // A repeat carries yesterday's rider on each row, so the new copies go // straight back to whoever ran them — no second question. Rows with no // previous rider simply stay pending, and a CSV import has none at all, // so this is a no-op there rather than a special case. const repeatAssign = rows.some((r) => r.__previousMilerUserId != null) ? await executeRepeatAssign(res.createdPairs, rows) : null; if (repeatAssign?.assigned) refreshPageData(); // Which of the new orders still need a rider — the ones whose source // had none, plus any the re-assign could not place. const failedIds = new Set((repeatAssign?.failures || []).map((f) => f.bookingid)); const stillUnassigned = repeatAssign ? (res.createdPairs || []) .filter(({ index, bookingid }) => rows?.[index]?.__previousMilerUserId == null || failedIds.has(bookingid)) .map((p) => p.bookingid) : res.createdIds; if (stillUnassigned?.length) setLastCreated({ ids: stillUnassigned, booking: null }); // Set AFTER the assign, because which follow-ups make sense depends on // whether anything is still waiting for a rider. A run where every // order was placed automatically must not then suggest "Assign order" — // that offers finished work as a pending step. if (res.created > 0) { setOutcomeKey(repeatAssign?.assigned && !stillUnassigned.length ? ORDER_CREATED_ASSIGNED : ORDER_CREATED); } push({ sender: 'assistant', text: `Created ${res.created} of ${rows.length} order${rows.length === 1 ? '' : 's'}.`, list: res.failures.length ? { title: 'Not created', numbered: false, items: res.failures.map((f) => ({ label: rows[f.index]?.customer_name || `Row ${f.index + 1}`, meta: f.reason })) } : undefined, detail: [ res.failed === 0 ? `All rows submitted${res.batches > 1 ? ` in ${res.batches} batches` : ''}.` : `${res.created} landed and ${res.failed} did not. Only the failed rows need re-submitting — re-sending everything would duplicate the ones that worked.`, // Stated plainly, including what was NOT done. An operator who // believes all 13 went back to their riders will not check the // three that stayed pending. // Assignment and notification are reported separately, because // they fail separately. This line used to end "notified // automatically" on the strength of `assigned` alone — so a notify // returning 500 (seen live) was still announced as a delivered // push. Same mistake executeAssign already had; it does not get to // survive here just because the wording is friendlier. repeatAssign?.assigned ? `${repeatAssign.assigned} went straight back to yesterday's rider across ${repeatAssign.riders} rider${ repeatAssign.riders === 1 ? '' : 's' }.` : null, repeatAssign?.assigned ? repeatAssign.notified === repeatAssign.riders ? `${repeatAssign.notified === 1 ? 'That rider was' : 'Those riders were'} notified.` : `${repeatAssign.riders - repeatAssign.notified} of ${repeatAssign.riders} could not be notified — tell them directly. The orders ARE assigned.` : null, repeatAssign?.skipped ? `${repeatAssign.skipped} had no rider yesterday and are waiting for one.` : null, repeatAssign?.failures?.length ? `${repeatAssign.failures.length} could not be re-assigned — that rider may no longer be active.` : null, // Sequencing is what the hub batch endpoint would have added. repeatAssign?.assigned ? 'Stop order is unchanged from yesterday, not re-optimised.' : null ] .filter(Boolean) .join(' '), // Rejected rows plus anything the check had already set aside, in the // template's own columns, so they can be fixed and re-uploaded // instead of re-sending a file that would double-book what landed. downloadRows: [...res.failures.map((f) => ({ ...rows[f.index], error: f.reason })), ...(invalid || [])], sourceCalls: [...(res.sourceCalls || []), ...(repeatAssign?.sourceCalls || [])] }); setFlow(null); return; } // ---- order ---- if (message.form.kind === 'createOrder') { // The saved location the operator chose — its address/pincode/coords go // on the booking, because pickuplocationid 500s server-side. const payload = buildOrderPayload(values, values.__pickup); const res = await executeCreateOrder(payload); if (res.ok) { refreshPageData(); setFormStatus(message.id, { status: 'done' }); // Same as the confirmOrder branch: the order exists, so the next // step is a rider. This path has no auto-hand-off at all, which is // exactly why the chip matters here. setOutcomeKey(ORDER_CREATED); if (res.id) setLastCreated({ ids: [res.id], booking: { bookingid: res.id, bookingno: res.bookingno } }); push({ sender: 'assistant', text: `Created order ${res.bookingno || res.id || ''}.`.trim(), list: { title: 'Submitted', numbered: false, items: Object.entries(payload) .filter(([k]) => k !== 'parcels') .map(([k, v]) => ({ label: k, meta: String(v) })) .concat([{ label: 'parcels', meta: `${payload.parcels.length} · ${payload.parcels[0].itemdescription}` }]) }, detail: 'Saved to /admin/expressbooking. It will appear on the Orders page under Pending until a rider is assigned.', sourceCalls: res.sourceCalls }); } else { setFormStatus(message.id, { status: 'failed', error: res.message }); push({ sender: 'assistant', state: { tone: STATUS.error, iconKey: 'warning', title: 'Order not created', body: res.message }, sourceCalls: res.sourceCalls }); } return; } try { const payload = buildCustomerPayload(values); const res = await executeCreateCustomer(payload); if (res.ok) { refreshPageData(); setFormStatus(message.id, { status: 'done' }); push({ sender: 'assistant', text: `Created ${[payload.firstname, payload.lastname].filter(Boolean).join(' ')}.`, // Everything that was actually sent, so the operator can see the // whole record instead of a one-line summary. list: { title: 'Saved', numbered: false, items: Object.entries(payload).map(([k, v]) => ({ label: k, meta: String(v) })) }, // Same store as the conversational path above. detail: `Customer ID ${res.id ?? '—'}. Saved to /admin/tenantcustomers — it appears on the Customers page.`, sourceCalls: res.sourceCalls }); } else { setFormStatus(message.id, { status: 'failed', error: res.message }); push({ sender: 'assistant', state: { tone: STATUS.error, iconKey: 'warning', title: 'Customer not created', body: res.message }, sourceCalls: res.sourceCalls }); } } catch (err) { console.error('[Doormile AI] createCustomer failed', err); setFormStatus(message.id, { status: 'failed', // executeCreateCustomer reports HTTP failures itself with the status and // endpoint; anything reaching here is unexpected. error: err.response?.data?.message || `${err.message || 'Unexpected error'} — nothing was saved.` }); } }, [push, setFormStatus] ); // A dropdown selection. Same code path as a typed answer — the only // difference is that the chosen OPTION comes along too, because steps that // pick a record (a location, a customer) fill several fields from it. const chooseStep = useCallback( async (message, value, option) => { if (!flow || flow.complete) return; // Mark the rendered dropdown as spent so it can't be submitted twice while // the next step is loading. setMessages((prev) => prev.map((m) => (m.id === message.id ? { ...m, flowStep: { ...m.flowStep, __answered: true } } : m))); setIsSending(true); try { // A table, not a ternary chain: with four conversations the chain became // unreadable, and mispairing an answer with the wrong finisher sends a // reply into the wrong conversation. const BY_KIND = { createBulk: [answerBulkStep, finishBulk], assignRider: [answerAssignStep, finishAssign], repeatRun: [answerRepeatStep, finishRepeat], createOrder: [answerOrderStep, finishOrder] }; const [answer, finish] = BY_KIND[flow.kind] || BY_KIND.createOrder; const next = await answer(flow, value, option); setFlow(next.flow); await renderFlowStep(next, finish); } catch (err) { console.error('[Doormile AI] flow step failed', err); push({ sender: 'assistant', text: 'Something went wrong on that step — say “cancel” and start again.' }); } finally { setIsSending(false); } }, [flow, push, renderFlowStep, finishBulk, finishOrder, finishAssign, finishRepeat] ); // Stops the long locate/price pass. The flag is a ref for the reason // documented on it — as state it was captured at call time and never seen. const stopLive = useCallback( (message) => { bulkStop.current = true; patch(message.id, { text: 'Stopping — the rows already checked are kept.', liveStop: false }); }, [patch] ); const cancelAction = useCallback( (message) => { if (message.form?.status !== 'open') return; setFormStatus(message.id, { status: 'cancelled' }); }, [setFormStatus] ); if (!isMounted) return null; 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(outcomeKey || 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.slice(0, 4); // 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( <>