Ir para o conteúdo

Avançados: conversa

Chat para thread entre pessoas e AIChat para conversa com um modelo. São componentes diferentes, não variantes — e é por isso que têm página própria.

Chat

Quando usar: uma thread de mensagens — suporte, chat interno, comentário de documento, histórico de atendimento.

Agrupa por autor e por dia, marca o lado do usuário atual, mostra estado de entrega, quem está digitando, e traz o composer quando você passa onSend.

import { Chat, Avatar, type ChatMessage } from "tempest-react-sdk";
import { useState } from "react";

export function Suporte({ me }: { me: { id: string } }) {
  const [mensagens, setMensagens] = useState<ChatMessage[]>([]);

  /** Insert otimista: a mensagem aparece antes do servidor confirmar. */
  const enviar = async (texto: string) => {
    const id = crypto.randomUUID();
    setMensagens((atual) => [
      ...atual,
      { id, body: texto, authorId: me.id, sentAt: Date.now(), status: "sending" },
    ]);
    await api.post("/mensagens", { body: { id, texto } });
    setMensagens((atual) =>
      atual.map((m) => (m.id === id ? { ...m, status: "sent" } : m)),
    );
  };

  return (
    <Chat
      messages={mensagens}
      currentUserId={me.id}
      onSend={enviar}
      onRetry={(m) => reenviar(m.id)}
      renderAvatar={(m) => <Avatar name={m.authorName ?? m.authorId} size="sm" />}
    />
  );
}
Prop Tipo Default O que faz
messages ChatMessage[] A thread, mais antiga primeiro. Nunca reordenada.
currentUserId string Autor tratado como "seu": lado, cor e ticks de entrega.
onSend (text: string) => void \| Promise<void> Renderiza o composer. Recebe o texto já trimado.
onRetry (message: ChatMessage) => void Liga o botão de retry numa mensagem "failed".
onSendError (error: unknown) => void Chamado quando onSend rejeita. O rascunho fica no campo.
typing string[] [] Quem está digitando. Um, dois ou a contagem é fraseado pra você.
renderAvatar (message) => ReactNode Avatar da primeira mensagem de cada bloco.
header ReactNode Barra acima da thread, dentro do painel.
groupWindowMs number 300000 Intervalo que ainda mantém mensagens no mesmo bloco.
locale "pt-BR" \| "en" "pt-BR" Rótulos ("Hoje", "Você", "Enviando"…).
emptyState ReactNode <EmptyState/> Thread vazia.
composerDisabled boolean false Sem permissão, thread arquivada, offline.

ChatMessage = { id, body, authorId, authorName?, sentAt, status?, data? } · status"sending" | "sent" | "read" | "failed".

O componente é apresentacional e controlado, como o resto do SDK: recebe a lista e emite intenção. De onde vêm as mensagens (REST, o createWebSocket do SDK, um stream SSE) e como o insert otimista é feito ficam com o app, porque isso muda por backend.

A rolagem só pula pro fim se você já estava no fim

Uma thread que sempre rola pra mensagem nova arranca quem está lendo o histórico, toda vez que qualquer pessoa digita. Então o pulo acontece só quando o leitor já estava embaixo (com 48px de folga pra última linha parcialmente visível) — a regra pra qual todo app de chat converge. Verificado no browser: lendo o histórico no topo, três mensagens chegaram e a posição não se moveu.

Bloco quebra por autor, por dia e por intervalo

Repetir avatar e nome em cada linha de uma rajada de cinco transforma conversa em lista de recibos. Mas uma resposta uma hora depois é um novo momento da conversa mesmo que ninguém tenha falado no meio — juntar ao bloco anterior colocaria um timestamp só em mensagens separadas por uma hora. O groupWindowMs é esse limite.

Estado de falha não é enfeite

Sem "failed" + onRetry, o usuário redigita o que já está na tela. A bolha que falhou mantém o texto legível (borda e meta em vermelho, não o fundo inteiro) justamente porque reler a mensagem é o que a pessoa faz antes de decidir reenviar.

A thread é role=\"log\" com aria-live=\"polite\" e alcançável por teclado

Mensagem nova é anunciada sem roubar o foco. O contêiner tem tabIndex={0} porque uma área que rola e não tem nada focável dentro é inacessível pelo teclado — o mesmo problema que a correção de rolagem resolveu no Table. Estado de entrega vai em texto (VisuallyHidden), não só no glifo: "✓✓" não é lido.

