Files
doormilxpress_astryx/src/components/assistant/DoormileAI/AIPanel.jsx
dharaneesh-r 1671a5f4b6 Milers rebrand, /doormile/home, and a set of console fixes
Two bodies of work that arrived in one working tree and are intertwined in
four files, so they commit together rather than pretending to a split the
diffs do not have.

TEAM WORK (pre-existing in the tree, uncommitted)

- "Riders" -> "Milers" across en.json and ~20 pages. Done carefully:
  Deliveries.jsx checks BOTH prefixes ("Miler #" and "Rider #") so rows
  written before the rename still render, and routes and query keys stay
  rider*/riderssummary — renaming those would have broken bookmarks and
  cache keys.
- MileTruth assistant rework: rename from "Doormile AI", HStack composer,
  maximise/minimise/reset controls, open state persisted to localStorage.
- New /doormile/home landing page; / and /doormile now redirect there
  instead of /doormile/dispatch. AdminLayout nav restructured with icons
  and descriptions.

FIXES

Tab strip was unreachable (Deliveries: Delivered and Cancelled)
  The pill variant is one non-wrapping inline-flex row. Given less width
  than it needs, flex COMPRESSED it — 872px squeezed into 780px, truncating
  labels inside the buttons — and with nothing scrollable no gesture could
  recover the last two tabs. Four of six tab pages had each hand-rolled the
  same wrapper; Deliveries and CompetitiveIntel had not. Fixed in the
  component, so no page can forget it. Verified in a browser at the real
  content width: 102px of scroll range, "Cancelled" reachable. 11 tests.

Home.jsx crash: Cannot read properties of null (reading 'flow')
  Introduced by the change below, which made deriveVisualData return null.
  All 12 .flow/.table dereferences are now behind visual?. gates.

Home.jsx presented fabricated figures as live operations
  deriveVisualData keyword-matched the prompt and returned hardcoded values
  for whole topics — revenue as a flat Rs 48,650, a workforce of 46 with 38
  active, six named hubs, four booking numbers — ignoring the real result it
  had been handed. Those branches sat ABOVE the one reading res.stats, so
  the correct code was unreachable: asking about revenue could not return
  the real number because a literal answered first.

  Two were worse than wrong figures. The default branch returned a staffing
  table for ANY unmatched question. And the no-answer path built a
  sourceCalls entry claiming /admin/milers had been called, status
  "complete", "46 milers scanned" — forging the provenance trail that exists
  so an operator can check where a number came from. A fabricated figure is
  a bug; a fabricated citation defeats the mechanism for catching one.

  190 lines removed. Every branch now reads the result and returns null when
  there is nothing to draw. No-answer says so; errors report as errors.

  Found while verifying: 'Inactive'.includes('active') is true, so every hub
  counted as active — carried from the original, whose own sample data
  tripped it (six hubs, one Inactive, shown as 6/6). Now an explicit
  vocabulary match, with unknown treated as down: a hub wrongly shown
  offline gets investigated, one wrongly shown online hides an outage.

Home launcher tiles named pages that do not exist
  Every tile now names its destination. Task Board -> Tripsheets,
  Staff -> Milers, Availability -> Milers Summary (it pointed at the same
  page as the tile beside it), Compliance -> Exceptions,
  Invoices -> Bookings, Payroll -> Profitability,
  AI Reports -> Orders Summary. Group headings: WORKFORCE SUITE -> FLEET,
  FINANCE & CRM -> CLIENTS & REVENUE, AI TOOLS -> REPORTS & AI. "Invoices",
  "Payroll" and "CRM" named systems Doormile does not run.

  Four hardcoded badges removed (3, 38, 7, "New"). None was computed. A red
  badge means "this many things need you", and one that never changes
  teaches operators to ignore the real ones.

Navbar MileTruth control misaligned, hover dead
  One cause: a 36px image (h-9 w-9 max-w-none) inside a 32px button. It
  stood proud of the bell and avatar AND covered its own button, leaving
  hover:bg-surface-sunken nowhere to render. Now 20px.

Assistant dock 30% -> 20%, and responsive
  clamp(320px, 20vw, 460px) rather than max(): the cap is what makes a large
  monitor work, since 20vw is 768px at 4K. Verified across seven screen
  classes — 384px/20% at 1920, capped at 460px beyond 2300px, full-width
  overlay at or below 900px. At 1024px the 320px floor wins (31%), because
  20vw would be 205px and too narrow for the composer.

