HextaUI

HextaAI

A complete AI chat app built from every HextaUI AI block. Chats in a sidebar, thinking with sources, tool calls with diffs and approvals, a plan you review before the agent runs, streamed Markdown and code, and silent voice mode, all driven by AI SDK message parts.

HextaAI is a complete AI chat app made from every HextaUI AI block. Chat Sidebar holds the history, Chat Thread holds the conversation, Prompt Input is the composer, and each reply is drawn from its parts: Thinking for reasoning, Tool Calls for what the agent did, Agent Todos for its plan, Code Block for diffs and terminals, and Streaming Markdown for the answer. Voice Mode opens from the composer and drops the conversation into the chat when you hang up.

The core is HextaMessageParts. It takes the same message parts the AI SDK’s useChat returns, groups neighbouring reasoning and tool calls, keeps one live plan at the top of the reply, and renders text with citations linked to the sources the model found. Reads, edits, writes, searches, web fetches and shell commands each get a fitting view, and you can add renderers for your own tools.

The demo runs on ScriptedChats, a small engine that streams realistic parts offline. Try a suggestion: research a question and watch sources come in, ask for a feature and review the plan before it runs, approve a test run that fails and gets fixed, or ask for an explanation with code and tables. Stop, regenerate, edit and switch chats while replies keep streaming in the background, then open voice mode and talk.

  1. Add the Pro registry to components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. Add your token

    Create a token on your account page and put it in .env.local as HEXTAUI_PRO_TOKEN.

  3. Add the block

    pnpm dlx shadcn@latest add @hextaui-pro/hexta-ai

With the AI SDK

Pass useChat messages straight to HextaMessageParts. Tool approvals and plan reviews answer through addToolApprovalResponse, and an edited plan goes back to the model as the approval reason.

"use client"

import { useChat } from "@ai-sdk/react"
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai"

import { ChatAssistantMessage, ChatThread, ChatUserMessage } from "../chat-thread/chat-thread"
import { PromptInput, PromptInputActions, PromptInputBody, PromptInputSubmit, PromptInputTextarea } from "../prompt-input/prompt-input"
import { HextaMessageParts } from "@/components/blocks/hexta-ai/message-parts"
import { textOf, type HextaMessage, type ToolPart } from "@/components/blocks/hexta-ai/parts"

export function Assistant() {
  const { messages, status, sendMessage, stop, regenerate, addToolApprovalResponse } = useChat({
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
  })
  const busy = status === "submitted" || status === "streaming"
  const chat = messages as HextaMessage[]

  const approvalId = (toolCallId: string) =>
    chat
      .flatMap((message) => message.parts)
      .find((part): part is ToolPart => "toolCallId" in part && part.toolCallId === toolCallId)
      ?.approval?.id

  return (
    <ChatThread
      busy={busy}
      composer={
        <PromptInput status={status} onStop={stop} onSubmit={({ text }) => sendMessage({ text })}>
          <PromptInputBody>
            <PromptInputTextarea />
            <PromptInputActions>
              <PromptInputSubmit />
            </PromptInputActions>
          </PromptInputBody>
        </PromptInput>
      }
    >
      {chat.map((message, index) => {
        const last = index === chat.length - 1
        if (message.role === "user") {
          return <ChatUserMessage key={message.id} id={message.id} text={textOf(message)} />
        }
        return (
          <ChatAssistantMessage
            key={message.id}
            id={message.id}
            text={textOf(message)}
            streaming={last && busy}
            last={last}
            onRetry={last ? () => regenerate() : undefined}
          >
            <HextaMessageParts
              parts={message.parts}
              streaming={last && busy}
              onApproval={(toolCallId, { approved, reason }) => {
                const id = approvalId(toolCallId)
                if (id) addToolApprovalResponse({ id, approved, reason })
              }}
              onPlanApprove={(toolCallId, todos) => {
                const id = approvalId(toolCallId)
                if (!id) return
                addToolApprovalResponse({
                  id,
                  approved: true,
                  reason: `Run this plan:\n${todos.map((todo, step) => `${step + 1}. ${todo.content}`).join("\n")}`,
                })
              }}
              onPlanCancel={(toolCallId) => {
                const id = approvalId(toolCallId)
                if (id) addToolApprovalResponse({ id, approved: false })
              }}
            />
          </ChatAssistantMessage>
        )
      })}
    </ChatThread>
  )
}

Your own tools

Map a tool name to a renderer that returns its kind, subject and content. Tools you don't map fall back to built-in views or a JSON view.

"use client"

import { CodeBlock } from "../code-block/code-block"
import { HextaMessageParts, type ToolRenderer } from "@/components/blocks/hexta-ai/message-parts"
import type { MessagePart } from "@/components/blocks/hexta-ai/parts"

const tools: Record<string, ToolRenderer> = {
  create_chart: (part) => {
    const input = part.input as { title?: string; spec?: string } | undefined
    return {
      kind: "write",
      subject: input?.title ?? "Chart",
      content: input?.spec ? <CodeBlock code={input.spec} language="json" filename="chart.json" /> : undefined,
    }
  },
}

export function Reply({ parts, streaming }: { parts: MessagePart[]; streaming: boolean }) {
  return <HextaMessageParts parts={parts} streaming={streaming} tools={tools} />
}

Scripted demos