Serve como thread de comentários

É o mesmo componente sem currentUserId e sem typing: todos do mesmo lado, nome por bloco. Foi por isso que "quem sou eu" virou uma prop em vez de um campo own em cada mensagem — num comentário de documento ninguém quer marcar 200 mensagens.

ChatComposer

Exportado à parte pra quem monta o próprio layout (composer fixo no rodapé de uma rota, por exemplo). Textarea que cresce com o conteúdo, Enter envia, Shift+Enter quebra linha.

Prop Tipo Default O que faz
onSend (text: string) => void \| Promise<void> Recebe o texto trimado. Limpa o campo só se não rejeitar.
onError (error: unknown) => void Erro do onSend. Rascunho preservado de qualquer forma.
actions ReactNode Antes do botão de enviar — anexo, emoji.
maxRows number 6 Altura máxima, em linhas.
sendLabel string locale Rótulo do botão.

Ele é não controlado, de propósito

Rascunho de chat muda a cada tecla, e subir isso pro estado do app re-renderiza a thread inteira por caractere — o único lugar onde "controlado por default" custa algo visível. Quem precisa do rascunho (composer persistido, menu de slash-command) lê pelo onChange ou usa o ref (focus(), setValue()).

IME: Enter durante composição não envia

Compondo japonês ou coreano, Enter confirma a palavra candidata. Enviar ali publica meia palavra e come a confirmação — daí a checagem de isComposing.

AIChat

Quando usar: conversa com um modelo — copiloto do seu app, assistente de suporte, busca conversacional. É a forma que o ChatGPT, o Claude e o DeepSeek convergiram.

Turnos por papel (user / assistant / system), resposta em Markdown com bloco de código, raciocínio em bloco separado, cursor de streaming, ações por turno (copiar, gerar de novo, editar, 👍/👎) e um composer que vira botão de parar enquanto a resposta chega.

AIChat e Chat são componentes diferentes, não variantes

Uma thread humana é endereçada por autor e se preocupa com estado de entrega. Um transcript de modelo é endereçado por papel, não tem estado de entrega nenhum, e precisa de três coisas que uma thread humana nunca precisa: saída parcial, raciocínio separado da resposta e re-perguntar. Encaixar os dois num variant misturaria dois modelos de dados no mesmo props e deixaria authorId/ticks mortos no caminho LLM.

Comece com o mínimo — uma lista e um onSend:

import { AIChat, type AIChatMessage } from "tempest-react-sdk";
import { useState } from "react";

export function Copiloto() {
  const [turnos, setTurnos] = useState<AIChatMessage[]>([]);

  const perguntar = async (texto: string) => {
    setTurnos((atual) => [
      ...atual,
      { id: crypto.randomUUID(), role: "user", content: texto },
    ]);
    const resposta = await fetch("/api/ask", {
      method: "POST",
      body: JSON.stringify({ prompt: texto }),
    }).then((r) => r.json());
    setTurnos((atual) => [
      ...atual,
      { id: crypto.randomUUID(), role: "assistant", content: resposta.text },
    ]);
  };

  return <AIChat messages={turnos} onSend={perguntar} />;
}

Isso já te dá o transcript, o Markdown, o composer, o Enter/Shift+Enter, a rolagem que segue a resposta e a ação de copiar. O que falta é o streaming — e é aí que o componente ganha a cara de produto.

Streaming, do zero

O SDK não faz a chamada por você: "como eu faço streaming do meu backend" tem resposta diferente por provider. O que ele faz é renderizar o estado. O contrato é simples — vá reescrevendo o content do último turno e mantenha streaming: true nele até acabar:

import { AIChat, type AIChatMessage } from "tempest-react-sdk";
import { useRef, useState } from "react";

