diff --git a/web/packages/common/src/components/AssistantChat/AssistantChatThread.tsx b/web/packages/common/src/components/AssistantChat/AssistantChatThread.tsx index b9599c1f44..882215518b 100644 --- a/web/packages/common/src/components/AssistantChat/AssistantChatThread.tsx +++ b/web/packages/common/src/components/AssistantChat/AssistantChatThread.tsx @@ -256,35 +256,37 @@ export const AssistantChatThread = ({ return ( - - - - + + + + + + - - - - + + + Scroll to bottom - + {composerOverride ?? ( diff --git a/web/packages/common/src/components/Chat/MessageContent/index.spec.tsx b/web/packages/common/src/components/Chat/MessageContent/index.spec.tsx new file mode 100644 index 0000000000..dbc8110ba6 --- /dev/null +++ b/web/packages/common/src/components/Chat/MessageContent/index.spec.tsx @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { MessageContent } from '@nemo/common/src/components/Chat/MessageContent'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +describe('MessageContent', () => { + it('renders markdown tables with sortable data-view table components', async () => { + render( + + ); + + const table = screen.getByRole('table'); + const user = userEvent.setup(); + + expect(screen.getByTestId('data-view-content')).toBeInTheDocument(); + expect(table).toHaveClass('nv-table-root'); + expect(table).toHaveClass('min-h-0'); + expect(within(table).getByRole('columnheader', { name: 'Name' })).toBeInTheDocument(); + expect(within(table).getByRole('columnheader', { name: 'Status' })).toBeInTheDocument(); + expect(within(table).getByRole('cell', { name: 'Agent chat' })).toBeInTheDocument(); + expect(within(table).getByRole('cell', { name: 'Ready' })).toBeInTheDocument(); + expect(within(table).getByRole('cell', { name: 'Code blocks' })).toBeInTheDocument(); + expect(within(table).getByRole('cell', { name: 'Pretty' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Filter/i })).not.toBeInTheDocument(); + + expect(within(table).getAllByRole('row')[1]).toHaveTextContent('Code blocks'); + + await user.click(screen.getByRole('button', { name: /Name/i })); + + expect(within(table).getAllByRole('row')[1]).toHaveTextContent('Agent chat'); + + await user.type(screen.getByPlaceholderText('Search table'), 'Agent'); + + await waitFor(() => { + expect(within(table).getByRole('cell', { name: 'Agent chat' })).toBeInTheDocument(); + expect(within(table).queryByRole('cell', { name: 'Code blocks' })).not.toBeInTheDocument(); + }); + }); +}); diff --git a/web/packages/common/src/components/Chat/MessageContent/index.tsx b/web/packages/common/src/components/Chat/MessageContent/index.tsx index 91e5bb17d7..4d91ef80a5 100644 --- a/web/packages/common/src/components/Chat/MessageContent/index.tsx +++ b/web/packages/common/src/components/Chat/MessageContent/index.tsx @@ -1,20 +1,148 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { splitMessageWithLabels } from '@nemo/common/src/components/Chat/MessageContent/utils'; +import { CodeDisplay } from '@nemo/common/src/components/CodeDisplay'; +import * as DataView from '@nemo/common/src/components/DataView/internal'; +import { simpleHash } from '@nemo/common/src/utils/simpleHash'; import { Stack, Text } from '@nvidia/foundations-react-core'; +import { childrenToText } from '@nvidia/foundations-react-core/lib'; import { decode } from 'html-entities'; -import { type FC, type PropsWithChildren, useMemo } from 'react'; +import { + Children, + type FC, + isValidElement, + type PropsWithChildren, + type ReactElement, + type ReactNode, + useMemo, +} from 'react'; import Markdown from 'react-markdown'; - -import { splitMessageWithLabels } from './utils'; -import { simpleHash } from '../../../utils/simpleHash'; -import { CodeDisplay } from '../../CodeDisplay'; +import remarkGfm from 'remark-gfm'; export interface MessageContentProps { content?: string | null; renderAsMarkdown?: boolean; } +interface MarkdownTableColumn { + id: string; + header: ReactNode; + headerText: string; +} + +interface MarkdownTableRow { + id: string; + cells: readonly ReactNode[]; + cellValues: readonly string[]; +} + +interface ElementWithChildrenProps { + children?: ReactNode; +} + +interface MarkdownTableData { + columns: readonly MarkdownTableColumn[]; + rows: readonly MarkdownTableRow[]; +} + +const isElementWithChildren = (node: ReactNode): node is ReactElement => + isValidElement(node); + +const isElementNamed = ( + node: ReactNode, + elementName: 'thead' | 'tbody' | 'tr' | 'th' | 'td' +): node is ReactElement => + isElementWithChildren(node) && node.type === elementName; + +const getChildNodes = (node: ReactElement): ReactNode[] => + Children.toArray(node.props.children); + +const getRowCells = (row: ReactElement): readonly ReactNode[] => + getChildNodes(row) + .filter((cell) => isElementNamed(cell, 'th') || isElementNamed(cell, 'td')) + .map((cell) => cell.props.children ?? ''); + +const getSectionRows = ( + section: ReactElement | undefined +): readonly (readonly ReactNode[])[] => { + if (!section) return []; + + return getChildNodes(section) + .filter((child) => isElementNamed(child, 'tr')) + .map(getRowCells); +}; + +const getNodeText = (node: ReactNode): string => childrenToText(node).trim(); + +const parseMarkdownTable = (children: ReactNode): MarkdownTableData => { + const tableChildren = Children.toArray(children); + const head = tableChildren.find((child) => isElementNamed(child, 'thead')); + const bodies = tableChildren.filter((child) => isElementNamed(child, 'tbody')); + const headerRows = getSectionRows(head); + const bodyRows = bodies.flatMap(getSectionRows); + const headerCells = headerRows[0] ?? bodyRows[0] ?? []; + const dataRows = headerRows.length > 0 ? bodyRows : bodyRows.slice(1); + const columnCount = Math.max(headerCells.length, ...dataRows.map((row) => row.length)); + + return { + columns: Array.from({ length: columnCount }, (_, index) => ({ + id: `column-${index}`, + header: headerCells[index] ?? `Column ${index + 1}`, + headerText: getNodeText(headerCells[index] ?? `Column ${index + 1}`), + })), + rows: dataRows.map((cells, rowIndex) => ({ + id: `row-${rowIndex}`, + cells: Array.from({ length: columnCount }, (_, cellIndex) => cells[cellIndex] ?? ''), + cellValues: Array.from({ length: columnCount }, (_, cellIndex) => + getNodeText(cells[cellIndex] ?? '') + ), + })), + }; +}; + +const MarkdownDataViewTable: FC = ({ children }) => { + const { columns, rows } = useMemo(() => parseMarkdownTable(children), [children]); + const dataViewState = DataView.useDataViewState(); + const makeColumns = useMemo>( + () => (columnHelper) => + columns.map((column, columnIndex) => + columnHelper.accessor((row) => row.cellValues[columnIndex] ?? '', { + id: column.id, + header: () => column.header, + cell: ({ row }) => row.original.cells[columnIndex] ?? '', + enableResizing: false, + enableSorting: true, + }) + ), + [columns] + ); + + if (!columns.length) return null; + + return ( + + + + + + + ); +}; + /** * This component takes a content string from a chat response and converts into a user readable * list of snippets using content-specific render types. Currently supports plaintext and code. @@ -35,9 +163,11 @@ export const MessageContent: FC> = ({ > {renderAsMarkdown ? ( {props.children}, + table: ({ children }) => {children}, }} > {decode(descriptor.value)}