Appearance
Action renderer
How a bot message becomes a component. One switch, one file, and a set of conventions that keep it from becoming unmaintainable.
File: defi-dex/src/component/defi/swapAI/BotMessage.tsx
Types: defi-dex/src/types/ai-chat.type.ts — IAIAction, IChatMessage
The contract
Every bot message carries { text, action, data }. action is the discriminator; data holds exactly one populated named field.
tsx
if (action === 'quotes') return <QuotesContainer quotes={message.data?.quotes} />
if (action === 'autopilot') return <AutopilotCard data={message.data?.autopilot} />
if (action === 'token_safety') return <TokenSafetyResult data={message.data?.tokenSafety} />
// …
return <ResponseCard markdown={message.text} /> // default: textFull mapping
| Action | Component | Path under swapAI/ |
|---|---|---|
quotes | QuotesContainer | quotes/QuotesContainer |
send_token | SendTransaction | SendTransaction |
portfolio | UserPortfolio | portfolio/UserPortfolio |
dca | Dca | Dca/Dca |
tx_info | TxInfo | actions/TxInfo |
tx_history | TxHistory | actions/TxHistory |
nft_info | NftInfo | actions/NftInfo |
buy_sell | BuyQuoteContainer | buy/BuyQuoteContainer |
buy_bz_token | PresaleMessage | presale/PresaleMessage |
create_dapp | DappPreview | dapp/DappPreview |
contract_audit | AuditResult | actions/AuditResult |
token_safety | TokenSafetyResult | actions/TokenSafetyResult |
wallet_card | WalletCardResult | actions/WalletCardResult |
revoke_approvals | ApprovalRevoker | actions/ApprovalRevoker |
autopilot | AutopilotCard | actions/AutopilotCard |
check_balance | Balance card | actions/ |
price_alert | Alert confirmation | actions/ |
switch_chain | Chain-switch card | actions/ |
market_intel | Market card | actions/ |
workflow | Workflow steps | actions/ |
batch | Batch list | actions/ |
limit_order | — | No component; falls through to text |
connect_wallet | ConnectWallet | ConnectWallet |
text | ResponseCard (markdown) | ResponseCard |
error, fallback | Text card | — |
history | Legacy | — |
MODIFY_DAPP maps to the create_dapp action because DappPreview handles both creation and iteration.
Card responsibilities
A card is not a passive display. Several of them own a full transaction flow.
Read-only cards
TxInfo, TxHistory, NftInfo, UserPortfolio, WalletCardResult, AuditResult, TokenSafetyResult, the market card. They render data and offer share or export affordances.
Cards that execute
| Card | What it drives |
|---|---|
QuotesContainer | Route selection → approve → simulate → send. The same pipeline as the swap page |
SendTransaction | A transfer, including username resolution |
Dca | CSIP position creation |
AutopilotCard | Create runs inline: sign policy → create agent → confirm. Management deep-links to /defi/autopilot |
ApprovalRevoker | Per-row approve(spender, 0) transactions with an idle → pending → success state machine, plus a chain-switch button |
BuyQuoteContainer | Provider comparison → hand-off to the winner's checkout |
PresaleMessage | BLAZ purchase |
DappPreview | Blob-URL iframe preview, iterate, rollback, go-onchain |
Some flows execute inline, others deep-link — and the split is deliberate
Creating something (a swap, a CSIP position, an Autopilot agent, a revoke) happens in chat, because that is the moment of intent. Managing something (editing an Autopilot policy, browsing CSIP positions) deep-links to the full page, because management needs a table and a chat bubble is the wrong shape for one.
Conventions worth keeping
One data field per action. message.data?.quotes, message.data?.autopilot, message.data?.tokenSafety. Never read two.
Optional chaining everywhere. A message persisted before an action existed will have data without the new field. message.data?.x prevents a render crash on old history.
The default is text. An unknown action renders ResponseCard with message.text. That is why LIMIT_ORDER degrades to a sentence instead of a blank bubble — and why a missing handler looks like a soft failure rather than an error.
Cards do not fetch on mount. Everything they need is in data, which came from the SSE meta event. This keeps history replay cheap: scrolling up re-renders cards from persisted data with no network calls.
Except where they must
AutopilotCard and CSIP views do poll for on-chain state, because a position's balance changes after the message was written. Position detail polls every 10 s, the list every 15 s. Those are exceptions with a reason, not the pattern.
Adding an action
Two files in defi-dex, steps 8–9 of the add-an-intent checklist:
ts
// 1. src/types/ai-chat.type.ts
export type IAIAction = … | 'my_intent'
export interface IChatMessage {
data?: {
…
myField?: IMyPayload
}
}tsx
// 2. src/component/defi/swapAI/BotMessage.tsx
if (action === 'my_intent') return <MyComponent data={message.data?.myField} />Then create the component — in actions/ for a simple card, or its own subfolder if it owns a multi-step flow.
Add the field to the backend schema too
bz-backend/v1/models/blazMessage.js's data subdocument must gain the same field, or Mongoose silently drops it on write and the card renders empty with no error anywhere.
Chat shell
Around the renderer:
| Component | Role |
|---|---|
swapAI/index.tsx | The chat page — composer, message list, SSE wiring, ?q= handling, disconnect clearing |
swapAI/SidebarBlazAi | Chat history sidebar. Drives body[data-blaz-fullscreen], which hides .blaz-nav-logo |
swapAI/ResponseCard | Markdown renderer for text replies |
component/ai/WalletControl | Top-right wallet pill (AI site) |
component/ai/PaletteTrigger + CommandPalette | ⌘K palette |
component/ai/pill.ts | AI_PILL_BASE — pins h-8 and leading-none for both pills |
The AI shell must not add a sidebar
SidebarBlazAi already renders one. A shell-level sidebar collides with it. See domains.
Layout variable
css
/* index.css */
:root { --blaz-chrome-h: 64px } /* responsive: 64 / 96 / 112 */
:root[data-site='ai'] { --blaz-chrome-h: 0 } /* specificity beats media queries */The chat column is absolutely positioned and used to hardcode defi navbar offsets (2xl:top-28 md:top-24 top-16, h-[calc(100vh-96px)]), which left ~96 px of dead space on the AI site. It now uses top-[var(--blaz-chrome-h)] and h-[calc(100svh-var(--blaz-chrome-h))].
Never hardcode a navbar offset in the chat again
That is what the variable is for.
Menus and palette panels must be opaque (bg-[#0d0d12], not /95) — the chat headline bled through translucent panels and muddied the labels.
Known limits
- No compile-time link between the action union and the switch. Adding
'my_intent'toIAIActionwithout a branch inBotMessagesilently falls through to text. - No compile-time link between the Mongoose
dataschema and the TS type. They are maintained by hand in two repos. limit_orderis a live action string with no renderer.- Old history renders with old data shapes. Cards must tolerate missing fields forever; there is no migration.