export function CopilotoStreaming() {
  const [turnos, setTurnos] = useState<AIChatMessage[]>([]);
  const [pendente, setPendente] = useState(false);
  const abortar = useRef<AbortController | null>(null);

  /** Reescreve o último turno a cada chunk — o componente segue o texto sozinho. */
  const escrever = (id: string, texto: string) =>
    setTurnos((atual) =>
      atual.map((t) => (t.id === id ? { ...t, content: texto } : t)),
    );

  const perguntar = async (prompt: string) => {
    const idResposta = crypto.randomUUID();
    setTurnos((atual) => [
      ...atual,
      { id: crypto.randomUUID(), role: "user", content: prompt },
    ]);
    setPendente(true);

    abortar.current = new AbortController();
    const resposta = await fetch("/api/stream", {
      method: "POST",
      body: JSON.stringify({ prompt }),
      signal: abortar.current.signal,
    });

    setPendente(false);
    setTurnos((atual) => [
      ...atual,
      { id: idResposta, role: "assistant", content: "", streaming: true },
    ]);

    const leitor = resposta.body!.pipeThrough(new TextDecoderStream()).getReader();
    let acumulado = "";
    try {
      while (true) {
        const { value, done } = await leitor.read();
        if (done) break;
        acumulado += value;
        escrever(idResposta, acumulado);
      }
    } catch (erro) {
      if ((erro as Error).name !== "AbortError") throw erro;
    } finally {
      setTurnos((atual) =>
        atual.map((t) =>
          t.id === idResposta ? { ...t, streaming: false } : t,
        ),
      );
    }
  };

  return (
    <AIChat
      messages={turnos}
      pending={pendente}
      onSend={perguntar}
      onStop={() => abortar.current?.abort()}
      composerFooter={<small>Pode errar  confira números antes de decidir.</small>}
    />
  );
}

O que você ganha de graça nesse trecho:

Você fez O componente faz
pending enquanto a request está no ar Mostra os três pontinhos e já troca Enviar por Parar
streaming: true no último turno Desenha o cursor no fim do texto e esconde as ações daquele turno
Reescreve content a cada chunk Rola pra acompanhar — só se o leitor já estava embaixo
onStop Botão de parar no lugar do enviar, e Escape no campo também aborta
streaming: false no fim Cursor sai, ações voltam, e o leitor de tela anuncia "Resposta concluída"

Se seu backend fala SSE, use o createEventStream do SDK

O laço acima é fetch + ReadableStream porque é o caminho comum de APIs de LLM. Pra um endpoint text/event-stream de verdade, o sse do SDK já cuida de reconexão e Last-Event-ID — o loop de escrever() é o mesmo.

Raciocínio (extended thinking / R1)

Um turno com reasoning ganha um bloco colapsável acima da resposta:

{
  id: "a1",
  role: "assistant",
  content: "São 12 pedidos.",
  reasoning: "Filtrei por data de entrega vencida e status != entregue…",
}

Enquanto só o raciocínio chegou, o bloco abre sozinho

Se o turno está com streaming: true e o content ainda está vazio, o bloco de raciocínio monta aberto — é o único conteúdo que existe, e escondê-lo deixaria a tela parada com um cursor piscando no vácuo. Terminou, ele continua aberto (quem quiser fecha); usar defaultReasoningOpen abre todos, o que serve pra uma tela de auditoria.

Ações por turno

Ação Aparece em Prop que liga
Copiar todo turno sempre (copia o Markdown cru, não o HTML)
Gerar de novo o turno de assistente mais novo onRegenerate
👍 / 👎 turno de assistente onFeedback
Editar turno de usuário onEditSubmit
Tentar de novo turno com error onRetry
<AIChat
  messages={turnos}
  onRegenerate={(turno) => reperguntar(turno)}
  onFeedback={(turno, voto) => track("answer_rated", { id: turno.id, voto })}
  onEditSubmit={(turno, texto) => {
    truncarAPartirDe(turno.id);   // seu app decide o que cai
    return perguntar(texto);
  }}
  votes={votosSalvos}             // opcional: votos que vieram do banco
/>

Gerar de novo aparece só no último turno de assistente — de propósito

Re-perguntar um turno do meio joga fora todo turno depois dele. Isso é uma operação diferente ("ramificar aqui") e precisa da própria confirmação; oferecer o mesmo botão nos dois casos convida a perder metade da conversa num clique.

Editar não decide o que apagar

O onEditSubmit te entrega o turno e o texto novo. Quem trunca o transcript é o app, porque "apagar tudo depois" e "criar uma ramificação" são produtos diferentes e o SDK não deve escolher por você.

Prompts sugeridos e estado vazio

<AIChat
  messages={[]}
  onSend={perguntar}
  suggestions={["Resuma o último relatório", "Quais pedidos atrasaram?"]}
/>

Numa conversa vazia as sugestões aparecem no rodapé da área de transcript; clicar em uma envia direto. Somem no primeiro turno. Sem onSend elas não são renderizadas (não haveria pra onde mandar) e cai no EmptyState — ou no seu emptyState.

