Chat Thread

A conversa inteira ao redor do composer. Sua pergunta fica fixa no topo enquanto a resposta chega em streaming, checkpoints na lateral saltam entre mensagens, e toda resposta pode ser copiada, editada, repetida, avaliada e alternada entre versões. As respostas renderizam Markdown com blocos de código, tabelas e citações, e o raciocínio, as chamadas de ferramenta e o Prompt Input se encaixam diretamente.

O Chat Thread é a conversa ao redor do composer: suas mensagens, as respostas do modelo e o que você pode fazer com elas. Ele começa com o seu estado vazio e o composer centralizado e depois encaixa o composer na parte de baixo quando há mensagens, sem remontá-lo, então o foco e o texto do rascunho permanecem no lugar.

A mensagem que você acabou de enviar sobe para o topo enquanto a resposta chega em streaming logo abaixo, e a visão acompanha o novo texto até você rolar para longe. Uma trilha de checkpoints à direita permite saltar entre as suas mensagens, com uma prévia no hover. O histórico mais antigo é carregado pelo topo sem a visão se mexer.

Passe o mouse sobre uma mensagem para copiar, editar ou tentar de novo. Edições e novas tentativas viram versões entre as quais você pode alternar, e nada se perde. Respostas com falha oferecem Try again, respostas interrompidas dizem isso, e o polegar para cima ou para baixo é um clique. Dentro de cada resposta, Reasoning, ToolCalls e Markdown dos outros blocos se encaixam diretamente.

  1. Adicione o registro Pro ao components.json

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

    Crie um token na sua página de conta e coloque-o em .env.local como HEXTAUI_PRO_TOKEN.

  3. Adicione o bloco

    pnpm dlx shadcn@latest add @hextaui-pro/chat-thread

Com o AI SDK

Mapeie as mensagens do useChat para a thread. As partes de reasoning vão para Reasoning, as partes de ferramenta para ToolCalls e o texto para Markdown. Fixe a última mensagem do usuário, use regenerate para Try again e reenvie uma edição a partir daquele ponto.

"use client"

import { useChat } from "@ai-sdk/react"
import { getToolName, isToolUIPart, type UIMessage } from "ai"

import { PromptInput, PromptInputActions, PromptInputBody, PromptInputSubmit, PromptInputTextarea } from "../prompt-input/prompt-input"
import { Reasoning } from "../thinking/reasoning"
import { ToolCalls } from "../tool-calls/tool-group"
import { getToolPartStatus } from "../tool-calls/tool-part"
import { Markdown } from "../markdown/markdown"
import { ChatAssistantMessage, ChatThread, ChatUserMessage } from "@/components/blocks/chat-thread/chat-thread"

function textOf(message: UIMessage) {
  return message.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")
}

export function Chat() {
  const { messages, status, sendMessage, regenerate, stop, setMessages } = useChat()
  const busy = status === "submitted" || status === "streaming"
  const lastUser = messages.findLastIndex((message) => message.role === "user")

  return (
    <ChatThread
      busy={busy}
      composer={
        <PromptInput status={status} onStop={stop} onSubmit={({ text }) => sendMessage({ text })}>
          <PromptInputBody>
            <PromptInputTextarea />
            <PromptInputActions>
              <PromptInputSubmit />
            </PromptInputActions>
          </PromptInputBody>
        </PromptInput>
      }
    >
      {messages.map((message, index) => {
        const last = index === messages.length - 1
        if (message.role === "user") {
          return (
            <ChatUserMessage
              key={message.id}
              id={message.id}
              text={textOf(message)}
              pin={index === lastUser}
              onEdit={(text) => {
                setMessages(messages.slice(0, index))
                sendMessage({ text })
              }}
            />
          )
        }
        const reasoning = message.parts.flatMap((part) => (part.type === "reasoning" ? [part.text] : [])).join("\n\n")
        const tools = message.parts.filter(isToolUIPart)
        const streaming = last && busy
        return (
          <ChatAssistantMessage
            key={message.id}
            id={message.id}
            text={textOf(message)}
            streaming={streaming}
            last={last}
            error={last && status === "error" ? "Something went wrong." : null}
            onRetry={last ? () => regenerate() : undefined}
          >
            {reasoning ? <Reasoning text={reasoning} streaming={streaming && !textOf(message)} /> : null}
            {tools.length > 0 ? (
              <ToolCalls
                calls={tools.map((part) => ({
                  id: part.toolCallId,
                  name: getToolName(part),
                  status: getToolPartStatus(part, { stopped: !streaming }),
                }))}
              />
            ) : null}
            <Markdown
              text={textOf(message)}
              streaming={streaming}
              sources={message.parts.flatMap((part) =>
                part.type === "source-url"
                  ? [{ id: part.sourceId, url: part.url, title: part.title ?? part.url }]
                  : []
              )}
            />
          </ChatAssistantMessage>
        )
      })}
    </ChatThread>
  )
}

