Mischief

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.

You

Why is the migration failing?

Assistant

The unique index is created before the backfill runs, so rows still holding a null email collide.

You

Can you reorder it?

Assistant

Yes. Moving the backfill above the index is enough, and the test suite passes after that.

You

What about existing rows?

Assistant

They are covered by the same backfill, so nothing else needs to change.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/conversation
import { Conversation } from "mischief-ui/conversation"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/conversation/conversation.tsx
"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>
Raise threshold when messages are tall, so near the bottom still counts as the bottom.

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.