Props

Prop Tipo Default O que faz
messages AIChatMessage[] O transcript, mais antigo primeiro. Nunca reordenado.
onSend (text: string) => void \| Promise<void> Renderiza o composer. Recebe o prompt já trimado.
onStop () => void Aborta o turno no ar. Troca enviar por parar; Escape também aborta.
pending boolean false Request no ar, nada de volta ainda.
onRegenerate (message) => void Liga o "gerar de novo" no último turno de assistente.
onEditSubmit (message, text) => void \| Promise<void> Liga o "editar" nos turnos de usuário.
onFeedback (message, vote) => void Liga 👍/👎. vote"up" \| "down".
onRetry (message) => void Liga o retry num turno com error.
onSendError (error: unknown) => void Erro do onSend ou do onEditSubmit. Rascunho preservado.
votes Record<string, AIChatVote> Votos que o app guarda. Sem isso o estado pressionado é local.
suggestions string[] [] Prompts oferecidos numa conversa vazia.
renderAvatar (message) => ReactNode Avatar por turno.
renderContent (message) => ReactNode Substitui o corpo — card de tool-call, gráfico, lista de citações.
showSystem boolean false Mostra turnos "system".
defaultReasoningOpen boolean false Abre todos os blocos de raciocínio.
showLineNumbers boolean false Numera linha em bloco de código.
header ReactNode Barra acima do transcript, dentro do painel.
composerActions ReactNode Antes do botão de enviar — anexo, seletor de modelo.
composerFooter ReactNode Abaixo do campo — contagem de token, disclaimer.
composerDisabled boolean false Sem crédito, conversa arquivada, offline.
maxRows number 8 Altura máxima do composer, em linhas.
locale "pt-BR" \| "en" "pt-BR" Rótulos ("Parar", "Raciocínio", "Você"…).
emptyState ReactNode <EmptyState/> Conversa vazia.

AIChatMessage = { id, role, content, reasoning?, streaming?, error?, createdAt?, model?, attachments?, data? } · role"user" \| "assistant" \| "system".

AIChatAttachment = { id, name, size?, url?, mimeType? } — com url vira miniatura, sem url vira chip com nome e tamanho.

Decisões que valem saber

Resposta é Markdown, prompt é texto puro

Um modelo emite Markdown por contrato. Uma pessoa que digitou calcule 2 * 3 * 4 não quis abrir um span de ênfase — e ver o próprio prompt reescrito é desconcertante. Por isso o turno de usuário é white-space: pre-wrap e o de assistente passa pelo Markdown (que já usa o CodeBlock nos blocos cercados). Quer Markdown no prompt também? renderContent.

A resposta é o documento, não uma bolha

Turno de assistente ocupa a largura toda, sem bolha; turno de usuário é uma bolha estreita encostada no fim da linha. Envolver a resposta numa bolha limitaria a largura dela, brigaria com as tabelas e blocos de código dentro, e faria resposta longa parecer mensagem gritada. O prompt é curto e precisa ser distinguido num relance, o que a bolha faz melhor que qualquer outra coisa.

O transcript não é aria-live — e isso é acessibilidade, não descuido

Uma região viva sobre texto em streaming faz o leitor de tela reler a resposta a cada token: inutilizável. Então o role="log" fica sem aria-live, e os dois momentos que importam ("Gerando resposta", "Resposta concluída") são anunciados por um role="status" separado. O turno em andamento leva aria-busy, e a resposta pronta é lida do log no ritmo de quem lê. O axe do jsdom não pega esse tipo de erro — foi decisão de projeto, verificada no browser.

A rolagem só segue a resposta se você já estava no fim

Mesma regra do Chat, e aqui ela pesa mais: um transcript que sempre pula pro texto novo arrancaria o leitor dezenas de vezes por segundo durante o streaming. Quando você não está no fim, aparece um botão redondo pra voltar — o pulo nunca acontece sem você pedir.

A dependência do efeito de rolagem não é a lista

Streaming acrescenta ao último turno. Um app que mutasse esse objeto no lugar — ou que re-renderizasse de uma store guardando o mesmo array — manteria a mesma dependência enquanto o texto cresce, e a visão pararia de seguir a resposta. É por isso que existe tailSignature() (exportado): tamanho da lista + identidade do último turno + tamanho do texto dele cobrem as duas formas.