ScriptedChats streams AI SDK-shaped parts from a script, with real pacing, approvals and stop. Use it for product tours, onboarding and offline demos.

"use client"

import * as React from "react"

import { HextaMessageParts } from "@/components/blocks/hexta-ai/message-parts"
import { textOf } from "@/components/blocks/hexta-ai/parts"
import { ScriptedChats, useScriptedChat, type Script } from "@/components/blocks/hexta-ai/scripted-chat"

const onboarding: Script = async (run) => {
  await run.think("**Picking a first step**\nShow them the one thing that makes the product click.")
  const search = await run.tool("web_search", { query: "best onboarding checklists" })
  await run.pause(600)
  search.finish({ results: 4 })
  await run.say("Start with **one task they can finish in a minute**, then show what it unlocked.")
}

export function ProductTour() {
  const [store] = React.useState(() => new ScriptedChats(() => onboarding))
  const { messages, status } = useScriptedChat(store, "tour")
  const busy = status === "submitted" || status === "streaming"

  return (
    <div className="flex flex-col gap-4">
      <button onClick={() => void store.send("tour", "How should onboarding start?")}>Play</button>
      {messages.map((message, index) =>
        message.role === "user" ? (
          <p key={message.id}>{textOf(message)}</p>
        ) : (
          <HextaMessageParts
            key={message.id}
            parts={message.parts}
            streaming={busy && index === messages.length - 1}
            reasoningDurations={message.metadata?.thoughts}
            onApproval={(id, approval) => store.decide(id, approval)}
          />
        )
      )}
    </div>
  )
}

Anatomy

The parts you compose, from the outside in.

PartDescription
HextaMessagePartsRenders one assistant message from its parts, in order: reasoning, plan, tool calls and Markdown.
defaultToolsBuilt-in renderers for read_file, list_files, grep, web_search, fetch, edit_file, write_file and bash.
ScriptedChats, useScriptedChatAn offline engine that streams AI SDK-shaped parts from scripts, with stop, regenerate, edit, approvals and plan review.
textOf, sourcesOf, toBlocksHelpers that read text and sources from a message and group its parts the way the renderer does.
HextaAIDemoThe full app: Chat Sidebar, Chat Thread, Prompt Input and Voice Mode wired to ScriptedChats.
PropTypeDefault
partsThe message’s parts: text, reasoning, source-url, file and tool parts, as useChat returns them.
MessagePart[]–
streamingWhether this message is still arriving. The last reasoning or text block animates, and running tools stay live.
booleanfalse
stoppedMarks running tools as cancelled and hides a pending plan review.
booleanfalse
toolsRenderers by tool name. Each returns kind, subject, title, meta, content, exitCode or error, and overrides the built-in view.
Record<string, ToolRenderer>{}
onApprovalCalled when someone allows or denies a tool call that is waiting for approval.
(toolCallId, { approved, reason?, always? }) => void–
onPlanApproveCalled with the edited plan when someone runs it from the review.
(toolCallId, todos) => void–
onPlanCancelCalled when the plan review is cancelled.
(toolCallId) => void–
reasoningDurationsSeconds spent on each reasoning block, so “Thought for” stays right after a reload.
number[]–
PropTypeDefault
new ScriptedChats(respond)Picks the script that answers each message.
(prompt, files) => Script–
send(chatId, text, files?, options?)Adds a user message and streams the reply. options: model, thinking and instant, which skips all waiting.
Promise<void>–
stop, regenerate, edit, append, removeStop the reply, redo the last one, resend from an edited message, add finished messages, or drop a chat.
methods–
decide(toolCallId, decision)Answers a pending approval or plan review. Pass todos to run an edited plan.
void–
useScriptedChat(store, chatId)Subscribes a component to one chat.
{ messages, status, pinned }–
PropTypeDefault
think(text)Streams a reasoning part. Skipped when thinking is off.
Promise<void>–
say(markdown)Streams a text part in uneven chunks, like a real model.
Promise<void>–
tool(name, input, { stream? })Adds a tool call, optionally streaming one input field. The handle can approve, progress, finish or fail it.
Promise<ToolHandle>–
plan(todos)Shows a plan for review and waits. Returns the edited plan, or null if cancelled.
Promise<Todo[] | null>–
todos(todos), source(page), pause(ms)Update the plan, cite a source, or wait.
methods–
KeyAction
⌘KSearches your chats.
⌘⇧OStarts a new chat.
⌘BCollapses the sidebar to icons, or opens it full screen on phones.
EnterSends the message. Shift+Enter adds a line.
⌘↵Runs the plan while it’s being reviewed, or allows a tool call that is waiting.
⌘⌫Denies the tool call that is waiting.
EscStops the reply, closes a menu, or ends voice mode.
  • Every part keeps the accessibility of the block that draws it: the thread announces new replies politely, tool calls and plans expose their state in text, citations name their source, and approvals are real buttons with keyboard shortcuts.
  • Nothing important is shown only by motion or color. Spinners have labels like “Replying”, finished chats in the background get an “Unread reply” label, and diffs mark added and removed lines with signs as well as color.
  • Streaming, shader and sheet animations all respect reduced motion. Voice mode works without speaking replies aloud, shows captions, and plays a sample conversation when there’s no microphone.

Code

5 files, added to components/blocks/hexta-ai.