Suggestion chips took a third of the panel
  .dai-suggestions was flex-direction:column, overriding the component's own
  wrap="wrap", so four chips became four full rows. Row + wrap, trimmed
  padding, ellipsis on the text span where text-overflow can act. Measured
  at the 320px floor: 4 rows/177px -> 2 rows/84px, nothing clipped.

Pricing and Customers were unreachable on mobile
  Moving them off the nav bar into the account dropdown removed them from
  the phone entirely — the mobile sheet builds from NAV plus NAV_GROUPS and
  they were in neither. Both stayed routed, so only a typed URL reached
  them. Now one SETTINGS_NAV array that the dropdown and the sheet share.

calculateDrivingDistance was called and never imported
  CreateOrder.jsx:371 — a ReferenceError on every customer-pickup order with
  a pinned collection address, thrown synchronously inside a useEffect so
  the .catch() on that chain could never see it. Neither the build nor the
  lint config catches an unbound identifier in a .jsx file: eslint.config.js
  spreads pluginJs.configs.recommended and then declares its own rules
  object, which replaces the spread rules wholesale, so no-undef has never
  run. A repo-wide sweep with it enabled found this was the only instance.
  Pre-existing; found while auditing the coordinate changes.

Dead code and stale docs
  ALL_DESTINATIONS (declared, never read; its comment claimed it was what
  the mobile sheet renders). --dai-panel-width (declared twice, read
  nowhere) and the max-width:1279px block that only set it. AdminLayout's
  header comment said "three grouped menus (Fleet Ops, Reports, Settings)"
  when there are two, and Settings is a section in the account menu — that
  difference in routing is what caused the mobile gap above.

VERIFIED

878 tests across 26 suites, build clean, no new lint problems. The tab
strip, dock width, chip wrapping and navbar sizing were measured in a real
browser against the shipped stylesheet. deriveVisualData is module-private,
so it was extracted and driven through 16 logic checks.

NOT VERIFIED

/doormile/home has never been rendered in a browser, and the responsive
pass covers the assistant panel only — no page has been viewed at any
breakpoint. Both need a signed-in session. DataTable carries its own
overflow-x-auto and only two fixed widths above 390px exist in src/pages,
but that is grep, not eyes.

STILL OPEN

customerAppBookings.js lost its customerstatus/customerstage grouping in
49ee0c5 and has not been restored, so the Bookings tabs still read only the
operational status. The Status column (Bookings.jsx:98, :415) was never
stage-aware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:21:21 +05:30

