diff --git a/src/layout/MainLayout/AppSideNav.js b/src/layout/MainLayout/AppSideNav.js
index 898f58a..bc1ad51 100644
--- a/src/layout/MainLayout/AppSideNav.js
+++ b/src/layout/MainLayout/AppSideNav.js
@@ -88,14 +88,39 @@ const AppSideNav = ({ isCollapsed, onCollapsedChange }) => {
.doormile-side-nav .astryx-side-nav-item[aria-label] > * {
margin: 0 !important;
}
+ /* COLLAPSED rail — the icon is the only thing left, so it carries the
+ whole item and has to be legible on its own.
+
+ font-size is the load-bearing property here, not width/height.
+ Astryx renders the glyph as an svg sized in em units, so the width and
+ height below only ever sized the WRAPPER: the box was 18px while the
+ glyph inside it stayed at the inherited 14px, leaving 2px of dead
+ space on every side and an icon that read small in a 40px target.
+ Setting the font-size is what actually scales the mark. */
.doormile-side-nav .astryx-side-nav-item[aria-label] .astryx-icon {
- width: 18px !important;
- height: 18px !important;
+ font-size: 20px !important;
+ width: 20px !important;
+ height: 20px !important;
margin: 0 !important;
display: flex;
align-items: center;
justify-content: center;
}
+ .doormile-side-nav .astryx-side-nav-item[aria-label] .astryx-icon svg {
+ width: 1em;
+ height: 1em;
+ }
+
+ /* EXPANDED rail — the label does the identifying, so the icon only has
+ to sit comfortably beside it. Still bumped off the inherited 14px,
+ which was undersized against a 14px label. */
+ .doormile-side-nav .astryx-side-nav-item:not([aria-label]) .astryx-icon {
+ font-size: 17px !important;
+ }
+ .doormile-side-nav .astryx-side-nav-item:not([aria-label]) .astryx-icon svg {
+ width: 1em;
+ height: 1em;
+ }
.doormile-side-nav .astryx-side-nav-item[aria-label]:hover {
background-color: ${DT.brand}14 !important;
}
diff --git a/src/pages/nearle/assistant/DoormileAI.css b/src/pages/nearle/assistant/DoormileAI.css
index 722e2f4..1df56f5 100644
--- a/src/pages/nearle/assistant/DoormileAI.css
+++ b/src/pages/nearle/assistant/DoormileAI.css
@@ -12,6 +12,38 @@
doesn't carry (the AI accent, the scrim, the panel elevation).
========================================================================== */
+/* ---------------------------------------------------------------------------
+ Dock geometry.
+
+ These three live on :root, not on .dai-root, because the LAYOUT has to read
+ them: the assistant is docked into the page rather than floated over it, so
+ the app's content container shifts by exactly the panel's width and animates
+ on the same curve. A token only .dai-root can see cannot do that.
+
+ All three are --dai- prefixed. :root is shared with Dispatch.css and
+ globalPolish.css, and an unprefixed name there is decided by bundle order.
+ --------------------------------------------------------------------------- */
+:root {
+ /* 30vw, NOT 30%.
+
+ 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.
+
+ 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. */
+ /* 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);
+ --dai-duration: 240ms;
+ --dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
+}
+
.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
@@ -20,6 +52,8 @@
change here rather than a hunt through the components. */
--dai-accent: var(--color-accent, #0f172a);
--dai-accent-contrast: #ffffff;
+ /* Focus ring, tinted with the accent rather than a flat grey wash. */
+ --dai-accent-ring: color-mix(in srgb, var(--dai-accent) 14%, transparent);
/* 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. */
@@ -48,7 +82,14 @@
Scrim — deliberately light. The dashboard underneath must stay readable;
this is a layering cue, not a modal blackout.
-------------------------------------------------------------------------- */
+/* The scrim exists for the MOBILE presentation only (see the media query at
+ the end of this block). On desktop the panel is docked into the layout and
+ dimming the page behind it would be a lie — the page is still live, still
+ scrollable, and still the thing the operator is working on. Dimming is also
+ the single strongest cue that something is modal, which is exactly the
+ impression this panel should not give. */
.dai-scrim {
+ display: none;
position: fixed;
inset: 0;
z-index: 1300;
@@ -62,31 +103,88 @@
/* --------------------------------------------------------------------------
Panel
-------------------------------------------------------------------------- */
+/* DOCKED, not floating.
+
+ It used to be an inset card: 12px off every edge, fully rounded, drop
+ shadowed, over a scrim — every cue of a popup. It now sits flush in the
+ right 30% of the workspace, starting under the app header and running to the
+ bottom of the window, with a single hairline on its left edge. The page does
+ not move underneath it; it makes room for it.
+
+ `top` reads Astryx's own measured header height so the dock lines up with
+ the bottom of the top nav rather than guessing a number.
+
+ No radius and no drop shadow: both are what make a surface read as floating
+ ABOVE the page. A soft shadow is kept only as a left-edge falloff so the
+ seam has depth without the panel detaching. */
.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));
+ /* 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;
+ z-index: 1200;
+ width: var(--dai-dock-width);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: var(--dai-surface);
- border: 1px solid var(--dai-border);
- border-radius: 5px;
- box-shadow: var(--dai-shadow);
- transform: translateX(calc(100% + var(--dai-inset) * 2));
- opacity: 0;
+ border-left: 1px solid var(--dai-border);
+ border-radius: 0;
+ box-shadow: -6px 0 20px rgba(15, 23, 42, 0.05);
+ transform: translateX(100%);
+ visibility: hidden;
transition:
transform var(--dai-duration) var(--dai-ease),
- opacity var(--dai-duration) var(--dai-ease);
+ visibility 0s linear var(--dai-duration);
}
.dai-panel[data-open='true'] {
transform: translateX(0);
- opacity: 1;
+ visibility: visible;
+ transition:
+ transform var(--dai-duration) var(--dai-ease),
+ visibility 0s;
+}
+
+/* ---- The page makes room -------------------------------------------------
+ `.dai-docked` is set on
while the panel is open. Padding rather than
+ width/margin: the content container is a flex child inside AppShell's own
+ height:fill chain, and changing its width there fights that chain, whereas
+ padding leaves the box model alone and simply reserves the strip.
+
+ 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. */
+.astryx-layout-content {
+ transition: padding-right var(--dai-duration) var(--dai-ease);
+}
+body.dai-docked .astryx-layout-content {
+ padding-right: var(--dai-dock-width);
+}
+
+/* ---- Mobile: there is no 30% worth having -------------------------------
+ 30% of a phone is ~120px. Below the breakpoint the panel becomes what it
+ used to be everywhere — a full-width overlay with a scrim — and the page
+ stops reserving a strip it cannot afford. */
+@media (max-width: 900px) {
+ .dai-panel {
+ top: 0;
+ width: 100%;
+ z-index: 1301;
+ border-left: none;
+ box-shadow: var(--dai-shadow);
+ }
+ .dai-scrim {
+ display: block;
+ }
+ .dai-scrim[data-open='true'] {
+ opacity: 1;
+ }
+ body.dai-docked .astryx-layout-content {
+ padding-right: 0;
+ }
}
.dai-panel:focus {
outline: none;
@@ -221,64 +319,102 @@
/* --------------------------------------------------------------------------
Suggestion cards
-------------------------------------------------------------------------- */
-.dai-suggestion {
- display: flex;
- align-items: center;
- gap: 10px;
+/* The suggestions strip sits between the conversation and the composer, so it
+ needs the composer's horizontal rhythm and a hairline to separate it from
+ the thread scrolling above it. */
+.dai-root .dai-suggestions-bar {
+ /* Shrinkable, with a hard cap.
+
+ At the normal 25% width the full twenty chips still stack to several rows.
+ As `flex: 0 0 auto` that block refuses to shrink, so on a short laptop it
+ pushes the composer off the bottom of the panel — the input disappears and
+ the assistant cannot be used at all. The cap is a safety valve, not a
+ layout choice: it only engages when the strip would otherwise cost more
+ than a third of the panel, and it never applies once a thread exists,
+ because the strip is four chips by then. */
+ flex: 0 1 auto;
+ min-height: 0;
+ max-height: 34vh;
+ overflow-y: auto;
+ padding: 10px 14px 8px;
+ border-top: 1px solid var(--dai-border);
+ background: var(--dai-surface);
+}
+
+/* Follow-up mode: a handful of chips, so the strip is FIXED — it never shrinks
+ and never scrolls.
+
+ The cap and `flex-shrink: 1` above exist for the cold panel's twenty chips.
+ Once a conversation starts the strip is three or four, and leaving it
+ shrinkable meant a long thread squeezed it: the flex row gave the space to
+ the conversation, `overflow-y: auto` quietly hid the remainder, and the
+ follow-ups the operator was meant to act on disappeared below a fold nobody
+ could see. Few chips must always be fully visible. */
+.dai-root .dai-suggestions-bar[data-compact='true'] {
+ flex: 0 0 auto;
+ max-height: none;
+ overflow: visible;
+}
+
+/* ---- Suggested questions: chips ------------------------------------------
+ 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 {
width: 100%;
- padding: 10px 11px;
+}
+
+.dai-suggestion {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ max-width: 100%;
+ padding: 5px 10px;
text-align: left;
font: inherit;
+ font-size: 12px;
+ line-height: 1.35;
+ font-weight: 500;
color: var(--dai-text);
background: var(--dai-surface);
border: 1px solid var(--dai-border);
- border-radius: 5px;
+ /* 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;
cursor: pointer;
transition:
background-color 140ms ease,
border-color 140ms ease,
+ color 140ms ease,
transform 140ms ease;
}
.dai-suggestion:hover {
background: var(--dai-surface-alt);
border-color: var(--dai-border-strong);
+ transform: translateY(-1px);
}
.dai-suggestion:active {
- transform: scale(0.99);
+ transform: translateY(0);
}
.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: 5px;
- background: var(--dai-surface-hover);
+ color: var(--dai-text-muted);
+ transition: color 140ms ease;
+}
+.dai-suggestion:hover .dai-suggestion-icon {
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);
+ min-width: 0;
}
/* Plain text link-button ("View more", "Sources") */
.dai-link {
@@ -565,17 +701,83 @@
}
.dai-root .dai-composer {
border: 1px solid var(--dai-border-strong);
- border-radius: 5px;
+ /* 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: 8px 8px 6px 12px;
+ padding: 10px 10px 8px 14px;
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);
+ box-shadow: 0 0 0 3px var(--dai-accent-ring);
+}
+
+/* Microphone.
+
+ Astryx's smallest dictation button is 28px, two short of the send button it
+ sits beside — a visible step between two controls on the same row. Pinned to
+ match, with the glyph scaled through font-size because the icon inside is
+ sized in em units (the same reason the side nav icons needed font-size
+ rather than width). */
+.dai-root .dai-mic {
+ width: 30px;
+ height: 30px;
+ min-width: 30px;
+ border-radius: 50%;
+ font-size: 16px;
+}
+.dai-root .dai-mic svg {
+ width: 1em;
+ height: 1em;
+}
+
+/* --------------------------------------------------------------------------
+ Send button.
+
+ This had NO styles at all — the class was referenced once, in a
+ reduced-motion reset, and nowhere else. It rendered as a bare 16px arrow
+ glyph on a transparent background with square corners: not a button, just an
+ icon sitting next to the microphone. Verified in the browser before fixing.
+ -------------------------------------------------------------------------- */
+.dai-root .dai-send {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ border: none;
+ border-radius: 50%;
+ background: var(--dai-accent);
+ color: var(--dai-accent-contrast);
+ cursor: pointer;
+ transition:
+ background-color 140ms ease,
+ opacity 140ms ease,
+ transform 140ms ease;
+}
+.dai-root .dai-send:hover:not(:disabled) {
+ opacity: 0.86;
+}
+.dai-root .dai-send:active:not(:disabled) {
+ transform: scale(0.94);
+}
+.dai-root .dai-send:focus-visible {
+ outline: 2px solid var(--dai-accent);
+ 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);
+ cursor: default;
}
.dai-root .dai-composer textarea {
display: block;
@@ -597,8 +799,10 @@
color: var(--dai-text-muted);
}
.dai-root .dai-hint {
- font-size: 11px;
+ font-size: 10.5px;
+ line-height: 1.3;
color: var(--dai-text-muted);
+ padding-inline: 2px;
}
/* --------------------------------------------------------------------------
Trigger (lives in the app TopNav)
diff --git a/src/pages/nearle/assistant/DoormileAI/AIComposer.js b/src/pages/nearle/assistant/DoormileAI/AIComposer.js
index 0d82528..31f91bf 100644
--- a/src/pages/nearle/assistant/DoormileAI/AIComposer.js
+++ b/src/pages/nearle/assistant/DoormileAI/AIComposer.js
@@ -1,4 +1,4 @@
-import { useLayoutEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { LuArrowUp } from 'react-icons/lu';
@@ -17,11 +17,73 @@ import { ChatDictationButton, useChatDictation } from '@astryxdesign/core/Chat';
const MAX_ROWS_PX = 108;
+// How long a dictating operator has to stay quiet before the message sends
+// itself. Long enough to think mid-sentence, short enough that you are not left
+// wondering whether it heard you.
+const SILENCE_MS = 3000;
+
const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
const textareaRef = useRef(null);
const [isFocused, setIsFocused] = useState(false);
- const dictation = useChatDictation({ onResult: (transcript) => onChange(transcript) });
+ // ---- dictation: send on silence ----
+ // Speech arrives as a stream of partial transcripts, so "finished speaking"
+ // is not an event the API gives us — it has to be inferred from the stream
+ // going quiet. Every transcript update re-arms a timer; whichever one is not
+ // followed by another within SILENCE_MS is the end of the sentence.
+ //
+ // Everything the timer touches is read through a ref. The callback is created
+ // once and fires up to three seconds later, so a closed-over `value` or
+ // `isBusy` would be whatever they were when dictation started, not when the
+ // operator stopped talking — it would send a stale half-sentence, or send
+ // while a previous answer was still streaming.
+ const valueRef = useRef(value);
+ const isBusyRef = useRef(isBusy);
+ const dictationRef = useRef(null);
+ const silenceTimer = useRef(0);
+
+ valueRef.current = value;
+ isBusyRef.current = isBusy;
+
+ const clearSilence = useCallback(() => {
+ if (silenceTimer.current) {
+ clearTimeout(silenceTimer.current);
+ silenceTimer.current = 0;
+ }
+ }, []);
+
+ const armSilence = useCallback(() => {
+ clearSilence();
+ silenceTimer.current = setTimeout(() => {
+ silenceTimer.current = 0;
+ // Stop the mic first. Sending while it is still listening would let the
+ // next words land in an input the operator has already "sent".
+ dictationRef.current?.stop();
+ const text = valueRef.current.trim();
+ if (text && !isBusyRef.current) onSubmit(text);
+ }, SILENCE_MS);
+ }, [clearSilence, onSubmit]);
+
+ const dictation = useChatDictation({
+ // Fires on every partial AND final result, which is what makes this a
+ // silence detector rather than an end-of-recognition one.
+ onTranscript: (transcript) => {
+ onChange(transcript);
+ armSilence();
+ },
+ onResult: (transcript) => {
+ onChange(transcript);
+ armSilence();
+ },
+ // Stopped by hand, or the engine gave up: no pending auto-send.
+ onEnd: clearSilence,
+ onError: clearSilence
+ });
+
+ dictationRef.current = dictation;
+
+ // A panel closed mid-sentence must not fire a message afterwards.
+ useEffect(() => clearSilence, [clearSilence]);
// 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
@@ -61,8 +123,12 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
placeholder={placeholder}
aria-label="Ask Doormile AI about operations"
/>
-
-
+ {/* Both controls group on the RIGHT. `justify="between"` pushed the mic
+ to the far left and the send button to the far right, leaving a
+ wide dead gap between two things that do the same job — put a
+ message in. Together they read as one action cluster. */}
+
+
- Enter to send · Shift + Enter for a new line
+
+ {dictation.isListening ? 'Listening — sends automatically after a short pause' : 'Enter to send · Shift + Enter for a new line'}
+
);
};
diff --git a/src/pages/nearle/assistant/DoormileAI/AIMessage.js b/src/pages/nearle/assistant/DoormileAI/AIMessage.js
index 798b4b2..40238f0 100644
--- a/src/pages/nearle/assistant/DoormileAI/AIMessage.js
+++ b/src/pages/nearle/assistant/DoormileAI/AIMessage.js
@@ -1,7 +1,7 @@
import { memo, useState } from 'react';
import PropTypes from 'prop-types';
import { CopyOutlined } from '@ant-design/icons';
-import { LuChevronDown, LuArrowRight } from 'react-icons/lu';
+import { LuChevronDown } from 'react-icons/lu';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
@@ -34,7 +34,7 @@ const UserMessage = ({ text }) => (
UserMessage.propTypes = { text: PropTypes.string.isRequired };
-const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) => {
+const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) => {
const [showSources, setShowSources] = useState(false);
const sourceCount = message.sourceCalls?.length || 0;
@@ -194,16 +194,15 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
)}
- {message.followUps?.length > 0 && (
-
- {message.followUps.map((q) => (
-
- ))}
-
- )}
+ {/* Follow-ups are NOT rendered here.
+
+ They used to appear twice: once under each answer and again in the
+ strip above the composer. Two sets of chips offering overlapping
+ questions in one panel is the assistant asking the same thing twice,
+ and the in-message copy scrolled away the moment the next answer
+ arrived — so the one that was always reachable is the one that was
+ kept. `message.followUps` still carries the intent's suggestions;
+ AIPanel feeds them into that single strip. */}
);
};
@@ -211,21 +210,19 @@ const AssistantMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction
AssistantMessage.propTypes = {
message: PropTypes.object.isRequired,
onCopy: PropTypes.func.isRequired,
- onAsk: PropTypes.func.isRequired,
onSubmitForm: PropTypes.func.isRequired,
onCancelAction: PropTypes.func.isRequired,
onChooseStep: PropTypes.func.isRequired,
onStopLive: PropTypes.func.isRequired
};
-const AIMessage = ({ message, onCopy, onAsk, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) =>
+const AIMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) =>
message.sender === 'user' ? (
) : (
nextId++;
const now = () => dayjs().format('hh:mm A');
@@ -121,6 +131,74 @@ const AIPanel = ({ isOpen, onClose }) => {
return () => clearTimeout(timer);
}, [isOpen]);
+ // ---- dock width: normal 25%, expanded 30% ----
+ // Written as a custom property on the document rather than held in CSS,
+ // because TWO things have to agree on it: the panel's own width and the
+ // padding the page reserves beside it (see the .dai-docked rule). One
+ // variable is the only way they cannot drift apart.
+ //
+ // The floors matter as much as the percentages. Below roughly a 1200px
+ // viewport these percentages fall under what a conversation is readable in,
+ // so each step has a pixel floor it will not go under.
+ //
+ // Persisted: the panel unmounts when it closes, so without this the operator
+ // re-expands it every single time they open the assistant.
+ const [isWide, setIsWide] = useState(() => {
+ try {
+ return localStorage.getItem(WIDTH_KEY) === 'wide';
+ } catch {
+ return false;
+ }
+ });
+
+ useEffect(() => {
+ document.documentElement.style.setProperty('--dai-dock-width', isWide ? DOCK_WIDE : DOCK_NORMAL);
+ try {
+ localStorage.setItem(WIDTH_KEY, isWide ? 'wide' : 'normal');
+ } catch {
+ /* private mode — the width just won't persist */
+ }
+ }, [isWide]);
+
+ // ---- anchor the dock to the real header height ----
+ // The dock starts where the top nav ends. `--appshell-header-height` looks
+ // like the right token for that and Dispatch.css already reads it, but
+ // Astryx does not actually publish it — verified in the browser, where it
+ // resolves to nothing and the fallback wins. The header is 57px, not the 64
+ // a fallback would guess, so trusting it left a 7px sliver of page visible
+ // above the panel.
+ //
+ // Measured instead, and re-measured on resize, so the seam stays closed if
+ // the header ever changes height.
+ useEffect(() => {
+ if (!isMounted) return undefined;
+ const header = document.querySelector('.astryx-layout-header');
+ if (!header) return undefined;
+ const apply = () => {
+ const h = Math.round(header.getBoundingClientRect().height);
+ document.documentElement.style.setProperty('--dai-dock-top', `${h}px`);
+ };
+ apply();
+ const ro = new ResizeObserver(apply);
+ ro.observe(header);
+ return () => ro.disconnect();
+ }, [isMounted]);
+
+ // ---- the page makes room for the dock ----
+ // The panel is portaled to , so it cannot be a flex sibling of the
+ // content area and cannot push it directly. This flag is the handshake: CSS
+ // (DoormileAI.css) reserves exactly --dai-dock-width of padding on the app's
+ // content container while it is set, and animates on the same curve, so the
+ // page slides open rather than being covered.
+ //
+ // Keyed on isShown, not isOpen, so the shift starts on the same frame the
+ // panel starts sliding in. Cleared on unmount as well as on close — leaving
+ // it set would strand the whole app behind a permanent empty gutter.
+ useEffect(() => {
+ document.body.classList.toggle('dai-docked', isShown);
+ return () => document.body.classList.remove('dai-docked');
+ }, [isShown]);
+
// ---- Escape closes ----
useEffect(() => {
if (!isOpen) return undefined;
@@ -1031,6 +1109,42 @@ const AIPanel = ({ isOpen, onClose }) => {
const hasThread = messages.length > 0;
+ // What the strip offers, and why it changes.
+ //
+ // Cold panel: everything, because nothing is known about intent yet.
+ //
+ // After a question: the natural NEXT steps for that question, not the same
+ // twenty again. Asking "Create an order" and being offered "Create an order"
+ // is the assistant not listening; being offered "Assign a rider" and "How
+ // many pending orders today?" is.
+ //
+ // The lookup is keyed on the last thing the OPERATOR said, not on the last
+ // answer, so it still narrows correctly when a flow is mid-conversation and
+ // the assistant's last message was a question back.
+ //
+ // Anything unrecognised — a typed question, or one with no obvious next step
+ // — falls back to this page's own suggestions rather than an empty strip.
+ const lastAsked = [...messages].reverse().find((m) => m.sender === 'user')?.text;
+ const lastAnswerFollowUps = [...messages].reverse().find((m) => m.sender === 'assistant' && m.followUps?.length)?.followUps;
+
+ // Three sources, most specific first:
+ // 1. the curated next steps for the exact question that was asked
+ // 2. the intent's own follow-ups, for a TYPED question the map doesn't
+ // cover — this is what the in-message chips used to render
+ // 3. this page's own suggestions, so the strip is never empty
+ const followUps = hasThread ? getFollowUps(lastAsked) : [];
+ const intentFollowUps = hasThread && followUps.length === 0 ? toChips(lastAnswerFollowUps) : [];
+ const nextSteps = followUps.length > 0 ? followUps : intentFollowUps;
+ const suggestionItems = hasThread
+ ? nextSteps.length > 0
+ ? nextSteps
+ : pageContext.suggestions.slice(0, 4)
+ : [...pageContext.suggestions, ...(pageContext.more || [])];
+
+ // Few chips => the strip is fixed and always fully visible. Twenty => it may
+ // shrink and scroll. See the [data-compact] rule in DoormileAI.css.
+ const isCompactStrip = hasThread;
+
return createPortal(
<>
@@ -1042,7 +1156,12 @@ const AIPanel = ({ isOpen, onClose }) => {
data-open={isShown}
gap={0}
padding={0}
- role="dialog"
+ /* `complementary`, not `dialog`. A dialog promises modality — focus is
+ trapped, the rest of the page is inert, and screen readers announce
+ it as blocking. None of that is true any more: the page beside it
+ stays live and operable, so announcing it as a dialog would be a
+ lie to exactly the users who cannot see that it is docked. */
+ role="complementary"
aria-label="Doormile AI — Operations Copilot"
tabIndex={-1}
>
@@ -1058,6 +1177,28 @@ const AIPanel = ({ isOpen, onClose }) => {
+ {/* No `size` prop on the icon — deliberately.
+
+ Its two siblings here are Ant Design icons, which render at
+ 1em and so inherit the button's 16px. Pinning this one to 15
+ made it a pixel smaller than the ⋯ and × beside it, which is
+ exactly the kind of one-pixel mismatch that reads as "off"
+ without being obviously wrong. Left unset, react-icons also
+ defaults to 1em and all three track the button together.
+
+ Panel icons rather than chevrons: a bare chevron next to a
+ close button is ambiguous (collapse? navigate? close?),
+ whereas these draw the side panel itself getting wider or
+ narrower, which is literally what the control does. */}
+ : }
+ onClick={() => setIsWide((v) => !v)}
+ />
, isIconOnly: true, variant: 'ghost', size: 'sm' }}
@@ -1091,7 +1232,6 @@ const AIPanel = ({ isOpen, onClose }) => {
key={m.id}
message={m}
onCopy={copyMessage}
- onAsk={ask}
onSubmitForm={submitForm}
onCancelAction={cancelAction}
onChooseStep={chooseStep}
@@ -1109,7 +1249,7 @@ const AIPanel = ({ isOpen, onClose }) => {
)}
) : (
-
+
)}
@@ -1121,6 +1261,24 @@ const AIPanel = ({ isOpen, onClose }) => {
)}
+ {/* ---- suggestions, pinned above the composer ----
+ They sit with the input because that is what they are: a way to
+ start a message without typing one. Inside the scrolling body they
+ were unreachable the moment a conversation began.
+
+ The full set shows only on an empty thread. Once there is a
+ conversation the strip drops to this page's four primary
+ suggestions — twenty chips permanently above the composer would
+ claim ~260px of a panel whose whole job is the conversation. */}
+ {suggestionItems.length > 0 && (
+
+
+ {hasThread ? (nextSteps.length > 0 ? 'Next steps' : 'Ask something else') : 'Suggested questions'}
+
+
+
+ )}
+
{/* ---- composer ---- */}
diff --git a/src/pages/nearle/assistant/DoormileAI/AIWelcome.js b/src/pages/nearle/assistant/DoormileAI/AIWelcome.js
index 7b65001..edb21ce 100644
--- a/src/pages/nearle/assistant/DoormileAI/AIWelcome.js
+++ b/src/pages/nearle/assistant/DoormileAI/AIWelcome.js
@@ -1,12 +1,12 @@
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';
+import { CHIP_LABELS } from './pageContext';
// ==============================|| Doormile AI — welcome state ||============================== //
//
@@ -21,61 +21,72 @@ const greeting = () => {
return 'Good evening';
};
-const SuggestionCard = ({ item, onAsk }) => {
+// A chip, not a row.
+//
+// These used to be full-width rows: a 26px icon tile, the question, and a
+// reveal-on-hover arrow, stacked one per line. That reads fine for four
+// suggestions and falls apart at twenty — the panel opened on a wall of
+// identical boxes that had to be read top to bottom, and the whole welcome
+// state scrolled.
+//
+// As chips they flow into a block that is scanned rather than read: each one
+// is only as wide as its question, so the varying widths give the eye
+// something to land on, and twenty of them occupy a few rows instead of
+// twenty. The icon stays as a small inline glyph for recognition; the arrow
+// is gone, because on a chip it was decoration competing with the label.
+const SuggestionChip = ({ item, onAsk }) => {
const Icon = item.icon;
+ // Display short, ask long. `item.text` is what intents.js matches on, so it
+ // is what gets asked and what assistive tech announces; the chip only shows
+ // a trimmed version of it so twenty of them fit in a few rows.
+ const label = CHIP_LABELS[item.text] || item.text;
return (
-