MessageList

Render the full transcript from ChatMessage[]. Use showCopyToolbar for user/assistant text copy, className for container sizing, and slots/classNames/toolRenderers for custom rendering. contentWidth sets the column width: keep the 420px default in a side widget, pass 720 or "100%" in a full-page chat. collapseToolRuns folds three or more consecutive read and search calls into one summary row, and a compaction part renders a CompactBoundary divider.

Presentation and tool context

A file part renders on both sides without a renderer: a picture from the person as a thumbnail in the bubble, a picture that came with an answer — from the agent or from a person writing beside it — as an image that opens fullscreen, and anything else as a file chip. labels.answerFile names the picture of an answer for screen readers, enableImagePreview={false} leaves the pictures unclickable, and partRenderers={{ file: … }} or createMediaPartRenderers() takes the part over entirely. MessageList takes the same transcript props as AgentChat: presentation ('cards', rowsPresentation or quietPresentation), toolCatalog, toolArgs, toolOutputs, locale, evenSpacing, workingRow, toolActivity, animateAppearance, frameBatched and withSearch. The defaults differ: AgentChat turns on frameBatched, animateAppearance, workingRow and toolActivity, while a standalone MessageList keeps all four off until you pass true. Its own labels cover the list: working, planning, search, toolRuns and the message toolbar. The rows inside a standalone list read their labels from ChatLabelsProvider and their approvals from ToolApprovalsProvider; AgentChat sets up both from its labels and approvals.

Custom parts, scrolling and screen readers

partRenderers renders part types the list does not know, keyed by part.type; each renderer gets part, messageId, index and chatStatus, and a part without one stays hidden. text and reasoning can be overridden as well: partRenderers.text replaces the Markdown of an answer, and reasoning, hidden by default, shows through its renderer; planning, the working row and copying still read the text parts. Create the object once, outside the component or with useMemo: a new object on every render re-renders finished messages, and the list warns about it in development. sendScroll="prompt-top" puts a sent question at the top and grows the answer under it; sendScroll="prompt-top-hold" also keeps the list still while the answer grows and follows it again only after the user scrolls to the bottom (wheel, touch, keyboard, scrollbar) or presses the new messages button, which shows once the answer reaches below the viewport; streamingCaret shows a caret after the growing text, and lazyTurns (a turn count, true means 50) skips layout and paint of finished turns out of view with content-visibility. All three are off by default in MessageList and AgentChat. The list is a log with aria-busy while an answer streams; labels.answerReady, labels.answerFailed or, after stopped, labels.answerStopped is announced once when it ends; AgentChat sets stopped from its stop button. messageActions.actions adds host buttons to the toolbar of each message, usually MessageActionButton.

Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-react
Examples
Presentations
Basic transcript
With timestamps
Collapsed tool runs
Compacted history
Source

The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.

