Skip to content

Commit 8b7d289

Browse files
[chat]Add available suggestions for chat (#10863)
* Add available suggestions for chat Signed-off-by: Lin Wang <[email protected]> * Changeset file for PR #10863 created/updated * Add unit tests Signed-off-by: Lin Wang <[email protected]> --------- Signed-off-by: Lin Wang <[email protected]> Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com>
1 parent 8c90bfb commit 8b7d289

17 files changed

+1326
-12
lines changed

changelogs/fragments/10863.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
feat:
2+
- Add available suggestions for chat ([#10863](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10863))

src/plugins/chat/public/components/chat_header_button.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { GlobalAssistantProvider } from '../../../context_provider/public';
1313
import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public';
1414
import { ContextProviderStart, TextSelectionMonitor } from '../../../context_provider/public';
1515
import './chat_header_button.scss';
16+
import { SuggestedActionsService } from '../services/suggested_action';
1617

1718
export interface ChatHeaderButtonInstance {
1819
startNewConversation: ({ content }: { content: string }) => Promise<void>;
@@ -28,10 +29,11 @@ interface ChatHeaderButtonProps {
2829
chatService: ChatService;
2930
contextProvider?: ContextProviderStart;
3031
charts?: any;
32+
suggestedActionsService: SuggestedActionsService;
3133
}
3234

3335
export const ChatHeaderButton = React.forwardRef<ChatHeaderButtonInstance, ChatHeaderButtonProps>(
34-
({ core, chatService, contextProvider, charts }, ref) => {
36+
({ core, chatService, contextProvider, charts, suggestedActionsService }, ref) => {
3537
// Use ChatService as source of truth for window state
3638
const [isOpen, setIsOpen] = useState<boolean>(chatService.isWindowOpen());
3739
const [layoutMode, setLayoutMode] = useState<ChatLayoutMode>(chatService.getWindowMode());
@@ -210,7 +212,10 @@ export const ChatHeaderButton = React.forwardRef<ChatHeaderButtonInstance, ChatH
210212
// Tools updated in chat
211213
}}
212214
>
213-
<ChatProvider chatService={chatService}>
215+
<ChatProvider
216+
chatService={chatService}
217+
suggestedActionsService={suggestedActionsService}
218+
>
214219
<ChatWindow
215220
layoutMode={layoutMode}
216221
onToggleLayout={toggleLayoutMode}

src/plugins/chat/public/components/chat_messages.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ToolCallRow } from './tool_call_row';
1111
import { ErrorRow } from './error_row';
1212
import type { Message, AssistantMessage, ToolMessage, ToolCall } from '../../common/types';
1313
import './chat_messages.scss';
14+
import { ChatSuggestions } from './chat_suggestions';
1415

1516
type TimelineItem = Message;
1617

@@ -53,6 +54,10 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
5354

5455
// Context is now handled by RFC hooks - no subscriptions needed
5556

57+
// Only show suggestion on llm outputs after last user input
58+
const showSuggestions =
59+
!isStreaming && timeline.length > 0 && timeline[timeline.length - 1].role === 'assistant';
60+
5661
return (
5762
<>
5863
{/* Context Tree View: Hiding this for now. Uncomment for development */}
@@ -139,6 +144,7 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
139144

140145
return null;
141146
})}
147+
{showSuggestions && <ChatSuggestions messages={timeline} />}
142148

143149
{/* Loading indicator - waiting for agent response */}
144150
{isStreaming && timeline.length === 0 && (
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
.chat-suggestion-bubble-panel {
7+
padding: 4px;
8+
border-radius: 4px;
9+
text-align: left;
10+
11+
// Default suggestion styling
12+
&.chat-suggestion-bubble-panel--default {
13+
border: 1px solid transparent;
14+
background-color: transparent;
15+
16+
&:hover {
17+
background-color: $euiColorLightestShade;
18+
cursor: pointer;
19+
}
20+
}
21+
22+
// Custom suggestion styling with visual distinction
23+
.chat-suggestion-bubble-panel--custom {
24+
/* stylelint-disable-next-line @osd/stylelint/no_restricted_values */
25+
background-color: rgba($euiColorPrimary, 0.05);
26+
position: relative;
27+
28+
&:hover {
29+
/* stylelint-disable-next-line @osd/stylelint/no_restricted_values */
30+
background-color: rgba($euiColorPrimary, 0.1);
31+
cursor: pointer;
32+
}
33+
}
34+
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import React from 'react';
7+
import { render, screen, waitFor } from '@testing-library/react';
8+
import userEvent from '@testing-library/user-event';
9+
import { ChatSuggestions } from './chat_suggestions';
10+
import { useChatContext } from '../contexts/chat_context';
11+
import { Message } from '../../common/types';
12+
13+
// Mock the chat context hook
14+
jest.mock('../contexts/chat_context');
15+
16+
describe('ChatSuggestions', () => {
17+
let mockSuggestedActionsService: any;
18+
let mockChatService: any;
19+
let mockMessages: Message[];
20+
21+
beforeEach(() => {
22+
jest.clearAllMocks();
23+
24+
// Setup mock messages
25+
mockMessages = [
26+
{
27+
id: 'msg-1',
28+
role: 'user',
29+
content: 'Hello',
30+
},
31+
{
32+
id: 'msg-2',
33+
role: 'assistant',
34+
content: 'Hi there!',
35+
},
36+
] as Message[];
37+
38+
// Setup mock services
39+
mockChatService = {
40+
getThreadId: jest.fn().mockReturnValue('thread-123'),
41+
};
42+
43+
mockSuggestedActionsService = {
44+
getCustomSuggestions: jest.fn(),
45+
};
46+
47+
// Setup mock context
48+
(useChatContext as jest.Mock).mockReturnValue({
49+
chatService: mockChatService,
50+
suggestedActionsService: mockSuggestedActionsService,
51+
});
52+
});
53+
54+
it('should render suggestions when custom suggestions are available', async () => {
55+
const mockSuggestions = [
56+
{
57+
actionType: 'customize',
58+
message: 'Try this action',
59+
action: jest.fn(),
60+
},
61+
{
62+
actionType: 'default',
63+
message: 'Another suggestion',
64+
action: jest.fn(),
65+
},
66+
];
67+
68+
mockSuggestedActionsService.getCustomSuggestions.mockResolvedValue(mockSuggestions);
69+
70+
render(<ChatSuggestions messages={mockMessages} />);
71+
72+
// Wait for suggestions to load
73+
await waitFor(() => {
74+
expect(screen.getByText('Available suggestions')).toBeInTheDocument();
75+
});
76+
77+
// Check that both suggestions are rendered
78+
expect(screen.getByText('Try this action')).toBeInTheDocument();
79+
expect(screen.getByText('Another suggestion')).toBeInTheDocument();
80+
});
81+
82+
it('should not render anything when loading suggestions', () => {
83+
// Mock a promise that never resolves to simulate loading state
84+
mockSuggestedActionsService.getCustomSuggestions.mockReturnValue(new Promise(() => {}));
85+
86+
const { container } = render(<ChatSuggestions messages={mockMessages} />);
87+
88+
// Component should render nothing while loading
89+
expect(container.firstChild).toBeNull();
90+
});
91+
92+
it('should not render anything when no custom suggestions are available', async () => {
93+
mockSuggestedActionsService.getCustomSuggestions.mockResolvedValue([]);
94+
95+
const { container } = render(<ChatSuggestions messages={mockMessages} />);
96+
97+
// Wait for the async operation to complete
98+
await waitFor(() => {
99+
expect(mockSuggestedActionsService.getCustomSuggestions).toHaveBeenCalled();
100+
});
101+
102+
// Component should render nothing when there are no suggestions
103+
expect(container.firstChild).toBeNull();
104+
});
105+
106+
it('should handle errors gracefully when loading suggestions fails', async () => {
107+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
108+
mockSuggestedActionsService.getCustomSuggestions.mockRejectedValue(
109+
new Error('Failed to load suggestions')
110+
);
111+
112+
const { container } = render(<ChatSuggestions messages={mockMessages} />);
113+
114+
// Wait for the async operation to complete
115+
await waitFor(() => {
116+
expect(consoleErrorSpy).toHaveBeenCalledWith(
117+
'Error loading custom suggestions:',
118+
expect.any(Error)
119+
);
120+
});
121+
122+
// Component should render nothing after error
123+
expect(container.firstChild).toBeNull();
124+
125+
consoleErrorSpy.mockRestore();
126+
});
127+
128+
it('should call getCustomSuggestions with correct context', async () => {
129+
mockSuggestedActionsService.getCustomSuggestions.mockResolvedValue([]);
130+
131+
render(<ChatSuggestions messages={mockMessages} />);
132+
133+
await waitFor(() => {
134+
expect(mockSuggestedActionsService.getCustomSuggestions).toHaveBeenCalledWith({
135+
conversationId: 'thread-123',
136+
currentMessage: mockMessages[mockMessages.length - 1],
137+
messageHistory: mockMessages,
138+
});
139+
});
140+
});
141+
142+
it('should invoke action callback when suggestion is clicked', async () => {
143+
const mockAction = jest.fn().mockResolvedValue(true);
144+
const mockSuggestions = [
145+
{
146+
actionType: 'default',
147+
message: 'Click me',
148+
action: mockAction,
149+
},
150+
];
151+
152+
mockSuggestedActionsService.getCustomSuggestions.mockResolvedValue(mockSuggestions);
153+
154+
render(<ChatSuggestions messages={mockMessages} />);
155+
156+
// Wait for suggestions to load
157+
await waitFor(() => {
158+
expect(screen.getByText('Click me')).toBeInTheDocument();
159+
});
160+
161+
// Click the suggestion
162+
const suggestionBubble = screen.getByText('Click me');
163+
await userEvent.click(suggestionBubble);
164+
165+
// Verify action was called
166+
expect(mockAction).toHaveBeenCalled();
167+
});
168+
169+
it('should render custom suggestions with different styling', async () => {
170+
const mockSuggestions = [
171+
{
172+
actionType: 'customize',
173+
message: 'Custom suggestion',
174+
action: jest.fn(),
175+
},
176+
];
177+
178+
mockSuggestedActionsService.getCustomSuggestions.mockResolvedValue(mockSuggestions);
179+
180+
render(<ChatSuggestions messages={mockMessages} />);
181+
182+
await waitFor(() => {
183+
expect(screen.getByText('Custom suggestion')).toBeInTheDocument();
184+
});
185+
186+
// Check that the custom suggestion bubble has the correct data-test-subj
187+
const customBubble = screen.getByTestId('custom-suggestion-bubble');
188+
expect(customBubble).toBeInTheDocument();
189+
expect(customBubble).toHaveClass('chat-suggestion-bubble-panel--custom');
190+
});
191+
192+
it('should render default suggestions with standard styling', async () => {
193+
const mockSuggestions = [
194+
{
195+
actionType: 'default',
196+
message: 'Default suggestion',
197+
action: jest.fn(),
198+
},
199+
];
200+
201+
mockSuggestedActionsService.getCustomSuggestions.mockResolvedValue(mockSuggestions);
202+
203+
render(<ChatSuggestions messages={mockMessages} />);
204+
205+
await waitFor(() => {
206+
expect(screen.getByText('Default suggestion')).toBeInTheDocument();
207+
});
208+
209+
// Check that the default suggestion bubble has the correct data-test-subj
210+
const defaultBubble = screen.getByTestId('default-suggestion-bubble');
211+
expect(defaultBubble).toBeInTheDocument();
212+
expect(defaultBubble).toHaveClass('chat-suggestion-bubble-panel--default');
213+
});
214+
});

0 commit comments

Comments
 (0)