Como construir uma UI de chat com IA usando shadcn/ui e o AI SDK da Vercel que sobreviva a usuários reais
Um chat com IA em streaming é fácil de demonstrar e difícil de entregar. Os estados que um chat com shadcn/ui e AI SDK precisa tratar, de parar e tentar de novo a aprovações de ferramentas e entrada CJK.
Preet Suthar · · 6 min de leitura
- AI
- React
- shadcn/ui
- Next.js
A demo leva uma tarde. useChat do AI SDK, uma lista de mensagens, um textarea, e os tokens aparecem na tela em streaming. Todo mundo no time fica impressionado.
Então uma pessoa de verdade usa. Ela aperta Enter enquanto compõe em japonês e envia meia palavra. Ela para uma resposta e a UI não mostra nada. Uma chamada de ferramenta pede aprovação no meio do stream e não existe UI para isso. Ela rola para cima para reler algo e a visualização a puxa de volta para o fim a cada token.
Eu construí essa tela mais vezes do que gostaria, mais recentemente como os blocos por trás do HextaUI. Este é o checklist do que uma UI de chat precisa tratar antes de estar pronta, com os detalhes de shadcn/ui e AI SDK para cada item.
Comece por partes de mensagem, não por strings
O useChat do AI SDK retorna mensagens feitas de partes: texto, raciocínio, chamadas de ferramenta com o estado delas, fontes, arquivos. Renderize as partes, não uma string concatenada. Todo recurso abaixo depende disso.
Partes de ferramenta são tipadas como tool-<name>, então isToolUIPart é a verificação confiável.
Mantenha seus blocos livres do import do SDK. Receba as partes como props. Assim os mesmos componentes funcionam com um backend LangGraph ou com o seu próprio servidor depois que você mapeia a saída dele para o mesmo formato.
O compositor
O textarea é onde mora a maioria dos bugs.
Enter envia, Shift+Enter quebra a linha. Todo mundo faz isso. Quase ninguém trata a composição. Quando alguém digita em japonês, chinês ou coreano, o IME está aberto e o Enter confirma o candidato, não a mensagem. Verifique event.nativeEvent.isComposing e saia:
Cresça com o texto, dentro de limites. Uma linha para começar, até cerca de oito linhas, depois scroll. Anime a mudança de altura, e nunca via estado do React a cada tecla. Meça o scrollHeight em um layout effect e defina a altura no nó do DOM. Qualquer setState em um listener nativo de input vai acabar perdendo ou atrasando teclas sob carga.
Rascunhos sobrevivem a um reload. Salve o rascunho no localStorage com o id do chat como chave, com debounce. As pessoas perdem prompts longos em refreshes acidentais e nunca te perdoam.
Enfileire mensagens digitadas durante uma resposta. Se o modelo ainda está escrevendo e o usuário envia outra mensagem, ou desabilite o envio ou enfileire-a e envie quando o stream terminar. Descartá-la em silêncio é a pior opção, e é o padrão.
Anexos mostram progresso e nova tentativa. Uploads falham. Mostre a falha no card do anexo com uma opção de tentar de novo, não como um toast que some.
Streaming que se lê bem
Modelos emitem tokens em rajadas. Se você renderiza exatamente o que chega, o texto engasga: nada, depois doze palavras, depois nada. Suavize. Revele os caracteres em um ritmo constante que acelera quando o buffer cresce, para nunca ficar muito atrás do stream, mas também nunca engasgar.
Markdown durante o streaming tem a própria armadilha: sintaxe pela metade. O modelo escreveu **bold e ainda não fechou, então o seu renderizador mostra asteriscos literais por um frame e depois texto em negrito. Segure a sintaxe inline incompleta até ela fechar, ou até o stream terminar.
Blocos de código precisam do mesmo cuidado. Faça o highlight de forma incremental e nunca refaça o highlight do bloco inteiro a cada token.
Parar, tentar de novo, editar e versões
Parar deve deixar a resposta parcial na tela, com um marcador "Stopped" visível para o usuário saber que está incompleta. Não a apague.
Tentar de novo em uma resposta com falha é um botão na mensagem, não um gesto escondido. Conecte-o a regenerate().
Editar uma mensagem anterior cria uma ramificação. A resposta antiga e a nova existem. Mostre um pequeno seletor 1 / 2 na mensagem para o usuário alternar entre as versões. É aqui que muita UI de chat feita em casa desiste, e é o recurso do ChatGPT e do Claude que as pessoas mais sentem falta.
Respostas com falha recebem um motivo e uma nova tentativa. "Algo deu errado" sem nenhuma ação é um beco sem saída.
Rolagem
Acompanhe os novos tokens quando o usuário está no fim. No momento em que ele rola para cima, pare de acompanhar e mostre um botão "ir para a mais recente" com a contagem do que ele perdeu. Quando ele pular, volte a acompanhar.
Carregar histórico mais antigo no topo não pode mover a visualização. Meça a altura do scroll antes e depois de inserir no início, e ajuste o scrollTop pela diferença no mesmo frame.
Se o usuário envia uma mensagem, role para que a mensagem dele fique fixada no topo da viewport e a resposta seja transmitida logo abaixo. É assim que os dois grandes apps de chat se comportam hoje, e os usuários esperam isso.
Chamadas de ferramenta e aprovações
O AI SDK dá a cada parte de ferramenta um estado: input-streaming, input-available, output-available, output-error e, com human-in-the-loop, approval-requested, approval-responded e output-denied. Todo estado precisa de uma aparência.
Leituras e buscas podem ser recolhidas em um resumo de uma linha. Edições, comandos e erros devem continuar expandidos. Aprovações bloqueiam o stream e precisam de um Allow e um Deny claros, mais uma forma de dizer "faça isso em vez disso" que envia texto de volta ao modelo como motivo da recusa.
Mostre o resultado quando for útil. Uma edição de arquivo abre em um diff. Um comando abre em um terminal com a saída. Uma busca abre em resultados. Uma chamada de ferramenta que só diz "Done" ensina os usuários a ignorar chamadas de ferramenta.
Raciocínio e fontes
Partes de raciocínio devem ser recolhíveis e vir recolhidas por padrão assim que a resposta começa. Enquanto o modelo ainda está pensando, um rótulo ao vivo que acompanha cada etapa ("Buscando nas docs", "Lendo três arquivos") tranquiliza muito mais do que um spinner.
Citações ficam ao lado das afirmações que sustentam, inline, não em uma lista no fim que ninguém lê.
Acessibilidade que você não pode pular
- Mensagens novas são anunciadas por uma região
role="log", para que usuários de leitor de tela ouçam as respostas. - O botão de parar é alcançável pelo teclado e tem um rótulo.
- Texto em streaming não deve disparar cem anúncios de live region. Anuncie quando a mensagem for concluída, ou nos limites de frase.
- Todo botão só com ícone tem
aria-label.
Juntando tudo
Se você quer as peças, o shadcn/ui lançou o AI Elements em outubro de 2026: 21 padrões que você copia para o seu projeto e compõe por conta própria, incluindo Chat, Prompt Input, Reasoning e Tool. A biblioteca AI Elements anterior da Vercel está sendo descontinuada no fim de novembro de 2026 em favor deles.
Se você quer as telas já montadas com os estados acima resolvidos, é isso que os blocos do HextaUI são: um Chat Thread com parar, tentar de novo e versões, Tool Calls com todos os estados do AI SDK e aprovações, Streaming e Markdown que nunca mostram sintaxe pela metade, e um Prompt Input que trata entrada CJK, uploads, filas e rascunhos. O Prompt Input é gratuito. O resto é uma compra única. Tudo lê partes de mensagem do AI SDK e nada importa o SDK, então funciona com qualquer backend que você consiga mapear.
Você pode experimentar tudo isso montado como um app de chat em hextaui.com/blocks/hexta-ai antes de instalar qualquer coisa.
FAQ
Preciso do AI SDK da Vercel para usar esses padrões? Não. O formato de partes de mensagem é a parte útil. Qualquer backend funciona depois que você mapeia a saída dele para partes.
Como trato o Enter com um IME? Verifique event.nativeEvent.isComposing no seu handler de keydown e retorne cedo.
O texto em streaming deve atualizar uma live region a cada token? Não. Anuncie ao concluir ou nos limites de frase, ou os leitores de tela ficam inutilizáveis.
O que acontece com uma resposta interrompida? Mantenha o texto parcial e marque-o como interrompido. Nunca apague o que o usuário já leu.