1570 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <body>, so it cannot be a flex sibling of the
// content area and cannot push it directly. This flag is the handshake: CSS
// (DoormileAI.css) reserves exactly --dai-dock-width of padding on the app's
// content container while it is set, and animates on the same curve, so the
// page slides open rather than being covered.
//
// Keyed on isShown, not isOpen, so the shift starts on the same frame the
// panel starts sliding in. Cleared on unmount as well as on close — leaving
// it set would strand the whole app behind a permanent empty gutter.
useEffect(() => {
document.body.classList.toggle('dai-docked', isShown);
return () => document.body.classList.remove('dai-docked');
}, [isShown]);
// ---- Escape closes ----
useEffect(() => {
if (!isOpen) return undefined;
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(
<>
<HStack className="dai-root dai-scrim" data-open={isShown} padding={0} gap={0} onClick={onClose} aria-hidden="true" />
<VStack
as="aside"
ref={panelRef}
className="dai-root dai-panel"
data-open={isShown}
gap={0}
padding={0}
/* `complementary`, not `dialog`. A dialog promises modality — focus is
trapped, the rest of the page is inert, and screen readers announce
it as blocking. None of that is true any more: the page beside it
stays live and operable, so announcing it as a dialog would be a
lie to exactly the users who cannot see that it is docked. */
role="complementary"
aria-label="MileTruth — Operations Copilot"
tabIndex={-1}
>
{/* ---- header ---- */}
<VStack as="header" className="dai-header" gap={0} padding={0}>
<HStack gap={1.5} padding={0} vAlign="center" justify="between">
<HStack gap={1.5} padding={0} vAlign="center">
<Spark size="md" />
<VStack gap={0} padding={0}>
<Text className="dai-title">MileTruth</Text>
<Text className="dai-subtitle">Operations Copilot</Text>
</VStack>
</HStack>
<HStack gap={1} padding={0} vAlign="center">
<IconButton
size="sm"
variant="ghost"
label={view === 'history' ? 'Back to conversation' : 'Conversation history'}
tooltip={view === 'history' ? 'Back to conversation' : 'History'}
aria-pressed={view === 'history'}
icon={view === 'history' ? <LuMessageSquare /> : <LuHistory />}
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
/>
<IconButton
size="sm"
variant="ghost"
label="New conversation"
tooltip="New conversation"
icon={<LuRotateCcw />}
onClick={newConversation}
/>
<IconButton
size="sm"
variant="ghost"
label={isWide ? 'Standard width (20%)' : 'Expand width (30%)'}
tooltip={isWide ? 'Standard width (20%)' : 'Expand width (30%)'}
aria-pressed={isWide}
icon={isWide ? <LuMinimize2 /> : <LuMaximize2 />}
onClick={() => setIsWide((v) => !v)}
/>
<IconButton
size="sm"
variant="ghost"
label="Close assistant"
tooltip="Close assistant"
icon={<LuX />}
onClick={onClose}
/>
</HStack>
</HStack>
</VStack>
{/* ---- conversation / welcome ---- */}
<VStack className="dai-scroll-wrap" gap={0} padding={0}>
<VStack ref={scrollRef} className="dai-scroll" gap={0} padding={0} onScroll={handleScroll}>
{view === 'history' ? (
<VStack className="dai-history" gap={2} padding={0}>
{conversations.length === 0 ? (
<VStack gap={1} padding={0}>
<Text className="dai-history-empty-title">No past conversations yet</Text>
<Text className="dai-history-empty">
Starting a new conversation keeps the current one here, so you can come back to it.
</Text>
</VStack>
) : (
conversations.map((c) => (
<HStack key={c.id} className="dai-history-row" gap={2} padding={0} vAlign="center">
<button type="button" className="dai-history-open" onClick={() => openConversation(c.id)}>
<Text className="dai-history-title">{c.title}</Text>
<Text className="dai-history-meta">
{dayjs(c.at).format('DD MMM · hh:mm A')} · {c.messages.length} message
{c.messages.length === 1 ? '' : 's'}
</Text>
</button>
<IconButton
size="sm"
variant="ghost"
label={`Delete conversation: ${c.title}`}
tooltip="Delete"
icon={<LuTrash2 />}
onClick={() => deleteConversation(c.id)}
/>
</HStack>
))
)}
</VStack>
) : hasThread ? (
<VStack className="dai-thread" gap={4} padding={0}>
{messages.map((m) => (
<AIMessage
key={m.id}
message={m}
onCopy={copyMessage}
onSubmitForm={submitForm}
onCancelAction={cancelAction}
onChooseStep={chooseStep}
onStopLive={stopLive}
/>
))}
{isSending && (
<VStack className="dai-msg" gap={1.5} padding={0}>
<HStack gap={1} padding={0} vAlign="center">
<Spark size="sm" />
<Text className="dai-msg-name">MileTruth</Text>
</HStack>
<TypingIndicator />
</VStack>
)}
</VStack>
) : (
<AIWelcome />
)}
</VStack>
{hasThread && !isPinned && (
<button type="button" className="dai-jump" onClick={scrollToLatest}>
<LuChevronDown size={13} aria-hidden="true" />
Jump to latest
</button>
)}
</VStack>
{/* ---- suggestions, pinned above the composer ----
They sit with the input because that is what they are: a way to
start a message without typing one. Inside the scrolling body they
were unreachable the moment a conversation began.
The full set shows only on an empty thread. Once there is a
conversation the strip drops to this page's four primary
suggestions — twenty chips permanently above the composer would
claim ~260px of a panel whose whole job is the conversation. */}
{view === 'chat' && suggestionItems.length > 0 && (
<VStack className="dai-suggestions-bar" data-compact={isCompactStrip} gap={1} padding={0}>
<Text className="dai-section-label">
{hasThread ? (nextSteps.length > 0 ? 'Next steps' : 'Ask something else') : 'Suggested questions'}
</Text>
<SuggestionChips items={suggestionItems} onAsk={ask} />
</VStack>
)}
{/* ---- composer ---- */}
<AIComposer value={value} onChange={setValue} onSubmit={ask} isBusy={isSending} placeholder="Ask about orders, milers, hubs…" />
</VStack>
</>,
document.body
);
};
AIPanel.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired
};
export default AIPanel;