AgentChat

Create a full chat surface with messages, status, and send/stop handlers. Messages are compatible with useChat from the Vercel AI SDK, see Installation.

Tool approvals

approvals attaches a confirmation to a tool call by its toolCallId without replacing the card that renders it, so an MCP call gets Allow/Deny with the same renderer. Each entry takes what ToolApprovalFooter understands — reason, approveOptions, ruleSuggestion, matchedRule, requestedBy, onExplain, onApprove, onReject, onRejectWithFeedback, labels — plus isPending while the call is still running. Once the host settles the request, replace the entry with { outcome: { decision: 'approved' | 'rejected', scope } } and the footer gives way to a quiet settled line. An id with no call in the transcript is ignored, and dropping the entry removes the footer. MessageList rendered on its own takes the same map through ToolApprovalsProvider. The settled line names the scope the call was approved with: set labels.toolApproval.scopes, for example { session: 'for this session' }, or it falls back to the label of the matching approve option and then to the scope itself.

Presentation

presentation sets how the transcript lays out tool calls. 'cards', the default, keeps a card per call. rowsPresentation shows the flat transcript of a terminal client: a marker, the call and its answer under a gutter, with even spacing. quietPresentation turns MCP calls, thinking and any call toolCatalog describes (a local tool of the host included) into muted lines that open into their arguments and result; the line reads its outcome through toolOutputs and its opened arguments through toolArgs when a formatter returns a string, a failure included, and falls back to the kit summary on null; the thinking and the finished or running calls a turn makes before its answer fold into one line such as Thought · used 2 tools · 26s, and a call that waits for a decision in approvals stays outside that line with a frame and its buttons. Other tool parts keep their cards. Both presentations are values imported from the package root, so a host that does not import them does not ship their code.

Tool catalog and formatters

