diff --git a/src/layout/MainLayout/AppTopNav.js b/src/layout/MainLayout/AppTopNav.js
index 6ed6a4c..327c9ec 100644
--- a/src/layout/MainLayout/AppTopNav.js
+++ b/src/layout/MainLayout/AppTopNav.js
@@ -34,6 +34,7 @@ import { clearFcmToken } from 'store/reducers/fcmSlice';
import { logoutUser } from 'store/reducers/loginUserSlice';
import { performSessionLogout } from 'utils/session';
import { DT } from 'themes/dt/tokens';
+import DoormileAITrigger from 'pages/nearle/assistant/DoormileAI';
// doormile-logo.png is a white asset; recolour to brand red for this
// light TopNav surface (same trick login.js uses for its white-card logo).
@@ -130,6 +131,8 @@ const AppTopNav = () => {
]}
/>
+
+
} label="Notifications">
} />
diff --git a/src/menu-items/nearle.js b/src/menu-items/nearle.js
index 3523a75..5d58751 100644
--- a/src/menu-items/nearle.js
+++ b/src/menu-items/nearle.js
@@ -32,8 +32,7 @@ import {
TruckOutlined,
WarningOutlined,
RadarChartOutlined,
- RiseOutlined,
- RobotOutlined
+ RiseOutlined
} from '@ant-design/icons';
// icons
@@ -67,8 +66,7 @@ const icons = {
TruckOutlined,
WarningOutlined,
RadarChartOutlined,
- RiseOutlined,
- RobotOutlined
+ RiseOutlined
};
// ==============================|| MENU ITEMS - SUPPORT ||============================== //
@@ -128,13 +126,6 @@ const nearle = {
url: '/doormile/customers',
icon: icons.ContactsOutlined
},
- {
- id: 'assistant',
- title: ,
- type: 'item',
- url: '/doormile/assistant',
- icon: icons.RobotOutlined
- },
{
id: 'fleetops',
title: ,
diff --git a/src/pages/nearle/assistant/DoormileAI.css b/src/pages/nearle/assistant/DoormileAI.css
new file mode 100644
index 0000000..ac758f9
--- /dev/null
+++ b/src/pages/nearle/assistant/DoormileAI.css
@@ -0,0 +1,776 @@
+/* ==========================================================================
+ Doormile AI — Operations Copilot
+ --------------------------------------------------------------------------
+ Styling for the right-side slide-over assistant. Follows the same
+ convention as OrdersRedesign.css / Dispatch.css (a page-scoped stylesheet
+ imported by its own component) rather than inline styles, so the panel's
+ hover / focus / media-query / reduced-motion states are all expressible.
+
+ Every value below resolves to a design token (`--color-*`, `--spacing-*`,
+ `--radius-*`) or to a local `--dai-*` token defined once here. The local
+ tokens exist because the AI surface needs a few values the shared DT set
+ doesn't carry (the AI accent, the scrim, the panel elevation).
+ ========================================================================== */
+
+.dai-root {
+ /* Brand accent — resolves to the app's accent token, which is black
+ (root CLAUDE.md §6.2: "the brand is black", one accent only). Send
+ button, active states and the trigger all read from this single
+ variable, so switching the assistant to Doormile red is a one-line
+ change here rather than a hunt through the components. */
+ --dai-accent: var(--color-accent, #0f172a);
+ --dai-accent-contrast: #ffffff;
+
+ /* AI identity accent — used ONLY on the spark/orb marks so the assistant
+ reads as an AI surface without turning the panel into a purple product. */
+ --dai-ai-from: #6366f1;
+ --dai-ai-to: #8b5cf6;
+ --dai-ai-glow: rgba(99, 102, 241, 0.18);
+
+ --dai-text: #0f172a;
+ --dai-text-secondary: #64748b;
+ --dai-text-muted: #94a3b8;
+ --dai-surface: #ffffff;
+ --dai-surface-alt: #f8fafc;
+ --dai-surface-hover: #f1f5f9;
+ --dai-border: rgba(15, 23, 42, 0.08);
+ --dai-border-strong: rgba(15, 23, 42, 0.14);
+ --dai-scrim: rgba(15, 23, 42, 0.08);
+ --dai-shadow: 0 8px 30px rgba(15, 23, 42, 0.1);
+ --dai-live: #10b981;
+
+ --dai-panel-width: 428px;
+ --dai-inset: 12px;
+ --dai-duration: 240ms;
+ --dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+/* --------------------------------------------------------------------------
+ Scrim — deliberately light. The dashboard underneath must stay readable;
+ this is a layering cue, not a modal blackout.
+ -------------------------------------------------------------------------- */
+.dai-scrim {
+ position: fixed;
+ inset: 0;
+ z-index: 1300;
+ background: var(--dai-scrim);
+ opacity: 0;
+ transition: opacity var(--dai-duration) var(--dai-ease);
+}
+
+.dai-scrim[data-open='true'] {
+ opacity: 1;
+}
+
+/* --------------------------------------------------------------------------
+ Panel
+ -------------------------------------------------------------------------- */
+.dai-panel {
+ position: fixed;
+ top: var(--dai-inset);
+ right: var(--dai-inset);
+ bottom: var(--dai-inset);
+ z-index: 1301;
+ width: var(--dai-panel-width);
+ max-width: calc(100vw - (var(--dai-inset) * 2));
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+ background: var(--dai-surface);
+ border: 1px solid var(--dai-border);
+ border-radius: 16px;
+ box-shadow: var(--dai-shadow);
+ transform: translateX(calc(100% + var(--dai-inset) * 2));
+ opacity: 0;
+ transition:
+ transform var(--dai-duration) var(--dai-ease),
+ opacity var(--dai-duration) var(--dai-ease);
+}
+
+.dai-panel[data-open='true'] {
+ transform: translateX(0);
+ opacity: 1;
+}
+
+.dai-panel:focus {
+ outline: none;
+}
+
+/* --------------------------------------------------------------------------
+ Header
+ -------------------------------------------------------------------------- */
+.dai-root .dai-header {
+ flex: 0 0 auto;
+ padding: 14px 12px 12px 14px;
+ border-bottom: 1px solid var(--dai-border);
+}
+
+.dai-root .dai-title {
+ font-size: 15px;
+ font-weight: 650;
+ line-height: 1.2;
+ letter-spacing: -0.01em;
+ color: var(--dai-text);
+}
+
+.dai-root .dai-subtitle {
+ font-size: 12px;
+ line-height: 1.3;
+ color: var(--dai-text-muted);
+}
+
+/* The AI mark. A soft gradient orb — not a robot face. */
+.dai-root .dai-spark {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ border-radius: 999px;
+ color: #ffffff;
+ background: linear-gradient(135deg, var(--dai-ai-from), var(--dai-ai-to));
+ box-shadow: 0 0 0 3px var(--dai-ai-glow);
+}
+
+.dai-root .dai-spark[data-size='sm'] {
+ width: 22px;
+ height: 22px;
+}
+
+.dai-root .dai-spark[data-size='md'] {
+ width: 30px;
+ height: 30px;
+}
+
+.dai-root .dai-spark[data-size='lg'] {
+ width: 44px;
+ height: 44px;
+ box-shadow: 0 0 0 6px var(--dai-ai-glow);
+}
+
+/* Live indicator — subtle, not a large pill. */
+.dai-root .dai-live {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 8px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 550;
+ color: #047857;
+ background: rgba(16, 185, 129, 0.08);
+ white-space: nowrap;
+}
+
+.dai-root .dai-live-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ background: var(--dai-live);
+ animation: dai-pulse 2.4s ease-in-out infinite;
+}
+
+@keyframes dai-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 50% {
+ opacity: 0.55;
+ transform: scale(0.85);
+ }
+}
+
+/* Page-context strip — "Orders · Today · All locations" */
+.dai-root .dai-context {
+ flex: 0 0 auto;
+ padding: 7px 14px;
+ font-size: 11.5px;
+ color: var(--dai-text-muted);
+ background: var(--dai-surface-alt);
+ border-bottom: 1px solid var(--dai-border);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* --------------------------------------------------------------------------
+ Scroll region
+ -------------------------------------------------------------------------- */
+.dai-scroll {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ scrollbar-width: thin;
+ scrollbar-color: var(--dai-border-strong) transparent;
+}
+
+.dai-scroll::-webkit-scrollbar {
+ width: 8px;
+}
+
+.dai-scroll::-webkit-scrollbar-thumb {
+ background: var(--dai-border-strong);
+ border-radius: 999px;
+ border: 2px solid transparent;
+ background-clip: content-box;
+}
+
+.dai-scroll::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+/* --------------------------------------------------------------------------
+ Welcome state
+ -------------------------------------------------------------------------- */
+.dai-root .dai-welcome {
+ padding: 22px 14px 8px;
+}
+
+.dai-root .dai-welcome-greeting {
+ font-size: 17px;
+ font-weight: 650;
+ letter-spacing: -0.01em;
+ color: var(--dai-text);
+}
+
+.dai-root .dai-welcome-lead {
+ font-size: 13.5px;
+ line-height: 1.5;
+ color: var(--dai-text-secondary);
+}
+
+.dai-root .dai-welcome-note {
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--dai-text-muted);
+}
+
+.dai-root .dai-section-label {
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--dai-text-muted);
+}
+
+/* --------------------------------------------------------------------------
+ Suggestion cards
+ -------------------------------------------------------------------------- */
+.dai-suggestion {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 10px 11px;
+ text-align: left;
+ font: inherit;
+ color: var(--dai-text);
+ background: var(--dai-surface);
+ border: 1px solid var(--dai-border);
+ border-radius: 12px;
+ cursor: pointer;
+ transition:
+ background-color 140ms ease,
+ border-color 140ms ease,
+ transform 140ms ease;
+}
+
+.dai-suggestion:hover {
+ background: var(--dai-surface-alt);
+ border-color: var(--dai-border-strong);
+}
+
+.dai-suggestion:active {
+ transform: scale(0.99);
+}
+
+.dai-suggestion:focus-visible {
+ outline: 2px solid var(--dai-accent);
+ outline-offset: 2px;
+}
+
+.dai-root .dai-suggestion-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ width: 26px;
+ height: 26px;
+ border-radius: 8px;
+ background: var(--dai-surface-hover);
+ color: var(--dai-text-secondary);
+}
+
+.dai-root .dai-suggestion-text {
+ flex: 1 1 auto;
+ font-size: 13px;
+ line-height: 1.35;
+}
+
+.dai-root .dai-suggestion-arrow {
+ flex: 0 0 auto;
+ color: var(--dai-text-muted);
+ opacity: 0;
+ transform: translateX(-3px);
+ transition:
+ opacity 140ms ease,
+ transform 140ms ease;
+}
+
+.dai-suggestion:hover .dai-suggestion-arrow,
+.dai-suggestion:focus-visible .dai-suggestion-arrow {
+ opacity: 1;
+ transform: translateX(0);
+}
+
+/* Plain text link-button ("View more", "Sources") */
+.dai-link {
+ font: inherit;
+ font-size: 12px;
+ color: var(--dai-text-secondary);
+ background: none;
+ border: none;
+ padding: 2px 0;
+ cursor: pointer;
+ align-self: flex-start;
+}
+
+.dai-link:hover {
+ color: var(--dai-text);
+ text-decoration: underline;
+}
+
+.dai-link:focus-visible {
+ outline: 2px solid var(--dai-accent);
+ outline-offset: 2px;
+ border-radius: 4px;
+}
+
+/* --------------------------------------------------------------------------
+ Conversation
+ -------------------------------------------------------------------------- */
+.dai-root .dai-thread {
+ padding: 16px 14px 8px;
+}
+
+.dai-root .dai-msg {
+ animation: dai-enter 220ms var(--dai-ease) both;
+}
+
+@keyframes dai-enter {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+/* User — compact, right aligned, brand surface. */
+.dai-root .dai-msg-user {
+ max-width: 82%;
+ margin-left: auto;
+ padding: 8px 12px;
+ border-radius: 14px;
+ border-bottom-right-radius: 6px;
+ font-size: 13.5px;
+ line-height: 1.45;
+ color: var(--dai-accent-contrast);
+ background: var(--dai-accent);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+/* Assistant — no bubble. Text sits on the panel surface. */
+.dai-root .dai-msg-ai {
+ font-size: 13.5px;
+ line-height: 1.5;
+ color: var(--dai-text);
+ overflow-wrap: anywhere;
+}
+
+.dai-root .dai-msg-ai-detail {
+ font-size: 12.5px;
+ line-height: 1.55;
+ color: var(--dai-text-secondary);
+ white-space: pre-line;
+}
+
+.dai-root .dai-msg-name {
+ font-size: 11.5px;
+ font-weight: 600;
+ color: var(--dai-text-secondary);
+}
+
+.dai-root .dai-msg-footer {
+ font-size: 11px;
+ color: var(--dai-text-muted);
+}
+
+/* Copy button — only revealed on hover of the message row. */
+.dai-root .dai-msg-actions {
+ opacity: 0;
+ transition: opacity 140ms ease;
+}
+
+.dai-root .dai-msg-row:hover .dai-msg-actions,
+.dai-root .dai-msg-row:focus-within .dai-msg-actions {
+ opacity: 1;
+}
+
+/* --------------------------------------------------------------------------
+ Structured metrics
+ -------------------------------------------------------------------------- */
+.dai-root .dai-stat-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 6px;
+ width: 100%;
+}
+
+.dai-root .dai-stat {
+ padding: 9px 10px;
+ border: 1px solid var(--dai-border);
+ border-radius: 10px;
+ background: var(--dai-surface-alt);
+}
+
+.dai-root .dai-stat-value {
+ font-size: 19px;
+ font-weight: 650;
+ line-height: 1.1;
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+}
+
+.dai-root .dai-stat-label {
+ font-size: 11px;
+ color: var(--dai-text-muted);
+}
+
+/* Headline metric — the "primary number is large" rule. */
+.dai-root .dai-metric-value {
+ font-size: 28px;
+ font-weight: 680;
+ line-height: 1.05;
+ letter-spacing: -0.025em;
+ color: var(--dai-text);
+ font-variant-numeric: tabular-nums;
+}
+
+.dai-root .dai-metric-label {
+ font-size: 12px;
+ color: var(--dai-text-secondary);
+}
+
+/* --------------------------------------------------------------------------
+ Typing / loading
+ -------------------------------------------------------------------------- */
+.dai-root .dai-typing {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ height: 18px;
+}
+
+.dai-root .dai-typing span {
+ width: 5px;
+ height: 5px;
+ border-radius: 999px;
+ background: var(--dai-text-muted);
+ animation: dai-bounce 1.3s ease-in-out infinite;
+}
+
+.dai-root .dai-typing span:nth-child(2) {
+ animation-delay: 0.16s;
+}
+
+.dai-root .dai-typing span:nth-child(3) {
+ animation-delay: 0.32s;
+}
+
+@keyframes dai-bounce {
+ 0%,
+ 70%,
+ 100% {
+ opacity: 0.3;
+ transform: translateY(0);
+ }
+ 35% {
+ opacity: 0.9;
+ transform: translateY(-3px);
+ }
+}
+
+.dai-root .dai-shimmer {
+ height: 9px;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--dai-surface-hover) 25%, #e9eef5 37%, var(--dai-surface-hover) 63%);
+ background-size: 400% 100%;
+ animation: dai-shimmer 1.5s ease infinite;
+}
+
+@keyframes dai-shimmer {
+ from {
+ background-position: 100% 50%;
+ }
+ to {
+ background-position: 0 50%;
+ }
+}
+
+/* --------------------------------------------------------------------------
+ States (error / empty)
+ -------------------------------------------------------------------------- */
+.dai-root .dai-state {
+ padding: 12px;
+ border: 1px solid var(--dai-border);
+ border-radius: 12px;
+ background: var(--dai-surface-alt);
+}
+
+.dai-root .dai-state-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--dai-text);
+}
+
+.dai-root .dai-state-body {
+ font-size: 12.5px;
+ line-height: 1.5;
+ color: var(--dai-text-secondary);
+}
+
+.dai-root .dai-state-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 26px;
+ border-radius: 8px;
+ flex: 0 0 auto;
+}
+
+/* --------------------------------------------------------------------------
+ Jump-to-latest
+ -------------------------------------------------------------------------- */
+.dai-jump {
+ position: absolute;
+ left: 50%;
+ bottom: 8px;
+ transform: translateX(-50%);
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 11px;
+ font: inherit;
+ font-size: 12px;
+ color: var(--dai-text);
+ background: var(--dai-surface);
+ border: 1px solid var(--dai-border-strong);
+ border-radius: 999px;
+ box-shadow: 0 4px 14px rgba(15, 23, 42, 0.1);
+ cursor: pointer;
+ z-index: 2;
+}
+
+.dai-jump:hover {
+ background: var(--dai-surface-alt);
+}
+
+.dai-jump:focus-visible {
+ outline: 2px solid var(--dai-accent);
+ outline-offset: 2px;
+}
+
+.dai-scroll-wrap {
+ position: relative;
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+/* --------------------------------------------------------------------------
+ Composer
+ -------------------------------------------------------------------------- */
+.dai-root .dai-composer-wrap {
+ flex: 0 0 auto;
+ padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
+ border-top: 1px solid var(--dai-border);
+ background: var(--dai-surface);
+}
+
+.dai-root .dai-composer {
+ border: 1px solid var(--dai-border-strong);
+ border-radius: 16px;
+ background: var(--dai-surface);
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
+ padding: 8px 8px 6px 12px;
+ transition:
+ border-color 140ms ease,
+ box-shadow 140ms ease;
+}
+
+.dai-root .dai-composer[data-focused='true'] {
+ border-color: var(--dai-accent);
+ box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.06);
+}
+
+.dai-root .dai-composer textarea {
+ display: block;
+ width: 100%;
+ border: none;
+ outline: none;
+ resize: none;
+ padding: 0;
+ margin: 0;
+ font: inherit;
+ font-size: 13.5px;
+ line-height: 1.45;
+ color: var(--dai-text);
+ background: transparent;
+ max-height: 108px; /* ~5 lines */
+ overflow-y: auto;
+}
+
+.dai-root .dai-composer textarea::placeholder {
+ color: var(--dai-text-muted);
+}
+
+.dai-send {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ width: 30px;
+ height: 30px;
+ border: none;
+ border-radius: 999px;
+ color: var(--dai-accent-contrast);
+ background: var(--dai-accent);
+ cursor: pointer;
+ transition:
+ opacity 140ms ease,
+ transform 140ms ease;
+}
+
+.dai-send:hover:not(:disabled) {
+ opacity: 0.88;
+}
+
+.dai-send:active:not(:disabled) {
+ transform: scale(0.93);
+}
+
+.dai-send:disabled {
+ opacity: 0.28;
+ cursor: default;
+}
+
+.dai-send:focus-visible {
+ outline: 2px solid var(--dai-accent);
+ outline-offset: 2px;
+}
+
+.dai-root .dai-hint {
+ font-size: 11px;
+ color: var(--dai-text-muted);
+}
+
+/* --------------------------------------------------------------------------
+ Trigger (lives in the app TopNav)
+ -------------------------------------------------------------------------- */
+.dai-trigger {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ border-radius: 9px;
+ border: 1px solid transparent;
+ background: transparent;
+ color: #4f46e5;
+ cursor: pointer;
+ transition:
+ background-color 140ms ease,
+ border-color 140ms ease,
+ transform 140ms ease;
+}
+
+.dai-trigger:hover {
+ background: rgba(99, 102, 241, 0.09);
+ border-color: rgba(99, 102, 241, 0.16);
+}
+
+.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'] {
+ color: #ffffff;
+ background: linear-gradient(135deg, var(--dai-ai-from), var(--dai-ai-to));
+ border-color: transparent;
+}
+
+/* --------------------------------------------------------------------------
+ Responsive
+ -------------------------------------------------------------------------- */
+@media (max-width: 1279px) {
+ .dai-root {
+ --dai-panel-width: 396px;
+ }
+}
+
+@media (max-width: 767px) {
+ .dai-root {
+ --dai-inset: 0px;
+ }
+
+ .dai-panel {
+ width: 100vw;
+ max-width: 100vw;
+ border-radius: 0;
+ border: none;
+ }
+
+ .dai-msg-user {
+ max-width: 88%;
+ }
+}
+
+/* --------------------------------------------------------------------------
+ Reduced motion — no slide, no pulse, no shimmer. Opacity only.
+ -------------------------------------------------------------------------- */
+@media (prefers-reduced-motion: reduce) {
+ .dai-panel,
+ .dai-scrim {
+ transition: opacity 1ms linear;
+ }
+
+ .dai-panel {
+ transform: none;
+ }
+
+ .dai-msg,
+ .dai-live-dot,
+ .dai-typing span,
+ .dai-shimmer,
+ .dai-suggestion,
+ .dai-send,
+ .dai-trigger {
+ animation: none !important;
+ transition: none !important;
+ }
+}
diff --git a/src/pages/nearle/assistant/DoormileAI/AIComposer.js b/src/pages/nearle/assistant/DoormileAI/AIComposer.js
new file mode 100644
index 0000000..0d82528
--- /dev/null
+++ b/src/pages/nearle/assistant/DoormileAI/AIComposer.js
@@ -0,0 +1,90 @@
+import { useLayoutEffect, useRef, useState } from 'react';
+import PropTypes from 'prop-types';
+import { LuArrowUp } from 'react-icons/lu';
+
+import { HStack } from '@astryxdesign/core/HStack';
+import { VStack } from '@astryxdesign/core/VStack';
+import { Text } from '@astryxdesign/core/Text';
+import { ChatDictationButton, useChatDictation } from '@astryxdesign/core/Chat';
+
+// ==============================|| Doormile AI — composer ||============================== //
+//
+// Auto-growing textarea (capped at ~5 lines by the stylesheet's max-height),
+// Enter to send, Shift+Enter for a newline. Hand-rolled rather than Astryx's
+// because the panel needs the compact anchored treatment and
+// a circular send button, neither of which that component exposes — the
+// dictation button it ships IS reused here rather than reinvented.
+
+const MAX_ROWS_PX = 108;
+
+const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
+ const textareaRef = useRef(null);
+ const [isFocused, setIsFocused] = useState(false);
+
+ const dictation = useChatDictation({ onResult: (transcript) => onChange(transcript) });
+
+ // Grow with content, then let the stylesheet's max-height take over and
+ // scroll. Runs before paint so there's no visible jump on the first line
+ // break.
+ useLayoutEffect(() => {
+ const el = textareaRef.current;
+ if (!el) return;
+ el.style.height = 'auto';
+ el.style.height = `${Math.min(el.scrollHeight, MAX_ROWS_PX)}px`;
+ }, [value]);
+
+ const canSend = value.trim().length > 0 && !isBusy;
+
+ const submit = () => {
+ if (!canSend) return;
+ onSubmit(value);
+ };
+
+ const handleKeyDown = (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ submit();
+ }
+ };
+
+ return (
+
+
+
+ Enter to send · Shift + Enter for a new line
+
+ );
+};
+
+AIComposer.propTypes = {
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+ isBusy: PropTypes.bool,
+ placeholder: PropTypes.string
+};
+
+export default AIComposer;
diff --git a/src/pages/nearle/assistant/DoormileAI/AIMessage.js b/src/pages/nearle/assistant/DoormileAI/AIMessage.js
new file mode 100644
index 0000000..62b78c0
--- /dev/null
+++ b/src/pages/nearle/assistant/DoormileAI/AIMessage.js
@@ -0,0 +1,122 @@
+import { useState } from 'react';
+import PropTypes from 'prop-types';
+import { CopyOutlined } from '@ant-design/icons';
+import { LuChevronDown, LuArrowRight } from 'react-icons/lu';
+
+import { HStack } from '@astryxdesign/core/HStack';
+import { VStack } from '@astryxdesign/core/VStack';
+import { Text } from '@astryxdesign/core/Text';
+import { IconButton } from '@astryxdesign/core/IconButton';
+import { ChatToolCalls } from '@astryxdesign/core/Chat';
+
+import { Spark, Metric, StatGrid, StateBlock } from './AIParts';
+
+// ==============================|| Doormile AI — a single turn ||============================== //
+//
+// User turns are compact right-aligned bubbles. Assistant turns are NOT
+// bubbles — text sits directly on the panel surface with a small spark mark,
+// which is what keeps this from reading as a consumer chat app.
+//
+// The `sources` disclosure is the one thing that must not be dropped in the
+// name of visual quiet: it's the existing verifiability contract (every
+// number traceable to a real call). It's collapsed by default and expands to
+// the same the previous panel showed inline.
+
+const UserMessage = ({ text }) => (
+
+ {text}
+
+);
+
+UserMessage.propTypes = { text: PropTypes.string.isRequired };
+
+const AssistantMessage = ({ message, onCopy, onAsk }) => {
+ const [showSources, setShowSources] = useState(false);
+ const sourceCount = message.sourceCalls?.length || 0;
+
+ return (
+
+
+
+
+ Doormile AI
+
+
+ }
+ onClick={() => onCopy(message)}
+ />
+
+
+
+ {message.state ? (
+
+ ) : (
+
+ {message.metric ? (
+
+
+
+ ) : (
+ {message.text}
+ )}
+
+
+
+ {message.detail && {message.detail}}
+
+ )}
+
+ {/* Live-data footer + source disclosure. Small and muted — the point is
+ that it's available, not that it's announced on every turn. */}
+ {sourceCount > 0 && (
+
+
+ ● Live data · {message.timestamp}
+
+
+ {showSources && }
+
+ )}
+
+ {message.followUps?.length > 0 && (
+
+ {message.followUps.map((q) => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+AssistantMessage.propTypes = {
+ message: PropTypes.object.isRequired,
+ onCopy: PropTypes.func.isRequired,
+ onAsk: PropTypes.func.isRequired
+};
+
+const AIMessage = ({ message, onCopy, onAsk }) =>
+ message.sender === 'user' ? : ;
+
+AIMessage.propTypes = {
+ message: PropTypes.object.isRequired,
+ onCopy: PropTypes.func.isRequired,
+ onAsk: PropTypes.func.isRequired
+};
+
+export default AIMessage;
diff --git a/src/pages/nearle/assistant/DoormileAI/AIPanel.js b/src/pages/nearle/assistant/DoormileAI/AIPanel.js
new file mode 100644
index 0000000..1f67c86
--- /dev/null
+++ b/src/pages/nearle/assistant/DoormileAI/AIPanel.js
@@ -0,0 +1,304 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import PropTypes from 'prop-types';
+import { useLocation } from 'react-router-dom';
+import dayjs from 'dayjs';
+import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
+import { LuChevronDown } from 'react-icons/lu';
+
+import { HStack } from '@astryxdesign/core/HStack';
+import { VStack } from '@astryxdesign/core/VStack';
+import { Text } from '@astryxdesign/core/Text';
+import { IconButton } from '@astryxdesign/core/IconButton';
+import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
+
+import { OpenToast } from 'components/third-party/OpenToast';
+import { STATUS } from 'themes/dt/tokens';
+import { answerQuestion, FOLLOW_UP_SUGGESTIONS } from '../intents';
+import { getPageContext } from './pageContext';
+import { Spark, LiveIndicator, TypingIndicator } from './AIParts';
+import AIWelcome 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 PIN_THRESHOLD_PX = 48;
+
+let nextId = 1;
+const makeId = () => nextId++;
+const now = () => dayjs().format('hh:mm A');
+
+const loadHistory = () => {
+ try {
+ const raw = localStorage.getItem(HISTORY_KEY);
+ const parsed = raw ? JSON.parse(raw) : [];
+ return Array.isArray(parsed) ? parsed : [];
+ } 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
+ }
+};
+
+const AIPanel = ({ isOpen, onClose }) => {
+ const { pathname } = useLocation();
+ const pageContext = getPageContext(pathname);
+
+ const [isMounted, setIsMounted] = useState(isOpen);
+ const [isShown, setIsShown] = useState(false);
+ const [messages, setMessages] = useState(loadHistory);
+ const [value, setValue] = useState('');
+ const [isSending, setIsSending] = useState(false);
+ const [context, setContext] = useState({ lastIntentId: null, lastParams: null });
+ const [isPinned, setIsPinned] = useState(true);
+
+ const panelRef = useRef(null);
+ const scrollRef = useRef(null);
+ const lastQuestionRef = useRef(null);
+
+ // ---- 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]);
+
+ // ---- 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]);
+
+ const handleScroll = () => {
+ const el = scrollRef.current;
+ if (!el) return;
+ setIsPinned(el.scrollHeight - el.scrollTop - el.clientHeight < PIN_THRESHOLD_PX);
+ };
+
+ const scrollToLatest = () => {
+ const el = scrollRef.current;
+ if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
+ setIsPinned(true);
+ };
+
+ const push = (message) => setMessages((prev) => [...prev, { id: makeId(), timestamp: now(), ...message }]);
+
+ const ask = useCallback(
+ async (text) => {
+ const question = String(text || '').trim();
+ if (!question || isSending) return;
+
+ lastQuestionRef.current = question;
+ setValue('');
+ setIsPinned(true);
+ setMessages((prev) => [...prev, { id: makeId(), sender: 'user', text: question, timestamp: now() }]);
+ setIsSending(true);
+
+ try {
+ const result = await answerQuestion(question, context);
+ if (result) {
+ setContext({ lastIntentId: result.intentId, lastParams: result.params });
+ push({
+ sender: 'assistant',
+ text: result.headline,
+ detail: result.detail,
+ 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.
+ push({
+ sender: 'assistant',
+ state: {
+ tone: STATUS.muted,
+ iconKey: 'info',
+ title: "I can't answer that one yet",
+ body: 'Try rephrasing around orders, riders, hubs, vehicles, batches, status, tenants or revenue — or pick one of the suggestions.'
+ }
+ });
+ }
+ } 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);
+ }
+ },
+ [context, isSending]
+ );
+
+ const copyMessage = (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));
+ };
+
+ const clearConversation = () => {
+ setMessages([]);
+ setContext({ lastIntentId: null, lastParams: null });
+ setIsPinned(true);
+ };
+
+ if (!isMounted) return null;
+
+ const hasThread = messages.length > 0;
+
+ return createPortal(
+ <>
+
+
+
+ {/* ---- header ---- */}
+
+
+
+
+
+ Doormile AI
+ Operations Copilot
+
+
+
+
+ , isIconOnly: true, variant: 'ghost', size: 'sm' }}
+ items={[
+ { label: 'New conversation', onClick: clearConversation },
+ { label: 'Clear conversation', onClick: clearConversation }
+ ]}
+ />
+ }
+ onClick={onClose}
+ />
+
+
+
+
+ {/* ---- page context ---- */}
+ {pageContext.label} · Today · All locations
+
+ {/* ---- conversation / welcome ---- */}
+
+
+ {hasThread ? (
+
+ {messages.map((m) => (
+
+ ))}
+ {isSending && (
+
+
+
+ Doormile AI
+
+
+
+ )}
+
+ ) : (
+
+ )}
+
+
+ {hasThread && !isPinned && (
+
+ )}
+
+
+ {/* ---- composer ---- */}
+
+
+ >,
+ document.body
+ );
+};
+
+AIPanel.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired
+};
+
+export default AIPanel;
diff --git a/src/pages/nearle/assistant/DoormileAI/AIParts.js b/src/pages/nearle/assistant/DoormileAI/AIParts.js
new file mode 100644
index 0000000..1322c0d
--- /dev/null
+++ b/src/pages/nearle/assistant/DoormileAI/AIParts.js
@@ -0,0 +1,124 @@
+import PropTypes from 'prop-types';
+import { LuSparkles } from 'react-icons/lu';
+import { WarningOutlined, InfoCircleOutlined } from '@ant-design/icons';
+
+import { HStack } from '@astryxdesign/core/HStack';
+import { VStack } from '@astryxdesign/core/VStack';
+import { Text } from '@astryxdesign/core/Text';
+
+// ==============================|| Doormile AI — shared primitives ||============================== //
+//
+// Small presentational pieces shared by the panel's states. Kept in one file
+// because each is a handful of lines and they are only ever used together.
+
+// The AI mark — a soft gradient orb, deliberately not a robot face.
+export const Spark = ({ size = 'sm' }) => (
+
+
+
+);
+
+Spark.propTypes = { size: PropTypes.oneOf(['sm', 'md', 'lg']) };
+
+// "● Live" — subtle, not a large pill.
+export const LiveIndicator = ({ label = 'Live' }) => (
+
+
+ {label}
+
+);
+
+LiveIndicator.propTypes = { label: PropTypes.string };
+
+// Thinking state. Three dots plus two shimmer bars — enough to read as
+// "working", not enough to be a light show.
+export const TypingIndicator = () => (
+
+
+
+
+
+
+
+
+
+
+
+);
+
+// The headline number for a counting answer — "the primary number is large".
+export const Metric = ({ value, label }) => (
+
+ {Number.isFinite(value) ? value.toLocaleString('en-IN') : value}
+ {label}
+
+);
+
+Metric.propTypes = {
+ value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
+ label: PropTypes.string.isRequired
+};
+
+// Compact breakdown grid. `color` is a status hex supplied by the caller —
+// the one case the token system genuinely can't express (see CLAUDE.md §6.5),
+// so it stays an inline style rather than a class.
+export const StatGrid = ({ stats }) => {
+ if (!stats?.length) return null;
+ return (
+
+ {stats.map((s) => (
+
+
+ {s.value}
+
+ {s.label}
+
+ ))}
+
+ );
+};
+
+StatGrid.propTypes = {
+ stats: PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
+ color: PropTypes.string
+ })
+ )
+};
+
+// Icons are looked up by KEY, never passed in as a node. A message carrying a
+// React element would be destroyed by the JSON round-trip through
+// localStorage (elements don't serialise — $$typeof is a Symbol and is
+// silently dropped), and the rehydrated "element" would then crash the render
+// on the next panel open.
+const STATE_ICONS = {
+ warning: ,
+ info:
+};
+
+// Shared shell for the error / no-data states, so both read as the same kind
+// of thing rather than two separately-invented blocks.
+export const StateBlock = ({ iconKey, tone, title, body, action }) => (
+
+
+
+ {STATE_ICONS[iconKey] || STATE_ICONS.info}
+
+
+ {title}
+ {body}
+
+
+ {action}
+
+);
+
+StateBlock.propTypes = {
+ iconKey: PropTypes.oneOf(['warning', 'info']),
+ tone: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ body: PropTypes.string.isRequired,
+ action: PropTypes.node
+};
diff --git a/src/pages/nearle/assistant/DoormileAI/AIWelcome.js b/src/pages/nearle/assistant/DoormileAI/AIWelcome.js
new file mode 100644
index 0000000..41d0ff0
--- /dev/null
+++ b/src/pages/nearle/assistant/DoormileAI/AIWelcome.js
@@ -0,0 +1,86 @@
+import { useState } from 'react';
+import PropTypes from 'prop-types';
+import dayjs from 'dayjs';
+import { LuArrowRight } from 'react-icons/lu';
+
+import { HStack } from '@astryxdesign/core/HStack';
+import { VStack } from '@astryxdesign/core/VStack';
+import { Text } from '@astryxdesign/core/Text';
+
+import { Spark } from './AIParts';
+
+// ==============================|| Doormile AI — welcome state ||============================== //
+//
+// Shown only when the thread is empty. Deliberately top-weighted and compact
+// so the panel never opens on a large blank region: greeting + one line of
+// what it can do, then straight into four suggestions.
+
+const greeting = () => {
+ const h = dayjs().hour();
+ if (h < 12) return 'Good morning';
+ if (h < 17) return 'Good afternoon';
+ return 'Good evening';
+};
+
+const SuggestionCard = ({ item, onAsk }) => {
+ const Icon = item.icon;
+ return (
+
+ );
+};
+
+SuggestionCard.propTypes = {
+ item: PropTypes.shape({ icon: PropTypes.elementType.isRequired, text: PropTypes.string.isRequired }).isRequired,
+ onAsk: PropTypes.func.isRequired
+};
+
+const AIWelcome = ({ context, onAsk }) => {
+ const [expanded, setExpanded] = useState(false);
+ const more = context.more || [];
+ const shown = expanded ? [...context.suggestions, ...more] : context.suggestions;
+
+ return (
+
+
+
+
+ {greeting()}
+ How can I help with today’s operations?
+
+
+ I read live orders, riders, vehicles and hubs. Every number comes from a real call — I never estimate one.
+
+
+
+
+ Suggested questions
+
+ {shown.map((item) => (
+
+ ))}
+
+ {more.length > 0 && (
+
+ )}
+
+
+ );
+};
+
+AIWelcome.propTypes = {
+ context: PropTypes.shape({
+ suggestions: PropTypes.array.isRequired,
+ more: PropTypes.array
+ }).isRequired,
+ onAsk: PropTypes.func.isRequired
+};
+
+export default AIWelcome;
diff --git a/src/pages/nearle/assistant/DoormileAI/index.js b/src/pages/nearle/assistant/DoormileAI/index.js
new file mode 100644
index 0000000..0628609
--- /dev/null
+++ b/src/pages/nearle/assistant/DoormileAI/index.js
@@ -0,0 +1,44 @@
+import { useCallback, useRef, useState } from 'react';
+import { LuSparkles } from 'react-icons/lu';
+
+import { Tooltip } from '@astryxdesign/core/Tooltip';
+
+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/pages/nearle/assistant/DoormileAI/pageContext.js b/src/pages/nearle/assistant/DoormileAI/pageContext.js
new file mode 100644
index 0000000..5197b71
--- /dev/null
+++ b/src/pages/nearle/assistant/DoormileAI/pageContext.js
@@ -0,0 +1,138 @@
+import { LuClock3, LuPackage, LuBike, LuTruck, LuBuilding2, LuCircleDot, LuBanknote, LuUsers, LuLayers, LuTimerOff } from 'react-icons/lu';
+
+// ==============================|| Doormile AI — page context ||============================== //
+//
+// Maps the current route to (a) a short context label shown under the panel
+// header and (b) the suggested questions offered in the welcome state, so the
+// assistant opens on questions relevant to what the operator is looking at
+// rather than a fixed Orders-shaped list.
+//
+// Every suggestion here MUST be a phrasing the deterministic matcher in
+// intents.js actually resolves — a suggestion chip that returns "I can't
+// answer that yet" is worse than no chip. When adding one, check it against
+// the INTENTS catalog first.
+
+const ORDERS = {
+ label: 'Orders',
+ suggestions: [
+ { icon: LuClock3, text: "Give me today's operations summary" },
+ { icon: LuPackage, text: 'How many orders today?' },
+ { icon: LuTimerOff, text: 'Which orders are delayed?' },
+ { icon: LuBanknote, text: 'Total revenue today' }
+ ],
+ more: [
+ { icon: LuCircleDot, text: 'How many pending orders today?' },
+ { icon: LuPackage, text: 'How many cancelled orders today?' },
+ { icon: LuPackage, text: 'How many delivered orders today?' },
+ { icon: LuLayers, text: 'Morning Batch orders today' },
+ { icon: LuClock3, text: 'Orders today vs yesterday' }
+ ]
+};
+
+const RIDERS = {
+ label: 'Riders',
+ suggestions: [
+ { icon: LuBike, text: 'How many riders are active?' },
+ { icon: LuClock3, text: "Give me today's operations summary" },
+ { icon: LuPackage, text: 'How many orders today?' },
+ { icon: LuTruck, text: 'How many vehicles are available?' }
+ ],
+ more: [
+ { icon: LuBuilding2, text: 'Current hub status' },
+ { icon: LuBanknote, text: 'Total revenue today' }
+ ]
+};
+
+const VEHICLES = {
+ label: 'Vehicles',
+ suggestions: [
+ { icon: LuTruck, text: 'How many vehicles are available?' },
+ { icon: LuBike, text: 'How many riders are active?' },
+ { icon: LuBuilding2, text: 'Current hub status' },
+ { icon: LuClock3, text: "Give me today's operations summary" }
+ ],
+ more: [{ icon: LuPackage, text: 'How many orders today?' }]
+};
+
+const HUBS = {
+ label: 'Hubs',
+ suggestions: [
+ { icon: LuBuilding2, text: 'Current hub status' },
+ { icon: LuTimerOff, text: 'Which hubs are experiencing delays?' },
+ { icon: LuTruck, text: 'How many vehicles are available?' },
+ { icon: LuBike, text: 'How many riders are active?' }
+ ],
+ more: [{ icon: LuPackage, text: 'How many orders today?' }]
+};
+
+const DISPATCH = {
+ label: 'Dispatch',
+ suggestions: [
+ { icon: LuLayers, text: 'Morning Batch orders today' },
+ { icon: LuTimerOff, text: 'Which orders are delayed?' },
+ { icon: LuBike, text: 'How many riders are active?' },
+ { icon: LuClock3, text: "Give me today's operations summary" }
+ ],
+ more: [
+ { icon: LuPackage, text: 'How many orders today?' },
+ { icon: LuCircleDot, text: 'How many pending orders today?' }
+ ]
+};
+
+const TENANTS = {
+ label: 'Tenants',
+ suggestions: [
+ { icon: LuUsers, text: 'How many tenants do we have?' },
+ { icon: LuPackage, text: 'How many orders today?' },
+ { icon: LuBanknote, text: 'Total revenue today' },
+ { icon: LuClock3, text: "Give me today's operations summary" }
+ ],
+ more: []
+};
+
+const REPORTS = {
+ label: 'Reports',
+ suggestions: [
+ { icon: LuBanknote, text: 'Total revenue today' },
+ { icon: LuClock3, text: 'Orders today vs yesterday' },
+ { icon: LuPackage, text: 'How many orders this week?' },
+ { icon: LuClock3, text: "Give me today's operations summary" }
+ ],
+ more: [{ icon: LuBanknote, text: 'Total revenue this week' }]
+};
+
+const DEFAULT_CONTEXT = {
+ label: 'Operations',
+ suggestions: [
+ { icon: LuClock3, text: "Give me today's operations summary" },
+ { icon: LuPackage, text: 'How many orders today?' },
+ { icon: LuBike, text: 'How many riders are active?' },
+ { icon: LuBuilding2, text: 'Current hub status' }
+ ],
+ more: [
+ { icon: LuBanknote, text: 'Total revenue today' },
+ { icon: LuTruck, text: 'How many vehicles are available?' }
+ ]
+};
+
+// Longest-prefix first so 'orders/create' doesn't fall through to 'orders'
+// with the wrong label.
+const ROUTES = [
+ ['/doormile/dispatch', DISPATCH],
+ ['/doormile/deliveries', { ...ORDERS, label: 'Deliveries' }],
+ ['/doormile/orders', ORDERS],
+ ['/doormile/riders', RIDERS],
+ ['/doormile/vehicles', VEHICLES],
+ ['/doormile/hubs', HUBS],
+ ['/doormile/tenants', TENANTS],
+ ['/doormile/reports', REPORTS],
+ ['/doormile/customers', { ...DEFAULT_CONTEXT, label: 'Customers' }],
+ ['/doormile/tripsheets', { ...DEFAULT_CONTEXT, label: 'Tripsheets' }],
+ ['/doormile/exceptions', { ...DEFAULT_CONTEXT, label: 'Exceptions' }],
+ ['/doormile/pricing', { ...DEFAULT_CONTEXT, label: 'Pricing' }]
+];
+
+export const getPageContext = (pathname = '') => {
+ const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix));
+ return match ? match[1] : DEFAULT_CONTEXT;
+};
diff --git a/src/pages/nearle/assistant/assistant.js b/src/pages/nearle/assistant/assistant.js
deleted file mode 100644
index fb662b7..0000000
--- a/src/pages/nearle/assistant/assistant.js
+++ /dev/null
@@ -1,150 +0,0 @@
-import { useState } from 'react';
-import dayjs from 'dayjs';
-import { MdSmartToy } from 'react-icons/md';
-
-import { VStack } from '@astryxdesign/core/VStack';
-import { Text } from '@astryxdesign/core/Text';
-import { Center } from '@astryxdesign/core/Center';
-import {
- ChatLayout,
- ChatMessageList,
- ChatMessage,
- ChatMessageBubble,
- ChatMessageMetadata,
- ChatComposer,
- ChatToolCalls
-} from '@astryxdesign/core/Chat';
-
-import PageHeader from 'components/nearle_components/PageHeader';
-import { PageShell } from 'components/nearle_components/PageLayout';
-import { OpenToast } from 'components/third-party/OpenToast';
-import { DT } from 'themes/dt/tokens';
-import { answerQuestion, SUPPORTED_QUESTIONS } from './intents';
-
-// ==============================|| Doormile Bot ||============================== //
-//
-// Deterministic Q&A over live order/rider data — see intents.js for the
-// intent catalog and the plan this was built from (no LLM: every answer
-// comes from a real API call, never a generated guess). This file owns only
-// the chat UI + message state; all data logic lives in intents.js.
-
-let nextId = 1;
-const makeId = () => nextId++;
-
-const now = () => dayjs().format('hh:mm A');
-
-export default function Assistant() {
- const [messages, setMessages] = useState([]);
- const [value, setValue] = useState('');
- const [isSending, setIsSending] = useState(false);
-
- const handleSubmit = async (text) => {
- const question = text.trim();
- if (!question || isSending) return;
-
- setValue('');
- setMessages((prev) => [...prev, { id: makeId(), sender: 'user', text: question, timestamp: now() }]);
- setIsSending(true);
-
- try {
- const result = await answerQuestion(question);
- if (result) {
- setMessages((prev) => [
- ...prev,
- {
- id: makeId(),
- sender: 'assistant',
- text: result.headline,
- detail: result.detail,
- sourceCalls: result.sourceCalls,
- timestamp: now()
- }
- ]);
- } else {
- setMessages((prev) => [
- ...prev,
- {
- id: makeId(),
- sender: 'assistant',
- text: "I can't answer that yet.",
- detail: `Right now I can answer:\n${SUPPORTED_QUESTIONS.map((q) => `• ${q}`).join('\n')}`,
- timestamp: now()
- }
- ]);
- }
- } catch (err) {
- OpenToast(err.response?.data?.message || err.message || 'Failed to fetch an answer', 'error', 2500);
- setMessages((prev) => [
- ...prev,
- {
- id: makeId(),
- sender: 'assistant',
- text: 'Something went wrong fetching that — try again in a moment.',
- timestamp: now()
- }
- ]);
- } finally {
- setIsSending(false);
- }
- };
-
- return (
-
-
-
-
-
- }
- emptyState={
-
-
-
- Ask Doormile
-
- I answer from live data only — no guessing. Try:
-
-
- {SUPPORTED_QUESTIONS.map((q) => (
-
- • {q}
-
- ))}
-
-
-
- }
- >
-
- {messages.map((m) => (
-
- }>
-
- {m.text}
- {m.detail && (
-
- {m.detail}
-
- )}
-
-
- {/* Audit trail — shows exactly what was queried and how many
- rows came back, so the answer is verifiable, not a claim
- the operator has to take on faith. */}
- {m.sourceCalls && }
-
- ))}
-
-
-
-
- );
-}
diff --git a/src/routes/MainRoutes.js b/src/routes/MainRoutes.js
index 7f259ac..3b3a1df 100644
--- a/src/routes/MainRoutes.js
+++ b/src/routes/MainRoutes.js
@@ -46,7 +46,6 @@ const AppUsers = Loadable(lazy(() => import('pages/nearle/appUsers/appUsers')));
const Tripsheets = Loadable(lazy(() => import('pages/nearle/tripsheets/tripsheets')));
const Exceptions = Loadable(lazy(() => import('pages/nearle/exceptions/exceptions')));
const CompetitiveIntel = Loadable(lazy(() => import('pages/nearle/competitiveIntel/competitiveIntel')));
-const Assistant = Loadable(lazy(() => import('pages/nearle/assistant/assistant')));
// ==============================|| MAIN ROUTING ||============================== //
@@ -169,10 +168,6 @@ const MainRoutes = {
{
path: 'competitive-intel',
element:
- },
- {
- path: 'assistant',
- element:
}
]
},
diff --git a/src/utils/locales/en.json b/src/utils/locales/en.json
index f927d28..7497109 100644
--- a/src/utils/locales/en.json
+++ b/src/utils/locales/en.json
@@ -17,7 +17,6 @@
"profitability": "Profitability",
"dispatch": "Dispatch",
"customers": "Customers",
- "assistant": "Assistant",
"fleetops": "Fleet & Ops",
"hubs": "Hubs",
"vehicles": "Vehicles",