updates on the changes and fix on the all the pages

This commit is contained in:
2026-09-19 12:09:45 +05:30
parent 8e87947c5f
commit a044afc614
40 changed files with 232 additions and 2559 deletions

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 (
<>
<Tooltip content="MileTruth">
<button
ref={buttonRef}
type="button"
className="dai-root dai-trigger"
data-active={isOpen}
aria-label="Open MileTruth"
aria-expanded={isOpen}
onClick={() => (isOpen ? close() : setIsOpen(true))}
>
<img className="dai-trigger-mark" src={doormileMark} alt="" aria-hidden="true" />
</button>
</Tooltip>
<AIPanel isOpen={isOpen} onClose={close} />
</>
);
};
export default DoormileAITrigger;

View File

@@ -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);
};
// ---------------------------------------------------------------------------

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };
}

View File

@@ -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);
}