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
@@ -0,0 +1,135 @@
import { useState, FunctionComponent, MouseEvent as ReactMouseEvent } from 'react';
import Message from '@patternfly/chatbot/dist/dynamic/Message';
import patternflyAvatar from './patternfly_avatar.jpg';
import { CopyIcon, WrenchIcon } from '@patternfly/react-icons';
import {
Button,
DescriptionList,
DescriptionListDescription,
DescriptionListGroup,
DescriptionListTerm,
ExpandableSection,
ExpandableSectionVariant,
Flex,
FlexItem,
Label
} from '@patternfly/react-core';

export const MessageWithToolResponseExample: FunctionComponent = () => {
const [isExpanded, setIsExpanded] = useState(false);

const onToggle = (_event: ReactMouseEvent, isExpanded: boolean) => {
setIsExpanded(isExpanded);
};

return (
<Message
name="Bot"
role="bot"
avatar={patternflyAvatar}
content="This example has a body description that's within the recommended limit of 2 lines:"
toolResponse={{
toggleContent: 'Tool response: toolName',
subheading: 'Thought for 3 seconds',
body: "Here's the summary for your toolName response:",
cardTitle: (
<Flex alignItems={{ default: 'alignItemsCenter' }} justifyContent={{ default: 'justifyContentSpaceBetween' }}>
<FlexItem>
<Flex direction={{ default: 'column' }} gap={{ default: 'gapXs' }}>
<FlexItem grow={{ default: 'grow' }}>
<Flex gap={{ default: 'gapXs' }}>
<FlexItem>
<WrenchIcon style={{ color: 'var(--pf-t--global--icon--color--brand--default' }} />
</FlexItem>
<FlexItem>toolName</FlexItem>
</Flex>
</FlexItem>
<FlexItem>
<Flex gap={{ default: 'gapSm' }} style={{ fontSize: '12px', fontWeight: '400' }}>
<FlexItem>Execution time:</FlexItem>
<FlexItem>0.12 seconds</FlexItem>
</Flex>
</FlexItem>
</Flex>
</FlexItem>
<FlexItem>
<Button
variant="plain"
aria-label="Copy tool response to clipboard"
icon={<CopyIcon style={{ color: 'var(--pf-t--global--icon--color--subtle)' }} />}
></Button>
</FlexItem>
</Flex>
),
cardBody: (
<>
<DescriptionList
style={{ '--pf-v6-c-description-list--RowGap': 'var(--pf-t--global--spacer--md)' } as any}

Check warning on line 67 in packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithToolResponse.tsx

View workflow job for this annotation

GitHub Actions / call-build-lint-test-workflow / lint

Unexpected any. Specify a different type
aria-label="Tool response"
>
<DescriptionListGroup
style={{ '--pf-v6-c-description-list__group--RowGap': 'var(--pf-t--global--spacer--xs)' } as any}

Check warning on line 71 in packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithToolResponse.tsx

View workflow job for this annotation

GitHub Actions / call-build-lint-test-workflow / lint

Unexpected any. Specify a different type
>
<DescriptionListTerm>Parameters</DescriptionListTerm>
<DescriptionListDescription>
<Flex direction={{ default: 'column' }}>
<FlexItem>Optional description text for parameters.</FlexItem>
<FlexItem>
<Flex gap={{ default: 'gapSm' }}>
<FlexItem>
<Label variant="outline" color="blue">
type
</Label>
</FlexItem>
<FlexItem>
<Label variant="outline" color="blue">
properties
</Label>
</FlexItem>
<FlexItem>
<Label variant="outline" color="blue">
label
</Label>
</FlexItem>
<FlexItem>
<Label variant="outline" color="blue">
label
</Label>
</FlexItem>
</Flex>
</FlexItem>
</Flex>
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup
style={{ '--pf-v6-c-description-list__group--RowGap': 'var(--pf-t--global--spacer--xs)' } as any}

Check warning on line 105 in packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithToolResponse.tsx

View workflow job for this annotation

GitHub Actions / call-build-lint-test-workflow / lint

Unexpected any. Specify a different type
>
<DescriptionListTerm>Response</DescriptionListTerm>
<DescriptionListDescription>
<ExpandableSection
variant={ExpandableSectionVariant.truncate}
toggleTextExpanded="show less of response"
toggleTextCollapsed="show more of response"
onToggle={onToggle}
isExpanded={isExpanded}
style={
{
'--pf-v6-c-expandable-section__content--Opacity': '1',
'--pf-v6-c-expandable-section__content--PaddingInlineStart': 0,
'--pf-v6-c-expandable-section__content--TranslateY': 0,
'--pf-v6-c-expandable-section--m-expand-top__content--TranslateY': 0

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

} as any

Check warning on line 121 in packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/MessageWithToolResponse.tsx

View workflow job for this annotation

GitHub Actions / call-build-lint-test-workflow / lint

Unexpected any. Specify a different type
}
>
Descriptive text about the tool response, including completion status, details on the data that was
processed, or anything else relevant to the use case.
</ExpandableSection>
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
</>
)
}}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import Message from '@patternfly/chatbot/dist/dynamic/Message';
import MessageDivider from '@patternfly/chatbot/dist/dynamic/MessageDivider';
import { rehypeCodeBlockToggle } from '@patternfly/chatbot/dist/esm/Message/Plugins/rehypeCodeBlockToggle';
import SourcesCard from '@patternfly/chatbot/dist/dynamic/SourcesCard';
import { ArrowCircleDownIcon, ArrowRightIcon, CheckCircleIcon, CubeIcon, CubesIcon, DownloadIcon, InfoCircleIcon, OutlinedQuestionCircleIcon, RedoIcon, RobotIcon } from '@patternfly/react-icons';
import { ArrowCircleDownIcon, ArrowRightIcon, CheckCircleIcon, CopyIcon, CubeIcon, CubesIcon, DownloadIcon, InfoCircleIcon, OutlinedQuestionCircleIcon, RedoIcon, RobotIcon, WrenchIcon } from '@patternfly/react-icons';
import patternflyAvatar from './patternfly_avatar.jpg';
import AttachmentEdit from '@patternfly/chatbot/dist/dynamic/AttachmentEdit';
import FileDetails from '@patternfly/chatbot/dist/dynamic/FileDetails';
Expand Down Expand Up @@ -178,6 +178,14 @@ The API for a source requires a link at minimum, but we strongly recommend provi

```

### Messages with tool responses

If you are using [model context protocol (MCP)](https://www.redhat.com/en/blog/model-context-protocol-discover-missing-link-ai-integration), you may find it useful to display information on tool responses as part of a message. Passing `toolResponse` to `<Message>` allows you to display a card with an optional subheading and body, as well as custom card content. Content is intentionally left fully customizable for now as this is an evolving area.

```js file="./MessageWithToolResponse.tsx"

```

### Messages with quick start tiles

[Quick start](/extensions/quick-starts/) tiles can be added to messages via the `quickStarts` prop. Users can initiate the quick start from a link within the message tile.
Expand Down
22 changes: 22 additions & 0 deletions packages/module/src/Message/Message.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,28 @@ describe('Message', () => {
);
expect(screen.getAllByRole('img')[1]).toHaveAttribute('src', 'test.png');
});
it('should handle tool response correctly', async () => {
render(
<Message
avatar="./img"
role="user"
name="User"
content="Hi"
toolResponse={{
toggleContent: 'Tool response: Name',
subheading: 'Thought for 3 seconds',
body: 'Lorem ipsum dolor sit amet',
cardTitle: 'Card title',
cardBody: 'Card body'
}}
/>
);
expect(screen.getByRole('button', { name: /Tool response: Name/i })).toBeTruthy();
expect(screen.getByText('Thought for 3 seconds')).toBeTruthy();
expect(screen.getByText('Lorem ipsum dolor sit amet')).toBeTruthy();
expect(screen.getByText('Card title')).toBeTruthy();
expect(screen.getByText('Card body')).toBeTruthy();
});
it('should handle block quote correctly', () => {
render(<Message avatar="./img" role="user" name="User" content={BLOCK_QUOTES} />);
expect(screen.getByText(/Blockquotes can also be nested.../)).toBeTruthy();
Expand Down
5 changes: 5 additions & 0 deletions packages/module/src/Message/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import LinkMessage from './LinkMessage/LinkMessage';
import ErrorMessage from './ErrorMessage/ErrorMessage';
import MessageInput from './MessageInput';
import { rehypeMoveImagesOutOfParagraphs } from './Plugins/rehypeMoveImagesOutOfParagraphs';
import ToolResponse, { ToolResponseProps } from '../ToolResponse';

export interface MessageAttachment {
/** Name of file attached to the message */
Expand Down Expand Up @@ -189,6 +190,8 @@ export interface MessageProps extends Omit<HTMLProps<HTMLDivElement>, 'role'> {
isMarkdownDisabled?: boolean;
/** Allows passing additional props down to markdown parser react-markdown, such as allowedElements and disallowedElements. See https://github.com/remarkjs/react-markdown?tab=readme-ov-file#options for options */
reactMarkdownProps?: Options;
/** Props for tool response card */
toolResponse?: ToolResponseProps;
}

export const MessageBase: FunctionComponent<MessageProps> = ({
Expand Down Expand Up @@ -230,6 +233,7 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
isCompact,
isMarkdownDisabled,
reactMarkdownProps,
toolResponse,
...props
}: MessageProps) => {
const [messageText, setMessageText] = useState(content);
Expand Down Expand Up @@ -376,6 +380,7 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
<div className="pf-chatbot__message-and-actions">
{renderMessage()}
{afterMainContent && <>{afterMainContent}</>}
{toolResponse && <ToolResponse {...toolResponse} />}
{!isLoading && sources && <SourcesCard {...sources} isCompact={isCompact} />}
{quickStarts && quickStarts.quickStart && (
<QuickStartTile
Expand Down
36 changes: 36 additions & 0 deletions packages/module/src/ToolResponse/ToolResponse.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
.pf-chatbot__tool-response {
--pf-v6-c-card--BorderColor: var(--pf-t--global--border--color--control--read-only);
overflow: unset;
}

.pf-chatbot__tool-response-expandable-section {
--pf-v6-c-expandable-section--Gap: var(--pf-t--global--spacer--xs);
}

.pf-chatbot__tool-response-section {
display: flex;
flex-direction: column;
gap: var(--pf-t--global--spacer--xs);
}

.pf-chatbot__tool-response-subheading {
font-size: var(--pf-t--global--font--size--body--sm);
font-weight: var(--pf-t--global--font--weight--body--default);
color: var(--pf-t--global--text--color--subtle);
}

.pf-chatbot__tool-response-body {
color: var(--pf-t--global--text--color--subtle);
margin-block-end: var(--pf-t--global--spacer--xs);
}

.pf-chatbot__tool-response-card {
--pf-v6-c-card--BorderColor: var(--pf-t--global--border--color--control--read-only);
--pf-v6-c-card--first-child--PaddingBlockStart: var(--pf-t--global--spacer--sm);
--pf-v6-c-card__title--not--last-child--PaddingBlockEnd: var(--pf-t--global--spacer--sm);
--pf-v6-c-card--c-divider--child--PaddingBlockStart: var(--pf-t--global--spacer--sm);

.pf-v6-c-divider {
--pf-v6-c-divider--Color: var(--pf-t--global--border--color--control--read-only);
}
}
78 changes: 78 additions & 0 deletions packages/module/src/ToolResponse/ToolResponse.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import ToolResponse from './ToolResponse';

describe('ToolResponse', () => {
const defaultProps = {
toggleContent: 'Tool response: toolName',
cardTitle: 'Title',
cardBody: 'Body'
};

it('should render with required props only', () => {
render(<ToolResponse {...defaultProps} />);
expect(screen.getByText('Title')).toBeTruthy();
expect(screen.getByText('Body')).toBeTruthy();
expect(screen.getByText('Tool response: toolName')).toBeTruthy();
});

it('should render subheading when provided', () => {
const subheading = 'Tool execution result';
render(<ToolResponse {...defaultProps} subheading={subheading} />);
expect(screen.getByText(subheading)).toBeTruthy();
});

it('should render body content when provided', () => {
const body = 'This is the tool response body content';
render(<ToolResponse {...defaultProps} body={body} />);
expect(screen.getByText(body)).toBeTruthy();
});

it('should render with complex content including React elements', () => {
const body = (
<div>
<p>Complex body content</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
</div>
);
const cardTitle = <strong>API Response</strong>;
const cardBody = (
<div>
<code>{"{ status: 'success' }"}</code>
</div>
);

render(<ToolResponse {...defaultProps} body={body} cardTitle={cardTitle} cardBody={cardBody} />);
expect(screen.getByText('Complex body content')).toBeTruthy();
expect(screen.getByText('Item 1')).toBeTruthy();
expect(screen.getByText('Item 2')).toBeTruthy();
expect(screen.getByText('API Response')).toBeTruthy();
expect(screen.getByText("{ status: 'success' }")).toBeTruthy();
});

it('should apply custom className from cardProps', () => {
const { container } = render(
<ToolResponse {...defaultProps} cardProps={{ className: 'custom-tool-response-class' }} />
);
expect(container.querySelector('.custom-tool-response-class')).toBeTruthy();
});

it('should pass through expandableSectionProps', () => {
render(<ToolResponse {...defaultProps} expandableSectionProps={{ className: 'custom-expandable-class' }} />);
expect(document.querySelector('.custom-expandable-class')).toBeTruthy();
});

it('should pass through toolResponseCardProps', () => {
render(<ToolResponse {...defaultProps} toolResponseCardProps={{ className: 'custom-card-class' }} />);
expect(document.querySelector('.custom-card-class')).toBeTruthy();
});

it('should not render subheading span when subheading is not provided', () => {
const { container } = render(<ToolResponse {...defaultProps} />);
const subheadingContainer = container.querySelector('.pf-chatbot__tool-response-subheading');
expect(subheadingContainer).toBeFalsy();
});
});
Loading
Loading