Skip to content

Latest commit

 

History

History
411 lines (338 loc) · 20 KB

File metadata and controls

411 lines (338 loc) · 20 KB

MessageList

Component for displaying a list of messages. Supports custom message renderers through MessageRendererRegistry.

Features

  • Automatic Rendering: Automatically renders appropriate component based on message type
  • Type Support: Supports user messages and assistant messages with various content types
  • Extensible: Supports custom message renderers through MessageRendererRegistry
  • Customizable: Supports custom className, QA attributes, and display options
  • Smart Action Filtering: Automatically excludes assistantActions for thinking messages
  • User rating: userRating on assistant messages sets filled state of like/dislike icons
  • Virtualization (opt-in): With virtualized, only the rows near the viewport are rendered via react-window, keeping the DOM size roughly constant for very large histories
  • Optimized re-renders: Each message is memoized, so during streaming only the message that actually changes re-renders

Usage

Basic Usage

import {MessageList} from '@/components/organisms';
import type {TChatMessage} from '@/types/messages';

const messages: TChatMessage[] = [
  {
    id: '1',
    role: 'user',
    timestamp: '2024-01-01T00:00:00Z',
    content: 'Hello!',
  },
  {
    id: '2',
    role: 'assistant',
    timestamp: '2024-01-01T00:01:00Z',
    content: 'Hi there!',
    userRating: 'like', // optional: 'like' | 'dislike' — affects like/dislike button icons
  },
];

<MessageList messages={messages} />;

With Custom Message Types

import {MessageList} from '@/components/organisms';
import {
  createMessageRendererRegistry,
  registerMessageRenderer,
  type MessageRendererRegistry,
  type MessageContentComponentProps,
} from '@/utils/messageTypeRegistry';
import type {TAssistantMessage, TMessageMetadata, TMessageContent} from '@/types/messages';

interface ChartMessageContentData {
  chartData: {
    labels: string[];
    datasets: Array<{
      label: string;
      data: number[];
      color?: string;
    }>;
  };
  chartType: 'line' | 'bar' | 'pie';
}

type ChartMessageContent = TMessageContent<'chart', ChartMessageContentData>;

const ChartMessageView: React.FC<MessageContentComponentProps<ChartMessageContent>> = ({part}) => {
  const {chartData, chartType} = part.data;
  return (
    <div className="chart-message">
      <Chart data={chartData} type={chartType} />
    </div>
  );
};

const customMessageRenderers: MessageRendererRegistry = createMessageRendererRegistry();
registerMessageRenderer<ChartMessageContent>(customMessageRenderers, 'chart', {
  component: ChartMessageView,
});

const chartMessage: TAssistantMessage<ChartMessageContent, TMessageMetadata> = {
  id: 'msg-chart-1',
  role: 'assistant',
  timestamp: '2024-01-01T00:00:00Z',
  content: {
    id: 'part-1',
    type: 'chart',
    data: {
      chartData: {
        labels: ['Январь', 'Февраль', 'Март', 'Апрель'],
        datasets: [
          {
            label: 'Продажи',
            data: [12, 19, 3, 5],
            color: '#0077ff',
          },
        ],
      },
      chartType: 'bar',
    },
  },
};

<MessageList<ChartMessageContent>
  messages={[chartMessage]}
  messageRendererRegistry={customMessageRenderers}
/>;

With Default Actions

import {MessageList} from '@/components/organisms';
import type {TChatMessage, TUserMessage, TAssistantMessage} from '@/types/messages';
import {BaseMessageAction} from '@/components/molecules/BaseMessage';

const userActions = [
  {
    type: BaseMessageAction.Edit,
    onClick: (message: TUserMessage) => console.log('Edit message', message.id),
  },
  {
    type: BaseMessageAction.Delete,
    onClick: (message: TUserMessage) => console.log('Delete message', message.id),
  },
];

const assistantActions = [
  {
    type: BaseMessageAction.Copy,
    onClick: (message: TAssistantMessage) => console.log('Copy message', message.id),
  },
  {
    type: BaseMessageAction.Like,
    onClick: (message: TAssistantMessage) => console.log('Like message', message.id),
  },
];

<MessageList messages={messages} userActions={userActions} assistantActions={assistantActions} />;

Note: assistantActions will NOT be displayed for messages containing only thinking content (type: 'thinking'). For messages with mixed content (thinking + text), assistantActions will be shown and the copy action will copy the entire message content. This allows:

  • ThinkingMessage copy button → copies only thinking text
  • Toolbar copy button → copies entire message (thinking + text)