toolCatalog takes the tool definitions of the connected MCP servers keyed by mcp__<server>__<tool>; the full part type and the bare tool name also match. Each entry is the Tool of the MCP specification (https://modelcontextprotocol.io/specification): title, description, annotations, inputSchema and outputSchema. A call then reads by the tool title instead of its name, its arguments by the order, titles and formats of inputSchema (a string argument stays a string), and its result strictly by the CallToolResult: structuredContent by outputSchema (an array is counted only when the schema declares one, dates are dates only by format), else no inline summary: a result of text alone shows only when the call is opened, as it is, never parsed as JSON (give it a summary with toolOutputs); image and audio blocks show as a preview and a player, resource_link and resource as file chips, isError as a failure. The result may arrive as the CallToolResult object or as its bare array of content blocks. toolArgs and toolOutputs format the arguments line and the result of a call. They are keyed like toolRenderers, by the full part type or a server-wide tool-mcp__<server>__*, and return null to keep the summary the kit builds. A formatter receives the part and a context with state, summary and locale, plus args and schema for arguments, or output, result, schema and labels for results. result is the CallToolResult and the recommended input; output keeps the value 0.3 gave: the text of the content, parsed when it holds JSON. locale formats numbers and dates; the runtime locale is the default.

Labels

labels translates the whole chat. Its sections merge key by key with the English defaults: messageList (including search, toolRuns and planning), inputBar, welcome, errorMessage, turnSummary, toolApproval, the tool sections toolTitles, toolCall, toolCard, toolRow, mcpTool, bashTool, editTool, searchTool, todoTool, planTool, toolGroup and thinkingTool, plus durationUnits, errorTitle and placeholder. durationUnits (hours, minutes, seconds, milliseconds) applies to every duration in the transcript: the turn summary, the working line, running calls and thinking. messageList.toolRuns holds the phrases of folded runs, such as otherTools and thought; messageList.planning is the row shown before the first token when workingRow is off. inputBarProps.labels, emptyState.labels and the labels of an approval request win over the matching section. A MessageList rendered on its own reads the same sections from ChatLabelsProvider.

Working row and motion

workingRow shows a quiet line at the end of the transcript while the agent works between tool calls, true by default; pass a node, for example an AgentStatus with your own label and token count, to replace it. toolActivity shows how long a running call has been going and the progress its MCP server reports, true by default. evenSpacing puts one gap between every two blocks (prompt, answer text, tool call) instead of the tighter gaps around the prompt, false by default and always on with rowsPresentation. animateAppearance fades a newly arrived message or part in over 150ms, true by default; the transcript already on screen at mount never animates, and prefers-reduced-motion turns it off. frameBatched types the streaming answer out evenly — whole words, once per animation frame, about 140 characters a second and faster while the buffer is full, so a provider that sends large bursts still reads as a steady stream — true by default; a finished stream, a hidden tab and reduced motion commit the rest right away. Words released in the last moments fade in over 100ms, which is why the growing tail is split into a few <span> elements while it streams; prefers-reduced-motion turns the fade off. tailGranularity reveals the streaming tail by character ('char', the default) or by finished line ('line').

Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-react
Examples
Basic
Empty centered
Welcome empty state
With attachments
Copy toolbar
Approving an MCP call
Cards rows and quiet lines
Catalog formatters and locale
Translated chat
Working row and tool activity
Full-page chat
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.

AgentChat/AgentChat.tsx
import React, { useMemo, useRef, useState } from 'react';
import { Box, rem, Stack, Text } from '@mantine/core';
import { useUncontrolled } from '@mantine/hooks';
import type { AgentChatProps, ChatMessage, ToolPart, ToolRendererSlotProps } from '../types';
import type { QuestionAnswer, QuestionConfig } from '../question/QuestionPrompt';
import { getContentWidthStyle } from '../utils/content-width';
import { cx } from '../utils/cx';
import { ToolApprovalSlot, ToolApprovalsProvider } from '../approvals/tool-approvals';
import { rendersFramedToolCard } from '../tools/tool-registry';
import { ChatLabelsProvider, type ChatComponentLabels } from '../labels/chat-labels';
import { mergeLabels } from '../utils/merge-labels';
import { MessageList } from '../MessageList/MessageList';
import { ToolRenderer } from '../tools/ToolRenderer';
import { InputBar } from '../input/InputBar';
import { Suggestions, type SuggestionItem } from '../input/Suggestions';
import { ChatWelcome, type ChatWelcomeAction } from './ChatWelcome';
import { DEFAULT_AGENT_CHAT_LABELS, type AgentChatLabels } from './agent-chat-labels';
import classes from './AgentChat.module.css';

/** Drop-in chat surface: message list, composer, suggestions and question bar */
export function AgentChat({
  messages,
  onSend,
  status,
  onStop,
  error,
  classNames,
  slots,
  toolRenderers,
  partRenderers,
  draft: draftProp,
  onDraftChange,
  sendScroll,
  streamingCaret,
  assistantBreathingSpace,
  stackFromBottom,
  lazyTurns,
  attachments,
  showCopyToolbar,
  collapseToolRuns,
  messageActions,
  onRetry,
  onToolAction,
  approvals,
  labels,
  inputBarProps,
  statusBar,
  withSearch = false,
  stickyPrompt,
  highlighter,
  longMessageThreshold,
  contentWidth,
  initialScrollBehavior,
  enableImagePreview,
  suggestions,
  emptyStatePosition = 'default',
  emptyState,
  hideSuggestionsWhenNotEmpty = false,
  alignComposer = false,
  topFade,
  wrapLines,
  responsiveTables,
  frameBatched = true,
  tailGranularity,
  animateAppearance = true,
  toolCallLookups,
  workingRow = true,
  toolActivity = true,
  presentation,
  toolOutputs,
  toolCatalog,
  toolArgs,
  evenSpacing,
  locale,
  emptySuggestionsPlacement = 'input',
  emptyStateWidth,
  questionTool,
  className,
  style,
}: AgentChatProps) {
  const isBusy = status === 'streaming' || status === 'submitted';
  const [stopped, setStopped] = useState(false);
  const [wasBusy, setWasBusy] = useState(isBusy);
  if (wasBusy !== isBusy) {
    setWasBusy(isBusy);
    if (isBusy) {
      setStopped(false);
    }
  }
  const [draft, setDraft] = useUncontrolled({
    value: draftProp,
    defaultValue: '',
    finalValue: '',
    onChange: onDraftChange,
  });
  const [scrollbarWidth, setScrollbarWidth] = useState(0);
  const resolvedEmptyStateWidth = emptyStateWidth ?? (emptyState ? 600 : undefined);
  const rootStyle: React.CSSProperties | undefined = {
    ...(resolvedEmptyStateWidth === undefined
      ? {}
      : {
          '--ae-empty-state-width':
            typeof resolvedEmptyStateWidth === 'number'
              ? rem(resolvedEmptyStateWidth)
              : resolvedEmptyStateWidth,
        }),
    ...(alignComposer && scrollbarWidth > 0
      ? { '--ae-scrollbar-width': `${scrollbarWidth}px` }
      : {}),
    ...style,
  } as React.CSSProperties;
  const [searchOpened, setSearchOpened] = useState(false);

  const ResolvedInputBar = slots?.InputBar ?? InputBar;
  const errorTitle = labels?.errorTitle ?? DEFAULT_AGENT_CHAT_LABELS.errorTitle;
  const inputBarLabels = useMemo(
    () => mergeLabels(labels?.inputBar, inputBarProps?.labels),
    [labels?.inputBar, inputBarProps?.labels]
  );
  const componentLabels = useMemo<Partial<ChatComponentLabels> | undefined>(
    () => (labels ? pickComponentLabels(labels) : undefined),
    [labels]
  );
  const hostToolRenderer = slots?.ToolRenderer;
  const withApprovals = Boolean(approvals);
  const approvalToolRenderer = useMemo(() => {
    if (!withApprovals) {
      return undefined;
    }
    const Base = hostToolRenderer ?? ToolRenderer;
    return function ApprovalToolRenderer(props: ToolRendererSlotProps) {
      return (
        <ToolApprovalSlot
          toolCallId={props.part.toolCallId}
          framed={!rendersFramedToolCard(resolvePartType(props.part))}
        >
          <Base {...props} />
        </ToolApprovalSlot>
      );
    };
  }, [withApprovals, hostToolRenderer]);
  const resolvedSlots = approvalToolRenderer
    ? { ...slots, ToolRenderer: approvalToolRenderer }
    : slots;
  const isEmpty = !error && messages.length === 0;
  const emptyLayout =
    emptyState?.layout ?? (emptyStatePosition === 'center' ? 'center' : 'welcome');
  const isCenteredEmptyState =
    isEmpty && (emptyState ? emptyLayout === 'center' : emptyStatePosition === 'center');
  const isWelcome = isEmpty && Boolean(emptyState) && emptyLayout === 'welcome';
  const rootRef = useRef<HTMLDivElement>(null);

  const pendingQuestion = useMemo(
    () => findPendingQuestion(messages, questionTool),
    [messages, questionTool]
  );
  const suggestionConfig = resolveSuggestions(suggestions);
  const emptySuggestionItems = emptyState?.suggestions ?? suggestionConfig.items;
  const showInputSuggestions =
    (emptySuggestionsPlacement === 'input' || emptySuggestionsPlacement === 'both') &&
    !(hideSuggestionsWhenNotEmpty && !isEmpty) &&
    !isWelcome;
  const welcomeActions: ChatWelcomeAction[] =
    emptyState?.actions ??
    (emptyState?.suggestions ?? suggestionConfig.items).map((item) => ({
      id: item.id,
      label: item.label,
      value: item.value,
      icon: item.icon,
    }));

  const handleWelcomeAction = (action: ChatWelcomeAction) => {
    setDraft(action.value ?? action.label);
    requestAnimationFrame(() => {
      const textarea = rootRef.current?.querySelector<HTMLTextAreaElement>('textarea');
      textarea?.focus();
      textarea?.setSelectionRange(textarea.value.length, textarea.value.length);
    });
  };
  const showEmptySuggestions =
    isCenteredEmptyState &&
    (emptyState?.suggestions !== undefined ||
      emptySuggestionsPlacement === 'empty' ||
      emptySuggestionsPlacement === 'both') &&
    emptySuggestionItems.length > 0;

  const handleEmptySuggestionSelect = (item: SuggestionItem) => {
    setDraft(item.value ?? item.label);
  };

  const emptySuggestionsNode = showEmptySuggestions ? (
    <Suggestions
      items={emptySuggestionItems}
      onSelect={handleEmptySuggestionSelect}
      disabled={status === 'streaming' || status === 'submitted'}
      className={cx(
        classes.emptySuggestions,
        classes.emptySuggestionsTop,
        suggestionConfig.className
      )}
      itemClassName={cx(classes.emptySuggestionItem, suggestionConfig.itemClassName)}
    />
  ) : null;

  const inputBarNode = (
    <ResolvedInputBar
      {...inputBarProps}
      onSend={onSend}
      status={status}
      onStop={() => {
        setStopped(true);
        onStop();
      }}
      value={draft}
      onChange={setDraft}
      placeholder={
        inputBarProps?.placeholder ?? labels?.placeholder ?? DEFAULT_AGENT_CHAT_LABELS.placeholder
      }
      labels={inputBarLabels}
      className={cx(
        classNames?.inputBar,
        inputBarProps?.className,
        isCenteredEmptyState && classes.centeredInputBar,
        alignComposer && !isCenteredEmptyState && classes.alignedInputBar
      )}
      onAttach={attachments?.onAttach ?? inputBarProps?.onAttach}
      attachedImages={attachments?.images ?? inputBarProps?.attachedImages}
      attachedFiles={attachments?.files ?? inputBarProps?.attachedFiles}
      onRemoveImage={attachments?.onRemoveImage ?? inputBarProps?.onRemoveImage}
      onRemoveFile={attachments?.onRemoveFile ?? inputBarProps?.onRemoveFile}
      onCancelFile={attachments?.onCancelFile ?? inputBarProps?.onCancelFile}
      onRetryFile={attachments?.onRetryFile ?? inputBarProps?.onRetryFile}
      blockSendWhileUploading={
        attachments?.blockSendWhileUploading ?? inputBarProps?.blockSendWhileUploading
      }
      onPaste={attachments?.onPaste ?? inputBarProps?.onPaste}
      isDragOver={attachments?.isDragOver ?? inputBarProps?.isDragOver}
      suggestions={showInputSuggestions ? (suggestions ?? inputBarProps?.suggestions) : []}
      questionBar={
        pendingQuestion
          ? {
              id: pendingQuestion.id,
              questions: pendingQuestion.questions,
              questionIndex: pendingQuestion.questionIndex,
              totalQuestions: pendingQuestion.totalQuestions,
              onPreviousQuestion: pendingQuestion.onPreviousQuestion,
              onNextQuestion: pendingQuestion.onNextQuestion,
              submitLabel: pendingQuestion.submitLabel,
              skipLabel: pendingQuestion.skipLabel,
              allowSkip: pendingQuestion.allowSkip,
              onSubmit: (answer: QuestionAnswer, { questionIndex }: { questionIndex: number }) => {
                questionTool?.onAnswer?.({
                  toolCallId: pendingQuestion.toolCallId,
                  question:
                    pendingQuestion.questions[questionIndex - 1] ?? pendingQuestion.question,
                  answer,
                });
              },
              onSkip: ({ questionIndex }: { questionIndex: number }) => {
                questionTool?.onAnswer?.({
                  toolCallId: pendingQuestion.toolCallId,
                  question:
                    pendingQuestion.questions[questionIndex - 1] ?? pendingQuestion.question,
                  answer: { kind: 'skip' },
                });
              },
            }
          : undefined
      }
    />
  );

  const listMessages = useMemo<ChatMessage[]>(
    () =>
      error
        ? [
            ...messages,
            {
              id: 'agent-chat-error',
              role: 'assistant',
              parts: [
                {
                  type: 'error',
                  title: errorTitle,
                  message: error.message,
                },
              ],
            },
          ]
        : messages,
    [messages, error, errorTitle]
  );

  return (
    <Box
      ref={rootRef}
      className={cx(classes.root, classNames?.root, className)}
      style={getContentWidthStyle(contentWidth, rootStyle)}
      data-empty-width={resolvedEmptyStateWidth === undefined ? undefined : true}
      data-empty-centered={isCenteredEmptyState || undefined}
      data-align-composer={alignComposer || undefined}
      onKeyDown={
        withSearch && !isCenteredEmptyState && !isWelcome
          ? (event: React.KeyboardEvent<HTMLDivElement>) => {
              if (
                !event.defaultPrevented &&
                (event.metaKey || event.ctrlKey) &&
                event.key.toLowerCase() === 'f'
              ) {
                event.preventDefault();
                setSearchOpened(true);
                event.currentTarget
                  .querySelector<HTMLInputElement>('[role="search"] input')
                  ?.focus();
              }
            }
          : undefined
      }
    >
      {isCenteredEmptyState ? (
        <div className={classes.emptyState}>
          <div className={classes.emptyStateInner}>
            {emptyState && (emptyState.title || emptyState.description) && (
              <Stack gap={6} align="center" ta="center" className={classes.emptyStateHeading}>
                {emptyState.title && (
                  <Text component="h2" size="md" fw={500} className={classes.emptyStateTitle}>
                    {emptyState.title}
                  </Text>
                )}
                {emptyState.description && (
                  <Text size="sm" c="dimmed">
                    {emptyState.description}
                  </Text>
                )}
              </Stack>
            )}
            {emptyState?.content && (
              <div className={classes.emptyStateContent}>{emptyState.content}</div>
            )}
            {emptySuggestionsNode}
            {inputBarNode}
          </div>
        </div>
      ) : isWelcome && emptyState ? (
        <ChatWelcome
          avatar={emptyState.avatar}
          title={emptyState.title}
          description={emptyState.description}
          content={emptyState.content}
          actions={welcomeActions}
          onAction={handleWelcomeAction}
          labels={mergeLabels(labels?.welcome, emptyState.labels)}
        />
      ) : (
        <ToolApprovalsProvider approvals={approvals} labels={labels?.toolApproval}>
          <ChatLabelsProvider labels={componentLabels}>
            <MessageList
              labels={labels?.messageList}
              messages={listMessages}
              status={status}
              classNames={classNames}
              slots={resolvedSlots}
              toolRenderers={toolRenderers}
              partRenderers={partRenderers}
              sendScroll={sendScroll}
              streamingCaret={streamingCaret}
              assistantBreathingSpace={assistantBreathingSpace}
              stackFromBottom={stackFromBottom}
              lazyTurns={lazyTurns}
              stopped={stopped}
              showCopyToolbar={showCopyToolbar}
              collapseToolRuns={collapseToolRuns}
              messageActions={messageActions}
              onRetry={onRetry}
              onToolAction={onToolAction}
              withSearch={withSearch}
              searchOpened={searchOpened}
              onSearchOpenedChange={setSearchOpened}
              stickyPrompt={stickyPrompt}
              topFade={topFade}
              onScrollbarWidthChange={alignComposer ? setScrollbarWidth : undefined}
              wrapLines={wrapLines}
              responsiveTables={responsiveTables}
              frameBatched={frameBatched}
              tailGranularity={tailGranularity}
              animateAppearance={animateAppearance}
              toolCallLookups={toolCallLookups}
              workingRow={workingRow}
              toolActivity={toolActivity}
              presentation={presentation}
              toolOutputs={toolOutputs}
              toolCatalog={toolCatalog}
              toolArgs={toolArgs}
              evenSpacing={evenSpacing}
              locale={locale}
              highlighter={highlighter}
              longMessageThreshold={longMessageThreshold}
              initialScrollBehavior={initialScrollBehavior}
              enableImagePreview={enableImagePreview}
              suppressQuestionTool={Boolean(pendingQuestion) && !pendingQuestion?.toolCallId}
              suppressQuestionToolCallId={pendingQuestion?.toolCallId}
            />
          </ChatLabelsProvider>
        </ToolApprovalsProvider>
      )}
      {statusBar && !isCenteredEmptyState ? (
        <div className={cx(classes.statusBar, alignComposer && classes.alignedStatusBar)}>
          {statusBar}
        </div>
      ) : null}
      {!isCenteredEmptyState ? inputBarNode : null}
    </Box>
  );
}

AgentChat.displayName = 'AgentChat';

function resolvePartType(part: ToolPart): string {
  return part.type === 'dynamic-tool' && typeof part.toolName === 'string'
    ? `tool-${part.toolName}`
    : part.type;
}

const COMPONENT_LABEL_KEYS = {
  errorMessage: true,
  turnSummary: true,
  mcpTool: true,
  toolCall: true,
  toolTitles: true,
  toolCard: true,
  toolRow: true,
  bashTool: true,
  editTool: true,
  searchTool: true,
  todoTool: true,
  planTool: true,
  toolGroup: true,
  durationUnits: true,
  thinkingTool: true,
} satisfies Record<keyof ChatComponentLabels, true>;

function pickComponentLabels(labels: Partial<AgentChatLabels>): Partial<ChatComponentLabels> {
  const picked: Record<string, unknown> = {};
  for (const key of Object.keys(COMPONENT_LABEL_KEYS) as Array<keyof ChatComponentLabels>) {
    if (labels[key] !== undefined) {
      picked[key] = labels[key];
    }
  }
  return picked as Partial<ChatComponentLabels>;
}

function resolveSuggestions(suggestions: AgentChatProps['suggestions']) {
  if (Array.isArray(suggestions)) {
    return {
      items: suggestions,
      className: undefined,
      itemClassName: undefined,
    };
  }
  return {
    items: suggestions?.items ?? [],
    className: suggestions?.className,
    itemClassName: suggestions?.itemClassName,
  };
}

type QuestionToolInput = {
  questions?: QuestionConfig[];
  question?: QuestionConfig;
  questionIndex?: number;
  totalQuestions?: number;
  onPreviousQuestion?: () => void;
  onNextQuestion?: () => void;
  submitLabel?: string;
  skipLabel?: string;
  allowSkip?: boolean;
};

/**
 * Finds the unanswered `tool-Question` part to dock in the input bar.
 * Only messages after the last user message are considered: anything before it
 * has already been answered or superseded by the user.
 */
export function findPendingQuestion(
  messages: AgentChatProps['messages'],
  questionTool: AgentChatProps['questionTool']
) {
  for (let i = messages.length - 1; i >= 0; i -= 1) {
    const message = messages[i];
    if (message?.role === 'user') {
      return null;
    }
    if (message?.role !== 'assistant') {
      continue;
    }
    const parts = message.parts ?? [];
    for (let p = parts.length - 1; p >= 0; p -= 1) {
      const part = parts[p] as {
        type?: string;
        toolCallId?: string;
        state?: string;
        input?: QuestionToolInput;
        output?: unknown;
      };
      if (part?.type !== 'tool-Question') {
        continue;
      }
      const input = part.input;
      const questions = input?.questions ?? [];
      const firstQuestion = questions[0] ?? input?.question;
      if (!firstQuestion) {
        continue;
      }
      if (
        part.state === 'output-available' ||
        part.state === 'output-error' ||
        part.output != null
      ) {
        return null;
      }
      return {
        id: part.toolCallId ?? `question-${i}-${p}`,
        toolCallId: part.toolCallId,
        questions,
        question: firstQuestion,
        questionIndex: input?.questionIndex,
        totalQuestions:
          input?.totalQuestions ?? (questions.length > 0 ? questions.length : undefined),
        onPreviousQuestion: input?.onPreviousQuestion,
        onNextQuestion: input?.onNextQuestion,
        submitLabel: questionTool?.submitLabel ?? input?.submitLabel,
        skipLabel: questionTool?.skipLabel ?? input?.skipLabel,
        allowSkip: questionTool?.allowSkip ?? input?.allowSkip,
      };
    }
  }
  return null;
}
API reference
Prop
Type
Required
messages
ChatMessage[]
Yes
onSend
(message: { role: 'user'; content: string }) => void
Yes
status
ChatStatus
Yes
onStop
() => void
Yes
error
Error
No
classNames
Partial<ChatClassNames>
No
slots
Partial<ChatSlots>
No
toolRenderers
Record<string, React.ComponentType<CustomToolRendererProps>>
No
partRenderers
PartRenderers
No
Renderers of parts keyed by `part.type`, `text` and `reasoning` included; see `PartRenderers`
draft
string
No
Controlled composer text; the chat keeps its own draft when omitted
onDraftChange
(draft: string) => void
No
Called on every composer change, including clearing after send
sendScroll
SendScroll
No
Where the list scrolls when the user sends, `bottom` by default; see `SendScroll`
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
attachments
{ onAttach?: () => void; images?: AttachedImage[]; files?: AttachedFile[]; onRemoveImage?: (id: string) => void; onRemoveFile?: (id: string) => void; onCancelFile?: (id: string) => void; onRetryFile?: (id: string) => void; blockSendWhileUploading?: boolean; onPaste?: (e: React.ClipboardEvent) => void; isDragOver?: boolean; }
No
Attachment configuration
showCopyToolbar
boolean
No
Show copy toolbar on text turns, `true` by default
collapseToolRuns
boolean | CollapseToolRunsOptions
No
Collapse runs of consecutive read and search tool calls into one summary row, `false` by default
messageActions
MessageListActions
No
Message actions under messages: edit, retry, rewind, branch and feedback; plain copy toolbar when omitted
onRetry
() => void
No
Adds a retry button to error cards, including the card rendered for `error`
onToolAction
ToolActionHandler
No
Receives actions reported by custom tool renderers through `onAction`
approvals
ToolApprovals
No
Approval requests keyed by `toolCallId`. The approve/reject footer is rendered under the card of that call whatever renders it, so an MCP call can ask for confirmation without replacing its renderer. An entry with an `outcome` shows a quiet settled line instead; an id with no call in the transcript is ignored.
labels
Partial<AgentChatLabels>
No
Every label of the chat, grouped by the component that shows it: `messageList` (with `search` and `toolRuns`), `inputBar`, `errorMessage`, `turnSummary`, `toolApproval`, `welcome`, the tool sections `toolTitles`, `toolCall`, `toolCard`, `toolRow`, `mcpTool`, `bashTool`, `editTool`, `searchTool`, `todoTool`, `planTool`, `toolGroup`, plus `errorTitle` and `placeholder`. Sections are partial and merge key by key with the English defaults; `inputBarProps.labels`, `emptyState.labels` and the `labels` of an approval request win over the matching section.
inputBarProps
Omit< Partial<InputBarProps>, 'onSend' | 'status' | 'onStop' | 'value' | 'onChange' >
No
Extra props for the composer: `completions`, `leftActions`, `rightActions`, `placeholder`, `onQueue`, `queuedMessages`, `onRemoveQueued`, `labels` and the rest of `InputBarProps`. `AgentChat` owns `onSend`, `status`, `onStop`, the draft value and the question bar; `attachments` and `suggestions` take precedence over the same fields here.
statusBar
React.ReactNode
No
Content above the composer aligned with the message column, for example `AgentStatus`
withSearch
boolean
No
Adds the conversation search, opened with Mod+F anywhere inside the chat, including the composer, `false` by default
stickyPrompt
boolean
No
Pins the prompt of the answer being read to the top while scrolling a long answer
highlighter
SyntaxHighlighter
No
Syntax highlighter for code blocks in answers; plain code blocks when omitted
longMessageThreshold
LongTextThreshold | boolean
No
Collapses long user messages to head and tail; `true` uses `{ chars: 2000, lines: 30 }`, `false` by default
contentWidth
number | string
No
Max width of the message column and composer: a number in px or any CSS width, `420px` by default. Pass `'100%'` or `960` for a full-page chat
initialScrollBehavior
'bottom' | 'top'
No
Where to position the scroll container on initial mount, `'bottom'` by default
enableImagePreview
boolean
No
Opens an attached image in a fullscreen lightbox on click, `true` by default
suggestions
InputSuggestions
No
emptyStatePosition
'default' | 'center'
No
emptyState
AgentChatEmptyState
No
Greeting of an empty chat; `layout` is `welcome` by default, `center` needs `layout: 'center'` or `emptyStatePosition="center"`
emptyStateWidth
number | string
No
Width of the centered empty state and its composer: a number in px or any CSS width; `600px` with `emptyState`, the message column width otherwise
hideSuggestionsWhenNotEmpty
boolean
No
Shows composer suggestions only while the chat has no messages
alignComposer
boolean
No
Lines the composer and status bar up with the text edge of the message column, `false` by default
topFade
boolean
No
Fades the top edge of the message list once it is scrolled, `false` by default
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, `true` 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, `true` by default. The transcript already on screen at mount never animates, and `prefers-reduced-motion` turns the animation off.
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, `true` by default. A node replaces the kit row, 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, `true` by default.
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
emptySuggestionsPlacement
'input' | 'empty' | 'both'
No
emptySuggestionsPosition
'top' | 'bottom'
No
Deprecated. Suggestions are always rendered above the composer; `bottom` is treated as `top`.
questionTool
{ submitLabel?: string; skipLabel?: string; allowSkip?: boolean; onAnswer?: (payload: { toolCallId?: string; question: QuestionConfig; answer: QuestionAnswer; }) => void; }
No
className
string
No
style
React.CSSProperties
No