Skip to content

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.tsIAIAction, 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: text

Full mapping

ActionComponentPath under swapAI/
quotesQuotesContainerquotes/QuotesContainer
send_tokenSendTransactionSendTransaction
portfolioUserPortfolioportfolio/UserPortfolio
dcaDcaDca/Dca
tx_infoTxInfoactions/TxInfo
tx_historyTxHistoryactions/TxHistory
nft_infoNftInfoactions/NftInfo
buy_sellBuyQuoteContainerbuy/BuyQuoteContainer
buy_bz_tokenPresaleMessagepresale/PresaleMessage
create_dappDappPreviewdapp/DappPreview
contract_auditAuditResultactions/AuditResult
token_safetyTokenSafetyResultactions/TokenSafetyResult
wallet_cardWalletCardResultactions/WalletCardResult
revoke_approvalsApprovalRevokeractions/ApprovalRevoker
autopilotAutopilotCardactions/AutopilotCard
check_balanceBalance cardactions/
price_alertAlert confirmationactions/
switch_chainChain-switch cardactions/
market_intelMarket cardactions/
workflowWorkflow stepsactions/
batchBatch listactions/
limit_orderNo component; falls through to text
connect_walletConnectWalletConnectWallet
textResponseCard (markdown)ResponseCard
error, fallbackText card
historyLegacy

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

CardWhat it drives
QuotesContainerRoute selection → approve → simulate → send. The same pipeline as the swap page
SendTransactionA transfer, including username resolution
DcaCSIP position creation
AutopilotCardCreate runs inline: sign policy → create agent → confirm. Management deep-links to /defi/autopilot
ApprovalRevokerPer-row approve(spender, 0) transactions with an idle → pending → success state machine, plus a chain-switch button
BuyQuoteContainerProvider comparison → hand-off to the winner's checkout
PresaleMessageBLAZ purchase
DappPreviewBlob-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:

ComponentRole
swapAI/index.tsxThe chat page — composer, message list, SSE wiring, ?q= handling, disconnect clearing
swapAI/SidebarBlazAiChat history sidebar. Drives body[data-blaz-fullscreen], which hides .blaz-nav-logo
swapAI/ResponseCardMarkdown renderer for text replies
component/ai/WalletControlTop-right wallet pill (AI site)
component/ai/PaletteTrigger + CommandPalette⌘K palette
component/ai/pill.tsAI_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' to IAIAction without a branch in BotMessage silently falls through to text.
  • No compile-time link between the Mongoose data schema and the TS type. They are maintained by hand in two repos.
  • limit_order is a live action string with no renderer.
  • Old history renders with old data shapes. Cards must tolerate missing fields forever; there is no migration.

Internal + developer documentation. Usage figures are point-in-time snapshots — re-verify before publishing them.