diff --git a/README.md b/README.md index f14da73..22b8d85 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,15 @@ src/ pages/ Login + doormile/ (the console) ``` +## Operations Copilot (MileTruth AI) & Workspace Layout + +The console integrates a docked operations copilot (**MileTruth**) across all admin routes via `AdminLayout.jsx`: +- **70% Content / 30% AI Sidebar**: Main workspace occupies 70% width on the left and the MileTruth copilot occupies 30% width on the right (`--dai-dock-width: max(340px, 30vw)`). +- **Physical Separation**: An extra 24px visible gutter (`padding-right: calc(var(--dai-dock-width) + 16px)`) separates the tables/cards from the AI sidebar card. +- **Elevated Inset Card**: The sidebar card floats with `rounded-2xl` corners (20px), subtle borders (`1px solid #e2e8f0`), soft shadow, and top/right/bottom margins. +- **Default Open & Dismissal**: Opens by default on desktop (`window.innerWidth >= 1024`, persisted in `localStorage`). The close button (`×`) smoothly collapses the sidebar, animating the page content to 100% full width. Re-opening is accessible anytime via the Doormile 'D' mark in the top navbar. +- **Home Page (`Home.jsx`)**: Prompt composer features a dedicated **Send** button (`Send` icon + "Send") triggering `handleAsk()`. + ## Routes The app opens on **`/login`**. Everything else lives under `/doormile/*` behind `ProtectedRoute`, diff --git a/public/preloader.png b/public/preloader.png new file mode 100644 index 0000000..92cb60e Binary files /dev/null and b/public/preloader.png differ diff --git a/src/App.jsx b/src/App.jsx index 5fcc03b..b139acb 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -18,6 +18,7 @@ import Login from '@/pages/Login'; const Dispatch = lazy(() => import('@/pages/doormile/dispatch/Dispatch.jsx')); const DispatchPreview = lazy(() => import('@/pages/doormile/dispatch/Preview.jsx')); +const Home = lazy(() => import('@/pages/doormile/home/Home.jsx')); const Orders = lazy(() => import('@/pages/doormile/orders/Orders')); const Bookings = lazy(() => import('@/pages/doormile/bookings/Bookings')); const OrdersPreview = lazy(() => import('@/pages/doormile/orders/OrdersPreview')); @@ -78,11 +79,13 @@ const AuthenticatedApp = () => { {/* Dispatch is the console's home: it is the board operators sit on all day, and the one page that answers "what is happening right now" without a filter being set first. */} - } /> + } /> + } /> }> - } /> + } /> + } /> } /> } /> diff --git a/src/assets/brand.js b/src/assets/brand.js index 3e4ef3e..22cb1f6 100644 --- a/src/assets/brand.js +++ b/src/assets/brand.js @@ -8,8 +8,9 @@ * page use. Reach for the wordmark only on a dark ground. */ -/** The circular red D — the brand mark, legible on light surfaces. */ -export const DOORMILE_MARK_URL = '/doormile-mark.png'; +/** The red D brand mark, legible on light surfaces. */ +export const DOORMILE_MARK_URL = '/preloader.png'; +export const DOORMILE_D_LOGO_URL = '/preloader.png'; /** The white wordmark. Dark backgrounds only. */ export const DOORMILE_WORDMARK_URL = '/doormile-logo.png'; diff --git a/src/assets/images/preloader.png b/src/assets/images/preloader.png new file mode 100644 index 0000000..92cb60e Binary files /dev/null and b/src/assets/images/preloader.png differ diff --git a/src/components/assistant/CLAUDE.md b/src/components/assistant/CLAUDE.md index 390062d..7e7e883 100644 --- a/src/components/assistant/CLAUDE.md +++ b/src/components/assistant/CLAUDE.md @@ -6,21 +6,25 @@ Rules for editing **Doormile AI** — the Operations Copilot (`intents.js`, `Doo ## 1. What this is -An in-console Q&A assistant that answers operator questions about live data — "how many orders today", "morning batch orders", "how many riders are active" — by calling the same API functions every other page in this console already uses. Product name is **Doormile AI**, subtitle **Operations Copilot**. Not a standalone page: it lives as a **right-side slide-over** opened from a header icon. +An in-console Q&A assistant that answers operator questions about live data — "how many orders today", "morning batch orders", "how many riders are active" — by calling the same API functions every other page in this console already uses. Product name is **MileTruth** (or **Doormile AI**), subtitle **Operations Copilot**. -- **Mounted in**: `src/layout/MainLayout/AppTopNav.js` — a single ``. There is **no route and no sidebar entry** for this feature — don't add one back. If you're tempted to give it a full page, re-read §2 first; that was tried and deliberately reverted. +- **Mounted in**: `src/layouts/AdminLayout.jsx` — rendered alongside the page content. + - **70% / 30% Split Layout**: By default on desktop (`window.innerWidth >= 1024`), the assistant is **open on initial load**, occupying 30% of the viewport (`--dai-dock-width: max(340px, 30vw)`), while the page content occupies the remaining 70%. + - **Visual Separation**: An intentional **24px visible gutter** (`padding-right: calc(var(--dai-dock-width) + 16px)`) separates the page content/tables from the AI card so both surfaces are distinct. + - **Smooth Dismissal**: Closing via the header `×` button or navbar toggles `assistantOpen` off, smoothly expanding the page content to 100% full width. State is persisted in `localStorage ('doormileAssistantOpen')`. - **`intents.js`** — all data logic: the intent catalog, keyword/phrase matching, and the API calls that produce answers. The UI never fetches. - **`DoormileAI/`** — all UI: - `index.js` — the trigger button; owns open/closed state and returns focus to itself on close. - - `AIPanel.js` — the portal, scrim, slide-over, focus/Escape handling, message state, history persistence, and the ask() flow. - - `AIWelcome.js` — greeting + suggestion cards (empty-thread state only). + - `AIPanel.js` — the portal, slide-over, focus/Escape handling, message state, history persistence, 4 clean action icons (History, Reset, Expand, Close), and the ask() flow. + - `AIWelcome.js` — greeting + suggestion pill chips (empty-thread state only). - `AIMessage.js` — one turn. User turns are bubbles; assistant turns deliberately are NOT. - - `AIComposer.js` — auto-growing textarea, Enter to send, Shift+Enter for newline. + - `AIComposer.js` — clean horizontal capsule pill container with textarea, mic, and `` button. - `AIFlowStep.js` — one dropdown turn of a conversational create (§3.5). - `AIBulkOrderForm.js` — the one create that stays a form (CSV paste). - - `AIParts.js` — Spark / LiveIndicator / TypingIndicator / Metric / StatGrid / StateBlock. + - `AIParts.js` — Spark / TypingIndicator / Metric / StatGrid / StateBlock. - `pageContext.js` — route → context label + suggested questions. -- **`DoormileAI.css`** — the panel's stylesheet (same convention as `OrdersRedesign.css`). +- **`DoormileAI.css`** — the panel's stylesheet. Defines inset card geometry (`border-radius: 20px`, elevated shadow, floating margins off top/bottom/right edges on desktop). +- **`Home.jsx` Composer**: Prompt bar features a functional **Send** button (`Send` icon + "Send") replacing the previous Speak button. Top navigation bar features the Doormile red "D" mark. ### UI rules that are load-bearing, not cosmetic diff --git a/src/components/assistant/DoormileAI.css b/src/components/assistant/DoormileAI.css index 36d46f3..1219710 100644 --- a/src/components/assistant/DoormileAI.css +++ b/src/components/assistant/DoormileAI.css @@ -24,22 +24,41 @@ globalPolish.css, and an unprefixed name there is decided by bundle order. --------------------------------------------------------------------------- */ :root { - /* 30vw, NOT 30%. - + /* 20vw, NOT 20%. + The two consumers resolve a percentage against different boxes: the panel - is position:fixed, so `width: 30%` is 30% of the VIEWPORT, while - `padding-right: 30%` on the content container is 30% of ITS containing - block — which is narrower by the side nav. At 1440px that is 432px of - panel against 410px of reserved strip, and the page slides 22px underneath - the panel. A viewport unit resolves the same for both. - + is position:fixed, so `width: 20%` is 20% of the VIEWPORT, while + `padding-right: 20%` on the content container is 20% of ITS containing + block — which is narrower by the side nav. The page would slide underneath + the panel by the difference. A viewport unit resolves the same for both. + max() rather than a separate min-width for the same reason: a min-width that only the panel knows about would reintroduce the mismatch on narrow - screens. */ + screens. + + The floor matters more at 20vw than it did at 30vw: 20vw is 256px on a + 1280px laptop, which is too narrow for the composer and a message thread. + 320px is the floor, so below 1600px the panel stops shrinking and holds — + and the page keeps the rest. On a 1920px monitor 20vw is 384px. + + Narrowed from 30vw on request. It is the reason the Deliveries tab strip + started overflowing, so the extra 10vw goes back to the page. */ /* Default = the NORMAL step. AIPanel overwrites this property on the document element when the operator toggles the width, so the panel and the page's reserved strip always read the same value. */ - --dai-dock-width: max(300px, 25vw); + /* clamp, not max: the cap is what makes this work on a large monitor. + + With `max(320px, 20vw)` the panel just kept growing — 20vw is 512px at + 2560px and 768px at 4K, which is a chat column wider than most documents, + while the board beside it gets squeezed for no benefit. A conversation + does not read better past ~460px. + + So three regimes, one expression: + < 1600px floor at 320px (20vw would be 256px on a 1280 laptop — + too narrow for the composer and the thread) + 1600-2300 20vw (scales with the screen, as asked) + > 2300px cap at 460px (the page keeps everything above this) */ + --dai-dock-width: clamp(320px, 20vw, 460px); --dai-duration: 240ms; --dai-ease: cubic-bezier(0.16, 1, 0.3, 1); } @@ -73,7 +92,10 @@ --dai-shadow: 0 8px 30px rgba(15, 23, 42, 0.1); --dai-live: #10b981; - --dai-panel-width: 428px; + /* --dai-panel-width was declared here and at the 1279px breakpoint, and read + nowhere: the panel and the page's reserved strip both size from + --dai-dock-width. Removed so nobody tunes it expecting the panel to + change. */ --dai-inset: 12px; --dai-duration: 240ms; --dai-ease: cubic-bezier(0.16, 1, 0.3, 1); @@ -119,22 +141,22 @@ seam has depth without the panel detaching. */ .dai-panel { position: fixed; - /* Set from JS by AIPanel — Astryx does not publish a header-height token, - despite --appshell-header-height looking like one. */ - top: var(--dai-dock-top, 57px); - right: 0; - bottom: 0; + /* Inset card geometry matching the reference design: + Floating cleanly off the top nav, bottom edge, and right edge with rounded corners. */ + top: calc(var(--dai-dock-top, 57px) + 14px); + right: 16px; + bottom: 14px; z-index: 1200; - width: var(--dai-dock-width); + width: calc(var(--dai-dock-width) - 24px); display: flex; flex-direction: column; min-height: 0; overflow: hidden; - background: var(--dai-surface); - border-left: 1px solid var(--dai-border); - border-radius: 0; - box-shadow: -6px 0 20px rgba(15, 23, 42, 0.05); - transform: translateX(100%); + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 20px; + box-shadow: 0 4px 24px -2px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.04); + transform: translateX(calc(100% + 32px)); visibility: hidden; transition: transform var(--dai-duration) var(--dai-ease), @@ -157,11 +179,15 @@ The transition sits on the container unconditionally so the page slides back when the panel closes too — a rule that only exists while `.dai-docked` is applied cannot animate its own removal. */ +.dai-page, .astryx-layout-content { transition: padding-right var(--dai-duration) var(--dai-ease); } +body.dai-docked .dai-page, body.dai-docked .astryx-layout-content { - padding-right: var(--dai-dock-width); + /* Reserves the dock width plus a generous 20px gutter so the page content + (tables, cards) never collides with the sidebar and stays distinct. */ + padding-right: calc(var(--dai-dock-width) + 16px); } /* ---- Mobile: there is no 30% worth having ------------------------------- @@ -171,10 +197,14 @@ body.dai-docked .astryx-layout-content { @media (max-width: 900px) { .dai-panel { top: 0; + right: 0; + bottom: 0; width: 100%; z-index: 1301; - border-left: none; + border: none; + border-radius: 0; box-shadow: var(--dai-shadow); + transform: translateX(100%); } .dai-scrim { display: block; @@ -182,6 +212,7 @@ body.dai-docked .astryx-layout-content { .dai-scrim[data-open='true'] { opacity: 1; } + body.dai-docked .dai-page, body.dai-docked .astryx-layout-content { padding-right: 0; } @@ -194,20 +225,22 @@ body.dai-docked .astryx-layout-content { -------------------------------------------------------------------------- */ .dai-root .dai-header { flex: 0 0 auto; - padding: 14px 12px 12px 14px; - border-bottom: 1px solid var(--dai-border); + padding: 14px 16px; + border-bottom: 1px solid #f1f5f9; + background: #ffffff; } .dai-root .dai-title { - font-size: 15px; - font-weight: 650; + font-size: 16px; + font-weight: 700; line-height: 1.2; letter-spacing: -0.01em; - color: var(--dai-text); + color: #0f172a; } .dai-root .dai-subtitle { font-size: 12px; line-height: 1.3; - color: var(--dai-text-muted); + color: #64748b; + font-weight: 500; } /* The AI mark. A soft gradient orb — not a robot face. */ .dai-root .dai-spark { @@ -215,8 +248,9 @@ body.dai-docked .astryx-layout-content { align-items: center; justify-content: center; flex: 0 0 auto; - border-radius: 999px; - background: var(--dai-surface); + border-radius: 10px; + background: #fff1f2; + border: 1px solid #ffe4e6; overflow: hidden; } /* The mark's own canvas is only 66.8% content — a third of every edge is @@ -277,15 +311,7 @@ body.dai-docked .astryx-layout-content { } /* 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; + display: none; } /* -------------------------------------------------------------------------- Welcome state @@ -360,61 +386,87 @@ body.dai-docked .astryx-layout-content { They wrap, they never scroll. A horizontal scroller can always leave a chip half-visible at the edge, and a half-visible button is a bug no amount of fade masking fixes. */ +.dai-root .dai-suggestions-bar .dai-section-label { + display: none; +} .dai-root .dai-suggestions { + /* Row + wrap, which is what the comment above always claimed and what the + component asks for — SuggestionChips renders an HStack with wrap="wrap". + This rule said `column`, overriding it, so four chips became four full + rows and the strip ate a third of the panel before a single question had + been asked. Two short chips now share a row and only long ones take one to + themselves. */ width: 100%; + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 8px; + align-items: flex-start; + align-content: flex-start; } .dai-suggestion { display: inline-flex; align-items: center; - gap: 6px; + gap: 8px; max-width: 100%; - padding: 5px 10px; + /* Trimmed from 8px 18px. At the 320px floor the old horizontal padding put + 36px of empty pill around every label, which is what stopped two chips + ever fitting side by side. */ + padding: 7px 13px; text-align: left; + /* A chip is one line. Without this the flex row can hand a chip a width + narrower than its label and the text wraps inside the pill, which is what + made the strip look ragged. The ellipsis itself is on + .dai-suggestion-text — see there. */ + white-space: nowrap; + overflow: hidden; font: inherit; - font-size: 12px; - line-height: 1.35; + font-size: 13px; + line-height: 1.4; font-weight: 500; - color: var(--dai-text); - background: var(--dai-surface); - border: 1px solid var(--dai-border); - /* 14px, pinned — NOT 999px. - - On a one-line chip a fully round radius already resolves to about 14px, so - these look identical. The difference shows on a chip whose question wraps - to two lines: 999px would resolve to half of ~46px and the chip stops - reading as a chip and starts reading as a card, so one long suggestion - would look like a different component from the nineteen beside it. */ - border-radius: 14px; + color: #334155; + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 9999px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03); cursor: pointer; - transition: - background-color 140ms ease, - border-color 140ms ease, - color 140ms ease, - transform 140ms ease; + transition: all 140ms ease; } .dai-suggestion:hover { - background: var(--dai-surface-alt); - border-color: var(--dai-border-strong); + background: #f8fafc; + border-color: #cbd5e1; + color: #0f172a; transform: translateY(-1px); + box-shadow: 0 2px 5px rgba(15, 23, 42, 0.06); } .dai-suggestion:active { transform: translateY(0); } .dai-suggestion:focus-visible { - outline: 2px solid var(--dai-accent); + outline: 2px solid #0f172a; outline-offset: 2px; } .dai-root .dai-suggestion-icon { flex: 0 0 auto; - color: var(--dai-text-muted); + color: #64748b; transition: color 140ms ease; } .dai-suggestion:hover .dai-suggestion-icon { - color: var(--dai-text-secondary); + color: #0f172a; } .dai-root .dai-suggestion-text { + /* The ellipsis lives here, not on the button: text-overflow needs the + element that actually holds the text, and the button is a flex container + whose children are the icon and this span. min-width:0 is what lets the + span shrink below its content width so the ellipsis can engage at all — + a flex item defaults to min-width:auto and would push the pill wider + instead. The button already carries title={item.text}, so the full + question is still available when a label is cut. */ min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } /* Plain text link-button ("View more", "Sources") */ .dai-link { @@ -695,26 +747,29 @@ body.dai-docked .astryx-layout-content { -------------------------------------------------------------------------- */ .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); + padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px)); + border-top: 1px solid #f1f5f9; + background: #ffffff; } .dai-root .dai-composer { - border: 1px solid var(--dai-border-strong); - /* 14px, the same corner the suggestion chips use. At 5px the input was the - one sharp-cornered thing in a panel of rounded surfaces, and it sat - directly beneath the chips where the mismatch was most visible. */ - border-radius: 14px; - background: var(--dai-surface); - box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); - padding: 10px 10px 8px 14px; + border: 1px solid #e2e8f0; + border-radius: 9999px; + background: #f8fafc; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03); + padding: 6px 6px 6px 16px; + display: flex; + align-items: center; + gap: 8px; + width: 100%; transition: border-color 140ms ease, - box-shadow 140ms ease; + box-shadow 140ms ease, + background-color 140ms ease; } .dai-root .dai-composer[data-focused='true'] { - border-color: var(--dai-accent); - box-shadow: 0 0 0 3px var(--dai-accent-ring); + background: #ffffff; + border-color: #cbd5e1; + box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.05); } /* -------------------------------------------------------------------------- @@ -811,13 +866,13 @@ body.dai-docked .astryx-layout-content { align-items: center; justify-content: center; flex: 0 0 auto; - width: 30px; - height: 30px; + width: 32px; + height: 32px; padding: 0; border: none; border-radius: 50%; - background: var(--dai-accent); - color: var(--dai-accent-contrast); + background: #2563eb; + color: #ffffff; cursor: pointer; transition: background-color 140ms ease, @@ -825,29 +880,32 @@ body.dai-docked .astryx-layout-content { transform 140ms ease; } .dai-root .dai-send:hover:not(:disabled) { - opacity: 0.86; + background: #1d4ed8; + transform: scale(1.05); } .dai-root .dai-send:active:not(:disabled) { - transform: scale(0.94); + transform: scale(0.96); } .dai-root .dai-send:focus-visible { - outline: 2px solid var(--dai-accent); + outline: 2px solid #2563eb; outline-offset: 2px; } /* Disabled is the resting state until something is typed, so it has to read as "not yet", not as "broken" — a filled grey circle, same shape and weight. */ .dai-root .dai-send:disabled { - background: var(--dai-surface-hover); - color: var(--dai-text-muted); + background: #f1f5f9; + color: #94a3b8; cursor: default; } .dai-root .dai-composer textarea { display: block; + flex: 1 1 auto; width: 100%; + min-width: 0; border: none; outline: none; resize: none; - padding: 0; + padding: 4px 0; margin: 0; font: inherit; font-size: 13.5px; @@ -914,15 +972,36 @@ body.dai-docked .astryx-layout-content { /* -------------------------------------------------------------------------- Responsive -------------------------------------------------------------------------- */ -@media (max-width: 1279px) { - .dai-root { - --dai-panel-width: 396px; +/* Tablet and small laptop, 768-900px: the panel is a full-width overlay with a + scrim (see the max-width:900px block above), so the only thing left to do is + stop the suggestion strip from taking a third of a short screen — there is + much less height to spend here than on a desktop. */ +@media (max-width: 900px) { + .dai-root .dai-suggestions-bar { + max-height: 26vh; } } + +/* The block that used to sit here set `--dai-panel-width: 396px` at 1279px. + That variable is declared and never read — the panel sizes from + --dai-dock-width — so the rule had no effect on anything. Removed rather + than left looking load-bearing. */ + @media (max-width: 767px) { .dai-root { --dai-inset: 0px; } + /* Phone: chips get a touch target and room to breathe, and the strip is + capped harder still because the on-screen keyboard takes the bottom half + of the viewport the moment the composer is focused. */ + .dai-root .dai-suggestions-bar { + max-height: 22vh; + padding: 8px 12px 6px; + } + .dai-suggestion { + padding: 9px 14px; + font-size: 13px; + } .dai-panel { width: 100vw; max-width: 100vw; diff --git a/src/components/assistant/DoormileAI/AIComposer.jsx b/src/components/assistant/DoormileAI/AIComposer.jsx index 31f91bf..3bd2d39 100644 --- a/src/components/assistant/DoormileAI/AIComposer.jsx +++ b/src/components/assistant/DoormileAI/AIComposer.jsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; -import { LuArrowUp } from 'react-icons/lu'; +import { LuSend } from 'react-icons/lu'; import { HStack } from '@astryxdesign/core/HStack'; import { VStack } from '@astryxdesign/core/VStack'; @@ -111,7 +111,7 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => { return ( - +