// Example: assistantActions behavior based on message content
const messages = [
  {
    role: 'assistant',
    content: 'Regular text message', // assistantActions WILL be shown
  },
  {
    role: 'assistant',
    content: {
      type: 'thinking',
      data: {content: 'Thinking...', status: 'thought'}, // assistantActions will NOT be shown
    },
  },
  {
    role: 'assistant',
    content: [
      {type: 'thinking', data: {content: 'Thinking...', status: 'thought'}},
      {type: 'text', data: {text: 'Answer'}},
    ], // assistantActions WILL be shown (mixed content)
  },
];

With Dynamic Action Content (render functions)

children in DefaultMessageAction accept a render function (message) => ReactNode, enabling dynamic button content based on message data.

Use children for text or composite content (e.g. badges). Use icon for SVG icon nodes. When both are provided, children takes precedence.

With Extra Info

Use assistantExtraInfo / userExtraInfo to render contextual metadata alongside action buttons — for example, token count. These props accept a React component that receives {message} as a prop.

type AssistantWithMeta = TAssistantMessage & {metadata?: {outputTokens?: number}};

// Renders token count next to action buttons.
// metadata.outputTokens is populated by useOpenAIStreamAdapter when trackTokenUsage: true.
const TokenCount = ({message}: {message: TAssistantMessage}) => {
  const tokens = (message as AssistantWithMeta).metadata?.outputTokens;
  return tokens != null ? <Text variant="caption-2">{tokens} tokens</Text> : null;
};

<MessageList
  messages={messages}
  assistantActions={assistantActions}
  assistantExtraInfo={TokenCount}
/>;

With Action Popups

Actions can have associated popups that open when the action button is clicked. This is useful for collecting feedback, confirmations, or any additional information.

import {MessageList, FeedbackForm} from '@gravity-ui/aikit';
import {ThumbsDown, ThumbsUp} from '@gravity-ui/icons';

const assistantActions = [
  {
    type: 'like',
    icon: <ThumbsUp />,
    onClick: (message) => {
      submitRating(message.id, 'like');
    },
  },
  {
    type: 'dislike',
    icon: <ThumbsDown />,
    onClick: (message) => {
      submitRating(message.id, 'dislike');
    },
    popup: {
      title: 'What went wrong?',
      placement: 'bottom-start',
      getContent: (message, {setContent, closePopup}) => {
        const handleSubmit = (reasons: string[], comment: string) => {
          // Submit feedback
          submitFeedback(message.id, reasons, comment);

          // Update popup content to show success
          setContent(
            <div style={{padding: '16px', textAlign: 'center'}}>
              <Text>Thank you for your feedback!</Text>
            </div>,
          );

          // Auto-close after 2 seconds
          setTimeout(closePopup, 2000);
        };

        return (
          <FeedbackForm
            options={[
              {id: 'no-answer', label: 'No answer'},
              {id: 'wrong-info', label: 'Wrong information'},
              {id: 'not-helpful', label: 'Not helpful'},
              {id: 'other', label: 'Other'},
            ]}
            onSubmit={handleSubmit}
            commentPlaceholder="Tell us more..."
            submitLabel="Submit"
          />
        );
      },
    },
  },
];

<MessageList
  messages={messages}
  assistantActions={assistantActions}
  actionPopupProps={{
    placement: 'bottom-start', // Default placement for all popups
    className: 'custom-popup',
  }}
/>;

Popup Context API:

The getContent function receives a context object with the following methods:

  • setContent(newContent: React.ReactNode) — Update popup content without closing it (useful for form → success state transitions)
  • setTitle(title: string | undefined) — Update popup title dynamically (pass undefined to hide)
  • setSubtitle(subtitle: string | undefined) — Update popup subtitle dynamically (pass undefined to hide)
  • closePopup() — Programmatically close the popup

When showActionsOnHover is enabled, MessageList temporarily disables hover-hiding for the message whose action popup is open, so users can interact with the popup without losing the anchor buttons.

ActionPopup Configuration:

popup?: {
  getContent: (message, context) => React.ReactNode;  // Function that returns popup content
  title?: string;                                     // Optional popup title
  subtitle?: string;                                  // Optional popup subtitle
  placement?: PopupPlacement;                         // Popup placement (default: 'bottom-start')
}

Global Popup Configuration:

Use actionPopupProps to set global defaults for all action popups:

actionPopupProps?: {
  title?: string;           // Override title for all popups
  subtitle?: string;        // Override subtitle for all popups
  placement?: PopupPlacement;  // Override placement for all popups
  className?: string;       // Additional CSS class for all popups
  qa?: string;              // QA/test identifier for all popups
}

Virtualization for Large Histories