Só o turno que cresce re-parseia

O Markdown parseia no próprio render, e o React não re-renderiza um filho cujo elemento é referencialmente o mesmo. Segurar esse elemento entre renders é o que impede um transcript de cinquenta turnos de re-parsear toda resposta já pronta a cada token da mais nova.

Parar ocupa o lugar do enviar, não um botão ao lado

O único botão embaixo do dedo é sempre o que você quer a seguir: enviar quando está parado, abortar quando a resposta está vindo. Dois botões lado a lado significariam acertar o certo no meio do stream.

Ação escondida em :hover é ação inexistente no touch

A linha de ações aparece no hover e no foco de teclado, e fica sempre visível onde não existe hover (@media (hover: none)). Sem isso, num celular o primeiro toque cairia no que estiver embaixo. Verificado com device de toque emulado: hover: none e pointer: coarse verdadeiros, linha com opacity: 1.

Os botões medem 28×28 — acima do piso de 24×24 da WCAG 2.5.8, abaixo dos 44×44 da 2.5.5, e são quatro lado a lado. Em pointer: coarse um hit-slop de ::after leva o alvo real a 44×44 sem mover um pixel do que se vê, o mesmo truque que o Button usa nos tamanhos icon-only. Aumentar o padding em vez disso espalharia a linha no desktop, onde o ponteiro é preciso e a linha deve ficar quieta.

Responsivo: de celular a TV

Medido no browser em 360×640, 390×844, 740×360 (celular em paisagem), 768×1024, 1440×900, 1920×1080 e 3840×2160. Em toda largura: zero overflow horizontal na página e no transcript, composer sempre visível, tabela e bloco de código rolando na própria caixa.

O que muda com a largura:

Faixa O que acontece
até 480px gap e padding do transcript encurtam, bolha do usuário e editor vão a max-width: 100%
480px – 768px a coluna de leitura acompanha a largura disponível
768px e acima a coluna trava em 48rem e centraliza; a sobra fica de margem

A largura da coluna é um knob: --tempest-ai-chat-width

Coluna limitada é a resposta certa do celular até um desktop 1920 — texto passando de ~90 caracteres por linha é mensuravelmente mais difícil de rastrear de volta ao começo da linha seguinte, e deixar a resposta correr a tela toda de um monitor largo piora, não ajuda.

De 2560 pra cima a troca se inverte: 768px no meio de uma tela de sala é quase só espaço vazio, e só o app sabe a que distância a pessoa está sentada. Por isso é knob e não constante:

:root {
  --tempest-ai-chat-width: 72rem; /* default 48rem */
}

Um valor só move os turnos, o indicador de "pensando", as sugestões e o composer juntos.

Tamanho de tipo não é resolvido aqui

A fonte é a mesma em 360px e em 4K. Escalar tipo pra TV é decisão de typography.css e density.css — uma rampa de fonte local ao componente brigaria com os tokens que todo app tematiza. Se você mira TV, suba --tempest-text-* no :root (ou use [data-tempest-density="spacious"]) junto com --tempest-ai-chat-width.

AIChatComposer e AIChatTurn

Exportados à parte pra quem monta o próprio layout — um composer fixo no rodapé de uma rota, um diff lado a lado de duas respostas. Mesmas props relevantes do painel, e o AIChatComposer é não controlado pelo mesmo motivo do ChatComposer: rascunho muda a cada tecla, e subir isso pro estado do app re-renderiza o transcript inteiro por caractere — com uma resposta em streaming em cima, isso é visível.

Helper exportado Pra que serve
visibleTurns({ messages, showSystem }) A lista que o painel realmente renderiza.
isGenerating(messages) Algum turno está em streaming.
lastAssistantId(messages) Qual turno recebe o "gerar de novo".
tailSignature(messages) Dependência de efeito que muda quando a cauda cresce.
aiChatStrings(locale) · roleLabel(role, strings) · turnTime(ts, locale) Rótulos, pra reusar num layout próprio.

Recap

  • Conversa: Chat para thread entre pessoas (autor, entrega, digitando) e AIChat para conversa com um modelo (papel, streaming, raciocínio, re-perguntar). São componentes diferentes, não variantes.
  • Todos seguem os mesmos padrões controlado/não-controlado, expõem A11y por teclado e importam de tempest-react-sdk.