Skip to content

Commit 70ea211

Browse files
feat(ToolCalls): added component (#667)
Co-authored-by: Eric Olkowski <[email protected]>
1 parent 9680c70 commit 70ea211

File tree

9 files changed

+435
-0
lines changed

9 files changed

+435
-0
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { FunctionComponent, useState } from 'react';
2+
import Message from '@patternfly/chatbot/dist/dynamic/Message';
3+
import patternflyAvatar from './patternfly_avatar.jpg';
4+
import { Checkbox, Flex, FlexItem } from '@patternfly/react-core';
5+
6+
export const MessageWithToolCallExample: FunctionComponent = () => {
7+
const [toolCallsAreLoading, setToolCallsAreLoading] = useState(false);
8+
return (
9+
<Flex direction={{ default: 'column' }} spaceItems={{ default: 'spaceItemsXl' }}>
10+
<Checkbox
11+
label="Tool calls are loading"
12+
id="tool-calls-are-loading"
13+
isChecked={toolCallsAreLoading}
14+
onChange={() => {
15+
setToolCallsAreLoading(!toolCallsAreLoading);
16+
}}
17+
/>
18+
<FlexItem>
19+
<Message
20+
name="Bot"
21+
role="bot"
22+
avatar={patternflyAvatar}
23+
content="This example has a static tool call title:"
24+
toolCall={{
25+
titleText: "Calling 'awesome_tool'",
26+
loadingText: "Loading 'awesome_tool'",
27+
isLoading: toolCallsAreLoading
28+
}}
29+
/>
30+
<Message
31+
name="Bot"
32+
role="bot"
33+
avatar={patternflyAvatar}
34+
content="This example has an expandable tool call title, with an additional description::"
35+
toolCall={{
36+
titleText: "Calling 'awesome_tool_expansion'",
37+
expandableContent: 'This is the expandable content for the tool call.',
38+
isLoading: toolCallsAreLoading,
39+
loadingText: "Loading 'awesome_tool_expansion'"
40+
}}
41+
/>
42+
</FlexItem>
43+
</Flex>
44+
);
45+
};

packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/Messages.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,16 @@ Because this is an evolving area, this card content is currently fully customiza
199199

200200
```
201201

202+
### Messages with tool calls
203+
204+
If you are using [model context protocol (MCP)](https://www.redhat.com/en/blog/model-context-protocol-discover-missing-link-ai-integration), you can share tool call information with users as part of a message. To display a tool card card, pass `toolCalls` to `<Message>`. This card contains a title, actions for running the tool and cancelling, and optional descriptive text.
205+
206+
You can also display a loading animation until the tool call can be run. To visualize loading behavior in this example, select the "Tool calls are loading" checkbox.
207+
208+
```js file="./MessageWithToolCall.tsx"
209+
210+
```
211+
202212
### Messages with quick start tiles
203213

204214
[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.

packages/module/src/Message/Message.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { rehypeMoveImagesOutOfParagraphs } from './Plugins/rehypeMoveImagesOutOf
5252
import ToolResponse, { ToolResponseProps } from '../ToolResponse';
5353
import DeepThinking, { DeepThinkingProps } from '../DeepThinking';
5454
import SuperscriptMessage from './SuperscriptMessage/SuperscriptMessage';
55+
import ToolCall, { ToolCallProps } from '../ToolCall';
5556

5657
export interface MessageAttachment {
5758
/** Name of file attached to the message */
@@ -200,6 +201,8 @@ export interface MessageProps extends Omit<HTMLProps<HTMLDivElement>, 'role'> {
200201
deepThinking?: DeepThinkingProps;
201202
/** Allows passing additional props down to remark-gfm. See https://github.com/remarkjs/remark-gfm?tab=readme-ov-file#options for options */
202203
remarkGfmProps?: Options;
204+
/** Props for a tool call message */
205+
toolCall?: ToolCallProps;
203206
}
204207

205208
export const MessageBase: FunctionComponent<MessageProps> = ({
@@ -245,6 +248,7 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
245248
toolResponse,
246249
deepThinking,
247250
remarkGfmProps,
251+
toolCall,
248252
...props
249253
}: MessageProps) => {
250254
const [messageText, setMessageText] = useState(content);
@@ -485,6 +489,7 @@ export const MessageBase: FunctionComponent<MessageProps> = ({
485489
{afterMainContent && <>{afterMainContent}</>}
486490
{toolResponse && <ToolResponse {...toolResponse} />}
487491
{deepThinking && <DeepThinking {...deepThinking} />}
492+
{toolCall && <ToolCall {...toolCall} />}
488493
{!isLoading && sources && <SourcesCard {...sources} isCompact={isCompact} />}
489494
{quickStarts && quickStarts.quickStart && (
490495
<QuickStartTile
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
.pf-chatbot__tool-call {
2+
--pf-v6-c-card--BorderColor: var(--pf-t--global--border--color--control--read-only);
3+
--pf-v6-c-card--BorderRadius: var(--pf-t--global--border--radius--small);
4+
5+
overflow: unset;
6+
row-gap: var(--pf-t--global--spacer--sm);
7+
8+
.pf-chatbot__tool-call-title-content {
9+
display: flex;
10+
gap: var(--pf-t--global--spacer--xs);
11+
align-items: center;
12+
}
13+
14+
.pf-chatbot__tool-call-title:not(:has(.pf-chatbot__tool-call-expandable-section)) {
15+
.pf-chatbot__tool-call-title-text {
16+
color: var(--pf-t--global--text--color--regular);
17+
font-size: var(--pf-t--global--font--size--body--default);
18+
font-weight: var(--pf-t--global--font--weight--body--default);
19+
}
20+
}
21+
22+
.pf-chatbot__tool-call-title {
23+
overflow: unset;
24+
}
25+
26+
.pf-chatbot__tool-call-expandable-section {
27+
--pf-v6-c-expandable-section--Gap: var(--pf-t--global--spacer--xs);
28+
29+
.pf-v6-c-expandable-section__content {
30+
color: var(--pf-t--global--text--color--subtle);
31+
}
32+
}
33+
34+
.pf-chatbot__tool-call-action-list {
35+
justify-content: flex-end;
36+
}
37+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { render, screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import '@testing-library/jest-dom';
4+
import ToolCall from './ToolCall';
5+
6+
describe('ToolCall', () => {
7+
const defaultProps = {
8+
titleText: 'ToolCall Title',
9+
loadingText: 'Loading ToolCall'
10+
};
11+
12+
it('Renders with passed in titleText', () => {
13+
render(<ToolCall {...defaultProps} />);
14+
expect(screen.getByText(defaultProps.titleText)).toBeVisible();
15+
});
16+
17+
it('Does not render with passed in loadingText when isLoading is false', () => {
18+
render(<ToolCall {...defaultProps} />);
19+
expect(screen.queryByText(defaultProps.loadingText)).not.toBeInTheDocument();
20+
});
21+
22+
it('Renders with passed in loadingText when isLoading is true', () => {
23+
render(<ToolCall {...defaultProps} isLoading />);
24+
expect(screen.getByText(defaultProps.loadingText)).toBeVisible();
25+
});
26+
27+
it('Does not render titleText when isLoading is true', () => {
28+
render(<ToolCall {...defaultProps} isLoading />);
29+
expect(screen.queryByText(defaultProps.titleText)).not.toBeInTheDocument();
30+
});
31+
32+
it('Passes spinnerProps to Spinner', () => {
33+
render(<ToolCall {...defaultProps} isLoading spinnerProps={{ id: 'spinner-test-id' }} />);
34+
35+
expect(screen.getByRole('progressbar')).toHaveAttribute('id', 'spinner-test-id');
36+
});
37+
38+
it('Does not render expandable toggle by default', () => {
39+
render(<ToolCall {...defaultProps} />);
40+
expect(screen.queryByRole('button', { name: defaultProps.titleText })).not.toBeInTheDocument();
41+
});
42+
43+
it('Renders titleText inside expandable toggle when expandableContent is passed', () => {
44+
render(<ToolCall {...defaultProps} expandableContent="Expandable Content" />);
45+
expect(screen.getByRole('button', { name: defaultProps.titleText })).toBeVisible();
46+
});
47+
48+
it('Does not render expandable content when expandableContent is passed by default', () => {
49+
render(<ToolCall {...defaultProps} expandableContent="Expandable Content" />);
50+
expect(screen.queryByText('Expandable Content')).not.toBeVisible();
51+
});
52+
53+
it('Renders expandable content when expandableContent is passed and toggle is clicked', async () => {
54+
const user = userEvent.setup();
55+
render(<ToolCall {...defaultProps} expandableContent="Expandable Content" />);
56+
await user.click(screen.getByRole('button', { name: defaultProps.titleText }));
57+
58+
expect(screen.getByText('Expandable Content')).toBeVisible();
59+
});
60+
61+
it('Passes expandableSectionProps to ExpandableSection', () => {
62+
render(
63+
<ToolCall
64+
{...defaultProps}
65+
expandableContent="Expandable Content"
66+
expandableSectionProps={{ id: 'expandable-section-test-id', isExpanded: true }}
67+
/>
68+
);
69+
expect(screen.getByRole('region').parentElement).toHaveAttribute('id', 'expandable-section-test-id');
70+
});
71+
72+
it('Renders "run" action button by default', () => {
73+
render(<ToolCall {...defaultProps} />);
74+
expect(screen.getByRole('button', { name: 'Run tool' })).toBeVisible();
75+
});
76+
77+
it('Renders "cancel" action button by default', () => {
78+
render(<ToolCall {...defaultProps} />);
79+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeVisible();
80+
});
81+
82+
it('Does not render "run" action button when isLoading is true', () => {
83+
render(<ToolCall {...defaultProps} isLoading />);
84+
expect(screen.queryByRole('button', { name: 'Run tool' })).not.toBeInTheDocument();
85+
});
86+
87+
it('Does not render "cancel" action button when isLoading is true', () => {
88+
render(<ToolCall {...defaultProps} isLoading />);
89+
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument();
90+
});
91+
92+
it('Renders runButtonText when passed', () => {
93+
render(<ToolCall {...defaultProps} runButtonText="Run my custom tool" />);
94+
expect(screen.getByRole('button', { name: 'Run my custom tool' })).toBeVisible();
95+
});
96+
97+
it('Renders cancelButtonText when passed', () => {
98+
render(<ToolCall {...defaultProps} cancelButtonText="Cancel my custom tool" />);
99+
expect(screen.getByRole('button', { name: 'Cancel my custom tool' })).toBeVisible();
100+
});
101+
102+
it('Passes runButtonProps to Button', () => {
103+
render(<ToolCall {...defaultProps} runButtonProps={{ id: 'run-button-test-id' }} />);
104+
expect(screen.getByRole('button', { name: 'Run tool' })).toHaveAttribute('id', 'run-button-test-id');
105+
});
106+
107+
it('Passes cancelButtonProps to Button', () => {
108+
render(<ToolCall {...defaultProps} cancelButtonProps={{ id: 'cancel-button-test-id' }} />);
109+
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveAttribute('id', 'cancel-button-test-id');
110+
});
111+
112+
it('Passes runActionItemProps to ActionListItem', () => {
113+
render(<ToolCall {...defaultProps} runActionItemProps={{ id: 'run-action-item-test-id' }} />);
114+
expect(screen.getByRole('button', { name: 'Run tool' }).parentElement).toHaveAttribute(
115+
'id',
116+
'run-action-item-test-id'
117+
);
118+
});
119+
120+
it('Passes cancelActionItemProps to ActionListItem', () => {
121+
render(<ToolCall {...defaultProps} cancelActionItemProps={{ id: 'cancel-action-item-test-id' }} />);
122+
expect(screen.getByRole('button', { name: 'Cancel' }).parentElement).toHaveAttribute(
123+
'id',
124+
'cancel-action-item-test-id'
125+
);
126+
});
127+
128+
it('Passes actionListProps to ActionList', () => {
129+
render(<ToolCall {...defaultProps} actionListProps={{ id: 'action-list-test-id' }} />);
130+
expect(screen.getByRole('button', { name: 'Run tool' }).closest('#action-list-test-id')).toBeVisible();
131+
});
132+
133+
it('Passes actionListGroupProps to ActionListGroup', () => {
134+
render(<ToolCall {...defaultProps} actionListGroupProps={{ id: 'action-list-group-test-id' }} />);
135+
expect(screen.getByRole('button', { name: 'Run tool' }).closest('#action-list-group-test-id')).toBeVisible();
136+
});
137+
138+
it('Passes actionListItemProps to ActionListItem for default actions', () => {
139+
render(<ToolCall {...defaultProps} actionListItemProps={{ className: 'action-list-item-test-class' }} />);
140+
expect(screen.getByRole('button', { name: 'Run tool' }).parentElement).toHaveClass('action-list-item-test-class');
141+
expect(screen.getByRole('button', { name: 'Cancel' }).parentElement).toHaveClass('action-list-item-test-class');
142+
});
143+
144+
it('Renders custom actions instead of default actions when actions are passed', () => {
145+
render(
146+
<ToolCall
147+
{...defaultProps}
148+
actions={[<div key="custom-action-1">Custom action 1</div>, <div key="custom-action-2">Custom action 2</div>]}
149+
/>
150+
);
151+
152+
expect(screen.getByText('Custom action 1')).toBeVisible();
153+
expect(screen.getByText('Custom action 2')).toBeVisible();
154+
expect(screen.queryByRole('button', { name: 'Run tool' })).not.toBeInTheDocument();
155+
expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument();
156+
});
157+
158+
it('Passes actionListItemProps to ActionListItem for custom actions', () => {
159+
render(
160+
<ToolCall
161+
{...defaultProps}
162+
actions={[<div key="custom-action-1">Custom action 1</div>, <div key="custom-action-2">Custom action 2</div>]}
163+
actionListItemProps={{ className: 'action-list-item-test-class' }}
164+
/>
165+
);
166+
expect(screen.getByText('Custom action 1').parentElement).toHaveClass('action-list-item-test-class');
167+
expect(screen.getByText('Custom action 2').parentElement).toHaveClass('action-list-item-test-class');
168+
});
169+
170+
it('Passes cardProps to Card', () => {
171+
render(<ToolCall {...defaultProps} cardProps={{ id: 'card-test-id' }} />);
172+
expect(screen.getByRole('button', { name: 'Run tool' }).closest('#card-test-id')).toBeVisible();
173+
});
174+
175+
it('Passes cardBodyProps to CardBody', () => {
176+
render(<ToolCall {...defaultProps} cardBodyProps={{ id: 'card-body-test-id' }} />);
177+
expect(screen.getByText(defaultProps.titleText).closest('#card-body-test-id')).toBeVisible();
178+
});
179+
180+
it('Passes cardFooterProps to CardFooter', () => {
181+
render(<ToolCall {...defaultProps} cardFooterProps={{ id: 'card-footer-test-id' }} />);
182+
expect(screen.getByRole('button', { name: 'Run tool' }).closest('#card-footer-test-id')).toBeVisible();
183+
});
184+
});

0 commit comments

Comments
 (0)