For chats with hundreds or thousands of messages, enable virtualized so only the rows near the viewport are mounted. Row heights are measured dynamically, the list stays pinned to the bottom while streaming (unless the user scrolls up), and scroll position is preserved when older messages are prepended via onLoadPreviousMessages.

import {MessageList} from '@gravity-ui/aikit';

// The container must have a constrained height so the windowed list can scroll.
<div style={{height: 600, display: 'flex'}}>
  <MessageList messages={messages} virtualized />
</div>;

The prop is off by default: the standard (non-virtualized) rendering path is preserved for existing usage and is preferable for short conversations.

Note: With virtualization, off-screen messages are not in the DOM, so browser find-in-page (Ctrl/Cmd+F) and screen readers only see the rendered window. Leave virtualized off if you rely on those for the full transcript.

Code-splitting Custom Message Types

Because a registered renderer is just a React component, a heavy custom type (charts, editors, maps, ...) can be loaded lazily with React.lazy, so it ships in a separate chunk and is fetched only when a message of that type is rendered. Wrap the lazy component in its own Suspense boundary inside the renderer:

import React, {Suspense, lazy} from 'react';
import {
  createMessageRendererRegistry,
  registerMessageRenderer,
  type MessageContentComponentProps,
} from '@gravity-ui/aikit';

const LazyChartView = lazy(() => import('./ChartView'));

const ChartRenderer: React.FC<MessageContentComponentProps<ChartMessageContent>> = (props) => (
  <Suspense fallback={<span>Loading chart…</span>}>
    <LazyChartView {...props} />
  </Suspense>
);

const registry = createMessageRendererRegistry();
registerMessageRenderer<ChartMessageContent>(registry, 'chart', {component: ChartRenderer});

<MessageList messages={messages} messageRendererRegistry={registry} />;

With Custom Loader Statuses

By default, the loader is displayed when status is 'submitted'. You can customize which statuses show the loader:

import {MessageList} from '@/components/organisms';

// Show loader for both 'submitted' and 'streaming' statuses
<MessageList messages={messages} status="streaming" loaderStatuses={['submitted', 'streaming']} />;

// Show loader only for 'ready' status (custom use case)
<MessageList messages={messages} status="ready" loaderStatuses={['ready']} />;

// Disable loader completely
<MessageList messages={messages} status="submitted" loaderStatuses={[]} />;

Props

Prop Type Required Default Description
messages TChatMessage[] - Array of messages to render
status ChatStatus - - Current chat status: 'submitted' | 'streaming' | 'streaming_loading' | 'ready' | 'error'
errorMessage AlertProps - - Error message to display when status is 'error'
loaderMessage string - - Loader message next to a loader
onRetry () => void - - Callback when user clicks retry button in error state
messageRendererRegistry MessageRendererRegistry - - Custom message renderer registry
transformOptions OptionsType - - Options from @diplodoc/transform package
shouldParseIncompleteMarkdown boolean - - Parse incomplete markdown (useful during streaming)
showActionsOnHover boolean - - Show message actions on hover
showTimestamp boolean - - Show message timestamp
showAvatar boolean - - Show avatar for user messages
userActions DefaultMessageAction<TUserMessage>[] - - Array of default actions for user messages. icon and children accept render functions (message) => ReactNode. Actions can include popup config.
assistantActions DefaultMessageAction<TAssistantMessage>[] - - Array of default actions for assistant messages. icon and children accept render functions (message) => ReactNode. Actions can include popup config.
userExtraInfo React.ComponentType<{message: TUserMessage}> - - Component rendered alongside action buttons for each user message. Receives the full message object.
assistantExtraInfo React.ComponentType<{message: TAssistantMessage}> - - Component rendered alongside action buttons for each assistant message. Receives the full message object (e.g. for displaying token count from metadata).
loaderStatuses ChatStatus[] - ['submitted', 'streaming_loading'] Array of chat statuses that should display the loader
ratingBlockProps RatingBlockProps - - Rating block configuration (for CSAT or other feedback use cases) - renders after messages list
actionPopupProps MessageListActionPopupConfig - - Global configuration for action popups (title, subtitle, placement, className, qa)
virtualized boolean - false Enable windowed rendering via react-window for very large histories. Requires a height-constrained container.
hasPreviousMessages boolean - false Whether there are older messages to load (shows scroll trigger with loader)
onLoadPreviousMessages () => void - - Callback to load previous messages when user scrolls to the top
className string - - Additional CSS class
qa string - - QA/test identifier

MessageListQa

Exported QA identifiers for MessageList internals:

enum MessageListQa {
  Root = 'message-list',
  Messages = 'message-list-messages',
}