Saltar al contenido principal
NachUI

Conversation

El hilo con scroll de un chat, pegado al último mensaje mientras llega el stream y fácil de retomar cuando te alejás.

Which component do I use for an agent run?
Task. One per step, each with a status, and Task.File for the files it touched.
And for the model thinking out loud?
Reasoning. It opens while the stream runs and folds away once the answer starts.
How do I show the files a user attached?
Attachments, as a grid, a row of chips or a list. The composer already uses it.
Can I keep the thread pinned to the latest message?
That is this component. Scroll up and a button appears to bring you back down.
1'use client';
2
3import { Conversation } from '@/components/ui/conversation';
4import { Bubble } from '@/components/ui/bubble';
5import { Message } from '@/components/ui/message';
6
7const THREAD = [
8 { role: 'user', text: 'Which component do I use for an agent run?' },
9 {
10 role: 'assistant',
11 text: 'Task. One per step, each with a status, and Task.File for the files it touched.',
12 },
13 { role: 'user', text: 'And for the model thinking out loud?' },
14 {
15 role: 'assistant',
16 text: 'Reasoning. It opens while the stream runs and folds away once the answer starts.',
17 },
18 { role: 'user', text: 'How do I show the files a user attached?' },
19 {
20 role: 'assistant',
21 text: 'Attachments, as a grid, a row of chips or a list. The composer already uses it.',
22 },
23 { role: 'user', text: 'Can I keep the thread pinned to the latest message?' },
24 {
25 role: 'assistant',
26 text: 'That is this component. Scroll up and a button appears to bring you back down.',
27 },
28] as const;
29
30export function Default() {
31 return (
32 <div className="border-border h-72 w-full max-w-md overflow-hidden rounded-xl border">
33 <Conversation className="h-full">
34 <Conversation.Content>
35 {THREAD.map((message, index) => (
36 <Message key={index} align={message.role === 'user' ? 'end' : 'start'}>
37 <Message.Content>
38 <Bubble variant={message.role === 'user' ? 'default' : 'muted'}>
39 <Bubble.Content>{message.text}</Bubble.Content>
40 </Bubble>
41 </Message.Content>
42 </Message>
43 ))}
44 </Conversation.Content>
45 <Conversation.ScrollButton />
46 </Conversation>
47 </div>
48 );
49}

Instalación

pnpm dlx nachui add conversation

Anatomía

1import { Conversation } from '@/components/ui/conversation';
1<Conversation className="h-96">
2 <Conversation.Content>
3 {messages.map((message) => (
4 <Message key={message.id}>...</Message>
5 ))}
6 </Conversation.Content>
7 <Conversation.ScrollButton />
8</Conversation>
La raíz es el contenedor con scroll, así que dale una altura. Se renderiza como una región en vivo de tipo polite, que es como un lector de pantalla se entera de los mensajes nuevos sin que lo interrumpan a mitad de una frase.

Composición

Usa la siguiente composición para construir un Conversation:
Conversation
├── Conversation.Content
├── Conversation.ScrollButton
└── Conversation.Empty

Pegado al fondo

Mientras el usuario está abajo, el contenido nuevo mantiene el hilo pegado ahí, que es lo que querés durante un stream. Apenas scrollea hacia arriba el hilo lo suelta, y aparece el botón para volver al último mensaje con un click. Si baja a mano, se vuelve a pegar solo.
Nothing yetStart the stream and scroll up to see the thread let go.
1'use client';
2
3import { useEffect, useState } from 'react';
4import { Conversation } from '@/components/ui/conversation';
5import { Bubble } from '@/components/ui/bubble';
6import { Message } from '@/components/ui/message';
7
8const LINES = [
9 'Reading the registry.',
10 'Found 58 components across three families.',
11 'Matching the request to prompt-input.tsx.',
12 'Writing the demo.',
13 'Regenerating the registry.',
14 'Running the type check.',
15 'Done. Two files changed.',
16];
17
18export function Streaming() {
19 const [running, setRunning] = useState(false);
20 const [messages, setMessages] = useState<string[]>([]);
21
22 useEffect(() => {
23 if (!running) return;
24 const interval = window.setInterval(() => {
25 setMessages((previous) => {
26 const next = LINES[previous.length % LINES.length];
27 return next ? [...previous, next] : previous;
28 });
29 }, 900);
30 return () => window.clearInterval(interval);
31 }, [running]);
32
33 return (
34 <div className="flex w-full max-w-md flex-col gap-4">
35 <div className="flex items-center gap-2">
36 <button
37 type="button"
38 onClick={() => setRunning((previous) => !previous)}
39 className="border-border hover:bg-muted w-fit rounded-full border px-3 py-1.5 text-xs transition-colors"
40 >
41 {running ? 'Stop stream' : 'Start stream'}
42 </button>
43 <button
44 type="button"
45 onClick={() => setMessages([])}
46 className="text-muted-foreground hover:text-foreground px-2 py-1.5 text-xs transition-colors"
47 >
48 Clear
49 </button>
50 </div>
51 <div className="border-border h-64 w-full overflow-hidden rounded-xl border">
52 <Conversation className="h-full">
53 {messages.length === 0 ? (
54 <Conversation.Empty>
55 <span className="text-foreground">Nothing yet</span>
56 <span>Start the stream and scroll up to see the thread let go.</span>
57 </Conversation.Empty>
58 ) : (
59 <Conversation.Content>
60 {messages.map((text, index) => (
61 <Message key={index}>
62 <Message.Content>
63 <Bubble variant="muted">
64 <Bubble.Content>{text}</Bubble.Content>
65 </Bubble>
66 </Message.Content>
67 </Message>
68 ))}
69 </Conversation.Content>
70 )}
71 <Conversation.ScrollButton />
72 </Conversation>
73 </div>
74 </div>
75 );
76}
Poné stickToBottom={false} para apagar el anclaje y quedarte solo con el contenedor y el botón.

Estado vacío

Conversation.Empty llena el hilo antes del primer mensaje. Metele la intro o un par de sugerencias, y cambialo por Conversation.Content cuando haya algo que mostrar.

useConversation

useConversation() lee si el hilo está pegado y expone scrollToBottom, así un composer puede bajar el hilo después de enviar.
1const { stuck, scrollToBottom } = useConversation();

Referencia de API

Conversation

PropTypeDefaultDescription
stickToBottombooleantrueMantiene el hilo pegado mientras crece el contenido
onStickChange(stuck: boolean) => void-Se llama cuando el hilo se pega o se suelta
classNamestring-Clases CSS adicionales, dale una altura

Conversation.Content

PropTypeDefaultDescription
classNamestring-Clases CSS adicionales

Conversation.ScrollButton

PropTypeDefaultDescription
labelstring'Scroll to bottom'Nombre accesible del botón
childrenReactNode-Reemplaza el icono del chevron
classNamestring-Clases CSS adicionales

Conversation.Empty

PropTypeDefaultDescription
classNamestring-Clases CSS adicionales
¿Encontraste algo que mejorar?

¿Notaste un error, tipografía o detalle faltante en esta página? Ayúdanos a mejorar la documentación abriendo un issue en GitHub.

Crear un Issue