Skip to content

Wallet Agent Chat

An AI wallet assistant that answers with transaction cards.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/wallet-agent-chat.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge viem
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/color.ts
    export type Vec3 = [r: number, g: number, b: number];
    
    /**
     * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels.
     * Anything else falls back to black, so a typo never crashes a shader.
     */
    export function hexToRgb(hex: string): Vec3 {
      let value = hex.trim().replace(/^#/, "");
      if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join("");
      if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0];
      const n = Number.parseInt(value.slice(0, 6), 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }
    
    /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function hexToVec3(hex: string): Vec3 {
      const [r, g, b] = hexToRgb(hex);
      return [r / 255, g / 255, b / 255];
    }
    
    /**
     * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as
     * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables
     * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result
     * instead of calling this every frame. Browser only; unreadable values fall back to black.
     */
    export function cssColorToRgb(color: string, element: Element): Vec3 {
      const value = color.trim();
      if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value);
      // Children of a canvas still get computed styles, but a regular parent is the safer host.
      const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element;
      const probe = document.createElement("span");
      probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden";
      probe.style.color = value;
      host.appendChild(probe);
      const computed = getComputedStyle(probe).color;
      probe.remove();
      // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes.
      const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
      if (!ctx) return [0, 0, 0];
      ctx.fillStyle = "#000";
      ctx.fillStyle = computed;
      ctx.fillRect(0, 0, 1, 1);
      const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
      return [r, g, b];
    }
    
    /**
     * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or
     * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme.
     * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe.
     */
    export function observeTheme(onChange: () => void): () => void {
      const observer = new MutationObserver(onChange);
      const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
      observer.observe(document.documentElement, options);
      if (document.body) observer.observe(document.body, options);
      const scheme = window.matchMedia("(prefers-color-scheme: dark)");
      scheme.addEventListener("change", onChange);
      return () => {
        observer.disconnect();
        scheme.removeEventListener("change", onChange);
      };
    }
    
    /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function cssColorToVec3(color: string, element: Element): Vec3 {
      const [r, g, b] = cssColorToRgb(color, element);
      return [r / 255, g / 255, b / 255];
    }
    
    const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
    const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
    
    /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */
    export function rgbToOklch([r, g, b]: Vec3): Vec3 {
      const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255));
      const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
      const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
      const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
      const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
      const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
      const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
      return [L, Math.hypot(A, B), Math.atan2(B, A)];
    }
    
    /** OKLCH back to 0–255 sRGB, clamped into gamut. */
    export function oklchToRgb([L, C, h]: Vec3): Vec3 {
      const A = C * Math.cos(h);
      const B = C * Math.sin(h);
      const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
      const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
      const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
      const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
      return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3;
    }
    
    /**
     * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep
     * their chroma instead of turning muddy the way an RGB mix does.
     */
    export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 {
      const [L1, C1, h1] = rgbToOklch(a);
      const [L2, C2, h2] = rgbToOklch(b);
      // A near-gray end has no meaningful hue: borrow the other end's.
      const ha = C1 < 0.02 ? h2 : h1;
      const hb = C2 < 0.02 ? h1 : h2;
      let dh = hb - ha;
      if (dh > Math.PI) dh -= Math.PI * 2;
      if (dh < -Math.PI) dh += Math.PI * 2;
      return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]);
    }
    lib/markets/avatar.tsx
    import { cn } from "@/lib/utils";
    
    /** A generated avatar: a gradient from a hash of the name, with initials. No images needed. */
    export function MarketAvatar({ name, className }: { name: string; className?: string }) {
      let hash = 0;
      for (let i = 0; i < name.length; i++) hash = (Math.imul(hash, 31) + name.charCodeAt(i)) | 0;
      const hue = Math.abs(hash) % 360;
      const initials = name
        .replace(/^@/, "")
        .split(/[\s._-]+/)
        .map((part) => part[0])
        .join("")
        .slice(0, 2)
        .toUpperCase();
      return (
        <span
          aria-hidden="true"
          className={cn("grid size-7 shrink-0 place-items-center rounded-full text-[10px] font-semibold text-white ring-2 ring-background", className)}
          style={{ background: `linear-gradient(135deg, hsl(${hue} 70% 55%), hsl(${(hue + 40) % 360} 70% 40%))` }}
        >
          {initials}
        </span>
      );
    }
    
    /** A small "Simulated" badge for demos that use mock data. */
    export function SimulatedBadge({ className }: { className?: string }) {
      return (
        <span className={cn("inline-flex h-5 items-center gap-1 rounded-full border border-warning/30 bg-warning/10 px-2 text-[11px] font-medium text-[color-mix(in_oklab,var(--warning)_75%,var(--foreground))]", className)}>
          <span aria-hidden="true" className="size-1.5 rounded-full bg-warning" />
          Simulated
        </span>
      );
    }
    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
    lib/usage/wallet-agent.ts
    import { useMemo } from "react";
    import { getAddress } from "viem";
    import { formatAmount, formatUsd, parseAmount, toUsd, USD_DECIMALS } from "@/lib/web3/format";
    import type { Token } from "@/lib/web3/types";
    
    /**
     * A scripted wallet agent for prototypes and demos. It parses three simple intents with regular
     * expressions and returns the steps, the reply text and a transaction card to review. It never
     * talks to a wallet, an RPC or a model, and nothing it returns is signed. All amounts are bigint.
     */
    
    /** A token the agent can move: its balance in token units and its USD price (USD_DECIMALS). */
    export interface AgentToken extends Token {
      balance: bigint;
      price: bigint;
    }
    
    /** A saved contact, or a spender for approvals. */
    export interface AgentContact {
      name: string;
      address: string;
    }
    
    interface CardBase {
      id: string;
      /** Network fee in USD with USD_DECIMALS. */
      networkFeeUsd: bigint;
    }
    
    export interface SwapCard extends CardBase {
      kind: "swap";
      from: AgentToken;
      to: AgentToken;
      amountIn: bigint;
      amountOut: bigint;
      /** Hops by symbol, e.g. ["ARB", "ETH", "USDC"]. */
      route: string[];
      feeBps: number;
      priceImpactBps: number;
    }
    
    export interface SendCard extends CardBase {
      kind: "send";
      token: AgentToken;
      amount: bigint;
      to: AgentContact;
    }
    
    export interface ApproveCard extends CardBase {
      kind: "approve";
      token: AgentToken;
      spender: AgentContact;
      /** Allowance in token units, or null for an unlimited allowance. */
      amount: bigint | null;
    }
    
    export type AgentActionCard = SwapCard | SendCard | ApproveCard;
    
    export interface AgentReply {
      /** Tool steps shown one by one, e.g. "Checking balance". */
      steps: string[];
      /** The reply text, streamed in after the steps. */
      text: string;
      /** A transaction to review, if the request made one. */
      card?: AgentActionCard;
      /** Follow-up prompts offered as chips. */
      suggestions?: string[];
    }
    
    export interface WalletAgent {
      /** Answers a prompt with a scripted reply. */
      respond: (prompt: string) => AgentReply;
    }
    
    const FEE_BPS = 30;
    const usd = (dollars: string) => parseAmount(dollars, USD_DECIMALS) ?? BigInt(0);
    const NETWORK_FEE = { swap: usd("4.18"), send: usd("1.12"), approve: usd("0.86") };
    
    /** A deterministic, checksummed placeholder address for a name (not a real account). */
    export function placeholderAddress(seed: string) {
      let hex = "";
      for (let round = 0; hex.length < 40; round++) {
        let hash = 0x811c9dc5;
        for (const char of `${round}:${seed}`) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193);
        hash ^= hash >>> 15;
        hex += (hash >>> 0).toString(16).padStart(8, "0");
      }
      return getAddress(`0x${hex.slice(0, 40)}`);
    }
    
    /**
     * A quote from USD prices: a 0.3% fee plus price impact that grows with size (1 bp per $1,000,
     * capped at 25%). `jitterBps` nudges the output to simulate a refreshed quote.
     */
    export function quoteSwap(from: AgentToken, to: AgentToken, amountIn: bigint, jitterBps = 0) {
      const usdIn = toUsd(amountIn, from.decimals, from.price);
      const thousand = BigInt(1000) * BigInt(10) ** BigInt(USD_DECIMALS);
      const impact = usdIn / thousand > BigInt(2500) ? 2500 : Number(usdIn / thousand);
      const usdOut = (usdIn * BigInt(10_000 - FEE_BPS - impact + jitterBps)) / BigInt(10_000);
      const amountOut = to.price > BigInt(0) ? (usdOut * BigInt(10) ** BigInt(to.decimals)) / to.price : BigInt(0);
      const stable = (t: Token) => ["USDC", "USDT", "DAI"].includes(t.symbol);
      const direct = from.symbol === "ETH" || to.symbol === "ETH" || (stable(from) && stable(to));
      return { amountOut, priceImpactBps: impact, route: direct ? [from.symbol, to.symbol] : [from.symbol, "ETH", to.symbol] };
    }
    
    const amountText = (token: AgentToken, value: bigint) => `${formatAmount(value, token.decimals)} ${token.symbol}`;
    const usdText = (token: AgentToken, value: bigint) => formatUsd(toUsd(value, token.decimals, token.price));
    
    let counter = 0;
    const nextId = () => `card-${Date.now().toString(36)}-${(counter++).toString(36)}`;
    
    const SWAP = /^(?:swap|trade|convert|exchange)\s+([\d.,]+|all|max)\s+([a-z0-9]+)\s+(?:to|for|into)\s+([a-z0-9]+)\b/i;
    const SEND = /^(?:send|pay|transfer)\s+([\d.,]+)\s+([a-z0-9]+)\s+to\s+(.+?)\.?$/i;
    const APPROVE = /^(?:approve|allow)\s+(unlimited|[\d.,]+)\s+([a-z0-9]+)\s+(?:for|to)\s+(.+?)\.?$/i;
    
    /** Builds a scripted agent over the given tokens and contacts. */
    export function createMockWalletAgent(tokens: AgentToken[], contacts: AgentContact[]): WalletAgent {
      const find = (symbol: string) => tokens.find((t) => t.symbol.toLowerCase() === symbol.toLowerCase());
      const help: AgentReply = {
        steps: ["Reading your request"],
        text: "I can prepare three kinds of transactions: a swap (\"swap 0.5 ETH to USDC\"), a payment to a saved contact (\"send 120 USDC to Maya\") or a token allowance (\"approve 500 USDC for Tidepool Router\"). Each one comes back as a card you review; nothing is signed until you approve it.",
        suggestions: ["Swap 0.5 ETH to USDC", `Send 50 USDC to ${contacts[0]?.name ?? "Maya"}`, "Approve 500 USDC for Tidepool Router"],
      };
      const unknownToken = (symbol: string): AgentReply => ({
        steps: ["Reading your request", "Looking up tokens"],
        text: `I couldn't find ${symbol.toUpperCase()} in this wallet. You hold ${tokens
          .filter((t) => t.balance > BigInt(0))
          .slice(0, 5)
          .map((t) => t.symbol)
          .join(", ")}.`,
        suggestions: tokens.slice(0, 2).length === 2 ? [`Swap 10 ${tokens[1].symbol} to ${tokens[0].symbol}`] : [],
      });
    
      return {
        respond(prompt) {
          const text = prompt.trim();
    
          const swap = text.match(SWAP);
          if (swap) {
            const from = find(swap[2]);
            const to = find(swap[3]);
            if (!from) return unknownToken(swap[2]);
            if (!to) return unknownToken(swap[3]);
            if (from.symbol === to.symbol) return { steps: ["Reading your request"], text: `${from.symbol} to ${to.symbol} is the same token, so there is nothing to swap.`, suggestions: [`Swap 0.1 ${from.symbol} to ${tokens.find((t) => t !== from)?.symbol ?? "USDC"}`] };
            const amountIn = /^(all|max)$/i.test(swap[1]) ? from.balance : (parseAmount(swap[1], from.decimals) ?? BigInt(0));
            if (amountIn <= BigInt(0)) return help;
            if (amountIn > from.balance) {
              return {
                steps: ["Checking balance"],
                text: `You have ${amountText(from, from.balance)}, which is less than the ${amountText(from, amountIn)} you asked to swap. I can prepare a swap for your full balance instead.`,
                suggestions: [`Swap max ${from.symbol} to ${to.symbol}`],
              };
            }
            const quote = quoteSwap(from, to, amountIn);
            return {
              steps: ["Checking balance", "Fetching quote", "Simulating transaction"],
              text: `Here's the best route I found: ${amountText(from, amountIn)} (${usdText(from, amountIn)}) for about ${amountText(to, quote.amountOut)}${quote.route.length > 2 ? ` through ${quote.route[1]}` : ""}. The quote refreshes every 15 seconds while you review it.`,
              card: { kind: "swap", id: nextId(), from, to, amountIn, amountOut: quote.amountOut, route: quote.route, feeBps: FEE_BPS, priceImpactBps: quote.priceImpactBps, networkFeeUsd: NETWORK_FEE.swap },
              suggestions: [`Send 25 ${to.symbol} to ${contacts[0]?.name ?? "Maya"}`, `Swap 0.1 ${to.symbol === "ETH" ? "USDC" : "ETH"} to ${to.symbol === "ETH" ? "ETH" : to.symbol}`],
            };
          }
    
          const send = text.match(SEND);
          if (send) {
            const token = find(send[2]);
            if (!token) return unknownToken(send[2]);
            const name = send[3].trim();
            const contact = contacts.find((c) => c.name.toLowerCase() === name.toLowerCase());
            if (!contact) {
              return {
                steps: ["Reading your request", "Searching contacts"],
                text: `I don't have a saved contact named ${name}. To keep payments safe I only send to saved contacts: ${contacts.map((c) => c.name).join(", ")}.`,
                suggestions: contacts.slice(0, 2).map((c) => `Send ${send[1]} ${token.symbol} to ${c.name}`),
              };
            }
            const amount = parseAmount(send[1], token.decimals) ?? BigInt(0);
            if (amount <= BigInt(0)) return help;
            if (amount > token.balance) {
              return { steps: ["Checking balance"], text: `You have ${amountText(token, token.balance)}, so I can't send ${amountText(token, amount)}.`, suggestions: [`Swap 0.5 ETH to ${token.symbol}`] };
            }
            return {
              steps: ["Checking balance", "Resolving contact", "Simulating transaction"],
              text: `Ready to send ${amountText(token, amount)} (${usdText(token, amount)}) to ${contact.name}. Check the address on the card before you approve.`,
              card: { kind: "send", id: nextId(), token, amount, to: contact, networkFeeUsd: NETWORK_FEE.send },
              suggestions: [`Send 10 ${token.symbol} to ${contacts.find((c) => c !== contact)?.name ?? contact.name}`, "Swap 0.25 ETH to USDC"],
            };
          }
    
          const approve = text.match(APPROVE);
          if (approve) {
            const token = find(approve[2]);
            if (!token) return unknownToken(approve[2]);
            const name = approve[3].trim();
            const spender = contacts.find((c) => c.name.toLowerCase() === name.toLowerCase()) ?? { name, address: placeholderAddress(`spender:${name.toLowerCase()}`) };
            const unlimited = /^unlimited$/i.test(approve[1]);
            const amount = unlimited ? null : (parseAmount(approve[1], token.decimals) ?? BigInt(0));
            return {
              steps: ["Reading your request", "Checking current allowance", "Simulating transaction"],
              text: unlimited
                ? `This lets ${spender.name} spend all of your ${token.symbol}, now and later. I'd suggest a cap instead; you can set one on the card.`
                : `This lets ${spender.name} spend up to ${amountText(token, amount ?? BigInt(0))}. You can lower or revoke it at any time.`,
              card: { kind: "approve", id: nextId(), token, spender, amount, networkFeeUsd: NETWORK_FEE.approve },
              suggestions: [`Swap 100 ${token.symbol} to ETH`],
            };
          }
    
          return help;
        },
      };
    }
    
    /** `createMockWalletAgent` as a hook, rebuilt only when the tokens or contacts change. */
    export function useMockWalletAgent({ tokens, contacts }: { tokens: AgentToken[]; contacts: AgentContact[] }) {
      return useMemo(() => createMockWalletAgent(tokens, contacts), [tokens, contacts]);
    }
    lib/web3/format.ts
    import { formatUnits, getAddress, isAddress, parseUnits } from "viem";
    
    /**
     * Formatting for on-chain values. Amounts are always `bigint` plus `decimals`: nothing here
     * converts a balance to a float, so large or precise values never lose digits.
     */
    
    /** Decimals used for USD values and prices across the web3 components (like Chainlink feeds). */
    export const USD_DECIMALS = 8;
    
    const COMPACT_UNITS = [
      { suffix: "T", exponent: 12 },
      { suffix: "B", exponent: 9 },
      { suffix: "M", exponent: 6 },
      { suffix: "K", exponent: 3 },
    ] as const;
    
    function groupThousands(integer: string) {
      return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
    }
    
    function compact(value: bigint, decimals: number): string | null {
      const base = BigInt(10) ** BigInt(decimals);
      for (const { suffix, exponent } of COMPACT_UNITS) {
        const unit = base * BigInt(10) ** BigInt(exponent);
        if (value < unit) continue;
        const tenths = (value * BigInt(10)) / unit;
        const whole = tenths / BigInt(10);
        const fraction = tenths % BigInt(10);
        return `${groupThousands(whole.toString())}${fraction && whole < BigInt(100) ? `.${fraction}` : ""}${suffix}`;
      }
      return null;
    }
    
    export interface FormatAmountOptions {
      /** Most fraction digits shown; extra digits are cut, never rounded up. @default 4 */
      maxFractionDigits?: number;
      /** Fraction digits always shown (padded with zeros). @default 0 */
      minFractionDigits?: number;
      /** Abbreviate values of 1,000 and up: 1.2K, 3.4M, 5B. @default false */
      compact?: boolean;
      /** Prefix positive values with "+". @default false */
      signed?: boolean;
    }
    
    /**
     * Formats a token amount: `formatAmount(parseUnits("1.23456789", 18), 18)` → "1.2345".
     * Digits past `maxFractionDigits` are truncated so a balance is never overstated, and a tiny
     * non-zero amount shows as "<0.0001" instead of "0".
     */
    export function formatAmount(value: bigint, decimals: number, options: FormatAmountOptions = {}) {
      const { maxFractionDigits = 4, minFractionDigits = 0, compact: useCompact = false, signed = false } = options;
      const negative = value < BigInt(0);
      const abs = negative ? -value : value;
      const sign = negative ? "-" : signed && abs > BigInt(0) ? "+" : "";
    
      const short = useCompact ? compact(abs, decimals) : null;
      if (short) return sign + short;
    
      const [integer, fraction = ""] = formatUnits(abs, decimals).split(".");
      let digits = fraction.slice(0, maxFractionDigits).replace(/0+$/, "");
      if (digits.length < minFractionDigits) digits = digits.padEnd(minFractionDigits, "0");
    
      if (abs > BigInt(0) && integer === "0" && !/[1-9]/.test(digits)) {
        return `${sign}<0.${"0".repeat(Math.max(0, maxFractionDigits - 1))}1`;
      }
      return `${sign}${groupThousands(integer)}${digits ? `.${digits}` : ""}`;
    }
    
    /** USD value of a token amount, as a bigint with `USD_DECIMALS` decimals. `price` uses the same decimals. */
    export function toUsd(amount: bigint, tokenDecimals: number, price: bigint) {
      return (amount * price) / BigInt(10) ** BigInt(tokenDecimals);
    }
    
    /** Formats a USD bigint (default `USD_DECIMALS`): "$1,234.56", or "$1.2M" with `compact`. */
    export function formatUsd(value: bigint, decimals = USD_DECIMALS, options: Omit<FormatAmountOptions, "maxFractionDigits"> = {}) {
      const { compact: useCompact = false, signed = false } = options;
      const negative = value < BigInt(0);
      const abs = negative ? -value : value;
      const sign = negative ? "-" : signed && abs > BigInt(0) ? "+" : "";
      // Under a cent, keep up to 6 significant fraction digits so token prices like $0.000042 stay readable.
      const tiny = abs > BigInt(0) && abs < BigInt(10) ** BigInt(Math.max(0, decimals - 2));
      const body = formatAmount(abs, decimals, {
        compact: useCompact,
        maxFractionDigits: tiny ? Math.min(decimals, 8) : 2,
        minFractionDigits: tiny ? 0 : 2,
      });
      return `${sign}$${body}`;
    }
    
    /** Formats a ratio as a percentage: `formatPercent(0.0123)` → "1.23%". */
    export function formatPercent(ratio: number, options: { fractionDigits?: number; signed?: boolean } = {}) {
      const { fractionDigits = 2, signed = false } = options;
      if (!Number.isFinite(ratio)) return "—";
      const text = `${(Math.abs(ratio) * 100).toFixed(fractionDigits)}%`;
      if (ratio < 0) return `-${text}`;
      return signed && ratio > 0 ? `+${text}` : text;
    }
    
    /** Formats basis points (1 bp = 0.01%): `formatBps(BigInt(50))` → "0.5%". */
    export function formatBps(bps: bigint | number) {
      const value = BigInt(bps);
      const abs = value < BigInt(0) ? -value : value;
      const fraction = abs % BigInt(100);
      return `${value < BigInt(0) ? "-" : ""}${abs / BigInt(100)}${fraction ? `.${fraction.toString().padStart(2, "0").replace(/0$/, "")}` : ""}%`;
    }
    
    /** Compact display for plain counts and non-token numbers: 1200 → "1.2K". */
    export function compactNumber(value: number | bigint) {
      return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(value);
    }
    
    /** EIP-55 checksummed address, or the input unchanged when it isn't a valid address. */
    export function checksumAddress(address: string) {
      return isAddress(address, { strict: false }) ? getAddress(address) : address;
    }
    
    /** "0x1234…abcd": checksummed, with `start` characters (including 0x) and `end` characters kept. */
    export function truncateAddress(address: string, { start = 6, end = 4 }: { start?: number; end?: number } = {}) {
      const value = checksumAddress(address);
      if (value.length <= start + end + 1) return value;
      return `${value.slice(0, start)}…${value.slice(-end)}`;
    }
    
    /**
     * Parses user input into a bigint amount. Accepts digits with one "." (commas are ignored),
     * cuts fraction digits past `decimals`, and returns null for anything that isn't a number.
     */
    export function parseAmount(input: string, decimals: number): bigint | null {
      const text = input.replace(/,/g, "").trim();
      if (!/^\d*\.?\d*$/.test(text) || text === "" || text === ".") return null;
      const [integer, fraction = ""] = text.split(".");
      return parseUnits(`${integer || "0"}.${fraction.slice(0, decimals) || "0"}`, decimals);
    }
    
    /** A percentage of an amount, in whole percent: `percentOf(balance, 25)`. Rounds down. */
    export function percentOf(amount: bigint, percent: number) {
      return (amount * BigInt(Math.round(percent * 100))) / BigInt(10_000);
    }
    lib/web3/icons.tsx
    "use client";
    
    import { useId, type ComponentProps } from "react";
    import { hexToRgb } from "@/lib/color";
    import { cn } from "@/lib/utils";
    
    /**
     * Generated avatars for tokens, chains and accounts. No third-party logos are bundled:
     * pass `iconUrl` (tokens, chains) or `src` (accounts) to show your own artwork.
     */
    
    const PALETTE = ["#6366F1", "#2563EB", "#0EA5E9", "#14B8A6", "#16A34A", "#F59E0B", "#EF4444", "#EC4899", "#8B5CF6", "#64748B"];
    
    function hashString(value: string) {
      let hash = 0x811c9dc5;
      for (const char of value) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193);
      return hash >>> 0;
    }
    
    /** Text color with enough contrast on a fill (relative luminance threshold). */
    function inkFor(fill: string) {
      const [r, g, b] = hexToRgb(fill).map((c) => {
        const s = c / 255;
        return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
      });
      return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.45 ? "#09090B" : "#FFFFFF";
    }
    
    function Monogram({ text, fill, size, shape, className }: { text: string; fill: string; size: number; shape: "circle" | "squircle"; className?: string }) {
      const letters = text.slice(0, 4);
      const fontSize = letters.length <= 2 ? 13 : letters.length === 3 ? 10 : 8.5;
      return (
        <svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" className={cn("shrink-0", className)}>
          {shape === "circle" ? <circle cx="16" cy="16" r="16" fill={fill} /> : <rect width="32" height="32" rx="9" fill={fill} />}
          <text
            x="16"
            y="16"
            dy="0.35em"
            textAnchor="middle"
            fontSize={fontSize}
            fontWeight={700}
            fill={inkFor(fill)}
            style={{ fontFamily: "inherit", letterSpacing: letters.length > 2 ? "-0.02em" : undefined }}
          >
            {letters}
          </text>
        </svg>
      );
    }
    
    function ImageIcon({ src, size, rounded, className }: { src: string; size: number; rounded: string; className?: string }) {
      // eslint-disable-next-line @next/next/no-img-element -- consumers pass arbitrary logo URLs
      return <img src={src} alt="" width={size} height={size} className={cn("shrink-0 object-cover", rounded, className)} />;
    }
    
    export interface TokenIconProps {
      token: { symbol: string; iconUrl?: string; color?: string };
      /** Pixel size. @default 24 */
      size?: number;
      className?: string;
    }
    
    /** A token's logo, or a colored circle with its symbol. Decorative: pair it with visible text. */
    export function TokenIcon({ token, size = 24, className }: TokenIconProps) {
      if (token.iconUrl) return <ImageIcon src={token.iconUrl} size={size} rounded="rounded-full" className={className} />;
      const fill = token.color ?? PALETTE[hashString(token.symbol) % PALETTE.length];
      return <Monogram text={token.symbol} fill={fill} size={size} shape="circle" className={className} />;
    }
    
    export interface ChainIconProps {
      chain: { name: string; shortName?: string; iconUrl?: string; color?: string };
      /** Pixel size. @default 20 */
      size?: number;
      className?: string;
    }
    
    /** A chain's logo, or a rounded square with its initial. Decorative: pair it with visible text. */
    export function ChainIcon({ chain, size = 20, className }: ChainIconProps) {
      if (chain.iconUrl) return <ImageIcon src={chain.iconUrl} size={size} rounded="rounded-[28%]" className={className} />;
      const fill = chain.color ?? PALETTE[hashString(chain.name) % PALETTE.length];
      return <Monogram text={(chain.shortName ?? chain.name).charAt(0)} fill={fill} size={size} shape="squircle" className={className} />;
    }
    
    export interface WalletIconProps {
      wallet: { name: string; iconUrl?: string; color?: string };
      /** Pixel size. @default 32 */
      size?: number;
      className?: string;
    }
    
    /** A wallet's logo, or a rounded square with its initial. Decorative: pair it with visible text. */
    export function WalletIcon({ wallet, size = 32, className }: WalletIconProps) {
      if (wallet.iconUrl) return <ImageIcon src={wallet.iconUrl} size={size} rounded="rounded-[28%]" className={className} />;
      const fill = wallet.color ?? PALETTE[hashString(wallet.name) % PALETTE.length];
      return <Monogram text={wallet.name.charAt(0)} fill={fill} size={size} shape="squircle" className={className} />;
    }
    
    export interface AddressAvatarProps extends Omit<ComponentProps<"svg">, "children"> {
      address: string;
      /** Pixel size. @default 24 */
      size?: number;
    }
    
    /** A deterministic gradient avatar for an address: same address, same avatar, on every device. */
    export function AddressAvatar({ address, size = 24, className, ...props }: AddressAvatarProps) {
      const id = useId();
      const hex = address.toLowerCase().replace(/^0x/, "").padEnd(40, "0");
      const byte = (i: number) => Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) || 0;
      const hue = Math.round((byte(0) / 255) * 360);
      const hue2 = (hue + 40 + Math.round((byte(1) / 255) * 120)) % 360;
      const angle = Math.round((byte(2) / 255) * 360);
      return (
        <svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" className={cn("shrink-0 rounded-full", className)} {...props}>
          <defs>
            <linearGradient id={`${id}g`} gradientTransform={`rotate(${angle} 0.5 0.5)`}>
              <stop offset="0" stopColor={`hsl(${hue} 85% 64%)`} />
              <stop offset="1" stopColor={`hsl(${hue2} 80% 52%)`} />
            </linearGradient>
            <clipPath id={`${id}c`}>
              <circle cx="16" cy="16" r="16" />
            </clipPath>
          </defs>
          <g clipPath={`url(#${id}c)`}>
            <rect width="32" height="32" fill={`url(#${id}g)`} />
            <circle cx={6 + (byte(3) % 20)} cy={6 + (byte(4) % 20)} r={6 + (byte(5) % 6)} fill="#fff" opacity="0.32" />
            <circle cx={6 + (byte(6) % 20)} cy={6 + (byte(7) % 20)} r={3 + (byte(8) % 5)} fill="#fff" opacity="0.22" />
          </g>
        </svg>
      );
    }
    lib/web3/rolling-value.tsx
    "use client";
    
    import { useEffect, useState } from "react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { cn } from "@/lib/utils";
    
    const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
    const REEL = { type: "spring", stiffness: 160, damping: 22, mass: 0.9 } as const;
    const MASK = "linear-gradient(to bottom, transparent, #000 22%, #000 78%, transparent)";
    
    function Reel({ digit, reduceMotion }: { digit: number; reduceMotion: boolean }) {
      return (
        <span className="relative inline-block [clip-path:inset(0)]" style={{ maskImage: MASK, WebkitMaskImage: MASK }}>
          <span className="invisible">0</span>
          <motion.span
            className="absolute inset-x-0 top-0 flex flex-col items-center"
            initial={false}
            animate={{ y: `${-digit * 10}%` }}
            transition={reduceMotion ? { duration: 0 } : REEL}
          >
            {DIGITS.map((n) => (
              <span key={n}>{n}</span>
            ))}
          </motion.span>
        </span>
      );
    }
    
    export interface RollingValueProps {
      /** Pre-formatted text, e.g. "$1,234.56" or "2.4815". Digits roll; everything else stays put. */
      value: string;
      /**
       * The raw value behind the text (a bigint amount or a number). When it changes, the text
       * flashes green for an increase and red for a decrease.
       */
      trend?: bigint | number;
      /** Flash on increases and decreases. @default true */
      flash?: boolean;
      className?: string;
    }
    
    type Flash = { direction: "up" | "down"; key: number };
    
    /**
     * Rolling digits for pre-formatted strings, so bigint amounts animate without ever becoming
     * floats. Uses tabular figures; screen readers get the plain text.
     */
    export function RollingValue({ value, trend, flash = true, className }: RollingValueProps) {
      const reduceMotion = useReducedMotion() ?? false;
      const [previous, setPrevious] = useState(trend);
      const [pulse, setPulse] = useState<Flash | null>(null);
    
      if (trend !== previous) {
        setPrevious(trend);
        if (flash && trend !== undefined && previous !== undefined) {
          setPulse({ direction: trend > previous ? "up" : "down", key: (pulse?.key ?? 0) + 1 });
        }
      }
    
      useEffect(() => {
        if (!pulse) return;
        const timer = window.setTimeout(() => setPulse(null), 900);
        return () => window.clearTimeout(timer);
      }, [pulse]);
    
      // Key characters by their distance from the end, so a given place value keeps its reel.
      const chars = [...value];
      return (
        <span
          className={cn(
            "relative inline-flex tabular-nums transition-colors duration-300",
            pulse?.direction === "up" && "text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))]",
            pulse?.direction === "down" && "text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]",
            className,
          )}
        >
          <span className="sr-only">{value}</span>
          <AnimatePresence>
            {pulse && !reduceMotion ? (
              <motion.span
                key={pulse.key}
                aria-hidden="true"
                className={cn(
                  "pointer-events-none absolute -inset-x-1 inset-y-0 rounded-[4px]",
                  pulse.direction === "up" ? "bg-success/12" : "bg-destructive/12",
                )}
                initial={{ opacity: 1 }}
                animate={{ opacity: 0 }}
                exit={{ opacity: 0 }}
                transition={{ duration: 0.9, ease: "easeOut" }}
              />
            ) : null}
          </AnimatePresence>
          <span aria-hidden="true" className="relative inline-flex select-none">
            {chars.map((char, index) => {
              const key = chars.length - index;
              return /\d/.test(char) ? <Reel key={key} digit={Number(char)} reduceMotion={reduceMotion} /> : <span key={`${key}${char}`}>{char}</span>;
            })}
          </span>
        </span>
      );
    }
    lib/web3/types.ts
    /** Shapes the web3 components accept. Your own token and chain objects only need these fields. */
    
    export interface Token {
      symbol: string;
      name?: string;
      /** Token decimals: 18 for ETH, 6 for USDC. Amounts are bigint in these units. */
      decimals: number;
      address?: string;
      /** Logo URL. Without one, a generated avatar (colored circle and symbol) is shown. */
      iconUrl?: string;
      /** Avatar color for the generated icon. Picked from the symbol when omitted. */
      color?: string;
      /** USD price as a bigint with USD_DECIMALS (8) decimals. */
      price?: bigint;
    }
    
    export interface Chain {
      id: number;
      name: string;
      shortName?: string;
      iconUrl?: string;
      color?: string;
      /** Block explorer base URL without a trailing slash, e.g. https://etherscan.io */
      explorerUrl?: string;
      nativeSymbol?: string;
    }
    
    export interface WalletOption {
      id: string;
      name: string;
      /** Logo URL. Without one, a generated squircle with the wallet's initial is shown. */
      iconUrl?: string;
      color?: string;
      /** Shows a "Recent" badge. */
      recent?: boolean;
      /** The wallet is detected in this browser. */
      installed?: boolean;
      /** Connects by scanning a QR code with a phone instead of a browser extension. */
      qr?: boolean;
      /** Short helper line under the name. */
      description?: string;
    }
    
    /** Lifecycle of anything the user signs: every state gets its own visual. */
    export type TxStatus = "idle" | "awaiting-signature" | "pending" | "confirmed" | "failed";
    
    /** Explorer URL for an address or transaction, or undefined without an explorer. */
    export function explorerLink(explorerUrl: string | undefined, kind: "address" | "tx", value: string) {
      return explorerUrl ? `${explorerUrl.replace(/\/$/, "")}/${kind}/${value}` : undefined;
    }
  3. Add the theme tokens

    This component uses --destructive-text, --link, --success, and --warning, which shadcn does not define by default. Add them to your global CSS (--destructive-text and --link fall back to your --destructive and --primary unless you set them):

    app/globals.css
    :root {
      --success: #15803d;
      --warning: #b45309;
    }
    
    .dark {
      --success: #4ade80;
      --warning: #fbbf24;
    }
    
    @theme inline {
      --color-destructive-text: var(--destructive-text, var(--destructive));
      --color-link: var(--link, var(--primary));
      --color-success: var(--success);
      --color-warning: var(--warning);
    }
  4. Copy the source

    components/ui/wallet-agent-chat/wallet-agent-chat.tsx
    "use client";
    
    import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
    import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react";
    import { ArrowDown, ArrowRight, Cpu, ExternalLink, RotateCcw, ShieldAlert } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { duration, ease, spring, stagger } from "@/lib/motion";
    import { AddressPill } from "@/components/ui/address-pill/address-pill";
    import { AgentSteps, type AgentStep } from "@/components/ui/agent-steps/agent-steps";
    import { AIPromptInput } from "@/components/ui/ai-prompt-input/ai-prompt-input";
    import { StreamingText, useTextStream } from "@/components/ui/streaming-text/streaming-text";
    import { SimulatedBadge } from "@/lib/markets/avatar";
    import { AddressAvatar, TokenIcon } from "@/lib/web3/icons";
    import { formatAmount, formatBps, formatUsd, toUsd, truncateAddress } from "@/lib/web3/format";
    import { RollingValue } from "@/lib/web3/rolling-value";
    import { explorerLink } from "@/lib/web3/types";
    import {
      quoteSwap,
      useMockWalletAgent,
      type AgentActionCard,
      type AgentContact,
      type AgentReply,
      type AgentToken,
      type WalletAgent,
    } from "@/lib/usage/wallet-agent";
    
    export interface StarterPrompt {
      /** Card title, e.g. "Swap tokens". */
      title: string;
      /** The prompt sent when the card is picked. */
      prompt: string;
    }
    
    export interface WalletAgentChatProps {
      /** Name shown in the header. @default "Atlas" */
      agentName?: string;
      /** The connected address. */
      address: string;
      /** Total wallet value in USD as a bigint with 8 decimals (USD_DECIMALS). Confirmed actions roll it down. */
      balanceUsd: bigint;
      /** Saved contacts the agent may pay. */
      contacts: AgentContact[];
      /** Tokens in the wallet with balances and USD prices. */
      tokens: AgentToken[];
      /** Up to three prompt cards for the empty state. */
      starterPrompts?: StarterPrompt[];
      /** The agent that answers prompts. @default a scripted agent from useMockWalletAgent */
      agent?: WalletAgent;
      /**
       * Called when a card is approved. Return a promise to hold the card at "Awaiting signature" until it
       * settles; resolve with a transaction hash, or reject to show the failed state.
       */
      onApprove?: (card: AgentActionCard) => Promise<string | void> | void;
      /** Called when a card is cancelled before signing. */
      onReject?: (card: AgentActionCard) => void;
      /** Block explorer base URL for the confirmed link, e.g. "https://etherscan.io". */
      explorerUrl?: string;
      /** Show the "Simulated" badge. @default true */
      simulated?: boolean;
      /** Classes for the chat. */
      className?: string;
    }
    
    type CardStatus = "review" | "awaiting-signature" | "pending" | "confirmed" | "failed" | "cancelled";
    
    type Message = { id: string; role: "user"; text: string } | { id: string; role: "agent"; reply: AgentReply };
    
    const STEP_MS = 650;
    const PENDING_MS = 2400;
    const QUOTE_SECONDS = 15;
    
    const usdOf = (token: AgentToken, amount: bigint) => toUsd(amount, token.decimals, token.price);
    const amountText = (token: AgentToken, amount: bigint) => `${formatAmount(amount, token.decimals)} ${token.symbol}`;
    
    /** What a confirmed card takes off the wallet's USD total. */
    function costOf(card: AgentActionCard) {
      if (card.kind === "send") return usdOf(card.token, card.amount) + card.networkFeeUsd;
      if (card.kind === "swap") return usdOf(card.from, card.amountIn) - usdOf(card.to, card.amountOut) + card.networkFeeUsd;
      return card.networkFeeUsd;
    }
    
    function fakeHash(seed: string) {
      let out = "";
      for (let round = 0; out.length < 64; round++) {
        let hash = 0x811c9dc5;
        for (const char of `${round}:${seed}`) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193);
        hash ^= hash >>> 13;
        out += (hash >>> 0).toString(16).padStart(8, "0");
      }
      return `0x${out.slice(0, 64)}`;
    }
    
    const button = "inline-flex h-9 cursor-pointer items-center justify-center gap-1.5 rounded-full px-4 text-[13px] font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50";
    
    function Row({ label, children, strong }: { label: string; children: React.ReactNode; strong?: boolean }) {
      return (
        <div className="flex items-baseline justify-between gap-3 text-[12px]">
          <dt className="text-muted-foreground">{label}</dt>
          <dd className={cn("text-right font-mono tabular-nums", strong ? "font-semibold text-foreground" : "text-foreground")}>{children}</dd>
        </div>
      );
    }
    
    /** A small ring that empties over the quote's lifetime. */
    function QuoteRing({ seconds }: { seconds: number }) {
      const r = 7;
      const c = 2 * Math.PI * r;
      return (
        <svg viewBox="0 0 18 18" className="size-4 -rotate-90" aria-hidden="true">
          <circle cx="9" cy="9" r={r} fill="none" stroke="var(--border)" strokeWidth="2" />
          <circle cx="9" cy="9" r={r} fill="none" stroke="var(--primary)" strokeWidth="2" strokeLinecap="round" strokeDasharray={c} strokeDashoffset={c * (1 - seconds / QUOTE_SECONDS)} className="transition-[stroke-dashoffset] duration-1000 ease-linear" />
        </svg>
      );
    }
    
    function ActionCard({
      card: initial,
      onApprove,
      onReject,
      onStatus,
      explorerUrl,
    }: {
      card: AgentActionCard;
      onApprove?: WalletAgentChatProps["onApprove"];
      onReject?: WalletAgentChatProps["onReject"];
      onStatus: (card: AgentActionCard, status: CardStatus) => void;
      explorerUrl?: string;
    }) {
      const reduceMotion = useReducedMotion();
      const [card, setCard] = useState(initial);
      const [status, setStatus] = useState<CardStatus>("review");
      const [hash, setHash] = useState("");
      const [error, setError] = useState("");
      const [seconds, setSeconds] = useState(QUOTE_SECONDS);
      const [progress, setProgress] = useState(0);
      const [scope, animate] = useAnimate<HTMLElement>();
      const left = useRef(QUOTE_SECONDS);
      const live = useRef(true);
      useEffect(() => {
        live.current = true;
        return () => {
          live.current = false;
        };
      }, []);
    
      const update = (next: CardStatus, current = card) => {
        setStatus(next);
        onStatus(current, next);
      };
    
      // The swap quote counts down while under review and re-rolls when it runs out.
      useEffect(() => {
        if (card.kind !== "swap" || status !== "review") return;
        const timer = window.setInterval(() => {
          left.current -= 1;
          if (left.current <= 0) {
            left.current = QUOTE_SECONDS;
            // A refreshed quote lands within ±0.2% of the last one.
            const jitter = Math.round((Math.random() - 0.5) * 40);
            setCard((c) => {
              if (c.kind !== "swap") return c;
              const quote = quoteSwap(c.from, c.to, c.amountIn, jitter);
              return { ...c, amountOut: quote.amountOut, priceImpactBps: quote.priceImpactBps };
            });
          }
          setSeconds(left.current);
        }, 1000);
        return () => window.clearInterval(timer);
      }, [card.kind, status]);
    
      // Pending fills a progress ring, then confirms.
      useEffect(() => {
        if (status !== "pending") return;
        const started = performance.now();
        let frame = 0;
        const tick = () => {
          const p = Math.min(1, (performance.now() - started) / PENDING_MS);
          setProgress(p);
          if (p < 1) frame = requestAnimationFrame(tick);
          else if (live.current) update("confirmed");
        };
        frame = requestAnimationFrame(tick);
        return () => cancelAnimationFrame(frame);
        // eslint-disable-next-line react-hooks/exhaustive-deps -- runs once per pending phase
      }, [status]);
    
      const approve = async () => {
        const signed = card;
        setError("");
        update("awaiting-signature", signed);
        try {
          const started = Date.now();
          const result = await onApprove?.(signed);
          // A simulated wallet takes a moment to "sign".
          const wait = Math.max(0, 1100 - (Date.now() - started));
          await new Promise((resolve) => window.setTimeout(resolve, wait));
          if (!live.current) return;
          setHash(typeof result === "string" ? result : fakeHash(signed.id));
          setProgress(0);
          update("pending", signed);
        } catch (reason) {
          if (!live.current) return;
          setError(reason instanceof Error ? reason.message : "The transaction was not signed.");
          update("failed", signed);
          if (!reduceMotion && scope.current) animate(scope.current, { x: [0, -8, 7, -5, 3, 0] }, { duration: 0.4 });
        }
      };
    
      const cancel = () => {
        update("cancelled");
        onReject?.(card);
      };
    
      const title = card.kind === "swap" ? "Swap" : card.kind === "send" ? "Send" : "Token approval";
      const verb = card.kind === "swap" ? "Confirm swap" : card.kind === "send" ? `Send ${card.token.symbol}` : "Approve";
      const signing = status === "awaiting-signature";
      const done = status === "confirmed";
      const href = hash ? explorerLink(explorerUrl, "tx", hash) : undefined;
      const ring = 2 * Math.PI * 9;
    
      return (
        <motion.article
          ref={scope}
          aria-label={`${title} review`}
          initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.98 }}
          animate={reduceMotion ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
          transition={reduceMotion ? { duration: duration.base } : spring.smooth}
          className={cn(
            "mt-3 w-full max-w-sm rounded-2xl border bg-background p-4 shadow-sm transition-colors",
            status === "failed" ? "border-destructive/40" : done ? "border-success/40" : "border-border",
            status === "cancelled" && "opacity-60",
          )}
        >
          <header className="flex items-center justify-between gap-2">
            <p className="text-[13px] font-semibold text-foreground">{title}</p>
            <span
              className={cn(
                "inline-flex h-6 items-center gap-1.5 rounded-full px-2 text-[11px] font-medium",
                status === "review" && "bg-surface-2 text-foreground",
                signing && "bg-primary/10 text-link",
                status === "pending" && "bg-warning/12 text-[color-mix(in_oklab,var(--warning)_65%,var(--foreground))]",
                done && "bg-success/12 text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))]",
                status === "failed" && "bg-destructive/10 text-destructive-text",
                status === "cancelled" && "bg-surface-2 text-muted-foreground",
              )}
            >
              {status === "review" ? "Review" : signing ? "Awaiting signature" : status === "pending" ? "Pending" : done ? "Confirmed" : status === "failed" ? "Failed" : "Cancelled"}
            </span>
          </header>
    
          {/* Body */}
          {card.kind === "swap" ? (
            <div className="mt-3 space-y-1.5">
              <div className="flex items-center gap-2.5 rounded-xl bg-surface p-2.5">
                <TokenIcon token={card.from} size={28} />
                <div className="min-w-0 flex-1">
                  <p className="text-[11px] text-muted-foreground">You pay</p>
                  <p className="font-mono text-[15px] font-semibold text-foreground tabular-nums">{amountText(card.from, card.amountIn)}</p>
                </div>
                <span className="font-mono text-[12px] text-muted-foreground tabular-nums">{formatUsd(usdOf(card.from, card.amountIn))}</span>
              </div>
              <div className="relative -my-3 flex justify-center">
                <span className="z-10 grid size-7 place-items-center rounded-full border border-border bg-background text-muted-foreground">
                  <ArrowDown className="size-3.5" aria-hidden="true" />
                </span>
              </div>
              <div className="flex items-center gap-2.5 rounded-xl bg-surface p-2.5">
                <TokenIcon token={card.to} size={28} />
                <div className="min-w-0 flex-1">
                  <p className="text-[11px] text-muted-foreground">You receive about</p>
                  <p className="font-mono text-[15px] font-semibold text-foreground">
                    <RollingValue value={formatAmount(card.amountOut, card.to.decimals)} trend={card.amountOut} /> {card.to.symbol}
                  </p>
                </div>
                <span className="font-mono text-[12px] text-muted-foreground tabular-nums">{formatUsd(usdOf(card.to, card.amountOut))}</span>
              </div>
            </div>
          ) : card.kind === "send" ? (
            <div className="mt-3 space-y-2">
              <div className="flex items-center gap-2.5 rounded-xl bg-surface p-2.5">
                <AddressAvatar address={card.to.address} size={32} />
                <div className="min-w-0 flex-1">
                  <p className="text-[13px] font-medium text-foreground">{card.to.name}</p>
                  <p className="font-mono text-[11px] text-muted-foreground" title={card.to.address}>
                    {truncateAddress(card.to.address)}
                  </p>
                </div>
                <span className="text-[11px] text-muted-foreground">Saved contact</span>
              </div>
              <div className="flex items-center gap-2.5 px-1">
                <TokenIcon token={card.token} size={24} />
                <p className="flex-1 font-mono text-[15px] font-semibold text-foreground tabular-nums">{amountText(card.token, card.amount)}</p>
                <span className="font-mono text-[12px] text-muted-foreground tabular-nums">{formatUsd(usdOf(card.token, card.amount))}</span>
              </div>
            </div>
          ) : (
            <div className="mt-3 space-y-2">
              <div className="flex items-center gap-2.5 rounded-xl bg-surface p-2.5">
                <TokenIcon token={card.token} size={28} />
                <div className="min-w-0 flex-1">
                  <p className="text-[11px] text-muted-foreground">Allow spending up to</p>
                  <p className="font-mono text-[15px] font-semibold text-foreground tabular-nums">{card.amount === null ? `Unlimited ${card.token.symbol}` : amountText(card.token, card.amount)}</p>
                </div>
              </div>
              <div className="flex items-center gap-2.5 px-1 text-[12px]">
                <span className="text-muted-foreground">Spender</span>
                <span className="ml-auto font-medium text-foreground">{card.spender.name}</span>
                <span className="font-mono text-muted-foreground" title={card.spender.address}>
                  {truncateAddress(card.spender.address)}
                </span>
              </div>
              {card.amount === null ? (
                <div role="alert" className="flex items-start gap-2 rounded-xl border border-warning/35 bg-warning/10 p-2.5 text-[12px] text-[color-mix(in_oklab,var(--warning)_60%,var(--foreground))]">
                  <ShieldAlert className="mt-px size-4 shrink-0" aria-hidden="true" />
                  <div className="flex-1">
                    <p className="font-medium">Unlimited allowance</p>
                    <p className="mt-0.5 opacity-90">{card.spender.name} could move every {card.token.symbol} you hold, now or later.</p>
                    {status === "review" ? (
                      <button
                        type="button"
                        onClick={() => setCard({ ...card, amount: card.token.balance })}
                        className="mt-1.5 cursor-pointer rounded font-semibold underline underline-offset-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
                      >
                        Cap at your balance ({amountText(card.token, card.token.balance)})
                      </button>
                    ) : null}
                  </div>
                </div>
              ) : null}
            </div>
          )}
    
          <dl className="mt-3 space-y-1 border-t border-border pt-3">
            {card.kind === "swap" ? (
              <>
                <Row label="Rate">
                  1 {card.from.symbol} ≈ {formatAmount(card.amountIn > BigInt(0) ? (card.amountOut * BigInt(10) ** BigInt(card.from.decimals)) / card.amountIn : BigInt(0), card.to.decimals)} {card.to.symbol}
                </Row>
                <Row label="Route">{card.route.join(" → ")}</Row>
                <Row label="Fees">
                  {formatBps(card.feeBps)} · impact{" "}
                  <span className={cn(card.priceImpactBps >= 300 && "text-destructive-text")}>{formatBps(card.priceImpactBps)}</span>
                </Row>
              </>
            ) : null}
            <Row label="Network fee">{formatUsd(card.networkFeeUsd)}</Row>
            {card.kind === "send" ? <Row label="Total" strong>{formatUsd(usdOf(card.token, card.amount) + card.networkFeeUsd)}</Row> : null}
          </dl>
    
          {card.kind === "swap" && status === "review" ? (
            <p className="mt-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
              <QuoteRing seconds={seconds} />
              Quote refreshes in <span className="font-mono tabular-nums">{seconds}s</span>
            </p>
          ) : null}
    
          {/* Lifecycle */}
          <div className="mt-3">
            <AnimatePresence mode="popLayout" initial={false}>
              {status === "review" || status === "awaiting-signature" ? (
                <motion.div key="actions" className="flex gap-2" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: duration.quick }}>
                  <button type="button" onClick={cancel} className={cn(button, "border border-border text-foreground hover:bg-surface-2")}>
                    Cancel
                  </button>
                  <button type="button" onClick={approve} disabled={signing} aria-busy={signing} className={cn(button, "flex-1 bg-foreground text-background")}>
                    {signing ? (
                      <>
                        <span aria-hidden="true" className="size-3.5 animate-spin rounded-full border-2 border-background/30 border-t-background motion-reduce:animate-none" />
                        Check your wallet…
                      </>
                    ) : (
                      verb
                    )}
                  </button>
                </motion.div>
              ) : status === "pending" ? (
                <motion.div key="pending" className="flex items-center gap-2.5 text-[12px] text-muted-foreground" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
                  <svg viewBox="0 0 22 22" className="size-5 -rotate-90" role="progressbar" aria-label="Confirming" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(progress * 100)}>
                    <circle cx="11" cy="11" r="9" fill="none" stroke="var(--border)" strokeWidth="2.5" />
                    <circle cx="11" cy="11" r="9" fill="none" stroke="var(--primary)" strokeWidth="2.5" strokeLinecap="round" strokeDasharray={ring} strokeDashoffset={ring * (1 - progress)} />
                  </svg>
                  Waiting for confirmation…
                  <span className="ml-auto font-mono">{truncateAddress(hash, { start: 8, end: 4 })}</span>
                </motion.div>
              ) : done ? (
                <motion.div key="done" className="flex items-center gap-2 text-[12px]" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
                  <svg viewBox="0 0 22 22" className="size-5" aria-hidden="true">
                    <circle cx="11" cy="11" r="10" fill="color-mix(in oklab, var(--success) 14%, transparent)" />
                    <motion.path d="M6.5 11.5l3 3 6-6.5" fill="none" stroke="color-mix(in oklab, var(--success) 80%, var(--foreground))" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" initial={{ pathLength: reduceMotion ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: duration.slow, ease: ease.out }} />
                  </svg>
                  <span className="font-medium text-foreground">Confirmed</span>
                  {href ? (
                    <a href={href} target="_blank" rel="noreferrer" className="ml-auto inline-flex items-center gap-1 rounded font-medium text-link outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring">
                      View on explorer <ExternalLink className="size-3" aria-hidden="true" />
                    </a>
                  ) : (
                    <span className="ml-auto font-mono text-muted-foreground">{truncateAddress(hash, { start: 8, end: 4 })}</span>
                  )}
                </motion.div>
              ) : status === "failed" ? (
                <motion.div key="failed" className="flex items-center gap-2" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
                  <p className="flex-1 text-[12px] text-destructive-text">{error}</p>
                  <button type="button" onClick={cancel} className={cn(button, "h-8 border border-border px-3 text-foreground hover:bg-surface-2")}>
                    Dismiss
                  </button>
                  <button type="button" onClick={approve} className={cn(button, "h-8 bg-foreground px-3 text-background")}>
                    <RotateCcw className="size-3.5" aria-hidden="true" />
                    Retry
                  </button>
                </motion.div>
              ) : (
                <motion.p key="cancelled" className="text-[12px] text-muted-foreground" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
                  Cancelled. Nothing was signed.
                </motion.p>
              )}
            </AnimatePresence>
          </div>
        </motion.article>
      );
    }
    
    function AgentMessage({
      reply,
      instant,
      onSettled,
      onStatus,
      onApprove,
      onReject,
      explorerUrl,
    }: {
      reply: AgentReply;
      instant: boolean;
      onSettled: () => void;
      onStatus: (card: AgentActionCard, status: CardStatus) => void;
      onApprove?: WalletAgentChatProps["onApprove"];
      onReject?: WalletAgentChatProps["onReject"];
      explorerUrl?: string;
    }) {
      const [done, setDone] = useState(0);
      const stepsDone = instant || done >= reply.steps.length;
      const stream = useTextStream(reply.text, { enabled: stepsDone && !instant, wordsPerSecond: 40 });
      const text = instant ? reply.text : stream.text;
      const finished = stepsDone && text.length >= reply.text.length;
      const settled = useRef(false);
    
      useEffect(() => {
        if (stepsDone) return;
        const timer = window.setTimeout(() => setDone((d) => d + 1), STEP_MS);
        return () => window.clearTimeout(timer);
      }, [done, stepsDone]);
    
      useEffect(() => {
        if (finished && !settled.current) {
          settled.current = true;
          onSettled();
        }
      }, [finished, onSettled]);
    
      const steps: AgentStep[] = reply.steps.map((title, i) => ({ id: `${i}`, title, status: stepsDone || i < done ? "done" : i === done ? "running" : "pending" }));
    
      return (
        <div className="flex gap-2.5">
          <span aria-hidden="true" className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-full bg-foreground text-background">
            <Cpu className="size-3.5" />
          </span>
          <div className="min-w-0 flex-1">
            <AgentSteps steps={steps} aria-label="Agent steps" className="[&_li]:pb-2.5" />
            {stepsDone ? <StreamingText text={text} streaming={!finished} className="mt-2 text-[14px] leading-6 text-foreground" /> : null}
            {finished && reply.card ? <ActionCard card={reply.card} onApprove={onApprove} onReject={onReject} onStatus={onStatus} explorerUrl={explorerUrl} /> : null}
          </div>
        </div>
      );
    }
    
    /**
     * An AI wallet assistant that answers with transaction cards the user reviews and approves in place.
     * Each reply resolves its tool steps, streams its text, then slides in a swap, send or approval card
     * that tracks Review → Awaiting signature → Pending → Confirmed or Failed. Nothing is signed until the
     * user approves, and cancel is always available before signing.
     */
    export function WalletAgentChat({
      agentName = "Atlas",
      address,
      balanceUsd,
      contacts,
      tokens,
      starterPrompts = [],
      agent,
      onApprove,
      onReject,
      explorerUrl,
      simulated = true,
      className,
    }: WalletAgentChatProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const fallback = useMockWalletAgent({ tokens, contacts });
      const responder = agent ?? fallback;
      const [messages, setMessages] = useState<Message[]>([]);
      const [busy, setBusy] = useState(false);
      const [instant, setInstant] = useState<Record<string, boolean>>({});
      const [spent, setSpent] = useState(BigInt(0));
      const [announce, setAnnounce] = useState("");
      const scroller = useRef<HTMLDivElement>(null);
      const list = useRef<HTMLDivElement>(null);
      const counter = useRef(0);
    
      // Keep the newest content in view as the conversation grows.
      useLayoutEffect(() => {
        const el = scroller.current;
        const inner = list.current;
        if (!el || !inner) return;
        const observer = new ResizeObserver(() => el.scrollTo({ top: el.scrollHeight, behavior: reduceMotion ? "auto" : "smooth" }));
        observer.observe(inner);
        return () => observer.disconnect();
      }, [reduceMotion]);
    
      const send = (prompt: string) => {
        const text = prompt.trim();
        if (!text || busy) return;
        const n = counter.current++;
        const reply = responder.respond(text);
        setMessages((m) => [...m, { id: `u${n}`, role: "user", text }, { id: `a${n}`, role: "agent", reply }]);
        setBusy(true);
      };
    
      const lastAgent = [...messages].reverse().find((m): m is Extract<Message, { role: "agent" }> => m.role === "agent");
      const settle = (message: Extract<Message, { role: "agent" }>) => () => {
        setBusy(false);
        setAnnounce(`${agentName}: ${message.reply.text}${message.reply.card ? " A card is ready to review." : ""}`);
      };
    
      const onStatus = (card: AgentActionCard, status: CardStatus) => {
        const label = card.kind === "swap" ? "Swap" : card.kind === "send" ? "Payment" : "Approval";
        if (status === "confirmed") setSpent((s) => s + costOf(card));
        const words: Partial<Record<CardStatus, string>> = { "awaiting-signature": "awaiting signature", pending: "pending", confirmed: "confirmed", failed: "failed", cancelled: "cancelled" };
        if (words[status]) setAnnounce(`${label} ${words[status]}`);
      };
    
      const balance = balanceUsd - spent > BigInt(0) ? balanceUsd - spent : BigInt(0);
    
      return (
        <section aria-labelledby={`${id}-title`} className={cn("flex h-[38rem] w-full max-w-xl flex-col overflow-hidden rounded-2xl border border-border bg-background", className)}>
          <header className="flex flex-wrap items-center gap-3 border-b border-border px-4 py-3">
            <span aria-hidden="true" className="grid size-9 shrink-0 place-items-center rounded-full bg-[conic-gradient(from_140deg,#6366f1,#22d3ee,#a855f7,#6366f1)] text-white">
              <Cpu className="size-4" />
            </span>
            <div className="min-w-0">
              <h3 id={`${id}-title`} className="flex items-center gap-2 text-sm font-semibold text-foreground">
                {agentName}
                {simulated ? <SimulatedBadge /> : null}
              </h3>
              <p className="text-[12px] text-muted-foreground">Wallet agent</p>
            </div>
            <div className="ml-auto flex items-center gap-2">
              <p className="text-right">
                <span className="block text-[11px] text-muted-foreground">Balance</span>
                <span className="font-mono text-[13px] font-semibold text-foreground">
                  <RollingValue value={formatUsd(balance)} trend={balance} />
                </span>
              </p>
              <AddressPill address={address} size="sm" explorerUrl={explorerUrl} />
            </div>
          </header>
    
          <div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-4 py-4" role="region" aria-label="Conversation">
            <div ref={list}>
              {messages.length === 0 ? (
                <div className="flex flex-col items-center py-6 text-center">
                  <p className="text-lg font-semibold tracking-tight text-foreground">What should we move today?</p>
                  <p className="mt-1 max-w-sm text-[13px] text-muted-foreground">I prepare every transaction as a card you can review. Nothing is signed until you approve it.</p>
                  <motion.ul
                    className="mt-5 grid w-full gap-2 sm:grid-cols-3"
                    initial="hidden"
                    animate="visible"
                    variants={{ hidden: {}, visible: { transition: { staggerChildren: stagger.base } } }}
                  >
                    {starterPrompts.slice(0, 3).map((s) => (
                      <motion.li key={s.title} variants={reduceMotion ? { hidden: { opacity: 0 }, visible: { opacity: 1 } } : { hidden: { opacity: 0, y: 8 }, visible: { opacity: 1, y: 0, transition: { duration: duration.base, ease: ease.out } } }}>
                        <button
                          type="button"
                          onClick={() => send(s.prompt)}
                          className="group flex h-full w-full cursor-pointer flex-col items-start gap-1 rounded-xl border border-border p-3 text-left outline-none transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-ring"
                        >
                          <span className="flex w-full items-center justify-between text-[13px] font-medium text-foreground">
                            {s.title}
                            <ArrowRight className="size-3.5 text-muted-foreground transition-transform group-hover:translate-x-0.5" aria-hidden="true" />
                          </span>
                          <span className="text-[12px] text-muted-foreground">{s.prompt}</span>
                        </button>
                      </motion.li>
                    ))}
                  </motion.ul>
                </div>
              ) : (
                <ul className="space-y-5">
                  {messages.map((m) =>
                    m.role === "user" ? (
                      <motion.li
                        key={m.id}
                        initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8 }}
                        animate={{ opacity: 1, y: 0 }}
                        transition={{ duration: duration.base, ease: ease.out }}
                        className="flex justify-end"
                      >
                        <p className="max-w-[80%] rounded-2xl rounded-br-md bg-foreground px-3.5 py-2 text-[14px] text-background">
                          <span className="sr-only">You: </span>
                          {m.text}
                        </p>
                      </motion.li>
                    ) : (
                      <li key={m.id}>
                        <span className="sr-only">{agentName}: </span>
                        <AgentMessage
                          reply={m.reply}
                          instant={!!instant[m.id]}
                          onSettled={settle(m)}
                          onStatus={onStatus}
                          onApprove={onApprove}
                          onReject={onReject}
                          explorerUrl={explorerUrl}
                        />
                      </li>
                    ),
                  )}
                </ul>
              )}
            </div>
          </div>
    
          <div className="border-t border-border p-3">
            <AnimatePresence initial={false}>
              {!busy && lastAgent?.reply.suggestions?.length ? (
                <motion.div
                  key={lastAgent.id}
                  role="group"
                  aria-label="Suggestions"
                  className="mb-2 flex gap-1.5 overflow-x-auto pb-0.5"
                  initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, transition: { duration: duration.instant } }}
                  transition={{ duration: duration.base, ease: ease.out }}
                >
                  {lastAgent.reply.suggestions.map((s) => (
                    <button
                      key={s}
                      type="button"
                      onClick={() => send(s)}
                      className="h-8 shrink-0 cursor-pointer rounded-full border border-border bg-background px-3 text-[12px] font-medium text-foreground outline-none transition-colors hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring"
                    >
                      {s}
                    </button>
                  ))}
                </motion.div>
              ) : null}
            </AnimatePresence>
            <AIPromptInput
              placeholder="Swap, send or approve…"
              allowAttachments={false}
              maxRows={4}
              loading={busy}
              onStop={() => {
                if (lastAgent) setInstant((s) => ({ ...s, [lastAgent.id]: true }));
              }}
              onSubmit={(value) => send(value)}
              aria-label={`Message ${agentName}`}
            />
          </div>
          <p aria-live="polite" className="sr-only">
            {announce}
          </p>
        </section>
      );
    }

