updates on the ui changes and changed into doormile

This commit is contained in:
2026-08-24 18:10:47 +05:30
parent dcf9770ada
commit aa42d9ee81
409 changed files with 49950 additions and 66532 deletions

View 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);