updates on the ui changes and changed into doormile
This commit is contained in:
245
src/components/assistant/DoormileAI/AIMessage.jsx
Normal file
245
src/components/assistant/DoormileAI/AIMessage.jsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { memo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { HStack } from './shim';
|
||||
import { VStack } from './shim';
|
||||
import { Text } from './shim';
|
||||
import { IconButton } from './shim';
|
||||
import { Button } from './shim';
|
||||
import { ChatToolCalls } from './shim';
|
||||
|
||||
import { Spark, Metric, StatGrid, StateBlock, AnswerList } from './AIParts';
|
||||
import AIFlowStep from './AIFlowStep';
|
||||
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 <ChatToolCalls> the previous panel showed inline.
|
||||
|
||||
const UserMessage = ({ text }) => (
|
||||
<VStack className="dai-msg" gap={0} padding={0}>
|
||||
<Text className="dai-msg-user">{text}</Text>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
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 (
|
||||
<VStack className="dai-msg dai-msg-row" gap={1.5} padding={0}>
|
||||
<HStack gap={1.5} padding={0} vAlign="center" justify="between">
|
||||
<HStack gap={1} padding={0} vAlign="center">
|
||||
<Spark size="sm" />
|
||||
<Text className="dai-msg-name">Doormile AI</Text>
|
||||
</HStack>
|
||||
<HStack className="dai-msg-actions" gap={0} padding={0}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label="Copy answer"
|
||||
tooltip="Copy answer"
|
||||
icon={<Copy />}
|
||||
onClick={() => onCopy(message)}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{message.state ? (
|
||||
<StateBlock iconKey={message.state.iconKey} tone={message.state.tone} title={message.state.title} body={message.state.body} />
|
||||
) : (
|
||||
<VStack gap={1.5} padding={0}>
|
||||
{message.metric ? (
|
||||
<VStack gap={1.5} padding={0}>
|
||||
<Metric value={message.metric.value} label={message.metric.label} />
|
||||
</VStack>
|
||||
) : (
|
||||
<Text className="dai-msg-ai">{message.text}</Text>
|
||||
)}
|
||||
|
||||
<StatGrid stats={message.stats} />
|
||||
|
||||
{message.list && <AnswerList title={message.list.title} items={message.list.items} numbered={message.list.numbered !== false} />}
|
||||
|
||||
{message.detail &&
|
||||
(message.isDoc ? (
|
||||
<Text className="dai-doc">{message.detail}</Text>
|
||||
) : (
|
||||
<Text className="dai-msg-ai-detail">{message.detail}</Text>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<VStack gap={1} padding={0}>
|
||||
<HStack className="dai-msg-foot" gap={0} padding={0}>
|
||||
<Text className="dai-msg-footer">● Live data · {message.timestamp}</Text>
|
||||
{/* 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. */}
|
||||
<button type="button" className="dai-link dai-sources" onClick={() => setShowSources((v) => !v)} aria-expanded={showSources}>
|
||||
<span>
|
||||
{showSources ? 'Hide' : 'Sources'} ({sourceCount})
|
||||
</span>
|
||||
<ChevronDown size={11} aria-hidden="true" />
|
||||
</button>
|
||||
</HStack>
|
||||
{showSources && <ChatToolCalls calls={message.sourceCalls} />}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* ---- 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 && (
|
||||
<VStack className="dai-action" gap={1} padding={0}>
|
||||
<Button label="Stop" variant="secondary" size="sm" onClick={() => onStopLive(message)} />
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* The bulk create's rows turn — a whole file in one answer. */}
|
||||
{message.flowStep?.type === 'rows' && !message.flowStep.__answered && (
|
||||
<AIRowsStep
|
||||
isBusy={message.flowStep.__answered}
|
||||
onSubmit={(parsed) => onChooseStep(message, parsed)}
|
||||
onCancel={() => onCancelAction(message)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{message.flowStep?.type === 'select' && (
|
||||
<AIFlowStep
|
||||
step={message.flowStep}
|
||||
draft={message.flowDraft}
|
||||
isBusy={message.flowStep.__answered}
|
||||
onChoose={(value, option) => onChooseStep(message, value, option)}
|
||||
onCancel={() => onCancelAction(message)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{['confirmCustomer', 'confirmOrder', 'confirmBulk', 'confirmAssign'].includes(message.form?.kind) && (
|
||||
<VStack className="dai-action" gap={1.5} padding={0}>
|
||||
{message.form.status === 'open' ? (
|
||||
<HStack gap={1.5} padding={0}>
|
||||
<Button
|
||||
label={
|
||||
message.form.confirmLabel
|
||||
? message.form.confirmLabel
|
||||
: message.form.kind === 'confirmBulk'
|
||||
? `Create ${message.form.draft?.rows?.length || 0} orders`
|
||||
: message.form.kind === 'confirmOrder'
|
||||
? 'Create order'
|
||||
: message.form.kind === 'confirmAssign'
|
||||
? 'Assign rider'
|
||||
: 'Submit'
|
||||
}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => onSubmitForm(message, message.form.payload || message.form.draft)}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" size="sm" onClick={() => onCancelAction(message)} />
|
||||
{/* A second, wider commit — used when rows were excluded by
|
||||
default but including them is a legitimate choice. */}
|
||||
{message.form.altDraft && (
|
||||
<Button
|
||||
label={message.form.altLabel || 'Include everything'}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSubmitForm(message, message.form.altDraft)}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
) : (
|
||||
<Text className={`dai-action-note${message.form.status === 'done' ? ' dai-ok' : ''}`}>
|
||||
{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.'}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{message.downloadRows?.length > 0 && (
|
||||
<VStack className="dai-action" gap={1} padding={0}>
|
||||
<Button
|
||||
label={`Download the ${message.downloadRows.length} row${message.downloadRows.length === 1 ? '' : 's'} that didn’t go through`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => downloadCsv('doormile-bulk-orders-unsendable.csv', failedRowsCsv(message.downloadRows))}
|
||||
/>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* 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. */}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
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' ? (
|
||||
<UserMessage text={message.text} />
|
||||
) : (
|
||||
<AssistantMessage
|
||||
message={message}
|
||||
onCopy={onCopy}
|
||||
onSubmitForm={onSubmitForm}
|
||||
onCancelAction={onCancelAction}
|
||||
onChooseStep={onChooseStep}
|
||||
onStopLive={onStopLive}
|
||||
/>
|
||||
);
|
||||
|
||||
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);
|
||||
Reference in New Issue
Block a user