From a044afc614e0ab8fc8780ac689b4aaaaeca02620 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Sat, 19 Sep 2026 12:09:45 +0530 Subject: [PATCH] updates on the changes and fix on the all the pages --- src/components/assistant/CLAUDE.md | 3 +- src/components/assistant/DoormileAI.css | 53 +- .../assistant/DoormileAI/AIFlowStep.jsx | 3 +- .../assistant/DoormileAI/AIPanel.jsx | 4 +- .../assistant/DoormileAI/AIRowsStep.jsx | 1 - src/components/assistant/DoormileAI/index.jsx | 44 - .../assistant/DoormileAI/pageContext.jsx | 152 ++- src/components/assistant/assignActions.js | 8 +- src/components/assistant/bulkFlow.js | 8 +- src/components/assistant/bulkOrderActions.js | 12 +- src/components/assistant/customerFlow.js | 2 +- src/components/assistant/flowEngine.js | 6 +- src/components/assistant/intents.js | 8 +- src/components/assistant/repeatRuns.js | 2 +- src/components/doormile/HubLocationSelect.jsx | 2 +- .../doormile/assistant/AssistantPanel.jsx | 405 ------ src/components/ds/ZoneSelector.jsx | 1 - .../nearle_components/AddressAutocomplete.jsx | 4 +- .../nearle_components/DebounceSearchBar.jsx | 2 +- src/layouts/AdminLayout.jsx | 58 +- src/lib/assistant/CLAUDE.md | 3 +- src/lib/assistant/DoormileAI.css | 1204 ----------------- src/lib/assistant/assignActions.js | 8 +- src/lib/assistant/bulkFlow.js | 8 +- src/lib/assistant/bulkOrderActions.js | 12 +- src/lib/assistant/customerFlow.js | 2 +- src/lib/assistant/flowEngine.js | 6 +- src/lib/assistant/flows.js | 613 --------- src/lib/assistant/intents.js | 8 +- src/lib/assistant/pageContext.js | 107 -- src/lib/assistant/repeatRuns.js | 2 +- src/pages/doormile/ViewProfile.jsx | 4 +- src/pages/doormile/bookings/Bookings.jsx | 5 +- src/pages/doormile/clients/CreateClient.jsx | 2 +- src/pages/doormile/clients/CreateCustomer.jsx | 2 +- src/pages/doormile/deliveries/Deliveries.jsx | 4 +- src/pages/doormile/home/Home.jsx | 17 +- src/pages/doormile/orders/Orders.jsx | 2 +- src/pages/doormile/riders/CreateRider.jsx | 2 +- src/pages/doormile/riders/EditRider.jsx | 2 +- 40 files changed, 232 insertions(+), 2559 deletions(-) delete mode 100644 src/components/assistant/DoormileAI/index.jsx delete mode 100644 src/components/doormile/assistant/AssistantPanel.jsx delete mode 100644 src/lib/assistant/DoormileAI.css delete mode 100644 src/lib/assistant/flows.js delete mode 100644 src/lib/assistant/pageContext.js diff --git a/src/components/assistant/CLAUDE.md b/src/components/assistant/CLAUDE.md index 4df5950..a023b1f 100644 --- a/src/components/assistant/CLAUDE.md +++ b/src/components/assistant/CLAUDE.md @@ -36,7 +36,8 @@ An in-console Q&A assistant that answers operator questions about live data — - **Selectors that style an Astryx Stack need two classes.** `padding={0}` emits a StyleX atomic at the same (0,1,0) specificity as a bare class, so `.dai-header` can lose on stylesheet order. Those rules are written `.dai-root .dai-header`. Don't "simplify" them back to one class. This never applies to `.dai-panel`/`.dai-scrim`, which carry `.dai-root` on the *same* element. - **The Doormile D is the assistant's identity, and `Spark` owns it.** Header, every reply, the welcome screen, the thinking state and the top-nav trigger all render `assets/images/doormile-mark.png` through that one component, so they can't drift apart. It replaced a white sparkle glyph, which is why the chip lost its gradient: the mark is red on a transparent ground and carries its own circular frame, so a coloured fill behind it fights the logo. The trigger's active state is a tinted surface for the same reason — an image can't be inverted to white the way an icon could. - **`--dai-accent` is the single accent knob.** It resolves to the app accent (black, root CLAUDE.md §6.2). Switching the assistant to Doormile red is one line in `DoormileAI.css`, not a hunt through components. -- **Every page offers every suggestion.** The assistant answers about orders, riders, hubs and the rest regardless of which screen is open, so hiding a question because you're on Dispatch made it look narrower than it is. `getPageContext` appends the whole deduplicated catalog to each route's own list — the page still decides ORDER (its questions lead), not membership. `more` is retired; one flat list means one place a question can be. +- **Each page offers ONLY its own suggestions.** `getPageContext` returns the route's context list and nothing else — no global set is appended. Asked for directly: on Orders the strip should be Orders questions, and likewise everywhere. This REVERSES the previous rule ("every page offers every suggestion"), which had already been walked back once from all twenty-one questions to a thirteen-question global set because Orders was offering "How many tenants do we have?" above the composer. Note it was barely observable before the change: every context declares at least four suggestions and the panel slices to four, so the globals mostly sat just past the cut — the behaviour was an accident of counting, not a rule. It is structural now. Dropping a question from a page's list does not remove it from the assistant: it stays typeable, stays a follow-up, and still leads on its own page. +- **A route missing from `ROUTES` falls back to `DEFAULT_CONTEXT`**, which is the generic operations set — and that is how a page ends up offering hub questions beside a table of bookings. `/doormile/bookings` did exactly that until it was given its own context. `/doormile/app-users` and `/doormile/competitive-intel` still fall back deliberately: the intent catalog has no questions about app users or competitor branches, and inventing chips that resolve to nothing would break the rule two lines down. Give a page its own context when real intents exist for it, not before. - **Off-topic questions point at doormile.com, they don't get invented answers.** `aboutDoormile` is LAST in `INTENTS` so every operational intent gets first refusal, and its trigger is narrow on purpose — "how many doormile orders today" mentions the name but is an orders question. What it says is only what this console demonstrably does; nothing about the company, its coverage, pricing or history is in this app, and doormile.com is where that lives. The no-match state in `AIPanel.js` points there too. - **Every suggestion in `pageContext.js` must actually resolve** against `INTENTS`. A chip that returns "I can't answer that yet" is worse than no chip — check it before adding. diff --git a/src/components/assistant/DoormileAI.css b/src/components/assistant/DoormileAI.css index 1219710..3b32575 100644 --- a/src/components/assistant/DoormileAI.css +++ b/src/components/assistant/DoormileAI.css @@ -925,50 +925,14 @@ body.dai-docked .astryx-layout-content { padding-inline: 2px; } /* -------------------------------------------------------------------------- - Trigger (lives in the app TopNav) + The trigger's styles lived here and are gone with it. + + `.dai-trigger` / `.dai-trigger-mark` styled DoormileAI/index.jsx, a trigger + button component that nothing rendered — AdminLayout has always declared + its own button in JSX with Tailwind classes. Component and CSS removed + together so neither is left looking like the thing that controls the + header button. To change that button, edit AdminLayout. -------------------------------------------------------------------------- */ -.dai-trigger { - display: inline-flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - padding: 0; - border-radius: 5px; - border: 1px solid transparent; - background: transparent; - cursor: pointer; - overflow: hidden; - transition: - background-color 140ms ease, - border-color 140ms ease, - transform 140ms ease; -} -/* 30px box × 1.5 cancels the mark's transparent padding, so the visible D is - 30px inside the 40px button. */ -.dai-trigger-mark { - width: 30px; - height: 30px; - max-width: none; - object-fit: contain; - display: block; - transform: scale(1.5); -} -.dai-trigger:hover { - background: rgba(0, 0, 0, 0.05); - border-color: rgba(0, 0, 0, 0.1); -} -.dai-trigger:active { - transform: scale(0.94); -} -.dai-trigger:focus-visible { - outline: 2px solid var(--dai-accent); - outline-offset: 2px; -} -.dai-trigger[data-active='true'] { - background: rgba(0, 0, 0, 0.07); - border-color: rgba(0, 0, 0, 0.14); -} /* -------------------------------------------------------------------------- Responsive -------------------------------------------------------------------------- */ @@ -1025,8 +989,7 @@ body.dai-docked .astryx-layout-content { .dai-typing span, .dai-shimmer, .dai-suggestion, - .dai-send, - .dai-trigger { + .dai-send { animation: none !important; transition: none !important; } diff --git a/src/components/assistant/DoormileAI/AIFlowStep.jsx b/src/components/assistant/DoormileAI/AIFlowStep.jsx index 6ccdc70..7b2199d 100644 --- a/src/components/assistant/DoormileAI/AIFlowStep.jsx +++ b/src/components/assistant/DoormileAI/AIFlowStep.jsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { VStack } from '@astryxdesign/core/VStack'; -import { HStack } from '@astryxdesign/core/HStack'; import { Text } from '@astryxdesign/core/Text'; import { Button } from '@/components/ui/button'; import { Selector } from '@astryxdesign/core/Selector'; @@ -38,7 +37,7 @@ const AIFlowStep = ({ step, onChoose, onCancel, draft, isBusy }) => { return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps + }, [step.id]); if (failed) { diff --git a/src/components/assistant/DoormileAI/AIPanel.jsx b/src/components/assistant/DoormileAI/AIPanel.jsx index ed27346..ee3c372 100644 --- a/src/components/assistant/DoormileAI/AIPanel.jsx +++ b/src/components/assistant/DoormileAI/AIPanel.jsx @@ -4,7 +4,6 @@ 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, @@ -19,7 +18,6 @@ import { 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'; @@ -36,7 +34,7 @@ import { executeAssign, executeRepeatAssign, describeRider } from '@/lib/assista 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 { Spark, TypingIndicator } from './AIParts'; import AIWelcome, { SuggestionChips } from './AIWelcome'; import AIMessage from './AIMessage'; import AIComposer from './AIComposer'; diff --git a/src/components/assistant/DoormileAI/AIRowsStep.jsx b/src/components/assistant/DoormileAI/AIRowsStep.jsx index c02916d..e8c4ec1 100644 --- a/src/components/assistant/DoormileAI/AIRowsStep.jsx +++ b/src/components/assistant/DoormileAI/AIRowsStep.jsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import PropTypes from 'prop-types'; import { VStack } from '@astryxdesign/core/VStack'; -import { HStack } from '@astryxdesign/core/HStack'; import { Text } from '@astryxdesign/core/Text'; import { Button } from '@/components/ui/button'; import { TextArea } from '@astryxdesign/core/TextArea'; diff --git a/src/components/assistant/DoormileAI/index.jsx b/src/components/assistant/DoormileAI/index.jsx deleted file mode 100644 index 01c4a54..0000000 --- a/src/components/assistant/DoormileAI/index.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useCallback, useRef, useState } from 'react'; - -import { Tooltip } from '@astryxdesign/core/Tooltip'; - -import doormileMark from 'assets/images/doormile-mark.png'; -import AIPanel from './AIPanel'; -import '../DoormileAI.css'; - -// ==============================|| Doormile AI — trigger ||============================== // -// -// Mounted in AppTopNav. Owns only the open/closed state; everything else -// lives in AIPanel. Focus returns here when the panel closes, so keyboard -// users land back where they started. - -const DoormileAITrigger = () => { - const [isOpen, setIsOpen] = useState(false); - const buttonRef = useRef(null); - - const close = useCallback(() => { - setIsOpen(false); - buttonRef.current?.focus(); - }, []); - - return ( - <> - - - - - - ); -}; - -export default DoormileAITrigger; diff --git a/src/components/assistant/DoormileAI/pageContext.jsx b/src/components/assistant/DoormileAI/pageContext.jsx index 7dee9d8..f0c5f8e 100644 --- a/src/components/assistant/DoormileAI/pageContext.jsx +++ b/src/components/assistant/DoormileAI/pageContext.jsx @@ -66,6 +66,9 @@ export const CHIP_LABELS = { 'Total revenue this week': 'Revenue this week', 'How many customers do we have?': 'Customers', 'Assign a rider': 'Assign order', + 'How many tripsheets are dispatched?': 'Tripsheets', + 'How many open exceptions?': 'Open exceptions', + 'How many pricing rules are configured?': 'Pricing rules', "Repeat yesterday's orders": 'Repeat yesterday' }; @@ -167,6 +170,73 @@ const DISPATCH = { ] }; +// Linehaul and middle-mile runs. +const TRIPSHEETS = { + label: 'Tripsheets', + suggestions: [ + { icon: LuLayers, text: 'How many tripsheets are dispatched?' }, + { icon: LuTruck, text: 'How many vehicles are available?' }, + { icon: LuBike, text: 'How many riders are active?' }, + { icon: LuClock3, text: "Give me today's operations summary" } + ], + more: [{ icon: LuBuilding2, text: 'Current hub status' }] +}; + +// The register of things that went wrong. +const EXCEPTIONS = { + label: 'Exceptions', + suggestions: [ + { icon: LuCircleDot, text: 'How many open exceptions?' }, + { icon: LuTimerOff, text: 'Which orders are delayed?' }, + { icon: LuTimerOff, text: 'Which hubs are experiencing delays?' }, + { icon: LuClock3, text: "Give me today's operations summary" } + ], + more: [] +}; + +// Rate cards. Reached from the account menu rather than the bar, but it is a +// real page and deserves its own questions like any other. +const PRICING = { + label: 'Pricing', + suggestions: [ + { icon: LuBanknote, text: 'How many pricing rules are configured?' }, + { icon: LuBanknote, text: 'Total revenue today' }, + { icon: LuUsers, text: 'How many tenants do we have?' }, + { icon: LuClock3, text: "Give me today's operations summary" } + ], + more: [{ icon: LuBanknote, text: 'Total revenue this week' }] +}; + +// Bookings — what customers raised in the app, before it becomes an order. +// +// This page was not in ROUTES at all, so it fell through to DEFAULT_CONTEXT +// and offered "How many riders are active?" and "Current hub status" beside a +// table of B2C bookings. It is a main nav destination, so that was the most +// visible instance of the problem this change fixes. +// +// No create actions: a booking here originates in the customer app, not in +// this console, so "Create an order" would offer to make a different kind of +// thing than the page shows. +// +// One honest limitation: these intents count ALL bookings, not only the +// customer-app ones this page lists. They are the right questions to ask from +// here and every one of them resolves, but the number will not match the row +// count when the page is filtered to a source. +const BOOKINGS = { + label: 'Bookings', + suggestions: [ + { icon: LuPackage, text: 'How many orders today?' }, + { icon: LuCircleDot, text: 'How many pending orders today?' }, + { icon: LuTimerOff, text: 'Which orders are delayed?' }, + { icon: LuClock3, text: "Give me today's operations summary" } + ], + more: [ + { icon: LuPackage, text: 'How many delivered orders today?' }, + { icon: LuPackage, text: 'How many cancelled orders today?' }, + { icon: LuClock3, text: 'Orders today vs yesterday' } + ] +}; + const TENANTS = { label: 'Tenants', suggestions: [ @@ -283,6 +353,7 @@ const FOLLOW_UPS = { const ROUTES = [ ['/doormile/control-x', DISPATCH], ['/doormile/deliveries', { ...ORDERS, label: 'Deliveries' }], + ['/doormile/bookings', BOOKINGS], ['/doormile/orders', ORDERS], ['/doormile/milers', RIDERS], ['/doormile/riders', RIDERS], @@ -303,9 +374,19 @@ const ROUTES = [ more: [{ icon: LuUsers, text: 'How many tenants do we have?' }] } ], - ['/doormile/tripsheets', { ...DEFAULT_CONTEXT, label: 'Tripsheets' }], - ['/doormile/exceptions', { ...DEFAULT_CONTEXT, label: 'Exceptions' }], - ['/doormile/pricing', { ...DEFAULT_CONTEXT, label: 'Pricing' }] + // These three were `{ ...DEFAULT_CONTEXT, label: '…' }` — a page-specific + // NAME over the generic operations questions, so Tripsheets offered "Active + // riders" and "Hub status" and nothing about tripsheets. The label made it + // look intentional. + // + // Each now leads with its own resource. The phrasings come from the intents' + // own documented examples (tripsheetStatus, exceptionStatus, pricingCount) + // and were checked against their `match` predicates before being added here, + // per the rule at the top of this file. None contains an order/booking word, + // so none is shadowed by the order intents that run earlier in the catalog. + ['/doormile/tripsheets', TRIPSHEETS], + ['/doormile/exceptions', EXCEPTIONS], + ['/doormile/pricing', PRICING] ]; // ---- every question, from every page --------------------------------------- @@ -322,8 +403,9 @@ const ALL_CONTEXTS = [ORDERS, RIDERS, VEHICLES, HUBS, DISPATCH, TENANTS, REPORTS // Every question declared anywhere. Two jobs: it is the icon index behind // QUESTION_BY_TEXT (so a follow-up naming a question that is no longer global -// still renders with the right icon), and it is the pool GLOBAL_SUGGESTIONS -// filters. Do not narrow it — narrow GLOBAL_TEXTS instead. +// still renders with the right icon). It no longer feeds a global suggestion +// pool — see the note below. Do not narrow it: a question dropped from here +// loses its icon everywhere, including as a follow-up. const EVERY_QUESTION = (() => { const seen = new Set(); const out = []; @@ -338,44 +420,38 @@ const EVERY_QUESTION = (() => { })(); // --------------------------------------------------------------------------- -// The questions that ride along on EVERY page. +// There is no longer a set of questions that rides along on every page. // -// This used to be EVERY_QUESTION — all twenty-one, everywhere — which meant the -// Orders page offered "How many tenants do we have?" and "Current hub status" -// above the composer, questions an operator working orders is not asking. The -// strip is a shortcut, and a shortcut that lists everything is a menu. +// GLOBAL_TEXTS / GLOBAL_SUGGESTIONS lived here: a thirteen-question daily +// operating picture appended to every context. It had already been narrowed +// once, from all twenty-one, because the Orders page was offering "How many +// tenants do we have?" above the composer. It is gone entirely now — a page +// offers its own questions and nothing else. // -// What is left is the daily operating picture: what came in, what is stuck, -// what got out, what it earned, and the three ways to put work into the system. -// -// Dropping a question from THIS list does not remove it from the assistant — -// it stays typeable, it stays a follow-up, and it still leads on its own page, -// because withGlobalQuestions puts each page's own suggestions first. Hubs -// still opens on hub questions; they just no longer follow you to Orders. +// EVERY_QUESTION above stays. It is still the icon index behind +// QUESTION_BY_TEXT, so a follow-up naming a question that no longer appears +// on any page strip still renders with the right icon. // --------------------------------------------------------------------------- -const GLOBAL_TEXTS = new Set([ - "Give me today's operations summary", - 'How many orders today?', - 'Which orders are delayed?', - 'How many pending orders today?', - 'How many delivered orders today?', - 'How many cancelled orders today?', - 'Morning Batch orders today', - 'Orders today vs yesterday', - 'Total revenue today', - 'How many riders are active?', - 'Create an order', - REPEAT_TEXT, - 'Create multiple orders' -]); -const GLOBAL_SUGGESTIONS = EVERY_QUESTION.filter((q) => GLOBAL_TEXTS.has(q.text)); - -// The page's own suggestions lead; the global set follows, deduplicated. -const withGlobalQuestions = (context) => { +// A page offers its OWN questions, and only those. +// +// The global set no longer rides along. Asked for directly: on Orders the +// strip should be Orders questions, and the same on every other page. +// +// Worth being precise about what changed, because it is easy to read this as +// a no-op. It very nearly was: every context happens to declare at least four +// own suggestions and the panel slices to four, so in practice the globals sat +// just past the cut on most pages. That made the current behaviour an accident +// of counting rather than a rule — declare a context with three suggestions +// and a hub question would surface on it. Now it cannot, whatever the counts. +// +// Dropping a question from a page's list does not remove it from the +// assistant. It stays typeable, it stays a follow-up, and it still leads on +// its own page. The strip is a shortcut, not the index. +const ownQuestionsOnly = (context) => { const seen = new Set(); const suggestions = []; - [...(context.suggestions || []), ...(context.more || []), ...GLOBAL_SUGGESTIONS].forEach((q) => { + [...(context.suggestions || []), ...(context.more || [])].forEach((q) => { if (seen.has(q.text)) return; seen.add(q.text); suggestions.push(q); @@ -385,7 +461,7 @@ const withGlobalQuestions = (context) => { export const getPageContext = (pathname = '') => { const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix)); - return withGlobalQuestions(match ? match[1] : DEFAULT_CONTEXT); + return ownQuestionsOnly(match ? match[1] : DEFAULT_CONTEXT); }; // --------------------------------------------------------------------------- diff --git a/src/components/assistant/assignActions.js b/src/components/assistant/assignActions.js index c93fe8b..7f7c0e3 100644 --- a/src/components/assistant/assignActions.js +++ b/src/components/assistant/assignActions.js @@ -191,10 +191,10 @@ export const executeRepeatAssign = async (createdPairs, rows) => { // Sequential on purpose. These are writes against real dispatch records, and // firing a burst of them concurrently makes a partial failure much harder to // report accurately — which order did not land, and to whom. - // eslint-disable-next-line no-restricted-syntax + for (const t of targets) { try { - // eslint-disable-next-line no-await-in-loop + await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) }); assigned += 1; assignedRiders.add(String(t.mileruserid)); @@ -218,12 +218,12 @@ export const executeRepeatAssign = async (createdPairs, rows) => { // drops back should feel one buzz, not ten. let notified = 0; if (lookup) { - // eslint-disable-next-line no-restricted-syntax + for (const userid of assignedRiders) { const rider = lookup.byUserId.get(userid); if (rider?.milerprofileid) { try { - // eslint-disable-next-line no-await-in-loop + await notifyRider(rider.milerprofileid); notified += 1; } catch { diff --git a/src/components/assistant/bulkFlow.js b/src/components/assistant/bulkFlow.js index aebeb1b..d2ce0b6 100644 --- a/src/components/assistant/bulkFlow.js +++ b/src/components/assistant/bulkFlow.js @@ -124,18 +124,18 @@ export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgres if (hasCoords(row)) { located.push(row); - // eslint-disable-next-line no-continue + continue; } const key = cacheKey(row); if (cache?.has(key)) { located.push({ ...row, ...cache.get(key) }); - // eslint-disable-next-line no-continue + continue; } onProgress?.({ phase: 'locate', done: i, total: rows.length, current: row.deliveryaddress }); - // eslint-disable-next-line no-await-in-loop + const place = await geocodeAddress(`${row.deliveryaddress} ${row.deliverypincode}`).catch(() => null); const found = { deliverylatitude: place?.geometry?.location?.lat?.(), @@ -147,7 +147,7 @@ export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgres // Only wait after a real request. A cache hit or a sheet coordinate costs // nothing, which is what makes a re-run fast. - // eslint-disable-next-line no-await-in-loop + if (i < rows.length - 1) await sleep(GEOCODE_INTERVAL_MS); } diff --git a/src/components/assistant/bulkOrderActions.js b/src/components/assistant/bulkOrderActions.js index 5e36bf7..4aa0963 100644 --- a/src/components/assistant/bulkOrderActions.js +++ b/src/components/assistant/bulkOrderActions.js @@ -128,15 +128,15 @@ export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, should if (String(row.finalprice ?? '') !== '') { out.push(row); - // eslint-disable-next-line no-continue + continue; } if (!match) { out.push({ ...row, priceError: 'no pricing configured for this tenant' }); - // eslint-disable-next-line no-continue + continue; } - // eslint-disable-next-line no-await-in-loop + const km = await calculateDrivingDistance( { latitude: pickup?.latitude, longitude: pickup?.longitude }, { latitude: row.deliverylatitude, longitude: row.deliverylongitude } @@ -144,7 +144,7 @@ export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, should if (km == null) { out.push({ ...row, priceError: 'could not measure the distance' }); - // eslint-disable-next-line no-continue + continue; } const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance); @@ -209,7 +209,7 @@ export const executeCreateBulk = async (rows, shared) => { const batch = batches[b]; const label = batches.length > 1 ? ` (batch ${b + 1}/${batches.length})` : ''; try { - // eslint-disable-next-line no-await-in-loop + const res = await createExpressBookingBulk(batch); // Three shapes, most-nested first. The live endpoint returns // { data: { results: [ { index, success, bookingid, bookingno } ] } } @@ -236,7 +236,7 @@ export const executeCreateBulk = async (rows, shared) => { status: 'error', errorMessage: res.message || 'Rejected' }); - // eslint-disable-next-line no-continue + continue; } diff --git a/src/components/assistant/customerFlow.js b/src/components/assistant/customerFlow.js index aac1c7c..6307c59 100644 --- a/src/components/assistant/customerFlow.js +++ b/src/components/assistant/customerFlow.js @@ -103,7 +103,7 @@ export const advance = (flow) => { const already = s.field === 'name' ? draft.firstname : draft[s.field]; if (already) { step += 1; - // eslint-disable-next-line no-continue + continue; } return { flow: { ...flow, step }, ask: s.ask, done: false }; diff --git a/src/components/assistant/flowEngine.js b/src/components/assistant/flowEngine.js index 5944584..09705b8 100644 --- a/src/components/assistant/flowEngine.js +++ b/src/components/assistant/flowEngine.js @@ -37,18 +37,18 @@ export const advanceFlow = async (steps, flow) => { const s = steps[step]; if (!applicable(s, draft) || draft[s.id] !== undefined) { step += 1; - // eslint-disable-next-line no-continue + continue; } if (s.auto) { - // eslint-disable-next-line no-await-in-loop + const auto = await s.auto(draft); if (auto?.patch) draft = { ...draft, ...auto.patch }; if (auto?.value !== undefined) { draft = s.apply(draft, auto.value); step += 1; - // eslint-disable-next-line no-continue + continue; } return { flow: { ...flow, step, draft }, step: s, ask: auto?.ask || s.ask, done: false }; diff --git a/src/components/assistant/intents.js b/src/components/assistant/intents.js index c1fd5de..67867f5 100644 --- a/src/components/assistant/intents.js +++ b/src/components/assistant/intents.js @@ -386,7 +386,7 @@ const fetchBookingsInRange = async (start, end) => { let lastPageFetched = 1; for (let page = 2; page <= budget && !stoppedEarly; page += 1) { - // eslint-disable-next-line no-await-in-loop + const next = await getBookingsPageCached(page); lastPageFetched = page; if (!next.rows.length) { @@ -2126,7 +2126,7 @@ const matchAndRun = async (text) => { for (const intent of INTENTS) { const params = intent.match(text); if (!params) continue; - // eslint-disable-next-line no-await-in-loop + const result = await intent.run(params); if (result) return { ...result, intentId: intent.id, params }; } @@ -2194,7 +2194,7 @@ const answerMultiPart = async (text) => { if (segments.length < 2) return null; const results = []; for (const segment of segments) { - // eslint-disable-next-line no-await-in-loop + const r = await matchAndRun(segment); if (r) results.push(r); } @@ -2243,7 +2243,7 @@ export async function answerQuestion(text, context = {}) { // WHAT. Embeddings are good at the former and unreliable at the latter. const params = intent?.match(normalized); if (intent && params) { - // eslint-disable-next-line no-await-in-loop + const result = await intent.run(params); if (result) return { ...result, intentId: intent.id, params, routing: routed }; } diff --git a/src/components/assistant/repeatRuns.js b/src/components/assistant/repeatRuns.js index df41b48..bab3601 100644 --- a/src/components/assistant/repeatRuns.js +++ b/src/components/assistant/repeatRuns.js @@ -220,7 +220,7 @@ export const buildRepeatRun = async (day, { onProgress, shouldStop } = {}) => { if (shouldStop?.()) break; const row = candidates[i]; onProgress?.({ phase: 'price', done: i, total: candidates.length, current: row.customer_name }); - // eslint-disable-next-line no-await-in-loop + const [out] = await priceBulkRows([row], row.__pickup, row.tenantid); priced.push(out); } diff --git a/src/components/doormile/HubLocationSelect.jsx b/src/components/doormile/HubLocationSelect.jsx index aae71d5..1e22c85 100644 --- a/src/components/doormile/HubLocationSelect.jsx +++ b/src/components/doormile/HubLocationSelect.jsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from 'react'; -import { Building2, Check, ChevronDown, MapPin, Search, Sparkles } from 'lucide-react'; +import { Building2, Check, ChevronDown, Search } from 'lucide-react'; import { cn } from '@/lib/utils'; import { DropdownMenu, diff --git a/src/components/doormile/assistant/AssistantPanel.jsx b/src/components/doormile/assistant/AssistantPanel.jsx deleted file mode 100644 index 73dbbac..0000000 --- a/src/components/doormile/assistant/AssistantPanel.jsx +++ /dev/null @@ -1,405 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useLocation } from 'react-router-dom'; -import { ChevronDown, Send, Sparkles, Trash2, X } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { Button, IconButton, Spinner, StatusBadge, Surface } from '@/components/ds'; -import { answerQuestion } from '@/lib/assistant/intents'; -import { advanceFlow, detectFlow, executeFlow, startFlow } from '@/lib/assistant/flows'; -import { getPageContext } from '@/lib/assistant/pageContext'; -import { messageOf } from '@/api/doormile/notify'; - -/** - * Doormile AI — the operations copilot. - * - * A right-side slide-over rather than a page: it answers about live data while - * the operator stays on whatever screen raised the question. - * - * Two rules shape this file: - * - * - **Never put a React element in message state.** Messages are round-tripped - * through localStorage, and an element does not survive JSON — `$$typeof` is a - * Symbol and is dropped, so the rehydrated value crashes the next render. - * Everything in a message is plain data. - * - **Assistant turns are not bubbles.** Only the operator's own words get a - * bubble. That asymmetry is what keeps this reading as part of the console - * rather than a chatbot bolted onto it. - */ - -const STORAGE_KEY = 'doormileAssistantThread'; -const MAX_STORED = 40; - -const loadThread = () => { - try { - const raw = localStorage.getItem(STORAGE_KEY); - const parsed = raw ? JSON.parse(raw) : []; - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -}; - -const saveThread = (messages) => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(messages.slice(-MAX_STORED))); - } catch { - /* A refused write (private mode, quota) costs the history, not the session. */ - } -}; - -/** The endpoints an answer actually called — the verifiability contract. */ -function SourceCalls({ calls }) { - const [open, setOpen] = useState(false); - if (!calls?.length) return null; - - return ( -
- - - {open && ( -
- {calls.map((entry, index) => ( -
-
- {entry.target} - -
- {entry.stats &&

{entry.stats}

} - {entry.errorMessage &&

{entry.errorMessage}

} -
- ))} -
- )} -
- ); -} - -function AssistantTurn({ message, onConfirm, confirming }) { - if (message.role === 'user') { - return ( -
-
- {message.text} -
-
- ); - } - - return ( -
- - - -
- {message.headline &&

{message.headline}

} - - {message.stats?.length > 0 && ( -
- {message.stats.map((stat) => ( -
-

{stat.value}

-

{stat.label}

-
- ))} -
- )} - - {message.text && ( -

{message.text}

- )} - - {/* An ordered trail — a parcel's scan history, most recent last. Plain - data like everything else in a message, so it survives the - localStorage round trip. */} - {message.list?.items?.length > 0 && ( -
-

{message.list.title}

-
    - {message.list.items.map((item, index) => ( -
  1. - {item.label} - {item.meta && {item.meta}} -
  2. - ))} -
-
- )} - - {message.review && ( - -

{message.review.title}

-
- {message.review.lines.map(([label, value]) => ( -
-
{label}
-
{value}
-
- ))} -
- - {message.review.preview?.length > 0 && ( -
    - {message.review.preview.map((line, index) => ( -
  • - {line} -
  • - ))} -
- )} - - {message.done ? ( -

Sent.

- ) : ( - - )} -
- )} - - -
-
- ); -} - -/** @param {any} props */ -export function AssistantPanel({ open, onClose }) { - const location = useLocation(); - const { label, suggestions } = useMemo(() => getPageContext(location.pathname), [location.pathname]); - - const [messages, setMessages] = useState(loadThread); - const [input, setInput] = useState(''); - const [busy, setBusy] = useState(false); - const [confirming, setConfirming] = useState(false); - const [flow, setFlow] = useState(null); - /* The previous turn, so a bare "what about yesterday?" can re-run it. */ - const [context, setContext] = useState({}); - - const scrollRef = useRef(null); - const inputRef = useRef(null); - - useEffect(() => saveThread(messages), [messages]); - - useEffect(() => { - if (open) inputRef.current?.focus(); - }, [open]); - - useEffect(() => { - scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); - }, [messages, busy]); - - useEffect(() => { - if (!open) return undefined; - const onKey = (event) => { - if (event.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [open, onClose]); - - const push = useCallback((message) => setMessages((current) => [...current, message]), []); - - const ask = useCallback( - async (rawText) => { - const text = String(rawText || '').trim(); - if (!text || busy) return; - - push({ role: 'user', text }); - setInput(''); - setBusy(true); - - try { - /* A flow reply must never reach the router: the router matches text, and - a bare answer like a phone number matches no intent. */ - if (flow) { - const { flow: next, review, error } = await advanceFlow(flow, text); - if (error) { - push({ role: 'assistant', text: error }); - } else if (review) { - setFlow(null); - push({ role: 'assistant', headline: 'Ready to send', review }); - } else { - setFlow(next); - push({ role: 'assistant', text: next.question }); - } - return; - } - - const kind = detectFlow(text); - if (kind) { - const started = startFlow(kind); - setFlow(started); - push({ role: 'assistant', headline: started.title, text: started.question }); - return; - } - - const result = await answerQuestion(text, context); - if (!result) { - push({ - role: 'assistant', - text: - 'I can’t answer that one yet. I cover orders, deliveries, riders, clients, customers, hubs, vehicles, tripsheets, exceptions and pricing — and I can create a customer or an order.\n\nFor anything about Doormile the company, doormile.com is where that lives.', - }); - return; - } - - setContext({ lastIntentId: result.intentId, lastParams: result.params }); - push({ - role: 'assistant', - headline: result.headline, - text: result.detail, - stats: result.stats, - list: result.list, - sourceCalls: result.sourceCalls, - }); - } catch (err) { - push({ role: 'assistant', text: messageOf(err, 'Something went wrong reaching the API.') }); - } finally { - setBusy(false); - } - }, - [busy, flow, context, push] - ); - - const confirm = useCallback( - async (review, index) => { - setConfirming(true); - try { - const result = await executeFlow(review); - setMessages((current) => - current.map((message, i) => (i === index ? { ...message, done: result.ok } : message)) - ); - push({ role: 'assistant', text: result.message }); - } catch (err) { - push({ role: 'assistant', text: messageOf(err, 'The write failed — nothing was created.') }); - } finally { - setConfirming(false); - } - }, - [push] - ); - - const reset = () => { - setMessages([]); - setFlow(null); - setContext({}); - }; - - if (!open) return null; - - return ( - <> -