Message List

Scroll container with smart auto-scroll: it follows a streaming answer, and stops the moment the reader scrolls away.

Open in
Show me a table and a TypeScript snippet.

Here is a quick comparison:

ModelSpeed
ComposerFast
OpusSlow
ts
export function greet(name: string) {  return `Hello, ${name}`}

Installation

npx shadcn@latest add miskibin/chat-components/message-list

Also installs message, generation-status.

Usage

"use client"

import { MessageList, type ChatMessageData } from "@/components/ui/message-list"

export function Conversation({ messages }: { messages: ChatMessageData[] }) {
  return <MessageList className="flex-1" messages={messages} />
}

Examples

Jump back to the bottom

Scroll up and a round button appears over the end of the list — while a turn streams too, which is the point. Clicking it scrolls down and re-arms following. It carries data-slot="message-list-jump" and data-visible.

What changed in the registry build?

Nothing in the registry build moved this week — the only edit was a comment, so the diff is one line. Scroll on: there are more turns below this one.

What changed in the docs nav?

Nothing in the docs nav moved this week — the only edit was a comment, so the diff is one line. Scroll on: there are more turns below this one.

What changed in the streaming path?

Nothing in the streaming path moved this week — the only edit was a comment, so the diff is one line. Scroll on: there are more turns below this one.

What changed in the sidebar groups?

Nothing in the sidebar groups moved this week — the only edit was a comment, so the diff is one line. Scroll on: there are more turns below this one.

What changed in the file panel?

Nothing in the file panel moved this week — the only edit was a comment, so the diff is one line. Scroll on: there are more turns below this one.

What changed in the change card?

Nothing in the change card moved this week — the only edit was a comment, so the diff is one line. Scroll on: there are more turns below this one.

Per-message actions

renderActions is skipped while a turn is still streaming or has an open question, and rendered outside the memoized row — so a button clicked on a settled turn runs the handler the caller has now, not the one that row first saw.

Model
Summarise the failing job.

The typecheck job fails on docs/component-doc.ts — a slug was added to the nav without a page.

Switching conversations

One mounted list, two transcripts. conversationKey is what tells it they are different conversations: scroll up in one, switch, and the other opens at its newest turn instead of inheriting a scroll position that was never its own.

Flaky tests — question 1?

Flaky tests — answer 1. Step 1 of the trail that makes this list scroll.

Flaky tests — question 2?

Flaky tests — answer 2. Step 3 of the trail that makes this list scroll.

Flaky tests — question 3?

Flaky tests — answer 3. Step 5 of the trail that makes this list scroll.

Flaky tests — question 4?

Flaky tests — answer 4. Step 7 of the trail that makes this list scroll.

Flaky tests — question 5?

Flaky tests — answer 5. Step 9 of the trail that makes this list scroll.

Flaky tests — question 6?

Flaky tests — answer 6. Step 11 of the trail that makes this list scroll.

Streaming a turn in

Pass isGenerating and grow the last message's content. Until the first token lands the list shows the generation dot where the answer will appear, and it keeps itself pinned to the bottom unless the reader scrolls away.

Explain how streaming renders.

Wider measure and an empty state

emptyState replaces the whole column while there are no messages. The measure lives on the list's inner wrapper, so [&>div] widens it past the default max-w-3xl.

No messages yet

Ask about a file, a failing job, or a diff you want reviewed.

Notes

API reference

MessageList

messages*ChatMessageData[]
Each item is a Message plus an id.
isGeneratingboolean = false
Marks the last assistant turn as streaming and shows the generation status while it is still empty.
generationStage"thinking" | "searching" | "responding" | "idle" = "idle"
Label for that status.
generationLabelstring
Replaces the stage word while the turn is still empty — for a backend that can say something more useful than “Thinking” (“Loading qwen3:8b into memory”). Drop it as soon as real output arrives.
onEditMessage(id: string, content: string) => void
Enables inline editing on user turns.
onAskAnswer(messageId: string, toolId: string, result: AskQuestionResult) => void
Fired when an Ask Question tool is submitted or skipped. Hide the composer send-as-skip yourself if you want Cursor's extra-details behavior.
onReviewChanges(messageId: string) => void
Makes the Review label on a turn's change card a button.
onOpenFile(messageId: string, tool: MessageToolCallData) => void
Fired when an Edit / Write / Read headline is clicked. Pair it with filePreviewFromTool and FilePreview.
onChangeFileClick(messageId: string, file: ChangeSummaryFile) => void
Fired when a row of a turn's change-summary card is clicked.
onFileReferenceClick(messageId: string, path: string, line?: number) => void
Fired when a file chip inside an answer's markdown is clicked, with the line the chip named — see MessageMarkdown.
fileActionsFileActionItem[]
Right-click menu for every file the transcript names — change cards, tool rows, path chips and images. Handed to the memoized rows by reference, so keep the array stable.
onQuote(messageId: string, text: string) => void
A selection quoted out of an assistant answer, for the composer. Stabilized like the other callbacks.
resolveFileUrl(path: string) => string | undefined
Turns a path a tool names into a URL the page can load — forwarded to every row so an image the agent wrote or read renders as a picture. Stabilized internally.
resolveFileUrl(path: string) => string | undefined
Turns a path a tool names into a URL this page can load. Only the host knows how a file on the machine reaches the browser, so without it an image an agent wrote or read renders as the stand-in line its harness returned. Return undefined for anything you cannot serve.
renderActions(message: ChatMessageData) => React.ReactNode
Rendered under each settled turn — copy, retry, feedback. It runs outside the memoized row, so the buttons always carry what the caller knows on this render rather than the closure the row last froze.
conversationKeystring
Identifies the conversation on screen. Pass it whenever one mounted list shows several transcripts in turn: on a change the list stops following the chat that was left and jumps to the end of the one just opened.
emptyStateReact.ReactNode
Shown instead of the list when there are no messages.
patternHandlersPatternHandler[] = []
Forwarded to every message.

useChatAutoScroll(messages, conversationKey?)

conversationKeystring
Optional argument, not a returned value: the conversation these messages belong to. On a change the hook forgets the reader's scroll position — it was formed in the chat they just left — and follows the new transcript from its end.
scrollRefRefObject<HTMLDivElement>
Attach to your own scroll container.
contentRefRefObject<HTMLDivElement>
Attach to the box inside it that actually holds the messages. Height that arrives after the last message — a mermaid diagram mounting, Shiki replacing a plain block, an image loading — re-pins a reader who is still following, which a message-keyed effect alone cannot see.
handleMessageScroll() => void
Call from onScroll. Re-arms following when the reader returns to the bottom.
atBottomboolean
Whether the reader is at (or within a few pixels of) the bottom — what shows and hides the jump button.
scrollToBottom() => void
Smooth-scrolls to the end and re-arms following.

Data slots

Every part below is addressable from a parent, so you can restyle internals without forking the component.

message-listmessage-list-itemmessage-list-jump