Usage

import { WalletAgentChat } from "@/components/ui/wallet-agent-chat/wallet-agent-chat";

<WalletAgentChat
  agentName="Atlas"
  address={account.address}
  balanceUsd={totalUsd} // bigint, 8 decimals
  tokens={tokens} // { symbol, decimals, price, balance: bigint }[]
  contacts={[{ name: "Maya", address: "0x…" }]}
  starterPrompts={[{ title: "Swap tokens", prompt: "Swap 0.5 ETH to USDC" }]}
  onApprove={async (card) => {
    // Hand the card to your own wallet flow here. Resolve with a tx hash, or throw to show Failed.
  }}
/>

API reference

PropTypeDefaultDescription
agentNamestring"Atlas"Name shown in the header.
address*string–The connected address.
balanceUsd*bigint–Total wallet value in USD as a bigint with 8 decimals (USD_DECIMALS). Confirmed actions roll it down.
contacts*AgentContact[]–Saved contacts the agent may pay.
tokens*AgentToken[]–Tokens in the wallet with balances and USD prices.
starterPromptsStarterPrompt[]–Up to three prompt cards for the empty state.
agentWalletAgenta scripted agent from useMockWalletAgentThe agent that answers prompts.
onApprove(card: AgentActionCard) => Promise<string | void> | void–Called when a card is approved. Return a promise to hold the card at "Awaiting signature" until it settles; resolve with a transaction hash, or reject to show the failed state.
onReject(card: AgentActionCard) => void–Called when a card is cancelled before signing.
explorerUrlstring–Block explorer base URL for the confirmed link, e.g. "https://etherscan.io".
simulatedbooleantrueShow the "Simulated" badge.
classNamestring–Classes for the chat.
  • Simulated by default: replies come from useMockWalletAgent in src/lib/usage/wallet-agent.ts, which parses "swap X A to B", "send X A to name" and "approve X A for spender" with regular expressions. Pass `agent` to plug in your own.
  • Nothing is signed without an explicit click on the card's approve button, and Cancel is available until the signature resolves. Payments only go to saved contacts.
  • All token amounts are bigint with their decimals; USD values use 8 decimals. Swap quotes refresh every 15 seconds while under review, and confirmed actions roll the header balance down.

Accessibility

  • The conversation is a labelled region. Each reply is announced politely once it finishes, along with each card's status changes (awaiting signature, pending, confirmed, failed, cancelled).
  • Every action card is an article with its amounts, fees and spender in text; the unlimited-allowance warning is an alert with a keyboard-reachable "Cap at your balance" action.
  • The pending ring is a progressbar with its value. Reduced motion keeps every state but drops the slide-in, shake and check draw.