Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -256,35 +256,37 @@ export const AssistantChatThread = ({

return (
<ThreadPrimitive.Root className="flex h-full w-full flex-col" role="log">
<ThreadPrimitive.Viewport
{...threadViewportAttributes}
className={cn(
'relative flex min-h-0 flex-1 flex-col overflow-y-auto',
viewportClassName,
threadViewportClassName
)}
>
<Stack gap="density-md" className={cn('min-h-full w-full', contentClassName)}>
<ThreadPrimitive.Empty>
<ChatEmptyState
className="h-full min-h-[250px] w-full"
slotHeading={emptyState?.slotHeading}
slotSubheading={emptyState?.slotSubheading}
<div className="relative min-h-0 flex-1">
<ThreadPrimitive.Viewport
{...threadViewportAttributes}
className={cn(
'flex h-full min-h-0 flex-col overflow-y-auto',
viewportClassName,
threadViewportClassName
)}
>
<Stack gap="density-md" className={cn('min-h-full w-full', contentClassName)}>
<ThreadPrimitive.Empty>
<ChatEmptyState
className="h-full min-h-[250px] w-full"
slotHeading={emptyState?.slotHeading}
slotSubheading={emptyState?.slotSubheading}
/>
</ThreadPrimitive.Empty>
<ThreadPrimitive.Messages
components={{
AssistantMessage: AssistantMessageComponent,
UserMessage,
UserEditComposer,
SystemMessage: AssistantMessageComponent,
}}
/>
</ThreadPrimitive.Empty>
<ThreadPrimitive.Messages
components={{
AssistantMessage: AssistantMessageComponent,
UserMessage,
UserEditComposer,
SystemMessage: AssistantMessageComponent,
}}
/>
</Stack>
<ThreadPrimitive.ScrollToBottom className="sticky bottom-density-sm self-center rounded border border-base bg-surface-raised px-density-sm py-density-xs text-sm shadow disabled:hidden">
</Stack>
</ThreadPrimitive.Viewport>
<ThreadPrimitive.ScrollToBottom className="absolute bottom-density-sm left-1/2 z-10 -translate-x-1/2 rounded border border-base bg-surface-raised px-density-sm py-density-xs text-sm shadow disabled:hidden">
Scroll to bottom
</ThreadPrimitive.ScrollToBottom>
</ThreadPrimitive.Viewport>
</div>
<Flex className={cn('w-full', composerContainerClassName)}>
{composerOverride ?? (
<AssistantComposer disabled={disabled} placeholder={placeholder} onReset={onReset} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Comment thread
htolentino-nvidia marked this conversation as resolved.

describe('MessageContent', () => {
it('renders markdown tables with sortable data-view table components', async () => {
render(
<MessageContent
content={[
'| Name | Status |',
'| --- | --- |',
'| Code blocks | Pretty |',
'| Agent chat | Ready |',
].join('\n')}
/>
);

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();
});
});
});
140 changes: 135 additions & 5 deletions web/packages/common/src/components/Chat/MessageContent/index.tsx
Original file line number Diff line number Diff line change
@@ -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';

Comment thread
htolentino-nvidia marked this conversation as resolved.
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<ElementWithChildrenProps> =>
isValidElement<ElementWithChildrenProps>(node);

const isElementNamed = (
node: ReactNode,
elementName: 'thead' | 'tbody' | 'tr' | 'th' | 'td'
): node is ReactElement<ElementWithChildrenProps> =>
isElementWithChildren(node) && node.type === elementName;

const getChildNodes = (node: ReactElement<ElementWithChildrenProps>): ReactNode[] =>
Children.toArray(node.props.children);

const getRowCells = (row: ReactElement<ElementWithChildrenProps>): readonly ReactNode[] =>
getChildNodes(row)
.filter((cell) => isElementNamed(cell, 'th') || isElementNamed(cell, 'td'))
.map((cell) => cell.props.children ?? '');

const getSectionRows = (
section: ReactElement<ElementWithChildrenProps> | 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<PropsWithChildren> = ({ children }) => {
const { columns, rows } = useMemo(() => parseMarkdownTable(children), [children]);
const dataViewState = DataView.useDataViewState();
const makeColumns = useMemo<DataView.MakeColumns<MarkdownTableRow>>(
() => (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 (
<DataView.Root
autoCellTooltips={false}
className="my-density-md min-w-0 max-w-full overflow-hidden [&>div]:min-h-0"
data={[...rows]}
dataMode="sort-filter-only"
makeColumns={makeColumns}
state={dataViewState}
totalCount={rows.length}
>
<DataView.Toolbar>
<DataView.SearchBar debounce={0} placeholder="Search table" />
</DataView.Toolbar>
<DataView.TableContent
className="min-h-0 border-0 [&_.nv-table-head]:border-b-0 [&_.nv-table-row]:border-b-0"
density="compact"
layout="auto"
stickyTableHeader={false}
/>
</DataView.Root>
);
};

/**
* 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.
Expand All @@ -35,9 +163,11 @@ export const MessageContent: FC<PropsWithChildren<MessageContentProps>> = ({
>
{renderAsMarkdown ? (
<Markdown
remarkPlugins={[remarkGfm]}
components={{
// We don't want links embedded in markdown responses to be clickable
a: ({ ...props }) => <span>{props.children}</span>,
table: ({ children }) => <MarkdownDataViewTable>{children}</MarkdownDataViewTable>,
}}
>
{decode(descriptor.value)}
Expand Down
Loading