diff --git a/changelogs/fragments/10759.yml b/changelogs/fragments/10759.yml new file mode 100644 index 000000000000..5954c87fcb93 --- /dev/null +++ b/changelogs/fragments/10759.yml @@ -0,0 +1,2 @@ +feat: +- [Context Provider][Tools] Add navigate to page tool ([#10759](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10759)) \ No newline at end of file diff --git a/src/plugins/chat/public/actions/navigate_action.test.tsx b/src/plugins/chat/public/actions/navigate_action.test.tsx new file mode 100644 index 000000000000..cd0bdcbe8ade --- /dev/null +++ b/src/plugins/chat/public/actions/navigate_action.test.tsx @@ -0,0 +1,617 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { useAssistantAction } from '../../../context_provider/public'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { useNavigateAction } from './navigate_action'; + +// Mock dependencies +jest.mock('../../../context_provider/public'); +jest.mock('../../../opensearch_dashboards_react/public'); + +describe('useNavigateAction', () => { + let mockUseAssistantAction: jest.MockedFunction; + let mockUseOpenSearchDashboards: jest.MockedFunction; + let registeredAction: any; + let mockNavigateToApp: jest.Mock; + let mockServices: any; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock console.error to avoid noise in test output + jest.spyOn(console, 'error').mockImplementation(() => {}); + + // Create mock navigation service + mockNavigateToApp = jest.fn(); + mockServices = { + core: { + application: { + navigateToApp: mockNavigateToApp, + }, + }, + }; + + // Mock hooks + mockUseAssistantAction = useAssistantAction as jest.MockedFunction; + mockUseOpenSearchDashboards = useOpenSearchDashboards as jest.MockedFunction< + typeof useOpenSearchDashboards + >; + + // Setup useOpenSearchDashboards mock + mockUseOpenSearchDashboards.mockReturnValue({ + services: mockServices, + }); + + // Capture the registered action + mockUseAssistantAction.mockImplementation((action) => { + registeredAction = action; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('action registration', () => { + it('should register action with correct name and description', () => { + // Component that uses the hook + const TestComponent = () => { + useNavigateAction(); + return
Test
; + }; + + render(); + + expect(mockUseAssistantAction).toHaveBeenCalledWith({ + name: 'navigate_to_page', + description: + 'Navigate the user to a different page within OpenSearch Dashboards. Use this when you need to take the user to a specific dashboard, discover page, or other application.', + parameters: expect.objectContaining({ + type: 'object', + properties: expect.objectContaining({ + appId: expect.objectContaining({ + type: 'string', + description: + 'The OpenSearch Dashboards application to navigate to (e.g., "management", "visualize", "discover", "dashboard", "explore")', + }), + path: expect.objectContaining({ + type: 'string', + description: + 'Optional path within the application (e.g., "#/saved-objects", "/create", "?query=example")', + }), + description: expect.objectContaining({ + type: 'string', + description: + 'Optional user-friendly description of the destination (e.g., "Visualization Builder", "Index Management")', + }), + }), + required: ['appId'], + }), + handler: expect.any(Function), + render: expect.any(Function), + }); + }); + + it('should have correct parameter schema structure', () => { + const TestComponent = () => { + useNavigateAction(); + return
Test
; + }; + + render(); + + const actionCall = mockUseAssistantAction.mock.calls[0][0]; + expect(actionCall.parameters.type).toBe('object'); + expect(actionCall.parameters.required).toEqual(['appId']); + expect(Object.keys(actionCall.parameters.properties)).toEqual([ + 'appId', + 'path', + 'description', + ]); + }); + }); + + describe('handler function', () => { + beforeEach(() => { + const TestComponent = () => { + useNavigateAction(); + return
Test
; + }; + render(); + }); + + describe('valid navigation scenarios', () => { + it('should handle navigation with appId only', async () => { + mockNavigateToApp.mockResolvedValueOnce(undefined); + + const result = await registeredAction.handler({ + appId: 'dashboard', + }); + + expect(mockNavigateToApp).toHaveBeenCalledWith('dashboard', undefined); + expect(result).toEqual({ + success: true, + navigated_to: 'dashboard', + path: '', + description: 'Navigated to dashboard', + timestamp: expect.any(Number), + }); + }); + + it('should handle navigation with appId and path', async () => { + mockNavigateToApp.mockResolvedValueOnce(undefined); + + const result = await registeredAction.handler({ + appId: 'management', + path: '#/saved-objects', + }); + + expect(mockNavigateToApp).toHaveBeenCalledWith('management', { path: '#/saved-objects' }); + expect(result).toEqual({ + success: true, + navigated_to: 'management', + path: '#/saved-objects', + description: 'Navigated to management (#/saved-objects)', + timestamp: expect.any(Number), + }); + }); + + it('should handle navigation with all parameters', async () => { + mockNavigateToApp.mockResolvedValueOnce(undefined); + + const result = await registeredAction.handler({ + appId: 'visualize', + path: '/create', + description: 'Visualization Builder', + }); + + expect(mockNavigateToApp).toHaveBeenCalledWith('visualize', { path: '/create' }); + expect(result).toEqual({ + success: true, + navigated_to: 'visualize', + path: '/create', + description: 'Visualization Builder', + timestamp: expect.any(Number), + }); + }); + + it('should handle empty path correctly', async () => { + mockNavigateToApp.mockResolvedValueOnce(undefined); + + const result = await registeredAction.handler({ + appId: 'discover', + path: '', + }); + + expect(mockNavigateToApp).toHaveBeenCalledWith('discover', undefined); + expect(result).toEqual({ + success: true, + navigated_to: 'discover', + path: '', + description: 'Navigated to discover', + timestamp: expect.any(Number), + }); + }); + }); + + describe('parameter validation', () => { + it('should reject missing appId', async () => { + const result = await registeredAction.handler({}); + + expect(mockNavigateToApp).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: false, + error: 'appId is required and must be a string', + attempted_app: undefined, + attempted_path: undefined, + timestamp: expect.any(Number), + }); + }); + + it('should reject null appId', async () => { + const result = await registeredAction.handler({ + appId: null, + }); + + expect(mockNavigateToApp).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: false, + error: 'appId is required and must be a string', + attempted_app: null, + attempted_path: undefined, + timestamp: expect.any(Number), + }); + }); + + it('should reject non-string appId', async () => { + const result = await registeredAction.handler({ + appId: 123, + }); + + expect(mockNavigateToApp).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: false, + error: 'appId is required and must be a string', + attempted_app: 123, + attempted_path: undefined, + timestamp: expect.any(Number), + }); + }); + + it('should reject empty string appId', async () => { + const result = await registeredAction.handler({ + appId: '', + }); + + expect(mockNavigateToApp).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: false, + error: 'appId is required and must be a string', + attempted_app: '', + attempted_path: undefined, + timestamp: expect.any(Number), + }); + }); + }); + + describe('navigation service errors', () => { + it('should handle navigation service rejection', async () => { + const navigationError = new Error('Navigation failed'); + mockNavigateToApp.mockRejectedValueOnce(navigationError); + + const result = await registeredAction.handler({ + appId: 'invalid-app', + path: '/some-path', + }); + + expect(mockNavigateToApp).toHaveBeenCalledWith('invalid-app', { path: '/some-path' }); + expect(result).toEqual({ + success: false, + error: 'Navigation failed', + attempted_app: 'invalid-app', + attempted_path: '/some-path', + timestamp: expect.any(Number), + }); + }); + + it('should handle non-Error navigation failures', async () => { + mockNavigateToApp.mockRejectedValueOnce('String error'); + + const result = await registeredAction.handler({ + appId: 'test-app', + }); + + expect(result).toEqual({ + success: false, + error: undefined, // String errors don't have .message property + attempted_app: 'test-app', + attempted_path: undefined, + timestamp: expect.any(Number), + }); + }); + }); + }); + + describe('render function', () => { + beforeEach(() => { + const TestComponent = () => { + useNavigateAction(); + return
Test
; + }; + render(); + }); + + it('should render null when no args provided', () => { + const { container } = render( +
{registeredAction.render({ status: 'complete', args: null, result: null })}
+ ); + + expect(container.firstChild?.textContent).toBe(''); + }); + + describe('executing status', () => { + it('should render executing status with basic navigation info', () => { + const args = { + appId: 'dashboard', + }; + + render(
{registeredAction.render({ status: 'executing', args, result: null })}
); + + expect(screen.getByText('Navigating...')).toBeInTheDocument(); + expect(screen.getByText('Taking you to: dashboard')).toBeInTheDocument(); + }); + + it('should render executing status with path', () => { + const args = { + appId: 'management', + path: '#/saved-objects', + }; + + render(
{registeredAction.render({ status: 'executing', args, result: null })}
); + + expect(screen.getByText('Navigating...')).toBeInTheDocument(); + expect(screen.getByText('Taking you to: management (#/saved-objects)')).toBeInTheDocument(); + }); + + it('should render executing status with description', () => { + const args = { + appId: 'visualize', + description: 'Visualization Builder', + }; + + render(
{registeredAction.render({ status: 'executing', args, result: null })}
); + + expect(screen.getByText('Taking you to: Visualization Builder')).toBeInTheDocument(); + }); + + it('should render executing status with description and path', () => { + const args = { + appId: 'explore', + path: '/create', + description: 'Data Explorer', + }; + + render(
{registeredAction.render({ status: 'executing', args, result: null })}
); + + expect(screen.getByText('Taking you to: Data Explorer (/create)')).toBeInTheDocument(); + }); + }); + + describe('complete status', () => { + it('should render success state', () => { + const args = { + appId: 'dashboard', + }; + + const result = { + success: true, + navigated_to: 'dashboard', + path: '', + description: 'Navigated to dashboard', + timestamp: Date.now(), + }; + + render(
{registeredAction.render({ status: 'complete', args, result })}
); + + expect(screen.getByText('✓ Redirecting...')).toBeInTheDocument(); + }); + + it('should render failure state with error message', () => { + const args = { + appId: 'invalid-app', + path: '/test', + }; + + const result = { + success: false, + error: 'Navigation failed', + attempted_app: 'invalid-app', + attempted_path: '/test', + timestamp: Date.now(), + }; + + render(
{registeredAction.render({ status: 'complete', args, result })}
); + + expect(screen.getByText('✗ Navigation failed: Navigation failed')).toBeInTheDocument(); + expect(screen.getByText(/Attempted URL:/)).toBeInTheDocument(); + }); + + it('should handle complete status without result', () => { + const args = { + appId: 'dashboard', + }; + + const { container } = render( +
{registeredAction.render({ status: 'complete', args, result: null })}
+ ); + + expect(container.firstChild?.textContent).toBe(''); + }); + }); + + describe('failed status', () => { + it('should render failed status with error', () => { + const args = { + appId: 'test-app', + }; + + const error = new Error('Action failed'); + + render( +
{registeredAction.render({ status: 'failed', args, result: null, error })}
+ ); + + expect(screen.getByText('✗ Navigate tool error: Action failed')).toBeInTheDocument(); + }); + + it('should handle failed status without error', () => { + const args = { + appId: 'test-app', + }; + + const { container } = render( +
{registeredAction.render({ status: 'failed', args, result: null })}
+ ); + + expect(container.firstChild?.textContent).toBe(''); + }); + }); + + describe('UI components and styling', () => { + it('should use correct panel color for executing state', () => { + const args = { appId: 'dashboard' }; + const { container } = render( +
{registeredAction.render({ status: 'executing', args, result: null })}
+ ); + + const panel = container.querySelector('[class*="euiPanel"]'); + expect(panel).toHaveClass('euiPanel--primary'); + }); + + it('should use correct panel color for success state', () => { + const args = { appId: 'dashboard' }; + const result = { + success: true, + navigated_to: 'dashboard', + path: '', + description: 'Success', + timestamp: Date.now(), + }; + + const { container } = render( +
{registeredAction.render({ status: 'complete', args, result })}
+ ); + + const panel = container.querySelector('[class*="euiPanel"]'); + expect(panel).toHaveClass('euiPanel--success'); + }); + + it('should use correct panel color for error states', () => { + const args = { appId: 'dashboard' }; + const result = { + success: false, + error: 'Failed', + attempted_app: 'dashboard', + attempted_path: undefined, + timestamp: Date.now(), + }; + + const { container } = render( +
{registeredAction.render({ status: 'complete', args, result })}
+ ); + + const panel = container.querySelector('[class*="euiPanel"]'); + expect(panel).toHaveClass('euiPanel--danger'); + }); + + it('should include symlink icon for executing and success states', () => { + const args = { appId: 'dashboard' }; + + // Test executing state + const { rerender } = render( +
{registeredAction.render({ status: 'executing', args, result: null })}
+ ); + expect(document.querySelector('[data-euiicon-type="symlink"]')).toBeInTheDocument(); + + // Test success state + const result = { + success: true, + navigated_to: 'dashboard', + path: '', + description: 'Success', + timestamp: Date.now(), + }; + rerender(
{registeredAction.render({ status: 'complete', args, result })}
); + expect(document.querySelector('[data-euiicon-type="symlink"]')).toBeInTheDocument(); + }); + + it('should include alert icon for error states', () => { + const args = { appId: 'dashboard' }; + const result = { + success: false, + error: 'Failed', + attempted_app: 'dashboard', + attempted_path: undefined, + timestamp: Date.now(), + }; + + render(
{registeredAction.render({ status: 'complete', args, result })}
); + + expect(document.querySelector('[data-euiicon-type="alert"]')).toBeInTheDocument(); + }); + }); + + describe('edge cases', () => { + it('should handle undefined path correctly', async () => { + mockNavigateToApp.mockResolvedValueOnce(undefined); + + const result = await registeredAction.handler({ + appId: 'test-app', + path: undefined, + }); + + expect(mockNavigateToApp).toHaveBeenCalledWith('test-app', undefined); + expect(result.success).toBe(true); + }); + + it('should handle special characters in appId', async () => { + mockNavigateToApp.mockResolvedValueOnce(undefined); + + const result = await registeredAction.handler({ + appId: 'test-app-with-dashes', + }); + + expect(mockNavigateToApp).toHaveBeenCalledWith('test-app-with-dashes', undefined); + expect(result.success).toBe(true); + }); + + it('should handle complex paths', async () => { + mockNavigateToApp.mockResolvedValueOnce(undefined); + + const complexPath = '#/saved-objects?type=dashboard&sortField=title&sortDir=asc'; + const result = await registeredAction.handler({ + appId: 'management', + path: complexPath, + }); + + expect(mockNavigateToApp).toHaveBeenCalledWith('management', { path: complexPath }); + expect(result.success).toBe(true); + expect(result.path).toBe(complexPath); + }); + + it('should handle very long descriptions', async () => { + mockNavigateToApp.mockResolvedValueOnce(undefined); + + const longDescription = + 'This is a very long description that might be used to describe a complex navigation scenario with many details and specific information about the destination page and its purpose'; + const result = await registeredAction.handler({ + appId: 'dashboard', + description: longDescription, + }); + + expect(result.success).toBe(true); + expect(result.description).toBe(longDescription); + }); + + it('should preserve timestamp precision', async () => { + mockNavigateToApp.mockResolvedValueOnce(undefined); + + const beforeTime = Date.now(); + const result = await registeredAction.handler({ + appId: 'test-app', + }); + const afterTime = Date.now(); + + expect(result.timestamp).toBeGreaterThanOrEqual(beforeTime); + expect(result.timestamp).toBeLessThanOrEqual(afterTime); + }); + + it('should handle render with minimal args', () => { + const args = { appId: 'minimal' }; + + render(
{registeredAction.render({ status: 'executing', args, result: null })}
); + + expect(screen.getByText('Taking you to: minimal')).toBeInTheDocument(); + }); + + it('should handle render with all optional properties', () => { + const args = { + appId: 'full', + path: '/complete/path', + description: 'Full Description', + }; + + render(
{registeredAction.render({ status: 'executing', args, result: null })}
); + + expect( + screen.getByText('Taking you to: Full Description (/complete/path)') + ).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/plugins/chat/public/actions/navigate_action.tsx b/src/plugins/chat/public/actions/navigate_action.tsx new file mode 100644 index 000000000000..b6046887f4a1 --- /dev/null +++ b/src/plugins/chat/public/actions/navigate_action.tsx @@ -0,0 +1,155 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiPanel, EuiText, EuiIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useAssistantAction } from '../../../context_provider/public'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { CoreStart } from '../../../../core/public'; + +interface NavigateArgs { + appId: string; + path?: string; + description?: string; +} + +export function useNavigateAction() { + const { services } = useOpenSearchDashboards<{ + core: CoreStart; + }>(); + + useAssistantAction({ + name: 'navigate_to_page', + description: + 'Navigate the user to a different page within OpenSearch Dashboards. Use this when you need to take the user to a specific dashboard, discover page, or other application.', + parameters: { + type: 'object', + properties: { + appId: { + type: 'string', + description: + 'The OpenSearch Dashboards application to navigate to (e.g., "management", "visualize", "discover", "dashboard", "explore")', + }, + path: { + type: 'string', + description: + 'Optional path within the application (e.g., "#/saved-objects", "/create", "?query=example")', + }, + description: { + type: 'string', + description: + 'Optional user-friendly description of the destination (e.g., "Visualization Builder", "Index Management")', + }, + }, + required: ['appId'], + }, + handler: async (args) => { + const { appId, path, description } = args; + + try { + if (!appId || typeof appId !== 'string') { + throw new Error('appId is required and must be a string'); + } + + const navigationOptions = path ? { path } : undefined; + await services.core.application.navigateToApp(appId, navigationOptions); + + return { + success: true, + navigated_to: appId, + path: path || '', + description: description || `Navigated to ${appId}${path ? ` (${path})` : ''}`, + timestamp: Date.now(), + }; + } catch (error) { + return { + success: false, + error: error.message, + attempted_app: appId, + attempted_path: path, + timestamp: Date.now(), + }; + } + }, + render: ({ status, args, result, error }) => { + if (status === 'executing' && args) { + return ( + + + + + + + +

+ Navigating... +

+

+ Taking you to: {args.description || args.appId} + {args.path ? ` (${args.path})` : ''} +

+
+
+
+
+ ); + } + + if (status === 'complete' && result) { + if (result.success) { + return ( + + + + + + + +

✓ Redirecting...

+
+
+
+
+ ); + } else { + return ( + + + + + + + +

✗ Navigation failed: {result.error}

+

Attempted URL: {result.attempted_url}

+
+
+
+
+ ); + } + } + + if (status === 'failed' && error) { + return ( + + + + + + + +

✗ Navigate tool error: {error.message}

+
+
+
+
+ ); + } + + return null; + }, + }); +} diff --git a/src/plugins/chat/public/components/chat_window.tsx b/src/plugins/chat/public/components/chat_window.tsx index 8a27c2c191ad..ad8ee7566275 100644 --- a/src/plugins/chat/public/components/chat_window.tsx +++ b/src/plugins/chat/public/components/chat_window.tsx @@ -32,6 +32,7 @@ import { ChatMessages } from './chat_messages'; import { ChatInput } from './chat_input'; import { ContextTreeView } from './context_tree_view'; import { useGraphTimeseriesDataAction } from '../actions/graph_timeseries_data_action'; +import { useNavigateAction } from '../actions/navigate_action'; interface ChatWindowProps { layoutMode?: ChatLayoutMode; @@ -76,6 +77,7 @@ function ChatWindowContent({ // Register actions useGraphTimeseriesDataAction(); + useNavigateAction(); // Context is now handled by RFC hooks - no need for context manager // The chat service will get context directly from assistantContextStore