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 @@ -96,7 +96,10 @@ export const AssistantChatThread = ({
threadViewportClassName
)}
>
<Stack gap="density-sm" className={cn('min-h-full w-full', contentClassName)}>
<Stack
gap="density-lg"
className={cn('min-h-full w-full py-density-md', contentClassName)}
>
<ThreadPrimitive.Empty>
<ChatEmptyState
className="h-full min-h-[250px] w-full"
Expand All @@ -113,7 +116,7 @@ export const AssistantChatThread = ({
</div>
{composerMode !== ComposerMode.BROADCAST_ALL && (
<Flex
className={cn('w-full pt-density-xl', composerContainerClassName)}
className={cn('w-full pt-density-lg', composerContainerClassName)}
data-testid="assistant-chat-composer-container"
>
{composerOverride ?? (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { ActionBarPrimitive, MessagePrimitive, ThreadPrimitive } from '@assistant-ui/react';
import {
ActionBarPrimitive,
MessagePrimitive,
ThreadPrimitive,
useAuiState,
} from '@assistant-ui/react';
import { AssistantChatMessageContent } from '@nemo/common/src/components/AssistantChat/AssistantChatMessageContent';
import {
ACTION_BUTTON_CLASS,
Expand All @@ -20,45 +25,64 @@ export const AssistantMessage = ({
}: MessageRenderProps & {
hideAssistantMessageActions?: boolean;
showRunningIndicator?: boolean;
}) => (
<MessagePrimitive.Root
data-testid="assistant-chat-message"
data-testspeaker="assistant"
className="group/message self-stretch whitespace-normal"
>
<AssistantChatMessageContent
messageContentProps={messageContentProps}
toolCallPartComponent={toolCallPartComponent}
/>
{showRunningIndicator ? (
<MessagePrimitive.If last>
<ThreadPrimitive.If running>
<div
className="mt-density-xs flex h-6 items-center"
data-testid="assistant-chat-running-indicator"
>
<Skeleton className="h-density-4 w-full" data-testid="assistant-chat-skeleton" />
</div>
</ThreadPrimitive.If>
</MessagePrimitive.If>
) : null}
{!hideAssistantMessageActions ? (
<div
className="mt-density-xs flex h-8 items-center"
data-testid="assistant-chat-message-actions"
>
<ActionBarPrimitive.Root hideWhenRunning className={MESSAGE_ACTIONS_CLASS}>
<Tooltip slotContent="Regenerate response">
<ActionBarPrimitive.Reload
aria-label="Regenerate response"
className={ACTION_BUTTON_CLASS}
}) => {
const isToolOnlyMessage = useAuiState((state) => {
const { parts } = state.message;
return parts.length > 0 && parts.every((part) => part.type === 'tool-call');
});

return (
<MessagePrimitive.Root
data-testid="assistant-chat-message"
data-testspeaker="assistant"
className="group/message flex w-full flex-col items-start gap-density-xs whitespace-normal"
>
{isToolOnlyMessage ? (
<AssistantChatMessageContent
messageContentProps={messageContentProps}
toolCallPartComponent={toolCallPartComponent}
/>
) : (
<div
className="w-full max-w-full rounded-lg border border-base border-l-4 border-l-[var(--border-color-brand)] bg-surface-base px-density-lg py-density-md shadow ring-1 ring-black/5 dark:ring-white/10"
data-testid="assistant-chat-message-surface"
>
<AssistantChatMessageContent
messageContentProps={messageContentProps}
toolCallPartComponent={toolCallPartComponent}
/>
</div>
)}
{showRunningIndicator ? (
<MessagePrimitive.If last>
<ThreadPrimitive.If running>
<div
className="flex h-6 w-full items-center"
data-testid="assistant-chat-running-indicator"
>
<RefreshCw size={16} />
</ActionBarPrimitive.Reload>
</Tooltip>
<CopyAction />
</ActionBarPrimitive.Root>
</div>
) : null}
</MessagePrimitive.Root>
);
<Skeleton className="h-density-4 w-full" data-testid="assistant-chat-skeleton" />
</div>
</ThreadPrimitive.If>
</MessagePrimitive.If>
) : null}
{!hideAssistantMessageActions ? (
<div
className="flex h-7 items-center pl-density-xs"
data-testid="assistant-chat-message-actions"
>
<ActionBarPrimitive.Root hideWhenRunning className={MESSAGE_ACTIONS_CLASS}>
<Tooltip slotContent="Regenerate response">
<ActionBarPrimitive.Reload
aria-label="Regenerate response"
className={ACTION_BUTTON_CLASS}
>
<RefreshCw size={16} />
</ActionBarPrimitive.Reload>
</Tooltip>
<CopyAction />
</ActionBarPrimitive.Root>
</div>
) : null}
</MessagePrimitive.Root>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const UserEditComposer = ({
}) => (
<MessagePrimitive.Root
data-testid="assistant-chat-edit-composer"
className="w-full max-w-[80%] self-end rounded-xl rounded-br-none bg-surface-overlay px-3 py-2"
className="w-full max-w-[min(76%,44rem)] self-end rounded-lg rounded-br-sm border border-[var(--border-color-accent-teal)] bg-[var(--background-color-accent-teal-subtle)] px-density-md py-density-sm shadow ring-1 ring-black/5 dark:ring-white/10"
>
<ComposerPrimitive.Root className="flex w-full flex-col gap-density-sm">
{enableImageAttachments && <ComposerAttachmentsRow />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ export const UserMessage = ({ messageContentProps, toolCallPartComponent }: Mess
data-testspeaker="user"
className="group/message flex w-full flex-col items-end gap-density-xs whitespace-normal"
>
<div className="max-w-[80%] rounded-xl rounded-br-none bg-surface-overlay px-3 py-2">
<div className="max-w-[min(76%,44rem)] rounded-lg rounded-br-sm border border-[var(--border-color-accent-teal)] bg-[var(--background-color-accent-teal-subtle)] px-density-md py-density-sm shadow ring-1 ring-black/5 dark:ring-white/10">
<AssistantChatMessageContent
messageContentProps={messageContentProps}
toolCallPartComponent={toolCallPartComponent}
/>
</div>
<div className="flex h-8 shrink-0 items-center">
<div className="flex h-7 shrink-0 items-center pr-density-xs">
<ActionBarPrimitive.Root hideWhenRunning className={MESSAGE_ACTIONS_CLASS}>
<Tooltip slotContent="Edit message">
<ActionBarPrimitive.Edit aria-label="Edit message" className={ACTION_BUTTON_CLASS}>
Expand Down
63 changes: 52 additions & 11 deletions web/packages/common/src/components/AssistantChat/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import {
AssistantRuntimeProvider,
type ThreadMessageLike,
type ToolCallMessagePartComponent,
useExternalStoreRuntime,
} from '@assistant-ui/react';
import { AssistantChatThread } from '@nemo/common/src/components/AssistantChat/AssistantChatThread';
Expand Down Expand Up @@ -86,6 +87,17 @@ const createHangingStream = (content: string): Stream<ChatCompletionChunk> => {

const completion = createCompletion('Hello from inference gateway.');
const interactionTimeoutMs = 10_000;
const defaultStaticMessages: readonly ThreadMessageLike[] = [
{
role: 'user',
content: [{ type: 'text', text: 'Existing prompt' }],
},
{
role: 'assistant',
content: [{ type: 'text', text: 'Existing response' }],
status: { type: 'complete', reason: 'stop' },
},
];

interface CompletionRequestWithSignal {
signal?: AbortSignal;
Expand All @@ -100,20 +112,14 @@ const renderAssistantChat = (element: ReactElement) =>

const StaticAssistantChatThread = ({
hideAssistantMessageActions,
messages: initialMessages = defaultStaticMessages,
toolCallPartComponent,
}: {
hideAssistantMessageActions?: boolean;
messages?: readonly ThreadMessageLike[];
toolCallPartComponent?: ToolCallMessagePartComponent;
}) => {
const [messages, setMessages] = useState<readonly ThreadMessageLike[]>([
{
role: 'user',
content: [{ type: 'text', text: 'Existing prompt' }],
},
{
role: 'assistant',
content: [{ type: 'text', text: 'Existing response' }],
status: { type: 'complete', reason: 'stop' },
},
]);
const [messages, setMessages] = useState<readonly ThreadMessageLike[]>(initialMessages);
const runtime = useExternalStoreRuntime<ThreadMessageLike>({
messages,
setMessages,
Expand All @@ -134,6 +140,7 @@ const StaticAssistantChatThread = ({
hideAssistantMessageActions={hideAssistantMessageActions}
placeholder="Task prompt"
onReset={() => undefined}
toolCallPartComponent={toolCallPartComponent}
/>
</AssistantRuntimeProvider>
);
Expand Down Expand Up @@ -254,6 +261,40 @@ describe('AssistantChat', () => {
expect(screen.getByRole('button', { name: /Edit message/i })).toBeInTheDocument();
});

it('renders tool-only assistant messages without the assistant response surface', () => {
renderAssistantChat(
<StaticAssistantChatThread
hideAssistantMessageActions
messages={[
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'toolu_read',
toolName: 'Read',
args: { file_path: 'README.md' },
},
],
status: { type: 'complete', reason: 'stop' },
},
]}
toolCallPartComponent={({ toolName }) => (
<div data-testid="assistant-chat-tool-pill">{toolName}</div>
)}
/>
);

const assistantMessage = screen.getByTestId('assistant-chat-message');

expect(within(assistantMessage).getByTestId('assistant-chat-tool-pill')).toHaveTextContent(
'Read'
);
expect(
within(assistantMessage).queryByTestId('assistant-chat-message-surface')
).not.toBeInTheDocument();
});

it('offers an enabled add-image affordance for a vision model when enabled', () => {
renderAssistantChat(
<AssistantChat model="test-vision-model" workspace="default" enableImageAttachments />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Tooltip } from '@nvidia/foundations-react-core';
import { Check, Copy } from 'lucide-react';

export const ACTION_BUTTON_CLASS =
'flex cursor-pointer size-8 items-center justify-center rounded text-base bg-surface-raised hover:bg-surface-sunken disabled:cursor-not-allowed disabled:opacity-50';
'flex cursor-pointer size-7 items-center justify-center rounded border border-transparent text-base bg-transparent text-secondary hover:border-base hover:bg-surface-raised hover:text-primary disabled:cursor-not-allowed disabled:opacity-50';

export const MESSAGE_ACTIONS_CLASS =
'flex gap-density-xs opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100 [@media(hover:none)]:opacity-100';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ import { type FC, type PropsWithChildren } from 'react';

export const MarkdownParagraph: FC<PropsWithChildren> = ({ children }) => (
<Text asChild kind="body/regular/md">
<p className="mb-density-xl text-sm leading-[160%] last:mb-0">{children}</p>
<p className="mb-density-md text-sm leading-6 last:mb-0">{children}</p>
</Text>
);
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const renderListItemChildren = (children: ReactNode): ReactNode => {

return [
<Text asChild kind="body/regular/md" key="leading-list-paragraph">
<span className="text-sm leading-[160%]">{getChildNodes(firstContent)}</span>
<span className="text-sm leading-6">{getChildNodes(firstContent)}</span>
</Text>,
...childNodes.slice(firstContentIndex + 1),
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// SPDX-License-Identifier: Apache-2.0

import { MessageContent } from '@nemo/common/src/components/Chat/MessageContent';
import { renderListItemChildren } from '@nemo/common/src/components/Chat/MessageContent/helpers';
import { MarkdownParagraph } from '@nemo/common/src/components/Chat/MessageContent/MarkdownParagraph';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { Components } from 'react-markdown';
Expand All @@ -12,6 +14,11 @@ const expectNoDirectParagraphChild = (listItem: HTMLElement): void => {
expect(listItem.querySelector(':scope > p')).not.toBeInTheDocument();
};

const expectListParagraphDensity = (paragraph: HTMLElement): void => {
expect(paragraph).toHaveClass('text-sm', 'leading-6');
expect(paragraph).not.toHaveClass('leading-[160%]');
};

describe('MessageContent', () => {
it('renders markdown headings and list items with visible list styling', () => {
render(
Expand All @@ -24,35 +31,32 @@ describe('MessageContent', () => {

expect(screen.getByRole('heading', { level: 1, name: 'Overview' })).toHaveClass(
'mb-density-sm',
'mt-density-3xl',
'mt-density-2xl',
'first:mt-0'
);
expect(screen.getByRole('heading', { level: 2, name: 'Plan' })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 2, name: 'Plan' })).toHaveClass(
'mb-density-sm',
'mt-density-3xl'
'mt-density-xl'
);
expect(screen.getByRole('heading', { level: 3, name: 'Details' })).toHaveClass(
'mb-density-sm',
'mt-density-2xl'
);
expect(screen.getByTestId('chat-message-content-text')).toHaveClass(
'text-sm',
'leading-[160%]'
'mb-density-xs',
'mt-density-lg'
);
expect(screen.getByTestId('chat-message-content-text')).toHaveClass('text-sm', 'leading-6');
expect(screen.getByTestId('chat-message-content-text')).not.toHaveClass('text-base');
expect(screen.getByText('Read the route carefully.')).toHaveClass('mb-density-xl');
expect(screen.getByRole('list')).toHaveClass('my-density-xl', 'list-disc');
expect(screen.getByText('Read the route carefully.')).toHaveClass('mb-density-md');
expect(screen.getByRole('list')).toHaveClass('my-density-md', 'list-disc');
expect(screen.getByRole('list')).not.toHaveClass('space-y-0.5');
const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(2);
expect(listItems[0]).toHaveTextContent('Read the route');
expect(listItems[0]).toHaveClass(
'mb-density-sm',
'mb-density-xs',
'whitespace-normal',
'pl-density-xs',
'text-sm',
'leading-[160%]',
'leading-6',
'last:mb-0',
'[&>p]:my-0'
);
Expand All @@ -77,7 +81,7 @@ describe('MessageContent', () => {
expect(screen.getByTestId('chat-message-content-text')).toHaveClass('whitespace-normal');
expect(orderedList.tagName).toBe('OL');
expect(orderedList).toHaveAttribute('start', '2');
expect(orderedList).toHaveClass('my-density-xl', 'list-decimal', 'pl-density-2xl');
expect(orderedList).toHaveClass('my-density-md', 'list-decimal', 'pl-density-2xl');
expect(orderedList).not.toHaveClass('pl-density-lg');

const listItems = within(orderedList).getAllByRole('listitem');
Expand All @@ -88,6 +92,16 @@ describe('MessageContent', () => {
expect(listItems[1]).toHaveTextContent('Second item with a paragraph.');
});

it('keeps unwrapped list item paragraphs at list item density', () => {
render(
<ul>
<li>{renderListItemChildren(<MarkdownParagraph>Wrapped item</MarkdownParagraph>)}</li>
</ul>
);

expectListParagraphDensity(screen.getByText('Wrapped item'));
});

it('keeps indented loose ordered list content aligned with the number marker', () => {
render(
<MessageContent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const MessageContent: FC<PropsWithChildren<MessageContentProps>> = ({
if (descriptor.type === 'plaintext') {
return (
<div
className="whitespace-normal text-sm font-normal leading-[160%]"
className="max-w-full whitespace-normal text-sm font-normal leading-6 text-primary"
data-testid="chat-message-content-text"
key={`plaintext-${contentHash}`}
>
Expand Down
Loading