15 / Agent UI
Conversation
The scroll container a thread lives in. It follows a streaming reply to the bottom, and stops the moment the reader scrolls up to read something older.
"use client"
import * as React from "react"
import { Conversation } from "mischief-ui/conversation"
import { Message } from "mischief-ui/message"
/** Stands in for a real profile picture, and is deliberately not square. */
const portrait =
"data:image/svg+xml;utf8," +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 72">
<rect width="120" height="72" fill="#d8cfc4"/>
<circle cx="60" cy="30" r="15" fill="#8a7f72"/>
<path d="M28 72c4-18 18-26 32-26s28 8 32 26z" fill="#8a7f72"/>
</svg>`
)
const turns = [
{ id: 1, role: "user" as const, text: "Why is the migration failing?" },
{
id: 2,
role: "assistant" as const,
text: "The unique index is created before the backfill runs, so rows still holding a null email collide.",
},
{ id: 3, role: "user" as const, text: "Can you reorder it?" },
{
id: 4,
role: "assistant" as const,
text: "Yes. Moving the backfill above the index is enough, and the test suite passes after that.",
},
{ id: 5, role: "user" as const, text: "What about existing rows?" },
{
id: 6,
role: "assistant" as const,
text: "They are covered by the same backfill, so nothing else needs to change.",
},
]
export function ConversationDemo() {
return (
<Conversation className="border-border bg-background h-72 w-full max-w-xl rounded-[var(--radius)] border px-3">
{turns.map((turn) => (
<Message
key={turn.id}
role={turn.role}
avatar={
turn.role === "user" ? (
// eslint-disable-next-line @next/next/no-img-element
<img alt="" src={portrait} />
) : (
"M"
)
}
>
{turn.text}
</Message>
))}
</Conversation>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/conversationimport { Conversation } from "mischief-ui/conversation"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { ArrowDown } from "lucide-react"import { cn } from "@/lib/utils" export type ConversationProps = React.HTMLAttributes<HTMLDivElement> & { stickToBottom?: boolean threshold?: number jumpLabel?: string showJumpButton?: boolean onFollowChange?: (following: boolean) => void} Usage
export function Thread({ messages }: { messages: Msg[] }) {
return (
<Conversation className="h-[32rem]">
{messages.map((message) => (
<Message key={message.id} role={message.role}>
{message.content}
</Message>
))}
</Conversation>
)
}Following the newest message
The viewport sticks to the bottom while it is already there, so a streaming answer stays in view. Scroll up and following stops immediately; come back within threshold pixels of the end and it resumes. That is what makes it possible to read back through a conversation while one is still arriving, without being dragged away mid-sentence.
A jump control appears whenever following has stopped, so getting back to the newest message is one click rather than a long scroll. onFollowChange reports the same state if you want to show something of your own.
<Conversation threshold={64} onFollowChange={setAtBottom}>
{messages.map((message) => (
<Message key={message.id} role={message.role}>
{message.text}
</Message>
))}
</Conversation>Turn the behaviour off entirely with stickToBottom={false} for a transcript that should open where it was left rather than at the end.
API
stickToBottombooleanFollows new content to the bottom. Defaults to true.thresholdnumberHow close to the bottom still counts as following, in pixels. Defaults to 48.showJumpButton, jumpLabelboolean, stringThe control offered once following has stopped.onFollowChange(following: boolean) => voidRuns when the reader leaves or returns to the bottom.Accessibility
Scrolling is never taken away from the reader. New content is followed only while they are already at the bottom, so scrolling up to read something older is not undone by the next token. Returning is an ordinary button rather than a gesture. The viewport uses contained overscroll so reaching the end does not scroll the page behind it.