import { memo, useState } from 'react'; import PropTypes from 'prop-types'; import { AiOutlineCopy as CopyOutlined } from 'react-icons/ai'; import { LuChevronDown, LuDownload } from 'react-icons/lu'; import { HStack } from '@astryxdesign/core/HStack'; import { VStack } from '@astryxdesign/core/VStack'; import { Text } from '@astryxdesign/core/Text'; import { ChatToolCalls } from '@astryxdesign/core/Chat'; import { Button } from '@/components/ui/button'; import { IconButton } from '@/components/ds'; import { Spark, Metric, StatGrid, StateBlock, AnswerList } from './AIParts'; import AIFlowStep from './AIFlowStep'; import AIAddressStep from './AIAddressStep'; import AIRowsStep from './AIRowsStep'; import { failedRowsCsv, downloadCsv } from '@/lib/assistant/bulkFile'; // ==============================|| 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, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) => { const [showSources, setShowSources] = useState(false); const sourceCount = message.sourceCalls?.length || 0; return ( MileTruth } onClick={() => onCopy(message)} /> {message.state ? ( ) : ( {message.metric ? ( ) : ( {message.text} )} {message.list && } {message.detail && (message.isDoc ? ( {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} {/* The chevron's offset and rotation live in the stylesheet — as inline styles they couldn't be transitioned, and `verticalAlign` only works while the button's content is inline, which is exactly the thing that made it wrap. */} {showSources && } )} {/* ---- Write gate --------------------------------------------------- Real inputs, not a parsed sentence. The form shows every value that will be sent and submits only on Create. A form rehydrated from localStorage in a later session is marked 'stale' by the panel and renders read-only, so a write can never fire from a resurrected message. */} {/* A conversational step that needs a dropdown rather than typing. */} {message.liveStop && ( )} {/* The bulk create's rows turn — a whole file in one answer. */} {message.flowStep?.type === 'rows' && !message.flowStep.__answered && ( onChooseStep(message, parsed)} onCancel={() => onCancelAction(message)} /> )} {/* The address turn. Without this branch the step passed the gate in AIPanel and nothing drew it — the question appeared with an empty space beneath it, which is worse than the free-text version it replaced. */} {message.flowStep?.type === 'address' && !message.flowStep.__answered && ( onChooseStep(message, value, option)} onCancel={() => onCancelAction(message)} /> )} {message.flowStep?.type === 'select' && ( onChooseStep(message, value, option)} onCancel={() => onCancelAction(message)} /> )} {['confirmCustomer', 'confirmOrder', 'confirmBulk', 'confirmAssign'].includes(message.form?.kind) && ( {message.form.status === 'open' ? (
{/* A second, wider commit — used when rows were excluded by default but including them is a legitimate choice. */} {message.form.altDraft && ( )}
) : ( {message.form.status === 'done' ? '✓ Created' : message.form.status === 'cancelled' ? 'Cancelled — nothing was sent.' : message.form.status === 'failed' ? message.form.error : 'Expired — ask again to recreate this.'} )}
)} {message.downloadRows?.length > 0 && ( )} {/* 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. */}
); }; AssistantMessage.propTypes = { message: PropTypes.object.isRequired, onCopy: PropTypes.func.isRequired, onSubmitForm: PropTypes.func.isRequired, onCancelAction: PropTypes.func.isRequired, onChooseStep: PropTypes.func.isRequired, onStopLive: PropTypes.func.isRequired }; const AIMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onChooseStep, onStopLive }) => message.sender === 'user' ? ( ) : ( ); AIMessage.propTypes = { message: PropTypes.object.isRequired, onCopy: PropTypes.func.isRequired, onSubmitForm: PropTypes.func.isRequired, onCancelAction: PropTypes.func.isRequired, onChooseStep: PropTypes.func.isRequired, onStopLive: PropTypes.func.isRequired }; // Memoised, and this is not an optimisation garnish — it's what makes the // thread scrollable. Every scroll event and every composer keystroke re-renders // the panel, and without this each one re-rendered all 50 restored turns, // tool-call disclosures included. The panel keeps its handler identities stable // so this comparison actually holds. export default memo(AIMessage);