Message List
Scroll container with smart auto-scroll: it follows a streaming answer, and stops the moment the reader scrolls away.
Here is a quick comparison:
Installation
npx shadcn@latest add miskibin/chat-components/message-listAlso 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.
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.
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.
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.
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.
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.
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.
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 — answer 1. Step 1 of the trail that makes this list scroll.
Flaky tests — answer 2. Step 3 of the trail that makes this list scroll.
Flaky tests — answer 3. Step 5 of the trail that makes this list scroll.
Flaky tests — answer 4. Step 7 of the trail that makes this list scroll.
Flaky tests — answer 5. Step 9 of the trail that makes this list scroll.
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.
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
Messageplus anid. 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
filePreviewFromToolandFilePreview. 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
undefinedfor 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