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,158 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { ArrowUp } from 'lucide-react';
import { HStack } from './shim';
import { VStack } from './shim';
import { Text } from './shim';
import { ChatDictationButton, useChatDictation } from './shim';
// ==============================|| Doormile AI — composer ||============================== //
//
// Auto-growing textarea (capped at ~5 lines by the stylesheet's max-height),
// Enter to send, Shift+Enter for a newline. Hand-rolled rather than Astryx's
// <ChatComposer> because the panel needs the compact anchored treatment and
// a circular send button, neither of which that component exposes — the
// dictation button it ships IS reused here rather than reinvented.
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);
// ---- 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
// break.
useLayoutEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, MAX_ROWS_PX)}px`;
}, [value]);
const canSend = value.trim().length > 0 && !isBusy;
const submit = () => {
if (!canSend) return;
onSubmit(value);
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
};
return (
<VStack className="dai-composer-wrap" gap={1} padding={0}>
<VStack className="dai-composer" data-focused={isFocused} gap={1} padding={0}>
<textarea
ref={textareaRef}
rows={1}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
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. */}
<HStack gap={1} padding={0} vAlign="center" justify="end">
<ChatDictationButton dictation={dictation} size="sm" className="dai-mic" />
<button
type="button"
className="dai-send"
onClick={submit}
disabled={!canSend}
aria-label={isBusy ? 'Waiting for the current answer' : 'Send message'}
>
<ArrowUp size={16} strokeWidth={2.4} aria-hidden="true" />
</button>
</HStack>
</VStack>
<Text className="dai-hint">
{dictation.isListening ? 'Listening — sends automatically after a short pause' : 'Enter to send · Shift + Enter for a new line'}
</Text>
</VStack>
);
};
AIComposer.propTypes = {
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
isBusy: PropTypes.bool,
placeholder: PropTypes.string
};
export default AIComposer;

View File

@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { VStack } from './shim';
import { HStack } from './shim';
import { Text } from './shim';
import { Button } from './shim';
import { Selector } from './shim';
// ==============================|| Doormile AI — one dropdown step ||============================== //
//
// Rendered when a conversational step is a `select`. The Create Order page uses
// dropdowns for business location, category, weight and service tier, so the
// bot does too — asking an operator to TYPE a location name they'd otherwise
// pick from a list is slower and invites a name the resolver can't match.
//
// Options load asynchronously (locations, customers and tenants are fetched
// live) so the list is never stale or invented. Two states matter and are
// distinguished rather than merged into one spinner:
//
// • loading — still fetching
// • empty — fetched successfully and there genuinely is nothing to pick,
// which is a dead end the operator has to be told about (a
// tenant with no saved locations cannot have an order created)
const AIFlowStep = ({ step, onChoose, onCancel, draft, isBusy }) => {
const [options, setOptions] = useState(null);
const [value, setValue] = useState('');
const [failed, setFailed] = useState(false);
useEffect(() => {
let alive = true;
setOptions(null);
setFailed(false);
setValue('');
Promise.resolve(step.options?.(draft) ?? [])
.then((list) => alive && setOptions(list))
.catch(() => alive && setFailed(true));
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step.id]);
if (failed) {
return (
<VStack className="dai-action" gap={1} padding={0}>
<Text className="dai-field-err">I couldn’t load the options for that. Say “cancel” and try again.</Text>
</VStack>
);
}
if (options === null) {
return (
<VStack className="dai-action" gap={1} padding={0}>
<Text className="dai-action-note">Loading options…</Text>
</VStack>
);
}
if (!options.length) {
return (
<VStack className="dai-action" gap={1.5} padding={0}>
{/* An empty list must still leave a way forward. The generic message
plus a lone Cancel is a dead end, and it is reached exactly when
something upstream failed — which is the worst moment to strip the
operator of options. A step that can also accept a typed answer
says so here via `emptyHint`. */}
<Text className="dai-field-err">
{step.id === 'pickuplocationid'
? 'This tenant has no saved business locations — add one on the Tenants page before creating an order.'
: step.emptyHint || 'There’s nothing to choose from here.'}
</Text>
<Button label="Cancel" variant="secondary" size="sm" onClick={onCancel} />
</VStack>
);
}
const chosen = options.find((o) => String(o.value) === String(value));
return (
<VStack className="dai-action" gap={1.5} padding={0}>
<Selector
label={step.ask}
isLabelHidden
placeholder="Choose one"
options={options.map((o) => ({ value: String(o.value), label: o.label }))}
value={value}
onChange={setValue}
// A long customer list is unusable without search.
hasSearch={options.length > 8}
isDisabled={isBusy}
/>
<HStack gap={1.5} padding={0}>
<Button
label="Continue"
variant="primary"
size="sm"
isDisabled={!value || isBusy}
// The whole option is passed back, not just its value — steps that
// pick a record (a location, a customer) need the record itself to
// fill several fields at once.
onClick={() => onChoose(value, chosen)}
/>
<Button label="Cancel" variant="secondary" size="sm" isDisabled={isBusy} onClick={onCancel} />
</HStack>
</VStack>
);
};
AIFlowStep.propTypes = {
step: PropTypes.shape({ id: PropTypes.string, ask: PropTypes.string, options: PropTypes.func }).isRequired,
onChoose: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
draft: PropTypes.object,
isBusy: PropTypes.bool
};
export default AIFlowStep;

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
import PropTypes from 'prop-types';
import { AlertTriangle, Info, Sparkles } from 'lucide-react';
import { HStack } from './shim';
import { VStack } from './shim';
import { Text } from './shim';
export const Spark = ({ size = 'sm' }) => (
<HStack className="dai-spark" data-size={size} padding={0} gap={0} aria-hidden="true">
<Sparkles className="text-brand w-4 h-4" />
</HStack>
);
Spark.propTypes = { size: PropTypes.oneOf(['sm', 'md', 'lg']) };
// "● Live" — subtle, not a large pill.
export const LiveIndicator = ({ label = 'Live' }) => (
<HStack className="dai-live" padding={0} gap={0} vAlign="center" aria-label="Answers are read from live operational data">
<HStack className="dai-live-dot" padding={0} gap={0} aria-hidden="true" />
{label}
</HStack>
);
LiveIndicator.propTypes = { label: PropTypes.string };
// Thinking state. Three dots plus two shimmer bars — enough to read as
// "working", not enough to be a light show.
export const TypingIndicator = () => (
<VStack gap={1.5} padding={0} aria-live="polite" aria-label="Doormile AI is thinking">
<HStack className="dai-typing" padding={0} gap={0} aria-hidden="true">
<span />
<span />
<span />
</HStack>
<VStack gap={1} padding={0} style={{ width: '100%' }} aria-hidden="true">
<HStack className="dai-shimmer" padding={0} gap={0} style={{ width: '78%' }} />
<HStack className="dai-shimmer" padding={0} gap={0} style={{ width: '52%' }} />
</VStack>
</VStack>
);
// The headline number for a counting answer — "the primary number is large".
export const Metric = ({ value, label }) => (
<VStack gap={0.25} padding={0}>
<Text className="dai-metric-value">{Number.isFinite(value) ? value.toLocaleString('en-IN') : value}</Text>
<Text className="dai-metric-label">{label}</Text>
</VStack>
);
Metric.propTypes = {
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
label: PropTypes.string.isRequired
};
// Compact breakdown grid. `color` is a status hex supplied by the caller —
// the one case the token system genuinely can't express (see CLAUDE.md §6.5),
// so it stays an inline style rather than a class.
export const StatGrid = ({ stats }) => {
if (!stats?.length) return null;
return (
<HStack className="dai-stat-grid" padding={0} gap={0} role="list">
{stats.map((s) => (
<VStack key={s.label} className="dai-stat" gap={0.25} padding={0} role="listitem">
<Text className="dai-stat-value" style={s.color ? { color: s.color } : undefined}>
{s.value}
</Text>
<Text className="dai-stat-label">{s.label}</Text>
</VStack>
))}
</HStack>
);
};
StatGrid.propTypes = {
stats: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
color: PropTypes.string
})
)
};
// Icons are looked up by KEY, never passed in as a node. A message carrying a
// React element would be destroyed by the JSON round-trip through
// localStorage (elements don't serialise — $$typeof is a Symbol and is
// silently dropped), and the rehydrated "element" would then crash the render
// on the next panel open.
const STATE_ICONS = {
warning: <AlertTriangle />,
info: <Info />
};
// A full list, rendered readably. Replaces the "…and 4 more" truncation that
// left an operator unable to see the rest of an answer at all — the body
// scrolls past ~12 rows so a long list can't stretch the panel.
export const AnswerList = ({ title, items, numbered = true }) => {
if (!items?.length) return null;
return (
<VStack className="dai-list" gap={0} padding={0}>
<HStack className="dai-list-head" padding={0} gap={0}>
<span>{title}</span>
<span>{items.length}</span>
</HStack>
<VStack className="dai-list-body" gap={0} padding={0} role="list">
{items.map((item, i) => (
<HStack className="dai-list-item" key={`${item.label}-${i}`} padding={0} gap={0} role="listitem">
{/* Numbering makes sense for "the 14 tenants"; it is noise on a
field/value dump like a submitted payload. */}
{numbered ? <span className="dai-list-index">{i + 1}</span> : null}
<span className="dai-list-label">{item.label}</span>
{item.meta ? <span className="dai-list-meta">{item.meta}</span> : null}
</HStack>
))}
</VStack>
</VStack>
);
};
AnswerList.propTypes = {
title: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({ label: PropTypes.string.isRequired, meta: PropTypes.string })),
numbered: PropTypes.bool
};
// Shared shell for the error / no-data states, so both read as the same kind
// of thing rather than two separately-invented blocks.
export const StateBlock = ({ iconKey, tone, title, body, action }) => (
<VStack className="dai-state" gap={1.5} padding={0}>
<HStack gap={1.5} padding={0} vAlign="start">
<HStack className="dai-state-icon" padding={0} gap={0} aria-hidden="true" style={{ background: `${tone}14`, color: tone }}>
{STATE_ICONS[iconKey] || STATE_ICONS.info}
</HStack>
<VStack gap={0.5} padding={0}>
<Text className="dai-state-title">{title}</Text>
<Text className="dai-state-body">{body}</Text>
</VStack>
</HStack>
{action}
</VStack>
);
StateBlock.propTypes = {
iconKey: PropTypes.oneOf(['warning', 'info']),
tone: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
body: PropTypes.string.isRequired,
action: PropTypes.node
};

View File

@@ -0,0 +1,154 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import { VStack } from './shim';
import { HStack } from './shim';
import { Text } from './shim';
import { Button } from './shim';
import { TextArea } from './shim';
import { FileInput } from './shim';
import { parseBulkRows, BULK_MAX } from '@/lib/assistant/bulkOrderActions';
import { parseBulkFile, templateCsv, downloadCsv } from '@/lib/assistant/bulkFile';
// ==============================|| Doormile AI — the rows turn ||============================== //
//
// One conversational turn that accepts a whole file. A sheet and a paste both
// produce the same row array (bulkFile.js / parseBulkRows), so this hands back
// one parse result either way and the flow doesn't care which was used.
//
// File and paste are offered together rather than behind a "file or paste?"
// question, because that question costs a turn and answers nothing the operator
// hasn't already decided by having a file or not.
//
// What is reported before the operator can continue, and why each matters:
// • rows read — so a truncated or half-empty sheet is visible immediately
// • columns NOT recognised, by name — an unread price column would otherwise
// silently re-quote every row
// • rows repeated inside the file — the bulk endpoint has no idempotency key,
// so a duplicate that gets through is a second real rider at one door
const PLACEHOLDER = `Ramesh Kumar, 9876543210, 12 Cross Cut Rd Gandhipuram, 641012, Coimbatore, 120, 2 meal boxes
Priya N, 9876500011, 45 RS Puram, 641002, Coimbatore, 95, documents`;
const AIRowsStep = ({ onSubmit, onCancel, isBusy }) => {
const [file, setFile] = useState(null);
const [fileParse, setFileParse] = useState(null);
const [fileError, setFileError] = useState(null);
const [raw, setRaw] = useState('');
const [isReading, setIsReading] = useState(false);
const pasted = raw.trim() ? parseBulkRows(raw) : null;
// A loaded file wins; clearing it falls back to whatever is in the textarea.
const parsed = fileParse || pasted;
const handleFile = async (picked) => {
const chosen = Array.isArray(picked) ? picked[0] : picked;
setFile(chosen || null);
setFileError(null);
if (!chosen) {
setFileParse(null);
return;
}
setIsReading(true);
try {
const result = await parseBulkFile(chosen);
setFileParse(result);
setRaw('');
} catch (err) {
setFileParse(null);
setFileError(err.message || 'Couldn’t read that file.');
} finally {
setIsReading(false);
}
};
return (
<VStack className="dai-action" gap={1.5} padding={0}>
<FileInput
label="Order sheet"
isLabelHidden
accept=".csv,.xls,.xlsx"
value={file}
onChange={handleFile}
isDisabled={isBusy || isReading}
/>
{!fileParse && (
<TextArea label="Or paste rows" rows={4} value={raw} onChange={setRaw} placeholder={PLACEHOLDER} isDisabled={isBusy} />
)}
{isReading && <Text className="dai-action-note">Reading the file…</Text>}
{fileError && <Text className="dai-field-err">{fileError}</Text>}
{parsed && (
<VStack gap={0.5} padding={0}>
<Text className="dai-action-note">
{fileParse ? `${fileParse.sheetName} — ` : ''}
{parsed.rows.length} complete row{parsed.rows.length === 1 ? '' : 's'}
{parsed.errors.length ? ` · ${parsed.errors.length} unusable` : ''}
</Text>
{parsed.ignoredColumns?.length > 0 && (
<Text className="dai-action-note">Columns I didn’t recognise, so didn’t use: {parsed.ignoredColumns.join(', ')}</Text>
)}
{parsed.duplicates?.length > 0 && (
<Text className="dai-field-err">
{parsed.duplicates.length} row{parsed.duplicates.length === 1 ? '' : 's'} repeat an earlier phone and address (
{parsed.duplicates.map((d) => `line ${d.line}`).join(', ')}) — they’d become separate orders.
</Text>
)}
{/* Keyed by line AND index: one row can fail several validations, so
`line` alone is not unique and React logged "two children with the
same key" for every repeat — seen live as a 1/2/3 cycle. Same
shape the list renderer in AIParts already uses. */}
{parsed.errors.slice(0, 5).map((e, i) => (
<Text key={`${e.line}-${i}`} className="dai-field-err">
Line {e.line}: {e.reason}
</Text>
))}
{parsed.errors.length > 5 && <Text className="dai-action-note">…and {parsed.errors.length - 5} more unusable rows.</Text>}
{parsed.rows.length > BULK_MAX && (
<Text className="dai-action-note">
Over {BULK_MAX} — I’ll send them in {Math.ceil(parsed.rows.length / BULK_MAX)} batches.
</Text>
)}
</VStack>
)}
<HStack gap={1.5} padding={0}>
<Button
label={parsed?.rows.length ? `Use these ${parsed.rows.length} rows` : 'Continue'}
variant="primary"
size="sm"
isDisabled={!parsed?.rows.length || isBusy || isReading}
onClick={() => onSubmit(parsed)}
/>
<Button label="Cancel" variant="secondary" size="sm" isDisabled={isBusy} onClick={onCancel} />
<Button
label="Template"
variant="ghost"
size="sm"
tooltip="Download a blank sheet with the exact column titles I read"
onClick={() => downloadCsv('doormile-bulk-orders-template.csv', templateCsv())}
/>
</HStack>
<Text className="dai-action-note">
Pasted rows: name, phone, address, pincode, city, price, description — comma, tab or pipe separated. Leave the price blank and I’ll
quote it.
</Text>
</VStack>
);
};
AIRowsStep.propTypes = {
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
isBusy: PropTypes.bool
};
export default AIRowsStep;

View File

@@ -0,0 +1,90 @@
import PropTypes from 'prop-types';
import dayjs from 'dayjs';
import { HStack } from './shim';
import { VStack } from './shim';
import { Text } from './shim';
import { CHIP_LABELS } from './pageContext';
// ==============================|| Doormile AI — welcome state ||============================== //
//
// Shown only when the thread is empty. Deliberately top-weighted and compact
// so the panel never opens on a large blank region: greeting + one line of
// what it can do, then straight into four suggestions.
const greeting = () => {
const h = dayjs().hour();
if (h < 12) return 'Good morning';
if (h < 17) return 'Good afternoon';
return 'Good evening';
};
// 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 (
<button type="button" className="dai-suggestion" onClick={() => onAsk(item.text)} aria-label={item.text} title={item.text}>
<Icon className="dai-suggestion-icon" size={13} strokeWidth={2} aria-hidden="true" />
<span className="dai-suggestion-text">{label}</span>
</button>
);
};
SuggestionChip.propTypes = {
item: PropTypes.shape({ icon: PropTypes.elementType.isRequired, text: PropTypes.string.isRequired }).isRequired,
onAsk: PropTypes.func.isRequired
};
// Exported so AIPanel can render it directly above the composer rather than
// inside the scrolling body. The chips are a way to START a message, so they
// belong with the input, not buried in the welcome copy where they scrolled
// out of reach the moment a conversation began.
export const SuggestionChips = ({ items, onAsk }) => (
<HStack className="dai-suggestions" wrap="wrap" gap={1.5} padding={0} vAlign="start">
{items.map((item) => (
<SuggestionChip key={item.text} item={item} onAsk={onAsk} />
))}
</HStack>
);
SuggestionChips.propTypes = {
items: PropTypes.array.isRequired,
onAsk: PropTypes.func.isRequired
};
const AIWelcome = () => {
return (
<VStack className="dai-welcome" gap={1.5} padding={0}>
<VStack gap={0.5} padding={0}>
<Text className="dai-welcome-greeting">{greeting()}</Text>
<Text className="dai-welcome-lead">How can I help with today&rsquo;s operations?</Text>
</VStack>
<Text className="dai-welcome-note">
I read live orders, riders, vehicles and hubs. Every number comes from a real call — I never estimate one.
</Text>
</VStack>
);
};
AIWelcome.propTypes = {
onAsk: PropTypes.func
};
export default AIWelcome;

View File

@@ -0,0 +1,39 @@
import { useCallback, useRef, useState } from 'react';
import { Bot } from 'lucide-react';
import AIPanel from './AIPanel';
import '../DoormileAI.css';
// ==============================|| Doormile AI — trigger ||============================== //
//
// Mounted in AppTopNav. Owns only the open/closed state; everything else
// lives in AIPanel. Focus returns here when the panel closes, so keyboard
// users land back where they started.
const DoormileAITrigger = () => {
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef(null);
const close = useCallback(() => {
setIsOpen(false);
buttonRef.current?.focus();
}, []);
return (
<>
<button
ref={buttonRef}
type="button"
className="dai-root dai-trigger inline-flex items-center justify-center rounded-md p-2 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
data-active={isOpen}
aria-label="Open Doormile AI"
aria-expanded={isOpen}
onClick={() => (isOpen ? close() : setIsOpen(true))}
>
<Bot className="dai-trigger-mark text-slate-700" aria-hidden="true" />
</button>
<AIPanel isOpen={isOpen} onClose={close} />
</>
);
};
export default DoormileAITrigger;

View File

@@ -0,0 +1,413 @@
import {
Clock3 as LuClock3,
Package as LuPackage,
Bike as LuBike,
Truck as LuTruck,
Building2 as LuBuilding2,
CircleDot as LuCircleDot,
Banknote as LuBanknote,
Users as LuUsers,
Layers as LuLayers,
TimerOff as LuTimerOff,
UserPlus as LuUserPlus,
PackagePlus as LuPackagePlus,
ListPlus as LuListPlus,
Repeat as LuRepeat
} from 'lucide-react';
// ==============================|| Doormile AI — page context ||============================== //
//
// Maps the current route to (a) a short context label shown under the panel
// header and (b) the suggested questions offered in the welcome state, so the
// assistant opens on questions relevant to what the operator is looking at
// rather than a fixed Orders-shaped list.
//
// Every suggestion here MUST be a phrasing the deterministic matcher in
// intents.js actually resolves — a suggestion chip that returns "I can't
// answer that yet" is worse than no chip. When adding one, check it against
// the INTENTS catalog first.
// ---------------------------------------------------------------------------
// Short chip labels.
//
// Every `text` below is the exact phrasing the deterministic matcher in
// intents.js resolves, so it CANNOT be shortened in place — that is the string
// the click actually asks. This map only changes what the chip DISPLAYS.
//
// It exists because the welcome state offers up to twenty suggestions, and as
// full sentences they are 130-255px wide in a ~393px panel: barely two fit per
// row, so the "chips" laid out as fourteen near-full-width rows and read as a
// list again. Shortened, three or four fit per row and the block becomes
// something you scan.
//
// The full question stays the accessible name and the tooltip, so nothing is
// lost for screen readers or for anyone unsure what a chip will ask. A
// suggestion with no entry here simply shows its full text.
// ---------------------------------------------------------------------------
export const CHIP_LABELS = {
"Give me today's operations summary": "Today's summary",
'How many orders today?': 'Orders today',
'Which orders are delayed?': 'Delayed orders',
'Create an order': 'Create order',
'Create multiple orders': 'Bulk orders',
'Total revenue today': 'Revenue today',
'How many pending orders today?': 'Pending today',
'How many cancelled orders today?': 'Cancelled today',
'How many delivered orders today?': 'Delivered today',
'Morning Batch orders today': 'Morning batch',
'Orders today vs yesterday': 'Today vs yesterday',
'Create a customer': 'Create customer',
'How many riders are active?': 'Active riders',
'How many vehicles are available?': 'Vehicles available',
'Current hub status': 'Hub status',
'Which hubs are experiencing delays?': 'Hub delays',
'How many tenants do we have?': 'Tenants',
'How many orders this week?': 'Orders this week',
'Total revenue this week': 'Revenue this week',
'How many customers do we have?': 'Customers',
'Assign a rider': 'Assign order',
"Repeat yesterday's orders": 'Repeat yesterday'
};
// The one follow-up phrasing that is not already a page suggestion. Verified
// against ASSIGN_TRIGGER in assignActions.js — it opens the assign-rider flow.
const ASSIGN_TEXT = 'Assign a rider';
// Recreates a previous day's run rather than building an order from scratch.
// This is the phrasing intents.js documents for it ("repeat yesterday's
// orders") and it is matched by REPEAT_TRIGGER in repeatRuns.js, which is
// ordered AHEAD of createOrder/createBulkOrders precisely so a sentence
// containing both "repeat" and "orders" recalls the run instead of opening a
// blank create form.
const REPEAT_TEXT = "Repeat yesterday's orders";
// Not a question — an OUTCOME. AIPanel sets this as the follow-up key when a
// create or repeat run actually succeeds, so the next steps offered are the
// ones that follow from an order existing, rather than the ones that follow
// from having asked to make one. Assigning a rider only makes sense on this
// side of the write.
export const ORDER_CREATED = '__orderCreated';
// The same write, but with every new order already given a rider — a repeat run
// that handed each copy back to whoever ran it yesterday.
//
// It needs its own key purely so ASSIGN_TEXT can be left out. Offering "Assign
// order" after the bot has just assigned them all presents finished work as a
// pending step, and an operator who takes the suggestion is walked into
// reassigning orders that are already out.
export const ORDER_CREATED_ASSIGNED = '__orderCreatedAssigned';
const ORDERS = {
label: 'Orders',
suggestions: [
{ icon: LuClock3, text: "Give me today's operations summary" },
{ icon: LuPackage, text: 'How many orders today?' },
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
{ icon: LuPackagePlus, text: 'Create an order' },
{ icon: LuRepeat, text: REPEAT_TEXT }
],
more: [
{ icon: LuListPlus, text: 'Create multiple orders' },
{ icon: LuBanknote, text: 'Total revenue today' },
{ icon: LuCircleDot, text: 'How many pending orders today?' },
{ icon: LuPackage, text: 'How many cancelled orders today?' },
{ icon: LuPackage, text: 'How many delivered orders today?' },
{ icon: LuLayers, text: 'Morning Batch orders today' },
{ icon: LuClock3, text: 'Orders today vs yesterday' }
]
};
const RIDERS = {
label: 'Riders',
suggestions: [
{ icon: LuBike, text: 'How many riders are active?' },
{ icon: LuClock3, text: "Give me today's operations summary" },
{ icon: LuPackage, text: 'How many orders today?' },
{ icon: LuTruck, text: 'How many vehicles are available?' }
],
more: [
{ icon: LuBuilding2, text: 'Current hub status' },
{ icon: LuBanknote, text: 'Total revenue today' }
]
};
const VEHICLES = {
label: 'Vehicles',
suggestions: [
{ icon: LuTruck, text: 'How many vehicles are available?' },
{ icon: LuBike, text: 'How many riders are active?' },
{ icon: LuBuilding2, text: 'Current hub status' },
{ icon: LuClock3, text: "Give me today's operations summary" }
],
more: [{ icon: LuPackage, text: 'How many orders today?' }]
};
const HUBS = {
label: 'Hubs',
suggestions: [
{ icon: LuBuilding2, text: 'Current hub status' },
{ icon: LuTimerOff, text: 'Which hubs are experiencing delays?' },
{ icon: LuTruck, text: 'How many vehicles are available?' },
{ icon: LuBike, text: 'How many riders are active?' }
],
more: [{ icon: LuPackage, text: 'How many orders today?' }]
};
const DISPATCH = {
label: 'Dispatch',
suggestions: [
{ icon: LuLayers, text: 'Morning Batch orders today' },
{ icon: LuTimerOff, text: 'Which orders are delayed?' },
{ icon: LuBike, text: 'How many riders are active?' },
{ icon: LuClock3, text: "Give me today's operations summary" }
],
more: [
{ icon: LuPackage, text: 'How many orders today?' },
{ icon: LuCircleDot, text: 'How many pending orders today?' }
]
};
const TENANTS = {
label: 'Tenants',
suggestions: [
{ icon: LuUsers, text: 'How many tenants do we have?' },
{ icon: LuPackage, text: 'How many orders today?' },
{ icon: LuBanknote, text: 'Total revenue today' },
{ icon: LuClock3, text: "Give me today's operations summary" }
],
more: []
};
const REPORTS = {
label: 'Reports',
suggestions: [
{ icon: LuBanknote, text: 'Total revenue today' },
{ icon: LuClock3, text: 'Orders today vs yesterday' },
{ icon: LuPackage, text: 'How many orders this week?' },
{ icon: LuClock3, text: "Give me today's operations summary" }
],
more: [{ icon: LuBanknote, text: 'Total revenue this week' }]
};
const DEFAULT_CONTEXT = {
label: 'Operations',
suggestions: [
{ icon: LuClock3, text: "Give me today's operations summary" },
{ icon: LuPackage, text: 'How many orders today?' },
{ icon: LuBike, text: 'How many riders are active?' },
{ icon: LuBuilding2, text: 'Current hub status' }
],
more: [
{ icon: LuBanknote, text: 'Total revenue today' },
{ icon: LuTruck, text: 'How many vehicles are available?' }
]
};
// ---------------------------------------------------------------------------
// Follow-ups.
//
// What the suggestion strip offers AFTER a question has been asked. Showing
// all twenty again is noise — the operator has already told you what they are
// doing, so the next set should be the natural next steps for it. Creating an
// order leads to assigning a rider and to what is still pending; asking about
// revenue leads to the week and to the day-over-day comparison.
//
// Every string here is either one of the CHIP_LABELS questions above (all of
// which the matcher already resolves) or ASSIGN_TEXT. That constraint is the
// whole point: pageContext's rule is that a suggestion which returns "I can't
// answer that yet" is worse than no suggestion, and a follow-up is MORE likely
// to be clicked than a cold one, because it arrives exactly when it is
// relevant. Do not add a phrasing here without checking it against INTENTS.
// ---------------------------------------------------------------------------
const FOLLOW_UPS = {
// --- write actions ---
// Note what is NOT here: assigning a rider.
//
// "Create an order" only STARTS the create conversation — no order exists at
// that moment, so offering to assign one is offering to act on nothing. And
// once the order really is created, AIPanel already drops the operator
// straight into the assign flow itself (see the confirmOrder branch), because
// that hand-off is mandatory rather than optional. A chip there would either
// duplicate a step the product performs on its own or fire it too early.
//
// ASSIGN_TEXT belongs on the questions below that surface orders which
// already EXIST and are waiting for a rider — and on ORDER_CREATED, which is
// keyed off the write succeeding rather than off anything that was asked.
'Create an order': [REPEAT_TEXT, 'How many pending orders today?', 'Which orders are delayed?', 'Create multiple orders'],
'Create multiple orders': ['How many pending orders today?', 'How many orders today?'],
[REPEAT_TEXT]: ['How many pending orders today?', 'How many orders today?', 'Orders today vs yesterday'],
'Create a customer': ['Create an order', 'How many customers do we have?'],
[ASSIGN_TEXT]: ['How many pending orders today?', 'Which orders are delayed?', 'How many riders are active?'],
[ORDER_CREATED]: [ASSIGN_TEXT, 'How many pending orders today?', 'Which orders are delayed?', 'Create an order'],
// No ASSIGN_TEXT: there is nothing left to assign.
[ORDER_CREATED_ASSIGNED]: ['How many riders are active?', 'How many pending orders today?', 'Which orders are delayed?', 'Create an order'],
// --- order volume ---
"Give me today's operations summary": [
'How many pending orders today?',
'Which orders are delayed?',
'Total revenue today',
'How many riders are active?'
],
'How many orders today?': [
'How many pending orders today?',
'How many delivered orders today?',
'Orders today vs yesterday',
'Total revenue today'
],
'How many pending orders today?': [ASSIGN_TEXT, 'Which orders are delayed?', 'How many delivered orders today?'],
'How many delivered orders today?': ['How many pending orders today?', 'Total revenue today', 'Orders today vs yesterday'],
'How many cancelled orders today?': ['How many delivered orders today?', 'How many orders today?', 'Which orders are delayed?'],
'Which orders are delayed?': [ASSIGN_TEXT, 'Which hubs are experiencing delays?', 'How many riders are active?'],
'Morning Batch orders today': ['Which orders are delayed?', 'How many riders are active?', 'How many orders today?'],
// --- money and trend ---
'Total revenue today': ['Total revenue this week', 'Orders today vs yesterday', 'How many orders today?'],
'Total revenue this week': ['How many orders this week?', 'Total revenue today'],
'Orders today vs yesterday': [REPEAT_TEXT, 'Total revenue today', 'How many orders this week?'],
'How many orders this week?': ['Total revenue this week', 'How many orders today?'],
// --- fleet and network ---
'How many riders are active?': ['How many vehicles are available?', 'Current hub status', ASSIGN_TEXT],
'How many vehicles are available?': ['How many riders are active?', 'Current hub status'],
'Current hub status': ['Which hubs are experiencing delays?', 'How many vehicles are available?', 'How many riders are active?'],
'Which hubs are experiencing delays?': ['Current hub status', 'Which orders are delayed?', 'How many riders are active?'],
// --- accounts ---
'How many tenants do we have?': ['How many customers do we have?', 'How many orders today?'],
'How many customers do we have?': ['Create a customer', 'How many tenants do we have?']
};
// Longest-prefix first so 'orders/create' doesn't fall through to 'orders'
// with the wrong label.
const ROUTES = [
['/doormile/dispatch', DISPATCH],
['/doormile/deliveries', { ...ORDERS, label: 'Deliveries' }],
['/doormile/orders', ORDERS],
['/doormile/riders', RIDERS],
['/doormile/vehicles', VEHICLES],
['/doormile/hubs', HUBS],
['/doormile/tenants', TENANTS],
['/doormile/reports', REPORTS],
[
'/doormile/customers',
{
label: 'Customers',
suggestions: [
{ icon: LuUserPlus, text: 'Create a customer' },
{ icon: LuUsers, text: 'How many customers do we have?' },
{ icon: LuClock3, text: "Give me today's operations summary" },
{ icon: LuPackage, text: 'How many orders today?' }
],
more: [{ icon: LuUsers, text: 'How many tenants do we have?' }]
}
],
['/doormile/tripsheets', { ...DEFAULT_CONTEXT, label: 'Tripsheets' }],
['/doormile/exceptions', { ...DEFAULT_CONTEXT, label: 'Exceptions' }],
['/doormile/pricing', { ...DEFAULT_CONTEXT, label: 'Pricing' }]
];
// ---- every question, from every page ---------------------------------------
//
// The catalog is the same wherever you are: the assistant can answer about
// orders, riders, hubs and the rest regardless of which screen is open, so
// hiding a question because you happen to be on Dispatch made it look narrower
// than it is. What the page still decides is ORDER — its own questions lead,
// then everything else follows, deduplicated by text.
//
// `more` is retired: AIWelcome renders one flat list now, so a second array
// would only be a second place for a question to hide.
const ALL_CONTEXTS = [ORDERS, RIDERS, VEHICLES, HUBS, DISPATCH, TENANTS, REPORTS, DEFAULT_CONTEXT, ...ROUTES.map(([, c]) => c)];
// Every question declared anywhere. Two jobs: it is the icon index behind
// QUESTION_BY_TEXT (so a follow-up naming a question that is no longer global
// still renders with the right icon), and it is the pool GLOBAL_SUGGESTIONS
// filters. Do not narrow it — narrow GLOBAL_TEXTS instead.
const EVERY_QUESTION = (() => {
const seen = new Set();
const out = [];
ALL_CONTEXTS.forEach((c) => {
[...(c.suggestions || []), ...(c.more || [])].forEach((q) => {
if (seen.has(q.text)) return;
seen.add(q.text);
out.push(q);
});
});
return out;
})();
// ---------------------------------------------------------------------------
// The questions that ride along on EVERY page.
//
// This used to be EVERY_QUESTION — all twenty-one, everywhere — which meant the
// Orders page offered "How many tenants do we have?" and "Current hub status"
// above the composer, questions an operator working orders is not asking. The
// strip is a shortcut, and a shortcut that lists everything is a menu.
//
// What is left is the daily operating picture: what came in, what is stuck,
// what got out, what it earned, and the three ways to put work into the system.
//
// Dropping a question from THIS list does not remove it from the assistant —
// it stays typeable, it stays a follow-up, and it still leads on its own page,
// because withGlobalQuestions puts each page's own suggestions first. Hubs
// still opens on hub questions; they just no longer follow you to Orders.
// ---------------------------------------------------------------------------
const GLOBAL_TEXTS = new Set([
"Give me today's operations summary",
'How many orders today?',
'Which orders are delayed?',
'How many pending orders today?',
'How many delivered orders today?',
'How many cancelled orders today?',
'Morning Batch orders today',
'Orders today vs yesterday',
'Total revenue today',
'How many riders are active?',
'Create an order',
REPEAT_TEXT,
'Create multiple orders'
]);
const GLOBAL_SUGGESTIONS = EVERY_QUESTION.filter((q) => GLOBAL_TEXTS.has(q.text));
// The page's own suggestions lead; the global set follows, deduplicated.
const withGlobalQuestions = (context) => {
const seen = new Set();
const suggestions = [];
[...(context.suggestions || []), ...(context.more || []), ...GLOBAL_SUGGESTIONS].forEach((q) => {
if (seen.has(q.text)) return;
seen.add(q.text);
suggestions.push(q);
});
return { ...context, suggestions, more: [] };
};
export const getPageContext = (pathname = '') => {
const match = ROUTES.find(([prefix]) => pathname.startsWith(prefix));
return withGlobalQuestions(match ? match[1] : DEFAULT_CONTEXT);
};
// ---------------------------------------------------------------------------
// Follow-ups are stored as bare strings (they have to be — they are the exact
// text that gets asked), so this turns one back into a renderable chip by
// looking its icon up among the questions already declared above. Built from
// EVERY_QUESTION rather than a second hand-written list, so a suggestion can
// never end up with an icon here that disagrees with its icon on a page.
// ---------------------------------------------------------------------------
const QUESTION_BY_TEXT = new Map(EVERY_QUESTION.map((q) => [q.text, q]));
QUESTION_BY_TEXT.set(ASSIGN_TEXT, { icon: LuBike, text: ASSIGN_TEXT });
// Returns [] for anything unrecognised — a typed question, or one with no
// natural next step. The caller falls back to the page's own suggestions, so
// the strip is never empty.
export const getFollowUps = (askedText) => {
const next = FOLLOW_UPS[askedText];
if (!next) return [];
return next.map((text) => QUESTION_BY_TEXT.get(text)).filter(Boolean);
};
// Turns arbitrary follow-up strings into chips. Used for the intent-keyed
// FOLLOW_UP_SUGGESTIONS in intents.js, whose phrasings ("What about
// yesterday?", "This week") are conversational rather than one of the declared
// questions, so they have no icon of their own.
export const toChips = (texts = []) => texts.map((text) => QUESTION_BY_TEXT.get(text) || { icon: LuCircleDot, text });

View File

@@ -0,0 +1,139 @@
import React from 'react';
import { DropdownMenu as KrowDropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
export const HStack = ({ as: Component = 'div', children, className = '', gap = 0, padding = 0, vAlign, justify, wrap, style, ...props }) => {
return (
<Component
className={`flex flex-row ${
vAlign === 'start' ? 'items-start' : vAlign === 'end' ? 'items-end' : vAlign === 'center' ? 'items-center' : 'items-stretch'
} ${
justify === 'between' ? 'justify-between' : justify === 'end' ? 'justify-end' : justify === 'center' ? 'justify-center' : 'justify-start'
} ${wrap === 'wrap' ? 'flex-wrap' : ''} ${className}`}
style={{ gap: `${gap * 0.5}rem`, padding: typeof padding === 'number' && padding > 0 ? `${padding * 0.5}rem` : undefined, ...style }}
{...props}
>
{children}
</Component>
);
};
export const VStack = ({ as: Component = 'div', children, className = '', gap = 0, padding = 0, align, justify, style, ...props }) => {
return (
<Component
className={`flex flex-col ${
align === 'start' ? 'items-start' : align === 'end' ? 'items-end' : align === 'center' ? 'items-center' : 'items-stretch'
} ${
justify === 'between' ? 'justify-between' : justify === 'end' ? 'justify-end' : justify === 'center' ? 'justify-center' : 'justify-start'
} ${className}`}
style={{ gap: `${gap * 0.5}rem`, padding: typeof padding === 'number' && padding > 0 ? `${padding * 0.5}rem` : undefined, ...style }}
{...props}
>
{children}
</Component>
);
};
export const Text = ({ as: Component = 'span', children, className = '', style, ...props }) => {
return (
<Component className={`text-sm ${className}`} style={style} {...props}>
{children}
</Component>
);
};
export const IconButton = ({ children, className = '', icon, onClick, tooltip, label, size, variant, isIconOnly, ...props }) => {
return (
<button
className={`inline-flex items-center justify-center rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors ${
size === 'sm' ? 'p-1.5' : 'p-2'
} ${isIconOnly ? 'aspect-square' : ''} ${className}`}
onClick={onClick}
title={tooltip || label}
aria-label={label}
{...props}
>
{icon || children}
</button>
);
};
export const DropdownMenu = ({ button, items }) => (
<KrowDropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton {...button} />
</DropdownMenuTrigger>
<DropdownMenuContent>
{items?.map((it, i) => (
<DropdownMenuItem key={i} onClick={it.onClick}>
{it.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</KrowDropdownMenu>
);
export const Button = ({ children, onClick, className = '' }) => (
<button
onClick={onClick}
className={`px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 ${className}`}
>
{children}
</button>
);
export const Selector = ({ value, onChange, options = [], placeholder }) => (
<select
value={value}
onChange={onChange}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="" disabled>
{placeholder || 'Select...'}
</option>
{options.map((o) => (
<option key={o.value || o} value={o.value || o}>
{o.label || o}
</option>
))}
</select>
);
export const TextArea = ({ value, onChange, placeholder, rows = 3, className = '' }) => (
<textarea
value={value}
onChange={onChange}
placeholder={placeholder}
rows={rows}
className={`block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${className}`}
/>
);
export const FileInput = ({ onChange, accept, className = '' }) => (
<input
type="file"
onChange={onChange}
accept={accept}
className={`block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 ${className}`}
/>
);
export const ChatToolCalls = ({ calls }) => (
<div className="text-xs text-gray-500 bg-gray-50 p-2 rounded">
{calls?.map((c, i) => (
<div key={i} className="mb-1">
<strong>{c.tool || 'Tool'}</strong>
<pre className="mt-1 overflow-x-auto text-[10px]">{JSON.stringify(c.args, null, 2)}</pre>
</div>
))}
</div>
);
export const useChatDictation = () => {
return { isDictating: false, startDictation: () => {}, stopDictation: () => {}, toggleDictation: () => {} };
};
export const ChatDictationButton = ({ isDictating, onToggle }) => (
<button type="button" onClick={onToggle} className="p-2 text-gray-500 hover:text-blue-500" title="Dictation unavailable">
🎤
</button>
);