Anatomia

As partes que você compõe, de fora para dentro.

ParteDescrição
ChatThreadA moldura: scroller, checkpoints, estado vazio e o espaço do composer.
ChatUserMessageSua mensagem: anexos, balão, horário, versões, copiar e editar.
ChatAssistantMessageUma resposta. Coloque Reasoning, ToolCalls e Markdown dentro dela; ela adiciona as ações e os estados de erro e de interrompido.
ChatDividerUma linha rotulada entre grupos de mensagens.
ChatAttachments, ChatAction, ChatCopy, ChatBranchSwitch, ChatEditorAs peças que as mensagens usam, exportadas para layouts personalizados.
PropTipoPadrão
childrenMensagens e divisores, em ordem.
ReactNode–
composerGeralmente o Prompt Input. Centralizado quando vazio, encaixado na parte de baixo nos demais casos.
ReactNode–
emptyExibido acima do composer quando não há mensagens.
ReactNode–
busyMarca a conversa como ocupada para tecnologias assistivas enquanto uma resposta chega em streaming.
booleanfalse
hasOlderMostra "Load earlier messages" no topo.
booleanfalse
loadingOlderMostra um spinner nesse botão. A posição de rolagem é restaurada quando vira false.
booleanfalse
onLoadOlderAdicione aqui as mensagens mais antigas, no início.
() => void–
PropTipoPadrão
idId estável. Mantenha-o igual entre as versões para que a visão não pule.
string–
textA mensagem. Também usada na prévia do checkpoint.
string–
pinRola esta mensagem até o topo quando ela aparece. Defina-a na mensagem recém-enviada.
booleanfalse
files{ id, name, size?, type?, url? }. Imagens com url aparecem como miniaturas.
ChatFile[]–
timeExibido no hover, no locale de quem lê.
Date–
branch{ index, count, onChange } para as setas de versão.
ChatBranch–
onEditMostra Edit. Chamado com o novo texto quando ele mudou.
(text: string) => void–
attachmentsConteúdo personalizado acima do balão.
ReactNode–
PropTipoPadrão
idId estável.
string–
childrenReasoning, ToolCalls, Markdown ou qualquer outra coisa.
ReactNode–
textTexto simples para o Copy.
string–
streamingOculta as ações até a resposta terminar.
booleanfalse
lastMantém as ações visíveis em vez de mostrá-las no hover.
booleanfalse
metaLado direito da linha de ações, como o modelo e o esforço.
ReactNode–
branchSetas de versão para as novas tentativas.
ChatBranch–
feedbackA avaliação atual.
"up" | "down" | nullnull
onFeedbackMostra os polegares. Chamado com null quando uma avaliação é desfeita.
(feedback) => void–
onRetryMostra Try again.
() => void–
errorMostra a mensagem com Try again no lugar das ações.
string | null–
stoppedMarca uma resposta que foi interrompida antes do fim.
booleanfalse
PropTipoPadrão
childrenO rótulo, como Hoje ou "Mudou para o Nova 3 Max".
ReactNode–
TeclaAção
TabPercorre as ações das mensagens. Elas aparecem no foco, não apenas no hover.
EnterEnvia uma mensagem editada.
ShiftEnterAdiciona uma linha durante a edição.
EscCancela uma edição.
↑↓PgUpPgDnRola a conversa quando ela tem o foco.
  • A conversa é uma região rotulada que contém um log, então as novas mensagens são anunciadas assim que chegam e a região é marcada como ocupada enquanto uma resposta chega em streaming.
  • Toda ação com ícone tem um rótulo e um tooltip. Os polegares usam aria-pressed, Copy e o feedback confirmam por uma região live polite, e os erros usam role=alert.
  • Os checkpoints são um marco de navegação de botões chamados "Mensagem 2: …", e o atual tem aria-current.
  • As setas de versão são um grupo chamado "Versão 1 de 2".
  • O editor se foca sozinho com o cursor no fim e volta à mensagem em Cancel. O Enter espera enquanto um método de entrada está compondo texto.
  • Os anéis de foco nunca são cortados pelo scroller, e as ações permanecem visíveis em telas de toque.

Construído com

Os componentes gratuitos do HextaUI de que Chat Thread é feito. Cada um é instalado separadamente.

Código

2 arquivos, adicionados a components/blocks/chat-thread.