MessageList/MessageList.tsx
import React, {
  memo,
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { Box, Button, CopyButton, Paper, Text, UnstyledButton } from '@mantine/core';
import { IconArrowDown, IconCheck, IconCopy, IconCornerLeftUp } from '@tabler/icons-react';
import type {
  ChatMessage,
  ChatStatus,
  CollapseToolRunsOptions,
  CustomToolRendererProps,
  PartRenderers,
  SendScroll,
  ToolActionHandler,
  ToolPart,
  ToolRendererSlotProps,
} from '../types';
import { createToolCallLookups, type ToolCallLookups } from '../tools/tool-call-state';
import { getContentWidthStyle, type ContentWidth } from '../utils/content-width';
import type { SyntaxHighlighter } from '../utils/highlighter';
import { cx } from '../utils/cx';
import { normalizeAssistantToolParts } from '../utils/tool-part-normalizer';
import { isErrorPart, isFilePart, isRecord, isTextPart, isV5ToolPart } from '../utils/parts';
import { UserMessage } from '../UserMessage/UserMessage';
import { Markdown, type MarkdownTailGranularity } from '../Markdown/Markdown';
import { ErrorMessage } from '../ErrorMessage/ErrorMessage';
import { ToolRowBase } from '../ToolRowBase/ToolRowBase';
import { SpiralLoader } from '../SpiralLoader/SpiralLoader';
import { WorkingLine } from './WorkingLine';
import type { TranscriptPresentation } from './transcript-presentation';
import { useToolApprovals } from '../approvals/approval-context';
import type { ToolOutputFormatters } from '../rows/tool-output';
import type { ToolArgsFormatters } from '../tools/tool-args';
import {
  ToolPresentationProvider,
  useToolPresentation,
  type ToolCatalog,
} from '../tools/tool-presentation';
import { ToolRenderer as DefaultToolRenderer } from '../tools/ToolRenderer';
import { CompactBoundary } from '../CompactBoundary/CompactBoundary';
import {
  groupToolRuns,
  resolveToolRunOptions,
  type ResolvedToolRunOptions,
  type ToolRunLabels,
} from './tool-runs';
import { ToolRunGroup } from './ToolRunGroup';
import { MessageActions } from '../message-actions/MessageActions/MessageActions';
import { EditMessageComposer } from '../message-actions/EditMessageComposer/EditMessageComposer';
import type { MessageListActions, SlashCommandInfo } from '../message-actions/types';
import { TurnSummary } from '../TurnSummary/TurnSummary';
import { ContextEventRow } from '../ContextEventRow/ContextEventRow';
import { HookActivity } from '../HookActivity/HookActivity';
import type { LongTextThreshold } from '../UserMessage/long-text';
import {
  carrySeenIds,
  countNewMessages,
  createFollowState,
  findStickyPromptTurn,
  followAfterResize,
  followAfterScroll,
  hasContentBelow,
  holdAfterScroll,
  readMetrics,
  type FollowState,
  type TurnBounds,
} from './scroll-follow';
import {
  getMessagePartIndex,
  hasUnresolvedToolCalls,
  indexTranscript,
  isCompactionPart,
  isContextEventPart,
  isFeedPart,
  isHookActivityPart,
  isTurnSummaryPart,
} from './transcript-index';
import { TranscriptSearch, type TranscriptSearchLabels } from './TranscriptSearch';
import { useTranscriptSearch } from './use-transcript-search';
import { useAppearanceTracker, type AppearanceTracker } from './appearance';
import { useUnstableRenderersWarning } from './use-unstable-renderers-warning';
import { AnswerFile, type AnswerFileLabels } from './AnswerFile';
import { VisuallyHiddenStatus } from '../primitives/VisuallyHiddenStatus/VisuallyHiddenStatus';
import classes from './MessageList.module.css';

export type MessageListLabels = {
  /** Floating button shown when new messages arrive below, `N new messages` by default */
  newMessages: (count: number) => string;
  /** Accessible label of the sticky prompt, `Scroll to the prompt` by default */
  scrollToPrompt: string;
  /** Verb of the working row shown between tool calls, `Working` by default */
  working: string;
  /** Row before the first token of an answer when `workingRow` is off, `Processing...` by default */
  planning: string;
  /** Accessible label of the copy button under a message, `Copy message` by default */
  copyMessage: string;
  /** Accessible label of the copy button right after copying, `Copied` by default */
  copied: string;
  /** Accessible label of the toolbar under a message, `Message actions` by default */
  messageActions: string;
  /** Announced to screen readers once an answer finishes, `Answer ready` by default */
  answerReady: string;
  /** Announced to screen readers once an answer stops with an error, `Answer stopped with an error` by default */
  answerFailed: string;
  /** Announced to screen readers once the user stops an answer, see `stopped`, `Answer stopped` by default */
  answerStopped: string;
  /** Labels of a file that arrived with an answer: its image preview and alternative text */
  answerFile?: Partial<AnswerFileLabels>;
  /** Labels of the search bar */
  search?: Partial<TranscriptSearchLabels>;
  /** Summary and progress labels of collapsed tool runs */
  toolRuns?: Partial<ToolRunLabels>;
};

export const DEFAULT_MESSAGE_LIST_LABELS: MessageListLabels = {
  newMessages: (count) => `${count} new ${count === 1 ? 'message' : 'messages'}`,
  scrollToPrompt: 'Scroll to the prompt',
  working: 'Working',
  planning: 'Processing...',
  copyMessage: 'Copy message',
  copied: 'Copied',
  messageActions: 'Message actions',
  answerReady: 'Answer ready',
  answerFailed: 'Answer stopped with an error',
  answerStopped: 'Answer stopped',
};

export type MessageListProps = {
  messages: ChatMessage[];
  status: ChatStatus;
  className?: string;
  style?: React.CSSProperties;
  /** Max width of the message column: a number in px or any CSS width, `420px` by default. Pass `'100%'` for a full-width chat */
  contentWidth?: ContentWidth;
  showCopyToolbar?: boolean;
  /** Hides every `tool-Question` part. Prefer `suppressQuestionToolCallId`. */
  suppressQuestionTool?: boolean;
  /** Hides only the `tool-Question` part with this `toolCallId` (e.g. the one shown in the input bar) */
  suppressQuestionToolCallId?: string;
  /**
   * Where to position the scroll container on initial mount.
   * - "bottom" (default): classic chat behavior, pinned to the latest message.
   * - "top": start from the top of the conversation, useful for static demos
   *   or read-only transcripts where the user should read top-to-bottom.
   */
  initialScrollBehavior?: 'bottom' | 'top';
  /** Opens an attached image of a user message in a fullscreen lightbox on click, `true` by default */
  enableImagePreview?: boolean;
  /**
   * Collapses runs of consecutive read and search tool calls in an assistant message into one
   * summary row, for example `Read 3 files, searched 2 patterns`, `false` by default. Does not
   * apply to the rows presentation, where every call keeps its own row.
   */
  collapseToolRuns?: boolean | CollapseToolRunsOptions;
  /**
   * Turns on message actions: edit and rewind for user messages, retry, feedback and branch for
   * assistant turns. Each action appears only when its callback is set; without this prop the plain
   * copy toolbar is rendered.
   */
  messageActions?: MessageListActions;
  /** Known slash commands; a user message that starts with one of them is shown as a command chip */
  commands?: SlashCommandInfo[];
  slots?: {
    UserMessage?: React.ComponentType<{
      message: ChatMessage;
      className?: string;
      enableImagePreview?: boolean;
      commands?: SlashCommandInfo[];
      longMessageThreshold?: LongTextThreshold | boolean;
    }>;
    ToolRenderer?: React.ComponentType<ToolRendererSlotProps>;
  };
  classNames?: {
    userMessage?: string;
  };
  toolRenderers?: Record<string, React.ComponentType<CustomToolRendererProps>>;
  /**
   * Renderers of part types the list does not know, keyed by `part.type`; parts without one stay
   * hidden. `text` replaces the Markdown of an answer, and `reasoning`, hidden by default, shows
   * through its renderer; planning, the working row and copying still read the `text` parts.
   */
  partRenderers?: PartRenderers;
  /**
   * Where the list scrolls when the user sends, `bottom` by default; `prompt-top` puts the question
   * at the top and grows the answer under it; `prompt-top-hold` also keeps the list still while the
   * answer grows, until the user scrolls to the bottom or presses the new messages button
   */
  sendScroll?: SendScroll;
  /** Shows a caret after the growing text while streaming, `false` by default */
  streamingCaret?: boolean;
  /**
   * Keeps room under a growing answer so the list does not jitter in the first frames, `true` by
   * default; the room is given while the answer is live and released when the turn is finished
   */
  assistantBreathingSpace?: boolean;
  /** Keeps a short transcript at the composer instead of the top of the feed, `false` by default */
  stackFromBottom?: boolean;
  /** Skips layout and paint of finished turns outside the viewport, `false` by default; a number is the turn count it starts from, `true` means 50 */
  lazyTurns?: boolean | number;
  /** The user stopped the latest answer, so its end is announced as `labels.answerStopped`; `AgentChat` sets it from its stop button */
  stopped?: boolean;
  /** Receives actions reported by custom tool renderers through `onAction` */
  onToolAction?: ToolActionHandler;
  /**
   * Transcript lookups behind the visible state of a tool call: queued, waiting for permission,
   * refused, already answered. Built from `messages` when omitted, see `createToolCallLookups`.
   */
  toolCallLookups?: ToolCallLookups;
  /**
   * Quiet line at the end of the transcript while the agent works between tool calls, `false` by
   * default. `true` renders the kit row: `labels.working` with the time since the last event; a node
   * replaces it, for example an `AgentStatus` of the host carrying its own label and token count.
   */
  workingRow?: React.ReactNode | boolean;
  /**
   * Shows how long a running tool call has been going and the progress its MCP server reports,
   * `false` by default. The time comes from the host when the call carries a start time and from
   * the kit otherwise.
   */
  toolActivity?: boolean;
  /**
   * How the transcript is laid out: `cards` (the default) keeps the tool cards; `rowsPresentation`
   * shows the flat transcript of a terminal client — a marker, the call and its answer under a
   * gutter; `quietPresentation` folds MCP calls into quiet lines that open into their details. Both
   * are passed as values so they only reach the bundle of a host that uses them.
   */
  presentation?: 'cards' | TranscriptPresentation;
  /**
   * Result formatters by part type, keyed as `toolRenderers`:
   * `tool-Read`, `tool-mcp__tracker__task_list` or a server-wide `tool-mcp__tracker__*`. A formatter
   * that returns `null` leaves the readable summary the kit builds.
   */
  toolOutputs?: ToolOutputFormatters;
  /**
   * Tool definitions of the connected MCP servers keyed by `mcp__<server>__<tool>`: a call then
   * reads by the tool `title` instead of its name, and its arguments and result as a short summary.
   */
  toolCatalog?: ToolCatalog;
  /** Argument formatters by part type, keyed as `toolOutputs`; `null` keeps the kit summary */
  toolArgs?: ToolArgsFormatters;
  /**
   * One vertical gap between every two blocks of the transcript — prompt, answer text, tool call —
   * instead of the tighter gaps around the prompt, `false` by default. Always on in `rows`.
   */
  evenSpacing?: boolean;
  /** Locale of numbers and dates in tool arguments and results, the locale of the runtime by default */
  locale?: string;
  /** Adds a retry button to error parts */
  onRetry?: () => void;
  /** Adds the conversation search, opened with Mod+F while focus is inside the list, `false` by default */
  withSearch?: boolean;
  /** Controlled open state of the search */
  searchOpened?: boolean;
  /** Called when the search opens or closes */
  onSearchOpenedChange?: (opened: boolean) => void;
  /** Pins the prompt of the answer being read to the top while scrolling a long answer */
  stickyPrompt?: boolean;
  /** Collapses long user messages to head and tail; `true` uses `{ chars: 2000, lines: 30 }`, `false` by default */
  longMessageThreshold?: LongTextThreshold | boolean;
  /** Fades the top edge of the list once it is scrolled, so content does not end abruptly under a header */
  topFade?: boolean;
  /** Wraps long lines in code blocks and diffs instead of scrolling them sideways, for narrow layouts, `false` by default */
  wrapLines?: boolean;
  /** Shows answer tables with too many columns for the width as one card per row, `false` by default */
  responsiveTables?: boolean;
  /**
   * Commits the streaming answer at most once per animation frame, `false` by default.
   * A finished stream, a hidden tab and `prefers-reduced-motion` commit right away.
   */
  frameBatched?: boolean;
  /** Reveals the growing tail of the streaming answer by character (`'char'`, the default) or by finished line (`'line'`) */
  tailGranularity?: MarkdownTailGranularity;
  /**
   * Fades a newly arrived message or part in over 150ms with a few pixels of travel, `false` by
   * default. The transcript already on screen at mount never animates, and `prefers-reduced-motion`
   * turns the animation off.
   */
  animateAppearance?: boolean;
  /** Called with the width in px of the vertical scrollbar whenever it appears, disappears or resizes */
  onScrollbarWidthChange?: (width: number) => void;
  /** Syntax highlighter for code blocks in answers and compaction summaries; plain code blocks when omitted */
  highlighter?: SyntaxHighlighter;
  /** Overrides of the default English labels */
  labels?: Partial<MessageListLabels>;
};

function isModKey(event: React.KeyboardEvent) {
  return event.metaKey || event.ctrlKey;
}
// Wheel and keyboard scrolls go on for a while after the event, smooth scrolling included.
const USER_SCROLL_WINDOW_MS = 600;
const SCROLL_KEYS = new Set(['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp', 'End', 'Home', ' ']);

const timeFormatter = new Intl.DateTimeFormat('en-US', {
  hour: 'numeric',
  minute: '2-digit',
  hour12: true,
});
const dateFormatter = new Intl.DateTimeFormat('en-US', {
  month: 'short',
  day: 'numeric',
});

function normalizeMessages(messages: ChatMessage[]): ChatMessage[] {
  let changed = false;
  const normalized = messages.map((message) => {
    if (Array.isArray(message.parts) && message.parts.length > 0) {
      return message;
    }
    const raw = message as { content?: string; text?: string };
    const content = raw.content ?? raw.text;
    if (typeof content !== 'string' || !content) {
      return message;
    }
    changed = true;
    return {
      ...message,
      parts: [{ type: 'text', text: content }],
    } as ChatMessage;
  });
  return changed ? normalized : messages;
}

function getTextFromParts(parts: unknown[], joiner: string): string {
  return parts
    .filter(isTextPart)
    .map((part) => part.text)
    .join(joiner);
}

/** Whether a call of the latest answer waits for the user, whose decision the status bar already asks for */
function hasOpenDecision(
  messages: ChatMessage[],
  requests: ReturnType<typeof useToolApprovals>,
  lookups: ToolCallLookups
): boolean {
  const last = messages[messages.length - 1];
  if (!last || last.role !== 'assistant') {
    return false;
  }
  return (last.parts ?? []).some((part) => {
    if (!isV5ToolPart(part) || !part.toolCallId) {
      return false;
    }
    const request = requests?.[part.toolCallId];
    if (request) {
      return !request.outcome;
    }
    return Boolean(lookups.isAwaitingPermission?.(part.toolCallId));
  });
}

/** Whether the answer itself is still growing, in which case the working row would double the caret */
function isTailGrowingText(messages: ChatMessage[]): boolean {
  const last = messages[messages.length - 1];
  if (!last || last.role !== 'assistant') {
    return false;
  }
  const parts = last.parts ?? [];
  for (let index = parts.length - 1; index >= 0; index -= 1) {
    const part = parts[index];
    if (isTextPart(part)) {
      return part.text.trim().length > 0;
    }
    if (isV5ToolPart(part)) {
      return false;
    }
  }
  return false;
}

function formatTimestamp(date: Date): string {
  const now = new Date();
  const isSameDay =
    date.getFullYear() === now.getFullYear() &&
    date.getMonth() === now.getMonth() &&
    date.getDate() === now.getDate();
  if (isSameDay) {
    return timeFormatter.format(date);
  }
  return dateFormatter.format(date);
}

type ToolbarLabels = Pick<MessageListLabels, 'copyMessage' | 'copied' | 'messageActions'>;

function ToolbarCopyButton({
  text,
  labels,
  onCopied,
}: {
  text: string;
  labels: ToolbarLabels;
  onCopied?: () => void;
}) {
  return (
    <CopyButton value={text} timeout={2000}>
      {({ copied, copy }) => (
        <UnstyledButton
          tabIndex={-1}
          aria-label={copied ? labels.copied : labels.copyMessage}
          className={classes.copyButton}
          onClick={() => {
            copy();
            onCopied?.();
          }}
          onPointerDown={(event) => event.stopPropagation()}
          onMouseDown={(event) => event.stopPropagation()}
        >
          <span className={classes.copyIcons} data-copied={copied || undefined}>
            <IconCopy size={14} className={classes.copyIcon} />
            <IconCheck size={14} className={classes.checkIcon} />
          </span>
        </UnstyledButton>
      )}
    </CopyButton>
  );
}

function MessageToolbar({
  text,
  timestamp,
  size,
  align,
  isVisible,
  labels,
  onCopied,
}: {
  text?: string;
  timestamp?: string;
  size: 'sm' | 'lg';
  align: 'start' | 'end';
  isVisible: boolean;
  labels: ToolbarLabels;
  onCopied?: () => void;
}) {
  return (
    <div
      role="toolbar"
      tabIndex={-1}
      aria-label={labels.messageActions}
      className={classes.toolbar}
      data-size={size}
      data-align={align}
      data-visible={isVisible || undefined}
      onMouseDown={(event) => event.stopPropagation()}
      onPointerDown={(event) => event.stopPropagation()}
    >
      {timestamp && <span>{timestamp}</span>}
      {text && <ToolbarCopyButton text={text} labels={labels} onCopied={onCopied} />}
    </div>
  );
}

/** Scrollable conversation view with turn grouping, tool rendering and copy toolbars */
export const MessageList = memo(function MessageList({
  messages,
  status,
  className,
  style,
  contentWidth,
  showCopyToolbar = true,
  suppressQuestionTool = false,
  suppressQuestionToolCallId,
  initialScrollBehavior = 'bottom',
  enableImagePreview = true,
  collapseToolRuns,
  messageActions,
  commands,
  slots,
  classNames,
  toolRenderers,
  partRenderers,
  sendScroll = 'bottom',
  streamingCaret = false,
  assistantBreathingSpace = true,
  stackFromBottom = false,
  lazyTurns = false,
  stopped = false,
  onToolAction,
  toolCallLookups,
  workingRow = false,
  toolActivity = false,
  presentation = 'cards',
  toolOutputs,
  toolCatalog,
  toolArgs,
  evenSpacing = false,
  locale,
  onRetry,
  withSearch = false,
  searchOpened: searchOpenedProp,
  onSearchOpenedChange,
  stickyPrompt = false,
  longMessageThreshold,
  highlighter,
  topFade = false,
  wrapLines,
  responsiveTables,
  frameBatched,
  tailGranularity,
  animateAppearance = false,
  onScrollbarWidthChange,
  labels: labelsProp,
}: MessageListProps) {
  const labels = { ...DEFAULT_MESSAGE_LIST_LABELS, ...labelsProp };
  useUnstableRenderersWarning(partRenderers);
  const appearance = useAppearanceTracker(animateAppearance, messages.length > 0);
  const custom = typeof presentation === 'object' ? presentation : undefined;
  const isRows = custom?.kind === 'rows';
  const isEven = evenSpacing && !isRows;
  const appearClass = (key: string) => (appearance.isNew(key) ? classes.appear : undefined);
  const chatContainerRef = useRef<HTMLDivElement | null>(null);
  const contentWrapperRef = useRef<HTMLDivElement>(null);
  const chatContainerObserverRef = useRef<ResizeObserver | null>(null);
  const followRef = useRef<FollowState>(
    createFollowState({ scrollTop: 0, scrollHeight: 0, clientHeight: 0 }, true)
  );
  const promptTop = sendScroll === 'prompt-top' || sendScroll === 'prompt-top-hold';
  const holdFollow = sendScroll === 'prompt-top-hold';
  const userScrollUntilRef = useRef(0);
  const pointerOnScrollbarRef = useRef(false);
  const [contentBelow, setContentBelow] = useState(false);
  const messagesRef = useRef<ChatMessage[]>(messages);
  const lastMessageIdRef = useRef<string | null>(messages[messages.length - 1]?.id ?? null);
  const assistantSpaceActiveRef = useRef(false);
  const [activeCopyId, setActiveCopyId] = useState<string | null>(null);
  const [isMounted, setIsMounted] = useState(false);
  const [editingId, setEditingId] = useState<string | null>(null);
  const focusAfterEditRef = useRef<string | null>(null);
  const finishEditing = useCallback(() => {
    focusAfterEditRef.current = editingId;
    setEditingId(null);
  }, [editingId]);

  useEffect(() => {
    const messageId = focusAfterEditRef.current;
    if (editingId !== null || messageId === null) {
      return;
    }
    focusAfterEditRef.current = null;
    chatContainerRef.current
      ?.querySelector<HTMLElement>(
        `[data-message-id="${CSS.escape(messageId)}"] [data-action="edit"]`
      )
      ?.focus();
  }, [editingId]);

  const CustomUserMessage = slots?.UserMessage || UserMessage;
  const CustomToolRenderer = slots?.ToolRenderer || DefaultToolRenderer;
  const toolRunLabels = labelsProp?.toolRuns;
  const toolRunOptions = useMemo(
    () => resolveToolRunOptions(collapseToolRuns, toolRunLabels),
    [collapseToolRuns, toolRunLabels]
  );

  // Consumers pass these inline, so the identity changes on every token; the memoized rows compare
  // props by identity and would re-render the whole transcript without a stable wrapper.
  const onToolActionRef = useRef(onToolAction);
  onToolActionRef.current = onToolAction;
  const hasToolAction = Boolean(onToolAction);
  const stableToolAction = useMemo<ToolActionHandler | undefined>(
    () =>
      hasToolAction
        ? (toolCallId, action, payload) => onToolActionRef.current?.(toolCallId, action, payload)
        : undefined,
    [hasToolAction]
  );
  const onRetryRef = useRef(onRetry);
  onRetryRef.current = onRetry;
  const hasRetry = Boolean(onRetry);
  const stableRetry = useMemo<(() => void) | undefined>(
    () => (hasRetry ? () => onRetryRef.current?.() : undefined),
    [hasRetry]
  );

  const markCopied = useCallback((id: string) => {
    setActiveCopyId(id);
  }, []);

  useEffect(() => {
    setIsMounted(true);
  }, []);

  useEffect(() => {
    const handlePointerDown = () => {
      setActiveCopyId(null);
    };
    window.addEventListener('pointerdown', handlePointerDown);
    return () => window.removeEventListener('pointerdown', handlePointerDown);
  }, []);

  const isStreaming = status === 'streaming' || status === 'submitted';
  const [announcement, setAnnouncement] = useState('');
  const wasStreamingRef = useRef(isStreaming);
  useEffect(() => {
    if (isStreaming) {
      setAnnouncement('');
    } else if (wasStreamingRef.current) {
      setAnnouncement(
        status === 'error'
          ? labels.answerFailed
          : stopped
            ? labels.answerStopped
            : labels.answerReady
      );
    }
    wasStreamingRef.current = isStreaming;
  }, [isStreaming, status, stopped, labels.answerFailed, labels.answerReady, labels.answerStopped]);

  const scrollbarWidthRef = useRef(0);
  const onScrollbarWidthChangeRef = useRef(onScrollbarWidthChange);
  onScrollbarWidthChangeRef.current = onScrollbarWidthChange;
  const reportScrollbarWidth = useCallback(() => {
    const el = chatContainerRef.current;
    if (!el || !onScrollbarWidthChangeRef.current) {
      return;
    }
    const width = Math.max(0, el.offsetWidth - el.clientWidth);
    if (width !== scrollbarWidthRef.current) {
      scrollbarWidthRef.current = width;
      onScrollbarWidthChangeRef.current(width);
    }
  }, []);

  const containerRefCallback = useCallback((el: HTMLDivElement | null) => {
    chatContainerRef.current = el;

    if (chatContainerObserverRef.current) {
      chatContainerObserverRef.current.disconnect();
      chatContainerObserverRef.current = null;
    }
    if (el) {
      el.style.setProperty('--chat-container-height', `${el.clientHeight}px`);
      const observer = new ResizeObserver((entries) => {
        const height = entries[0]?.contentRect.height ?? 0;
        el.style.setProperty('--chat-container-height', `${height}px`);
        reportScrollbarWidth();
      });
      observer.observe(el);
      chatContainerObserverRef.current = observer;
    }
  }, []);

  const scrollToBottomInstant = useCallback(() => {
    const container = chatContainerRef.current;
    if (!container) {
      return;
    }
    container.scrollTop = container.scrollHeight;
  }, []);

  const scrollToBottomSettled = useCallback(() => {
    let rafOne = 0;
    let rafTwo = 0;
    scrollToBottomInstant();
    rafOne = requestAnimationFrame(() => {
      scrollToBottomInstant();
      rafTwo = requestAnimationFrame(() => {
        scrollToBottomInstant();
      });
    });
    return () => {
      cancelAnimationFrame(rafOne);
      cancelAnimationFrame(rafTwo);
    };
  }, [scrollToBottomInstant]);

  const [unseenCount, setUnseenCount] = useState(0);
  const [isScrolled, setIsScrolled] = useState(false);
  const [stickyTurnKey, setStickyTurnKey] = useState<string | null>(null);
  const seenIdsRef = useRef<Set<string>>(new Set(messages.map((message) => message.id)));
  const stickyFrameRef = useRef(0);

  const markAllSeen = useCallback(() => {
    seenIdsRef.current = new Set(messagesRef.current.map((message) => message.id));
    setUnseenCount(0);
  }, []);

  const isStreamingRef = useRef(isStreaming);
  isStreamingRef.current = isStreaming;

  // The count only means something while there is content below the viewport to jump to.
  const refreshUnseen = useCallback(() => {
    const container = chatContainerRef.current;
    const list = messagesRef.current;
    const ids = list.map((message) => message.id);
    const below = container ? hasContentBelow(readMetrics(container)) : false;
    setContentBelow(below);
    if (followRef.current.following) {
      seenIdsRef.current = new Set(ids);
      setUnseenCount(0);
      return;
    }
    if (!below) {
      // A streaming answer that fits now can still grow past the viewport, so it stays unseen.
      const tail = list[list.length - 1];
      const growing = isStreamingRef.current && tail !== undefined && tail.role !== 'user';
      seenIdsRef.current = new Set(growing ? ids.slice(0, -1) : ids);
      setUnseenCount(0);
      return;
    }
    // After prompt-top the question stays in view at the top, so only the answer under it is new.
    const counted = promptTop
      ? list.filter((message) => message.role !== 'user').map((message) => message.id)
      : ids;
    setUnseenCount(countNewMessages(seenIdsRef.current, counted));
  }, [promptTop]);

  const updateStickyPrompt = useCallback(() => {
    const container = chatContainerRef.current;
    if (!stickyPrompt || !container) {
      return;
    }
    cancelAnimationFrame(stickyFrameRef.current);
    stickyFrameRef.current = requestAnimationFrame(() => {
      const top = container.getBoundingClientRect().top;
      const bounds: TurnBounds[] = [];
      container.querySelectorAll<HTMLElement>('[data-turn-key]').forEach((element) => {
        const rect = element.getBoundingClientRect();
        const prompt = element.querySelector<HTMLElement>('[data-turn-prompt]');
        bounds.push({
          key: element.dataset.turnKey ?? '',
          top: rect.top - top,
          bottom: rect.bottom - top,
          promptBottom: prompt ? prompt.getBoundingClientRect().bottom - top : null,
        });
      });
      setStickyTurnKey(findStickyPromptTurn(bounds));
    });
  }, [stickyPrompt]);

  useEffect(() => () => cancelAnimationFrame(stickyFrameRef.current), []);

  const handleScroll = useCallback(() => {
    const container = chatContainerRef.current;
    if (!container) {
      return;
    }
    if (topFade) {
      setIsScrolled(container.scrollTop > 0);
    }
    const metrics = readMetrics(container);
    const next = followAfterScroll(followRef.current, metrics);
    followRef.current = holdFollow
      ? holdAfterScroll(
          followRef.current,
          next,
          pointerOnScrollbarRef.current || performance.now() < userScrollUntilRef.current
        )
      : next;
    refreshUnseen();
    updateStickyPrompt();
  }, [refreshUnseen, updateStickyPrompt, topFade, holdFollow]);

  useEffect(() => {
    const container = chatContainerRef.current;
    if (!holdFollow || !container) {
      return;
    }
    const markUserScroll = () => {
      userScrollUntilRef.current = performance.now() + USER_SCROLL_WINDOW_MS;
    };
    const onKeyDown = (event: KeyboardEvent) => {
      if (SCROLL_KEYS.has(event.key)) {
        markUserScroll();
      }
    };
    // A press on the element itself, not on a child, lands on its scrollbar.
    const onPointerDown = (event: PointerEvent) => {
      if (event.target === container) {
        pointerOnScrollbarRef.current = true;
      }
    };
    const onPointerUp = () => {
      if (pointerOnScrollbarRef.current) {
        pointerOnScrollbarRef.current = false;
        markUserScroll();
      }
    };
    container.addEventListener('wheel', markUserScroll, { passive: true });
    container.addEventListener('touchmove', markUserScroll, { passive: true });
    container.addEventListener('keydown', onKeyDown);
    container.addEventListener('pointerdown', onPointerDown);
    window.addEventListener('pointerup', onPointerUp);
    return () => {
      container.removeEventListener('wheel', markUserScroll);
      container.removeEventListener('touchmove', markUserScroll);
      container.removeEventListener('keydown', onKeyDown);
      container.removeEventListener('pointerdown', onPointerDown);
      window.removeEventListener('pointerup', onPointerUp);
    };
  }, [holdFollow]);

  const pinIfGrown = useCallback(() => {
    const container = chatContainerRef.current;
    if (!container) {
      return;
    }
    const metrics = readMetrics(container);
    const { state, pin } = followAfterResize(followRef.current, metrics);
    if (!pin) {
      followRef.current = state;
      return;
    }
    container.scrollTop = container.scrollHeight;
    followRef.current = createFollowState(readMetrics(container), true);
  }, []);

  useLayoutEffect(() => {
    const container = chatContainerRef.current;
    const contentWrapper = contentWrapperRef.current;
    if (!container || !contentWrapper) {
      return;
    }

    if (initialScrollBehavior === 'top') {
      container.scrollTop = 0;
    } else {
      container.scrollTop = container.scrollHeight;
    }
    followRef.current = createFollowState(readMetrics(container), initialScrollBehavior !== 'top');

    let lastContentHeight = contentWrapper.getBoundingClientRect().height;
    reportScrollbarWidth();

    // A tall block can arrive in a single resize, so growth is followed whenever the list is pinned.
    const resizeObserver = new ResizeObserver(() => {
      const newContentHeight = contentWrapper.getBoundingClientRect().height;
      if (newContentHeight === lastContentHeight) {
        return;
      }
      lastContentHeight = newContentHeight;
      reportScrollbarWidth();
      pinIfGrown();
      refreshUnseen();
    });

    resizeObserver.observe(contentWrapper);
    return () => resizeObserver.disconnect();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const normalizedMessages = useMemo(() => normalizeMessages(messages), [messages]);

  useLayoutEffect(() => {
    pinIfGrown();
  }, [normalizedMessages, pinIfGrown]);

  useEffect(() => {
    const previousIds = messagesRef.current.map((message) => message.id);
    messagesRef.current = normalizedMessages;
    seenIdsRef.current = carrySeenIds(
      seenIdsRef.current,
      previousIds,
      normalizedMessages.map((message) => message.id)
    );
    refreshUnseen();
    updateStickyPrompt();
  }, [normalizedMessages, refreshUnseen, updateStickyPrompt]);

  // A button that would show for a single frame, while a send scrolls the list, reads as a flicker.
  const wantsJump = unseenCount > 0 && contentBelow;
  const [jumpSettled, setJumpSettled] = useState(false);
  useEffect(() => {
    if (!wantsJump) {
      setJumpSettled(false);
      return;
    }
    const frame = requestAnimationFrame(() => setJumpSettled(true));
    return () => cancelAnimationFrame(frame);
  }, [wantsJump]);

  const turnsRootRef = useRef<HTMLDivElement>(null);
  const searchInputRef = useRef<HTMLInputElement>(null);
  const [internalSearchOpened, setInternalSearchOpened] = useState(false);
  const isSearchOpen = withSearch && (searchOpenedProp ?? internalSearchOpened);
  const setSearchOpen = useCallback(
    (opened: boolean) => {
      setInternalSearchOpened(opened);
      onSearchOpenedChange?.(opened);
    },
    [onSearchOpenedChange]
  );
  const search = useTranscriptSearch({
    enabled: isSearchOpen,
    rootRef: turnsRootRef,
    scrollRef: chatContainerRef,
    contentKey: normalizedMessages,
  });

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (!withSearch || !isModKey(event) || event.key.toLowerCase() !== 'f') {
      return;
    }
    event.preventDefault();
    setSearchOpen(true);
    searchInputRef.current?.focus();
    searchInputRef.current?.select();
  };

  const closeSearch = () => {
    setSearchOpen(false);
    search.setQuery('');
    chatContainerRef.current?.focus();
  };

  const jumpToLatest = () => {
    const container = chatContainerRef.current;
    if (!container) {
      return;
    }
    followRef.current = createFollowState(readMetrics(container), true);
    container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
    markAllSeen();
  };
  const lastMessage = normalizedMessages[normalizedMessages.length - 1];
  const lastMessageId = lastMessage?.id ?? null;
  const lastMessageRole = lastMessage?.role ?? null;
  const transcript = useMemo(() => indexTranscript(normalizedMessages), [normalizedMessages]);
  const derivedToolCallLookups = useMemo(
    () => createToolCallLookups(normalizedMessages),
    [normalizedMessages]
  );
  const lookups = toolCallLookups ?? derivedToolCallLookups;
  const approvalRequests = useToolApprovals();
  const isWaitingForDecision = useMemo(
    () => hasOpenDecision(normalizedMessages, approvalRequests, lookups),
    [normalizedMessages, approvalRequests, lookups]
  );
  const { turns, lastUserMessageId } = transcript;
  const turnStartRef = useRef({ id: lastUserMessageId, at: Date.now() });
  if (turnStartRef.current.id !== lastUserMessageId) {
    turnStartRef.current = { id: lastUserMessageId, at: Date.now() };
  }

  const lastUserMessageIdRef = useRef(lastUserMessageId);
  const pendingPlanningScrollUserIdRef = useRef<string | null>(null);
  const lastUserIndex = lastUserMessageId
    ? normalizedMessages.findIndex((message) => message.id === lastUserMessageId)
    : -1;
  const lastUserText =
    lastUserIndex >= 0 ? getTextFromParts(normalizedMessages[lastUserIndex].parts ?? [], '') : '';
  const lastUserRef = useRef({ index: lastUserIndex, text: lastUserText });
  useLayoutEffect(() => {
    const previousUserId = lastUserMessageIdRef.current;
    const previousUser = lastUserRef.current;
    lastUserRef.current = { index: lastUserIndex, text: lastUserText };
    // The saved id of the question replacing its optimistic one is the same question, not a send.
    const replaced =
      previousUserId !== null &&
      lastUserIndex === previousUser.index &&
      lastUserText === previousUser.text &&
      !normalizedMessages.some((message) => message.id === previousUserId);
    if (replaced) {
      lastUserMessageIdRef.current = lastUserMessageId;
      return;
    }
    if (lastUserMessageId && lastUserMessageId !== lastUserMessageIdRef.current) {
      lastUserMessageIdRef.current = lastUserMessageId;
      const container = chatContainerRef.current;
      const turn = container?.querySelector<HTMLElement>(
        `[data-turn-key="${CSS.escape(lastUserMessageId)}"]`
      );
      const previousLastId = lastMessageIdRef.current;
      const appended = normalizedMessages.some((message) => message.id === previousLastId);
      if (promptTop && appended && container && turn) {
        const top = container.getBoundingClientRect().top;
        const overlay = container.querySelector<HTMLElement>('[data-sticky-overlay]');
        const covered = overlay ? Math.max(0, overlay.getBoundingClientRect().bottom - top) : 0;
        container.scrollTop += turn.getBoundingClientRect().top - top - covered;
        followRef.current = createFollowState(readMetrics(container), false);
        userScrollUntilRef.current = 0;
        setContentBelow(hasContentBelow(readMetrics(container)));
        return;
      }
      followRef.current = { ...followRef.current, following: true };
      pendingPlanningScrollUserIdRef.current = lastUserMessageId;
      return scrollToBottomSettled();
    }
  }, [
    lastUserMessageId,
    lastUserIndex,
    lastUserText,
    normalizedMessages,
    promptTop,
    scrollToBottomSettled,
  ]);

  const lastActivityRef = useRef({ messages: normalizedMessages, at: Date.now() });
  if (lastActivityRef.current.messages !== normalizedMessages) {
    lastActivityRef.current = { messages: normalizedMessages, at: Date.now() };
  }

  const hasWorkingRow = workingRow !== false && workingRow !== undefined;
  const showPlanning =
    Boolean(lastMessage) &&
    isStreaming &&
    (lastMessageRole === 'user' || !transcript.lastAssistantHasContent);
  const showWorkingRow =
    hasWorkingRow &&
    isStreaming &&
    (workingRow !== true || !isTailGrowingText(normalizedMessages)) &&
    !isWaitingForDecision &&
    !custom?.showsActivity?.(lastMessage?.parts ?? [], toolCatalog);
  const isNewAssistantMessage =
    lastMessageRole === 'assistant' &&
    Boolean(lastMessageId) &&
    lastMessageId !== lastMessageIdRef.current;
  const showAssistantBreathingSpace =
    assistantBreathingSpace &&
    !promptTop &&
    isStreaming &&
    (showPlanning || assistantSpaceActiveRef.current || isNewAssistantMessage);

  useEffect(() => {
    if (lastMessageRole === 'assistant') {
      if (lastMessageId && lastMessageId !== lastMessageIdRef.current) {
        assistantSpaceActiveRef.current = true;
      }
    }
    if (lastMessageRole === 'user') {
      assistantSpaceActiveRef.current = false;
    }
    lastMessageIdRef.current = lastMessageId;
  }, [lastMessageId, lastMessageRole]);

  const workingNode = showWorkingRow && (
    <React.Fragment key="working-row">
      {workingRow === true ? (
        <WorkingLine
          label={labels.working}
          since={showPlanning ? turnStartRef.current.at : lastActivityRef.current.at}
          className={cx(classes.workingRow, isRows && classes.workingRowInline)}
        />
      ) : (
        workingRow
      )}
    </React.Fragment>
  );
  const lazyFrom = lazyTurns === false ? null : lazyTurns === true ? 50 : lazyTurns;

  useLayoutEffect(() => {
    if (!showPlanning || !lastUserMessageId) {
      return;
    }
    if (pendingPlanningScrollUserIdRef.current !== lastUserMessageId) {
      return;
    }
    const cancel = scrollToBottomSettled();
    pendingPlanningScrollUserIdRef.current = null;
    return cancel;
  }, [lastUserMessageId, showPlanning, scrollToBottomSettled]);

  return (
    <ToolPresentationProvider
      catalog={toolCatalog}
      args={toolArgs}
      outputs={toolOutputs}
      locale={locale}
    >
      <Box
        ref={containerRefCallback}
        onScroll={handleScroll}
        onKeyDown={withSearch ? handleKeyDown : undefined}
        tabIndex={withSearch ? -1 : undefined}
        data-top-fade={topFade && isScrolled ? true : undefined}
        className={cx(classes.root, className)}
        data-stack-from-bottom={stackFromBottom || undefined}
        style={getContentWidthStyle(contentWidth, style)}
        role="log"
        aria-live="off"
        aria-busy={isStreaming}
      >
        <div ref={contentWrapperRef} className={classes.content}>
          {(isSearchOpen || (stickyPrompt && stickyTurnKey)) && (
            <div className={classes.stickyTop} data-search-ignore>
              <div className={classes.stickyTopInner} data-sticky-overlay>
                {isSearchOpen && (
                  <TranscriptSearch
                    inputRef={searchInputRef}
                    value={search.query}
                    onChange={search.setQuery}
                    activeIndex={search.activeIndex}
                    total={search.total}
                    onNext={search.next}
                    onPrevious={search.previous}
                    onClose={closeSearch}
                    labels={labels.search}
                  />
                )}
                {stickyPrompt &&
                  stickyTurnKey &&
                  (() => {
                    const turn = turns.find((item) => item.userMsg?.id === stickyTurnKey);
                    const promptText = turn?.userMsg
                      ? getTextFromParts(turn.userMsg.parts ?? [], ' ')
                      : '';
                    if (!promptText) {
                      return null;
                    }
                    return (
                      <Paper withBorder shadow="xs" radius="md" className={classes.stickyPrompt}>
                        <UnstyledButton
                          className={classes.stickyPromptButton}
                          aria-label={labels.scrollToPrompt}
                          onClick={() =>
                            chatContainerRef.current
                              ?.querySelector<HTMLElement>(
                                `[data-turn-key="${CSS.escape(stickyTurnKey)}"]`
                              )
                              ?.scrollIntoView({
                                block: 'start',
                                behavior: 'smooth',
                              })
                          }
                        >
                          <IconCornerLeftUp size={14} aria-hidden />
                          <Text component="span" size="xs" truncate="end" miw={0}>
                            {promptText}
                          </Text>
                        </UnstyledButton>
                      </Paper>
                    );
                  })()}
              </div>
            </div>
          )}
          <div
            ref={turnsRootRef}
            className={classes.turns}
            data-rows={isRows || undefined}
            data-even={isEven || undefined}
          >
            {turns.map((turn, turnIndex) => {
              const isLastTurn = turnIndex === turns.length - 1;
              const turnKey = turn.userMsg?.id ?? turn.assistantMsgs[0]?.id ?? `turn-${turnIndex}`;

              return (
                <div
                  key={turnKey}
                  className={classes.turn}
                  data-turn-key={turnKey}
                  data-send-scroll={(promptTop && isLastTurn) || undefined}
                  data-lazy={
                    (lazyFrom !== null && turns.length >= lazyFrom && !isLastTurn) || undefined
                  }
                  data-rows={isRows || undefined}
                  data-even={isEven || undefined}
                >
                  {turn.userMsg &&
                    (() => {
                      const userMsg = turn.userMsg;
                      const unanswered = turn.assistantMsgs.length === 0 && !isLastTurn;
                      const text = getTextFromParts(userMsg.parts ?? [], '');
                      const hasParts = (userMsg.parts ?? []).length > 0;
                      if (!text && !hasParts) {
                        return null;
                      }
                      const userCreatedAt = userMsg.createdAt;
                      const userCopyKey = `user-${userMsg.id}`;
                      const userCopyVisible = activeCopyId === userCopyKey;
                      const userTimestamp =
                        isMounted && userCreatedAt
                          ? formatTimestamp(new Date(userCreatedAt))
                          : undefined;
                      const showUserToolbar =
                        (showCopyToolbar && Boolean(text)) || Boolean(userTimestamp);
                      if (messageActions) {
                        const { onEdit, onRewind } = messageActions;
                        if (onEdit && editingId === userMsg.id) {
                          return (
                            <div className={classes.group} data-turn-prompt>
                              <EditMessageComposer
                                defaultValue={text}
                                onCancel={finishEditing}
                                onSubmit={async (nextText) => {
                                  await onEdit(userMsg.id, nextText);
                                  finishEditing();
                                }}
                              />
                            </div>
                          );
                        }
                        return (
                          <div
                            className={cx(classes.group, appearClass(`user:${userMsg.id}`))}
                            data-message-actions-host
                            data-message-id={userMsg.id}
                            data-turn-prompt
                            data-unanswered={unanswered || undefined}
                          >
                            <CustomUserMessage
                              message={userMsg}
                              className={classNames?.userMessage}
                              enableImagePreview={enableImagePreview}
                              commands={commands}
                              longMessageThreshold={longMessageThreshold}
                            />
                            <MessageActions
                              messageRole="user"
                              align="end"
                              text={showCopyToolbar ? text : undefined}
                              timestamp={userTimestamp}
                              disabled={isStreaming}
                              onEdit={onEdit && text ? () => setEditingId(userMsg.id) : undefined}
                              onRewind={onRewind ? () => onRewind(userMsg.id) : undefined}
                              actions={messageActions?.actions?.(userMsg.id, 'user')}
                            />
                          </div>
                        );
                      }
                      return (
                        <div
                          className={cx(classes.group, appearClass(`user:${userMsg.id}`))}
                          data-turn-prompt
                          data-unanswered={unanswered || undefined}
                        >
                          <CustomUserMessage
                            message={userMsg}
                            className={classNames?.userMessage}
                            enableImagePreview={enableImagePreview}
                            commands={commands}
                            longMessageThreshold={longMessageThreshold}
                          />
                          {showUserToolbar && (
                            <MessageToolbar
                              text={showCopyToolbar ? text : ''}
                              timestamp={userTimestamp}
                              size="sm"
                              align="end"
                              isVisible={userCopyVisible}
                              labels={labels}
                              onCopied={() => markCopied(userCopyKey)}
                            />
                          )}
                        </div>
                      );
                    })()}

                  {turn.assistantMsgs.length > 0 &&
                    !(isLastTurn && showPlanning) &&
                    (() => {
                      const assistantText = getTextFromParts(
                        turn.assistantMsgs.flatMap((msg) => msg.parts ?? []),
                        '\n\n'
                      );
                      const isTurnStreaming = isStreaming && isLastTurn;
                      const showToolbar =
                        showCopyToolbar && Boolean(assistantText.trim()) && !isTurnStreaming;
                      const copyKey = `assistant-${turnKey}-all`;
                      const toolbarText = showCopyToolbar ? assistantText : '';
                      const toolbarVisible = activeCopyId === copyKey;
                      const firstAssistant = turn.assistantMsgs.find(
                        (msg) => msg.role === 'assistant'
                      );
                      const firstAssistantId = firstAssistant?.id ?? '';
                      const isErrorOnlyTurn = turn.assistantMsgs.every((msg) =>
                        (msg.parts ?? []).every(isErrorPart)
                      );
                      const showActions =
                        Boolean(messageActions) &&
                        Boolean(firstAssistant) &&
                        !isTurnStreaming &&
                        !isErrorOnlyTurn;
                      const actionsNode = messageActions && showActions && (
                        <MessageActions
                          messageRole="assistant"
                          text={showCopyToolbar && assistantText.trim() ? assistantText : undefined}
                          feedbackReasons={messageActions.feedbackReasons}
                          actions={messageActions.actions?.(firstAssistantId, 'assistant')}
                          feedback={
                            messageActions.feedback
                              ? (messageActions.feedback[firstAssistantId] ?? null)
                              : undefined
                          }
                          onRetry={
                            messageActions.onRetry
                              ? () => messageActions.onRetry!(firstAssistantId)
                              : undefined
                          }
                          onBranch={
                            messageActions.onBranch
                              ? () => messageActions.onBranch!(firstAssistantId)
                              : undefined
                          }
                          onFeedback={
                            messageActions.onFeedback
                              ? (value, details) =>
                                  details
                                    ? messageActions.onFeedback!(firstAssistantId, value, details)
                                    : messageActions.onFeedback!(firstAssistantId, value)
                              : undefined
                          }
                        />
                      );

                      return (
                        <div
                          className={classes.group}
                          data-message-actions-host={messageActions ? true : undefined}
                        >
                          <div
                            className={classes.assistantStack}
                            data-rows={isRows || undefined}
                            data-even={isEven || undefined}
                          >
                            {turn.assistantMsgs.map((msg, i) => {
                              const isLastMsg = isLastTurn && i === turn.assistantMsgs.length - 1;
                              return (
                                <AssistantParts
                                  key={msg.id}
                                  msg={msg}
                                  isRowStreaming={isLastMsg && isStreaming}
                                  isRowTextStreaming={isLastMsg && status === 'streaming'}
                                  highlighter={highlighter}
                                  wrapLines={wrapLines}
                                  responsiveTables={responsiveTables}
                                  frameBatched={frameBatched}
                                  tailGranularity={tailGranularity}
                                  suppressQuestionTool={suppressQuestionTool}
                                  suppressQuestionToolCallId={suppressQuestionToolCallId}
                                  ToolRendererComponent={CustomToolRenderer}
                                  toolRenderers={toolRenderers}
                                  partRenderers={partRenderers}
                                  streamingCaret={streamingCaret}
                                  onToolAction={stableToolAction}
                                  onRetry={isLastMsg ? stableRetry : undefined}
                                  retryAnywhere={stableRetry}
                                  enableImagePreview={enableImagePreview}
                                  answerFileLabels={labels.answerFile}
                                  toolRunOptions={toolRunOptions}
                                  appearance={appearance}
                                  lookups={lookups}
                                  toolActivity={toolActivity}
                                  custom={custom}
                                  runLabels={toolRunLabels}
                                  turnStartedAt={
                                    isLastTurn && i === 0 ? turnStartRef.current.at : undefined
                                  }
                                  toolOutputs={toolOutputs}
                                  evenSpacing={isEven}
                                />
                              );
                            })}
                          </div>
                          {messageActions ? (
                            actionsNode
                          ) : showToolbar || toolbarVisible ? (
                            <MessageToolbar
                              text={toolbarText}
                              size="lg"
                              align="start"
                              isVisible={toolbarVisible}
                              labels={labels}
                              onCopied={() => markCopied(copyKey)}
                            />
                          ) : null}
                        </div>
                      );
                    })()}

                  {isLastTurn && promptTop && workingRow === true && workingNode}
                  {isLastTurn && showPlanning && !hasWorkingRow && (
                    <ToolRowBase
                      icon={<SpiralLoader size={12} />}
                      shimmerLabel={labels.planning}
                      completeLabel="Done"
                      isAnimating
                    />
                  )}
                </div>
              );
            })}
            {(!promptTop || workingRow !== true) && workingNode}
          </div>
          {showAssistantBreathingSpace && (
            <div aria-hidden="true" data-breathing-space className={classes.breathingSpace} />
          )}
          {wantsJump && jumpSettled && (
            <div className={classes.jumpToLatest} data-search-ignore>
              <Button
                size="compact-sm"
                radius="xl"
                variant="default"
                leftSection={<IconArrowDown size={14} />}
                className={classes.jumpToLatestButton}
                onClick={jumpToLatest}
              >
                {labels.newMessages(unseenCount)}
              </Button>
            </div>
          )}
        </div>
      </Box>
      <VisuallyHiddenStatus>{announcement}</VisuallyHiddenStatus>
    </ToolPresentationProvider>
  );
});

MessageList.displayName = 'MessageList';

type AssistantPartsProps = {
  msg: ChatMessage;
  isRowStreaming: boolean;
  isRowTextStreaming: boolean;
  highlighter?: SyntaxHighlighter;
  wrapLines?: boolean;
  responsiveTables?: boolean;
  frameBatched?: boolean;
  tailGranularity?: MarkdownTailGranularity;
  suppressQuestionTool: boolean;
  suppressQuestionToolCallId?: string;
  ToolRendererComponent: React.ComponentType<ToolRendererSlotProps>;
  toolRenderers?: Record<string, React.ComponentType<CustomToolRendererProps>>;
  partRenderers?: PartRenderers;
  streamingCaret: boolean;
  onToolAction?: ToolActionHandler;
  onRetry?: () => void;
  retryAnywhere?: () => void;
  enableImagePreview?: boolean;
  answerFileLabels?: Partial<AnswerFileLabels>;
  toolRunOptions?: ResolvedToolRunOptions | null;
  appearance: AppearanceTracker;
  lookups: ToolCallLookups;
  toolActivity: boolean;
  custom?: TranscriptPresentation;
  runLabels?: Partial<ToolRunLabels>;
  turnStartedAt?: number;
  toolOutputs?: ToolOutputFormatters;
  evenSpacing: boolean;
};

function findPartRenderer(renderers: PartRenderers | undefined, part: unknown) {
  const type = isRecord(part) ? part.type : undefined;
  return renderers && typeof type === 'string' && Object.hasOwn(renderers, type)
    ? renderers[type]
    : undefined;
}

function samePartList(previous: unknown[] = [], next: unknown[] = []): boolean {
  if (previous === next) {
    return true;
  }
  if (previous.length !== next.length) {
    return false;
  }
  return previous.every((part, index) => part === next[index]);
}

function sameRenderers(
  previous: Record<string, unknown> | undefined,
  next: Record<string, unknown> | undefined
): boolean {
  if (previous === next) {
    return true;
  }
  if (!previous || !next) {
    return false;
  }
  const keys = Object.keys(previous);
  return (
    keys.length === Object.keys(next).length && keys.every((key) => previous[key] === next[key])
  );
}

/**
 * A finished message keeps its element tree while the streaming one grows a part per token.
 * Only a row that can no longer move on its own is allowed to bail out: it must not be streaming
 * and every tool call it holds must already have a result. Whole-transcript facts never reach this
 * comparator, they arrive as booleans derived from the row itself.
 */
function areAssistantPartsEqual(previous: AssistantPartsProps, next: AssistantPartsProps): boolean {
  if (next.isRowStreaming || next.isRowTextStreaming) {
    return false;
  }
  if (hasUnresolvedToolCalls(next.msg.parts)) {
    return false;
  }
  if (previous.msg !== next.msg) {
    if (previous.msg.id !== next.msg.id || previous.msg.role !== next.msg.role) {
      return false;
    }
    if (!samePartList(previous.msg.parts, next.msg.parts)) {
      return false;
    }
  }
  return (
    previous.isRowStreaming === next.isRowStreaming &&
    previous.isRowTextStreaming === next.isRowTextStreaming &&
    previous.highlighter === next.highlighter &&
    previous.wrapLines === next.wrapLines &&
    previous.responsiveTables === next.responsiveTables &&
    previous.frameBatched === next.frameBatched &&
    previous.tailGranularity === next.tailGranularity &&
    previous.suppressQuestionTool === next.suppressQuestionTool &&
    previous.suppressQuestionToolCallId === next.suppressQuestionToolCallId &&
    previous.ToolRendererComponent === next.ToolRendererComponent &&
    previous.onToolAction === next.onToolAction &&
    previous.onRetry === next.onRetry &&
    previous.retryAnywhere === next.retryAnywhere &&
    previous.enableImagePreview === next.enableImagePreview &&
    previous.answerFileLabels === next.answerFileLabels &&
    previous.toolRunOptions === next.toolRunOptions &&
    previous.appearance === next.appearance &&
    previous.toolActivity === next.toolActivity &&
    previous.custom === next.custom &&
    previous.runLabels === next.runLabels &&
    previous.turnStartedAt === next.turnStartedAt &&
    previous.toolOutputs === next.toolOutputs &&
    previous.evenSpacing === next.evenSpacing &&
    previous.streamingCaret === next.streamingCaret &&
    sameRenderers(previous.toolRenderers, next.toolRenderers) &&
    sameRenderers(previous.partRenderers, next.partRenderers)
  );
}

const AssistantParts = memo(function AssistantParts({
  msg,
  isRowStreaming,
  isRowTextStreaming,
  highlighter,
  wrapLines,
  responsiveTables,
  frameBatched,
  tailGranularity,
  suppressQuestionTool,
  suppressQuestionToolCallId,
  ToolRendererComponent,
  toolRenderers,
  partRenderers,
  streamingCaret,
  onToolAction,
  onRetry,
  retryAnywhere,
  enableImagePreview,
  answerFileLabels,
  toolRunOptions,
  appearance,
  lookups,
  toolActivity,
  custom,
  runLabels,
  turnStartedAt,
  toolOutputs,
  evenSpacing,
}: AssistantPartsProps) {
  const parts = useMemo(
    () => normalizeAssistantToolParts(msg.parts ?? []) as unknown[],
    [msg.parts]
  );

  const approvals = useToolApprovals();
  const { catalog } = useToolPresentation();
  const liveLookups = useMemo(
    () => (hasUnresolvedToolCalls(parts) ? lookups : undefined),
    [parts, lookups]
  );

  const elements = useMemo(() => {
    const { siblingsByParentId, nestedToolCallIds, lastTextIndex } = getMessagePartIndex(parts);
    const chatStreamingStatus = isRowStreaming ? 'streaming' : 'ready';
    const visible: Array<{ part: unknown; index: number }> = [];

    parts.forEach((part, index) => {
      if (isTextPart(part)) {
        if (part.text) {
          visible.push({ part, index });
        }
        return;
      }
      if (
        isErrorPart(part) ||
        isFeedPart(part) ||
        isFilePart(part) ||
        findPartRenderer(partRenderers, part)
      ) {
        visible.push({ part, index });
        return;
      }
      if (!isV5ToolPart(part) || part.type === 'tool-TaskOutput') {
        return;
      }
      if (
        part.type === 'tool-Question' &&
        (suppressQuestionTool ||
          (suppressQuestionToolCallId !== undefined &&
            part.toolCallId === suppressQuestionToolCallId))
      ) {
        return;
      }
      if (part.toolCallId && nestedToolCallIds.has(part.toolCallId)) {
        return;
      }
      visible.push({ part, index });
    });

    const renderEntry = ({ part, index }: { part: unknown; index: number }): React.ReactNode => {
      const PartRenderer = findPartRenderer(partRenderers, part);
      if (PartRenderer) {
        return (
          <PartRenderer
            key={`${msg.id}-part-${index}`}
            part={part}
            messageId={msg.id}
            index={index}
            chatStatus={chatStreamingStatus}
          />
        );
      }
      if (isFilePart(part)) {
        return (
          <AnswerFile
            key={`${msg.id}-file-${index}`}
            part={part}
            messageId={msg.id}
            enableImagePreview={enableImagePreview}
            labels={answerFileLabels}
          />
        );
      }
      if (isTextPart(part)) {
        return (
          <div key={`${msg.id}-text-${index}`} className={classes.assistantText}>
            <Markdown
              content={part.text}
              highlighter={highlighter}
              wrapLines={wrapLines}
              responsiveTables={responsiveTables}
              frameBatched={frameBatched}
              tailGranularity={tailGranularity}
              streaming={isRowStreaming && index === lastTextIndex ? true : undefined}
              streamingCaret={streamingCaret && isRowTextStreaming}
            />
          </div>
        );
      }
      if (isErrorPart(part)) {
        const retry =
          part.retryable === undefined ? onRetry : part.retryable ? retryAnywhere : undefined;
        return (
          <ErrorMessage
            key={`${msg.id}-error-${index}`}
            title={part.title}
            message={part.message}
            onRetry={retry}
          />
        );
      }
      if (isTurnSummaryPart(part)) {
        return (
          <TurnSummary
            key={`${msg.id}-summary-${index}`}
            durationMs={part.durationMs}
            tokens={part.tokens}
            tokenBudget={part.tokenBudget}
            backgroundTasks={part.backgroundTasks}
          />
        );
      }
      if (isContextEventPart(part)) {
        return (
          <ContextEventRow
            key={`${msg.id}-context-${index}`}
            kind={part.kind}
            label={part.label}
            detail={part.detail}
            items={part.items}
          />
        );
      }
      if (isHookActivityPart(part)) {
        return (
          <HookActivity
            key={`${msg.id}-hooks-${index}`}
            event={part.event}
            status={part.status}
            hooks={part.hooks}
            reason={part.reason}
          />
        );
      }
      if (isCompactionPart(part)) {
        return (
          <CompactBoundary
            key={`${msg.id}-compaction-${index}`}
            summary={part.summary}
            tokensBefore={part.tokensBefore}
            tokensAfter={part.tokensAfter}
            direction={part.direction}
            userContext={part.userContext}
            highlighter={highlighter}
          />
        );
      }
      const toolPart = part as ToolPart;
      const toolCallId = toolPart.toolCallId;
      const nestedTools =
        (toolPart.type === 'tool-Task' || toolPart.type === 'tool-Agent') && toolCallId
          ? siblingsByParentId.get(toolCallId) || []
          : undefined;
      return (
        <ToolRendererComponent
          key={toolCallId ?? `${msg.id}-tool-${index}`}
          part={toolPart}
          nestedTools={nestedTools}
          chatStatus={chatStreamingStatus}
          toolRenderers={toolRenderers}
          onToolAction={onToolAction}
          wrapLines={wrapLines}
          lookups={liveLookups}
          showActivity={toolActivity}
        />
      );
    };

    const appear = (node: React.ReactNode): React.ReactNode => {
      if (!appearance.enabled || !React.isValidElement(node) || node.key === null) {
        return node;
      }
      if (!appearance.isNew(`${msg.id}:${node.key}`)) {
        return node;
      }
      return (
        <div key={node.key} className={classes.appear}>
          {node}
        </div>
      );
    };

    if (custom) {
      return custom
        .renderParts(visible, renderEntry, {
          messageId: msg.id,
          chatStatus: chatStreamingStatus,
          lookups: liveLookups,
          approvals,
          showActivity: toolActivity,
          highlighter,
          wrapLines,
          toolOutputs,
          catalog,
          runLabels,
          turnStartedAt,
        })
        .map(appear);
    }

    if (!toolRunOptions) {
      return visible.map((entry) => appear(renderEntry(entry)));
    }

    return groupToolRuns(
      visible,
      ({ part }) => {
        if (!isV5ToolPart(part) || !toolRunOptions.types.has(part.type)) {
          return false;
        }
        const request = approvals?.[part.toolCallId ?? ''];
        if (request && (!request.outcome || request.isPending)) {
          return false;
        }
        return part.state !== 'output-error' || part.recovered === true;
      },
      toolRunOptions.minRun
    ).map((segment) => {
      if (segment.kind === 'single') {
        return appear(renderEntry(segment.item));
      }
      const runParts = segment.items.map((entry) => entry.part as ToolPart);
      const first = segment.items[0];
      return appear(
        <ToolRunGroup
          key={`run-${runParts[0].toolCallId ?? `${msg.id}-${first.index}`}`}
          parts={runParts}
          chatStatus={chatStreamingStatus}
          ToolRendererComponent={ToolRendererComponent}
          toolRenderers={toolRenderers}
          onToolAction={onToolAction}
          wrapLines={wrapLines}
          lookups={liveLookups}
          showActivity={toolActivity}
          labels={toolRunOptions.labels}
        />
      );
    });
  }, [
    parts,
    msg.id,
    isRowStreaming,
    isRowTextStreaming,
    highlighter,
    wrapLines,
    responsiveTables,
    frameBatched,
    tailGranularity,
    suppressQuestionTool,
    suppressQuestionToolCallId,
    ToolRendererComponent,
    toolRenderers,
    onToolAction,
    onRetry,
    toolRunOptions,
    appearance,
    liveLookups,
    toolActivity,
    custom,
    runLabels,
    turnStartedAt,
    toolOutputs,
    catalog,
    approvals,
    partRenderers,
    streamingCaret,
  ]);

  return (
    <div
      className={classes.assistantParts}
      data-stacked={elements.length > 1 || undefined}
      data-rows={custom?.kind === 'rows' || undefined}
      data-even={evenSpacing || undefined}
    >
      {elements}
    </div>
  );
}, areAssistantPartsEqual);

AssistantParts.displayName = 'AssistantParts';
API reference
Prop
Type
Required
messages
ChatMessage[]
Yes
status
ChatStatus
Yes
className
string
No
style
React.CSSProperties
No
contentWidth
ContentWidth
No
Max width of the message column: a number in px or any CSS width, `420px` by default. Pass `'100%'` for a full-width chat
showCopyToolbar
boolean
No
suppressQuestionTool
boolean
No
Hides every `tool-Question` part. Prefer `suppressQuestionToolCallId`.
suppressQuestionToolCallId
string
No
Hides only the `tool-Question` part with this `toolCallId` (e.g. the one shown in the input bar)
initialScrollBehavior
'bottom' | 'top'
No
Where to position the scroll container on initial mount. - "bottom" (default): classic chat behavior, pinned to the latest message. - "top": start from the top of the conversation, useful for static demos or read-only transcripts where the user should read top-to-bottom.
enableImagePreview
boolean
No
Opens an attached image of a user message in a fullscreen lightbox on click, `true` by default
collapseToolRuns
boolean | CollapseToolRunsOptions
No
Collapses runs of consecutive read and search tool calls in an assistant message into one summary row, for example `Read 3 files, searched 2 patterns`, `false` by default. Does not apply to the rows presentation, where every call keeps its own row.
messageActions
MessageListActions
No
Turns on message actions: edit and rewind for user messages, retry, feedback and branch for assistant turns. Each action appears only when its callback is set; without this prop the plain copy toolbar is rendered.
commands
SlashCommandInfo[]
No
Known slash commands; a user message that starts with one of them is shown as a command chip
slots
{ UserMessage?: React.ComponentType<{ message: ChatMessage; className?: string; enableImagePreview?: boolean; commands?: SlashCommandInfo[]; longMessageThreshold?: LongTextThreshold | boolean; }>; ToolRenderer?: React.ComponentType<ToolRendererSlotProps>; }
No
classNames
{ userMessage?: string; }
No
toolRenderers
Record<string, React.ComponentType<CustomToolRendererProps>>
No
partRenderers
PartRenderers
No
Renderers of part types the list does not know, keyed by `part.type`; parts without one stay hidden. `text` replaces the Markdown of an answer, and `reasoning`, hidden by default, shows through its renderer; planning, the working row and copying still read the `text` parts.
sendScroll
SendScroll
No
Where the list scrolls when the user sends, `bottom` by default; `prompt-top` puts the question at the top and grows the answer under it; `prompt-top-hold` also keeps the list still while the answer grows, until the user scrolls to the bottom or presses the new messages button
streamingCaret
boolean
No
Shows a caret after the growing text while streaming, `false` by default
assistantBreathingSpace
boolean
No
Keeps room under a growing answer so the list does not jitter in the first frames, `true` by default; the room is given while the answer is live and released when the turn is finished
stackFromBottom
boolean
No
Keeps a short transcript at the composer instead of the top of the feed, `false` by default
lazyTurns
boolean | number
No
Skips layout and paint of finished turns outside the viewport, `false` by default; a number is the turn count it starts from, `true` means 50
stopped
boolean
No
The user stopped the latest answer, so its end is announced as `labels.answerStopped`; `AgentChat` sets it from its stop button
onToolAction
ToolActionHandler
No
Receives actions reported by custom tool renderers through `onAction`
toolCallLookups
ToolCallLookups
No
Transcript lookups behind the visible state of a tool call: queued, waiting for permission, refused, already answered. Built from `messages` when omitted, see `createToolCallLookups`.
workingRow
React.ReactNode | boolean
No
Quiet line at the end of the transcript while the agent works between tool calls, `false` by default. `true` renders the kit row: `labels.working` with the time since the last event; a node replaces it, for example an `AgentStatus` of the host carrying its own label and token count.
toolActivity
boolean
No
Shows how long a running tool call has been going and the progress its MCP server reports, `false` by default. The time comes from the host when the call carries a start time and from the kit otherwise.
presentation
'cards' | TranscriptPresentation
No
How the transcript is laid out: `cards` (the default) keeps the tool cards; `rowsPresentation` shows the flat transcript of a terminal client — a marker, the call and its answer under a gutter; `quietPresentation` folds MCP calls into quiet lines that open into their details. Both are passed as values so they only reach the bundle of a host that uses them.
toolOutputs
ToolOutputFormatters
No
Result formatters by part type, keyed as `toolRenderers`: `tool-Read`, `tool-mcp__tracker__task_list` or a server-wide `tool-mcp__tracker__*`. A formatter that returns `null` leaves the readable summary the kit builds.
toolCatalog
ToolCatalog
No
Tool definitions of the connected MCP servers keyed by `mcp__<server>__<tool>`: a call then reads by the tool `title` instead of its name, and its arguments and result as a short summary.
toolArgs
ToolArgsFormatters
No
Argument formatters by part type, keyed as `toolOutputs`; `null` keeps the kit summary
evenSpacing
boolean
No
One vertical gap between every two blocks of the transcript — prompt, answer text, tool call — instead of the tighter gaps around the prompt, `false` by default. Always on in `rows`.
locale
string
No
Locale of numbers and dates in tool arguments and results, the locale of the runtime by default
onRetry
() => void
No
Adds a retry button to error parts
withSearch
boolean
No
Adds the conversation search, opened with Mod+F while focus is inside the list, `false` by default
searchOpened
boolean
No
Controlled open state of the search
onSearchOpenedChange
(opened: boolean) => void
No
Called when the search opens or closes
stickyPrompt
boolean
No
Pins the prompt of the answer being read to the top while scrolling a long answer
longMessageThreshold
LongTextThreshold | boolean
No
Collapses long user messages to head and tail; `true` uses `{ chars: 2000, lines: 30 }`, `false` by default
topFade
boolean
No
Fades the top edge of the list once it is scrolled, so content does not end abruptly under a header
wrapLines
boolean
No
Wraps long lines in code blocks and diffs instead of scrolling them sideways, for narrow layouts, `false` by default
responsiveTables
boolean
No
Shows answer tables with too many columns for the width as one card per row, `false` by default
frameBatched
boolean
No
Commits the streaming answer at most once per animation frame, `false` by default. A finished stream, a hidden tab and `prefers-reduced-motion` commit right away.
tailGranularity
MarkdownTailGranularity
No
Reveals the growing tail of the streaming answer by character (`'char'`, the default) or by finished line (`'line'`)
animateAppearance
boolean
No
Fades a newly arrived message or part in over 150ms with a few pixels of travel, `false` by default. The transcript already on screen at mount never animates, and `prefers-reduced-motion` turns the animation off.
onScrollbarWidthChange
(width: number) => void
No
Called with the width in px of the vertical scrollbar whenever it appears, disappears or resizes
highlighter
SyntaxHighlighter
No
Syntax highlighter for code blocks in answers and compaction summaries; plain code blocks when omitted
labels
Partial<MessageListLabels>
No
Overrides of the default English labels