Chat Thread

Toda la conversación alrededor del compositor. Tu pregunta se fija arriba mientras la respuesta llega en streaming, los puntos de control del lateral saltan entre mensajes, y cada respuesta se puede copiar, editar, reintentar, valorar y cambiar entre versiones. Las respuestas renderizan Markdown con bloques de código, tablas y citas, y el razonamiento, las llamadas a herramientas y Prompt Input encajan directamente.

Chat Thread es la conversación alrededor del compositor: tus mensajes, las respuestas del modelo y lo que puedes hacer con ellos. Empieza con tu estado vacío y el compositor centrado, y luego ancla el compositor abajo cuando hay mensajes, sin volver a montarlo, de modo que el foco y el texto del borrador se mantienen.

El mensaje que acabas de enviar sube arriba mientras su respuesta llega por debajo, y la vista sigue el texto nuevo hasta que te desplazas. Un riel de puntos de control a la derecha te permite saltar entre tus mensajes, con una vista previa al pasar el cursor. El historial anterior se carga desde arriba sin que la vista se mueva.

Pasa el cursor sobre un mensaje para copiar, editar o reintentar. Las ediciones y los reintentos se convierten en versiones entre las que puedes alternar, y no se pierde nada. Las respuestas fallidas ofrecen Try again, las detenidas lo indican, y el pulgar arriba o abajo es un clic. Dentro de cada respuesta, Reasoning, ToolCalls y Markdown de los otros bloques encajan directamente.

  1. Añade el registro Pro a components.json

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

    Crea un token en tu página de cuenta y colócalo en .env.local como HEXTAUI_PRO_TOKEN.

  3. Añade el bloque

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

Con AI SDK

Asigna los mensajes de useChat al hilo. Las partes de razonamiento van a Reasoning, las de herramientas a ToolCalls y el texto a Markdown. Fija el último mensaje del usuario, usa regenerate para Try again y vuelve a enviar una edición desde ese punto.

"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>
  )
}

Anatomía

Las partes que compones, de fuera hacia dentro.

ParteDescripción
ChatThreadEl marco: scroller, puntos de control, estado vacío y hueco del compositor.
ChatUserMessageTu mensaje: adjuntos, burbuja, hora, versiones, copiar y editar.
ChatAssistantMessageUna respuesta. Coloca Reasoning, ToolCalls y Markdown dentro; añade las acciones y los estados de error y detenido.
ChatDividerUna línea etiquetada entre grupos de mensajes.
ChatAttachments, ChatAction, ChatCopy, ChatBranchSwitch, ChatEditorLas piezas que usan los mensajes, exportadas para diseños personalizados.
PropTipoPredeterminado
childrenMensajes y divisores, en orden.
ReactNode–
composerNormalmente Prompt Input. Centrado cuando está vacío, anclado abajo en caso contrario.
ReactNode–
emptySe muestra sobre el compositor cuando no hay mensajes.
ReactNode–
busyMarca la conversación como ocupada para las tecnologías de asistencia mientras llega una respuesta.
booleanfalse
hasOlderMuestra «Load earlier messages» arriba.
booleanfalse
loadingOlderMuestra un spinner en ese botón. La posición de scroll se restaura cuando pasa a false.
booleanfalse
onLoadOlderAntepón aquí los mensajes más antiguos.
() => void–
PropTipoPredeterminado
idId estable. Mantenlo igual entre versiones para que la vista no salte.
string–
textEl mensaje. También se usa para la vista previa del punto de control.
string–
pinDesplaza este mensaje hasta arriba cuando aparece. Actívalo en el mensaje recién enviado.
booleanfalse
files{ id, name, size?, type?, url? }. Las imágenes con url se muestran como miniaturas.
ChatFile[]–
timeSe muestra al pasar el cursor, en la configuración regional del lector.
Date–
branch{ index, count, onChange } para las flechas de versión.
ChatBranch–
onEditMuestra Edit. Se llama con el texto nuevo cuando cambió.
(text: string) => void–
attachmentsContenido personalizado sobre la burbuja.
ReactNode–
PropTipoPredeterminado
idId estable.
string–
childrenReasoning, ToolCalls, Markdown o cualquier otra cosa.
ReactNode–
textTexto plano para Copy.
string–
streamingOculta las acciones hasta que la respuesta termine.
booleanfalse
lastMantiene las acciones visibles en lugar de mostrarlas al pasar el cursor.
booleanfalse
metaLado derecho de la fila de acciones, como el modelo y el esfuerzo.
ReactNode–
branchFlechas de versión para los reintentos.
ChatBranch–
feedbackLa valoración actual.
"up" | "down" | nullnull
onFeedbackMuestra los pulgares. Se llama con null cuando se deshace una valoración.
(feedback) => void–
onRetryMuestra Try again.
() => void–
errorMuestra el mensaje con Try again en lugar de las acciones.
string | null–
stoppedMarca una respuesta que se detuvo antes de tiempo.
booleanfalse
PropTipoPredeterminado
childrenLa etiqueta, como Today o «Switched to Nova 3 Max».
ReactNode–
KeyAcción
TabRecorre las acciones de los mensajes. Aparecen con el foco, no solo al pasar el cursor.
EnterEnvía un mensaje editado.
ShiftEnterAñade una línea durante la edición.
EscCancela una edición.
↑↓PgUpPgDnDesplaza la conversación cuando tiene el foco.
  • La conversación es una región etiquetada que contiene un log, así que los mensajes nuevos se anuncian al llegar y la región se marca como ocupada mientras llega una respuesta.
  • Cada acción con icono tiene una etiqueta y un tooltip. Los pulgares usan aria-pressed, Copy y el feedback se confirman mediante una región live polite, y los errores usan role=alert.
  • Los puntos de control son un hito de navegación de botones llamados «Message 2: …», y el actual tiene aria-current.
  • Las flechas de versión son un grupo llamado «Version 1 of 2».
  • El editor se enfoca solo con el cursor al final y vuelve al mensaje con Cancel. Enter espera mientras un método de entrada está componiendo.
  • Los anillos de foco nunca los recorta el scroller, y las acciones permanecen visibles en pantallas táctiles.

Construido con

Los componentes gratuitos de HextaUI con los que está hecho Chat Thread. Cada uno se instala por separado.

Código

2 archivos, añadidos a components/blocks/chat-thread.